From 92189aed1a4789e13e275caec4492aac04b7a3a2 Mon Sep 17 00:00:00 2001 From: bndw Date: Sun, 25 Jan 2026 09:57:24 -0800 Subject: Preserve existing config when updating apps and static sites Previously, updating an app or static site without specifying all flags would clear existing configuration (e.g., custom domains would be lost if --domain wasn't provided on update). Now all config is merged in runDeploy before calling deploy functions: - Load state once instead of 2-3 times - Check for existing app once to determine create vs update - Build merged DeployOptions with existing config + CLI overrides - Deploy functions receive fully-merged config and just execute This ensures existing configuration is preserved unless explicitly overridden by CLI flags. --- cmd/ship/root.go | 395 ++++++++++++++++++++++++++++++------------------------- 1 file changed, 215 insertions(+), 180 deletions(-) (limited to 'cmd/ship') diff --git a/cmd/ship/root.go b/cmd/ship/root.go index c46491f..24eed1e 100644 --- a/cmd/ship/root.go +++ b/cmd/ship/root.go @@ -16,100 +16,223 @@ import ( // DeployOptions contains all options for deploying or updating an app type DeployOptions struct { - Host string - Domain string - Name string - Binary string - Port int - EnvVars []string - EnvFile string - Args string - Files []string - Memory string - CPU string + Host string + Domain string + Name string + Binary string + Dir string // for static sites + Port int + Args string + Files []string + Memory string + CPU string + Env map[string]string // merged env vars + IsUpdate bool } func runDeploy(cmd *cobra.Command, args []string) error { flags := cmd.Flags() - opts := DeployOptions{} - opts.Binary, _ = flags.GetString("binary") + // Parse CLI flags + binary, _ := flags.GetString("binary") static, _ := flags.GetBool("static") dir, _ := flags.GetString("dir") - opts.Domain, _ = flags.GetString("domain") - opts.Name, _ = flags.GetString("name") - opts.Port, _ = flags.GetInt("port") - opts.EnvVars, _ = flags.GetStringArray("env") - opts.EnvFile, _ = flags.GetString("env-file") - opts.Args, _ = flags.GetString("args") - opts.Files, _ = flags.GetStringArray("file") - opts.Memory, _ = flags.GetString("memory") - opts.CPU, _ = flags.GetString("cpu") + domain, _ := flags.GetString("domain") + name, _ := flags.GetString("name") + port, _ := flags.GetInt("port") + envVars, _ := flags.GetStringArray("env") + envFile, _ := flags.GetString("env-file") + argsFlag, _ := flags.GetString("args") + files, _ := flags.GetStringArray("file") + memory, _ := flags.GetString("memory") + cpu, _ := flags.GetString("cpu") // Get host from flag or state default - opts.Host = hostFlag - if opts.Host == "" { + host := hostFlag + if host == "" { st, err := state.Load() if err != nil { return fmt.Errorf("error loading state: %w", err) } - opts.Host = st.GetDefaultHost() + host = st.GetDefaultHost() } // If no flags provided, show help - if opts.Domain == "" && opts.Binary == "" && !static && opts.Name == "" { + if domain == "" && binary == "" && !static && name == "" { return cmd.Help() } - if opts.Host == "" { + if host == "" { return fmt.Errorf("--host is required") } - // Load state to check base domain + // Load state once - this will be used throughout st, err := state.Load() if err != nil { return fmt.Errorf("error loading state: %w", err) } - hostState := st.GetHost(opts.Host) + hostState := st.GetHost(host) // Config update mode: --name provided without --binary or --static - if opts.Name != "" && opts.Binary == "" && !static { - return updateAppConfig(opts) - } + if name != "" && binary == "" && !static { + 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 opts.Domain == "" && hostState.BaseDomain == "" { - return fmt.Errorf("--domain required (or configure base domain with 'ship host init --base-domain')") + // Build merged config starting from existing app + opts := DeployOptions{ + Host: host, + Name: name, + Port: existingApp.Port, + Args: existingApp.Args, + Files: existingApp.Files, + Memory: existingApp.Memory, + CPU: existingApp.CPU, + Env: make(map[string]string), + } + for k, v := range existingApp.Env { + opts.Env[k] = v + } + + // Override with CLI flags if provided + if argsFlag != "" { + opts.Args = argsFlag + } + if len(files) > 0 { + opts.Files = files + } + if memory != "" { + opts.Memory = memory + } + if cpu != "" { + opts.CPU = cpu + } + + // Merge env vars (CLI overrides existing) + for _, e := range envVars { + parts := strings.SplitN(e, "=", 2) + if len(parts) == 2 { + opts.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 { + opts.Env[k] = v + } + } + + return updateAppConfig(st, opts) } - // Infer name early so we can use it for subdomain generation - if opts.Name == "" { + // Infer name early so we can use it for subdomain generation and existing app lookup + if name == "" { if static { - opts.Name = opts.Domain - if opts.Name == "" && hostState.BaseDomain != "" { - opts.Name = filepath.Base(dir) + name = domain + if name == "" && hostState.BaseDomain != "" { + name = filepath.Base(dir) } } else { - opts.Name = filepath.Base(opts.Binary) + name = filepath.Base(binary) } } - // Generate subdomain if base domain configured + // Check if this is an update to an existing app/site + existingApp, _ := st.GetApp(host, name) + isUpdate := existingApp != nil + + // For new deployments, require domain or base domain + if !isUpdate && domain == "" && hostState.BaseDomain == "" { + return fmt.Errorf("--domain required (or configure base domain with 'ship host init --base-domain')") + } + + // Build merged config, starting from existing app if updating + opts := DeployOptions{ + Host: host, + Name: name, + Binary: binary, + Dir: dir, + IsUpdate: isUpdate, + } + + // Merge domain: auto-subdomain + (user-provided or existing custom domain) var domains []string if hostState.BaseDomain != "" { - domains = append(domains, opts.Name+"."+hostState.BaseDomain) - } - if opts.Domain != "" { - domains = append(domains, opts.Domain) + domains = append(domains, name+"."+hostState.BaseDomain) + } + if domain != "" { + domains = append(domains, domain) + } else if isUpdate && existingApp.Domain != "" { + for _, d := range strings.Split(existingApp.Domain, ",") { + d = strings.TrimSpace(d) + if d != "" && (hostState.BaseDomain == "" || !strings.HasSuffix(d, "."+hostState.BaseDomain)) { + domains = append(domains, d) + } + } } opts.Domain = strings.Join(domains, ", ") + // For apps, merge all config fields + if !static { + // Start with existing values if updating + if isUpdate { + opts.Port = existingApp.Port + opts.Args = existingApp.Args + opts.Files = existingApp.Files + opts.Memory = existingApp.Memory + opts.CPU = existingApp.CPU + opts.Env = make(map[string]string) + for k, v := range existingApp.Env { + opts.Env[k] = v + } + } else { + opts.Port = port + opts.Env = make(map[string]string) + } + + // Override with CLI flags if provided + if argsFlag != "" { + opts.Args = argsFlag + } + if len(files) > 0 { + opts.Files = files + } + if memory != "" { + opts.Memory = memory + } + if cpu != "" { + opts.CPU = cpu + } + + // Merge env vars (CLI overrides existing) + for _, e := range envVars { + parts := strings.SplitN(e, "=", 2) + if len(parts) == 2 { + opts.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 { + opts.Env[k] = v + } + } + } + if static { - return deployStatic(opts.Host, opts.Domain, opts.Name, dir) + return deployStatic(st, opts) } - return deployApp(opts) + return deployApp(st, opts) } -func deployApp(opts DeployOptions) error { +func deployApp(st *state.State, opts DeployOptions) error { if opts.Binary == "" { return fmt.Errorf("--binary is required") } @@ -122,67 +245,19 @@ func deployApp(opts DeployOptions) error { fmt.Printf(" Domain(s): %s\n", opts.Domain) fmt.Printf(" Binary: %s\n", opts.Binary) - st, err := state.Load() - if err != nil { - return fmt.Errorf("error loading state: %w", err) - } - - existingApp, _ := st.GetApp(opts.Host, opts.Name) - var port int - if existingApp != nil { - port = existingApp.Port + // Allocate port for new apps + port := opts.Port + if opts.IsUpdate { fmt.Printf(" Updating existing deployment (port %d)\n", port) } else { - if opts.Port > 0 { - port = opts.Port - } else { + if port == 0 { port = st.AllocatePort(opts.Host) } fmt.Printf(" Allocated port: %d\n", port) } - // Merge with existing config - args := opts.Args - files := opts.Files - memory := opts.Memory - cpu := opts.CPU - env := make(map[string]string) - if existingApp != nil { - for k, v := range existingApp.Env { - env[k] = v - } - if args == "" && existingApp.Args != "" { - args = existingApp.Args - } - 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 opts.EnvVars { - parts := strings.SplitN(e, "=", 2) - if len(parts) == 2 { - env[parts[0]] = parts[1] - } - } - - if opts.EnvFile != "" { - fileEnv, err := parseEnvFile(opts.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(port) + // Add PORT to env + opts.Env["PORT"] = strconv.Itoa(port) client, err := ssh.Connect(opts.Host) if err != nil { @@ -217,9 +292,9 @@ func deployApp(opts DeployOptions) error { return fmt.Errorf("error making binary executable: %w", err) } - if len(files) > 0 { + if len(opts.Files) > 0 { fmt.Println("-> Uploading config files...") - for _, file := range files { + for _, file := range opts.Files { if _, err := os.Stat(file); err != nil { return fmt.Errorf("config file not found: %s", file) } @@ -246,7 +321,7 @@ func deployApp(opts DeployOptions) error { fmt.Println("-> Creating environment file...") envFilePath := fmt.Sprintf("/etc/ship/env/%s.env", opts.Name) envContent := "" - for k, v := range env { + for k, v := range opts.Env { envContent += fmt.Sprintf("%s=%s\n", k, v) } if err := client.WriteSudoFile(envFilePath, envContent); err != nil { @@ -267,9 +342,9 @@ func deployApp(opts DeployOptions) error { "BinaryPath": binaryDest, "Port": strconv.Itoa(port), "EnvFile": envFilePath, - "Args": args, - "Memory": memory, - "CPU": cpu, + "Args": opts.Args, + "Memory": opts.Memory, + "CPU": opts.CPU, }) if err != nil { return fmt.Errorf("error generating systemd unit: %w", err) @@ -316,11 +391,11 @@ func deployApp(opts DeployOptions) error { Type: "app", Domain: opts.Domain, Port: port, - Env: env, - Args: args, - Files: files, - Memory: memory, - CPU: cpu, + Env: opts.Env, + Args: opts.Args, + Files: opts.Files, + Memory: opts.Memory, + CPU: opts.CPU, }) if err := st.Save(); err != nil { return fmt.Errorf("error saving state: %w", err) @@ -334,12 +409,7 @@ func deployApp(opts DeployOptions) error { return nil } -func updateAppConfig(opts DeployOptions) error { - st, err := state.Load() - if err != nil { - return fmt.Errorf("error loading state: %w", err) - } - +func updateAppConfig(st *state.State, opts DeployOptions) error { existingApp, err := st.GetApp(opts.Host, opts.Name) if err != nil { return fmt.Errorf("app %s not found (use --binary to deploy a new app)", opts.Name) @@ -351,41 +421,8 @@ func updateAppConfig(opts DeployOptions) error { fmt.Printf("Updating config: %s\n", opts.Name) - // Merge with existing values - args := opts.Args - if args == "" { - args = existingApp.Args - } - memory := opts.Memory - if memory == "" { - memory = existingApp.Memory - } - cpu := opts.CPU - 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 opts.EnvVars { - parts := strings.SplitN(e, "=", 2) - if len(parts) == 2 { - env[parts[0]] = parts[1] - } - } - if opts.EnvFile != "" { - fileEnv, err := parseEnvFile(opts.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) + // Add PORT to env + opts.Env["PORT"] = strconv.Itoa(existingApp.Port) client, err := ssh.Connect(opts.Host) if err != nil { @@ -397,7 +434,7 @@ func updateAppConfig(opts DeployOptions) error { fmt.Println("-> Updating environment file...") envFilePath := fmt.Sprintf("/etc/ship/env/%s.env", opts.Name) envContent := "" - for k, v := range env { + for k, v := range opts.Env { envContent += fmt.Sprintf("%s=%s\n", k, v) } if err := client.WriteSudoFile(envFilePath, envContent); err != nil { @@ -415,9 +452,9 @@ func updateAppConfig(opts DeployOptions) error { "BinaryPath": binaryDest, "Port": strconv.Itoa(existingApp.Port), "EnvFile": envFilePath, - "Args": args, - "Memory": memory, - "CPU": cpu, + "Args": opts.Args, + "Memory": opts.Memory, + "CPU": opts.CPU, }) if err != nil { return fmt.Errorf("error generating systemd unit: %w", err) @@ -439,10 +476,10 @@ func updateAppConfig(opts DeployOptions) error { } // Update state - existingApp.Args = args - existingApp.Memory = memory - existingApp.CPU = cpu - existingApp.Env = env + existingApp.Args = opts.Args + existingApp.Memory = opts.Memory + existingApp.CPU = opts.CPU + existingApp.Env = opts.Env if err := st.Save(); err != nil { return fmt.Errorf("error saving state: %w", err) } @@ -451,27 +488,26 @@ func updateAppConfig(opts DeployOptions) error { 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) +func deployStatic(st *state.State, opts DeployOptions) error { + if _, err := os.Stat(opts.Dir); err != nil { + return fmt.Errorf("directory not found: %s", opts.Dir) } - fmt.Printf("Deploying static site: %s\n", name) - fmt.Printf(" Domain(s): %s\n", domain) - fmt.Printf(" Directory: %s\n", dir) + fmt.Printf("Deploying static site: %s\n", opts.Name) + fmt.Printf(" Domain(s): %s\n", opts.Domain) + fmt.Printf(" Directory: %s\n", opts.Dir) - st, err := state.Load() - if err != nil { - return fmt.Errorf("error loading state: %w", err) + if opts.IsUpdate { + fmt.Println(" Updating existing deployment") } - client, err := ssh.Connect(host) + client, err := ssh.Connect(opts.Host) if err != nil { return fmt.Errorf("error connecting to VPS: %w", err) } defer client.Close() - remoteDir := fmt.Sprintf("/var/www/%s", name) + remoteDir := fmt.Sprintf("/var/www/%s", opts.Name) fmt.Println("-> Creating remote directory...") if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", remoteDir)); err != nil { return fmt.Errorf("error creating remote directory: %w", err) @@ -488,7 +524,7 @@ func deployStatic(host, domain, name, dir string) error { } fmt.Println("-> Uploading files...") - if err := client.UploadDir(dir, remoteDir); err != nil { + if err := client.UploadDir(opts.Dir, remoteDir); err != nil { return fmt.Errorf("error uploading files: %w", err) } @@ -505,14 +541,14 @@ func deployStatic(host, domain, name, dir string) error { fmt.Println("-> Configuring Caddy...") caddyContent, err := templates.StaticCaddy(map[string]string{ - "Domain": domain, + "Domain": opts.Domain, "RootDir": remoteDir, }) if err != nil { return fmt.Errorf("error generating Caddy config: %w", err) } - caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) + caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", opts.Name) if err := client.WriteSudoFile(caddyPath, caddyContent); err != nil { return fmt.Errorf("error creating Caddy config: %w", err) } @@ -522,17 +558,16 @@ func deployStatic(host, domain, name, dir string) error { return fmt.Errorf("error reloading Caddy: %w", err) } - st.AddApp(host, name, &state.App{ + st.AddApp(opts.Host, opts.Name, &state.App{ Type: "static", - Domain: domain, + Domain: opts.Domain, }) if err := st.Save(); err != nil { return fmt.Errorf("error saving state: %w", err) } fmt.Printf("\n Static site deployed successfully!\n") - // Show first domain in the URL message - primaryDomain := strings.Split(domain, ",")[0] + primaryDomain := strings.Split(opts.Domain, ",")[0] primaryDomain = strings.TrimSpace(primaryDomain) fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", primaryDomain) return nil -- cgit v1.2.3