From 85e97bf1ebe339513ab0661f6cc1ab1ff17a46cb Mon Sep 17 00:00:00 2001 From: bndw Date: Sun, 25 Jan 2026 08:06:56 -0800 Subject: 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. --- cmd/ship/main.go | 2 + cmd/ship/root.go | 138 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 136 insertions(+), 4 deletions(-) (limited to 'cmd') 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() { rootCmd.Flags().String("env-file", "", "Path to .env file") rootCmd.Flags().String("args", "", "Arguments to pass to binary") rootCmd.Flags().StringArray("file", nil, "Config file to upload to working directory (can be specified multiple times)") + rootCmd.Flags().String("memory", "", "Memory limit (e.g., 512M, 1G)") + rootCmd.Flags().String("cpu", "", "CPU limit (e.g., 50%, 200% for 2 cores)") // Add subcommands 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 { envFile, _ := flags.GetString("env-file") binaryArgs, _ := flags.GetString("args") files, _ := flags.GetStringArray("file") + memory, _ := flags.GetString("memory") + cpu, _ := flags.GetString("cpu") // Get host from flag or state default host := hostFlag @@ -39,7 +41,7 @@ func runDeploy(cmd *cobra.Command, args []string) error { } // If no flags provided, show help - if domain == "" && binary == "" && !static { + if domain == "" && binary == "" && !static && name == "" { return cmd.Help() } @@ -54,6 +56,11 @@ func runDeploy(cmd *cobra.Command, args []string) error { } hostState := st.GetHost(host) + // Config update mode: --name provided without --binary or --static + if name != "" && binary == "" && !static { + return updateAppConfig(host, name, envVars, envFile, binaryArgs, memory, cpu) + } + if domain == "" && hostState.BaseDomain == "" { return fmt.Errorf("--domain required (or configure base domain with 'ship host init --base-domain')") } @@ -84,10 +91,10 @@ func runDeploy(cmd *cobra.Command, args []string) error { if static { return deployStatic(host, combinedDomains, inferredName, dir) } - return deployApp(host, combinedDomains, inferredName, binary, port, envVars, envFile, binaryArgs, files) + return deployApp(host, combinedDomains, inferredName, binary, port, envVars, envFile, binaryArgs, files, memory, cpu) } -func deployApp(host, domain, name, binaryPath string, portOverride int, envVars []string, envFile, args string, files []string) error { +func deployApp(host, domain, name, binaryPath string, portOverride int, envVars []string, envFile, args string, files []string, memory, cpu string) error { if binaryPath == "" { return fmt.Errorf("--binary is required") } @@ -130,6 +137,12 @@ func deployApp(host, domain, name, binaryPath string, portOverride int, envVars if len(files) == 0 && len(existingApp.Files) > 0 { files = existingApp.Files } + if memory == "" && existingApp.Memory != "" { + memory = existingApp.Memory + } + if cpu == "" && existingApp.CPU != "" { + cpu = existingApp.CPU + } } for _, e := range envVars { @@ -235,6 +248,8 @@ func deployApp(host, domain, name, binaryPath string, portOverride int, envVars "Port": strconv.Itoa(port), "EnvFile": envFilePath, "Args": args, + "Memory": memory, + "CPU": cpu, }) if err != nil { return fmt.Errorf("error generating systemd unit: %w", err) @@ -284,6 +299,8 @@ func deployApp(host, domain, name, binaryPath string, portOverride int, envVars Env: env, Args: args, Files: files, + Memory: memory, + CPU: cpu, }) if err := st.Save(); err != nil { return fmt.Errorf("error saving state: %w", err) @@ -297,8 +314,121 @@ func deployApp(host, domain, name, binaryPath string, portOverride int, envVars return nil } -func deployStatic(host, domain, name, dir string) error { +func updateAppConfig(host, name string, envVars []string, envFile, args, memory, cpu string) error { + st, err := state.Load() + if err != nil { + return fmt.Errorf("error loading state: %w", err) + } + + existingApp, err := st.GetApp(host, name) + if err != nil { + return fmt.Errorf("app %s not found (use --binary to deploy a new app)", name) + } + + if existingApp.Type != "app" { + return fmt.Errorf("%s is a static site, not an app", name) + } + + fmt.Printf("Updating config: %s\n", name) + + // Merge with existing values + if args == "" { + args = existingApp.Args + } + if memory == "" { + memory = existingApp.Memory + } + if cpu == "" { + cpu = existingApp.CPU + } + + // Merge env vars + env := make(map[string]string) + for k, v := range existingApp.Env { + env[k] = v + } + for _, e := range envVars { + parts := strings.SplitN(e, "=", 2) + if len(parts) == 2 { + env[parts[0]] = parts[1] + } + } + if envFile != "" { + fileEnv, err := parseEnvFile(envFile) + if err != nil { + return fmt.Errorf("error reading env file: %w", err) + } + for k, v := range fileEnv { + env[k] = v + } + } + env["PORT"] = strconv.Itoa(existingApp.Port) + client, err := ssh.Connect(host) + if err != nil { + return fmt.Errorf("error connecting to VPS: %w", err) + } + defer client.Close() + + // Update env file + fmt.Println("-> Updating environment file...") + envFilePath := fmt.Sprintf("/etc/ship/env/%s.env", name) + envContent := "" + for k, v := range env { + envContent += fmt.Sprintf("%s=%s\n", k, v) + } + if err := client.WriteSudoFile(envFilePath, envContent); err != nil { + return fmt.Errorf("error creating env file: %w", err) + } + + // Regenerate systemd unit + fmt.Println("-> Updating systemd service...") + workDir := fmt.Sprintf("/var/lib/%s", name) + binaryDest := fmt.Sprintf("/usr/local/bin/%s", name) + serviceContent, err := templates.SystemdService(map[string]string{ + "Name": name, + "User": name, + "WorkDir": workDir, + "BinaryPath": binaryDest, + "Port": strconv.Itoa(existingApp.Port), + "EnvFile": envFilePath, + "Args": args, + "Memory": memory, + "CPU": cpu, + }) + if err != nil { + return fmt.Errorf("error generating systemd unit: %w", err) + } + + servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", name) + if err := client.WriteSudoFile(servicePath, serviceContent); err != nil { + return fmt.Errorf("error creating systemd unit: %w", err) + } + + fmt.Println("-> Reloading systemd...") + if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { + return fmt.Errorf("error reloading systemd: %w", err) + } + + fmt.Println("-> Restarting service...") + if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { + return fmt.Errorf("error restarting service: %w", err) + } + + // Update state + existingApp.Args = args + existingApp.Memory = memory + existingApp.CPU = cpu + existingApp.Env = env + if err := st.Save(); err != nil { + return fmt.Errorf("error saving state: %w", err) + } + + fmt.Printf("\n Config updated successfully!\n") + return nil +} + +func deployStatic(host, domain, name, dir string) error { if _, err := os.Stat(dir); err != nil { return fmt.Errorf("directory not found: %s", dir) } -- cgit v1.2.3