diff options
Diffstat (limited to 'cmd/deploy')
| -rw-r--r-- | cmd/deploy/deploy.go | 482 | ||||
| -rw-r--r-- | cmd/deploy/env.go | 170 | ||||
| -rw-r--r-- | cmd/deploy/env/env.go | 17 | ||||
| -rw-r--r-- | cmd/deploy/env/list.go | 69 | ||||
| -rw-r--r-- | cmd/deploy/env/set.go | 132 | ||||
| -rw-r--r-- | cmd/deploy/env/unset.go | 92 | ||||
| -rw-r--r-- | cmd/deploy/host/host.go | 18 | ||||
| -rw-r--r-- | cmd/deploy/host/init.go | 137 | ||||
| -rw-r--r-- | cmd/deploy/host/ssh.go | 45 | ||||
| -rw-r--r-- | cmd/deploy/host/status.go | 108 | ||||
| -rw-r--r-- | cmd/deploy/host/update.go | 80 | ||||
| -rw-r--r-- | cmd/deploy/init.go | 154 | ||||
| -rw-r--r-- | cmd/deploy/list.go | 36 | ||||
| -rw-r--r-- | cmd/deploy/logs.go | 75 | ||||
| -rw-r--r-- | cmd/deploy/main.go | 130 | ||||
| -rw-r--r-- | cmd/deploy/manage.go | 306 | ||||
| -rw-r--r-- | cmd/deploy/remove.go | 83 | ||||
| -rw-r--r-- | cmd/deploy/restart.go | 57 | ||||
| -rw-r--r-- | cmd/deploy/root.go | 377 | ||||
| -rw-r--r-- | cmd/deploy/status.go | 60 | ||||
| -rw-r--r-- | cmd/deploy/ui.go (renamed from cmd/deploy/webui.go) | 59 | ||||
| -rw-r--r-- | cmd/deploy/version.go | 17 | ||||
| -rw-r--r-- | cmd/deploy/vps.go | 229 |
23 files changed, 1450 insertions, 1483 deletions
diff --git a/cmd/deploy/deploy.go b/cmd/deploy/deploy.go deleted file mode 100644 index 31aabe5..0000000 --- a/cmd/deploy/deploy.go +++ /dev/null | |||
| @@ -1,482 +0,0 @@ | |||
| 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/ssh" | ||
| 13 | "github.com/bdw/deploy/internal/state" | ||
| 14 | "github.com/bdw/deploy/internal/templates" | ||
| 15 | ) | ||
| 16 | |||
| 17 | type envFlags []string | ||
| 18 | |||
| 19 | func (e *envFlags) String() string { | ||
| 20 | return strings.Join(*e, ",") | ||
| 21 | } | ||
| 22 | |||
| 23 | func (e *envFlags) Set(value string) error { | ||
| 24 | *e = append(*e, value) | ||
| 25 | return nil | ||
| 26 | } | ||
| 27 | |||
| 28 | type fileFlags []string | ||
| 29 | |||
| 30 | func (f *fileFlags) String() string { | ||
| 31 | return strings.Join(*f, ",") | ||
| 32 | } | ||
| 33 | |||
| 34 | func (f *fileFlags) Set(value string) error { | ||
| 35 | *f = append(*f, value) | ||
| 36 | return nil | ||
| 37 | } | ||
| 38 | |||
| 39 | func runDeploy(args []string) { | ||
| 40 | fs := flag.NewFlagSet("deploy", flag.ExitOnError) | ||
| 41 | host := fs.String("host", "", "VPS host (SSH config alias or user@host)") | ||
| 42 | domain := fs.String("domain", "", "Domain name (required)") | ||
| 43 | name := fs.String("name", "", "App name (default: inferred from binary or directory)") | ||
| 44 | binary := fs.String("binary", "", "Path to Go binary (for app deployment)") | ||
| 45 | static := fs.Bool("static", false, "Deploy as static site") | ||
| 46 | dir := fs.String("dir", ".", "Directory to deploy (for static sites)") | ||
| 47 | port := fs.Int("port", 0, "Port override (default: auto-allocate)") | ||
| 48 | var envVars envFlags | ||
| 49 | fs.Var(&envVars, "env", "Environment variable (KEY=VALUE, can be specified multiple times)") | ||
| 50 | envFile := fs.String("env-file", "", "Path to .env file") | ||
| 51 | binaryArgs := fs.String("args", "", "Arguments to pass to binary") | ||
| 52 | var files fileFlags | ||
| 53 | fs.Var(&files, "file", "Config file to upload to working directory (can be specified multiple times)") | ||
| 54 | |||
| 55 | fs.Parse(args) | ||
| 56 | |||
| 57 | // Get host from flag or state default | ||
| 58 | if *host == "" { | ||
| 59 | st, err := state.Load() | ||
| 60 | if err != nil { | ||
| 61 | fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) | ||
| 62 | os.Exit(1) | ||
| 63 | } | ||
| 64 | *host = st.GetDefaultHost() | ||
| 65 | } | ||
| 66 | |||
| 67 | if *host == "" || *domain == "" { | ||
| 68 | fmt.Fprintf(os.Stderr, "Error: --host and --domain are required\n") | ||
| 69 | fs.Usage() | ||
| 70 | os.Exit(1) | ||
| 71 | } | ||
| 72 | |||
| 73 | if *static { | ||
| 74 | deployStatic(*host, *domain, *name, *dir) | ||
| 75 | } else { | ||
| 76 | deployApp(*host, *domain, *name, *binary, *port, envVars, *envFile, *binaryArgs, files) | ||
| 77 | } | ||
| 78 | } | ||
| 79 | |||
| 80 | func deployApp(host, domain, name, binaryPath string, portOverride int, envVars []string, envFile, args string, files []string) { | ||
| 81 | // Require binary path | ||
| 82 | if binaryPath == "" { | ||
| 83 | fmt.Fprintf(os.Stderr, "Error: --binary is required\n") | ||
| 84 | os.Exit(1) | ||
| 85 | } | ||
| 86 | |||
| 87 | // Determine app name from binary if not specified | ||
| 88 | if name == "" { | ||
| 89 | name = filepath.Base(binaryPath) | ||
| 90 | } | ||
| 91 | |||
| 92 | // Verify binary exists | ||
| 93 | if _, err := os.Stat(binaryPath); err != nil { | ||
| 94 | fmt.Fprintf(os.Stderr, "Error: binary not found: %s\n", binaryPath) | ||
| 95 | os.Exit(1) | ||
| 96 | } | ||
| 97 | |||
| 98 | fmt.Printf("Deploying app: %s\n", name) | ||
| 99 | fmt.Printf(" Domain: %s\n", domain) | ||
| 100 | fmt.Printf(" Binary: %s\n", binaryPath) | ||
| 101 | |||
| 102 | // Load state | ||
| 103 | st, err := state.Load() | ||
| 104 | if err != nil { | ||
| 105 | fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) | ||
| 106 | os.Exit(1) | ||
| 107 | } | ||
| 108 | |||
| 109 | // Check if app already exists (update) or new deployment | ||
| 110 | existingApp, _ := st.GetApp(host, name) | ||
| 111 | var port int | ||
| 112 | if existingApp != nil { | ||
| 113 | port = existingApp.Port | ||
| 114 | fmt.Printf(" Updating existing deployment (port %d)\n", port) | ||
| 115 | } else { | ||
| 116 | if portOverride > 0 { | ||
| 117 | port = portOverride | ||
| 118 | } else { | ||
| 119 | port = st.AllocatePort(host) | ||
| 120 | } | ||
| 121 | fmt.Printf(" Allocated port: %d\n", port) | ||
| 122 | } | ||
| 123 | |||
| 124 | // Parse environment variables | ||
| 125 | env := make(map[string]string) | ||
| 126 | if existingApp != nil { | ||
| 127 | // Preserve existing env vars | ||
| 128 | for k, v := range existingApp.Env { | ||
| 129 | env[k] = v | ||
| 130 | } | ||
| 131 | // Preserve existing args if not provided | ||
| 132 | if args == "" && existingApp.Args != "" { | ||
| 133 | args = existingApp.Args | ||
| 134 | } | ||
| 135 | // Preserve existing files if not provided | ||
| 136 | if len(files) == 0 && len(existingApp.Files) > 0 { | ||
| 137 | files = existingApp.Files | ||
| 138 | } | ||
| 139 | } | ||
| 140 | |||
| 141 | // Add/override from flags | ||
| 142 | for _, e := range envVars { | ||
| 143 | parts := strings.SplitN(e, "=", 2) | ||
| 144 | if len(parts) == 2 { | ||
| 145 | env[parts[0]] = parts[1] | ||
| 146 | } | ||
| 147 | } | ||
| 148 | |||
| 149 | // Add/override from file | ||
| 150 | if envFile != "" { | ||
| 151 | fileEnv, err := parseEnvFile(envFile) | ||
| 152 | if err != nil { | ||
| 153 | fmt.Fprintf(os.Stderr, "Error reading env file: %v\n", err) | ||
| 154 | os.Exit(1) | ||
| 155 | } | ||
| 156 | for k, v := range fileEnv { | ||
| 157 | env[k] = v | ||
| 158 | } | ||
| 159 | } | ||
| 160 | |||
| 161 | // Always set PORT | ||
| 162 | env["PORT"] = strconv.Itoa(port) | ||
| 163 | |||
| 164 | // Connect to VPS | ||
| 165 | client, err := ssh.Connect(host) | ||
| 166 | if err != nil { | ||
| 167 | fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err) | ||
| 168 | os.Exit(1) | ||
| 169 | } | ||
| 170 | defer client.Close() | ||
| 171 | |||
| 172 | // Upload binary | ||
| 173 | fmt.Println("→ Uploading binary...") | ||
| 174 | remoteTmpPath := fmt.Sprintf("/tmp/%s", name) | ||
| 175 | if err := client.Upload(binaryPath, remoteTmpPath); err != nil { | ||
| 176 | fmt.Fprintf(os.Stderr, "Error uploading binary: %v\n", err) | ||
| 177 | os.Exit(1) | ||
| 178 | } | ||
| 179 | |||
| 180 | // Create user (ignore error if already exists) | ||
| 181 | fmt.Println("→ Creating system user...") | ||
| 182 | client.RunSudo(fmt.Sprintf("useradd -r -s /bin/false %s", name)) | ||
| 183 | |||
| 184 | // Create working directory | ||
| 185 | fmt.Println("→ Setting up directories...") | ||
| 186 | workDir := fmt.Sprintf("/var/lib/%s", name) | ||
| 187 | if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", workDir)); err != nil { | ||
| 188 | fmt.Fprintf(os.Stderr, "Error creating work directory: %v\n", err) | ||
| 189 | os.Exit(1) | ||
| 190 | } | ||
| 191 | if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", name, name, workDir)); err != nil { | ||
| 192 | fmt.Fprintf(os.Stderr, "Error setting work directory ownership: %v\n", err) | ||
| 193 | os.Exit(1) | ||
| 194 | } | ||
| 195 | |||
| 196 | // Move binary to /usr/local/bin | ||
| 197 | fmt.Println("→ Installing binary...") | ||
| 198 | binaryDest := fmt.Sprintf("/usr/local/bin/%s", name) | ||
| 199 | if _, err := client.RunSudo(fmt.Sprintf("mv %s %s", remoteTmpPath, binaryDest)); err != nil { | ||
| 200 | fmt.Fprintf(os.Stderr, "Error moving binary: %v\n", err) | ||
| 201 | os.Exit(1) | ||
| 202 | } | ||
| 203 | if _, err := client.RunSudo(fmt.Sprintf("chmod +x %s", binaryDest)); err != nil { | ||
| 204 | fmt.Fprintf(os.Stderr, "Error making binary executable: %v\n", err) | ||
| 205 | os.Exit(1) | ||
| 206 | } | ||
| 207 | |||
| 208 | // Upload config files to working directory | ||
| 209 | if len(files) > 0 { | ||
| 210 | fmt.Println("→ Uploading config files...") | ||
| 211 | for _, file := range files { | ||
| 212 | // Verify file exists locally | ||
| 213 | if _, err := os.Stat(file); err != nil { | ||
| 214 | fmt.Fprintf(os.Stderr, "Error: config file not found: %s\n", file) | ||
| 215 | os.Exit(1) | ||
| 216 | } | ||
| 217 | |||
| 218 | // Determine remote path (preserve filename) | ||
| 219 | remotePath := fmt.Sprintf("%s/%s", workDir, filepath.Base(file)) | ||
| 220 | remoteTmpPath := fmt.Sprintf("/tmp/%s_%s", name, filepath.Base(file)) | ||
| 221 | |||
| 222 | // Upload to tmp first | ||
| 223 | if err := client.Upload(file, remoteTmpPath); err != nil { | ||
| 224 | fmt.Fprintf(os.Stderr, "Error uploading config file %s: %v\n", file, err) | ||
| 225 | os.Exit(1) | ||
| 226 | } | ||
| 227 | |||
| 228 | // Move to working directory | ||
| 229 | if _, err := client.RunSudo(fmt.Sprintf("mv %s %s", remoteTmpPath, remotePath)); err != nil { | ||
| 230 | fmt.Fprintf(os.Stderr, "Error moving config file %s: %v\n", file, err) | ||
| 231 | os.Exit(1) | ||
| 232 | } | ||
| 233 | |||
| 234 | // Set ownership | ||
| 235 | if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", name, name, remotePath)); err != nil { | ||
| 236 | fmt.Fprintf(os.Stderr, "Error setting config file ownership %s: %v\n", file, err) | ||
| 237 | os.Exit(1) | ||
| 238 | } | ||
| 239 | |||
| 240 | fmt.Printf(" Uploaded: %s\n", file) | ||
| 241 | } | ||
| 242 | } | ||
| 243 | |||
| 244 | // Create env file | ||
| 245 | fmt.Println("→ Creating environment file...") | ||
| 246 | envFilePath := fmt.Sprintf("/etc/deploy/env/%s.env", name) | ||
| 247 | envContent := "" | ||
| 248 | for k, v := range env { | ||
| 249 | envContent += fmt.Sprintf("%s=%s\n", k, v) | ||
| 250 | } | ||
| 251 | if err := client.WriteSudoFile(envFilePath, envContent); err != nil { | ||
| 252 | fmt.Fprintf(os.Stderr, "Error creating env file: %v\n", err) | ||
| 253 | os.Exit(1) | ||
| 254 | } | ||
| 255 | if _, err := client.RunSudo(fmt.Sprintf("chmod 600 %s", envFilePath)); err != nil { | ||
| 256 | fmt.Fprintf(os.Stderr, "Error setting env file permissions: %v\n", err) | ||
| 257 | os.Exit(1) | ||
| 258 | } | ||
| 259 | if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", name, name, envFilePath)); err != nil { | ||
| 260 | fmt.Fprintf(os.Stderr, "Error setting env file ownership: %v\n", err) | ||
| 261 | os.Exit(1) | ||
| 262 | } | ||
| 263 | |||
| 264 | // Generate systemd unit | ||
| 265 | fmt.Println("→ Creating systemd service...") | ||
| 266 | serviceContent, err := templates.SystemdService(map[string]string{ | ||
| 267 | "Name": name, | ||
| 268 | "User": name, | ||
| 269 | "WorkDir": workDir, | ||
| 270 | "BinaryPath": binaryDest, | ||
| 271 | "Port": strconv.Itoa(port), | ||
| 272 | "EnvFile": envFilePath, | ||
| 273 | "Args": args, | ||
| 274 | }) | ||
| 275 | if err != nil { | ||
| 276 | fmt.Fprintf(os.Stderr, "Error generating systemd unit: %v\n", err) | ||
| 277 | os.Exit(1) | ||
| 278 | } | ||
| 279 | |||
| 280 | servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", name) | ||
| 281 | if err := client.WriteSudoFile(servicePath, serviceContent); err != nil { | ||
| 282 | fmt.Fprintf(os.Stderr, "Error creating systemd unit: %v\n", err) | ||
| 283 | os.Exit(1) | ||
| 284 | } | ||
| 285 | |||
| 286 | // Generate Caddy config | ||
| 287 | fmt.Println("→ Configuring Caddy...") | ||
| 288 | caddyContent, err := templates.AppCaddy(map[string]string{ | ||
| 289 | "Domain": domain, | ||
| 290 | "Port": strconv.Itoa(port), | ||
| 291 | }) | ||
| 292 | if err != nil { | ||
| 293 | fmt.Fprintf(os.Stderr, "Error generating Caddy config: %v\n", err) | ||
| 294 | os.Exit(1) | ||
| 295 | } | ||
| 296 | |||
| 297 | caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) | ||
| 298 | if err := client.WriteSudoFile(caddyPath, caddyContent); err != nil { | ||
| 299 | fmt.Fprintf(os.Stderr, "Error creating Caddy config: %v\n", err) | ||
| 300 | os.Exit(1) | ||
| 301 | } | ||
| 302 | |||
| 303 | // Reload systemd | ||
| 304 | fmt.Println("→ Reloading systemd...") | ||
| 305 | if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { | ||
| 306 | fmt.Fprintf(os.Stderr, "Error reloading systemd: %v\n", err) | ||
| 307 | os.Exit(1) | ||
| 308 | } | ||
| 309 | |||
| 310 | // Enable and start service | ||
| 311 | fmt.Println("→ Starting service...") | ||
| 312 | if _, err := client.RunSudo(fmt.Sprintf("systemctl enable %s", name)); err != nil { | ||
| 313 | fmt.Fprintf(os.Stderr, "Error enabling service: %v\n", err) | ||
| 314 | os.Exit(1) | ||
| 315 | } | ||
| 316 | if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { | ||
| 317 | fmt.Fprintf(os.Stderr, "Error starting service: %v\n", err) | ||
| 318 | os.Exit(1) | ||
| 319 | } | ||
| 320 | |||
| 321 | // Reload Caddy | ||
| 322 | fmt.Println("→ Reloading Caddy...") | ||
| 323 | if _, err := client.RunSudo("systemctl reload caddy"); err != nil { | ||
| 324 | fmt.Fprintf(os.Stderr, "Error reloading Caddy: %v\n", err) | ||
| 325 | os.Exit(1) | ||
| 326 | } | ||
| 327 | |||
| 328 | // Update state | ||
| 329 | st.AddApp(host, name, &state.App{ | ||
| 330 | Type: "app", | ||
| 331 | Domain: domain, | ||
| 332 | Port: port, | ||
| 333 | Env: env, | ||
| 334 | Args: args, | ||
| 335 | Files: files, | ||
| 336 | }) | ||
| 337 | if err := st.Save(); err != nil { | ||
| 338 | fmt.Fprintf(os.Stderr, "Error saving state: %v\n", err) | ||
| 339 | os.Exit(1) | ||
| 340 | } | ||
| 341 | |||
| 342 | fmt.Printf("\n✓ App deployed successfully!\n") | ||
| 343 | fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", domain) | ||
| 344 | } | ||
| 345 | |||
| 346 | func deployStatic(host, domain, name, dir string) { | ||
| 347 | // Determine site name (default to domain to avoid conflicts) | ||
| 348 | if name == "" { | ||
| 349 | name = domain | ||
| 350 | } | ||
| 351 | |||
| 352 | // Verify directory exists | ||
| 353 | if _, err := os.Stat(dir); err != nil { | ||
| 354 | fmt.Fprintf(os.Stderr, "Error: directory not found: %s\n", dir) | ||
| 355 | os.Exit(1) | ||
| 356 | } | ||
| 357 | |||
| 358 | fmt.Printf("Deploying static site: %s\n", name) | ||
| 359 | fmt.Printf(" Domain: %s\n", domain) | ||
| 360 | fmt.Printf(" Directory: %s\n", dir) | ||
| 361 | |||
| 362 | // Load state | ||
| 363 | st, err := state.Load() | ||
| 364 | if err != nil { | ||
| 365 | fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) | ||
| 366 | os.Exit(1) | ||
| 367 | } | ||
| 368 | |||
| 369 | // Connect to VPS | ||
| 370 | client, err := ssh.Connect(host) | ||
| 371 | if err != nil { | ||
| 372 | fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err) | ||
| 373 | os.Exit(1) | ||
| 374 | } | ||
| 375 | defer client.Close() | ||
| 376 | |||
| 377 | // Create remote directory | ||
| 378 | remoteDir := fmt.Sprintf("/var/www/%s", name) | ||
| 379 | fmt.Println("→ Creating remote directory...") | ||
| 380 | if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", remoteDir)); err != nil { | ||
| 381 | fmt.Fprintf(os.Stderr, "Error creating remote directory: %v\n", err) | ||
| 382 | os.Exit(1) | ||
| 383 | } | ||
| 384 | |||
| 385 | // Get current user for temporary ownership during upload | ||
| 386 | currentUser, err := client.Run("whoami") | ||
| 387 | if err != nil { | ||
| 388 | fmt.Fprintf(os.Stderr, "Error getting current user: %v\n", err) | ||
| 389 | os.Exit(1) | ||
| 390 | } | ||
| 391 | currentUser = strings.TrimSpace(currentUser) | ||
| 392 | |||
| 393 | // Set ownership to current user for upload | ||
| 394 | if _, err := client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", currentUser, currentUser, remoteDir)); err != nil { | ||
| 395 | fmt.Fprintf(os.Stderr, "Error setting temporary ownership: %v\n", err) | ||
| 396 | os.Exit(1) | ||
| 397 | } | ||
| 398 | |||
| 399 | // Upload files | ||
| 400 | fmt.Println("→ Uploading files...") | ||
| 401 | if err := client.UploadDir(dir, remoteDir); err != nil { | ||
| 402 | fmt.Fprintf(os.Stderr, "Error uploading files: %v\n", err) | ||
| 403 | os.Exit(1) | ||
| 404 | } | ||
| 405 | |||
| 406 | // Set ownership and permissions | ||
| 407 | fmt.Println("→ Setting permissions...") | ||
| 408 | if _, err := client.RunSudo(fmt.Sprintf("chown -R www-data:www-data %s", remoteDir)); err != nil { | ||
| 409 | fmt.Fprintf(os.Stderr, "Error setting ownership: %v\n", err) | ||
| 410 | os.Exit(1) | ||
| 411 | } | ||
| 412 | // Make files readable by all (755 for dirs, 644 for files) | ||
| 413 | if _, err := client.RunSudo(fmt.Sprintf("chmod -R 755 %s", remoteDir)); err != nil { | ||
| 414 | fmt.Fprintf(os.Stderr, "Error setting directory permissions: %v\n", err) | ||
| 415 | os.Exit(1) | ||
| 416 | } | ||
| 417 | if _, err := client.RunSudo(fmt.Sprintf("find %s -type f -exec chmod 644 {} \\;", remoteDir)); err != nil { | ||
| 418 | fmt.Fprintf(os.Stderr, "Error setting file permissions: %v\n", err) | ||
| 419 | os.Exit(1) | ||
| 420 | } | ||
| 421 | |||
| 422 | // Generate Caddy config | ||
| 423 | fmt.Println("→ Configuring Caddy...") | ||
| 424 | caddyContent, err := templates.StaticCaddy(map[string]string{ | ||
| 425 | "Domain": domain, | ||
| 426 | "RootDir": remoteDir, | ||
| 427 | }) | ||
| 428 | if err != nil { | ||
| 429 | fmt.Fprintf(os.Stderr, "Error generating Caddy config: %v\n", err) | ||
| 430 | os.Exit(1) | ||
| 431 | } | ||
| 432 | |||
| 433 | caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) | ||
| 434 | if err := client.WriteSudoFile(caddyPath, caddyContent); err != nil { | ||
| 435 | fmt.Fprintf(os.Stderr, "Error creating Caddy config: %v\n", err) | ||
| 436 | os.Exit(1) | ||
| 437 | } | ||
| 438 | |||
| 439 | // Reload Caddy | ||
| 440 | fmt.Println("→ Reloading Caddy...") | ||
| 441 | if _, err := client.RunSudo("systemctl reload caddy"); err != nil { | ||
| 442 | fmt.Fprintf(os.Stderr, "Error reloading Caddy: %v\n", err) | ||
| 443 | os.Exit(1) | ||
| 444 | } | ||
| 445 | |||
| 446 | // Update state | ||
| 447 | st.AddApp(host, name, &state.App{ | ||
| 448 | Type: "static", | ||
| 449 | Domain: domain, | ||
| 450 | }) | ||
| 451 | if err := st.Save(); err != nil { | ||
| 452 | fmt.Fprintf(os.Stderr, "Error saving state: %v\n", err) | ||
| 453 | os.Exit(1) | ||
| 454 | } | ||
| 455 | |||
| 456 | fmt.Printf("\n✓ Static site deployed successfully!\n") | ||
| 457 | fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", domain) | ||
| 458 | } | ||
| 459 | |||
| 460 | func parseEnvFile(path string) (map[string]string, error) { | ||
| 461 | file, err := os.Open(path) | ||
| 462 | if err != nil { | ||
| 463 | return nil, err | ||
| 464 | } | ||
| 465 | defer file.Close() | ||
| 466 | |||
| 467 | env := make(map[string]string) | ||
| 468 | scanner := bufio.NewScanner(file) | ||
| 469 | for scanner.Scan() { | ||
| 470 | line := strings.TrimSpace(scanner.Text()) | ||
| 471 | if line == "" || strings.HasPrefix(line, "#") { | ||
| 472 | continue | ||
| 473 | } | ||
| 474 | |||
| 475 | parts := strings.SplitN(line, "=", 2) | ||
| 476 | if len(parts) == 2 { | ||
| 477 | env[parts[0]] = parts[1] | ||
| 478 | } | ||
| 479 | } | ||
| 480 | |||
| 481 | return env, scanner.Err() | ||
| 482 | } | ||
diff --git a/cmd/deploy/env.go b/cmd/deploy/env.go deleted file mode 100644 index a43cd6a..0000000 --- a/cmd/deploy/env.go +++ /dev/null | |||
| @@ -1,170 +0,0 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "flag" | ||
| 5 | "fmt" | ||
| 6 | "os" | ||
| 7 | "strings" | ||
| 8 | |||
| 9 | "github.com/bdw/deploy/internal/ssh" | ||
| 10 | "github.com/bdw/deploy/internal/state" | ||
| 11 | ) | ||
| 12 | |||
| 13 | func runEnv(args []string) { | ||
| 14 | fs := flag.NewFlagSet("env", flag.ExitOnError) | ||
| 15 | host := fs.String("host", "", "VPS host (SSH config alias or user@host)") | ||
| 16 | var setVars envFlags | ||
| 17 | fs.Var(&setVars, "set", "Set environment variable (KEY=VALUE, can be specified multiple times)") | ||
| 18 | var unsetVars envFlags | ||
| 19 | fs.Var(&unsetVars, "unset", "Unset environment variable (KEY, can be specified multiple times)") | ||
| 20 | envFile := fs.String("file", "", "Load environment from file") | ||
| 21 | fs.Parse(args) | ||
| 22 | |||
| 23 | if len(fs.Args()) == 0 { | ||
| 24 | fmt.Fprintf(os.Stderr, "Error: app name is required\n") | ||
| 25 | fmt.Fprintf(os.Stderr, "Usage: deploy env <app-name> [--set KEY=VALUE] [--unset KEY] [--file .env] --host user@vps-ip\n") | ||
| 26 | os.Exit(1) | ||
| 27 | } | ||
| 28 | |||
| 29 | name := fs.Args()[0] | ||
| 30 | |||
| 31 | // Load state | ||
| 32 | st, err := state.Load() | ||
| 33 | if err != nil { | ||
| 34 | fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) | ||
| 35 | os.Exit(1) | ||
| 36 | } | ||
| 37 | |||
| 38 | // Get host from flag or state default | ||
| 39 | if *host == "" { | ||
| 40 | *host = st.GetDefaultHost() | ||
| 41 | } | ||
| 42 | |||
| 43 | if *host == "" { | ||
| 44 | fmt.Fprintf(os.Stderr, "Error: --host is required\n") | ||
| 45 | fs.Usage() | ||
| 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 | if app.Type != "app" { | ||
| 57 | fmt.Fprintf(os.Stderr, "Error: env is only available for apps, not static sites\n") | ||
| 58 | os.Exit(1) | ||
| 59 | } | ||
| 60 | |||
| 61 | // If no flags, just display current env (masked) | ||
| 62 | if len(setVars) == 0 && len(unsetVars) == 0 && *envFile == "" { | ||
| 63 | fmt.Printf("Environment variables for %s:\n\n", name) | ||
| 64 | if len(app.Env) == 0 { | ||
| 65 | fmt.Println(" (none)") | ||
| 66 | } else { | ||
| 67 | for k, v := range app.Env { | ||
| 68 | // Mask sensitive-looking values | ||
| 69 | display := v | ||
| 70 | if isSensitive(k) { | ||
| 71 | display = "***" | ||
| 72 | } | ||
| 73 | fmt.Printf(" %s=%s\n", k, display) | ||
| 74 | } | ||
| 75 | } | ||
| 76 | return | ||
| 77 | } | ||
| 78 | |||
| 79 | // Initialize env if nil | ||
| 80 | if app.Env == nil { | ||
| 81 | app.Env = make(map[string]string) | ||
| 82 | } | ||
| 83 | |||
| 84 | // Apply changes | ||
| 85 | changed := false | ||
| 86 | |||
| 87 | // Unset variables | ||
| 88 | for _, key := range unsetVars { | ||
| 89 | if _, exists := app.Env[key]; exists { | ||
| 90 | delete(app.Env, key) | ||
| 91 | changed = true | ||
| 92 | fmt.Printf("Unset %s\n", key) | ||
| 93 | } | ||
| 94 | } | ||
| 95 | |||
| 96 | // Set variables from flags | ||
| 97 | for _, e := range setVars { | ||
| 98 | parts := strings.SplitN(e, "=", 2) | ||
| 99 | if len(parts) == 2 { | ||
| 100 | app.Env[parts[0]] = parts[1] | ||
| 101 | changed = true | ||
| 102 | fmt.Printf("Set %s\n", parts[0]) | ||
| 103 | } | ||
| 104 | } | ||
| 105 | |||
| 106 | // Set variables from file | ||
| 107 | if *envFile != "" { | ||
| 108 | fileEnv, err := parseEnvFile(*envFile) | ||
| 109 | if err != nil { | ||
| 110 | fmt.Fprintf(os.Stderr, "Error reading env file: %v\n", err) | ||
| 111 | os.Exit(1) | ||
| 112 | } | ||
| 113 | for k, v := range fileEnv { | ||
| 114 | app.Env[k] = v | ||
| 115 | changed = true | ||
| 116 | fmt.Printf("Set %s\n", k) | ||
| 117 | } | ||
| 118 | } | ||
| 119 | |||
| 120 | if !changed { | ||
| 121 | fmt.Println("No changes made") | ||
| 122 | return | ||
| 123 | } | ||
| 124 | |||
| 125 | // Save state | ||
| 126 | if err := st.Save(); err != nil { | ||
| 127 | fmt.Fprintf(os.Stderr, "Error saving state: %v\n", err) | ||
| 128 | os.Exit(1) | ||
| 129 | } | ||
| 130 | |||
| 131 | // Connect to VPS and update env file | ||
| 132 | client, err := ssh.Connect(*host) | ||
| 133 | if err != nil { | ||
| 134 | fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err) | ||
| 135 | os.Exit(1) | ||
| 136 | } | ||
| 137 | defer client.Close() | ||
| 138 | |||
| 139 | // Regenerate env file | ||
| 140 | fmt.Println("→ Updating environment file on VPS...") | ||
| 141 | envFilePath := fmt.Sprintf("/etc/deploy/env/%s.env", name) | ||
| 142 | envContent := "" | ||
| 143 | for k, v := range app.Env { | ||
| 144 | envContent += fmt.Sprintf("%s=%s\n", k, v) | ||
| 145 | } | ||
| 146 | if err := client.WriteSudoFile(envFilePath, envContent); err != nil { | ||
| 147 | fmt.Fprintf(os.Stderr, "Error updating env file: %v\n", err) | ||
| 148 | os.Exit(1) | ||
| 149 | } | ||
| 150 | |||
| 151 | // Restart service to pick up new env | ||
| 152 | fmt.Println("→ Restarting service...") | ||
| 153 | if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { | ||
| 154 | fmt.Fprintf(os.Stderr, "Error restarting service: %v\n", err) | ||
| 155 | os.Exit(1) | ||
| 156 | } | ||
| 157 | |||
| 158 | fmt.Println("✓ Environment variables updated successfully") | ||
| 159 | } | ||
| 160 | |||
| 161 | func isSensitive(key string) bool { | ||
| 162 | key = strings.ToLower(key) | ||
| 163 | sensitiveWords := []string{"key", "secret", "password", "token", "api"} | ||
| 164 | for _, word := range sensitiveWords { | ||
| 165 | if strings.Contains(key, word) { | ||
| 166 | return true | ||
| 167 | } | ||
| 168 | } | ||
| 169 | return false | ||
| 170 | } | ||
diff --git a/cmd/deploy/env/env.go b/cmd/deploy/env/env.go new file mode 100644 index 0000000..489353a --- /dev/null +++ b/cmd/deploy/env/env.go | |||
| @@ -0,0 +1,17 @@ | |||
| 1 | package env | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "github.com/spf13/cobra" | ||
| 5 | ) | ||
| 6 | |||
| 7 | var Cmd = &cobra.Command{ | ||
| 8 | Use: "env", | ||
| 9 | Short: "Manage environment variables", | ||
| 10 | Long: "Manage environment variables for deployed applications", | ||
| 11 | } | ||
| 12 | |||
| 13 | func init() { | ||
| 14 | Cmd.AddCommand(listCmd) | ||
| 15 | Cmd.AddCommand(setCmd) | ||
| 16 | Cmd.AddCommand(unsetCmd) | ||
| 17 | } | ||
diff --git a/cmd/deploy/env/list.go b/cmd/deploy/env/list.go new file mode 100644 index 0000000..af92171 --- /dev/null +++ b/cmd/deploy/env/list.go | |||
| @@ -0,0 +1,69 @@ | |||
| 1 | package env | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | "strings" | ||
| 6 | |||
| 7 | "github.com/bdw/deploy/internal/state" | ||
| 8 | "github.com/spf13/cobra" | ||
| 9 | ) | ||
| 10 | |||
| 11 | var listCmd = &cobra.Command{ | ||
| 12 | Use: "list <app>", | ||
| 13 | Short: "List environment variables for an app", | ||
| 14 | Args: cobra.ExactArgs(1), | ||
| 15 | RunE: runList, | ||
| 16 | } | ||
| 17 | |||
| 18 | func runList(cmd *cobra.Command, args []string) error { | ||
| 19 | name := args[0] | ||
| 20 | |||
| 21 | st, err := state.Load() | ||
| 22 | if err != nil { | ||
| 23 | return fmt.Errorf("error loading state: %w", err) | ||
| 24 | } | ||
| 25 | |||
| 26 | host, _ := cmd.Flags().GetString("host") | ||
| 27 | if host == "" { | ||
| 28 | host = st.GetDefaultHost() | ||
| 29 | } | ||
| 30 | |||
| 31 | if host == "" { | ||
| 32 | return fmt.Errorf("--host is required") | ||
| 33 | } | ||
| 34 | |||
| 35 | app, err := st.GetApp(host, name) | ||
| 36 | if err != nil { | ||
| 37 | return err | ||
| 38 | } | ||
| 39 | |||
| 40 | if app.Type != "app" { | ||
| 41 | return fmt.Errorf("env is only available for apps, not static sites") | ||
| 42 | } | ||
| 43 | |||
| 44 | fmt.Printf("Environment variables for %s:\n\n", name) | ||
| 45 | if len(app.Env) == 0 { | ||
| 46 | fmt.Println(" (none)") | ||
| 47 | } else { | ||
| 48 | for k, v := range app.Env { | ||
| 49 | display := v | ||
| 50 | if isSensitive(k) { | ||
| 51 | display = "***" | ||
| 52 | } | ||
| 53 | fmt.Printf(" %s=%s\n", k, display) | ||
| 54 | } | ||
| 55 | } | ||
| 56 | |||
| 57 | return nil | ||
| 58 | } | ||
| 59 | |||
| 60 | func isSensitive(key string) bool { | ||
| 61 | key = strings.ToLower(key) | ||
| 62 | sensitiveWords := []string{"key", "secret", "password", "token", "api"} | ||
| 63 | for _, word := range sensitiveWords { | ||
| 64 | if strings.Contains(key, word) { | ||
| 65 | return true | ||
| 66 | } | ||
| 67 | } | ||
| 68 | return false | ||
| 69 | } | ||
diff --git a/cmd/deploy/env/set.go b/cmd/deploy/env/set.go new file mode 100644 index 0000000..35d77ff --- /dev/null +++ b/cmd/deploy/env/set.go | |||
| @@ -0,0 +1,132 @@ | |||
| 1 | package env | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bufio" | ||
| 5 | "fmt" | ||
| 6 | "os" | ||
| 7 | "strings" | ||
| 8 | |||
| 9 | "github.com/bdw/deploy/internal/ssh" | ||
| 10 | "github.com/bdw/deploy/internal/state" | ||
| 11 | "github.com/spf13/cobra" | ||
| 12 | ) | ||
| 13 | |||
| 14 | var setCmd = &cobra.Command{ | ||
| 15 | Use: "set <app> KEY=VALUE...", | ||
| 16 | Short: "Set environment variable(s)", | ||
| 17 | Long: "Set one or more environment variables for an app. Variables are specified as KEY=VALUE pairs.", | ||
| 18 | Args: cobra.MinimumNArgs(2), | ||
| 19 | RunE: runSet, | ||
| 20 | } | ||
| 21 | |||
| 22 | func init() { | ||
| 23 | setCmd.Flags().StringP("file", "f", "", "Load environment from file") | ||
| 24 | } | ||
| 25 | |||
| 26 | func runSet(cmd *cobra.Command, args []string) error { | ||
| 27 | name := args[0] | ||
| 28 | envVars := args[1:] | ||
| 29 | |||
| 30 | st, err := state.Load() | ||
| 31 | if err != nil { | ||
| 32 | return fmt.Errorf("error loading state: %w", err) | ||
| 33 | } | ||
| 34 | |||
| 35 | host, _ := cmd.Flags().GetString("host") | ||
| 36 | if host == "" { | ||
| 37 | host = st.GetDefaultHost() | ||
| 38 | } | ||
| 39 | |||
| 40 | if host == "" { | ||
| 41 | return fmt.Errorf("--host is required") | ||
| 42 | } | ||
| 43 | |||
| 44 | app, err := st.GetApp(host, name) | ||
| 45 | if err != nil { | ||
| 46 | return err | ||
| 47 | } | ||
| 48 | |||
| 49 | if app.Type != "app" { | ||
| 50 | return fmt.Errorf("env is only available for apps, not static sites") | ||
| 51 | } | ||
| 52 | |||
| 53 | if app.Env == nil { | ||
| 54 | app.Env = make(map[string]string) | ||
| 55 | } | ||
| 56 | |||
| 57 | // Set variables from args | ||
| 58 | for _, e := range envVars { | ||
| 59 | parts := strings.SplitN(e, "=", 2) | ||
| 60 | if len(parts) == 2 { | ||
| 61 | app.Env[parts[0]] = parts[1] | ||
| 62 | fmt.Printf("Set %s\n", parts[0]) | ||
| 63 | } else { | ||
| 64 | return fmt.Errorf("invalid format: %s (expected KEY=VALUE)", e) | ||
| 65 | } | ||
| 66 | } | ||
| 67 | |||
| 68 | // Set variables from file if provided | ||
| 69 | envFile, _ := cmd.Flags().GetString("file") | ||
| 70 | if envFile != "" { | ||
| 71 | fileEnv, err := parseEnvFile(envFile) | ||
| 72 | if err != nil { | ||
| 73 | return fmt.Errorf("error reading env file: %w", err) | ||
| 74 | } | ||
| 75 | for k, v := range fileEnv { | ||
| 76 | app.Env[k] = v | ||
| 77 | fmt.Printf("Set %s\n", k) | ||
| 78 | } | ||
| 79 | } | ||
| 80 | |||
| 81 | if err := st.Save(); err != nil { | ||
| 82 | return fmt.Errorf("error saving state: %w", err) | ||
| 83 | } | ||
| 84 | |||
| 85 | client, err := ssh.Connect(host) | ||
| 86 | if err != nil { | ||
| 87 | return fmt.Errorf("error connecting to VPS: %w", err) | ||
| 88 | } | ||
| 89 | defer client.Close() | ||
| 90 | |||
| 91 | fmt.Println("-> Updating environment file on VPS...") | ||
| 92 | envFilePath := fmt.Sprintf("/etc/deploy/env/%s.env", name) | ||
| 93 | envContent := "" | ||
| 94 | for k, v := range app.Env { | ||
| 95 | envContent += fmt.Sprintf("%s=%s\n", k, v) | ||
| 96 | } | ||
| 97 | if err := client.WriteSudoFile(envFilePath, envContent); err != nil { | ||
| 98 | return fmt.Errorf("error updating env file: %w", err) | ||
| 99 | } | ||
| 100 | |||
| 101 | fmt.Println("-> Restarting service...") | ||
| 102 | if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { | ||
| 103 | return fmt.Errorf("error restarting service: %w", err) | ||
| 104 | } | ||
| 105 | |||
| 106 | fmt.Println("Environment variables updated successfully") | ||
| 107 | return nil | ||
| 108 | } | ||
| 109 | |||
| 110 | func parseEnvFile(path string) (map[string]string, error) { | ||
| 111 | file, err := os.Open(path) | ||
| 112 | if err != nil { | ||
| 113 | return nil, err | ||
| 114 | } | ||
| 115 | defer file.Close() | ||
| 116 | |||
| 117 | env := make(map[string]string) | ||
| 118 | scanner := bufio.NewScanner(file) | ||
| 119 | for scanner.Scan() { | ||
| 120 | line := strings.TrimSpace(scanner.Text()) | ||
| 121 | if line == "" || strings.HasPrefix(line, "#") { | ||
| 122 | continue | ||
| 123 | } | ||
| 124 | |||
| 125 | parts := strings.SplitN(line, "=", 2) | ||
| 126 | if len(parts) == 2 { | ||
| 127 | env[parts[0]] = parts[1] | ||
| 128 | } | ||
| 129 | } | ||
| 130 | |||
| 131 | return env, scanner.Err() | ||
| 132 | } | ||
diff --git a/cmd/deploy/env/unset.go b/cmd/deploy/env/unset.go new file mode 100644 index 0000000..65a8986 --- /dev/null +++ b/cmd/deploy/env/unset.go | |||
| @@ -0,0 +1,92 @@ | |||
| 1 | package env | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | |||
| 6 | "github.com/bdw/deploy/internal/ssh" | ||
| 7 | "github.com/bdw/deploy/internal/state" | ||
| 8 | "github.com/spf13/cobra" | ||
| 9 | ) | ||
| 10 | |||
| 11 | var unsetCmd = &cobra.Command{ | ||
| 12 | Use: "unset <app> KEY...", | ||
| 13 | Short: "Unset environment variable(s)", | ||
| 14 | Long: "Remove one or more environment variables from an app.", | ||
| 15 | Args: cobra.MinimumNArgs(2), | ||
| 16 | RunE: runUnset, | ||
| 17 | } | ||
| 18 | |||
| 19 | func runUnset(cmd *cobra.Command, args []string) error { | ||
| 20 | name := args[0] | ||
| 21 | keys := args[1:] | ||
| 22 | |||
| 23 | st, err := state.Load() | ||
| 24 | if err != nil { | ||
| 25 | return fmt.Errorf("error loading state: %w", err) | ||
| 26 | } | ||
| 27 | |||
| 28 | host, _ := cmd.Flags().GetString("host") | ||
| 29 | if host == "" { | ||
| 30 | host = st.GetDefaultHost() | ||
| 31 | } | ||
| 32 | |||
| 33 | if host == "" { | ||
| 34 | return fmt.Errorf("--host is required") | ||
| 35 | } | ||
| 36 | |||
| 37 | app, err := st.GetApp(host, name) | ||
| 38 | if err != nil { | ||
| 39 | return err | ||
| 40 | } | ||
| 41 | |||
| 42 | if app.Type != "app" { | ||
| 43 | return fmt.Errorf("env is only available for apps, not static sites") | ||
| 44 | } | ||
| 45 | |||
| 46 | if app.Env == nil { | ||
| 47 | return fmt.Errorf("no environment variables set") | ||
| 48 | } | ||
| 49 | |||
| 50 | changed := false | ||
| 51 | for _, key := range keys { | ||
| 52 | if _, exists := app.Env[key]; exists { | ||
| 53 | delete(app.Env, key) | ||
| 54 | changed = true | ||
| 55 | fmt.Printf("Unset %s\n", key) | ||
| 56 | } else { | ||
| 57 | fmt.Printf("Warning: %s not found\n", key) | ||
| 58 | } | ||
| 59 | } | ||
| 60 | |||
| 61 | if !changed { | ||
| 62 | return nil | ||
| 63 | } | ||
| 64 | |||
| 65 | if err := st.Save(); err != nil { | ||
| 66 | return fmt.Errorf("error saving state: %w", err) | ||
| 67 | } | ||
| 68 | |||
| 69 | client, err := ssh.Connect(host) | ||
| 70 | if err != nil { | ||
| 71 | return fmt.Errorf("error connecting to VPS: %w", err) | ||
| 72 | } | ||
| 73 | defer client.Close() | ||
| 74 | |||
| 75 | fmt.Println("-> Updating environment file on VPS...") | ||
| 76 | envFilePath := fmt.Sprintf("/etc/deploy/env/%s.env", name) | ||
| 77 | envContent := "" | ||
| 78 | for k, v := range app.Env { | ||
| 79 | envContent += fmt.Sprintf("%s=%s\n", k, v) | ||
| 80 | } | ||
| 81 | if err := client.WriteSudoFile(envFilePath, envContent); err != nil { | ||
| 82 | return fmt.Errorf("error updating env file: %w", err) | ||
| 83 | } | ||
| 84 | |||
| 85 | fmt.Println("-> Restarting service...") | ||
| 86 | if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { | ||
| 87 | return fmt.Errorf("error restarting service: %w", err) | ||
| 88 | } | ||
| 89 | |||
| 90 | fmt.Println("Environment variables updated successfully") | ||
| 91 | return nil | ||
| 92 | } | ||
diff --git a/cmd/deploy/host/host.go b/cmd/deploy/host/host.go new file mode 100644 index 0000000..603a946 --- /dev/null +++ b/cmd/deploy/host/host.go | |||
| @@ -0,0 +1,18 @@ | |||
| 1 | package host | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "github.com/spf13/cobra" | ||
| 5 | ) | ||
| 6 | |||
| 7 | var Cmd = &cobra.Command{ | ||
| 8 | Use: "host", | ||
| 9 | Short: "Manage VPS host", | ||
| 10 | Long: "Commands for managing and monitoring the VPS host", | ||
| 11 | } | ||
| 12 | |||
| 13 | func init() { | ||
| 14 | Cmd.AddCommand(initCmd) | ||
| 15 | Cmd.AddCommand(statusCmd) | ||
| 16 | Cmd.AddCommand(updateCmd) | ||
| 17 | Cmd.AddCommand(sshCmd) | ||
| 18 | } | ||
diff --git a/cmd/deploy/host/init.go b/cmd/deploy/host/init.go new file mode 100644 index 0000000..984e5d3 --- /dev/null +++ b/cmd/deploy/host/init.go | |||
| @@ -0,0 +1,137 @@ | |||
| 1 | package host | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | "strings" | ||
| 6 | |||
| 7 | "github.com/bdw/deploy/internal/ssh" | ||
| 8 | "github.com/bdw/deploy/internal/state" | ||
| 9 | "github.com/spf13/cobra" | ||
| 10 | ) | ||
| 11 | |||
| 12 | var initCmd = &cobra.Command{ | ||
| 13 | Use: "init", | ||
| 14 | Short: "Initialize VPS (one-time setup)", | ||
| 15 | Long: "Set up a fresh VPS with Caddy for automatic HTTPS and required directories", | ||
| 16 | RunE: runInit, | ||
| 17 | } | ||
| 18 | |||
| 19 | func runInit(cmd *cobra.Command, args []string) error { | ||
| 20 | st, err := state.Load() | ||
| 21 | if err != nil { | ||
| 22 | return fmt.Errorf("error loading state: %w", err) | ||
| 23 | } | ||
| 24 | |||
| 25 | host, _ := cmd.Flags().GetString("host") | ||
| 26 | if host == "" { | ||
| 27 | host = st.GetDefaultHost() | ||
| 28 | } | ||
| 29 | |||
| 30 | if host == "" { | ||
| 31 | return fmt.Errorf("--host is required") | ||
| 32 | } | ||
| 33 | |||
| 34 | fmt.Printf("Initializing VPS: %s\n", host) | ||
| 35 | |||
| 36 | client, err := ssh.Connect(host) | ||
| 37 | if err != nil { | ||
| 38 | return fmt.Errorf("error connecting to VPS: %w", err) | ||
| 39 | } | ||
| 40 | defer client.Close() | ||
| 41 | |||
| 42 | fmt.Println("-> Detecting OS...") | ||
| 43 | osRelease, err := client.Run("cat /etc/os-release") | ||
| 44 | if err != nil { | ||
| 45 | return fmt.Errorf("error detecting OS: %w", err) | ||
| 46 | } | ||
| 47 | |||
| 48 | if !strings.Contains(osRelease, "Ubuntu") && !strings.Contains(osRelease, "Debian") { | ||
| 49 | return fmt.Errorf("unsupported OS (only Ubuntu and Debian are supported)") | ||
| 50 | } | ||
| 51 | fmt.Println(" Detected Ubuntu/Debian") | ||
| 52 | |||
| 53 | fmt.Println("-> Checking for Caddy...") | ||
| 54 | _, err = client.Run("which caddy") | ||
| 55 | if err == nil { | ||
| 56 | fmt.Println(" Caddy already installed") | ||
| 57 | } else { | ||
| 58 | fmt.Println(" Installing Caddy...") | ||
| 59 | if err := installCaddy(client); err != nil { | ||
| 60 | return err | ||
| 61 | } | ||
| 62 | fmt.Println(" Caddy installed") | ||
| 63 | } | ||
| 64 | |||
| 65 | fmt.Println("-> Configuring Caddy...") | ||
| 66 | caddyfile := `{ | ||
| 67 | email admin@example.com | ||
| 68 | } | ||
| 69 | |||
| 70 | import /etc/caddy/sites-enabled/* | ||
| 71 | ` | ||
| 72 | if err := client.WriteSudoFile("/etc/caddy/Caddyfile", caddyfile); err != nil { | ||
| 73 | return fmt.Errorf("error creating Caddyfile: %w", err) | ||
| 74 | } | ||
| 75 | fmt.Println(" Caddyfile created") | ||
| 76 | |||
| 77 | fmt.Println("-> Creating directories...") | ||
| 78 | if _, err := client.RunSudo("mkdir -p /etc/deploy/env"); err != nil { | ||
| 79 | return fmt.Errorf("error creating /etc/deploy/env: %w", err) | ||
| 80 | } | ||
| 81 | if _, err := client.RunSudo("mkdir -p /etc/caddy/sites-enabled"); err != nil { | ||
| 82 | return fmt.Errorf("error creating /etc/caddy/sites-enabled: %w", err) | ||
| 83 | } | ||
| 84 | fmt.Println(" Directories created") | ||
| 85 | |||
| 86 | fmt.Println("-> Starting Caddy...") | ||
| 87 | if _, err := client.RunSudo("systemctl enable caddy"); err != nil { | ||
| 88 | return fmt.Errorf("error enabling Caddy: %w", err) | ||
| 89 | } | ||
| 90 | if _, err := client.RunSudo("systemctl restart caddy"); err != nil { | ||
| 91 | return fmt.Errorf("error starting Caddy: %w", err) | ||
| 92 | } | ||
| 93 | fmt.Println(" Caddy started") | ||
| 94 | |||
| 95 | fmt.Println("-> Verifying installation...") | ||
| 96 | output, err := client.RunSudo("systemctl is-active caddy") | ||
| 97 | if err != nil || strings.TrimSpace(output) != "active" { | ||
| 98 | fmt.Println(" Warning: Caddy may not be running properly") | ||
| 99 | } else { | ||
| 100 | fmt.Println(" Caddy is active") | ||
| 101 | } | ||
| 102 | |||
| 103 | st.GetHost(host) | ||
| 104 | if st.GetDefaultHost() == "" { | ||
| 105 | st.SetDefaultHost(host) | ||
| 106 | fmt.Printf(" Set %s as default host\n", host) | ||
| 107 | } | ||
| 108 | if err := st.Save(); err != nil { | ||
| 109 | return fmt.Errorf("error saving state: %w", err) | ||
| 110 | } | ||
| 111 | |||
| 112 | fmt.Println("\nVPS initialized successfully!") | ||
| 113 | fmt.Println("\nNext steps:") | ||
| 114 | fmt.Println(" 1. Deploy a Go app:") | ||
| 115 | fmt.Printf(" deploy --binary ./myapp --domain api.example.com\n") | ||
| 116 | fmt.Println(" 2. Deploy a static site:") | ||
| 117 | fmt.Printf(" deploy --static --dir ./dist --domain example.com\n") | ||
| 118 | return nil | ||
| 119 | } | ||
| 120 | |||
| 121 | func installCaddy(client *ssh.Client) error { | ||
| 122 | commands := []string{ | ||
| 123 | "apt-get update", | ||
| 124 | "apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl", | ||
| 125 | "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg", | ||
| 126 | "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list", | ||
| 127 | "apt-get update", | ||
| 128 | "apt-get install -y caddy", | ||
| 129 | } | ||
| 130 | |||
| 131 | for _, cmd := range commands { | ||
| 132 | if _, err := client.RunSudo(cmd); err != nil { | ||
| 133 | return fmt.Errorf("error running: %s: %w", cmd, err) | ||
| 134 | } | ||
| 135 | } | ||
| 136 | return nil | ||
| 137 | } | ||
diff --git a/cmd/deploy/host/ssh.go b/cmd/deploy/host/ssh.go new file mode 100644 index 0000000..a33986f --- /dev/null +++ b/cmd/deploy/host/ssh.go | |||
| @@ -0,0 +1,45 @@ | |||
| 1 | package host | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | "os" | ||
| 6 | "os/exec" | ||
| 7 | |||
| 8 | "github.com/bdw/deploy/internal/state" | ||
| 9 | "github.com/spf13/cobra" | ||
| 10 | ) | ||
| 11 | |||
| 12 | var sshCmd = &cobra.Command{ | ||
| 13 | Use: "ssh", | ||
| 14 | Short: "Open interactive SSH session", | ||
| 15 | RunE: runSSH, | ||
| 16 | } | ||
| 17 | |||
| 18 | func runSSH(cmd *cobra.Command, args []string) error { | ||
| 19 | st, err := state.Load() | ||
| 20 | if err != nil { | ||
| 21 | return fmt.Errorf("error loading state: %w", err) | ||
| 22 | } | ||
| 23 | |||
| 24 | host, _ := cmd.Flags().GetString("host") | ||
| 25 | if host == "" { | ||
| 26 | host = st.GetDefaultHost() | ||
| 27 | } | ||
| 28 | |||
| 29 | if host == "" { | ||
| 30 | return fmt.Errorf("--host is required (no default host set)") | ||
| 31 | } | ||
| 32 | |||
| 33 | sshCmd := exec.Command("ssh", host) | ||
| 34 | sshCmd.Stdin = os.Stdin | ||
| 35 | sshCmd.Stdout = os.Stdout | ||
| 36 | sshCmd.Stderr = os.Stderr | ||
| 37 | |||
| 38 | if err := sshCmd.Run(); err != nil { | ||
| 39 | if exitErr, ok := err.(*exec.ExitError); ok { | ||
| 40 | os.Exit(exitErr.ExitCode()) | ||
| 41 | } | ||
| 42 | return err | ||
| 43 | } | ||
| 44 | return nil | ||
| 45 | } | ||
diff --git a/cmd/deploy/host/status.go b/cmd/deploy/host/status.go new file mode 100644 index 0000000..bdd9c31 --- /dev/null +++ b/cmd/deploy/host/status.go | |||
| @@ -0,0 +1,108 @@ | |||
| 1 | package host | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | |||
| 6 | "github.com/bdw/deploy/internal/ssh" | ||
| 7 | "github.com/bdw/deploy/internal/state" | ||
| 8 | "github.com/spf13/cobra" | ||
| 9 | ) | ||
| 10 | |||
| 11 | var statusCmd = &cobra.Command{ | ||
| 12 | Use: "status", | ||
| 13 | Short: "Show VPS health (uptime, disk, memory)", | ||
| 14 | RunE: runStatus, | ||
| 15 | } | ||
| 16 | |||
| 17 | func runStatus(cmd *cobra.Command, args []string) error { | ||
| 18 | st, err := state.Load() | ||
| 19 | if err != nil { | ||
| 20 | return fmt.Errorf("error loading state: %w", err) | ||
| 21 | } | ||
| 22 | |||
| 23 | host, _ := cmd.Flags().GetString("host") | ||
| 24 | if host == "" { | ||
| 25 | host = st.GetDefaultHost() | ||
| 26 | } | ||
| 27 | |||
| 28 | if host == "" { | ||
| 29 | return fmt.Errorf("--host is required (no default host set)") | ||
| 30 | } | ||
| 31 | |||
| 32 | fmt.Printf("Connecting to %s...\n\n", host) | ||
| 33 | |||
| 34 | client, err := ssh.Connect(host) | ||
| 35 | if err != nil { | ||
| 36 | return fmt.Errorf("error connecting to VPS: %w", err) | ||
| 37 | } | ||
| 38 | defer client.Close() | ||
| 39 | |||
| 40 | fmt.Println("UPTIME") | ||
| 41 | if output, err := client.Run("uptime -p"); err == nil { | ||
| 42 | fmt.Printf(" %s", output) | ||
| 43 | } | ||
| 44 | if output, err := client.Run("uptime -s"); err == nil { | ||
| 45 | fmt.Printf(" Since: %s", output) | ||
| 46 | } | ||
| 47 | fmt.Println() | ||
| 48 | |||
| 49 | fmt.Println("LOAD") | ||
| 50 | if output, err := client.Run("cat /proc/loadavg | awk '{print $1, $2, $3}'"); err == nil { | ||
| 51 | fmt.Printf(" 1m, 5m, 15m: %s", output) | ||
| 52 | } | ||
| 53 | fmt.Println() | ||
| 54 | |||
| 55 | fmt.Println("MEMORY") | ||
| 56 | if output, err := client.Run("free -h | awk 'NR==2 {print \" Used: \" $3 \" / \" $2}'"); err == nil { | ||
| 57 | fmt.Print(output) | ||
| 58 | } | ||
| 59 | if output, err := client.Run("free -h | awk 'NR==2 {printf \" Available: %s\\n\", $7}'"); err == nil { | ||
| 60 | fmt.Print(output) | ||
| 61 | } | ||
| 62 | fmt.Println() | ||
| 63 | |||
| 64 | fmt.Println("DISK") | ||
| 65 | if output, err := client.Run("df -h / | awk 'NR==2 {print \" Used: \" $3 \" / \" $2 \" (\" $5 \")\"}'"); err == nil { | ||
| 66 | fmt.Print(output) | ||
| 67 | } | ||
| 68 | if output, err := client.Run("df -h / | awk 'NR==2 {print \" Available: \" $4}'"); err == nil { | ||
| 69 | fmt.Print(output) | ||
| 70 | } | ||
| 71 | fmt.Println() | ||
| 72 | |||
| 73 | fmt.Println("UPDATES") | ||
| 74 | if output, err := client.Run("[ -f /var/lib/update-notifier/updates-available ] && cat /var/lib/update-notifier/updates-available | head -2 || echo ' (update info not available)'"); err == nil { | ||
| 75 | fmt.Print(output) | ||
| 76 | } | ||
| 77 | fmt.Println() | ||
| 78 | |||
| 79 | fmt.Println("SERVICES") | ||
| 80 | if output, err := client.Run("systemctl is-active caddy 2>/dev/null && echo ' Caddy: active' || echo ' Caddy: inactive'"); err == nil { | ||
| 81 | if output == "active\n" { | ||
| 82 | fmt.Println(" Caddy: active") | ||
| 83 | } else { | ||
| 84 | fmt.Println(" Caddy: inactive") | ||
| 85 | } | ||
| 86 | } | ||
| 87 | |||
| 88 | hostState := st.GetHost(host) | ||
| 89 | if hostState != nil && len(hostState.Apps) > 0 { | ||
| 90 | activeCount := 0 | ||
| 91 | for name, app := range hostState.Apps { | ||
| 92 | if app.Type == "app" { | ||
| 93 | if output, err := client.Run(fmt.Sprintf("systemctl is-active %s 2>/dev/null", name)); err == nil && output == "active\n" { | ||
| 94 | activeCount++ | ||
| 95 | } | ||
| 96 | } | ||
| 97 | } | ||
| 98 | appCount := 0 | ||
| 99 | for _, app := range hostState.Apps { | ||
| 100 | if app.Type == "app" { | ||
| 101 | appCount++ | ||
| 102 | } | ||
| 103 | } | ||
| 104 | fmt.Printf(" Deployed apps: %d/%d active\n", activeCount, appCount) | ||
| 105 | } | ||
| 106 | |||
| 107 | return nil | ||
| 108 | } | ||
diff --git a/cmd/deploy/host/update.go b/cmd/deploy/host/update.go new file mode 100644 index 0000000..6f1b43b --- /dev/null +++ b/cmd/deploy/host/update.go | |||
| @@ -0,0 +1,80 @@ | |||
| 1 | package host | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bufio" | ||
| 5 | "fmt" | ||
| 6 | "os" | ||
| 7 | "strings" | ||
| 8 | |||
| 9 | "github.com/bdw/deploy/internal/ssh" | ||
| 10 | "github.com/bdw/deploy/internal/state" | ||
| 11 | "github.com/spf13/cobra" | ||
| 12 | ) | ||
| 13 | |||
| 14 | var updateCmd = &cobra.Command{ | ||
| 15 | Use: "update", | ||
| 16 | Short: "Update VPS packages", | ||
| 17 | Long: "Run apt update && apt upgrade on the VPS", | ||
| 18 | RunE: runUpdate, | ||
| 19 | } | ||
| 20 | |||
| 21 | func init() { | ||
| 22 | updateCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") | ||
| 23 | } | ||
| 24 | |||
| 25 | func runUpdate(cmd *cobra.Command, args []string) error { | ||
| 26 | st, err := state.Load() | ||
| 27 | if err != nil { | ||
| 28 | return fmt.Errorf("error loading state: %w", err) | ||
| 29 | } | ||
| 30 | |||
| 31 | host, _ := cmd.Flags().GetString("host") | ||
| 32 | if host == "" { | ||
| 33 | host = st.GetDefaultHost() | ||
| 34 | } | ||
| 35 | |||
| 36 | if host == "" { | ||
| 37 | return fmt.Errorf("--host is required (no default host set)") | ||
| 38 | } | ||
| 39 | |||
| 40 | yes, _ := cmd.Flags().GetBool("yes") | ||
| 41 | if !yes { | ||
| 42 | fmt.Printf("This will run apt update && apt upgrade on %s\n", host) | ||
| 43 | fmt.Print("Continue? [y/N]: ") | ||
| 44 | reader := bufio.NewReader(os.Stdin) | ||
| 45 | response, _ := reader.ReadString('\n') | ||
| 46 | response = strings.TrimSpace(response) | ||
| 47 | if response != "y" && response != "Y" { | ||
| 48 | fmt.Println("Aborted.") | ||
| 49 | return nil | ||
| 50 | } | ||
| 51 | } | ||
| 52 | |||
| 53 | fmt.Printf("Connecting to %s...\n", host) | ||
| 54 | |||
| 55 | client, err := ssh.Connect(host) | ||
| 56 | if err != nil { | ||
| 57 | return fmt.Errorf("error connecting to VPS: %w", err) | ||
| 58 | } | ||
| 59 | defer client.Close() | ||
| 60 | |||
| 61 | fmt.Println("\n-> Running apt update...") | ||
| 62 | if err := client.RunSudoStream("apt update"); err != nil { | ||
| 63 | return fmt.Errorf("error running apt update: %w", err) | ||
| 64 | } | ||
| 65 | |||
| 66 | fmt.Println("\n-> Running apt upgrade...") | ||
| 67 | if err := client.RunSudoStream("DEBIAN_FRONTEND=noninteractive apt upgrade -y"); err != nil { | ||
| 68 | return fmt.Errorf("error running apt upgrade: %w", err) | ||
| 69 | } | ||
| 70 | |||
| 71 | fmt.Println() | ||
| 72 | if output, err := client.Run("[ -f /var/run/reboot-required ] && echo 'yes' || echo 'no'"); err == nil { | ||
| 73 | if output == "yes\n" { | ||
| 74 | fmt.Println("Note: A reboot is required to complete the update.") | ||
| 75 | } | ||
| 76 | } | ||
| 77 | |||
| 78 | fmt.Println("Update complete") | ||
| 79 | return nil | ||
| 80 | } | ||
diff --git a/cmd/deploy/init.go b/cmd/deploy/init.go deleted file mode 100644 index 1713879..0000000 --- a/cmd/deploy/init.go +++ /dev/null | |||
| @@ -1,154 +0,0 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "flag" | ||
| 5 | "fmt" | ||
| 6 | "os" | ||
| 7 | "strings" | ||
| 8 | |||
| 9 | "github.com/bdw/deploy/internal/ssh" | ||
| 10 | "github.com/bdw/deploy/internal/state" | ||
| 11 | ) | ||
| 12 | |||
| 13 | func runInit(args []string) { | ||
| 14 | fs := flag.NewFlagSet("init", flag.ExitOnError) | ||
| 15 | host := fs.String("host", "", "VPS host (SSH config alias or user@host)") | ||
| 16 | fs.Parse(args) | ||
| 17 | |||
| 18 | // Load state | ||
| 19 | st, err := state.Load() | ||
| 20 | if err != nil { | ||
| 21 | fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) | ||
| 22 | os.Exit(1) | ||
| 23 | } | ||
| 24 | |||
| 25 | // Get host from flag or state default | ||
| 26 | if *host == "" { | ||
| 27 | *host = st.GetDefaultHost() | ||
| 28 | } | ||
| 29 | |||
| 30 | if *host == "" { | ||
| 31 | fmt.Fprintf(os.Stderr, "Error: --host is required\n") | ||
| 32 | fs.Usage() | ||
| 33 | os.Exit(1) | ||
| 34 | } | ||
| 35 | |||
| 36 | fmt.Printf("Initializing VPS: %s\n", *host) | ||
| 37 | |||
| 38 | // Connect to VPS | ||
| 39 | client, err := ssh.Connect(*host) | ||
| 40 | if err != nil { | ||
| 41 | fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err) | ||
| 42 | os.Exit(1) | ||
| 43 | } | ||
| 44 | defer client.Close() | ||
| 45 | |||
| 46 | // Detect OS | ||
| 47 | fmt.Println("→ Detecting OS...") | ||
| 48 | osRelease, err := client.Run("cat /etc/os-release") | ||
| 49 | if err != nil { | ||
| 50 | fmt.Fprintf(os.Stderr, "Error detecting OS: %v\n", err) | ||
| 51 | os.Exit(1) | ||
| 52 | } | ||
| 53 | |||
| 54 | if !strings.Contains(osRelease, "Ubuntu") && !strings.Contains(osRelease, "Debian") { | ||
| 55 | fmt.Fprintf(os.Stderr, "Error: Unsupported OS (only Ubuntu and Debian are supported)\n") | ||
| 56 | os.Exit(1) | ||
| 57 | } | ||
| 58 | fmt.Println(" ✓ Detected Ubuntu/Debian") | ||
| 59 | |||
| 60 | // Check if Caddy is already installed | ||
| 61 | fmt.Println("→ Checking for Caddy...") | ||
| 62 | _, err = client.Run("which caddy") | ||
| 63 | if err == nil { | ||
| 64 | fmt.Println(" ✓ Caddy already installed") | ||
| 65 | } else { | ||
| 66 | // Install Caddy | ||
| 67 | fmt.Println(" Installing Caddy...") | ||
| 68 | installCaddy(client) | ||
| 69 | fmt.Println(" ✓ Caddy installed") | ||
| 70 | } | ||
| 71 | |||
| 72 | // Create Caddyfile | ||
| 73 | fmt.Println("→ Configuring Caddy...") | ||
| 74 | caddyfile := `{ | ||
| 75 | email admin@example.com | ||
| 76 | } | ||
| 77 | |||
| 78 | import /etc/caddy/sites-enabled/* | ||
| 79 | ` | ||
| 80 | if err := client.WriteSudoFile("/etc/caddy/Caddyfile", caddyfile); err != nil { | ||
| 81 | fmt.Fprintf(os.Stderr, "Error creating Caddyfile: %v\n", err) | ||
| 82 | os.Exit(1) | ||
| 83 | } | ||
| 84 | fmt.Println(" ✓ Caddyfile created") | ||
| 85 | |||
| 86 | // Create directories | ||
| 87 | fmt.Println("→ Creating directories...") | ||
| 88 | if _, err := client.RunSudo("mkdir -p /etc/deploy/env"); err != nil { | ||
| 89 | fmt.Fprintf(os.Stderr, "Error creating /etc/deploy/env: %v\n", err) | ||
| 90 | os.Exit(1) | ||
| 91 | } | ||
| 92 | if _, err := client.RunSudo("mkdir -p /etc/caddy/sites-enabled"); err != nil { | ||
| 93 | fmt.Fprintf(os.Stderr, "Error creating /etc/caddy/sites-enabled: %v\n", err) | ||
| 94 | os.Exit(1) | ||
| 95 | } | ||
| 96 | fmt.Println(" ✓ Directories created") | ||
| 97 | |||
| 98 | // Enable and start Caddy | ||
| 99 | fmt.Println("→ Starting Caddy...") | ||
| 100 | if _, err := client.RunSudo("systemctl enable caddy"); err != nil { | ||
| 101 | fmt.Fprintf(os.Stderr, "Error enabling Caddy: %v\n", err) | ||
| 102 | os.Exit(1) | ||
| 103 | } | ||
| 104 | if _, err := client.RunSudo("systemctl restart caddy"); err != nil { | ||
| 105 | fmt.Fprintf(os.Stderr, "Error starting Caddy: %v\n", err) | ||
| 106 | os.Exit(1) | ||
| 107 | } | ||
| 108 | fmt.Println(" ✓ Caddy started") | ||
| 109 | |||
| 110 | // Verify Caddy is running | ||
| 111 | fmt.Println("→ Verifying installation...") | ||
| 112 | output, err := client.RunSudo("systemctl is-active caddy") | ||
| 113 | if err != nil || strings.TrimSpace(output) != "active" { | ||
| 114 | fmt.Fprintf(os.Stderr, "Warning: Caddy may not be running properly\n") | ||
| 115 | } else { | ||
| 116 | fmt.Println(" ✓ Caddy is active") | ||
| 117 | } | ||
| 118 | |||
| 119 | // Update state | ||
| 120 | st.GetHost(*host) // Ensure host exists in state | ||
| 121 | if st.GetDefaultHost() == "" { | ||
| 122 | st.SetDefaultHost(*host) | ||
| 123 | fmt.Printf(" Set %s as default host\n", *host) | ||
| 124 | } | ||
| 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 index ce1605b..ab19a12 100644 --- a/cmd/deploy/list.go +++ b/cmd/deploy/list.go | |||
| @@ -1,44 +1,41 @@ | |||
| 1 | package main | 1 | package main |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "flag" | ||
| 5 | "fmt" | 4 | "fmt" |
| 6 | "os" | 5 | "os" |
| 7 | "text/tabwriter" | 6 | "text/tabwriter" |
| 8 | 7 | ||
| 9 | "github.com/bdw/deploy/internal/state" | 8 | "github.com/bdw/deploy/internal/state" |
| 9 | "github.com/spf13/cobra" | ||
| 10 | ) | 10 | ) |
| 11 | 11 | ||
| 12 | func runList(args []string) { | 12 | var listCmd = &cobra.Command{ |
| 13 | fs := flag.NewFlagSet("list", flag.ExitOnError) | 13 | Use: "list", |
| 14 | host := fs.String("host", "", "VPS host (SSH config alias or user@host)") | 14 | Short: "List all deployed apps and sites", |
| 15 | fs.Parse(args) | 15 | RunE: runList, |
| 16 | } | ||
| 16 | 17 | ||
| 17 | // Load state | 18 | func runList(cmd *cobra.Command, args []string) error { |
| 18 | st, err := state.Load() | 19 | st, err := state.Load() |
| 19 | if err != nil { | 20 | if err != nil { |
| 20 | fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) | 21 | return fmt.Errorf("error loading state: %w", err) |
| 21 | os.Exit(1) | ||
| 22 | } | 22 | } |
| 23 | 23 | ||
| 24 | // Get host from flag or state default | 24 | host := hostFlag |
| 25 | if *host == "" { | 25 | if host == "" { |
| 26 | *host = st.GetDefaultHost() | 26 | host = st.GetDefaultHost() |
| 27 | } | 27 | } |
| 28 | 28 | ||
| 29 | if *host == "" { | 29 | if host == "" { |
| 30 | fmt.Fprintf(os.Stderr, "Error: --host is required\n") | 30 | return fmt.Errorf("--host is required") |
| 31 | fs.Usage() | ||
| 32 | os.Exit(1) | ||
| 33 | } | 31 | } |
| 34 | 32 | ||
| 35 | apps := st.ListApps(*host) | 33 | apps := st.ListApps(host) |
| 36 | if len(apps) == 0 { | 34 | if len(apps) == 0 { |
| 37 | fmt.Printf("No deployments found for %s\n", *host) | 35 | fmt.Printf("No deployments found for %s\n", host) |
| 38 | return | 36 | return nil |
| 39 | } | 37 | } |
| 40 | 38 | ||
| 41 | // Print table | ||
| 42 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) | 39 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) |
| 43 | fmt.Fprintln(w, "NAME\tTYPE\tDOMAIN\tPORT") | 40 | fmt.Fprintln(w, "NAME\tTYPE\tDOMAIN\tPORT") |
| 44 | for name, app := range apps { | 41 | for name, app := range apps { |
| @@ -49,4 +46,5 @@ func runList(args []string) { | |||
| 49 | fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", name, app.Type, app.Domain, port) | 46 | fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", name, app.Type, app.Domain, port) |
| 50 | } | 47 | } |
| 51 | w.Flush() | 48 | w.Flush() |
| 49 | return nil | ||
| 52 | } | 50 | } |
diff --git a/cmd/deploy/logs.go b/cmd/deploy/logs.go new file mode 100644 index 0000000..2b016b8 --- /dev/null +++ b/cmd/deploy/logs.go | |||
| @@ -0,0 +1,75 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | |||
| 6 | "github.com/bdw/deploy/internal/ssh" | ||
| 7 | "github.com/bdw/deploy/internal/state" | ||
| 8 | "github.com/spf13/cobra" | ||
| 9 | ) | ||
| 10 | |||
| 11 | var logsCmd = &cobra.Command{ | ||
| 12 | Use: "logs <app>", | ||
| 13 | Short: "View logs for a deployment", | ||
| 14 | Args: cobra.ExactArgs(1), | ||
| 15 | RunE: runLogs, | ||
| 16 | } | ||
| 17 | |||
| 18 | func init() { | ||
| 19 | logsCmd.Flags().BoolP("follow", "f", false, "Follow logs") | ||
| 20 | logsCmd.Flags().IntP("lines", "n", 50, "Number of lines to show") | ||
| 21 | } | ||
| 22 | |||
| 23 | func runLogs(cmd *cobra.Command, args []string) error { | ||
| 24 | name := args[0] | ||
| 25 | follow, _ := cmd.Flags().GetBool("follow") | ||
| 26 | lines, _ := cmd.Flags().GetInt("lines") | ||
| 27 | |||
| 28 | st, err := state.Load() | ||
| 29 | if err != nil { | ||
| 30 | return fmt.Errorf("error loading state: %w", err) | ||
| 31 | } | ||
| 32 | |||
| 33 | host := hostFlag | ||
| 34 | if host == "" { | ||
| 35 | host = st.GetDefaultHost() | ||
| 36 | } | ||
| 37 | |||
| 38 | if host == "" { | ||
| 39 | return fmt.Errorf("--host is required") | ||
| 40 | } | ||
| 41 | |||
| 42 | app, err := st.GetApp(host, name) | ||
| 43 | if err != nil { | ||
| 44 | return err | ||
| 45 | } | ||
| 46 | |||
| 47 | if app.Type != "app" { | ||
| 48 | return fmt.Errorf("logs are only available for apps, not static sites") | ||
| 49 | } | ||
| 50 | |||
| 51 | client, err := ssh.Connect(host) | ||
| 52 | if err != nil { | ||
| 53 | return fmt.Errorf("error connecting to VPS: %w", err) | ||
| 54 | } | ||
| 55 | defer client.Close() | ||
| 56 | |||
| 57 | journalCmd := fmt.Sprintf("journalctl -u %s -n %d", name, lines) | ||
| 58 | if follow { | ||
| 59 | journalCmd += " -f" | ||
| 60 | } | ||
| 61 | |||
| 62 | if follow { | ||
| 63 | if err := client.RunStream(journalCmd); err != nil { | ||
| 64 | return fmt.Errorf("error fetching logs: %w", err) | ||
| 65 | } | ||
| 66 | } else { | ||
| 67 | output, err := client.Run(journalCmd) | ||
| 68 | if err != nil { | ||
| 69 | return fmt.Errorf("error fetching logs: %w", err) | ||
| 70 | } | ||
| 71 | fmt.Print(output) | ||
| 72 | } | ||
| 73 | |||
| 74 | return nil | ||
| 75 | } | ||
diff --git a/cmd/deploy/main.go b/cmd/deploy/main.go index 51439dd..ad61523 100644 --- a/cmd/deploy/main.go +++ b/cmd/deploy/main.go | |||
| @@ -1,93 +1,65 @@ | |||
| 1 | package main | 1 | package main |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "fmt" | ||
| 5 | "os" | 4 | "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 "list": | ||
| 20 | runList(os.Args[2:]) | ||
| 21 | case "rm", "remove": | ||
| 22 | runRemove(os.Args[2:]) | ||
| 23 | case "logs": | ||
| 24 | runLogs(os.Args[2:]) | ||
| 25 | case "status": | ||
| 26 | runStatus(os.Args[2:]) | ||
| 27 | case "restart": | ||
| 28 | runRestart(os.Args[2:]) | ||
| 29 | case "env": | ||
| 30 | runEnv(os.Args[2:]) | ||
| 31 | case "webui": | ||
| 32 | runWebUI(os.Args[2:]) | ||
| 33 | case "vps": | ||
| 34 | runVPS(os.Args[2:]) | ||
| 35 | case "vps-update": | ||
| 36 | runUpdate(os.Args[2:]) | ||
| 37 | case "vps-ssh": | ||
| 38 | runSSH(os.Args[2:]) | ||
| 39 | case "help", "--help", "-h": | ||
| 40 | printUsage() | ||
| 41 | default: | ||
| 42 | // Default action is deploy - pass all args including the first one | ||
| 43 | runDeploy(os.Args[1:]) | ||
| 44 | } | ||
| 45 | } | ||
| 46 | 5 | ||
| 47 | func printUsage() { | 6 | "github.com/bdw/deploy/cmd/deploy/env" |
| 48 | usage := `deploy - Deploy Go apps and static sites to a VPS with automatic HTTPS | 7 | "github.com/bdw/deploy/cmd/deploy/host" |
| 49 | 8 | "github.com/spf13/cobra" | |
| 50 | USAGE: | 9 | ) |
| 51 | deploy [flags] Deploy an app or static site | ||
| 52 | deploy <command> [flags] Run a subcommand | ||
| 53 | |||
| 54 | COMMANDS: | ||
| 55 | init Initialize a fresh VPS (one-time setup) | ||
| 56 | list List all deployed apps and sites | ||
| 57 | rm Remove a deployment | ||
| 58 | logs View logs for a deployment | ||
| 59 | status Check status of a deployment | ||
| 60 | restart Restart a deployment | ||
| 61 | env Manage environment variables | ||
| 62 | vps Show VPS health (uptime, disk, memory, load) | ||
| 63 | vps-update Update VPS packages (apt update && upgrade) | ||
| 64 | vps-ssh Open an interactive SSH session | ||
| 65 | webui Launch web UI to manage deployments | ||
| 66 | 10 | ||
| 67 | FLAGS: | 11 | var ( |
| 68 | Run 'deploy -h' or 'deploy <command> -h' for flags | 12 | // Persistent flags |
| 13 | hostFlag string | ||
| 69 | 14 | ||
| 70 | EXAMPLES: | 15 | // Version info (set via ldflags) |
| 71 | # Initialize VPS (sets it as default host) | 16 | version = "dev" |
| 72 | deploy init --host user@vps-ip | 17 | commit = "none" |
| 18 | date = "unknown" | ||
| 19 | ) | ||
| 73 | 20 | ||
| 74 | # Deploy Go app | 21 | var rootCmd = &cobra.Command{ |
| 75 | deploy --binary ./myapp --domain api.example.com | 22 | Use: "deploy", |
| 23 | Short: "Deploy Go apps and static sites to a VPS with automatic HTTPS", | ||
| 24 | Long: `deploy - Deploy Go apps and static sites to a VPS with automatic HTTPS | ||
| 76 | 25 | ||
| 77 | # Deploy static site | 26 | A CLI tool for deploying applications and static sites to a VPS. |
| 78 | deploy --static --dir ./dist --domain example.com | 27 | Uses Caddy for automatic HTTPS and systemd for service management.`, |
| 28 | RunE: runDeploy, | ||
| 29 | SilenceUsage: true, | ||
| 30 | SilenceErrors: true, | ||
| 31 | } | ||
| 79 | 32 | ||
| 80 | # List deployments | 33 | func init() { |
| 81 | deploy list | 34 | // Persistent flags available to all subcommands |
| 35 | rootCmd.PersistentFlags().StringVar(&hostFlag, "host", "", "VPS host (SSH config alias or user@host)") | ||
| 82 | 36 | ||
| 83 | # View logs | 37 | // Root command (deploy) flags |
| 84 | deploy logs myapp | 38 | rootCmd.Flags().String("binary", "", "Path to Go binary (for app deployment)") |
| 39 | rootCmd.Flags().Bool("static", false, "Deploy as static site") | ||
| 40 | rootCmd.Flags().String("dir", ".", "Directory to deploy (for static sites)") | ||
| 41 | rootCmd.Flags().String("domain", "", "Domain name (required)") | ||
| 42 | rootCmd.Flags().String("name", "", "App name (default: inferred from binary or directory)") | ||
| 43 | rootCmd.Flags().Int("port", 0, "Port override (default: auto-allocate)") | ||
| 44 | rootCmd.Flags().StringArray("env", nil, "Environment variable (KEY=VALUE, can be specified multiple times)") | ||
| 45 | rootCmd.Flags().String("env-file", "", "Path to .env file") | ||
| 46 | rootCmd.Flags().String("args", "", "Arguments to pass to binary") | ||
| 47 | rootCmd.Flags().StringArray("file", nil, "Config file to upload to working directory (can be specified multiple times)") | ||
| 85 | 48 | ||
| 86 | # Check VPS health | 49 | // Add subcommands |
| 87 | deploy vps | 50 | rootCmd.AddCommand(listCmd) |
| 51 | rootCmd.AddCommand(logsCmd) | ||
| 52 | rootCmd.AddCommand(statusCmd) | ||
| 53 | rootCmd.AddCommand(restartCmd) | ||
| 54 | rootCmd.AddCommand(removeCmd) | ||
| 55 | rootCmd.AddCommand(env.Cmd) | ||
| 56 | rootCmd.AddCommand(host.Cmd) | ||
| 57 | rootCmd.AddCommand(uiCmd) | ||
| 58 | rootCmd.AddCommand(versionCmd) | ||
| 59 | } | ||
| 88 | 60 | ||
| 89 | # Update VPS packages | 61 | func main() { |
| 90 | deploy vps-update | 62 | if err := rootCmd.Execute(); err != nil { |
| 91 | ` | 63 | os.Exit(1) |
| 92 | fmt.Fprint(os.Stderr, usage) | 64 | } |
| 93 | } | 65 | } |
diff --git a/cmd/deploy/manage.go b/cmd/deploy/manage.go deleted file mode 100644 index 1f52b92..0000000 --- a/cmd/deploy/manage.go +++ /dev/null | |||
| @@ -1,306 +0,0 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "flag" | ||
| 5 | "fmt" | ||
| 6 | "os" | ||
| 7 | |||
| 8 | "github.com/bdw/deploy/internal/ssh" | ||
| 9 | "github.com/bdw/deploy/internal/state" | ||
| 10 | ) | ||
| 11 | |||
| 12 | func runRemove(args []string) { | ||
| 13 | fs := flag.NewFlagSet("remove", flag.ExitOnError) | ||
| 14 | host := fs.String("host", "", "VPS host (SSH config alias or user@host)") | ||
| 15 | fs.Parse(args) | ||
| 16 | |||
| 17 | if len(fs.Args()) == 0 { | ||
| 18 | fmt.Fprintf(os.Stderr, "Error: app name is required\n") | ||
| 19 | fmt.Fprintf(os.Stderr, "Usage: deploy remove <app-name> --host user@vps-ip\n") | ||
| 20 | os.Exit(1) | ||
| 21 | } | ||
| 22 | |||
| 23 | name := fs.Args()[0] | ||
| 24 | |||
| 25 | // Load state | ||
| 26 | st, err := state.Load() | ||
| 27 | if err != nil { | ||
| 28 | fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) | ||
| 29 | os.Exit(1) | ||
| 30 | } | ||
| 31 | |||
| 32 | // Get host from flag or state default | ||
| 33 | if *host == "" { | ||
| 34 | *host = st.GetDefaultHost() | ||
| 35 | } | ||
| 36 | |||
| 37 | if *host == "" { | ||
| 38 | fmt.Fprintf(os.Stderr, "Error: --host is required\n") | ||
| 39 | fs.Usage() | ||
| 40 | os.Exit(1) | ||
| 41 | } | ||
| 42 | |||
| 43 | // Get app info | ||
| 44 | app, err := st.GetApp(*host, name) | ||
| 45 | if err != nil { | ||
| 46 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) | ||
| 47 | os.Exit(1) | ||
| 48 | } | ||
| 49 | |||
| 50 | fmt.Printf("Removing deployment: %s\n", name) | ||
| 51 | |||
| 52 | // Connect to VPS | ||
| 53 | client, err := ssh.Connect(*host) | ||
| 54 | if err != nil { | ||
| 55 | fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err) | ||
| 56 | os.Exit(1) | ||
| 57 | } | ||
| 58 | defer client.Close() | ||
| 59 | |||
| 60 | if app.Type == "app" { | ||
| 61 | // Stop and disable service | ||
| 62 | fmt.Println("→ Stopping service...") | ||
| 63 | client.RunSudo(fmt.Sprintf("systemctl stop %s", name)) | ||
| 64 | client.RunSudo(fmt.Sprintf("systemctl disable %s", name)) | ||
| 65 | |||
| 66 | // Remove systemd unit | ||
| 67 | client.RunSudo(fmt.Sprintf("rm -f /etc/systemd/system/%s.service", name)) | ||
| 68 | client.RunSudo("systemctl daemon-reload") | ||
| 69 | |||
| 70 | // Remove binary | ||
| 71 | client.RunSudo(fmt.Sprintf("rm -f /usr/local/bin/%s", name)) | ||
| 72 | |||
| 73 | // Remove working directory | ||
| 74 | client.RunSudo(fmt.Sprintf("rm -rf /var/lib/%s", name)) | ||
| 75 | |||
| 76 | // Remove env file | ||
| 77 | client.RunSudo(fmt.Sprintf("rm -f /etc/deploy/env/%s.env", name)) | ||
| 78 | |||
| 79 | // Remove user | ||
| 80 | client.RunSudo(fmt.Sprintf("userdel %s", name)) | ||
| 81 | } else { | ||
| 82 | // Remove static site files | ||
| 83 | fmt.Println("→ Removing files...") | ||
| 84 | client.RunSudo(fmt.Sprintf("rm -rf /var/www/%s", name)) | ||
| 85 | } | ||
| 86 | |||
| 87 | // Remove Caddy config | ||
| 88 | fmt.Println("→ Removing Caddy config...") | ||
| 89 | client.RunSudo(fmt.Sprintf("rm -f /etc/caddy/sites-enabled/%s.caddy", name)) | ||
| 90 | |||
| 91 | // Reload Caddy | ||
| 92 | fmt.Println("→ Reloading Caddy...") | ||
| 93 | if _, err := client.RunSudo("systemctl reload caddy"); err != nil { | ||
| 94 | fmt.Fprintf(os.Stderr, "Warning: Error reloading Caddy: %v\n", err) | ||
| 95 | } | ||
| 96 | |||
| 97 | // Update state | ||
| 98 | if err := st.RemoveApp(*host, name); err != nil { | ||
| 99 | fmt.Fprintf(os.Stderr, "Error updating state: %v\n", err) | ||
| 100 | os.Exit(1) | ||
| 101 | } | ||
| 102 | if err := st.Save(); err != nil { | ||
| 103 | fmt.Fprintf(os.Stderr, "Error saving state: %v\n", err) | ||
| 104 | os.Exit(1) | ||
| 105 | } | ||
| 106 | |||
| 107 | fmt.Printf("✓ Deployment removed successfully\n") | ||
| 108 | } | ||
| 109 | |||
| 110 | func runLogs(args []string) { | ||
| 111 | fs := flag.NewFlagSet("logs", flag.ExitOnError) | ||
| 112 | host := fs.String("host", "", "VPS host (SSH config alias or user@host)") | ||
| 113 | follow := fs.Bool("f", false, "Follow logs") | ||
| 114 | lines := fs.Int("n", 50, "Number of lines to show") | ||
| 115 | fs.Parse(args) | ||
| 116 | |||
| 117 | if len(fs.Args()) == 0 { | ||
| 118 | fmt.Fprintf(os.Stderr, "Error: app name is required\n") | ||
| 119 | fmt.Fprintf(os.Stderr, "Usage: deploy logs <app-name> --host user@vps-ip\n") | ||
| 120 | os.Exit(1) | ||
| 121 | } | ||
| 122 | |||
| 123 | name := fs.Args()[0] | ||
| 124 | |||
| 125 | // Load state | ||
| 126 | st, err := state.Load() | ||
| 127 | if err != nil { | ||
| 128 | fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) | ||
| 129 | os.Exit(1) | ||
| 130 | } | ||
| 131 | |||
| 132 | // Get host from flag or state default | ||
| 133 | if *host == "" { | ||
| 134 | *host = st.GetDefaultHost() | ||
| 135 | } | ||
| 136 | |||
| 137 | if *host == "" { | ||
| 138 | fmt.Fprintf(os.Stderr, "Error: --host is required\n") | ||
| 139 | fs.Usage() | ||
| 140 | os.Exit(1) | ||
| 141 | } | ||
| 142 | |||
| 143 | app, err := st.GetApp(*host, name) | ||
| 144 | if err != nil { | ||
| 145 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) | ||
| 146 | os.Exit(1) | ||
| 147 | } | ||
| 148 | |||
| 149 | if app.Type != "app" { | ||
| 150 | fmt.Fprintf(os.Stderr, "Error: logs are only available for apps, not static sites\n") | ||
| 151 | os.Exit(1) | ||
| 152 | } | ||
| 153 | |||
| 154 | // Connect to VPS | ||
| 155 | client, err := ssh.Connect(*host) | ||
| 156 | if err != nil { | ||
| 157 | fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err) | ||
| 158 | os.Exit(1) | ||
| 159 | } | ||
| 160 | defer client.Close() | ||
| 161 | |||
| 162 | // Build journalctl command | ||
| 163 | cmd := fmt.Sprintf("journalctl -u %s -n %d", name, *lines) | ||
| 164 | if *follow { | ||
| 165 | cmd += " -f" | ||
| 166 | } | ||
| 167 | |||
| 168 | // Run command | ||
| 169 | if *follow { | ||
| 170 | // Stream output for follow mode (no sudo needed for journalctl) | ||
| 171 | if err := client.RunStream(cmd); err != nil { | ||
| 172 | fmt.Fprintf(os.Stderr, "Error fetching logs: %v\n", err) | ||
| 173 | os.Exit(1) | ||
| 174 | } | ||
| 175 | } else { | ||
| 176 | // Buffer output for non-follow mode (no sudo needed for journalctl) | ||
| 177 | output, err := client.Run(cmd) | ||
| 178 | if err != nil { | ||
| 179 | fmt.Fprintf(os.Stderr, "Error fetching logs: %v\n", err) | ||
| 180 | os.Exit(1) | ||
| 181 | } | ||
| 182 | fmt.Print(output) | ||
| 183 | } | ||
| 184 | } | ||
| 185 | |||
| 186 | func runStatus(args []string) { | ||
| 187 | fs := flag.NewFlagSet("status", flag.ExitOnError) | ||
| 188 | host := fs.String("host", "", "VPS host (SSH config alias or user@host)") | ||
| 189 | fs.Parse(args) | ||
| 190 | |||
| 191 | if len(fs.Args()) == 0 { | ||
| 192 | fmt.Fprintf(os.Stderr, "Error: app name is required\n") | ||
| 193 | fmt.Fprintf(os.Stderr, "Usage: deploy status <app-name> --host user@vps-ip\n") | ||
| 194 | os.Exit(1) | ||
| 195 | } | ||
| 196 | |||
| 197 | name := fs.Args()[0] | ||
| 198 | |||
| 199 | // Load state | ||
| 200 | st, err := state.Load() | ||
| 201 | if err != nil { | ||
| 202 | fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) | ||
| 203 | os.Exit(1) | ||
| 204 | } | ||
| 205 | |||
| 206 | // Get host from flag or state default | ||
| 207 | if *host == "" { | ||
| 208 | *host = st.GetDefaultHost() | ||
| 209 | } | ||
| 210 | |||
| 211 | if *host == "" { | ||
| 212 | fmt.Fprintf(os.Stderr, "Error: --host is required\n") | ||
| 213 | fs.Usage() | ||
| 214 | os.Exit(1) | ||
| 215 | } | ||
| 216 | |||
| 217 | app, err := st.GetApp(*host, name) | ||
| 218 | if err != nil { | ||
| 219 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) | ||
| 220 | os.Exit(1) | ||
| 221 | } | ||
| 222 | |||
| 223 | if app.Type != "app" { | ||
| 224 | fmt.Fprintf(os.Stderr, "Error: status is only available for apps, not static sites\n") | ||
| 225 | os.Exit(1) | ||
| 226 | } | ||
| 227 | |||
| 228 | // Connect to VPS | ||
| 229 | client, err := ssh.Connect(*host) | ||
| 230 | if err != nil { | ||
| 231 | fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err) | ||
| 232 | os.Exit(1) | ||
| 233 | } | ||
| 234 | defer client.Close() | ||
| 235 | |||
| 236 | // Get status | ||
| 237 | output, err := client.RunSudo(fmt.Sprintf("systemctl status %s", name)) | ||
| 238 | if err != nil { | ||
| 239 | // systemctl status returns non-zero for non-active services | ||
| 240 | // but we still want to show the output | ||
| 241 | fmt.Print(output) | ||
| 242 | return | ||
| 243 | } | ||
| 244 | |||
| 245 | fmt.Print(output) | ||
| 246 | } | ||
| 247 | |||
| 248 | func runRestart(args []string) { | ||
| 249 | fs := flag.NewFlagSet("restart", flag.ExitOnError) | ||
| 250 | host := fs.String("host", "", "VPS host (SSH config alias or user@host)") | ||
| 251 | fs.Parse(args) | ||
| 252 | |||
| 253 | if len(fs.Args()) == 0 { | ||
| 254 | fmt.Fprintf(os.Stderr, "Error: app name is required\n") | ||
| 255 | fmt.Fprintf(os.Stderr, "Usage: deploy restart <app-name> --host user@vps-ip\n") | ||
| 256 | os.Exit(1) | ||
| 257 | } | ||
| 258 | |||
| 259 | name := fs.Args()[0] | ||
| 260 | |||
| 261 | // Load state | ||
| 262 | st, err := state.Load() | ||
| 263 | if err != nil { | ||
| 264 | fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) | ||
| 265 | os.Exit(1) | ||
| 266 | } | ||
| 267 | |||
| 268 | // Get host from flag or state default | ||
| 269 | if *host == "" { | ||
| 270 | *host = st.GetDefaultHost() | ||
| 271 | } | ||
| 272 | |||
| 273 | if *host == "" { | ||
| 274 | fmt.Fprintf(os.Stderr, "Error: --host is required\n") | ||
| 275 | fs.Usage() | ||
| 276 | os.Exit(1) | ||
| 277 | } | ||
| 278 | |||
| 279 | app, err := st.GetApp(*host, name) | ||
| 280 | if err != nil { | ||
| 281 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) | ||
| 282 | os.Exit(1) | ||
| 283 | } | ||
| 284 | |||
| 285 | if app.Type != "app" { | ||
| 286 | fmt.Fprintf(os.Stderr, "Error: restart is only available for apps, not static sites\n") | ||
| 287 | os.Exit(1) | ||
| 288 | } | ||
| 289 | |||
| 290 | // Connect to VPS | ||
| 291 | client, err := ssh.Connect(*host) | ||
| 292 | if err != nil { | ||
| 293 | fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err) | ||
| 294 | os.Exit(1) | ||
| 295 | } | ||
| 296 | defer client.Close() | ||
| 297 | |||
| 298 | // Restart service | ||
| 299 | fmt.Printf("Restarting %s...\n", name) | ||
| 300 | if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { | ||
| 301 | fmt.Fprintf(os.Stderr, "Error restarting service: %v\n", err) | ||
| 302 | os.Exit(1) | ||
| 303 | } | ||
| 304 | |||
| 305 | fmt.Println("✓ Service restarted successfully") | ||
| 306 | } | ||
diff --git a/cmd/deploy/remove.go b/cmd/deploy/remove.go new file mode 100644 index 0000000..5a98bf3 --- /dev/null +++ b/cmd/deploy/remove.go | |||
| @@ -0,0 +1,83 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | |||
| 6 | "github.com/bdw/deploy/internal/ssh" | ||
| 7 | "github.com/bdw/deploy/internal/state" | ||
| 8 | "github.com/spf13/cobra" | ||
| 9 | ) | ||
| 10 | |||
| 11 | var removeCmd = &cobra.Command{ | ||
| 12 | Use: "remove <app>", | ||
| 13 | Aliases: []string{"rm"}, | ||
| 14 | Short: "Remove a deployment", | ||
| 15 | Args: cobra.ExactArgs(1), | ||
| 16 | RunE: runRemove, | ||
| 17 | } | ||
| 18 | |||
| 19 | func runRemove(cmd *cobra.Command, args []string) error { | ||
| 20 | name := args[0] | ||
| 21 | |||
| 22 | st, err := state.Load() | ||
| 23 | if err != nil { | ||
| 24 | return fmt.Errorf("error loading state: %w", err) | ||
| 25 | } | ||
| 26 | |||
| 27 | host := hostFlag | ||
| 28 | if host == "" { | ||
| 29 | host = st.GetDefaultHost() | ||
| 30 | } | ||
| 31 | |||
| 32 | if host == "" { | ||
| 33 | return fmt.Errorf("--host is required") | ||
| 34 | } | ||
| 35 | |||
| 36 | app, err := st.GetApp(host, name) | ||
| 37 | if err != nil { | ||
| 38 | return err | ||
| 39 | } | ||
| 40 | |||
| 41 | fmt.Printf("Removing deployment: %s\n", name) | ||
| 42 | |||
| 43 | client, err := ssh.Connect(host) | ||
| 44 | if err != nil { | ||
| 45 | return fmt.Errorf("error connecting to VPS: %w", err) | ||
| 46 | } | ||
| 47 | defer client.Close() | ||
| 48 | |||
| 49 | if app.Type == "app" { | ||
| 50 | fmt.Println("-> Stopping service...") | ||
| 51 | client.RunSudo(fmt.Sprintf("systemctl stop %s", name)) | ||
| 52 | client.RunSudo(fmt.Sprintf("systemctl disable %s", name)) | ||
| 53 | |||
| 54 | client.RunSudo(fmt.Sprintf("rm -f /etc/systemd/system/%s.service", name)) | ||
| 55 | client.RunSudo("systemctl daemon-reload") | ||
| 56 | |||
| 57 | client.RunSudo(fmt.Sprintf("rm -f /usr/local/bin/%s", name)) | ||
| 58 | client.RunSudo(fmt.Sprintf("rm -rf /var/lib/%s", name)) | ||
| 59 | client.RunSudo(fmt.Sprintf("rm -f /etc/deploy/env/%s.env", name)) | ||
| 60 | client.RunSudo(fmt.Sprintf("userdel %s", name)) | ||
| 61 | } else { | ||
| 62 | fmt.Println("-> Removing files...") | ||
| 63 | client.RunSudo(fmt.Sprintf("rm -rf /var/www/%s", name)) | ||
| 64 | } | ||
| 65 | |||
| 66 | fmt.Println("-> Removing Caddy config...") | ||
| 67 | client.RunSudo(fmt.Sprintf("rm -f /etc/caddy/sites-enabled/%s.caddy", name)) | ||
| 68 | |||
| 69 | fmt.Println("-> Reloading Caddy...") | ||
| 70 | if _, err := client.RunSudo("systemctl reload caddy"); err != nil { | ||
| 71 | fmt.Printf("Warning: Error reloading Caddy: %v\n", err) | ||
| 72 | } | ||
| 73 | |||
| 74 | if err := st.RemoveApp(host, name); err != nil { | ||
| 75 | return fmt.Errorf("error updating state: %w", err) | ||
| 76 | } | ||
| 77 | if err := st.Save(); err != nil { | ||
| 78 | return fmt.Errorf("error saving state: %w", err) | ||
| 79 | } | ||
| 80 | |||
| 81 | fmt.Println("Deployment removed successfully") | ||
| 82 | return nil | ||
| 83 | } | ||
diff --git a/cmd/deploy/restart.go b/cmd/deploy/restart.go new file mode 100644 index 0000000..d1cfa86 --- /dev/null +++ b/cmd/deploy/restart.go | |||
| @@ -0,0 +1,57 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | |||
| 6 | "github.com/bdw/deploy/internal/ssh" | ||
| 7 | "github.com/bdw/deploy/internal/state" | ||
| 8 | "github.com/spf13/cobra" | ||
| 9 | ) | ||
| 10 | |||
| 11 | var restartCmd = &cobra.Command{ | ||
| 12 | Use: "restart <app>", | ||
| 13 | Short: "Restart a deployment", | ||
| 14 | Args: cobra.ExactArgs(1), | ||
| 15 | RunE: runRestart, | ||
| 16 | } | ||
| 17 | |||
| 18 | func runRestart(cmd *cobra.Command, args []string) error { | ||
| 19 | name := args[0] | ||
| 20 | |||
| 21 | st, err := state.Load() | ||
| 22 | if err != nil { | ||
| 23 | return fmt.Errorf("error loading state: %w", err) | ||
| 24 | } | ||
| 25 | |||
| 26 | host := hostFlag | ||
| 27 | if host == "" { | ||
| 28 | host = st.GetDefaultHost() | ||
| 29 | } | ||
| 30 | |||
| 31 | if host == "" { | ||
| 32 | return fmt.Errorf("--host is required") | ||
| 33 | } | ||
| 34 | |||
| 35 | app, err := st.GetApp(host, name) | ||
| 36 | if err != nil { | ||
| 37 | return err | ||
| 38 | } | ||
| 39 | |||
| 40 | if app.Type != "app" { | ||
| 41 | return fmt.Errorf("restart is only available for apps, not static sites") | ||
| 42 | } | ||
| 43 | |||
| 44 | client, err := ssh.Connect(host) | ||
| 45 | if err != nil { | ||
| 46 | return fmt.Errorf("error connecting to VPS: %w", err) | ||
| 47 | } | ||
| 48 | defer client.Close() | ||
| 49 | |||
| 50 | fmt.Printf("Restarting %s...\n", name) | ||
| 51 | if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { | ||
| 52 | return fmt.Errorf("error restarting service: %w", err) | ||
| 53 | } | ||
| 54 | |||
| 55 | fmt.Println("Service restarted successfully") | ||
| 56 | return nil | ||
| 57 | } | ||
diff --git a/cmd/deploy/root.go b/cmd/deploy/root.go new file mode 100644 index 0000000..adbc7c8 --- /dev/null +++ b/cmd/deploy/root.go | |||
| @@ -0,0 +1,377 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bufio" | ||
| 5 | "fmt" | ||
| 6 | "os" | ||
| 7 | "path/filepath" | ||
| 8 | "strconv" | ||
| 9 | "strings" | ||
| 10 | |||
| 11 | "github.com/bdw/deploy/internal/ssh" | ||
| 12 | "github.com/bdw/deploy/internal/state" | ||
| 13 | "github.com/bdw/deploy/internal/templates" | ||
| 14 | "github.com/spf13/cobra" | ||
| 15 | ) | ||
| 16 | |||
| 17 | func runDeploy(cmd *cobra.Command, args []string) error { | ||
| 18 | flags := cmd.Flags() | ||
| 19 | |||
| 20 | binary, _ := flags.GetString("binary") | ||
| 21 | static, _ := flags.GetBool("static") | ||
| 22 | dir, _ := flags.GetString("dir") | ||
| 23 | domain, _ := flags.GetString("domain") | ||
| 24 | name, _ := flags.GetString("name") | ||
| 25 | port, _ := flags.GetInt("port") | ||
| 26 | envVars, _ := flags.GetStringArray("env") | ||
| 27 | envFile, _ := flags.GetString("env-file") | ||
| 28 | binaryArgs, _ := flags.GetString("args") | ||
| 29 | files, _ := flags.GetStringArray("file") | ||
| 30 | |||
| 31 | // Get host from flag or state default | ||
| 32 | host := hostFlag | ||
| 33 | if host == "" { | ||
| 34 | st, err := state.Load() | ||
| 35 | if err != nil { | ||
| 36 | return fmt.Errorf("error loading state: %w", err) | ||
| 37 | } | ||
| 38 | host = st.GetDefaultHost() | ||
| 39 | } | ||
| 40 | |||
| 41 | // If no flags provided, show help | ||
| 42 | if domain == "" && binary == "" && !static { | ||
| 43 | return cmd.Help() | ||
| 44 | } | ||
| 45 | |||
| 46 | if host == "" || domain == "" { | ||
| 47 | return fmt.Errorf("--host and --domain are required") | ||
| 48 | } | ||
| 49 | |||
| 50 | if static { | ||
| 51 | return deployStatic(host, domain, name, dir) | ||
| 52 | } | ||
| 53 | return deployApp(host, domain, name, binary, port, envVars, envFile, binaryArgs, files) | ||
| 54 | } | ||
| 55 | |||
| 56 | func deployApp(host, domain, name, binaryPath string, portOverride int, envVars []string, envFile, args string, files []string) error { | ||
| 57 | if binaryPath == "" { | ||
| 58 | return fmt.Errorf("--binary is required") | ||
| 59 | } | ||
| 60 | |||
| 61 | if name == "" { | ||
| 62 | name = filepath.Base(binaryPath) | ||
| 63 | } | ||
| 64 | |||
| 65 | if _, err := os.Stat(binaryPath); err != nil { | ||
| 66 | return fmt.Errorf("binary not found: %s", binaryPath) | ||
| 67 | } | ||
| 68 | |||
| 69 | fmt.Printf("Deploying app: %s\n", name) | ||
| 70 | fmt.Printf(" Domain: %s\n", domain) | ||
| 71 | fmt.Printf(" Binary: %s\n", binaryPath) | ||
| 72 | |||
| 73 | st, err := state.Load() | ||
| 74 | if err != nil { | ||
| 75 | return fmt.Errorf("error loading state: %w", err) | ||
| 76 | } | ||
| 77 | |||
| 78 | existingApp, _ := st.GetApp(host, name) | ||
| 79 | var port int | ||
| 80 | if existingApp != nil { | ||
| 81 | port = existingApp.Port | ||
| 82 | fmt.Printf(" Updating existing deployment (port %d)\n", port) | ||
| 83 | } else { | ||
| 84 | if portOverride > 0 { | ||
| 85 | port = portOverride | ||
| 86 | } else { | ||
| 87 | port = st.AllocatePort(host) | ||
| 88 | } | ||
| 89 | fmt.Printf(" Allocated port: %d\n", port) | ||
| 90 | } | ||
| 91 | |||
| 92 | env := make(map[string]string) | ||
| 93 | if existingApp != nil { | ||
| 94 | for k, v := range existingApp.Env { | ||
| 95 | env[k] = v | ||
| 96 | } | ||
| 97 | if args == "" && existingApp.Args != "" { | ||
| 98 | args = existingApp.Args | ||
| 99 | } | ||
| 100 | if len(files) == 0 && len(existingApp.Files) > 0 { | ||
| 101 | files = existingApp.Files | ||
| 102 | } | ||
| 103 | } | ||
| 104 | |||
| 105 | for _, e := range envVars { | ||
| 106 | parts := strings.SplitN(e, "=", 2) | ||
| 107 | if len(parts) == 2 { | ||
| 108 | env[parts[0]] = parts[1] | ||
| 109 | } | ||
| 110 | } | ||
| 111 | |||
| 112 | if envFile != "" { | ||
| 113 | fileEnv, err := parseEnvFile(envFile) | ||
| 114 | if err != nil { | ||
| 115 | return fmt.Errorf("error reading env file: %w", err) | ||
| 116 | } | ||
| 117 | for k, v := range fileEnv { | ||
| 118 | env[k] = v | ||
| 119 | } | ||
| 120 | } | ||
| 121 | |||
| 122 | env["PORT"] = strconv.Itoa(port) | ||
| 123 | |||
| 124 | client, err := ssh.Connect(host) | ||
| 125 | if err != nil { | ||
| 126 | return fmt.Errorf("error connecting to VPS: %w", err) | ||
| 127 | } | ||
| 128 | defer client.Close() | ||
| 129 | |||
| 130 | fmt.Println("-> Uploading binary...") | ||
| 131 | remoteTmpPath := fmt.Sprintf("/tmp/%s", name) | ||
| 132 | if err := client.Upload(binaryPath, remoteTmpPath); err != nil { | ||
| 133 | return fmt.Errorf("error uploading binary: %w", err) | ||
| 134 | } | ||
| 135 | |||
| 136 | fmt.Println("-> Creating system user...") | ||
| 137 | client.RunSudo(fmt.Sprintf("useradd -r -s /bin/false %s", name)) | ||
| 138 | |||
| 139 | fmt.Println("-> Setting up directories...") | ||
| 140 | workDir := fmt.Sprintf("/var/lib/%s", name) | ||
| 141 | if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", workDir)); err != nil { | ||
| 142 | return fmt.Errorf("error creating work directory: %w", err) | ||
| 143 | } | ||
| 144 | if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", name, name, workDir)); err != nil { | ||
| 145 | return fmt.Errorf("error setting work directory ownership: %w", err) | ||
| 146 | } | ||
| 147 | |||
| 148 | fmt.Println("-> Installing binary...") | ||
| 149 | binaryDest := fmt.Sprintf("/usr/local/bin/%s", name) | ||
| 150 | if _, err := client.RunSudo(fmt.Sprintf("mv %s %s", remoteTmpPath, binaryDest)); err != nil { | ||
| 151 | return fmt.Errorf("error moving binary: %w", err) | ||
| 152 | } | ||
| 153 | if _, err := client.RunSudo(fmt.Sprintf("chmod +x %s", binaryDest)); err != nil { | ||
| 154 | return fmt.Errorf("error making binary executable: %w", err) | ||
| 155 | } | ||
| 156 | |||
| 157 | if len(files) > 0 { | ||
| 158 | fmt.Println("-> Uploading config files...") | ||
| 159 | for _, file := range files { | ||
| 160 | if _, err := os.Stat(file); err != nil { | ||
| 161 | return fmt.Errorf("config file not found: %s", file) | ||
| 162 | } | ||
| 163 | |||
| 164 | remotePath := fmt.Sprintf("%s/%s", workDir, filepath.Base(file)) | ||
| 165 | remoteTmpPath := fmt.Sprintf("/tmp/%s_%s", name, filepath.Base(file)) | ||
| 166 | |||
| 167 | if err := client.Upload(file, remoteTmpPath); err != nil { | ||
| 168 | return fmt.Errorf("error uploading config file %s: %w", file, err) | ||
| 169 | } | ||
| 170 | |||
| 171 | if _, err := client.RunSudo(fmt.Sprintf("mv %s %s", remoteTmpPath, remotePath)); err != nil { | ||
| 172 | return fmt.Errorf("error moving config file %s: %w", file, err) | ||
| 173 | } | ||
| 174 | |||
| 175 | if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", name, name, remotePath)); err != nil { | ||
| 176 | return fmt.Errorf("error setting config file ownership %s: %w", file, err) | ||
| 177 | } | ||
| 178 | |||
| 179 | fmt.Printf(" Uploaded: %s\n", file) | ||
| 180 | } | ||
| 181 | } | ||
| 182 | |||
| 183 | fmt.Println("-> Creating environment file...") | ||
| 184 | envFilePath := fmt.Sprintf("/etc/deploy/env/%s.env", name) | ||
| 185 | envContent := "" | ||
| 186 | for k, v := range env { | ||
| 187 | envContent += fmt.Sprintf("%s=%s\n", k, v) | ||
| 188 | } | ||
| 189 | if err := client.WriteSudoFile(envFilePath, envContent); err != nil { | ||
| 190 | return fmt.Errorf("error creating env file: %w", err) | ||
| 191 | } | ||
| 192 | if _, err := client.RunSudo(fmt.Sprintf("chmod 600 %s", envFilePath)); err != nil { | ||
| 193 | return fmt.Errorf("error setting env file permissions: %w", err) | ||
| 194 | } | ||
| 195 | if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", name, name, envFilePath)); err != nil { | ||
| 196 | return fmt.Errorf("error setting env file ownership: %w", err) | ||
| 197 | } | ||
| 198 | |||
| 199 | fmt.Println("-> Creating systemd service...") | ||
| 200 | serviceContent, err := templates.SystemdService(map[string]string{ | ||
| 201 | "Name": name, | ||
| 202 | "User": name, | ||
| 203 | "WorkDir": workDir, | ||
| 204 | "BinaryPath": binaryDest, | ||
| 205 | "Port": strconv.Itoa(port), | ||
| 206 | "EnvFile": envFilePath, | ||
| 207 | "Args": args, | ||
| 208 | }) | ||
| 209 | if err != nil { | ||
| 210 | return fmt.Errorf("error generating systemd unit: %w", err) | ||
| 211 | } | ||
| 212 | |||
| 213 | servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", name) | ||
| 214 | if err := client.WriteSudoFile(servicePath, serviceContent); err != nil { | ||
| 215 | return fmt.Errorf("error creating systemd unit: %w", err) | ||
| 216 | } | ||
| 217 | |||
| 218 | fmt.Println("-> Configuring Caddy...") | ||
| 219 | caddyContent, err := templates.AppCaddy(map[string]string{ | ||
| 220 | "Domain": domain, | ||
| 221 | "Port": strconv.Itoa(port), | ||
| 222 | }) | ||
| 223 | if err != nil { | ||
| 224 | return fmt.Errorf("error generating Caddy config: %w", err) | ||
| 225 | } | ||
| 226 | |||
| 227 | caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) | ||
| 228 | if err := client.WriteSudoFile(caddyPath, caddyContent); err != nil { | ||
| 229 | return fmt.Errorf("error creating Caddy config: %w", err) | ||
| 230 | } | ||
| 231 | |||
| 232 | fmt.Println("-> Reloading systemd...") | ||
| 233 | if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { | ||
| 234 | return fmt.Errorf("error reloading systemd: %w", err) | ||
| 235 | } | ||
| 236 | |||
| 237 | fmt.Println("-> Starting service...") | ||
| 238 | if _, err := client.RunSudo(fmt.Sprintf("systemctl enable %s", name)); err != nil { | ||
| 239 | return fmt.Errorf("error enabling service: %w", err) | ||
| 240 | } | ||
| 241 | if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { | ||
| 242 | return fmt.Errorf("error starting service: %w", err) | ||
| 243 | } | ||
| 244 | |||
| 245 | fmt.Println("-> Reloading Caddy...") | ||
| 246 | if _, err := client.RunSudo("systemctl reload caddy"); err != nil { | ||
| 247 | return fmt.Errorf("error reloading Caddy: %w", err) | ||
| 248 | } | ||
| 249 | |||
| 250 | st.AddApp(host, name, &state.App{ | ||
| 251 | Type: "app", | ||
| 252 | Domain: domain, | ||
| 253 | Port: port, | ||
| 254 | Env: env, | ||
| 255 | Args: args, | ||
| 256 | Files: files, | ||
| 257 | }) | ||
| 258 | if err := st.Save(); err != nil { | ||
| 259 | return fmt.Errorf("error saving state: %w", err) | ||
| 260 | } | ||
| 261 | |||
| 262 | fmt.Printf("\n App deployed successfully!\n") | ||
| 263 | fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", domain) | ||
| 264 | return nil | ||
| 265 | } | ||
| 266 | |||
| 267 | func deployStatic(host, domain, name, dir string) error { | ||
| 268 | if name == "" { | ||
| 269 | name = domain | ||
| 270 | } | ||
| 271 | |||
| 272 | if _, err := os.Stat(dir); err != nil { | ||
| 273 | return fmt.Errorf("directory not found: %s", dir) | ||
| 274 | } | ||
| 275 | |||
| 276 | fmt.Printf("Deploying static site: %s\n", name) | ||
| 277 | fmt.Printf(" Domain: %s\n", domain) | ||
| 278 | fmt.Printf(" Directory: %s\n", dir) | ||
| 279 | |||
| 280 | st, err := state.Load() | ||
| 281 | if err != nil { | ||
| 282 | return fmt.Errorf("error loading state: %w", err) | ||
| 283 | } | ||
| 284 | |||
| 285 | client, err := ssh.Connect(host) | ||
| 286 | if err != nil { | ||
| 287 | return fmt.Errorf("error connecting to VPS: %w", err) | ||
| 288 | } | ||
| 289 | defer client.Close() | ||
| 290 | |||
| 291 | remoteDir := fmt.Sprintf("/var/www/%s", name) | ||
| 292 | fmt.Println("-> Creating remote directory...") | ||
| 293 | if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", remoteDir)); err != nil { | ||
| 294 | return fmt.Errorf("error creating remote directory: %w", err) | ||
| 295 | } | ||
| 296 | |||
| 297 | currentUser, err := client.Run("whoami") | ||
| 298 | if err != nil { | ||
| 299 | return fmt.Errorf("error getting current user: %w", err) | ||
| 300 | } | ||
| 301 | currentUser = strings.TrimSpace(currentUser) | ||
| 302 | |||
| 303 | if _, err := client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", currentUser, currentUser, remoteDir)); err != nil { | ||
| 304 | return fmt.Errorf("error setting temporary ownership: %w", err) | ||
| 305 | } | ||
| 306 | |||
| 307 | fmt.Println("-> Uploading files...") | ||
| 308 | if err := client.UploadDir(dir, remoteDir); err != nil { | ||
| 309 | return fmt.Errorf("error uploading files: %w", err) | ||
| 310 | } | ||
| 311 | |||
| 312 | fmt.Println("-> Setting permissions...") | ||
| 313 | if _, err := client.RunSudo(fmt.Sprintf("chown -R www-data:www-data %s", remoteDir)); err != nil { | ||
| 314 | return fmt.Errorf("error setting ownership: %w", err) | ||
| 315 | } | ||
| 316 | if _, err := client.RunSudo(fmt.Sprintf("chmod -R 755 %s", remoteDir)); err != nil { | ||
| 317 | return fmt.Errorf("error setting directory permissions: %w", err) | ||
| 318 | } | ||
| 319 | if _, err := client.RunSudo(fmt.Sprintf("find %s -type f -exec chmod 644 {} \\;", remoteDir)); err != nil { | ||
| 320 | return fmt.Errorf("error setting file permissions: %w", err) | ||
| 321 | } | ||
| 322 | |||
| 323 | fmt.Println("-> Configuring Caddy...") | ||
| 324 | caddyContent, err := templates.StaticCaddy(map[string]string{ | ||
| 325 | "Domain": domain, | ||
| 326 | "RootDir": remoteDir, | ||
| 327 | }) | ||
| 328 | if err != nil { | ||
| 329 | return fmt.Errorf("error generating Caddy config: %w", err) | ||
| 330 | } | ||
| 331 | |||
| 332 | caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) | ||
| 333 | if err := client.WriteSudoFile(caddyPath, caddyContent); err != nil { | ||
| 334 | return fmt.Errorf("error creating Caddy config: %w", err) | ||
| 335 | } | ||
| 336 | |||
| 337 | fmt.Println("-> Reloading Caddy...") | ||
| 338 | if _, err := client.RunSudo("systemctl reload caddy"); err != nil { | ||
| 339 | return fmt.Errorf("error reloading Caddy: %w", err) | ||
| 340 | } | ||
| 341 | |||
| 342 | st.AddApp(host, name, &state.App{ | ||
| 343 | Type: "static", | ||
| 344 | Domain: domain, | ||
| 345 | }) | ||
| 346 | if err := st.Save(); err != nil { | ||
| 347 | return fmt.Errorf("error saving state: %w", err) | ||
| 348 | } | ||
| 349 | |||
| 350 | fmt.Printf("\n Static site deployed successfully!\n") | ||
| 351 | fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", domain) | ||
| 352 | return nil | ||
| 353 | } | ||
| 354 | |||
| 355 | func parseEnvFile(path string) (map[string]string, error) { | ||
| 356 | file, err := os.Open(path) | ||
| 357 | if err != nil { | ||
| 358 | return nil, err | ||
| 359 | } | ||
| 360 | defer file.Close() | ||
| 361 | |||
| 362 | env := make(map[string]string) | ||
| 363 | scanner := bufio.NewScanner(file) | ||
| 364 | for scanner.Scan() { | ||
| 365 | line := strings.TrimSpace(scanner.Text()) | ||
| 366 | if line == "" || strings.HasPrefix(line, "#") { | ||
| 367 | continue | ||
| 368 | } | ||
| 369 | |||
| 370 | parts := strings.SplitN(line, "=", 2) | ||
| 371 | if len(parts) == 2 { | ||
| 372 | env[parts[0]] = parts[1] | ||
| 373 | } | ||
| 374 | } | ||
| 375 | |||
| 376 | return env, scanner.Err() | ||
| 377 | } | ||
diff --git a/cmd/deploy/status.go b/cmd/deploy/status.go new file mode 100644 index 0000000..4bcfc68 --- /dev/null +++ b/cmd/deploy/status.go | |||
| @@ -0,0 +1,60 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | |||
| 6 | "github.com/bdw/deploy/internal/ssh" | ||
| 7 | "github.com/bdw/deploy/internal/state" | ||
| 8 | "github.com/spf13/cobra" | ||
| 9 | ) | ||
| 10 | |||
| 11 | var statusCmd = &cobra.Command{ | ||
| 12 | Use: "status <app>", | ||
| 13 | Short: "Check status of a deployment", | ||
| 14 | Args: cobra.ExactArgs(1), | ||
| 15 | RunE: runStatus, | ||
| 16 | } | ||
| 17 | |||
| 18 | func runStatus(cmd *cobra.Command, args []string) error { | ||
| 19 | name := args[0] | ||
| 20 | |||
| 21 | st, err := state.Load() | ||
| 22 | if err != nil { | ||
| 23 | return fmt.Errorf("error loading state: %w", err) | ||
| 24 | } | ||
| 25 | |||
| 26 | host := hostFlag | ||
| 27 | if host == "" { | ||
| 28 | host = st.GetDefaultHost() | ||
| 29 | } | ||
| 30 | |||
| 31 | if host == "" { | ||
| 32 | return fmt.Errorf("--host is required") | ||
| 33 | } | ||
| 34 | |||
| 35 | app, err := st.GetApp(host, name) | ||
| 36 | if err != nil { | ||
| 37 | return err | ||
| 38 | } | ||
| 39 | |||
| 40 | if app.Type != "app" { | ||
| 41 | return fmt.Errorf("status is only available for apps, not static sites") | ||
| 42 | } | ||
| 43 | |||
| 44 | client, err := ssh.Connect(host) | ||
| 45 | if err != nil { | ||
| 46 | return fmt.Errorf("error connecting to VPS: %w", err) | ||
| 47 | } | ||
| 48 | defer client.Close() | ||
| 49 | |||
| 50 | output, err := client.RunSudo(fmt.Sprintf("systemctl status %s", name)) | ||
| 51 | if err != nil { | ||
| 52 | // systemctl status returns non-zero for non-active services | ||
| 53 | // but we still want to show the output | ||
| 54 | fmt.Print(output) | ||
| 55 | return nil | ||
| 56 | } | ||
| 57 | |||
| 58 | fmt.Print(output) | ||
| 59 | return nil | ||
| 60 | } | ||
diff --git a/cmd/deploy/webui.go b/cmd/deploy/ui.go index f57400e..2ca88e0 100644 --- a/cmd/deploy/webui.go +++ b/cmd/deploy/ui.go | |||
| @@ -3,65 +3,45 @@ package main | |||
| 3 | import ( | 3 | import ( |
| 4 | "embed" | 4 | "embed" |
| 5 | "encoding/json" | 5 | "encoding/json" |
| 6 | "flag" | ||
| 7 | "fmt" | 6 | "fmt" |
| 8 | "html/template" | 7 | "html/template" |
| 9 | "net/http" | 8 | "net/http" |
| 10 | "os" | ||
| 11 | "sort" | 9 | "sort" |
| 12 | "strconv" | 10 | "strconv" |
| 13 | 11 | ||
| 14 | "github.com/bdw/deploy/internal/state" | 12 | "github.com/bdw/deploy/internal/state" |
| 15 | "github.com/bdw/deploy/internal/templates" | 13 | "github.com/bdw/deploy/internal/templates" |
| 14 | "github.com/spf13/cobra" | ||
| 16 | ) | 15 | ) |
| 17 | 16 | ||
| 18 | //go:embed templates/*.html | 17 | //go:embed templates/*.html |
| 19 | var templatesFS embed.FS | 18 | var templatesFS embed.FS |
| 20 | 19 | ||
| 21 | func runWebUI(args []string) { | 20 | var uiCmd = &cobra.Command{ |
| 22 | fs := flag.NewFlagSet("webui", flag.ExitOnError) | 21 | Use: "ui", |
| 23 | port := fs.String("port", "8080", "Port to run the web UI on") | 22 | Short: "Launch web management UI", |
| 24 | help := fs.Bool("h", false, "Show help") | 23 | RunE: runUI, |
| 25 | 24 | } | |
| 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 | 25 | ||
| 38 | EXAMPLE: | 26 | func init() { |
| 39 | # Launch web UI on default port (8080) | 27 | uiCmd.Flags().StringP("port", "p", "8080", "Port to run the web UI on") |
| 40 | deploy webui | 28 | } |
| 41 | 29 | ||
| 42 | # Launch on custom port | 30 | func runUI(cmd *cobra.Command, args []string) error { |
| 43 | deploy webui -port 3000 | 31 | port, _ := cmd.Flags().GetString("port") |
| 44 | `) | ||
| 45 | os.Exit(0) | ||
| 46 | } | ||
| 47 | 32 | ||
| 48 | // Parse template | ||
| 49 | tmpl, err := template.ParseFS(templatesFS, "templates/webui.html") | 33 | tmpl, err := template.ParseFS(templatesFS, "templates/webui.html") |
| 50 | if err != nil { | 34 | if err != nil { |
| 51 | fmt.Fprintf(os.Stderr, "Error parsing template: %v\n", err) | 35 | return fmt.Errorf("error parsing template: %w", err) |
| 52 | os.Exit(1) | ||
| 53 | } | 36 | } |
| 54 | 37 | ||
| 55 | // Handler for the main page | ||
| 56 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { | 38 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { |
| 57 | // Reload state on each request to show latest changes | ||
| 58 | st, err := state.Load() | 39 | st, err := state.Load() |
| 59 | if err != nil { | 40 | if err != nil { |
| 60 | http.Error(w, fmt.Sprintf("Error loading state: %v", err), http.StatusInternalServerError) | 41 | http.Error(w, fmt.Sprintf("Error loading state: %v", err), http.StatusInternalServerError) |
| 61 | return | 42 | return |
| 62 | } | 43 | } |
| 63 | 44 | ||
| 64 | // Prepare data for template | ||
| 65 | type AppData struct { | 45 | type AppData struct { |
| 66 | Name string | 46 | Name string |
| 67 | Type string | 47 | Type string |
| @@ -90,7 +70,6 @@ EXAMPLE: | |||
| 90 | }) | 70 | }) |
| 91 | } | 71 | } |
| 92 | 72 | ||
| 93 | // Sort apps by name | ||
| 94 | sort.Slice(apps, func(i, j int) bool { | 73 | sort.Slice(apps, func(i, j int) bool { |
| 95 | return apps[i].Name < apps[j].Name | 74 | return apps[i].Name < apps[j].Name |
| 96 | }) | 75 | }) |
| @@ -101,7 +80,6 @@ EXAMPLE: | |||
| 101 | }) | 80 | }) |
| 102 | } | 81 | } |
| 103 | 82 | ||
| 104 | // Sort hosts by name | ||
| 105 | sort.Slice(hosts, func(i, j int) bool { | 83 | sort.Slice(hosts, func(i, j int) bool { |
| 106 | return hosts[i].Host < hosts[j].Host | 84 | return hosts[i].Host < hosts[j].Host |
| 107 | }) | 85 | }) |
| @@ -118,7 +96,6 @@ EXAMPLE: | |||
| 118 | } | 96 | } |
| 119 | }) | 97 | }) |
| 120 | 98 | ||
| 121 | // API endpoint to get state as JSON | ||
| 122 | http.HandleFunc("/api/state", func(w http.ResponseWriter, r *http.Request) { | 99 | http.HandleFunc("/api/state", func(w http.ResponseWriter, r *http.Request) { |
| 123 | st, err := state.Load() | 100 | st, err := state.Load() |
| 124 | if err != nil { | 101 | if err != nil { |
| @@ -130,7 +107,6 @@ EXAMPLE: | |||
| 130 | json.NewEncoder(w).Encode(st) | 107 | json.NewEncoder(w).Encode(st) |
| 131 | }) | 108 | }) |
| 132 | 109 | ||
| 133 | // API endpoint to get rendered configs for an app | ||
| 134 | http.HandleFunc("/api/configs", func(w http.ResponseWriter, r *http.Request) { | 110 | http.HandleFunc("/api/configs", func(w http.ResponseWriter, r *http.Request) { |
| 135 | host := r.URL.Query().Get("host") | 111 | host := r.URL.Query().Get("host") |
| 136 | appName := r.URL.Query().Get("app") | 112 | appName := r.URL.Query().Get("app") |
| @@ -154,7 +130,6 @@ EXAMPLE: | |||
| 154 | 130 | ||
| 155 | configs := make(map[string]string) | 131 | configs := make(map[string]string) |
| 156 | 132 | ||
| 157 | // Render environment file | ||
| 158 | if app.Env != nil && len(app.Env) > 0 { | 133 | if app.Env != nil && len(app.Env) > 0 { |
| 159 | envContent := "" | 134 | envContent := "" |
| 160 | for k, v := range app.Env { | 135 | for k, v := range app.Env { |
| @@ -164,9 +139,7 @@ EXAMPLE: | |||
| 164 | configs["envPath"] = fmt.Sprintf("/etc/deploy/env/%s.env", appName) | 139 | configs["envPath"] = fmt.Sprintf("/etc/deploy/env/%s.env", appName) |
| 165 | } | 140 | } |
| 166 | 141 | ||
| 167 | // Render configs based on app type | ||
| 168 | if app.Type == "app" { | 142 | if app.Type == "app" { |
| 169 | // Render systemd service | ||
| 170 | workDir := fmt.Sprintf("/var/lib/%s", appName) | 143 | workDir := fmt.Sprintf("/var/lib/%s", appName) |
| 171 | binaryPath := fmt.Sprintf("/usr/local/bin/%s", appName) | 144 | binaryPath := fmt.Sprintf("/usr/local/bin/%s", appName) |
| 172 | envFilePath := fmt.Sprintf("/etc/deploy/env/%s.env", appName) | 145 | envFilePath := fmt.Sprintf("/etc/deploy/env/%s.env", appName) |
| @@ -187,7 +160,6 @@ EXAMPLE: | |||
| 187 | configs["systemd"] = serviceContent | 160 | configs["systemd"] = serviceContent |
| 188 | configs["systemdPath"] = fmt.Sprintf("/etc/systemd/system/%s.service", appName) | 161 | configs["systemdPath"] = fmt.Sprintf("/etc/systemd/system/%s.service", appName) |
| 189 | 162 | ||
| 190 | // Render Caddy config | ||
| 191 | caddyContent, err := templates.AppCaddy(map[string]string{ | 163 | caddyContent, err := templates.AppCaddy(map[string]string{ |
| 192 | "Domain": app.Domain, | 164 | "Domain": app.Domain, |
| 193 | "Port": strconv.Itoa(app.Port), | 165 | "Port": strconv.Itoa(app.Port), |
| @@ -199,7 +171,6 @@ EXAMPLE: | |||
| 199 | configs["caddy"] = caddyContent | 171 | configs["caddy"] = caddyContent |
| 200 | configs["caddyPath"] = fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", appName) | 172 | configs["caddyPath"] = fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", appName) |
| 201 | } else if app.Type == "static" { | 173 | } else if app.Type == "static" { |
| 202 | // Render Caddy config for static site | ||
| 203 | remoteDir := fmt.Sprintf("/var/www/%s", appName) | 174 | remoteDir := fmt.Sprintf("/var/www/%s", appName) |
| 204 | caddyContent, err := templates.StaticCaddy(map[string]string{ | 175 | caddyContent, err := templates.StaticCaddy(map[string]string{ |
| 205 | "Domain": app.Domain, | 176 | "Domain": app.Domain, |
| @@ -217,12 +188,12 @@ EXAMPLE: | |||
| 217 | json.NewEncoder(w).Encode(configs) | 188 | json.NewEncoder(w).Encode(configs) |
| 218 | }) | 189 | }) |
| 219 | 190 | ||
| 220 | addr := fmt.Sprintf("localhost:%s", *port) | 191 | addr := fmt.Sprintf("localhost:%s", port) |
| 221 | fmt.Printf("Starting web UI on http://%s\n", addr) | 192 | fmt.Printf("Starting web UI on http://%s\n", addr) |
| 222 | fmt.Printf("Press Ctrl+C to stop\n") | 193 | fmt.Printf("Press Ctrl+C to stop\n") |
| 223 | 194 | ||
| 224 | if err := http.ListenAndServe(addr, nil); err != nil { | 195 | if err := http.ListenAndServe(addr, nil); err != nil { |
| 225 | fmt.Fprintf(os.Stderr, "Error starting server: %v\n", err) | 196 | return fmt.Errorf("error starting server: %w", err) |
| 226 | os.Exit(1) | ||
| 227 | } | 197 | } |
| 198 | return nil | ||
| 228 | } | 199 | } |
diff --git a/cmd/deploy/version.go b/cmd/deploy/version.go new file mode 100644 index 0000000..d2cd430 --- /dev/null +++ b/cmd/deploy/version.go | |||
| @@ -0,0 +1,17 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | |||
| 6 | "github.com/spf13/cobra" | ||
| 7 | ) | ||
| 8 | |||
| 9 | var versionCmd = &cobra.Command{ | ||
| 10 | Use: "version", | ||
| 11 | Short: "Show version information", | ||
| 12 | Run: func(cmd *cobra.Command, args []string) { | ||
| 13 | fmt.Printf("deploy version %s\n", version) | ||
| 14 | fmt.Printf(" commit: %s\n", commit) | ||
| 15 | fmt.Printf(" built: %s\n", date) | ||
| 16 | }, | ||
| 17 | } | ||
diff --git a/cmd/deploy/vps.go b/cmd/deploy/vps.go deleted file mode 100644 index bd6278b..0000000 --- a/cmd/deploy/vps.go +++ /dev/null | |||
| @@ -1,229 +0,0 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "flag" | ||
| 5 | "fmt" | ||
| 6 | "os" | ||
| 7 | "os/exec" | ||
| 8 | |||
| 9 | "github.com/bdw/deploy/internal/ssh" | ||
| 10 | "github.com/bdw/deploy/internal/state" | ||
| 11 | ) | ||
| 12 | |||
| 13 | func runVPS(args []string) { | ||
| 14 | fs := flag.NewFlagSet("vps", flag.ExitOnError) | ||
| 15 | host := fs.String("host", "", "VPS host (SSH config alias or user@host)") | ||
| 16 | fs.Parse(args) | ||
| 17 | |||
| 18 | // Load state | ||
| 19 | st, err := state.Load() | ||
| 20 | if err != nil { | ||
| 21 | fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) | ||
| 22 | os.Exit(1) | ||
| 23 | } | ||
| 24 | |||
| 25 | // Get host from flag or state default | ||
| 26 | if *host == "" { | ||
| 27 | *host = st.GetDefaultHost() | ||
| 28 | } | ||
| 29 | |||
| 30 | if *host == "" { | ||
| 31 | fmt.Fprintf(os.Stderr, "Error: --host is required (no default host set)\n") | ||
| 32 | fs.Usage() | ||
| 33 | os.Exit(1) | ||
| 34 | } | ||
| 35 | |||
| 36 | fmt.Printf("Connecting to %s...\n\n", *host) | ||
| 37 | |||
| 38 | // Connect to VPS | ||
| 39 | client, err := ssh.Connect(*host) | ||
| 40 | if err != nil { | ||
| 41 | fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err) | ||
| 42 | os.Exit(1) | ||
| 43 | } | ||
| 44 | defer client.Close() | ||
| 45 | |||
| 46 | // Uptime | ||
| 47 | fmt.Println("UPTIME") | ||
| 48 | if output, err := client.Run("uptime -p"); err == nil { | ||
| 49 | fmt.Printf(" %s", output) | ||
| 50 | } | ||
| 51 | if output, err := client.Run("uptime -s"); err == nil { | ||
| 52 | fmt.Printf(" Since: %s", output) | ||
| 53 | } | ||
| 54 | fmt.Println() | ||
| 55 | |||
| 56 | // Load average | ||
| 57 | fmt.Println("LOAD") | ||
| 58 | if output, err := client.Run("cat /proc/loadavg | awk '{print $1, $2, $3}'"); err == nil { | ||
| 59 | fmt.Printf(" 1m, 5m, 15m: %s", output) | ||
| 60 | } | ||
| 61 | fmt.Println() | ||
| 62 | |||
| 63 | // Memory | ||
| 64 | fmt.Println("MEMORY") | ||
| 65 | if output, err := client.Run("free -h | awk 'NR==2 {print \" Used: \" $3 \" / \" $2}'"); err == nil { | ||
| 66 | fmt.Print(output) | ||
| 67 | } | ||
| 68 | if output, err := client.Run("free -h | awk 'NR==2 {printf \" Available: %s\\n\", $7}'"); err == nil { | ||
| 69 | fmt.Print(output) | ||
| 70 | } | ||
| 71 | fmt.Println() | ||
| 72 | |||
| 73 | // Disk | ||
| 74 | fmt.Println("DISK") | ||
| 75 | if output, err := client.Run("df -h / | awk 'NR==2 {print \" Used: \" $3 \" / \" $2 \" (\" $5 \")\"}'"); err == nil { | ||
| 76 | fmt.Print(output) | ||
| 77 | } | ||
| 78 | if output, err := client.Run("df -h / | awk 'NR==2 {print \" Available: \" $4}'"); err == nil { | ||
| 79 | fmt.Print(output) | ||
| 80 | } | ||
| 81 | fmt.Println() | ||
| 82 | |||
| 83 | // Updates available | ||
| 84 | fmt.Println("UPDATES") | ||
| 85 | if output, err := client.Run("[ -f /var/lib/update-notifier/updates-available ] && cat /var/lib/update-notifier/updates-available | head -2 || echo ' (update info not available)'"); err == nil { | ||
| 86 | fmt.Print(output) | ||
| 87 | } | ||
| 88 | fmt.Println() | ||
| 89 | |||
| 90 | // Services | ||
| 91 | fmt.Println("SERVICES") | ||
| 92 | if output, err := client.Run("systemctl is-active caddy 2>/dev/null && echo ' Caddy: active' || echo ' Caddy: inactive'"); err == nil { | ||
| 93 | // Parse the output | ||
| 94 | if output == "active\n" { | ||
| 95 | fmt.Println(" Caddy: active") | ||
| 96 | } else { | ||
| 97 | fmt.Println(" Caddy: inactive") | ||
| 98 | } | ||
| 99 | } | ||
| 100 | |||
| 101 | // Count deployed apps that are running | ||
| 102 | hostState := st.GetHost(*host) | ||
| 103 | if hostState != nil && len(hostState.Apps) > 0 { | ||
| 104 | activeCount := 0 | ||
| 105 | for name, app := range hostState.Apps { | ||
| 106 | if app.Type == "app" { | ||
| 107 | if output, err := client.Run(fmt.Sprintf("systemctl is-active %s 2>/dev/null", name)); err == nil && output == "active\n" { | ||
| 108 | activeCount++ | ||
| 109 | } | ||
| 110 | } | ||
| 111 | } | ||
| 112 | appCount := 0 | ||
| 113 | for _, app := range hostState.Apps { | ||
| 114 | if app.Type == "app" { | ||
| 115 | appCount++ | ||
| 116 | } | ||
| 117 | } | ||
| 118 | fmt.Printf(" Deployed apps: %d/%d active\n", activeCount, appCount) | ||
| 119 | } | ||
| 120 | } | ||
| 121 | |||
| 122 | func runUpdate(args []string) { | ||
| 123 | fs := flag.NewFlagSet("vps-update", flag.ExitOnError) | ||
| 124 | host := fs.String("host", "", "VPS host (SSH config alias or user@host)") | ||
| 125 | yes := fs.Bool("y", false, "Skip confirmation prompt") | ||
| 126 | fs.Parse(args) | ||
| 127 | |||
| 128 | // Load state | ||
| 129 | st, err := state.Load() | ||
| 130 | if err != nil { | ||
| 131 | fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) | ||
| 132 | os.Exit(1) | ||
| 133 | } | ||
| 134 | |||
| 135 | // Get host from flag or state default | ||
| 136 | if *host == "" { | ||
| 137 | *host = st.GetDefaultHost() | ||
| 138 | } | ||
| 139 | |||
| 140 | if *host == "" { | ||
| 141 | fmt.Fprintf(os.Stderr, "Error: --host is required (no default host set)\n") | ||
| 142 | fs.Usage() | ||
| 143 | os.Exit(1) | ||
| 144 | } | ||
| 145 | |||
| 146 | // Confirm unless -y flag is set | ||
| 147 | if !*yes { | ||
| 148 | fmt.Printf("This will run apt update && apt upgrade on %s\n", *host) | ||
| 149 | fmt.Print("Continue? [y/N]: ") | ||
| 150 | var response string | ||
| 151 | fmt.Scanln(&response) | ||
| 152 | if response != "y" && response != "Y" { | ||
| 153 | fmt.Println("Aborted.") | ||
| 154 | return | ||
| 155 | } | ||
| 156 | } | ||
| 157 | |||
| 158 | fmt.Printf("Connecting to %s...\n", *host) | ||
| 159 | |||
| 160 | // Connect to VPS | ||
| 161 | client, err := ssh.Connect(*host) | ||
| 162 | if err != nil { | ||
| 163 | fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err) | ||
| 164 | os.Exit(1) | ||
| 165 | } | ||
| 166 | defer client.Close() | ||
| 167 | |||
| 168 | // Run apt update | ||
| 169 | fmt.Println("\n→ Running apt update...") | ||
| 170 | if err := client.RunSudoStream("apt update"); err != nil { | ||
| 171 | fmt.Fprintf(os.Stderr, "Error running apt update: %v\n", err) | ||
| 172 | os.Exit(1) | ||
| 173 | } | ||
| 174 | |||
| 175 | // Run apt upgrade | ||
| 176 | fmt.Println("\n→ Running apt upgrade...") | ||
| 177 | if err := client.RunSudoStream("DEBIAN_FRONTEND=noninteractive apt upgrade -y"); err != nil { | ||
| 178 | fmt.Fprintf(os.Stderr, "Error running apt upgrade: %v\n", err) | ||
| 179 | os.Exit(1) | ||
| 180 | } | ||
| 181 | |||
| 182 | // Check if reboot is required | ||
| 183 | fmt.Println() | ||
| 184 | if output, err := client.Run("[ -f /var/run/reboot-required ] && echo 'yes' || echo 'no'"); err == nil { | ||
| 185 | if output == "yes\n" { | ||
| 186 | fmt.Println("Note: A reboot is required to complete the update.") | ||
| 187 | } | ||
| 188 | } | ||
| 189 | |||
| 190 | fmt.Println("✓ Update complete") | ||
| 191 | } | ||
| 192 | |||
| 193 | func runSSH(args []string) { | ||
| 194 | fs := flag.NewFlagSet("vps-ssh", flag.ExitOnError) | ||
| 195 | host := fs.String("host", "", "VPS host (SSH config alias or user@host)") | ||
| 196 | fs.Parse(args) | ||
| 197 | |||
| 198 | // Load state | ||
| 199 | st, err := state.Load() | ||
| 200 | if err != nil { | ||
| 201 | fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) | ||
| 202 | os.Exit(1) | ||
| 203 | } | ||
| 204 | |||
| 205 | // Get host from flag or state default | ||
| 206 | if *host == "" { | ||
| 207 | *host = st.GetDefaultHost() | ||
| 208 | } | ||
| 209 | |||
| 210 | if *host == "" { | ||
| 211 | fmt.Fprintf(os.Stderr, "Error: --host is required (no default host set)\n") | ||
| 212 | fs.Usage() | ||
| 213 | os.Exit(1) | ||
| 214 | } | ||
| 215 | |||
| 216 | // Launch interactive SSH session | ||
| 217 | cmd := exec.Command("ssh", *host) | ||
| 218 | cmd.Stdin = os.Stdin | ||
| 219 | cmd.Stdout = os.Stdout | ||
| 220 | cmd.Stderr = os.Stderr | ||
| 221 | |||
| 222 | if err := cmd.Run(); err != nil { | ||
| 223 | if exitErr, ok := err.(*exec.ExitError); ok { | ||
| 224 | os.Exit(exitErr.ExitCode()) | ||
| 225 | } | ||
| 226 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) | ||
| 227 | os.Exit(1) | ||
| 228 | } | ||
| 229 | } | ||
