diff options
| author | bndw <ben@bdw.to> | 2026-01-25 09:57:24 -0800 |
|---|---|---|
| committer | bndw <ben@bdw.to> | 2026-01-25 09:57:24 -0800 |
| commit | 92189aed1a4789e13e275caec4492aac04b7a3a2 (patch) | |
| tree | 65329be02ba1e99668ba7deaf51a634ac787fe89 /cmd | |
| parent | 1704c7d9dd88ebdae78217ea9b1a5941dc10f998 (diff) | |
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.
Diffstat (limited to 'cmd')
| -rw-r--r-- | cmd/ship/root.go | 395 |
1 files changed, 215 insertions, 180 deletions
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 ( | |||
| 16 | 16 | ||
| 17 | // DeployOptions contains all options for deploying or updating an app | 17 | // DeployOptions contains all options for deploying or updating an app |
| 18 | type DeployOptions struct { | 18 | type DeployOptions struct { |
| 19 | Host string | 19 | Host string |
| 20 | Domain string | 20 | Domain string |
| 21 | Name string | 21 | Name string |
| 22 | Binary string | 22 | Binary string |
| 23 | Port int | 23 | Dir string // for static sites |
| 24 | EnvVars []string | 24 | Port int |
| 25 | EnvFile string | 25 | Args string |
| 26 | Args string | 26 | Files []string |
| 27 | Files []string | 27 | Memory string |
| 28 | Memory string | 28 | CPU string |
| 29 | CPU string | 29 | Env map[string]string // merged env vars |
| 30 | IsUpdate bool | ||
| 30 | } | 31 | } |
| 31 | 32 | ||
| 32 | func runDeploy(cmd *cobra.Command, args []string) error { | 33 | func runDeploy(cmd *cobra.Command, args []string) error { |
| 33 | flags := cmd.Flags() | 34 | flags := cmd.Flags() |
| 34 | 35 | ||
| 35 | opts := DeployOptions{} | 36 | // Parse CLI flags |
| 36 | opts.Binary, _ = flags.GetString("binary") | 37 | binary, _ := flags.GetString("binary") |
| 37 | static, _ := flags.GetBool("static") | 38 | static, _ := flags.GetBool("static") |
| 38 | dir, _ := flags.GetString("dir") | 39 | dir, _ := flags.GetString("dir") |
| 39 | opts.Domain, _ = flags.GetString("domain") | 40 | domain, _ := flags.GetString("domain") |
| 40 | opts.Name, _ = flags.GetString("name") | 41 | name, _ := flags.GetString("name") |
| 41 | opts.Port, _ = flags.GetInt("port") | 42 | port, _ := flags.GetInt("port") |
| 42 | opts.EnvVars, _ = flags.GetStringArray("env") | 43 | envVars, _ := flags.GetStringArray("env") |
| 43 | opts.EnvFile, _ = flags.GetString("env-file") | 44 | envFile, _ := flags.GetString("env-file") |
| 44 | opts.Args, _ = flags.GetString("args") | 45 | argsFlag, _ := flags.GetString("args") |
| 45 | opts.Files, _ = flags.GetStringArray("file") | 46 | files, _ := flags.GetStringArray("file") |
| 46 | opts.Memory, _ = flags.GetString("memory") | 47 | memory, _ := flags.GetString("memory") |
| 47 | opts.CPU, _ = flags.GetString("cpu") | 48 | cpu, _ := flags.GetString("cpu") |
| 48 | 49 | ||
| 49 | // Get host from flag or state default | 50 | // Get host from flag or state default |
| 50 | opts.Host = hostFlag | 51 | host := hostFlag |
| 51 | if opts.Host == "" { | 52 | if host == "" { |
| 52 | st, err := state.Load() | 53 | st, err := state.Load() |
| 53 | if err != nil { | 54 | if err != nil { |
| 54 | return fmt.Errorf("error loading state: %w", err) | 55 | return fmt.Errorf("error loading state: %w", err) |
| 55 | } | 56 | } |
| 56 | opts.Host = st.GetDefaultHost() | 57 | host = st.GetDefaultHost() |
| 57 | } | 58 | } |
| 58 | 59 | ||
| 59 | // If no flags provided, show help | 60 | // If no flags provided, show help |
| 60 | if opts.Domain == "" && opts.Binary == "" && !static && opts.Name == "" { | 61 | if domain == "" && binary == "" && !static && name == "" { |
| 61 | return cmd.Help() | 62 | return cmd.Help() |
| 62 | } | 63 | } |
| 63 | 64 | ||
| 64 | if opts.Host == "" { | 65 | if host == "" { |
| 65 | return fmt.Errorf("--host is required") | 66 | return fmt.Errorf("--host is required") |
| 66 | } | 67 | } |
| 67 | 68 | ||
| 68 | // Load state to check base domain | 69 | // Load state once - this will be used throughout |
| 69 | st, err := state.Load() | 70 | st, err := state.Load() |
| 70 | if err != nil { | 71 | if err != nil { |
| 71 | return fmt.Errorf("error loading state: %w", err) | 72 | return fmt.Errorf("error loading state: %w", err) |
| 72 | } | 73 | } |
| 73 | hostState := st.GetHost(opts.Host) | 74 | hostState := st.GetHost(host) |
| 74 | 75 | ||
| 75 | // Config update mode: --name provided without --binary or --static | 76 | // Config update mode: --name provided without --binary or --static |
| 76 | if opts.Name != "" && opts.Binary == "" && !static { | 77 | if name != "" && binary == "" && !static { |
| 77 | return updateAppConfig(opts) | 78 | existingApp, err := st.GetApp(host, name) |
| 78 | } | 79 | if err != nil { |
| 80 | return fmt.Errorf("app %s not found (use --binary to deploy a new app)", name) | ||
| 81 | } | ||
| 79 | 82 | ||
| 80 | if opts.Domain == "" && hostState.BaseDomain == "" { | 83 | // Build merged config starting from existing app |
| 81 | return fmt.Errorf("--domain required (or configure base domain with 'ship host init --base-domain')") | 84 | opts := DeployOptions{ |
| 85 | Host: host, | ||
| 86 | Name: name, | ||
| 87 | Port: existingApp.Port, | ||
| 88 | Args: existingApp.Args, | ||
| 89 | Files: existingApp.Files, | ||
| 90 | Memory: existingApp.Memory, | ||
| 91 | CPU: existingApp.CPU, | ||
| 92 | Env: make(map[string]string), | ||
| 93 | } | ||
| 94 | for k, v := range existingApp.Env { | ||
| 95 | opts.Env[k] = v | ||
| 96 | } | ||
| 97 | |||
| 98 | // Override with CLI flags if provided | ||
| 99 | if argsFlag != "" { | ||
| 100 | opts.Args = argsFlag | ||
| 101 | } | ||
| 102 | if len(files) > 0 { | ||
| 103 | opts.Files = files | ||
| 104 | } | ||
| 105 | if memory != "" { | ||
| 106 | opts.Memory = memory | ||
| 107 | } | ||
| 108 | if cpu != "" { | ||
| 109 | opts.CPU = cpu | ||
| 110 | } | ||
| 111 | |||
| 112 | // Merge env vars (CLI overrides existing) | ||
| 113 | for _, e := range envVars { | ||
| 114 | parts := strings.SplitN(e, "=", 2) | ||
| 115 | if len(parts) == 2 { | ||
| 116 | opts.Env[parts[0]] = parts[1] | ||
| 117 | } | ||
| 118 | } | ||
| 119 | if envFile != "" { | ||
| 120 | fileEnv, err := parseEnvFile(envFile) | ||
| 121 | if err != nil { | ||
| 122 | return fmt.Errorf("error reading env file: %w", err) | ||
| 123 | } | ||
| 124 | for k, v := range fileEnv { | ||
| 125 | opts.Env[k] = v | ||
| 126 | } | ||
| 127 | } | ||
| 128 | |||
| 129 | return updateAppConfig(st, opts) | ||
| 82 | } | 130 | } |
| 83 | 131 | ||
| 84 | // Infer name early so we can use it for subdomain generation | 132 | // Infer name early so we can use it for subdomain generation and existing app lookup |
| 85 | if opts.Name == "" { | 133 | if name == "" { |
| 86 | if static { | 134 | if static { |
| 87 | opts.Name = opts.Domain | 135 | name = domain |
| 88 | if opts.Name == "" && hostState.BaseDomain != "" { | 136 | if name == "" && hostState.BaseDomain != "" { |
| 89 | opts.Name = filepath.Base(dir) | 137 | name = filepath.Base(dir) |
| 90 | } | 138 | } |
| 91 | } else { | 139 | } else { |
| 92 | opts.Name = filepath.Base(opts.Binary) | 140 | name = filepath.Base(binary) |
| 93 | } | 141 | } |
| 94 | } | 142 | } |
| 95 | 143 | ||
| 96 | // Generate subdomain if base domain configured | 144 | // Check if this is an update to an existing app/site |
| 145 | existingApp, _ := st.GetApp(host, name) | ||
| 146 | isUpdate := existingApp != nil | ||
| 147 | |||
| 148 | // For new deployments, require domain or base domain | ||
| 149 | if !isUpdate && domain == "" && hostState.BaseDomain == "" { | ||
| 150 | return fmt.Errorf("--domain required (or configure base domain with 'ship host init --base-domain')") | ||
| 151 | } | ||
| 152 | |||
| 153 | // Build merged config, starting from existing app if updating | ||
| 154 | opts := DeployOptions{ | ||
| 155 | Host: host, | ||
| 156 | Name: name, | ||
| 157 | Binary: binary, | ||
| 158 | Dir: dir, | ||
| 159 | IsUpdate: isUpdate, | ||
| 160 | } | ||
| 161 | |||
| 162 | // Merge domain: auto-subdomain + (user-provided or existing custom domain) | ||
| 97 | var domains []string | 163 | var domains []string |
| 98 | if hostState.BaseDomain != "" { | 164 | if hostState.BaseDomain != "" { |
| 99 | domains = append(domains, opts.Name+"."+hostState.BaseDomain) | 165 | domains = append(domains, name+"."+hostState.BaseDomain) |
| 100 | } | 166 | } |
| 101 | if opts.Domain != "" { | 167 | if domain != "" { |
| 102 | domains = append(domains, opts.Domain) | 168 | domains = append(domains, domain) |
| 169 | } else if isUpdate && existingApp.Domain != "" { | ||
| 170 | for _, d := range strings.Split(existingApp.Domain, ",") { | ||
| 171 | d = strings.TrimSpace(d) | ||
| 172 | if d != "" && (hostState.BaseDomain == "" || !strings.HasSuffix(d, "."+hostState.BaseDomain)) { | ||
| 173 | domains = append(domains, d) | ||
| 174 | } | ||
| 175 | } | ||
| 103 | } | 176 | } |
| 104 | opts.Domain = strings.Join(domains, ", ") | 177 | opts.Domain = strings.Join(domains, ", ") |
| 105 | 178 | ||
| 179 | // For apps, merge all config fields | ||
| 180 | if !static { | ||
| 181 | // Start with existing values if updating | ||
| 182 | if isUpdate { | ||
| 183 | opts.Port = existingApp.Port | ||
| 184 | opts.Args = existingApp.Args | ||
| 185 | opts.Files = existingApp.Files | ||
| 186 | opts.Memory = existingApp.Memory | ||
| 187 | opts.CPU = existingApp.CPU | ||
| 188 | opts.Env = make(map[string]string) | ||
| 189 | for k, v := range existingApp.Env { | ||
| 190 | opts.Env[k] = v | ||
| 191 | } | ||
| 192 | } else { | ||
| 193 | opts.Port = port | ||
| 194 | opts.Env = make(map[string]string) | ||
| 195 | } | ||
| 196 | |||
| 197 | // Override with CLI flags if provided | ||
| 198 | if argsFlag != "" { | ||
| 199 | opts.Args = argsFlag | ||
| 200 | } | ||
| 201 | if len(files) > 0 { | ||
| 202 | opts.Files = files | ||
| 203 | } | ||
| 204 | if memory != "" { | ||
| 205 | opts.Memory = memory | ||
| 206 | } | ||
| 207 | if cpu != "" { | ||
| 208 | opts.CPU = cpu | ||
| 209 | } | ||
| 210 | |||
| 211 | // Merge env vars (CLI overrides existing) | ||
| 212 | for _, e := range envVars { | ||
| 213 | parts := strings.SplitN(e, "=", 2) | ||
| 214 | if len(parts) == 2 { | ||
| 215 | opts.Env[parts[0]] = parts[1] | ||
| 216 | } | ||
| 217 | } | ||
| 218 | if envFile != "" { | ||
| 219 | fileEnv, err := parseEnvFile(envFile) | ||
| 220 | if err != nil { | ||
| 221 | return fmt.Errorf("error reading env file: %w", err) | ||
| 222 | } | ||
| 223 | for k, v := range fileEnv { | ||
| 224 | opts.Env[k] = v | ||
| 225 | } | ||
| 226 | } | ||
| 227 | } | ||
| 228 | |||
| 106 | if static { | 229 | if static { |
| 107 | return deployStatic(opts.Host, opts.Domain, opts.Name, dir) | 230 | return deployStatic(st, opts) |
| 108 | } | 231 | } |
| 109 | return deployApp(opts) | 232 | return deployApp(st, opts) |
| 110 | } | 233 | } |
| 111 | 234 | ||
| 112 | func deployApp(opts DeployOptions) error { | 235 | func deployApp(st *state.State, opts DeployOptions) error { |
| 113 | if opts.Binary == "" { | 236 | if opts.Binary == "" { |
| 114 | return fmt.Errorf("--binary is required") | 237 | return fmt.Errorf("--binary is required") |
| 115 | } | 238 | } |
| @@ -122,67 +245,19 @@ func deployApp(opts DeployOptions) error { | |||
| 122 | fmt.Printf(" Domain(s): %s\n", opts.Domain) | 245 | fmt.Printf(" Domain(s): %s\n", opts.Domain) |
| 123 | fmt.Printf(" Binary: %s\n", opts.Binary) | 246 | fmt.Printf(" Binary: %s\n", opts.Binary) |
| 124 | 247 | ||
| 125 | st, err := state.Load() | 248 | // Allocate port for new apps |
| 126 | if err != nil { | 249 | port := opts.Port |
| 127 | return fmt.Errorf("error loading state: %w", err) | 250 | if opts.IsUpdate { |
| 128 | } | ||
| 129 | |||
| 130 | existingApp, _ := st.GetApp(opts.Host, opts.Name) | ||
| 131 | var port int | ||
| 132 | if existingApp != nil { | ||
| 133 | port = existingApp.Port | ||
| 134 | fmt.Printf(" Updating existing deployment (port %d)\n", port) | 251 | fmt.Printf(" Updating existing deployment (port %d)\n", port) |
| 135 | } else { | 252 | } else { |
| 136 | if opts.Port > 0 { | 253 | if port == 0 { |
| 137 | port = opts.Port | ||
| 138 | } else { | ||
| 139 | port = st.AllocatePort(opts.Host) | 254 | port = st.AllocatePort(opts.Host) |
| 140 | } | 255 | } |
| 141 | fmt.Printf(" Allocated port: %d\n", port) | 256 | fmt.Printf(" Allocated port: %d\n", port) |
| 142 | } | 257 | } |
| 143 | 258 | ||
| 144 | // Merge with existing config | 259 | // Add PORT to env |
| 145 | args := opts.Args | 260 | opts.Env["PORT"] = strconv.Itoa(port) |
| 146 | files := opts.Files | ||
| 147 | memory := opts.Memory | ||
| 148 | cpu := opts.CPU | ||
| 149 | env := make(map[string]string) | ||
| 150 | if existingApp != nil { | ||
| 151 | for k, v := range existingApp.Env { | ||
| 152 | env[k] = v | ||
| 153 | } | ||
| 154 | if args == "" && existingApp.Args != "" { | ||
| 155 | args = existingApp.Args | ||
| 156 | } | ||
| 157 | if len(files) == 0 && len(existingApp.Files) > 0 { | ||
| 158 | files = existingApp.Files | ||
| 159 | } | ||
| 160 | if memory == "" && existingApp.Memory != "" { | ||
| 161 | memory = existingApp.Memory | ||
| 162 | } | ||
| 163 | if cpu == "" && existingApp.CPU != "" { | ||
| 164 | cpu = existingApp.CPU | ||
| 165 | } | ||
| 166 | } | ||
| 167 | |||
| 168 | for _, e := range opts.EnvVars { | ||
| 169 | parts := strings.SplitN(e, "=", 2) | ||
| 170 | if len(parts) == 2 { | ||
| 171 | env[parts[0]] = parts[1] | ||
| 172 | } | ||
| 173 | } | ||
| 174 | |||
| 175 | if opts.EnvFile != "" { | ||
| 176 | fileEnv, err := parseEnvFile(opts.EnvFile) | ||
| 177 | if err != nil { | ||
| 178 | return fmt.Errorf("error reading env file: %w", err) | ||
| 179 | } | ||
| 180 | for k, v := range fileEnv { | ||
| 181 | env[k] = v | ||
| 182 | } | ||
| 183 | } | ||
| 184 | |||
| 185 | env["PORT"] = strconv.Itoa(port) | ||
| 186 | 261 | ||
| 187 | client, err := ssh.Connect(opts.Host) | 262 | client, err := ssh.Connect(opts.Host) |
| 188 | if err != nil { | 263 | if err != nil { |
| @@ -217,9 +292,9 @@ func deployApp(opts DeployOptions) error { | |||
| 217 | return fmt.Errorf("error making binary executable: %w", err) | 292 | return fmt.Errorf("error making binary executable: %w", err) |
| 218 | } | 293 | } |
| 219 | 294 | ||
| 220 | if len(files) > 0 { | 295 | if len(opts.Files) > 0 { |
| 221 | fmt.Println("-> Uploading config files...") | 296 | fmt.Println("-> Uploading config files...") |
| 222 | for _, file := range files { | 297 | for _, file := range opts.Files { |
| 223 | if _, err := os.Stat(file); err != nil { | 298 | if _, err := os.Stat(file); err != nil { |
| 224 | return fmt.Errorf("config file not found: %s", file) | 299 | return fmt.Errorf("config file not found: %s", file) |
| 225 | } | 300 | } |
| @@ -246,7 +321,7 @@ func deployApp(opts DeployOptions) error { | |||
| 246 | fmt.Println("-> Creating environment file...") | 321 | fmt.Println("-> Creating environment file...") |
| 247 | envFilePath := fmt.Sprintf("/etc/ship/env/%s.env", opts.Name) | 322 | envFilePath := fmt.Sprintf("/etc/ship/env/%s.env", opts.Name) |
| 248 | envContent := "" | 323 | envContent := "" |
| 249 | for k, v := range env { | 324 | for k, v := range opts.Env { |
| 250 | envContent += fmt.Sprintf("%s=%s\n", k, v) | 325 | envContent += fmt.Sprintf("%s=%s\n", k, v) |
| 251 | } | 326 | } |
| 252 | if err := client.WriteSudoFile(envFilePath, envContent); err != nil { | 327 | if err := client.WriteSudoFile(envFilePath, envContent); err != nil { |
| @@ -267,9 +342,9 @@ func deployApp(opts DeployOptions) error { | |||
| 267 | "BinaryPath": binaryDest, | 342 | "BinaryPath": binaryDest, |
| 268 | "Port": strconv.Itoa(port), | 343 | "Port": strconv.Itoa(port), |
| 269 | "EnvFile": envFilePath, | 344 | "EnvFile": envFilePath, |
| 270 | "Args": args, | 345 | "Args": opts.Args, |
| 271 | "Memory": memory, | 346 | "Memory": opts.Memory, |
| 272 | "CPU": cpu, | 347 | "CPU": opts.CPU, |
| 273 | }) | 348 | }) |
| 274 | if err != nil { | 349 | if err != nil { |
| 275 | return fmt.Errorf("error generating systemd unit: %w", err) | 350 | return fmt.Errorf("error generating systemd unit: %w", err) |
| @@ -316,11 +391,11 @@ func deployApp(opts DeployOptions) error { | |||
| 316 | Type: "app", | 391 | Type: "app", |
| 317 | Domain: opts.Domain, | 392 | Domain: opts.Domain, |
| 318 | Port: port, | 393 | Port: port, |
| 319 | Env: env, | 394 | Env: opts.Env, |
| 320 | Args: args, | 395 | Args: opts.Args, |
| 321 | Files: files, | 396 | Files: opts.Files, |
| 322 | Memory: memory, | 397 | Memory: opts.Memory, |
| 323 | CPU: cpu, | 398 | CPU: opts.CPU, |
| 324 | }) | 399 | }) |
| 325 | if err := st.Save(); err != nil { | 400 | if err := st.Save(); err != nil { |
| 326 | return fmt.Errorf("error saving state: %w", err) | 401 | return fmt.Errorf("error saving state: %w", err) |
| @@ -334,12 +409,7 @@ func deployApp(opts DeployOptions) error { | |||
| 334 | return nil | 409 | return nil |
| 335 | } | 410 | } |
| 336 | 411 | ||
| 337 | func updateAppConfig(opts DeployOptions) error { | 412 | func updateAppConfig(st *state.State, opts DeployOptions) error { |
| 338 | st, err := state.Load() | ||
| 339 | if err != nil { | ||
| 340 | return fmt.Errorf("error loading state: %w", err) | ||
| 341 | } | ||
| 342 | |||
| 343 | existingApp, err := st.GetApp(opts.Host, opts.Name) | 413 | existingApp, err := st.GetApp(opts.Host, opts.Name) |
| 344 | if err != nil { | 414 | if err != nil { |
| 345 | return fmt.Errorf("app %s not found (use --binary to deploy a new app)", opts.Name) | 415 | 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 { | |||
| 351 | 421 | ||
| 352 | fmt.Printf("Updating config: %s\n", opts.Name) | 422 | fmt.Printf("Updating config: %s\n", opts.Name) |
| 353 | 423 | ||
| 354 | // Merge with existing values | 424 | // Add PORT to env |
| 355 | args := opts.Args | 425 | opts.Env["PORT"] = strconv.Itoa(existingApp.Port) |
| 356 | if args == "" { | ||
| 357 | args = existingApp.Args | ||
| 358 | } | ||
| 359 | memory := opts.Memory | ||
| 360 | if memory == "" { | ||
| 361 | memory = existingApp.Memory | ||
| 362 | } | ||
| 363 | cpu := opts.CPU | ||
| 364 | if cpu == "" { | ||
| 365 | cpu = existingApp.CPU | ||
| 366 | } | ||
| 367 | |||
| 368 | // Merge env vars | ||
| 369 | env := make(map[string]string) | ||
| 370 | for k, v := range existingApp.Env { | ||
| 371 | env[k] = v | ||
| 372 | } | ||
| 373 | for _, e := range opts.EnvVars { | ||
| 374 | parts := strings.SplitN(e, "=", 2) | ||
| 375 | if len(parts) == 2 { | ||
| 376 | env[parts[0]] = parts[1] | ||
| 377 | } | ||
| 378 | } | ||
| 379 | if opts.EnvFile != "" { | ||
| 380 | fileEnv, err := parseEnvFile(opts.EnvFile) | ||
| 381 | if err != nil { | ||
| 382 | return fmt.Errorf("error reading env file: %w", err) | ||
| 383 | } | ||
| 384 | for k, v := range fileEnv { | ||
| 385 | env[k] = v | ||
| 386 | } | ||
| 387 | } | ||
| 388 | env["PORT"] = strconv.Itoa(existingApp.Port) | ||
| 389 | 426 | ||
| 390 | client, err := ssh.Connect(opts.Host) | 427 | client, err := ssh.Connect(opts.Host) |
| 391 | if err != nil { | 428 | if err != nil { |
| @@ -397,7 +434,7 @@ func updateAppConfig(opts DeployOptions) error { | |||
| 397 | fmt.Println("-> Updating environment file...") | 434 | fmt.Println("-> Updating environment file...") |
| 398 | envFilePath := fmt.Sprintf("/etc/ship/env/%s.env", opts.Name) | 435 | envFilePath := fmt.Sprintf("/etc/ship/env/%s.env", opts.Name) |
| 399 | envContent := "" | 436 | envContent := "" |
| 400 | for k, v := range env { | 437 | for k, v := range opts.Env { |
| 401 | envContent += fmt.Sprintf("%s=%s\n", k, v) | 438 | envContent += fmt.Sprintf("%s=%s\n", k, v) |
| 402 | } | 439 | } |
| 403 | if err := client.WriteSudoFile(envFilePath, envContent); err != nil { | 440 | if err := client.WriteSudoFile(envFilePath, envContent); err != nil { |
| @@ -415,9 +452,9 @@ func updateAppConfig(opts DeployOptions) error { | |||
| 415 | "BinaryPath": binaryDest, | 452 | "BinaryPath": binaryDest, |
| 416 | "Port": strconv.Itoa(existingApp.Port), | 453 | "Port": strconv.Itoa(existingApp.Port), |
| 417 | "EnvFile": envFilePath, | 454 | "EnvFile": envFilePath, |
| 418 | "Args": args, | 455 | "Args": opts.Args, |
| 419 | "Memory": memory, | 456 | "Memory": opts.Memory, |
| 420 | "CPU": cpu, | 457 | "CPU": opts.CPU, |
| 421 | }) | 458 | }) |
| 422 | if err != nil { | 459 | if err != nil { |
| 423 | return fmt.Errorf("error generating systemd unit: %w", err) | 460 | return fmt.Errorf("error generating systemd unit: %w", err) |
| @@ -439,10 +476,10 @@ func updateAppConfig(opts DeployOptions) error { | |||
| 439 | } | 476 | } |
| 440 | 477 | ||
| 441 | // Update state | 478 | // Update state |
| 442 | existingApp.Args = args | 479 | existingApp.Args = opts.Args |
| 443 | existingApp.Memory = memory | 480 | existingApp.Memory = opts.Memory |
| 444 | existingApp.CPU = cpu | 481 | existingApp.CPU = opts.CPU |
| 445 | existingApp.Env = env | 482 | existingApp.Env = opts.Env |
| 446 | if err := st.Save(); err != nil { | 483 | if err := st.Save(); err != nil { |
| 447 | return fmt.Errorf("error saving state: %w", err) | 484 | return fmt.Errorf("error saving state: %w", err) |
| 448 | } | 485 | } |
| @@ -451,27 +488,26 @@ func updateAppConfig(opts DeployOptions) error { | |||
| 451 | return nil | 488 | return nil |
| 452 | } | 489 | } |
| 453 | 490 | ||
| 454 | func deployStatic(host, domain, name, dir string) error { | 491 | func deployStatic(st *state.State, opts DeployOptions) error { |
| 455 | if _, err := os.Stat(dir); err != nil { | 492 | if _, err := os.Stat(opts.Dir); err != nil { |
| 456 | return fmt.Errorf("directory not found: %s", dir) | 493 | return fmt.Errorf("directory not found: %s", opts.Dir) |
| 457 | } | 494 | } |
| 458 | 495 | ||
| 459 | fmt.Printf("Deploying static site: %s\n", name) | 496 | fmt.Printf("Deploying static site: %s\n", opts.Name) |
| 460 | fmt.Printf(" Domain(s): %s\n", domain) | 497 | fmt.Printf(" Domain(s): %s\n", opts.Domain) |
| 461 | fmt.Printf(" Directory: %s\n", dir) | 498 | fmt.Printf(" Directory: %s\n", opts.Dir) |
| 462 | 499 | ||
| 463 | st, err := state.Load() | 500 | if opts.IsUpdate { |
| 464 | if err != nil { | 501 | fmt.Println(" Updating existing deployment") |
| 465 | return fmt.Errorf("error loading state: %w", err) | ||
| 466 | } | 502 | } |
| 467 | 503 | ||
| 468 | client, err := ssh.Connect(host) | 504 | client, err := ssh.Connect(opts.Host) |
| 469 | if err != nil { | 505 | if err != nil { |
| 470 | return fmt.Errorf("error connecting to VPS: %w", err) | 506 | return fmt.Errorf("error connecting to VPS: %w", err) |
| 471 | } | 507 | } |
| 472 | defer client.Close() | 508 | defer client.Close() |
| 473 | 509 | ||
| 474 | remoteDir := fmt.Sprintf("/var/www/%s", name) | 510 | remoteDir := fmt.Sprintf("/var/www/%s", opts.Name) |
| 475 | fmt.Println("-> Creating remote directory...") | 511 | fmt.Println("-> Creating remote directory...") |
| 476 | if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", remoteDir)); err != nil { | 512 | if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", remoteDir)); err != nil { |
| 477 | return fmt.Errorf("error creating remote directory: %w", err) | 513 | return fmt.Errorf("error creating remote directory: %w", err) |
| @@ -488,7 +524,7 @@ func deployStatic(host, domain, name, dir string) error { | |||
| 488 | } | 524 | } |
| 489 | 525 | ||
| 490 | fmt.Println("-> Uploading files...") | 526 | fmt.Println("-> Uploading files...") |
| 491 | if err := client.UploadDir(dir, remoteDir); err != nil { | 527 | if err := client.UploadDir(opts.Dir, remoteDir); err != nil { |
| 492 | return fmt.Errorf("error uploading files: %w", err) | 528 | return fmt.Errorf("error uploading files: %w", err) |
| 493 | } | 529 | } |
| 494 | 530 | ||
| @@ -505,14 +541,14 @@ func deployStatic(host, domain, name, dir string) error { | |||
| 505 | 541 | ||
| 506 | fmt.Println("-> Configuring Caddy...") | 542 | fmt.Println("-> Configuring Caddy...") |
| 507 | caddyContent, err := templates.StaticCaddy(map[string]string{ | 543 | caddyContent, err := templates.StaticCaddy(map[string]string{ |
| 508 | "Domain": domain, | 544 | "Domain": opts.Domain, |
| 509 | "RootDir": remoteDir, | 545 | "RootDir": remoteDir, |
| 510 | }) | 546 | }) |
| 511 | if err != nil { | 547 | if err != nil { |
| 512 | return fmt.Errorf("error generating Caddy config: %w", err) | 548 | return fmt.Errorf("error generating Caddy config: %w", err) |
| 513 | } | 549 | } |
| 514 | 550 | ||
| 515 | caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) | 551 | caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", opts.Name) |
| 516 | if err := client.WriteSudoFile(caddyPath, caddyContent); err != nil { | 552 | if err := client.WriteSudoFile(caddyPath, caddyContent); err != nil { |
| 517 | return fmt.Errorf("error creating Caddy config: %w", err) | 553 | return fmt.Errorf("error creating Caddy config: %w", err) |
| 518 | } | 554 | } |
| @@ -522,17 +558,16 @@ func deployStatic(host, domain, name, dir string) error { | |||
| 522 | return fmt.Errorf("error reloading Caddy: %w", err) | 558 | return fmt.Errorf("error reloading Caddy: %w", err) |
| 523 | } | 559 | } |
| 524 | 560 | ||
| 525 | st.AddApp(host, name, &state.App{ | 561 | st.AddApp(opts.Host, opts.Name, &state.App{ |
| 526 | Type: "static", | 562 | Type: "static", |
| 527 | Domain: domain, | 563 | Domain: opts.Domain, |
| 528 | }) | 564 | }) |
| 529 | if err := st.Save(); err != nil { | 565 | if err := st.Save(); err != nil { |
| 530 | return fmt.Errorf("error saving state: %w", err) | 566 | return fmt.Errorf("error saving state: %w", err) |
| 531 | } | 567 | } |
| 532 | 568 | ||
| 533 | fmt.Printf("\n Static site deployed successfully!\n") | 569 | fmt.Printf("\n Static site deployed successfully!\n") |
| 534 | // Show first domain in the URL message | 570 | primaryDomain := strings.Split(opts.Domain, ",")[0] |
| 535 | primaryDomain := strings.Split(domain, ",")[0] | ||
| 536 | primaryDomain = strings.TrimSpace(primaryDomain) | 571 | primaryDomain = strings.TrimSpace(primaryDomain) |
| 537 | fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", primaryDomain) | 572 | fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", primaryDomain) |
| 538 | return nil | 573 | return nil |
