From af109c04a3edd4dcd4e7b16242052442fb4a3b24 Mon Sep 17 00:00:00 2001 From: bndw Date: Sun, 25 Jan 2026 10:16:23 -0800 Subject: Move deploy implementation to deploy.go Restructure CLI files to follow idiomatic cobra layout: - main.go: minimal entry point - root.go: command definition, flags, and subcommand wiring - deploy.go: all deploy implementation --- cmd/ship/deploy.go | 598 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 598 insertions(+) create mode 100644 cmd/ship/deploy.go (limited to 'cmd/ship/deploy.go') diff --git a/cmd/ship/deploy.go b/cmd/ship/deploy.go new file mode 100644 index 0000000..24eed1e --- /dev/null +++ b/cmd/ship/deploy.go @@ -0,0 +1,598 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/bdw/ship/internal/ssh" + "github.com/bdw/ship/internal/state" + "github.com/bdw/ship/internal/templates" + "github.com/spf13/cobra" +) + +// DeployOptions contains all options for deploying or updating an app +type DeployOptions struct { + 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() + + // Parse CLI flags + binary, _ := flags.GetString("binary") + static, _ := flags.GetBool("static") + dir, _ := flags.GetString("dir") + 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 + host := hostFlag + if host == "" { + st, err := state.Load() + if err != nil { + return fmt.Errorf("error loading state: %w", err) + } + host = st.GetDefaultHost() + } + + // If no flags provided, show help + if domain == "" && binary == "" && !static && name == "" { + return cmd.Help() + } + + if host == "" { + return fmt.Errorf("--host is required") + } + + // 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(host) + + // Config update mode: --name provided without --binary or --static + 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) + } + + // 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 and existing app lookup + if name == "" { + if static { + name = domain + if name == "" && hostState.BaseDomain != "" { + name = filepath.Base(dir) + } + } else { + name = filepath.Base(binary) + } + } + + // 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, 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(st, opts) + } + return deployApp(st, opts) +} + +func deployApp(st *state.State, opts DeployOptions) error { + if opts.Binary == "" { + return fmt.Errorf("--binary is required") + } + + if _, err := os.Stat(opts.Binary); err != nil { + return fmt.Errorf("binary not found: %s", opts.Binary) + } + + fmt.Printf("Deploying app: %s\n", opts.Name) + fmt.Printf(" Domain(s): %s\n", opts.Domain) + fmt.Printf(" Binary: %s\n", opts.Binary) + + // Allocate port for new apps + port := opts.Port + if opts.IsUpdate { + fmt.Printf(" Updating existing deployment (port %d)\n", port) + } else { + if port == 0 { + port = st.AllocatePort(opts.Host) + } + fmt.Printf(" Allocated port: %d\n", port) + } + + // Add PORT to env + opts.Env["PORT"] = strconv.Itoa(port) + + client, err := ssh.Connect(opts.Host) + if err != nil { + return fmt.Errorf("error connecting to VPS: %w", err) + } + defer client.Close() + + fmt.Println("-> Uploading binary...") + remoteTmpPath := fmt.Sprintf("/tmp/%s", opts.Name) + if err := client.Upload(opts.Binary, remoteTmpPath); err != nil { + return fmt.Errorf("error uploading binary: %w", err) + } + + fmt.Println("-> Creating system user...") + client.RunSudo(fmt.Sprintf("useradd -r -s /bin/false %s", opts.Name)) + + fmt.Println("-> Setting up directories...") + workDir := fmt.Sprintf("/var/lib/%s", opts.Name) + if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", workDir)); err != nil { + return fmt.Errorf("error creating work directory: %w", err) + } + if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", opts.Name, opts.Name, workDir)); err != nil { + return fmt.Errorf("error setting work directory ownership: %w", err) + } + + fmt.Println("-> Installing binary...") + binaryDest := fmt.Sprintf("/usr/local/bin/%s", opts.Name) + if _, err := client.RunSudo(fmt.Sprintf("mv %s %s", remoteTmpPath, binaryDest)); err != nil { + return fmt.Errorf("error moving binary: %w", err) + } + if _, err := client.RunSudo(fmt.Sprintf("chmod +x %s", binaryDest)); err != nil { + return fmt.Errorf("error making binary executable: %w", err) + } + + if len(opts.Files) > 0 { + fmt.Println("-> Uploading config files...") + for _, file := range opts.Files { + if _, err := os.Stat(file); err != nil { + return fmt.Errorf("config file not found: %s", file) + } + + remotePath := fmt.Sprintf("%s/%s", workDir, filepath.Base(file)) + fileTmpPath := fmt.Sprintf("/tmp/%s_%s", opts.Name, filepath.Base(file)) + + if err := client.Upload(file, fileTmpPath); err != nil { + return fmt.Errorf("error uploading config file %s: %w", file, err) + } + + if _, err := client.RunSudo(fmt.Sprintf("mv %s %s", fileTmpPath, remotePath)); err != nil { + return fmt.Errorf("error moving config file %s: %w", file, err) + } + + if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", opts.Name, opts.Name, remotePath)); err != nil { + return fmt.Errorf("error setting config file ownership %s: %w", file, err) + } + + fmt.Printf(" Uploaded: %s\n", file) + } + } + + fmt.Println("-> Creating environment file...") + envFilePath := fmt.Sprintf("/etc/ship/env/%s.env", opts.Name) + envContent := "" + for k, v := range opts.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) + } + if _, err := client.RunSudo(fmt.Sprintf("chmod 600 %s", envFilePath)); err != nil { + return fmt.Errorf("error setting env file permissions: %w", err) + } + if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", opts.Name, opts.Name, envFilePath)); err != nil { + return fmt.Errorf("error setting env file ownership: %w", err) + } + + fmt.Println("-> Creating systemd service...") + serviceContent, err := templates.SystemdService(map[string]string{ + "Name": opts.Name, + "User": opts.Name, + "WorkDir": workDir, + "BinaryPath": binaryDest, + "Port": strconv.Itoa(port), + "EnvFile": envFilePath, + "Args": opts.Args, + "Memory": opts.Memory, + "CPU": opts.CPU, + }) + if err != nil { + return fmt.Errorf("error generating systemd unit: %w", err) + } + + servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", opts.Name) + if err := client.WriteSudoFile(servicePath, serviceContent); err != nil { + return fmt.Errorf("error creating systemd unit: %w", err) + } + + fmt.Println("-> Configuring Caddy...") + caddyContent, err := templates.AppCaddy(map[string]string{ + "Domain": opts.Domain, + "Port": strconv.Itoa(port), + }) + if err != nil { + return fmt.Errorf("error generating Caddy config: %w", err) + } + + 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) + } + + fmt.Println("-> Reloading systemd...") + if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { + return fmt.Errorf("error reloading systemd: %w", err) + } + + fmt.Println("-> Starting service...") + if _, err := client.RunSudo(fmt.Sprintf("systemctl enable %s", opts.Name)); err != nil { + return fmt.Errorf("error enabling service: %w", err) + } + if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", opts.Name)); err != nil { + return fmt.Errorf("error starting service: %w", err) + } + + fmt.Println("-> Reloading Caddy...") + if _, err := client.RunSudo("systemctl reload caddy"); err != nil { + return fmt.Errorf("error reloading Caddy: %w", err) + } + + st.AddApp(opts.Host, opts.Name, &state.App{ + Type: "app", + Domain: opts.Domain, + Port: port, + 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) + } + + fmt.Printf("\n App deployed successfully!\n") + // Show first domain in the URL message + 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 +} + +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) + } + + if existingApp.Type != "app" { + return fmt.Errorf("%s is a static site, not an app", opts.Name) + } + + fmt.Printf("Updating config: %s\n", opts.Name) + + // Add PORT to env + opts.Env["PORT"] = strconv.Itoa(existingApp.Port) + + client, err := ssh.Connect(opts.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", opts.Name) + envContent := "" + for k, v := range opts.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", opts.Name) + binaryDest := fmt.Sprintf("/usr/local/bin/%s", opts.Name) + serviceContent, err := templates.SystemdService(map[string]string{ + "Name": opts.Name, + "User": opts.Name, + "WorkDir": workDir, + "BinaryPath": binaryDest, + "Port": strconv.Itoa(existingApp.Port), + "EnvFile": envFilePath, + "Args": opts.Args, + "Memory": opts.Memory, + "CPU": opts.CPU, + }) + if err != nil { + return fmt.Errorf("error generating systemd unit: %w", err) + } + + servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", opts.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", opts.Name)); err != nil { + return fmt.Errorf("error restarting service: %w", err) + } + + // Update state + 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) + } + + fmt.Printf("\n Config updated successfully!\n") + return nil +} + +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", opts.Name) + fmt.Printf(" Domain(s): %s\n", opts.Domain) + fmt.Printf(" Directory: %s\n", opts.Dir) + + if opts.IsUpdate { + fmt.Println(" Updating existing deployment") + } + + 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", 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) + } + + currentUser, err := client.Run("whoami") + if err != nil { + return fmt.Errorf("error getting current user: %w", err) + } + currentUser = strings.TrimSpace(currentUser) + + if _, err := client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", currentUser, currentUser, remoteDir)); err != nil { + return fmt.Errorf("error setting temporary ownership: %w", err) + } + + fmt.Println("-> Uploading files...") + if err := client.UploadDir(opts.Dir, remoteDir); err != nil { + return fmt.Errorf("error uploading files: %w", err) + } + + fmt.Println("-> Setting permissions...") + if _, err := client.RunSudo(fmt.Sprintf("chown -R www-data:www-data %s", remoteDir)); err != nil { + return fmt.Errorf("error setting ownership: %w", err) + } + if _, err := client.RunSudo(fmt.Sprintf("chmod -R 755 %s", remoteDir)); err != nil { + return fmt.Errorf("error setting directory permissions: %w", err) + } + if _, err := client.RunSudo(fmt.Sprintf("find %s -type f -exec chmod 644 {} \\;", remoteDir)); err != nil { + return fmt.Errorf("error setting file permissions: %w", err) + } + + fmt.Println("-> Configuring Caddy...") + caddyContent, err := templates.StaticCaddy(map[string]string{ + "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", opts.Name) + if err := client.WriteSudoFile(caddyPath, caddyContent); err != nil { + return fmt.Errorf("error creating Caddy config: %w", err) + } + + fmt.Println("-> Reloading Caddy...") + if _, err := client.RunSudo("systemctl reload caddy"); err != nil { + return fmt.Errorf("error reloading Caddy: %w", err) + } + + st.AddApp(opts.Host, opts.Name, &state.App{ + Type: "static", + 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") + 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 +} + +func parseEnvFile(path string) (map[string]string, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + env := make(map[string]string) + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + env[parts[0]] = parts[1] + } + } + + return env, scanner.Err() +} -- cgit v1.2.3