diff options
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | cmd/deploy/deploy.go | 494 | ||||
| -rw-r--r-- | cmd/deploy/env.go | 176 | ||||
| -rw-r--r-- | cmd/deploy/init.go | 154 | ||||
| -rw-r--r-- | cmd/deploy/list.go | 58 | ||||
| -rw-r--r-- | cmd/deploy/main.go | 82 | ||||
| -rw-r--r-- | cmd/deploy/manage.go | 327 | ||||
| -rw-r--r-- | cmd/deploy/templates/webui.html | 440 | ||||
| -rw-r--r-- | cmd/deploy/webui.go | 228 |
9 files changed, 1960 insertions, 1 deletions
| @@ -1,5 +1,5 @@ | |||
| 1 | # Binaries | 1 | # Binaries |
| 2 | deploy | 2 | /deploy |
| 3 | *.exe | 3 | *.exe |
| 4 | *.dll | 4 | *.dll |
| 5 | *.so | 5 | *.so |
diff --git a/cmd/deploy/deploy.go b/cmd/deploy/deploy.go new file mode 100644 index 0000000..2b3ab4a --- /dev/null +++ b/cmd/deploy/deploy.go | |||
| @@ -0,0 +1,494 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bufio" | ||
| 5 | "flag" | ||
| 6 | "fmt" | ||
| 7 | "os" | ||
| 8 | "path/filepath" | ||
| 9 | "strconv" | ||
| 10 | "strings" | ||
| 11 | |||
| 12 | "github.com/bdw/deploy/internal/config" | ||
| 13 | "github.com/bdw/deploy/internal/ssh" | ||
| 14 | "github.com/bdw/deploy/internal/state" | ||
| 15 | "github.com/bdw/deploy/internal/templates" | ||
| 16 | ) | ||
| 17 | |||
| 18 | type envFlags []string | ||
| 19 | |||
| 20 | func (e *envFlags) String() string { | ||
| 21 | return strings.Join(*e, ",") | ||
| 22 | } | ||
| 23 | |||
| 24 | func (e *envFlags) Set(value string) error { | ||
| 25 | *e = append(*e, value) | ||
| 26 | return nil | ||
| 27 | } | ||
| 28 | |||
| 29 | type fileFlags []string | ||
| 30 | |||
| 31 | func (f *fileFlags) String() string { | ||
| 32 | return strings.Join(*f, ",") | ||
| 33 | } | ||
| 34 | |||
| 35 | func (f *fileFlags) Set(value string) error { | ||
| 36 | *f = append(*f, value) | ||
| 37 | return nil | ||
| 38 | } | ||
| 39 | |||
| 40 | func runDeploy(args []string) { | ||
| 41 | fs := flag.NewFlagSet("deploy", flag.ExitOnError) | ||
| 42 | host := fs.String("host", "", "VPS host (SSH config alias or user@host)") | ||
| 43 | domain := fs.String("domain", "", "Domain name (required)") | ||
| 44 | name := fs.String("name", "", "App name (default: inferred from binary or directory)") | ||
| 45 | binary := fs.String("binary", "", "Path to Go binary (for app deployment)") | ||
| 46 | static := fs.Bool("static", false, "Deploy as static site") | ||
| 47 | dir := fs.String("dir", ".", "Directory to deploy (for static sites)") | ||
| 48 | port := fs.Int("port", 0, "Port override (default: auto-allocate)") | ||
| 49 | var envVars envFlags | ||
| 50 | fs.Var(&envVars, "env", "Environment variable (KEY=VALUE, can be specified multiple times)") | ||
| 51 | envFile := fs.String("env-file", "", "Path to .env file") | ||
| 52 | binaryArgs := fs.String("args", "", "Arguments to pass to binary") | ||
| 53 | var files fileFlags | ||
| 54 | fs.Var(&files, "file", "Config file to upload to working directory (can be specified multiple times)") | ||
| 55 | |||
| 56 | fs.Parse(args) | ||
| 57 | |||
| 58 | // Get host from flag or config | ||
| 59 | if *host == "" { | ||
| 60 | cfg, err := config.Load() | ||
| 61 | if err != nil { | ||
| 62 | fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err) | ||
| 63 | os.Exit(1) | ||
| 64 | } | ||
| 65 | *host = cfg.Host | ||
| 66 | } | ||
| 67 | |||
| 68 | if *host == "" || *domain == "" { | ||
| 69 | fmt.Fprintf(os.Stderr, "Error: --host and --domain are required\n") | ||
| 70 | fs.Usage() | ||
| 71 | os.Exit(1) | ||
| 72 | } | ||
| 73 | |||
| 74 | if *static { | ||
| 75 | deployStatic(*host, *domain, *name, *dir) | ||
| 76 | } else { | ||
| 77 | deployApp(*host, *domain, *name, *binary, *port, envVars, *envFile, *binaryArgs, files) | ||
| 78 | } | ||
| 79 | } | ||
| 80 | |||
| 81 | func deployApp(host, domain, name, binaryPath string, portOverride int, envVars []string, envFile, args string, files []string) { | ||
| 82 | // Determine app name | ||
| 83 | if name == "" { | ||
| 84 | if binaryPath != "" { | ||
| 85 | name = filepath.Base(binaryPath) | ||
| 86 | } else { | ||
| 87 | // Try to find a binary in current directory | ||
| 88 | cwd, _ := os.Getwd() | ||
| 89 | name = filepath.Base(cwd) | ||
| 90 | } | ||
| 91 | } | ||
| 92 | |||
| 93 | // Find binary if not specified | ||
| 94 | if binaryPath == "" { | ||
| 95 | // Look for binary with same name as directory | ||
| 96 | if _, err := os.Stat(name); err == nil { | ||
| 97 | binaryPath = name | ||
| 98 | } else { | ||
| 99 | fmt.Fprintf(os.Stderr, "Error: --binary is required (could not find binary in current directory)\n") | ||
| 100 | os.Exit(1) | ||
| 101 | } | ||
| 102 | } | ||
| 103 | |||
| 104 | // Verify binary exists | ||
| 105 | if _, err := os.Stat(binaryPath); err != nil { | ||
| 106 | fmt.Fprintf(os.Stderr, "Error: binary not found: %s\n", binaryPath) | ||
| 107 | os.Exit(1) | ||
| 108 | } | ||
| 109 | |||
| 110 | fmt.Printf("Deploying app: %s\n", name) | ||
| 111 | fmt.Printf(" Domain: %s\n", domain) | ||
| 112 | fmt.Printf(" Binary: %s\n", binaryPath) | ||
| 113 | |||
| 114 | // Load state | ||
| 115 | st, err := state.Load() | ||
| 116 | if err != nil { | ||
| 117 | fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) | ||
| 118 | os.Exit(1) | ||
| 119 | } | ||
| 120 | |||
| 121 | // Check if app already exists (update) or new deployment | ||
| 122 | existingApp, _ := st.GetApp(host, name) | ||
| 123 | var port int | ||
| 124 | if existingApp != nil { | ||
| 125 | port = existingApp.Port | ||
| 126 | fmt.Printf(" Updating existing deployment (port %d)\n", port) | ||
| 127 | } else { | ||
| 128 | if portOverride > 0 { | ||
| 129 | port = portOverride | ||
| 130 | } else { | ||
| 131 | port = st.AllocatePort(host) | ||
| 132 | } | ||
| 133 | fmt.Printf(" Allocated port: %d\n", port) | ||
| 134 | } | ||
| 135 | |||
| 136 | // Parse environment variables | ||
| 137 | env := make(map[string]string) | ||
| 138 | if existingApp != nil { | ||
| 139 | // Preserve existing env vars | ||
| 140 | for k, v := range existingApp.Env { | ||
| 141 | env[k] = v | ||
| 142 | } | ||
| 143 | // Preserve existing args if not provided | ||
| 144 | if args == "" && existingApp.Args != "" { | ||
| 145 | args = existingApp.Args | ||
| 146 | } | ||
| 147 | // Preserve existing files if not provided | ||
| 148 | if len(files) == 0 && len(existingApp.Files) > 0 { | ||
| 149 | files = existingApp.Files | ||
| 150 | } | ||
| 151 | } | ||
| 152 | |||
| 153 | // Add/override from flags | ||
| 154 | for _, e := range envVars { | ||
| 155 | parts := strings.SplitN(e, "=", 2) | ||
| 156 | if len(parts) == 2 { | ||
| 157 | env[parts[0]] = parts[1] | ||
| 158 | } | ||
| 159 | } | ||
| 160 | |||
| 161 | // Add/override from file | ||
| 162 | if envFile != "" { | ||
| 163 | fileEnv, err := parseEnvFile(envFile) | ||
| 164 | if err != nil { | ||
| 165 | fmt.Fprintf(os.Stderr, "Error reading env file: %v\n", err) | ||
| 166 | os.Exit(1) | ||
| 167 | } | ||
| 168 | for k, v := range fileEnv { | ||
| 169 | env[k] = v | ||
| 170 | } | ||
| 171 | } | ||
| 172 | |||
| 173 | // Always set PORT | ||
| 174 | env["PORT"] = strconv.Itoa(port) | ||
| 175 | |||
| 176 | // Connect to VPS | ||
| 177 | client, err := ssh.Connect(host) | ||
| 178 | if err != nil { | ||
| 179 | fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err) | ||
| 180 | os.Exit(1) | ||
| 181 | } | ||
| 182 | defer client.Close() | ||
| 183 | |||
| 184 | // Upload binary | ||
| 185 | fmt.Println("→ Uploading binary...") | ||
| 186 | remoteTmpPath := fmt.Sprintf("/tmp/%s", name) | ||
| 187 | if err := client.Upload(binaryPath, remoteTmpPath); err != nil { | ||
| 188 | fmt.Fprintf(os.Stderr, "Error uploading binary: %v\n", err) | ||
| 189 | os.Exit(1) | ||
| 190 | } | ||
| 191 | |||
| 192 | // Create user (ignore error if already exists) | ||
| 193 | fmt.Println("→ Creating system user...") | ||
| 194 | client.RunSudo(fmt.Sprintf("useradd -r -s /bin/false %s", name)) | ||
| 195 | |||
| 196 | // Create working directory | ||
| 197 | fmt.Println("→ Setting up directories...") | ||
| 198 | workDir := fmt.Sprintf("/var/lib/%s", name) | ||
| 199 | if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", workDir)); err != nil { | ||
| 200 | fmt.Fprintf(os.Stderr, "Error creating work directory: %v\n", err) | ||
| 201 | os.Exit(1) | ||
| 202 | } | ||
| 203 | if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", name, name, workDir)); err != nil { | ||
| 204 | fmt.Fprintf(os.Stderr, "Error setting work directory ownership: %v\n", err) | ||
| 205 | os.Exit(1) | ||
| 206 | } | ||
| 207 | |||
| 208 | // Move binary to /usr/local/bin | ||
| 209 | fmt.Println("→ Installing binary...") | ||
| 210 | binaryDest := fmt.Sprintf("/usr/local/bin/%s", name) | ||
| 211 | if _, err := client.RunSudo(fmt.Sprintf("mv %s %s", remoteTmpPath, binaryDest)); err != nil { | ||
| 212 | fmt.Fprintf(os.Stderr, "Error moving binary: %v\n", err) | ||
| 213 | os.Exit(1) | ||
| 214 | } | ||
| 215 | if _, err := client.RunSudo(fmt.Sprintf("chmod +x %s", binaryDest)); err != nil { | ||
| 216 | fmt.Fprintf(os.Stderr, "Error making binary executable: %v\n", err) | ||
| 217 | os.Exit(1) | ||
| 218 | } | ||
| 219 | |||
| 220 | // Upload config files to working directory | ||
| 221 | if len(files) > 0 { | ||
| 222 | fmt.Println("→ Uploading config files...") | ||
| 223 | for _, file := range files { | ||
| 224 | // Verify file exists locally | ||
| 225 | if _, err := os.Stat(file); err != nil { | ||
| 226 | fmt.Fprintf(os.Stderr, "Error: config file not found: %s\n", file) | ||
| 227 | os.Exit(1) | ||
| 228 | } | ||
| 229 | |||
| 230 | // Determine remote path (preserve filename) | ||
| 231 | remotePath := fmt.Sprintf("%s/%s", workDir, filepath.Base(file)) | ||
| 232 | remoteTmpPath := fmt.Sprintf("/tmp/%s_%s", name, filepath.Base(file)) | ||
| 233 | |||
| 234 | // Upload to tmp first | ||
| 235 | if err := client.Upload(file, remoteTmpPath); err != nil { | ||
| 236 | fmt.Fprintf(os.Stderr, "Error uploading config file %s: %v\n", file, err) | ||
| 237 | os.Exit(1) | ||
| 238 | } | ||
| 239 | |||
| 240 | // Move to working directory | ||
| 241 | if _, err := client.RunSudo(fmt.Sprintf("mv %s %s", remoteTmpPath, remotePath)); err != nil { | ||
| 242 | fmt.Fprintf(os.Stderr, "Error moving config file %s: %v\n", file, err) | ||
| 243 | os.Exit(1) | ||
| 244 | } | ||
| 245 | |||
| 246 | // Set ownership | ||
| 247 | if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", name, name, remotePath)); err != nil { | ||
| 248 | fmt.Fprintf(os.Stderr, "Error setting config file ownership %s: %v\n", file, err) | ||
| 249 | os.Exit(1) | ||
| 250 | } | ||
| 251 | |||
| 252 | fmt.Printf(" Uploaded: %s\n", file) | ||
| 253 | } | ||
| 254 | } | ||
| 255 | |||
| 256 | // Create env file | ||
| 257 | fmt.Println("→ Creating environment file...") | ||
| 258 | envFilePath := fmt.Sprintf("/etc/deploy/env/%s.env", name) | ||
| 259 | envContent := "" | ||
| 260 | for k, v := range env { | ||
| 261 | envContent += fmt.Sprintf("%s=%s\n", k, v) | ||
| 262 | } | ||
| 263 | if err := client.WriteSudoFile(envFilePath, envContent); err != nil { | ||
| 264 | fmt.Fprintf(os.Stderr, "Error creating env file: %v\n", err) | ||
| 265 | os.Exit(1) | ||
| 266 | } | ||
| 267 | if _, err := client.RunSudo(fmt.Sprintf("chmod 600 %s", envFilePath)); err != nil { | ||
| 268 | fmt.Fprintf(os.Stderr, "Error setting env file permissions: %v\n", err) | ||
| 269 | os.Exit(1) | ||
| 270 | } | ||
| 271 | if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", name, name, envFilePath)); err != nil { | ||
| 272 | fmt.Fprintf(os.Stderr, "Error setting env file ownership: %v\n", err) | ||
| 273 | os.Exit(1) | ||
| 274 | } | ||
| 275 | |||
| 276 | // Generate systemd unit | ||
| 277 | fmt.Println("→ Creating systemd service...") | ||
| 278 | serviceContent, err := templates.SystemdService(map[string]string{ | ||
| 279 | "Name": name, | ||
| 280 | "User": name, | ||
| 281 | "WorkDir": workDir, | ||
| 282 | "BinaryPath": binaryDest, | ||
| 283 | "Port": strconv.Itoa(port), | ||
| 284 | "EnvFile": envFilePath, | ||
| 285 | "Args": args, | ||
| 286 | }) | ||
| 287 | if err != nil { | ||
| 288 | fmt.Fprintf(os.Stderr, "Error generating systemd unit: %v\n", err) | ||
| 289 | os.Exit(1) | ||
| 290 | } | ||
| 291 | |||
| 292 | servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", name) | ||
| 293 | if err := client.WriteSudoFile(servicePath, serviceContent); err != nil { | ||
| 294 | fmt.Fprintf(os.Stderr, "Error creating systemd unit: %v\n", err) | ||
| 295 | os.Exit(1) | ||
| 296 | } | ||
| 297 | |||
| 298 | // Generate Caddy config | ||
| 299 | fmt.Println("→ Configuring Caddy...") | ||
| 300 | caddyContent, err := templates.AppCaddy(map[string]string{ | ||
| 301 | "Domain": domain, | ||
| 302 | "Port": strconv.Itoa(port), | ||
| 303 | }) | ||
| 304 | if err != nil { | ||
| 305 | fmt.Fprintf(os.Stderr, "Error generating Caddy config: %v\n", err) | ||
| 306 | os.Exit(1) | ||
| 307 | } | ||
| 308 | |||
| 309 | caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) | ||
| 310 | if err := client.WriteSudoFile(caddyPath, caddyContent); err != nil { | ||
| 311 | fmt.Fprintf(os.Stderr, "Error creating Caddy config: %v\n", err) | ||
| 312 | os.Exit(1) | ||
| 313 | } | ||
| 314 | |||
| 315 | // Reload systemd | ||
| 316 | fmt.Println("→ Reloading systemd...") | ||
| 317 | if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { | ||
| 318 | fmt.Fprintf(os.Stderr, "Error reloading systemd: %v\n", err) | ||
| 319 | os.Exit(1) | ||
| 320 | } | ||
| 321 | |||
| 322 | // Enable and start service | ||
| 323 | fmt.Println("→ Starting service...") | ||
| 324 | if _, err := client.RunSudo(fmt.Sprintf("systemctl enable %s", name)); err != nil { | ||
| 325 | fmt.Fprintf(os.Stderr, "Error enabling service: %v\n", err) | ||
| 326 | os.Exit(1) | ||
| 327 | } | ||
| 328 | if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { | ||
| 329 | fmt.Fprintf(os.Stderr, "Error starting service: %v\n", err) | ||
| 330 | os.Exit(1) | ||
| 331 | } | ||
| 332 | |||
| 333 | // Reload Caddy | ||
| 334 | fmt.Println("→ Reloading Caddy...") | ||
| 335 | if _, err := client.RunSudo("systemctl reload caddy"); err != nil { | ||
| 336 | fmt.Fprintf(os.Stderr, "Error reloading Caddy: %v\n", err) | ||
| 337 | os.Exit(1) | ||
| 338 | } | ||
| 339 | |||
| 340 | // Update state | ||
| 341 | st.AddApp(host, name, &state.App{ | ||
| 342 | Type: "app", | ||
| 343 | Domain: domain, | ||
| 344 | Port: port, | ||
| 345 | Env: env, | ||
| 346 | Args: args, | ||
| 347 | Files: files, | ||
| 348 | }) | ||
| 349 | if err := st.Save(); err != nil { | ||
| 350 | fmt.Fprintf(os.Stderr, "Error saving state: %v\n", err) | ||
| 351 | os.Exit(1) | ||
| 352 | } | ||
| 353 | |||
| 354 | fmt.Printf("\n✓ App deployed successfully!\n") | ||
| 355 | fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", domain) | ||
| 356 | } | ||
| 357 | |||
| 358 | func deployStatic(host, domain, name, dir string) { | ||
| 359 | // Determine site name (default to domain to avoid conflicts) | ||
| 360 | if name == "" { | ||
| 361 | name = domain | ||
| 362 | } | ||
| 363 | |||
| 364 | // Verify directory exists | ||
| 365 | if _, err := os.Stat(dir); err != nil { | ||
| 366 | fmt.Fprintf(os.Stderr, "Error: directory not found: %s\n", dir) | ||
| 367 | os.Exit(1) | ||
| 368 | } | ||
| 369 | |||
| 370 | fmt.Printf("Deploying static site: %s\n", name) | ||
| 371 | fmt.Printf(" Domain: %s\n", domain) | ||
| 372 | fmt.Printf(" Directory: %s\n", dir) | ||
| 373 | |||
| 374 | // Load state | ||
| 375 | st, err := state.Load() | ||
| 376 | if err != nil { | ||
| 377 | fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) | ||
| 378 | os.Exit(1) | ||
| 379 | } | ||
| 380 | |||
| 381 | // Connect to VPS | ||
| 382 | client, err := ssh.Connect(host) | ||
| 383 | if err != nil { | ||
| 384 | fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err) | ||
| 385 | os.Exit(1) | ||
| 386 | } | ||
| 387 | defer client.Close() | ||
| 388 | |||
| 389 | // Create remote directory | ||
| 390 | remoteDir := fmt.Sprintf("/var/www/%s", name) | ||
| 391 | fmt.Println("→ Creating remote directory...") | ||
| 392 | if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", remoteDir)); err != nil { | ||
| 393 | fmt.Fprintf(os.Stderr, "Error creating remote directory: %v\n", err) | ||
| 394 | os.Exit(1) | ||
| 395 | } | ||
| 396 | |||
| 397 | // Get current user for temporary ownership during upload | ||
| 398 | currentUser, err := client.Run("whoami") | ||
| 399 | if err != nil { | ||
| 400 | fmt.Fprintf(os.Stderr, "Error getting current user: %v\n", err) | ||
| 401 | os.Exit(1) | ||
| 402 | } | ||
| 403 | currentUser = strings.TrimSpace(currentUser) | ||
| 404 | |||
| 405 | // Set ownership to current user for upload | ||
| 406 | if _, err := client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", currentUser, currentUser, remoteDir)); err != nil { | ||
| 407 | fmt.Fprintf(os.Stderr, "Error setting temporary ownership: %v\n", err) | ||
| 408 | os.Exit(1) | ||
| 409 | } | ||
| 410 | |||
| 411 | // Upload files | ||
| 412 | fmt.Println("→ Uploading files...") | ||
| 413 | if err := client.UploadDir(dir, remoteDir); err != nil { | ||
| 414 | fmt.Fprintf(os.Stderr, "Error uploading files: %v\n", err) | ||
| 415 | os.Exit(1) | ||
| 416 | } | ||
| 417 | |||
| 418 | // Set ownership and permissions | ||
| 419 | fmt.Println("→ Setting permissions...") | ||
| 420 | if _, err := client.RunSudo(fmt.Sprintf("chown -R www-data:www-data %s", remoteDir)); err != nil { | ||
| 421 | fmt.Fprintf(os.Stderr, "Error setting ownership: %v\n", err) | ||
| 422 | os.Exit(1) | ||
| 423 | } | ||
| 424 | // Make files readable by all (755 for dirs, 644 for files) | ||
| 425 | if _, err := client.RunSudo(fmt.Sprintf("chmod -R 755 %s", remoteDir)); err != nil { | ||
| 426 | fmt.Fprintf(os.Stderr, "Error setting directory permissions: %v\n", err) | ||
| 427 | os.Exit(1) | ||
| 428 | } | ||
| 429 | if _, err := client.RunSudo(fmt.Sprintf("find %s -type f -exec chmod 644 {} \\;", remoteDir)); err != nil { | ||
| 430 | fmt.Fprintf(os.Stderr, "Error setting file permissions: %v\n", err) | ||
| 431 | os.Exit(1) | ||
| 432 | } | ||
| 433 | |||
| 434 | // Generate Caddy config | ||
| 435 | fmt.Println("→ Configuring Caddy...") | ||
| 436 | caddyContent, err := templates.StaticCaddy(map[string]string{ | ||
| 437 | "Domain": domain, | ||
| 438 | "RootDir": remoteDir, | ||
| 439 | }) | ||
| 440 | if err != nil { | ||
| 441 | fmt.Fprintf(os.Stderr, "Error generating Caddy config: %v\n", err) | ||
| 442 | os.Exit(1) | ||
| 443 | } | ||
| 444 | |||
| 445 | caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) | ||
| 446 | if err := client.WriteSudoFile(caddyPath, caddyContent); err != nil { | ||
| 447 | fmt.Fprintf(os.Stderr, "Error creating Caddy config: %v\n", err) | ||
| 448 | os.Exit(1) | ||
| 449 | } | ||
| 450 | |||
| 451 | // Reload Caddy | ||
| 452 | fmt.Println("→ Reloading Caddy...") | ||
| 453 | if _, err := client.RunSudo("systemctl reload caddy"); err != nil { | ||
| 454 | fmt.Fprintf(os.Stderr, "Error reloading Caddy: %v\n", err) | ||
| 455 | os.Exit(1) | ||
| 456 | } | ||
| 457 | |||
| 458 | // Update state | ||
| 459 | st.AddApp(host, name, &state.App{ | ||
| 460 | Type: "static", | ||
| 461 | Domain: domain, | ||
| 462 | }) | ||
| 463 | if err := st.Save(); err != nil { | ||
| 464 | fmt.Fprintf(os.Stderr, "Error saving state: %v\n", err) | ||
| 465 | os.Exit(1) | ||
| 466 | } | ||
| 467 | |||
| 468 | fmt.Printf("\n✓ Static site deployed successfully!\n") | ||
| 469 | fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", domain) | ||
| 470 | } | ||
| 471 | |||
| 472 | func parseEnvFile(path string) (map[string]string, error) { | ||
| 473 | file, err := os.Open(path) | ||
| 474 | if err != nil { | ||
| 475 | return nil, err | ||
| 476 | } | ||
| 477 | defer file.Close() | ||
| 478 | |||
| 479 | env := make(map[string]string) | ||
| 480 | scanner := bufio.NewScanner(file) | ||
| 481 | for scanner.Scan() { | ||
| 482 | line := strings.TrimSpace(scanner.Text()) | ||
| 483 | if line == "" || strings.HasPrefix(line, "#") { | ||
| 484 | continue | ||
| 485 | } | ||
| 486 | |||
| 487 | parts := strings.SplitN(line, "=", 2) | ||
| 488 | if len(parts) == 2 { | ||
| 489 | env[parts[0]] = parts[1] | ||
| 490 | } | ||
| 491 | } | ||
| 492 | |||
| 493 | return env, scanner.Err() | ||
| 494 | } | ||
diff --git a/cmd/deploy/env.go b/cmd/deploy/env.go new file mode 100644 index 0000000..135fb77 --- /dev/null +++ b/cmd/deploy/env.go | |||
| @@ -0,0 +1,176 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "flag" | ||
| 5 | "fmt" | ||
| 6 | "os" | ||
| 7 | "strings" | ||
| 8 | |||
| 9 | "github.com/bdw/deploy/internal/config" | ||
| 10 | "github.com/bdw/deploy/internal/ssh" | ||
| 11 | "github.com/bdw/deploy/internal/state" | ||
| 12 | ) | ||
| 13 | |||
| 14 | func runEnv(args []string) { | ||
| 15 | fs := flag.NewFlagSet("env", flag.ExitOnError) | ||
| 16 | host := fs.String("host", "", "VPS host (SSH config alias or user@host)") | ||
| 17 | var setVars envFlags | ||
| 18 | fs.Var(&setVars, "set", "Set environment variable (KEY=VALUE, can be specified multiple times)") | ||
| 19 | var unsetVars envFlags | ||
| 20 | fs.Var(&unsetVars, "unset", "Unset environment variable (KEY, can be specified multiple times)") | ||
| 21 | envFile := fs.String("file", "", "Load environment from file") | ||
| 22 | fs.Parse(args) | ||
| 23 | |||
| 24 | if len(fs.Args()) == 0 { | ||
| 25 | fmt.Fprintf(os.Stderr, "Error: app name is required\n") | ||
| 26 | fmt.Fprintf(os.Stderr, "Usage: deploy env <app-name> [--set KEY=VALUE] [--unset KEY] [--file .env] --host user@vps-ip\n") | ||
| 27 | os.Exit(1) | ||
| 28 | } | ||
| 29 | |||
| 30 | name := fs.Args()[0] | ||
| 31 | |||
| 32 | // Get host from flag or config | ||
| 33 | if *host == "" { | ||
| 34 | cfg, err := config.Load() | ||
| 35 | if err != nil { | ||
| 36 | fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err) | ||
| 37 | os.Exit(1) | ||
| 38 | } | ||
| 39 | *host = cfg.Host | ||
| 40 | } | ||
| 41 | |||
| 42 | if *host == "" { | ||
| 43 | fmt.Fprintf(os.Stderr, "Error: --host is required\n") | ||
| 44 | fs.Usage() | ||
| 45 | os.Exit(1) | ||
| 46 | } | ||
| 47 | |||
| 48 | // Load state | ||
| 49 | st, err := state.Load() | ||
| 50 | if err != nil { | ||
| 51 | fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) | ||
| 52 | os.Exit(1) | ||
| 53 | } | ||
| 54 | |||
| 55 | // Get app info | ||
| 56 | app, err := st.GetApp(*host, name) | ||
| 57 | if err != nil { | ||
| 58 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) | ||
| 59 | os.Exit(1) | ||
| 60 | } | ||
| 61 | |||
| 62 | if app.Type != "app" { | ||
| 63 | fmt.Fprintf(os.Stderr, "Error: env is only available for apps, not static sites\n") | ||
| 64 | os.Exit(1) | ||
| 65 | } | ||
| 66 | |||
| 67 | // If no flags, just display current env (masked) | ||
| 68 | if len(setVars) == 0 && len(unsetVars) == 0 && *envFile == "" { | ||
| 69 | fmt.Printf("Environment variables for %s:\n\n", name) | ||
| 70 | if len(app.Env) == 0 { | ||
| 71 | fmt.Println(" (none)") | ||
| 72 | } else { | ||
| 73 | for k, v := range app.Env { | ||
| 74 | // Mask sensitive-looking values | ||
| 75 | display := v | ||
| 76 | if isSensitive(k) { | ||
| 77 | display = "***" | ||
| 78 | } | ||
| 79 | fmt.Printf(" %s=%s\n", k, display) | ||
| 80 | } | ||
| 81 | } | ||
| 82 | return | ||
| 83 | } | ||
| 84 | |||
| 85 | // Initialize env if nil | ||
| 86 | if app.Env == nil { | ||
| 87 | app.Env = make(map[string]string) | ||
| 88 | } | ||
| 89 | |||
| 90 | // Apply changes | ||
| 91 | changed := false | ||
| 92 | |||
| 93 | // Unset variables | ||
| 94 | for _, key := range unsetVars { | ||
| 95 | if _, exists := app.Env[key]; exists { | ||
| 96 | delete(app.Env, key) | ||
| 97 | changed = true | ||
| 98 | fmt.Printf("Unset %s\n", key) | ||
| 99 | } | ||
| 100 | } | ||
| 101 | |||
| 102 | // Set variables from flags | ||
| 103 | for _, e := range setVars { | ||
| 104 | parts := strings.SplitN(e, "=", 2) | ||
| 105 | if len(parts) == 2 { | ||
| 106 | app.Env[parts[0]] = parts[1] | ||
| 107 | changed = true | ||
| 108 | fmt.Printf("Set %s\n", parts[0]) | ||
| 109 | } | ||
| 110 | } | ||
| 111 | |||
| 112 | // Set variables from file | ||
| 113 | if *envFile != "" { | ||
| 114 | fileEnv, err := parseEnvFile(*envFile) | ||
| 115 | if err != nil { | ||
| 116 | fmt.Fprintf(os.Stderr, "Error reading env file: %v\n", err) | ||
| 117 | os.Exit(1) | ||
| 118 | } | ||
| 119 | for k, v := range fileEnv { | ||
| 120 | app.Env[k] = v | ||
| 121 | changed = true | ||
| 122 | fmt.Printf("Set %s\n", k) | ||
| 123 | } | ||
| 124 | } | ||
| 125 | |||
| 126 | if !changed { | ||
| 127 | fmt.Println("No changes made") | ||
| 128 | return | ||
| 129 | } | ||
| 130 | |||
| 131 | // Save state | ||
| 132 | if err := st.Save(); err != nil { | ||
| 133 | fmt.Fprintf(os.Stderr, "Error saving state: %v\n", err) | ||
| 134 | os.Exit(1) | ||
| 135 | } | ||
| 136 | |||
| 137 | // Connect to VPS and update env file | ||
| 138 | client, err := ssh.Connect(*host) | ||
| 139 | if err != nil { | ||
| 140 | fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err) | ||
| 141 | os.Exit(1) | ||
| 142 | } | ||
| 143 | defer client.Close() | ||
| 144 | |||
| 145 | // Regenerate env file | ||
| 146 | fmt.Println("→ Updating environment file on VPS...") | ||
| 147 | envFilePath := fmt.Sprintf("/etc/deploy/env/%s.env", name) | ||
| 148 | envContent := "" | ||
| 149 | for k, v := range app.Env { | ||
| 150 | envContent += fmt.Sprintf("%s=%s\n", k, v) | ||
| 151 | } | ||
| 152 | if err := client.WriteSudoFile(envFilePath, envContent); err != nil { | ||
| 153 | fmt.Fprintf(os.Stderr, "Error updating env file: %v\n", err) | ||
| 154 | os.Exit(1) | ||
| 155 | } | ||
| 156 | |||
| 157 | // Restart service to pick up new env | ||
| 158 | fmt.Println("→ Restarting service...") | ||
| 159 | if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { | ||
| 160 | fmt.Fprintf(os.Stderr, "Error restarting service: %v\n", err) | ||
| 161 | os.Exit(1) | ||
| 162 | } | ||
| 163 | |||
| 164 | fmt.Println("✓ Environment variables updated successfully") | ||
| 165 | } | ||
| 166 | |||
| 167 | func isSensitive(key string) bool { | ||
| 168 | key = strings.ToLower(key) | ||
| 169 | sensitiveWords := []string{"key", "secret", "password", "token", "api"} | ||
| 170 | for _, word := range sensitiveWords { | ||
| 171 | if strings.Contains(key, word) { | ||
| 172 | return true | ||
| 173 | } | ||
| 174 | } | ||
| 175 | return false | ||
| 176 | } | ||
diff --git a/cmd/deploy/init.go b/cmd/deploy/init.go new file mode 100644 index 0000000..72c7d53 --- /dev/null +++ b/cmd/deploy/init.go | |||
| @@ -0,0 +1,154 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "flag" | ||
| 5 | "fmt" | ||
| 6 | "os" | ||
| 7 | "strings" | ||
| 8 | |||
| 9 | "github.com/bdw/deploy/internal/config" | ||
| 10 | "github.com/bdw/deploy/internal/ssh" | ||
| 11 | "github.com/bdw/deploy/internal/state" | ||
| 12 | ) | ||
| 13 | |||
| 14 | func runInit(args []string) { | ||
| 15 | fs := flag.NewFlagSet("init", flag.ExitOnError) | ||
| 16 | host := fs.String("host", "", "VPS host (SSH config alias or user@host)") | ||
| 17 | fs.Parse(args) | ||
| 18 | |||
| 19 | // Get host from flag or config | ||
| 20 | if *host == "" { | ||
| 21 | cfg, err := config.Load() | ||
| 22 | if err != nil { | ||
| 23 | fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err) | ||
| 24 | os.Exit(1) | ||
| 25 | } | ||
| 26 | *host = cfg.Host | ||
| 27 | } | ||
| 28 | |||
| 29 | if *host == "" { | ||
| 30 | fmt.Fprintf(os.Stderr, "Error: --host is required\n") | ||
| 31 | fs.Usage() | ||
| 32 | os.Exit(1) | ||
| 33 | } | ||
| 34 | |||
| 35 | fmt.Printf("Initializing VPS: %s\n", *host) | ||
| 36 | |||
| 37 | // Connect to VPS | ||
| 38 | client, err := ssh.Connect(*host) | ||
| 39 | if err != nil { | ||
| 40 | fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err) | ||
| 41 | os.Exit(1) | ||
| 42 | } | ||
| 43 | defer client.Close() | ||
| 44 | |||
| 45 | // Detect OS | ||
| 46 | fmt.Println("→ Detecting OS...") | ||
| 47 | osRelease, err := client.Run("cat /etc/os-release") | ||
| 48 | if err != nil { | ||
| 49 | fmt.Fprintf(os.Stderr, "Error detecting OS: %v\n", err) | ||
| 50 | os.Exit(1) | ||
| 51 | } | ||
| 52 | |||
| 53 | if !strings.Contains(osRelease, "Ubuntu") && !strings.Contains(osRelease, "Debian") { | ||
| 54 | fmt.Fprintf(os.Stderr, "Error: Unsupported OS (only Ubuntu and Debian are supported)\n") | ||
| 55 | os.Exit(1) | ||
| 56 | } | ||
| 57 | fmt.Println(" ✓ Detected Ubuntu/Debian") | ||
| 58 | |||
| 59 | // Check if Caddy is already installed | ||
| 60 | fmt.Println("→ Checking for Caddy...") | ||
| 61 | _, err = client.Run("which caddy") | ||
| 62 | if err == nil { | ||
| 63 | fmt.Println(" ✓ Caddy already installed") | ||
| 64 | } else { | ||
| 65 | // Install Caddy | ||
| 66 | fmt.Println(" Installing Caddy...") | ||
| 67 | installCaddy(client) | ||
| 68 | fmt.Println(" ✓ Caddy installed") | ||
| 69 | } | ||
| 70 | |||
| 71 | // Create Caddyfile | ||
| 72 | fmt.Println("→ Configuring Caddy...") | ||
| 73 | caddyfile := `{ | ||
| 74 | email admin@example.com | ||
| 75 | } | ||
| 76 | |||
| 77 | import /etc/caddy/sites-enabled/* | ||
| 78 | ` | ||
| 79 | if err := client.WriteSudoFile("/etc/caddy/Caddyfile", caddyfile); err != nil { | ||
| 80 | fmt.Fprintf(os.Stderr, "Error creating Caddyfile: %v\n", err) | ||
| 81 | os.Exit(1) | ||
| 82 | } | ||
| 83 | fmt.Println(" ✓ Caddyfile created") | ||
| 84 | |||
| 85 | // Create directories | ||
| 86 | fmt.Println("→ Creating directories...") | ||
| 87 | if _, err := client.RunSudo("mkdir -p /etc/deploy/env"); err != nil { | ||
| 88 | fmt.Fprintf(os.Stderr, "Error creating /etc/deploy/env: %v\n", err) | ||
| 89 | os.Exit(1) | ||
| 90 | } | ||
| 91 | if _, err := client.RunSudo("mkdir -p /etc/caddy/sites-enabled"); err != nil { | ||
| 92 | fmt.Fprintf(os.Stderr, "Error creating /etc/caddy/sites-enabled: %v\n", err) | ||
| 93 | os.Exit(1) | ||
| 94 | } | ||
| 95 | fmt.Println(" ✓ Directories created") | ||
| 96 | |||
| 97 | // Enable and start Caddy | ||
| 98 | fmt.Println("→ Starting Caddy...") | ||
| 99 | if _, err := client.RunSudo("systemctl enable caddy"); err != nil { | ||
| 100 | fmt.Fprintf(os.Stderr, "Error enabling Caddy: %v\n", err) | ||
| 101 | os.Exit(1) | ||
| 102 | } | ||
| 103 | if _, err := client.RunSudo("systemctl restart caddy"); err != nil { | ||
| 104 | fmt.Fprintf(os.Stderr, "Error starting Caddy: %v\n", err) | ||
| 105 | os.Exit(1) | ||
| 106 | } | ||
| 107 | fmt.Println(" ✓ Caddy started") | ||
| 108 | |||
| 109 | // Verify Caddy is running | ||
| 110 | fmt.Println("→ Verifying installation...") | ||
| 111 | output, err := client.RunSudo("systemctl is-active caddy") | ||
| 112 | if err != nil || strings.TrimSpace(output) != "active" { | ||
| 113 | fmt.Fprintf(os.Stderr, "Warning: Caddy may not be running properly\n") | ||
| 114 | } else { | ||
| 115 | fmt.Println(" ✓ Caddy is active") | ||
| 116 | } | ||
| 117 | |||
| 118 | // Initialize local state if needed | ||
| 119 | st, err := state.Load() | ||
| 120 | if err != nil { | ||
| 121 | fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) | ||
| 122 | os.Exit(1) | ||
| 123 | } | ||
| 124 | st.GetHost(*host) // Ensure host exists in state | ||
| 125 | if err := st.Save(); err != nil { | ||
| 126 | fmt.Fprintf(os.Stderr, "Error saving state: %v\n", err) | ||
| 127 | os.Exit(1) | ||
| 128 | } | ||
| 129 | |||
| 130 | fmt.Println("\n✓ VPS initialized successfully!") | ||
| 131 | fmt.Println("\nNext steps:") | ||
| 132 | fmt.Println(" 1. Deploy a Go app:") | ||
| 133 | fmt.Printf(" deploy deploy --host %s --binary ./myapp --domain api.example.com\n", *host) | ||
| 134 | fmt.Println(" 2. Deploy a static site:") | ||
| 135 | fmt.Printf(" deploy deploy --host %s --static --dir ./dist --domain example.com\n", *host) | ||
| 136 | } | ||
| 137 | |||
| 138 | func installCaddy(client *ssh.Client) { | ||
| 139 | commands := []string{ | ||
| 140 | "apt-get update", | ||
| 141 | "apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl", | ||
| 142 | "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg", | ||
| 143 | "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list", | ||
| 144 | "apt-get update", | ||
| 145 | "apt-get install -y caddy", | ||
| 146 | } | ||
| 147 | |||
| 148 | for _, cmd := range commands { | ||
| 149 | if _, err := client.RunSudo(cmd); err != nil { | ||
| 150 | fmt.Fprintf(os.Stderr, "Error running: %s\nError: %v\n", cmd, err) | ||
| 151 | os.Exit(1) | ||
| 152 | } | ||
| 153 | } | ||
| 154 | } | ||
diff --git a/cmd/deploy/list.go b/cmd/deploy/list.go new file mode 100644 index 0000000..b74cf35 --- /dev/null +++ b/cmd/deploy/list.go | |||
| @@ -0,0 +1,58 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "flag" | ||
| 5 | "fmt" | ||
| 6 | "os" | ||
| 7 | "text/tabwriter" | ||
| 8 | |||
| 9 | "github.com/bdw/deploy/internal/config" | ||
| 10 | "github.com/bdw/deploy/internal/state" | ||
| 11 | ) | ||
| 12 | |||
| 13 | func runList(args []string) { | ||
| 14 | fs := flag.NewFlagSet("list", flag.ExitOnError) | ||
| 15 | host := fs.String("host", "", "VPS host (SSH config alias or user@host)") | ||
| 16 | fs.Parse(args) | ||
| 17 | |||
| 18 | // Get host from flag or config | ||
| 19 | if *host == "" { | ||
| 20 | cfg, err := config.Load() | ||
| 21 | if err != nil { | ||
| 22 | fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err) | ||
| 23 | os.Exit(1) | ||
| 24 | } | ||
| 25 | *host = cfg.Host | ||
| 26 | } | ||
| 27 | |||
| 28 | if *host == "" { | ||
| 29 | fmt.Fprintf(os.Stderr, "Error: --host is required\n") | ||
| 30 | fs.Usage() | ||
| 31 | os.Exit(1) | ||
| 32 | } | ||
| 33 | |||
| 34 | // Load state | ||
| 35 | st, err := state.Load() | ||
| 36 | if err != nil { | ||
| 37 | fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) | ||
| 38 | os.Exit(1) | ||
| 39 | } | ||
| 40 | |||
| 41 | apps := st.ListApps(*host) | ||
| 42 | if len(apps) == 0 { | ||
| 43 | fmt.Printf("No deployments found for %s\n", *host) | ||
| 44 | return | ||
| 45 | } | ||
| 46 | |||
| 47 | // Print table | ||
| 48 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) | ||
| 49 | fmt.Fprintln(w, "NAME\tTYPE\tDOMAIN\tPORT") | ||
| 50 | for name, app := range apps { | ||
| 51 | port := "" | ||
| 52 | if app.Type == "app" { | ||
| 53 | port = fmt.Sprintf(":%d", app.Port) | ||
| 54 | } | ||
| 55 | fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", name, app.Type, app.Domain, port) | ||
| 56 | } | ||
| 57 | w.Flush() | ||
| 58 | } | ||
diff --git a/cmd/deploy/main.go b/cmd/deploy/main.go new file mode 100644 index 0000000..1589af3 --- /dev/null +++ b/cmd/deploy/main.go | |||
| @@ -0,0 +1,82 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | "os" | ||
| 6 | ) | ||
| 7 | |||
| 8 | func main() { | ||
| 9 | if len(os.Args) < 2 { | ||
| 10 | printUsage() | ||
| 11 | os.Exit(1) | ||
| 12 | } | ||
| 13 | |||
| 14 | command := os.Args[1] | ||
| 15 | |||
| 16 | switch command { | ||
| 17 | case "init": | ||
| 18 | runInit(os.Args[2:]) | ||
| 19 | case "deploy": | ||
| 20 | runDeploy(os.Args[2:]) | ||
| 21 | case "list": | ||
| 22 | runList(os.Args[2:]) | ||
| 23 | case "remove": | ||
| 24 | runRemove(os.Args[2:]) | ||
| 25 | case "logs": | ||
| 26 | runLogs(os.Args[2:]) | ||
| 27 | case "status": | ||
| 28 | runStatus(os.Args[2:]) | ||
| 29 | case "restart": | ||
| 30 | runRestart(os.Args[2:]) | ||
| 31 | case "env": | ||
| 32 | runEnv(os.Args[2:]) | ||
| 33 | case "webui": | ||
| 34 | runWebUI(os.Args[2:]) | ||
| 35 | case "help", "--help", "-h": | ||
| 36 | printUsage() | ||
| 37 | default: | ||
| 38 | fmt.Fprintf(os.Stderr, "Unknown command: %s\n\n", command) | ||
| 39 | printUsage() | ||
| 40 | os.Exit(1) | ||
| 41 | } | ||
| 42 | } | ||
| 43 | |||
| 44 | func printUsage() { | ||
| 45 | usage := `deploy - Deploy Go apps and static sites to a VPS with automatic HTTPS | ||
| 46 | |||
| 47 | USAGE: | ||
| 48 | deploy <command> [flags] | ||
| 49 | |||
| 50 | COMMANDS: | ||
| 51 | init Initialize a fresh VPS (one-time setup) | ||
| 52 | deploy Deploy a Go app or static site | ||
| 53 | list List all deployed apps and sites | ||
| 54 | remove Remove a deployment | ||
| 55 | logs View logs for a deployment | ||
| 56 | status Check status of a deployment | ||
| 57 | restart Restart a deployment | ||
| 58 | env Manage environment variables | ||
| 59 | webui Launch web UI to manage deployments | ||
| 60 | |||
| 61 | FLAGS: | ||
| 62 | Run 'deploy <command> -h' for command-specific flags | ||
| 63 | |||
| 64 | EXAMPLES: | ||
| 65 | # Initialize VPS | ||
| 66 | deploy init --host user@vps-ip | ||
| 67 | |||
| 68 | # Deploy Go app | ||
| 69 | deploy deploy --host user@vps-ip --binary ./myapp --domain api.example.com | ||
| 70 | |||
| 71 | # Deploy static site | ||
| 72 | deploy deploy --host user@vps-ip --static --dir ./dist --domain example.com | ||
| 73 | |||
| 74 | # List deployments | ||
| 75 | deploy list --host user@vps-ip | ||
| 76 | |||
| 77 | CONFIG FILE: | ||
| 78 | Create ~/.config/deploy/config to set default host: | ||
| 79 | host: user@your-vps-ip | ||
| 80 | ` | ||
| 81 | fmt.Fprint(os.Stderr, usage) | ||
| 82 | } | ||
diff --git a/cmd/deploy/manage.go b/cmd/deploy/manage.go new file mode 100644 index 0000000..3cee1f4 --- /dev/null +++ b/cmd/deploy/manage.go | |||
| @@ -0,0 +1,327 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "flag" | ||
| 5 | "fmt" | ||
| 6 | "os" | ||
| 7 | |||
| 8 | "github.com/bdw/deploy/internal/config" | ||
| 9 | "github.com/bdw/deploy/internal/ssh" | ||
| 10 | "github.com/bdw/deploy/internal/state" | ||
| 11 | ) | ||
| 12 | |||
| 13 | func runRemove(args []string) { | ||
| 14 | fs := flag.NewFlagSet("remove", flag.ExitOnError) | ||
| 15 | host := fs.String("host", "", "VPS host (SSH config alias or user@host)") | ||
| 16 | fs.Parse(args) | ||
| 17 | |||
| 18 | if len(fs.Args()) == 0 { | ||
| 19 | fmt.Fprintf(os.Stderr, "Error: app name is required\n") | ||
| 20 | fmt.Fprintf(os.Stderr, "Usage: deploy remove <app-name> --host user@vps-ip\n") | ||
| 21 | os.Exit(1) | ||
| 22 | } | ||
| 23 | |||
| 24 | name := fs.Args()[0] | ||
| 25 | |||
| 26 | // Get host from flag or config | ||
| 27 | if *host == "" { | ||
| 28 | cfg, err := config.Load() | ||
| 29 | if err != nil { | ||
| 30 | fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err) | ||
| 31 | os.Exit(1) | ||
| 32 | } | ||
| 33 | *host = cfg.Host | ||
| 34 | } | ||
| 35 | |||
| 36 | if *host == "" { | ||
| 37 | fmt.Fprintf(os.Stderr, "Error: --host is required\n") | ||
| 38 | fs.Usage() | ||
| 39 | os.Exit(1) | ||
| 40 | } | ||
| 41 | |||
| 42 | // Load state | ||
| 43 | st, err := state.Load() | ||
| 44 | if err != nil { | ||
| 45 | fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) | ||
| 46 | os.Exit(1) | ||
| 47 | } | ||
| 48 | |||
| 49 | // Get app info | ||
| 50 | app, err := st.GetApp(*host, name) | ||
| 51 | if err != nil { | ||
| 52 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) | ||
| 53 | os.Exit(1) | ||
| 54 | } | ||
| 55 | |||
| 56 | fmt.Printf("Removing deployment: %s\n", name) | ||
| 57 | |||
| 58 | // Connect to VPS | ||
| 59 | client, err := ssh.Connect(*host) | ||
| 60 | if err != nil { | ||
| 61 | fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err) | ||
| 62 | os.Exit(1) | ||
| 63 | } | ||
| 64 | defer client.Close() | ||
| 65 | |||
| 66 | if app.Type == "app" { | ||
| 67 | // Stop and disable service | ||
| 68 | fmt.Println("→ Stopping service...") | ||
| 69 | client.RunSudo(fmt.Sprintf("systemctl stop %s", name)) | ||
| 70 | client.RunSudo(fmt.Sprintf("systemctl disable %s", name)) | ||
| 71 | |||
| 72 | // Remove systemd unit | ||
| 73 | client.RunSudo(fmt.Sprintf("rm -f /etc/systemd/system/%s.service", name)) | ||
| 74 | client.RunSudo("systemctl daemon-reload") | ||
| 75 | |||
| 76 | // Remove binary | ||
| 77 | client.RunSudo(fmt.Sprintf("rm -f /usr/local/bin/%s", name)) | ||
| 78 | |||
| 79 | // Remove working directory | ||
| 80 | client.RunSudo(fmt.Sprintf("rm -rf /var/lib/%s", name)) | ||
| 81 | |||
| 82 | // Remove env file | ||
| 83 | client.RunSudo(fmt.Sprintf("rm -f /etc/deploy/env/%s.env", name)) | ||
| 84 | |||
| 85 | // Remove user | ||
| 86 | client.RunSudo(fmt.Sprintf("userdel %s", name)) | ||
| 87 | } else { | ||
| 88 | // Remove static site files | ||
| 89 | fmt.Println("→ Removing files...") | ||
| 90 | client.RunSudo(fmt.Sprintf("rm -rf /var/www/%s", name)) | ||
| 91 | } | ||
| 92 | |||
| 93 | // Remove Caddy config | ||
| 94 | fmt.Println("→ Removing Caddy config...") | ||
| 95 | client.RunSudo(fmt.Sprintf("rm -f /etc/caddy/sites-enabled/%s.caddy", name)) | ||
| 96 | |||
| 97 | // Reload Caddy | ||
| 98 | fmt.Println("→ Reloading Caddy...") | ||
| 99 | if _, err := client.RunSudo("systemctl reload caddy"); err != nil { | ||
| 100 | fmt.Fprintf(os.Stderr, "Warning: Error reloading Caddy: %v\n", err) | ||
| 101 | } | ||
| 102 | |||
| 103 | // Update state | ||
| 104 | if err := st.RemoveApp(*host, name); err != nil { | ||
| 105 | fmt.Fprintf(os.Stderr, "Error updating state: %v\n", err) | ||
| 106 | os.Exit(1) | ||
| 107 | } | ||
| 108 | if err := st.Save(); err != nil { | ||
| 109 | fmt.Fprintf(os.Stderr, "Error saving state: %v\n", err) | ||
| 110 | os.Exit(1) | ||
| 111 | } | ||
| 112 | |||
| 113 | fmt.Printf("✓ Deployment removed successfully\n") | ||
| 114 | } | ||
| 115 | |||
| 116 | func runLogs(args []string) { | ||
| 117 | fs := flag.NewFlagSet("logs", flag.ExitOnError) | ||
| 118 | host := fs.String("host", "", "VPS host (SSH config alias or user@host)") | ||
| 119 | follow := fs.Bool("f", false, "Follow logs") | ||
| 120 | lines := fs.Int("n", 50, "Number of lines to show") | ||
| 121 | fs.Parse(args) | ||
| 122 | |||
| 123 | if len(fs.Args()) == 0 { | ||
| 124 | fmt.Fprintf(os.Stderr, "Error: app name is required\n") | ||
| 125 | fmt.Fprintf(os.Stderr, "Usage: deploy logs <app-name> --host user@vps-ip\n") | ||
| 126 | os.Exit(1) | ||
| 127 | } | ||
| 128 | |||
| 129 | name := fs.Args()[0] | ||
| 130 | |||
| 131 | // Get host from flag or config | ||
| 132 | if *host == "" { | ||
| 133 | cfg, err := config.Load() | ||
| 134 | if err != nil { | ||
| 135 | fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err) | ||
| 136 | os.Exit(1) | ||
| 137 | } | ||
| 138 | *host = cfg.Host | ||
| 139 | } | ||
| 140 | |||
| 141 | if *host == "" { | ||
| 142 | fmt.Fprintf(os.Stderr, "Error: --host is required\n") | ||
| 143 | fs.Usage() | ||
| 144 | os.Exit(1) | ||
| 145 | } | ||
| 146 | |||
| 147 | // Load state to verify app exists | ||
| 148 | st, err := state.Load() | ||
| 149 | if err != nil { | ||
| 150 | fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) | ||
| 151 | os.Exit(1) | ||
| 152 | } | ||
| 153 | |||
| 154 | app, err := st.GetApp(*host, name) | ||
| 155 | if err != nil { | ||
| 156 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) | ||
| 157 | os.Exit(1) | ||
| 158 | } | ||
| 159 | |||
| 160 | if app.Type != "app" { | ||
| 161 | fmt.Fprintf(os.Stderr, "Error: logs are only available for apps, not static sites\n") | ||
| 162 | os.Exit(1) | ||
| 163 | } | ||
| 164 | |||
| 165 | // Connect to VPS | ||
| 166 | client, err := ssh.Connect(*host) | ||
| 167 | if err != nil { | ||
| 168 | fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err) | ||
| 169 | os.Exit(1) | ||
| 170 | } | ||
| 171 | defer client.Close() | ||
| 172 | |||
| 173 | // Build journalctl command | ||
| 174 | cmd := fmt.Sprintf("journalctl -u %s -n %d", name, *lines) | ||
| 175 | if *follow { | ||
| 176 | cmd += " -f" | ||
| 177 | } | ||
| 178 | |||
| 179 | // Run command | ||
| 180 | if *follow { | ||
| 181 | // Stream output for follow mode (no sudo needed for journalctl) | ||
| 182 | if err := client.RunStream(cmd); err != nil { | ||
| 183 | fmt.Fprintf(os.Stderr, "Error fetching logs: %v\n", err) | ||
| 184 | os.Exit(1) | ||
| 185 | } | ||
| 186 | } else { | ||
| 187 | // Buffer output for non-follow mode (no sudo needed for journalctl) | ||
| 188 | output, err := client.Run(cmd) | ||
| 189 | if err != nil { | ||
| 190 | fmt.Fprintf(os.Stderr, "Error fetching logs: %v\n", err) | ||
| 191 | os.Exit(1) | ||
| 192 | } | ||
| 193 | fmt.Print(output) | ||
| 194 | } | ||
| 195 | } | ||
| 196 | |||
| 197 | func runStatus(args []string) { | ||
| 198 | fs := flag.NewFlagSet("status", flag.ExitOnError) | ||
| 199 | host := fs.String("host", "", "VPS host (SSH config alias or user@host)") | ||
| 200 | fs.Parse(args) | ||
| 201 | |||
| 202 | if len(fs.Args()) == 0 { | ||
| 203 | fmt.Fprintf(os.Stderr, "Error: app name is required\n") | ||
| 204 | fmt.Fprintf(os.Stderr, "Usage: deploy status <app-name> --host user@vps-ip\n") | ||
| 205 | os.Exit(1) | ||
| 206 | } | ||
| 207 | |||
| 208 | name := fs.Args()[0] | ||
| 209 | |||
| 210 | // Get host from flag or config | ||
| 211 | if *host == "" { | ||
| 212 | cfg, err := config.Load() | ||
| 213 | if err != nil { | ||
| 214 | fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err) | ||
| 215 | os.Exit(1) | ||
| 216 | } | ||
| 217 | *host = cfg.Host | ||
| 218 | } | ||
| 219 | |||
| 220 | if *host == "" { | ||
| 221 | fmt.Fprintf(os.Stderr, "Error: --host is required\n") | ||
| 222 | fs.Usage() | ||
| 223 | os.Exit(1) | ||
| 224 | } | ||
| 225 | |||
| 226 | // Load state to verify app exists | ||
| 227 | st, err := state.Load() | ||
| 228 | if err != nil { | ||
| 229 | fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) | ||
| 230 | os.Exit(1) | ||
| 231 | } | ||
| 232 | |||
| 233 | app, err := st.GetApp(*host, name) | ||
| 234 | if err != nil { | ||
| 235 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) | ||
| 236 | os.Exit(1) | ||
| 237 | } | ||
| 238 | |||
| 239 | if app.Type != "app" { | ||
| 240 | fmt.Fprintf(os.Stderr, "Error: status is only available for apps, not static sites\n") | ||
| 241 | os.Exit(1) | ||
| 242 | } | ||
| 243 | |||
| 244 | // Connect to VPS | ||
| 245 | client, err := ssh.Connect(*host) | ||
| 246 | if err != nil { | ||
| 247 | fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err) | ||
| 248 | os.Exit(1) | ||
| 249 | } | ||
| 250 | defer client.Close() | ||
| 251 | |||
| 252 | // Get status | ||
| 253 | output, err := client.RunSudo(fmt.Sprintf("systemctl status %s", name)) | ||
| 254 | if err != nil { | ||
| 255 | // systemctl status returns non-zero for non-active services | ||
| 256 | // but we still want to show the output | ||
| 257 | fmt.Print(output) | ||
| 258 | return | ||
| 259 | } | ||
| 260 | |||
| 261 | fmt.Print(output) | ||
| 262 | } | ||
| 263 | |||
| 264 | func runRestart(args []string) { | ||
| 265 | fs := flag.NewFlagSet("restart", flag.ExitOnError) | ||
| 266 | host := fs.String("host", "", "VPS host (SSH config alias or user@host)") | ||
| 267 | fs.Parse(args) | ||
| 268 | |||
| 269 | if len(fs.Args()) == 0 { | ||
| 270 | fmt.Fprintf(os.Stderr, "Error: app name is required\n") | ||
| 271 | fmt.Fprintf(os.Stderr, "Usage: deploy restart <app-name> --host user@vps-ip\n") | ||
| 272 | os.Exit(1) | ||
| 273 | } | ||
| 274 | |||
| 275 | name := fs.Args()[0] | ||
| 276 | |||
| 277 | // Get host from flag or config | ||
| 278 | if *host == "" { | ||
| 279 | cfg, err := config.Load() | ||
| 280 | if err != nil { | ||
| 281 | fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err) | ||
| 282 | os.Exit(1) | ||
| 283 | } | ||
| 284 | *host = cfg.Host | ||
| 285 | } | ||
| 286 | |||
| 287 | if *host == "" { | ||
| 288 | fmt.Fprintf(os.Stderr, "Error: --host is required\n") | ||
| 289 | fs.Usage() | ||
| 290 | os.Exit(1) | ||
| 291 | } | ||
| 292 | |||
| 293 | // Load state to verify app exists | ||
| 294 | st, err := state.Load() | ||
| 295 | if err != nil { | ||
| 296 | fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) | ||
| 297 | os.Exit(1) | ||
| 298 | } | ||
| 299 | |||
| 300 | app, err := st.GetApp(*host, name) | ||
| 301 | if err != nil { | ||
| 302 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) | ||
| 303 | os.Exit(1) | ||
| 304 | } | ||
| 305 | |||
| 306 | if app.Type != "app" { | ||
| 307 | fmt.Fprintf(os.Stderr, "Error: restart is only available for apps, not static sites\n") | ||
| 308 | os.Exit(1) | ||
| 309 | } | ||
| 310 | |||
| 311 | // Connect to VPS | ||
| 312 | client, err := ssh.Connect(*host) | ||
| 313 | if err != nil { | ||
| 314 | fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err) | ||
| 315 | os.Exit(1) | ||
| 316 | } | ||
| 317 | defer client.Close() | ||
| 318 | |||
| 319 | // Restart service | ||
| 320 | fmt.Printf("Restarting %s...\n", name) | ||
| 321 | if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { | ||
| 322 | fmt.Fprintf(os.Stderr, "Error restarting service: %v\n", err) | ||
| 323 | os.Exit(1) | ||
| 324 | } | ||
| 325 | |||
| 326 | fmt.Println("✓ Service restarted successfully") | ||
| 327 | } | ||
diff --git a/cmd/deploy/templates/webui.html b/cmd/deploy/templates/webui.html new file mode 100644 index 0000000..052d599 --- /dev/null +++ b/cmd/deploy/templates/webui.html | |||
| @@ -0,0 +1,440 @@ | |||
| 1 | <!DOCTYPE html> | ||
| 2 | <html lang="en"> | ||
| 3 | <head> | ||
| 4 | <meta charset="UTF-8"> | ||
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||
| 6 | <title>Deploy - Web UI</title> | ||
| 7 | <style> | ||
| 8 | * { | ||
| 9 | margin: 0; | ||
| 10 | padding: 0; | ||
| 11 | box-sizing: border-box; | ||
| 12 | } | ||
| 13 | |||
| 14 | body { | ||
| 15 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; | ||
| 16 | background: #f5f5f5; | ||
| 17 | color: #333; | ||
| 18 | line-height: 1.6; | ||
| 19 | } | ||
| 20 | |||
| 21 | header { | ||
| 22 | background: #2c3e50; | ||
| 23 | color: white; | ||
| 24 | padding: 1.5rem 2rem; | ||
| 25 | box-shadow: 0 2px 4px rgba(0,0,0,0.1); | ||
| 26 | } | ||
| 27 | |||
| 28 | header h1 { | ||
| 29 | font-size: 1.8rem; | ||
| 30 | font-weight: 600; | ||
| 31 | } | ||
| 32 | |||
| 33 | header p { | ||
| 34 | color: #bdc3c7; | ||
| 35 | margin-top: 0.25rem; | ||
| 36 | font-size: 0.9rem; | ||
| 37 | } | ||
| 38 | |||
| 39 | .container { | ||
| 40 | max-width: 1200px; | ||
| 41 | margin: 2rem auto; | ||
| 42 | padding: 0 2rem; | ||
| 43 | } | ||
| 44 | |||
| 45 | .empty-state { | ||
| 46 | text-align: center; | ||
| 47 | padding: 4rem 2rem; | ||
| 48 | background: white; | ||
| 49 | border-radius: 8px; | ||
| 50 | box-shadow: 0 2px 4px rgba(0,0,0,0.1); | ||
| 51 | } | ||
| 52 | |||
| 53 | .empty-state h2 { | ||
| 54 | color: #7f8c8d; | ||
| 55 | font-weight: 500; | ||
| 56 | margin-bottom: 0.5rem; | ||
| 57 | } | ||
| 58 | |||
| 59 | .empty-state p { | ||
| 60 | color: #95a5a6; | ||
| 61 | } | ||
| 62 | |||
| 63 | .host-section { | ||
| 64 | margin-bottom: 2rem; | ||
| 65 | } | ||
| 66 | |||
| 67 | .host-header { | ||
| 68 | background: white; | ||
| 69 | padding: 1rem 1.5rem; | ||
| 70 | border-radius: 8px 8px 0 0; | ||
| 71 | border-left: 4px solid #3498db; | ||
| 72 | box-shadow: 0 2px 4px rgba(0,0,0,0.1); | ||
| 73 | } | ||
| 74 | |||
| 75 | .host-header h2 { | ||
| 76 | font-size: 1.3rem; | ||
| 77 | color: #2c3e50; | ||
| 78 | font-weight: 600; | ||
| 79 | } | ||
| 80 | |||
| 81 | .apps-grid { | ||
| 82 | display: grid; | ||
| 83 | grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); | ||
| 84 | gap: 1rem; | ||
| 85 | padding: 1rem; | ||
| 86 | background: #ecf0f1; | ||
| 87 | border-radius: 0 0 8px 8px; | ||
| 88 | } | ||
| 89 | |||
| 90 | .app-card { | ||
| 91 | background: white; | ||
| 92 | padding: 1.5rem; | ||
| 93 | border-radius: 6px; | ||
| 94 | box-shadow: 0 1px 3px rgba(0,0,0,0.1); | ||
| 95 | transition: transform 0.2s, box-shadow 0.2s; | ||
| 96 | } | ||
| 97 | |||
| 98 | .app-card:hover { | ||
| 99 | transform: translateY(-2px); | ||
| 100 | box-shadow: 0 4px 8px rgba(0,0,0,0.15); | ||
| 101 | } | ||
| 102 | |||
| 103 | .app-header { | ||
| 104 | display: flex; | ||
| 105 | justify-content: space-between; | ||
| 106 | align-items: center; | ||
| 107 | margin-bottom: 1rem; | ||
| 108 | } | ||
| 109 | |||
| 110 | .app-name { | ||
| 111 | font-size: 1.2rem; | ||
| 112 | font-weight: 600; | ||
| 113 | color: #2c3e50; | ||
| 114 | } | ||
| 115 | |||
| 116 | .app-type { | ||
| 117 | padding: 0.25rem 0.75rem; | ||
| 118 | border-radius: 12px; | ||
| 119 | font-size: 0.75rem; | ||
| 120 | font-weight: 500; | ||
| 121 | text-transform: uppercase; | ||
| 122 | } | ||
| 123 | |||
| 124 | .app-type.app { | ||
| 125 | background: #3498db; | ||
| 126 | color: white; | ||
| 127 | } | ||
| 128 | |||
| 129 | .app-type.static { | ||
| 130 | background: #2ecc71; | ||
| 131 | color: white; | ||
| 132 | } | ||
| 133 | |||
| 134 | .app-info { | ||
| 135 | margin-bottom: 0.5rem; | ||
| 136 | } | ||
| 137 | |||
| 138 | .app-info-label { | ||
| 139 | color: #7f8c8d; | ||
| 140 | font-size: 0.85rem; | ||
| 141 | font-weight: 500; | ||
| 142 | margin-bottom: 0.25rem; | ||
| 143 | } | ||
| 144 | |||
| 145 | .app-info-value { | ||
| 146 | color: #2c3e50; | ||
| 147 | font-family: 'Monaco', 'Courier New', monospace; | ||
| 148 | font-size: 0.9rem; | ||
| 149 | word-break: break-all; | ||
| 150 | } | ||
| 151 | |||
| 152 | .app-info-value a { | ||
| 153 | color: #3498db; | ||
| 154 | text-decoration: none; | ||
| 155 | } | ||
| 156 | |||
| 157 | .app-info-value a:hover { | ||
| 158 | text-decoration: underline; | ||
| 159 | } | ||
| 160 | |||
| 161 | .config-buttons { | ||
| 162 | margin-top: 1rem; | ||
| 163 | padding-top: 1rem; | ||
| 164 | border-top: 1px solid #ecf0f1; | ||
| 165 | display: flex; | ||
| 166 | gap: 0.5rem; | ||
| 167 | flex-wrap: wrap; | ||
| 168 | } | ||
| 169 | |||
| 170 | .config-btn { | ||
| 171 | padding: 0.4rem 0.8rem; | ||
| 172 | background: #3498db; | ||
| 173 | color: white; | ||
| 174 | border: none; | ||
| 175 | border-radius: 4px; | ||
| 176 | font-size: 0.8rem; | ||
| 177 | cursor: pointer; | ||
| 178 | transition: background 0.2s; | ||
| 179 | } | ||
| 180 | |||
| 181 | .config-btn:hover { | ||
| 182 | background: #2980b9; | ||
| 183 | } | ||
| 184 | |||
| 185 | .config-btn.secondary { | ||
| 186 | background: #95a5a6; | ||
| 187 | } | ||
| 188 | |||
| 189 | .config-btn.secondary:hover { | ||
| 190 | background: #7f8c8d; | ||
| 191 | } | ||
| 192 | |||
| 193 | .modal { | ||
| 194 | display: none; | ||
| 195 | position: fixed; | ||
| 196 | z-index: 1000; | ||
| 197 | left: 0; | ||
| 198 | top: 0; | ||
| 199 | width: 100%; | ||
| 200 | height: 100%; | ||
| 201 | overflow: auto; | ||
| 202 | background-color: rgba(0,0,0,0.6); | ||
| 203 | } | ||
| 204 | |||
| 205 | .modal.active { | ||
| 206 | display: block; | ||
| 207 | } | ||
| 208 | |||
| 209 | .modal-content { | ||
| 210 | background-color: #fefefe; | ||
| 211 | margin: 5% auto; | ||
| 212 | padding: 0; | ||
| 213 | border-radius: 8px; | ||
| 214 | width: 90%; | ||
| 215 | max-width: 900px; | ||
| 216 | max-height: 80vh; | ||
| 217 | display: flex; | ||
| 218 | flex-direction: column; | ||
| 219 | box-shadow: 0 4px 20px rgba(0,0,0,0.3); | ||
| 220 | } | ||
| 221 | |||
| 222 | .modal-header { | ||
| 223 | padding: 1.5rem; | ||
| 224 | border-bottom: 1px solid #ecf0f1; | ||
| 225 | display: flex; | ||
| 226 | justify-content: space-between; | ||
| 227 | align-items: center; | ||
| 228 | } | ||
| 229 | |||
| 230 | .modal-header h3 { | ||
| 231 | margin: 0; | ||
| 232 | color: #2c3e50; | ||
| 233 | } | ||
| 234 | |||
| 235 | .modal-path { | ||
| 236 | font-family: 'Monaco', 'Courier New', monospace; | ||
| 237 | font-size: 0.85rem; | ||
| 238 | color: #7f8c8d; | ||
| 239 | margin-top: 0.25rem; | ||
| 240 | } | ||
| 241 | |||
| 242 | .close { | ||
| 243 | color: #aaa; | ||
| 244 | font-size: 28px; | ||
| 245 | font-weight: bold; | ||
| 246 | cursor: pointer; | ||
| 247 | line-height: 1; | ||
| 248 | } | ||
| 249 | |||
| 250 | .close:hover { | ||
| 251 | color: #000; | ||
| 252 | } | ||
| 253 | |||
| 254 | .modal-body { | ||
| 255 | padding: 1.5rem; | ||
| 256 | overflow: auto; | ||
| 257 | flex: 1; | ||
| 258 | } | ||
| 259 | |||
| 260 | .config-content { | ||
| 261 | background: #282c34; | ||
| 262 | color: #abb2bf; | ||
| 263 | padding: 1rem; | ||
| 264 | border-radius: 4px; | ||
| 265 | font-family: 'Monaco', 'Courier New', monospace; | ||
| 266 | font-size: 0.85rem; | ||
| 267 | line-height: 1.5; | ||
| 268 | white-space: pre-wrap; | ||
| 269 | word-wrap: break-word; | ||
| 270 | overflow-x: auto; | ||
| 271 | text-align: left; | ||
| 272 | } | ||
| 273 | |||
| 274 | .loading { | ||
| 275 | text-align: center; | ||
| 276 | padding: 2rem; | ||
| 277 | color: #7f8c8d; | ||
| 278 | } | ||
| 279 | |||
| 280 | .refresh-info { | ||
| 281 | text-align: center; | ||
| 282 | color: #7f8c8d; | ||
| 283 | font-size: 0.9rem; | ||
| 284 | margin-top: 2rem; | ||
| 285 | padding: 1rem; | ||
| 286 | } | ||
| 287 | </style> | ||
| 288 | </head> | ||
| 289 | <body> | ||
| 290 | <header> | ||
| 291 | <h1>Deploy Web UI</h1> | ||
| 292 | <p>Manage your VPS deployments</p> | ||
| 293 | </header> | ||
| 294 | |||
| 295 | <div class="container"> | ||
| 296 | {{if not .Hosts}} | ||
| 297 | <div class="empty-state"> | ||
| 298 | <h2>No deployments found</h2> | ||
| 299 | <p>Use the CLI to deploy your first app or static site</p> | ||
| 300 | </div> | ||
| 301 | {{else}} | ||
| 302 | {{range .Hosts}} | ||
| 303 | <div class="host-section"> | ||
| 304 | <div class="host-header"> | ||
| 305 | <h2>{{.Host}}</h2> | ||
| 306 | </div> | ||
| 307 | <div class="apps-grid"> | ||
| 308 | {{range .Apps}} | ||
| 309 | <div class="app-card"> | ||
| 310 | <div class="app-header"> | ||
| 311 | <div class="app-name">{{.Name}}</div> | ||
| 312 | <div class="app-type {{.Type}}">{{.Type}}</div> | ||
| 313 | </div> | ||
| 314 | |||
| 315 | <div class="app-info"> | ||
| 316 | <div class="app-info-label">Domain</div> | ||
| 317 | <div class="app-info-value"> | ||
| 318 | <a href="https://{{.Domain}}" target="_blank">{{.Domain}}</a> | ||
| 319 | </div> | ||
| 320 | </div> | ||
| 321 | |||
| 322 | {{if eq .Type "app"}} | ||
| 323 | <div class="app-info"> | ||
| 324 | <div class="app-info-label">Port</div> | ||
| 325 | <div class="app-info-value">{{.Port}}</div> | ||
| 326 | </div> | ||
| 327 | {{end}} | ||
| 328 | |||
| 329 | <div class="config-buttons"> | ||
| 330 | {{if eq .Type "app"}} | ||
| 331 | <button class="config-btn" onclick="showConfig('{{.Host}}', '{{.Name}}', 'systemd')">Systemd Unit</button> | ||
| 332 | {{end}} | ||
| 333 | <button class="config-btn" onclick="showConfig('{{.Host}}', '{{.Name}}', 'caddy')">Caddy Config</button> | ||
| 334 | {{if .Env}} | ||
| 335 | <button class="config-btn secondary" onclick="showConfig('{{.Host}}', '{{.Name}}', 'env')">Environment</button> | ||
| 336 | {{end}} | ||
| 337 | </div> | ||
| 338 | </div> | ||
| 339 | {{end}} | ||
| 340 | </div> | ||
| 341 | </div> | ||
| 342 | {{end}} | ||
| 343 | {{end}} | ||
| 344 | |||
| 345 | <div class="refresh-info"> | ||
| 346 | Refresh the page to see latest changes | ||
| 347 | </div> | ||
| 348 | </div> | ||
| 349 | |||
| 350 | <!-- Modal --> | ||
| 351 | <div id="configModal" class="modal"> | ||
| 352 | <div class="modal-content"> | ||
| 353 | <div class="modal-header"> | ||
| 354 | <div> | ||
| 355 | <h3 id="modalTitle">Configuration</h3> | ||
| 356 | <div class="modal-path" id="modalPath"></div> | ||
| 357 | </div> | ||
| 358 | <span class="close" onclick="closeModal()">×</span> | ||
| 359 | </div> | ||
| 360 | <div class="modal-body"> | ||
| 361 | <div id="modalContent" class="loading">Loading...</div> | ||
| 362 | </div> | ||
| 363 | </div> | ||
| 364 | </div> | ||
| 365 | |||
| 366 | <script> | ||
| 367 | const modal = document.getElementById('configModal'); | ||
| 368 | const modalTitle = document.getElementById('modalTitle'); | ||
| 369 | const modalPath = document.getElementById('modalPath'); | ||
| 370 | const modalContent = document.getElementById('modalContent'); | ||
| 371 | |||
| 372 | function closeModal() { | ||
| 373 | modal.classList.remove('active'); | ||
| 374 | } | ||
| 375 | |||
| 376 | window.onclick = function(event) { | ||
| 377 | if (event.target == modal) { | ||
| 378 | closeModal(); | ||
| 379 | } | ||
| 380 | } | ||
| 381 | |||
| 382 | async function showConfig(host, app, type) { | ||
| 383 | modal.classList.add('active'); | ||
| 384 | modalContent.innerHTML = '<div class="loading">Loading...</div>'; | ||
| 385 | |||
| 386 | const titles = { | ||
| 387 | 'systemd': 'Systemd Service Unit', | ||
| 388 | 'caddy': 'Caddy Configuration', | ||
| 389 | 'env': 'Environment Variables' | ||
| 390 | }; | ||
| 391 | |||
| 392 | modalTitle.textContent = titles[type]; | ||
| 393 | |||
| 394 | try { | ||
| 395 | const response = await fetch(`/api/configs?host=${encodeURIComponent(host)}&app=${encodeURIComponent(app)}`); | ||
| 396 | if (!response.ok) { | ||
| 397 | throw new Error(`HTTP error! status: ${response.status}`); | ||
| 398 | } | ||
| 399 | const configs = await response.json(); | ||
| 400 | |||
| 401 | let content = ''; | ||
| 402 | let path = ''; | ||
| 403 | |||
| 404 | switch(type) { | ||
| 405 | case 'systemd': | ||
| 406 | content = configs.systemd || 'No systemd config available'; | ||
| 407 | path = configs.systemdPath || ''; | ||
| 408 | break; | ||
| 409 | case 'caddy': | ||
| 410 | content = configs.caddy || 'No Caddy config available'; | ||
| 411 | path = configs.caddyPath || ''; | ||
| 412 | break; | ||
| 413 | case 'env': | ||
| 414 | content = configs.env || 'No environment variables'; | ||
| 415 | path = configs.envPath || ''; | ||
| 416 | break; | ||
| 417 | } | ||
| 418 | |||
| 419 | modalPath.textContent = path; | ||
| 420 | modalContent.innerHTML = `<div class="config-content">${escapeHtml(content)}</div>`; | ||
| 421 | } catch (error) { | ||
| 422 | modalContent.innerHTML = `<div class="loading">Error loading config: ${error.message}</div>`; | ||
| 423 | } | ||
| 424 | } | ||
| 425 | |||
| 426 | function escapeHtml(text) { | ||
| 427 | const div = document.createElement('div'); | ||
| 428 | div.textContent = text; | ||
| 429 | return div.innerHTML; | ||
| 430 | } | ||
| 431 | |||
| 432 | // Close modal with Escape key | ||
| 433 | document.addEventListener('keydown', function(event) { | ||
| 434 | if (event.key === 'Escape') { | ||
| 435 | closeModal(); | ||
| 436 | } | ||
| 437 | }); | ||
| 438 | </script> | ||
| 439 | </body> | ||
| 440 | </html> | ||
diff --git a/cmd/deploy/webui.go b/cmd/deploy/webui.go new file mode 100644 index 0000000..f57400e --- /dev/null +++ b/cmd/deploy/webui.go | |||
| @@ -0,0 +1,228 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "embed" | ||
| 5 | "encoding/json" | ||
| 6 | "flag" | ||
| 7 | "fmt" | ||
| 8 | "html/template" | ||
| 9 | "net/http" | ||
| 10 | "os" | ||
| 11 | "sort" | ||
| 12 | "strconv" | ||
| 13 | |||
| 14 | "github.com/bdw/deploy/internal/state" | ||
| 15 | "github.com/bdw/deploy/internal/templates" | ||
| 16 | ) | ||
| 17 | |||
| 18 | //go:embed templates/*.html | ||
| 19 | var templatesFS embed.FS | ||
| 20 | |||
| 21 | func runWebUI(args []string) { | ||
| 22 | fs := flag.NewFlagSet("webui", flag.ExitOnError) | ||
| 23 | port := fs.String("port", "8080", "Port to run the web UI on") | ||
| 24 | help := fs.Bool("h", false, "Show help") | ||
| 25 | |||
| 26 | fs.Parse(args) | ||
| 27 | |||
| 28 | if *help { | ||
| 29 | fmt.Fprintf(os.Stderr, `Usage: deploy webui [flags] | ||
| 30 | |||
| 31 | Launch a web interface to view and manage deployments. | ||
| 32 | |||
| 33 | FLAGS: | ||
| 34 | -port string | ||
| 35 | Port to run the web UI on (default "8080") | ||
| 36 | -h Show this help message | ||
| 37 | |||
| 38 | EXAMPLE: | ||
| 39 | # Launch web UI on default port (8080) | ||
| 40 | deploy webui | ||
| 41 | |||
| 42 | # Launch on custom port | ||
| 43 | deploy webui -port 3000 | ||
| 44 | `) | ||
| 45 | os.Exit(0) | ||
| 46 | } | ||
| 47 | |||
| 48 | // Parse template | ||
| 49 | tmpl, err := template.ParseFS(templatesFS, "templates/webui.html") | ||
| 50 | if err != nil { | ||
| 51 | fmt.Fprintf(os.Stderr, "Error parsing template: %v\n", err) | ||
| 52 | os.Exit(1) | ||
| 53 | } | ||
| 54 | |||
| 55 | // Handler for the main page | ||
| 56 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { | ||
| 57 | // Reload state on each request to show latest changes | ||
| 58 | st, err := state.Load() | ||
| 59 | if err != nil { | ||
| 60 | http.Error(w, fmt.Sprintf("Error loading state: %v", err), http.StatusInternalServerError) | ||
| 61 | return | ||
| 62 | } | ||
| 63 | |||
| 64 | // Prepare data for template | ||
| 65 | type AppData struct { | ||
| 66 | Name string | ||
| 67 | Type string | ||
| 68 | Domain string | ||
| 69 | Port int | ||
| 70 | Env map[string]string | ||
| 71 | Host string | ||
| 72 | } | ||
| 73 | |||
| 74 | type HostData struct { | ||
| 75 | Host string | ||
| 76 | Apps []AppData | ||
| 77 | } | ||
| 78 | |||
| 79 | var hosts []HostData | ||
| 80 | for hostName, host := range st.Hosts { | ||
| 81 | var apps []AppData | ||
| 82 | for appName, app := range host.Apps { | ||
| 83 | apps = append(apps, AppData{ | ||
| 84 | Name: appName, | ||
| 85 | Type: app.Type, | ||
| 86 | Domain: app.Domain, | ||
| 87 | Port: app.Port, | ||
| 88 | Env: app.Env, | ||
| 89 | Host: hostName, | ||
| 90 | }) | ||
| 91 | } | ||
| 92 | |||
| 93 | // Sort apps by name | ||
| 94 | sort.Slice(apps, func(i, j int) bool { | ||
| 95 | return apps[i].Name < apps[j].Name | ||
| 96 | }) | ||
| 97 | |||
| 98 | hosts = append(hosts, HostData{ | ||
| 99 | Host: hostName, | ||
| 100 | Apps: apps, | ||
| 101 | }) | ||
| 102 | } | ||
| 103 | |||
| 104 | // Sort hosts by name | ||
| 105 | sort.Slice(hosts, func(i, j int) bool { | ||
| 106 | return hosts[i].Host < hosts[j].Host | ||
| 107 | }) | ||
| 108 | |||
| 109 | data := struct { | ||
| 110 | Hosts []HostData | ||
| 111 | }{ | ||
| 112 | Hosts: hosts, | ||
| 113 | } | ||
| 114 | |||
| 115 | if err := tmpl.Execute(w, data); err != nil { | ||
| 116 | http.Error(w, fmt.Sprintf("Error rendering template: %v", err), http.StatusInternalServerError) | ||
| 117 | return | ||
| 118 | } | ||
| 119 | }) | ||
| 120 | |||
| 121 | // API endpoint to get state as JSON | ||
| 122 | http.HandleFunc("/api/state", func(w http.ResponseWriter, r *http.Request) { | ||
| 123 | st, err := state.Load() | ||
| 124 | if err != nil { | ||
| 125 | http.Error(w, fmt.Sprintf("Error loading state: %v", err), http.StatusInternalServerError) | ||
| 126 | return | ||
| 127 | } | ||
| 128 | |||
| 129 | w.Header().Set("Content-Type", "application/json") | ||
| 130 | json.NewEncoder(w).Encode(st) | ||
| 131 | }) | ||
| 132 | |||
| 133 | // API endpoint to get rendered configs for an app | ||
| 134 | http.HandleFunc("/api/configs", func(w http.ResponseWriter, r *http.Request) { | ||
| 135 | host := r.URL.Query().Get("host") | ||
| 136 | appName := r.URL.Query().Get("app") | ||
| 137 | |||
| 138 | if host == "" || appName == "" { | ||
| 139 | http.Error(w, "Missing host or app parameter", http.StatusBadRequest) | ||
| 140 | return | ||
| 141 | } | ||
| 142 | |||
| 143 | st, err := state.Load() | ||
| 144 | if err != nil { | ||
| 145 | http.Error(w, fmt.Sprintf("Error loading state: %v", err), http.StatusInternalServerError) | ||
| 146 | return | ||
| 147 | } | ||
| 148 | |||
| 149 | app, err := st.GetApp(host, appName) | ||
| 150 | if err != nil { | ||
| 151 | http.Error(w, fmt.Sprintf("App not found: %v", err), http.StatusNotFound) | ||
| 152 | return | ||
| 153 | } | ||
| 154 | |||
| 155 | configs := make(map[string]string) | ||
| 156 | |||
| 157 | // Render environment file | ||
| 158 | if app.Env != nil && len(app.Env) > 0 { | ||
| 159 | envContent := "" | ||
| 160 | for k, v := range app.Env { | ||
| 161 | envContent += fmt.Sprintf("%s=%s\n", k, v) | ||
| 162 | } | ||
| 163 | configs["env"] = envContent | ||
| 164 | configs["envPath"] = fmt.Sprintf("/etc/deploy/env/%s.env", appName) | ||
| 165 | } | ||
| 166 | |||
| 167 | // Render configs based on app type | ||
| 168 | if app.Type == "app" { | ||
| 169 | // Render systemd service | ||
| 170 | workDir := fmt.Sprintf("/var/lib/%s", appName) | ||
| 171 | binaryPath := fmt.Sprintf("/usr/local/bin/%s", appName) | ||
| 172 | envFilePath := fmt.Sprintf("/etc/deploy/env/%s.env", appName) | ||
| 173 | |||
| 174 | serviceContent, err := templates.SystemdService(map[string]string{ | ||
| 175 | "Name": appName, | ||
| 176 | "User": appName, | ||
| 177 | "WorkDir": workDir, | ||
| 178 | "BinaryPath": binaryPath, | ||
| 179 | "Port": strconv.Itoa(app.Port), | ||
| 180 | "EnvFile": envFilePath, | ||
| 181 | "Args": app.Args, | ||
| 182 | }) | ||
| 183 | if err != nil { | ||
| 184 | http.Error(w, fmt.Sprintf("Error rendering systemd service: %v", err), http.StatusInternalServerError) | ||
| 185 | return | ||
| 186 | } | ||
| 187 | configs["systemd"] = serviceContent | ||
| 188 | configs["systemdPath"] = fmt.Sprintf("/etc/systemd/system/%s.service", appName) | ||
| 189 | |||
| 190 | // Render Caddy config | ||
| 191 | caddyContent, err := templates.AppCaddy(map[string]string{ | ||
| 192 | "Domain": app.Domain, | ||
| 193 | "Port": strconv.Itoa(app.Port), | ||
| 194 | }) | ||
| 195 | if err != nil { | ||
| 196 | http.Error(w, fmt.Sprintf("Error rendering Caddy config: %v", err), http.StatusInternalServerError) | ||
| 197 | return | ||
| 198 | } | ||
| 199 | configs["caddy"] = caddyContent | ||
| 200 | configs["caddyPath"] = fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", appName) | ||
| 201 | } else if app.Type == "static" { | ||
| 202 | // Render Caddy config for static site | ||
| 203 | remoteDir := fmt.Sprintf("/var/www/%s", appName) | ||
| 204 | caddyContent, err := templates.StaticCaddy(map[string]string{ | ||
| 205 | "Domain": app.Domain, | ||
| 206 | "RootDir": remoteDir, | ||
| 207 | }) | ||
| 208 | if err != nil { | ||
| 209 | http.Error(w, fmt.Sprintf("Error rendering Caddy config: %v", err), http.StatusInternalServerError) | ||
| 210 | return | ||
| 211 | } | ||
| 212 | configs["caddy"] = caddyContent | ||
| 213 | configs["caddyPath"] = fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", appName) | ||
| 214 | } | ||
| 215 | |||
| 216 | w.Header().Set("Content-Type", "application/json") | ||
| 217 | json.NewEncoder(w).Encode(configs) | ||
| 218 | }) | ||
| 219 | |||
| 220 | addr := fmt.Sprintf("localhost:%s", *port) | ||
| 221 | fmt.Printf("Starting web UI on http://%s\n", addr) | ||
| 222 | fmt.Printf("Press Ctrl+C to stop\n") | ||
| 223 | |||
| 224 | if err := http.ListenAndServe(addr, nil); err != nil { | ||
| 225 | fmt.Fprintf(os.Stderr, "Error starting server: %v\n", err) | ||
| 226 | os.Exit(1) | ||
| 227 | } | ||
| 228 | } | ||
