diff options
| author | bndw <ben@bdw.to> | 2026-01-25 08:06:56 -0800 |
|---|---|---|
| committer | bndw <ben@bdw.to> | 2026-01-25 08:06:56 -0800 |
| commit | 85e97bf1ebe339513ab0661f6cc1ab1ff17a46cb (patch) | |
| tree | bd65f952b632375d0512aaf32ce17fd79ae1b262 /cmd | |
| parent | 8a3cff0dd7eb88cadb73a6df4e14f85450d63317 (diff) | |
Add CPU and memory limits for apps
Adds --memory and --cpu flags to set systemd resource limits:
ship --binary ./app --memory 512M --cpu 100%
Also adds config update mode - use --name without --binary to
update an existing app's config without redeploying the binary:
ship --name myapp --cpu 50%
ship --name myapp --memory 256M --env DEBUG=true
Limits are stored in state and preserved on redeploy.
Diffstat (limited to 'cmd')
| -rw-r--r-- | cmd/ship/main.go | 2 | ||||
| -rw-r--r-- | cmd/ship/root.go | 138 |
2 files changed, 136 insertions, 4 deletions
diff --git a/cmd/ship/main.go b/cmd/ship/main.go index a6984ec..47dd579 100644 --- a/cmd/ship/main.go +++ b/cmd/ship/main.go | |||
| @@ -65,6 +65,8 @@ func init() { | |||
| 65 | rootCmd.Flags().String("env-file", "", "Path to .env file") | 65 | rootCmd.Flags().String("env-file", "", "Path to .env file") |
| 66 | rootCmd.Flags().String("args", "", "Arguments to pass to binary") | 66 | rootCmd.Flags().String("args", "", "Arguments to pass to binary") |
| 67 | rootCmd.Flags().StringArray("file", nil, "Config file to upload to working directory (can be specified multiple times)") | 67 | rootCmd.Flags().StringArray("file", nil, "Config file to upload to working directory (can be specified multiple times)") |
| 68 | rootCmd.Flags().String("memory", "", "Memory limit (e.g., 512M, 1G)") | ||
| 69 | rootCmd.Flags().String("cpu", "", "CPU limit (e.g., 50%, 200% for 2 cores)") | ||
| 68 | 70 | ||
| 69 | // Add subcommands | 71 | // Add subcommands |
| 70 | rootCmd.AddCommand(listCmd) | 72 | rootCmd.AddCommand(listCmd) |
diff --git a/cmd/ship/root.go b/cmd/ship/root.go index 81d33d1..d63772b 100644 --- a/cmd/ship/root.go +++ b/cmd/ship/root.go | |||
| @@ -27,6 +27,8 @@ func runDeploy(cmd *cobra.Command, args []string) error { | |||
| 27 | envFile, _ := flags.GetString("env-file") | 27 | envFile, _ := flags.GetString("env-file") |
| 28 | binaryArgs, _ := flags.GetString("args") | 28 | binaryArgs, _ := flags.GetString("args") |
| 29 | files, _ := flags.GetStringArray("file") | 29 | files, _ := flags.GetStringArray("file") |
| 30 | memory, _ := flags.GetString("memory") | ||
| 31 | cpu, _ := flags.GetString("cpu") | ||
| 30 | 32 | ||
| 31 | // Get host from flag or state default | 33 | // Get host from flag or state default |
| 32 | host := hostFlag | 34 | host := hostFlag |
| @@ -39,7 +41,7 @@ func runDeploy(cmd *cobra.Command, args []string) error { | |||
| 39 | } | 41 | } |
| 40 | 42 | ||
| 41 | // If no flags provided, show help | 43 | // If no flags provided, show help |
| 42 | if domain == "" && binary == "" && !static { | 44 | if domain == "" && binary == "" && !static && name == "" { |
| 43 | return cmd.Help() | 45 | return cmd.Help() |
| 44 | } | 46 | } |
| 45 | 47 | ||
| @@ -54,6 +56,11 @@ func runDeploy(cmd *cobra.Command, args []string) error { | |||
| 54 | } | 56 | } |
| 55 | hostState := st.GetHost(host) | 57 | hostState := st.GetHost(host) |
| 56 | 58 | ||
| 59 | // Config update mode: --name provided without --binary or --static | ||
| 60 | if name != "" && binary == "" && !static { | ||
| 61 | return updateAppConfig(host, name, envVars, envFile, binaryArgs, memory, cpu) | ||
| 62 | } | ||
| 63 | |||
| 57 | if domain == "" && hostState.BaseDomain == "" { | 64 | if domain == "" && hostState.BaseDomain == "" { |
| 58 | return fmt.Errorf("--domain required (or configure base domain with 'ship host init --base-domain')") | 65 | return fmt.Errorf("--domain required (or configure base domain with 'ship host init --base-domain')") |
| 59 | } | 66 | } |
| @@ -84,10 +91,10 @@ func runDeploy(cmd *cobra.Command, args []string) error { | |||
| 84 | if static { | 91 | if static { |
| 85 | return deployStatic(host, combinedDomains, inferredName, dir) | 92 | return deployStatic(host, combinedDomains, inferredName, dir) |
| 86 | } | 93 | } |
| 87 | return deployApp(host, combinedDomains, inferredName, binary, port, envVars, envFile, binaryArgs, files) | 94 | return deployApp(host, combinedDomains, inferredName, binary, port, envVars, envFile, binaryArgs, files, memory, cpu) |
| 88 | } | 95 | } |
| 89 | 96 | ||
| 90 | func deployApp(host, domain, name, binaryPath string, portOverride int, envVars []string, envFile, args string, files []string) error { | 97 | func deployApp(host, domain, name, binaryPath string, portOverride int, envVars []string, envFile, args string, files []string, memory, cpu string) error { |
| 91 | if binaryPath == "" { | 98 | if binaryPath == "" { |
| 92 | return fmt.Errorf("--binary is required") | 99 | return fmt.Errorf("--binary is required") |
| 93 | } | 100 | } |
| @@ -130,6 +137,12 @@ func deployApp(host, domain, name, binaryPath string, portOverride int, envVars | |||
| 130 | if len(files) == 0 && len(existingApp.Files) > 0 { | 137 | if len(files) == 0 && len(existingApp.Files) > 0 { |
| 131 | files = existingApp.Files | 138 | files = existingApp.Files |
| 132 | } | 139 | } |
| 140 | if memory == "" && existingApp.Memory != "" { | ||
| 141 | memory = existingApp.Memory | ||
| 142 | } | ||
| 143 | if cpu == "" && existingApp.CPU != "" { | ||
| 144 | cpu = existingApp.CPU | ||
| 145 | } | ||
| 133 | } | 146 | } |
| 134 | 147 | ||
| 135 | for _, e := range envVars { | 148 | for _, e := range envVars { |
| @@ -235,6 +248,8 @@ func deployApp(host, domain, name, binaryPath string, portOverride int, envVars | |||
| 235 | "Port": strconv.Itoa(port), | 248 | "Port": strconv.Itoa(port), |
| 236 | "EnvFile": envFilePath, | 249 | "EnvFile": envFilePath, |
| 237 | "Args": args, | 250 | "Args": args, |
| 251 | "Memory": memory, | ||
| 252 | "CPU": cpu, | ||
| 238 | }) | 253 | }) |
| 239 | if err != nil { | 254 | if err != nil { |
| 240 | return fmt.Errorf("error generating systemd unit: %w", err) | 255 | return fmt.Errorf("error generating systemd unit: %w", err) |
| @@ -284,6 +299,8 @@ func deployApp(host, domain, name, binaryPath string, portOverride int, envVars | |||
| 284 | Env: env, | 299 | Env: env, |
| 285 | Args: args, | 300 | Args: args, |
| 286 | Files: files, | 301 | Files: files, |
| 302 | Memory: memory, | ||
| 303 | CPU: cpu, | ||
| 287 | }) | 304 | }) |
| 288 | if err := st.Save(); err != nil { | 305 | if err := st.Save(); err != nil { |
| 289 | return fmt.Errorf("error saving state: %w", err) | 306 | return fmt.Errorf("error saving state: %w", err) |
| @@ -297,8 +314,121 @@ func deployApp(host, domain, name, binaryPath string, portOverride int, envVars | |||
| 297 | return nil | 314 | return nil |
| 298 | } | 315 | } |
| 299 | 316 | ||
| 300 | func deployStatic(host, domain, name, dir string) error { | 317 | func updateAppConfig(host, name string, envVars []string, envFile, args, memory, cpu string) error { |
| 318 | st, err := state.Load() | ||
| 319 | if err != nil { | ||
| 320 | return fmt.Errorf("error loading state: %w", err) | ||
| 321 | } | ||
| 322 | |||
| 323 | existingApp, err := st.GetApp(host, name) | ||
| 324 | if err != nil { | ||
| 325 | return fmt.Errorf("app %s not found (use --binary to deploy a new app)", name) | ||
| 326 | } | ||
| 327 | |||
| 328 | if existingApp.Type != "app" { | ||
| 329 | return fmt.Errorf("%s is a static site, not an app", name) | ||
| 330 | } | ||
| 331 | |||
| 332 | fmt.Printf("Updating config: %s\n", name) | ||
| 333 | |||
| 334 | // Merge with existing values | ||
| 335 | if args == "" { | ||
| 336 | args = existingApp.Args | ||
| 337 | } | ||
| 338 | if memory == "" { | ||
| 339 | memory = existingApp.Memory | ||
| 340 | } | ||
| 341 | if cpu == "" { | ||
| 342 | cpu = existingApp.CPU | ||
| 343 | } | ||
| 344 | |||
| 345 | // Merge env vars | ||
| 346 | env := make(map[string]string) | ||
| 347 | for k, v := range existingApp.Env { | ||
| 348 | env[k] = v | ||
| 349 | } | ||
| 350 | for _, e := range envVars { | ||
| 351 | parts := strings.SplitN(e, "=", 2) | ||
| 352 | if len(parts) == 2 { | ||
| 353 | env[parts[0]] = parts[1] | ||
| 354 | } | ||
| 355 | } | ||
| 356 | if envFile != "" { | ||
| 357 | fileEnv, err := parseEnvFile(envFile) | ||
| 358 | if err != nil { | ||
| 359 | return fmt.Errorf("error reading env file: %w", err) | ||
| 360 | } | ||
| 361 | for k, v := range fileEnv { | ||
| 362 | env[k] = v | ||
| 363 | } | ||
| 364 | } | ||
| 365 | env["PORT"] = strconv.Itoa(existingApp.Port) | ||
| 301 | 366 | ||
| 367 | client, err := ssh.Connect(host) | ||
| 368 | if err != nil { | ||
| 369 | return fmt.Errorf("error connecting to VPS: %w", err) | ||
| 370 | } | ||
| 371 | defer client.Close() | ||
| 372 | |||
| 373 | // Update env file | ||
| 374 | fmt.Println("-> Updating environment file...") | ||
| 375 | envFilePath := fmt.Sprintf("/etc/ship/env/%s.env", name) | ||
| 376 | envContent := "" | ||
| 377 | for k, v := range env { | ||
| 378 | envContent += fmt.Sprintf("%s=%s\n", k, v) | ||
| 379 | } | ||
| 380 | if err := client.WriteSudoFile(envFilePath, envContent); err != nil { | ||
| 381 | return fmt.Errorf("error creating env file: %w", err) | ||
| 382 | } | ||
| 383 | |||
| 384 | // Regenerate systemd unit | ||
| 385 | fmt.Println("-> Updating systemd service...") | ||
| 386 | workDir := fmt.Sprintf("/var/lib/%s", name) | ||
| 387 | binaryDest := fmt.Sprintf("/usr/local/bin/%s", name) | ||
| 388 | serviceContent, err := templates.SystemdService(map[string]string{ | ||
| 389 | "Name": name, | ||
| 390 | "User": name, | ||
| 391 | "WorkDir": workDir, | ||
| 392 | "BinaryPath": binaryDest, | ||
| 393 | "Port": strconv.Itoa(existingApp.Port), | ||
| 394 | "EnvFile": envFilePath, | ||
| 395 | "Args": args, | ||
| 396 | "Memory": memory, | ||
| 397 | "CPU": cpu, | ||
| 398 | }) | ||
| 399 | if err != nil { | ||
| 400 | return fmt.Errorf("error generating systemd unit: %w", err) | ||
| 401 | } | ||
| 402 | |||
| 403 | servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", name) | ||
| 404 | if err := client.WriteSudoFile(servicePath, serviceContent); err != nil { | ||
| 405 | return fmt.Errorf("error creating systemd unit: %w", err) | ||
| 406 | } | ||
| 407 | |||
| 408 | fmt.Println("-> Reloading systemd...") | ||
| 409 | if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { | ||
| 410 | return fmt.Errorf("error reloading systemd: %w", err) | ||
| 411 | } | ||
| 412 | |||
| 413 | fmt.Println("-> Restarting service...") | ||
| 414 | if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { | ||
| 415 | return fmt.Errorf("error restarting service: %w", err) | ||
| 416 | } | ||
| 417 | |||
| 418 | // Update state | ||
| 419 | existingApp.Args = args | ||
| 420 | existingApp.Memory = memory | ||
| 421 | existingApp.CPU = cpu | ||
| 422 | existingApp.Env = env | ||
| 423 | if err := st.Save(); err != nil { | ||
| 424 | return fmt.Errorf("error saving state: %w", err) | ||
| 425 | } | ||
| 426 | |||
| 427 | fmt.Printf("\n Config updated successfully!\n") | ||
| 428 | return nil | ||
| 429 | } | ||
| 430 | |||
| 431 | func deployStatic(host, domain, name, dir string) error { | ||
| 302 | if _, err := os.Stat(dir); err != nil { | 432 | if _, err := os.Stat(dir); err != nil { |
| 303 | return fmt.Errorf("directory not found: %s", dir) | 433 | return fmt.Errorf("directory not found: %s", dir) |
| 304 | } | 434 | } |
