diff options
Diffstat (limited to 'cmd/ship')
| -rw-r--r-- | cmd/ship/deploy.go | 56 | ||||
| -rw-r--r-- | cmd/ship/deploy_cmd.go | 138 | ||||
| -rw-r--r-- | cmd/ship/host/init.go | 131 | ||||
| -rw-r--r-- | cmd/ship/init.go | 247 | ||||
| -rw-r--r-- | cmd/ship/list.go | 2 | ||||
| -rw-r--r-- | cmd/ship/logs.go | 2 | ||||
| -rw-r--r-- | cmd/ship/remove.go | 27 | ||||
| -rw-r--r-- | cmd/ship/restart.go | 2 | ||||
| -rw-r--r-- | cmd/ship/root.go | 2 | ||||
| -rw-r--r-- | cmd/ship/status.go | 2 |
10 files changed, 577 insertions, 32 deletions
diff --git a/cmd/ship/deploy.go b/cmd/ship/deploy.go index 24eed1e..9ac754c 100644 --- a/cmd/ship/deploy.go +++ b/cmd/ship/deploy.go | |||
| @@ -415,7 +415,7 @@ func updateAppConfig(st *state.State, opts DeployOptions) error { | |||
| 415 | 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) |
| 416 | } | 416 | } |
| 417 | 417 | ||
| 418 | if existingApp.Type != "app" { | 418 | if existingApp.Type != "app" && existingApp.Type != "git-app" { |
| 419 | return fmt.Errorf("%s is a static site, not an app", opts.Name) | 419 | return fmt.Errorf("%s is a static site, not an app", opts.Name) |
| 420 | } | 420 | } |
| 421 | 421 | ||
| @@ -441,33 +441,37 @@ func updateAppConfig(st *state.State, opts DeployOptions) error { | |||
| 441 | return fmt.Errorf("error creating env file: %w", err) | 441 | return fmt.Errorf("error creating env file: %w", err) |
| 442 | } | 442 | } |
| 443 | 443 | ||
| 444 | // Regenerate systemd unit | 444 | // For git-app, the systemd unit comes from .ship/service in the repo, |
| 445 | fmt.Println("-> Updating systemd service...") | 445 | // so we only update the env file and restart. |
| 446 | workDir := fmt.Sprintf("/var/lib/%s", opts.Name) | 446 | if existingApp.Type != "git-app" { |
| 447 | binaryDest := fmt.Sprintf("/usr/local/bin/%s", opts.Name) | 447 | // Regenerate systemd unit |
| 448 | serviceContent, err := templates.SystemdService(map[string]string{ | 448 | fmt.Println("-> Updating systemd service...") |
| 449 | "Name": opts.Name, | 449 | workDir := fmt.Sprintf("/var/lib/%s", opts.Name) |
| 450 | "User": opts.Name, | 450 | binaryDest := fmt.Sprintf("/usr/local/bin/%s", opts.Name) |
| 451 | "WorkDir": workDir, | 451 | serviceContent, err := templates.SystemdService(map[string]string{ |
| 452 | "BinaryPath": binaryDest, | 452 | "Name": opts.Name, |
| 453 | "Port": strconv.Itoa(existingApp.Port), | 453 | "User": opts.Name, |
| 454 | "EnvFile": envFilePath, | 454 | "WorkDir": workDir, |
| 455 | "Args": opts.Args, | 455 | "BinaryPath": binaryDest, |
| 456 | "Memory": opts.Memory, | 456 | "Port": strconv.Itoa(existingApp.Port), |
| 457 | "CPU": opts.CPU, | 457 | "EnvFile": envFilePath, |
| 458 | }) | 458 | "Args": opts.Args, |
| 459 | if err != nil { | 459 | "Memory": opts.Memory, |
| 460 | return fmt.Errorf("error generating systemd unit: %w", err) | 460 | "CPU": opts.CPU, |
| 461 | } | 461 | }) |
| 462 | if err != nil { | ||
| 463 | return fmt.Errorf("error generating systemd unit: %w", err) | ||
| 464 | } | ||
| 462 | 465 | ||
| 463 | servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", opts.Name) | 466 | servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", opts.Name) |
| 464 | if err := client.WriteSudoFile(servicePath, serviceContent); err != nil { | 467 | if err := client.WriteSudoFile(servicePath, serviceContent); err != nil { |
| 465 | return fmt.Errorf("error creating systemd unit: %w", err) | 468 | return fmt.Errorf("error creating systemd unit: %w", err) |
| 466 | } | 469 | } |
| 467 | 470 | ||
| 468 | fmt.Println("-> Reloading systemd...") | 471 | fmt.Println("-> Reloading systemd...") |
| 469 | if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { | 472 | if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { |
| 470 | return fmt.Errorf("error reloading systemd: %w", err) | 473 | return fmt.Errorf("error reloading systemd: %w", err) |
| 474 | } | ||
| 471 | } | 475 | } |
| 472 | 476 | ||
| 473 | fmt.Println("-> Restarting service...") | 477 | fmt.Println("-> Restarting service...") |
diff --git a/cmd/ship/deploy_cmd.go b/cmd/ship/deploy_cmd.go new file mode 100644 index 0000000..ca5c54d --- /dev/null +++ b/cmd/ship/deploy_cmd.go | |||
| @@ -0,0 +1,138 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | |||
| 6 | "github.com/bdw/ship/internal/ssh" | ||
| 7 | "github.com/bdw/ship/internal/state" | ||
| 8 | "github.com/spf13/cobra" | ||
| 9 | ) | ||
| 10 | |||
| 11 | var deployGitCmd = &cobra.Command{ | ||
| 12 | Use: "deploy <name>", | ||
| 13 | Short: "Manually rebuild and deploy a git-deployed app", | ||
| 14 | Long: `Trigger a manual rebuild from the latest code in the git repo. | ||
| 15 | |||
| 16 | This runs the same steps as the post-receive hook: checkout code, | ||
| 17 | install .ship/ configs, docker build (for apps), and restart. | ||
| 18 | |||
| 19 | Examples: | ||
| 20 | ship deploy myapp`, | ||
| 21 | Args: cobra.ExactArgs(1), | ||
| 22 | RunE: runDeployGit, | ||
| 23 | } | ||
| 24 | |||
| 25 | func runDeployGit(cmd *cobra.Command, args []string) error { | ||
| 26 | name := args[0] | ||
| 27 | |||
| 28 | st, err := state.Load() | ||
| 29 | if err != nil { | ||
| 30 | return fmt.Errorf("error loading state: %w", err) | ||
| 31 | } | ||
| 32 | |||
| 33 | host := hostFlag | ||
| 34 | if host == "" { | ||
| 35 | host = st.GetDefaultHost() | ||
| 36 | } | ||
| 37 | if host == "" { | ||
| 38 | return fmt.Errorf("--host is required") | ||
| 39 | } | ||
| 40 | |||
| 41 | app, err := st.GetApp(host, name) | ||
| 42 | if err != nil { | ||
| 43 | return err | ||
| 44 | } | ||
| 45 | |||
| 46 | if app.Type != "git-app" && app.Type != "git-static" { | ||
| 47 | return fmt.Errorf("%s is not a git-deployed app (type: %s)", name, app.Type) | ||
| 48 | } | ||
| 49 | |||
| 50 | fmt.Printf("Deploying %s...\n", name) | ||
| 51 | |||
| 52 | client, err := ssh.Connect(host) | ||
| 53 | if err != nil { | ||
| 54 | return fmt.Errorf("error connecting to VPS: %w", err) | ||
| 55 | } | ||
| 56 | defer client.Close() | ||
| 57 | |||
| 58 | if app.Type == "git-app" { | ||
| 59 | if err := deployGitApp(client, name); err != nil { | ||
| 60 | return err | ||
| 61 | } | ||
| 62 | } else { | ||
| 63 | if err := deployGitStatic(client, name); err != nil { | ||
| 64 | return err | ||
| 65 | } | ||
| 66 | } | ||
| 67 | |||
| 68 | fmt.Println("\nDeploy complete!") | ||
| 69 | return nil | ||
| 70 | } | ||
| 71 | |||
| 72 | func deployGitApp(client *ssh.Client, name string) error { | ||
| 73 | repo := fmt.Sprintf("/srv/git/%s.git", name) | ||
| 74 | src := fmt.Sprintf("/var/lib/%s/src", name) | ||
| 75 | |||
| 76 | fmt.Println("-> Checking out code...") | ||
| 77 | if _, err := client.RunSudo(fmt.Sprintf("git --work-tree=%s --git-dir=%s checkout -f main", src, repo)); err != nil { | ||
| 78 | return fmt.Errorf("error checking out code: %w", err) | ||
| 79 | } | ||
| 80 | |||
| 81 | // Install deployment config from repo | ||
| 82 | serviceSrc := fmt.Sprintf("%s/.ship/service", src) | ||
| 83 | serviceDst := fmt.Sprintf("/etc/systemd/system/%s.service", name) | ||
| 84 | fmt.Println("-> Installing systemd unit...") | ||
| 85 | if _, err := client.RunSudo(fmt.Sprintf("cp %s %s", serviceSrc, serviceDst)); err != nil { | ||
| 86 | fmt.Printf(" Warning: no .ship/service found, skipping\n") | ||
| 87 | } else { | ||
| 88 | if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { | ||
| 89 | return fmt.Errorf("error reloading systemd: %w", err) | ||
| 90 | } | ||
| 91 | } | ||
| 92 | |||
| 93 | caddySrc := fmt.Sprintf("%s/.ship/Caddyfile", src) | ||
| 94 | caddyDst := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) | ||
| 95 | fmt.Println("-> Installing Caddy config...") | ||
| 96 | if _, err := client.RunSudo(fmt.Sprintf("cp %s %s", caddySrc, caddyDst)); err != nil { | ||
| 97 | fmt.Printf(" Warning: no .ship/Caddyfile found, skipping\n") | ||
| 98 | } else { | ||
| 99 | if _, err := client.RunSudo("systemctl reload caddy"); err != nil { | ||
| 100 | return fmt.Errorf("error reloading Caddy: %w", err) | ||
| 101 | } | ||
| 102 | } | ||
| 103 | |||
| 104 | fmt.Println("-> Building Docker image...") | ||
| 105 | if err := client.RunSudoStream(fmt.Sprintf("docker build -t %s:latest %s", name, src)); err != nil { | ||
| 106 | return fmt.Errorf("error building Docker image: %w", err) | ||
| 107 | } | ||
| 108 | |||
| 109 | fmt.Println("-> Restarting service...") | ||
| 110 | if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { | ||
| 111 | return fmt.Errorf("error restarting service: %w", err) | ||
| 112 | } | ||
| 113 | |||
| 114 | return nil | ||
| 115 | } | ||
| 116 | |||
| 117 | func deployGitStatic(client *ssh.Client, name string) error { | ||
| 118 | repo := fmt.Sprintf("/srv/git/%s.git", name) | ||
| 119 | webroot := fmt.Sprintf("/var/www/%s", name) | ||
| 120 | |||
| 121 | fmt.Println("-> Deploying static site...") | ||
| 122 | if _, err := client.RunSudo(fmt.Sprintf("git --work-tree=%s --git-dir=%s checkout -f main", webroot, repo)); err != nil { | ||
| 123 | return fmt.Errorf("error checking out code: %w", err) | ||
| 124 | } | ||
| 125 | |||
| 126 | caddySrc := fmt.Sprintf("%s/.ship/Caddyfile", webroot) | ||
| 127 | caddyDst := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) | ||
| 128 | fmt.Println("-> Installing Caddy config...") | ||
| 129 | if _, err := client.RunSudo(fmt.Sprintf("cp %s %s", caddySrc, caddyDst)); err != nil { | ||
| 130 | fmt.Printf(" Warning: no .ship/Caddyfile found, skipping\n") | ||
| 131 | } else { | ||
| 132 | if _, err := client.RunSudo("systemctl reload caddy"); err != nil { | ||
| 133 | return fmt.Errorf("error reloading Caddy: %w", err) | ||
| 134 | } | ||
| 135 | } | ||
| 136 | |||
| 137 | return nil | ||
| 138 | } | ||
diff --git a/cmd/ship/host/init.go b/cmd/ship/host/init.go index 27f67af..d7143ff 100644 --- a/cmd/ship/host/init.go +++ b/cmd/ship/host/init.go | |||
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | ||
| 7 | "github.com/bdw/ship/internal/ssh" | 7 | "github.com/bdw/ship/internal/ssh" |
| 8 | "github.com/bdw/ship/internal/state" | 8 | "github.com/bdw/ship/internal/state" |
| 9 | "github.com/bdw/ship/internal/templates" | ||
| 9 | "github.com/spf13/cobra" | 10 | "github.com/spf13/cobra" |
| 10 | ) | 11 | ) |
| 11 | 12 | ||
| @@ -105,6 +106,14 @@ import /etc/caddy/sites-enabled/* | |||
| 105 | hostState.BaseDomain = baseDomain | 106 | hostState.BaseDomain = baseDomain |
| 106 | fmt.Printf(" Base domain: %s\n", baseDomain) | 107 | fmt.Printf(" Base domain: %s\n", baseDomain) |
| 107 | } | 108 | } |
| 109 | |||
| 110 | // Git-centric deployment setup (gated on base domain) | ||
| 111 | if baseDomain != "" { | ||
| 112 | if err := setupGitDeploy(client, baseDomain, hostState); err != nil { | ||
| 113 | return err | ||
| 114 | } | ||
| 115 | } | ||
| 116 | |||
| 108 | if st.GetDefaultHost() == "" { | 117 | if st.GetDefaultHost() == "" { |
| 109 | st.SetDefaultHost(host) | 118 | st.SetDefaultHost(host) |
| 110 | fmt.Printf(" Set %s as default host\n", host) | 119 | fmt.Printf(" Set %s as default host\n", host) |
| @@ -119,6 +128,128 @@ import /etc/caddy/sites-enabled/* | |||
| 119 | fmt.Printf(" ship --binary ./myapp --domain api.example.com\n") | 128 | fmt.Printf(" ship --binary ./myapp --domain api.example.com\n") |
| 120 | fmt.Println(" 2. Deploy a static site:") | 129 | fmt.Println(" 2. Deploy a static site:") |
| 121 | fmt.Printf(" ship --static --dir ./dist --domain example.com\n") | 130 | fmt.Printf(" ship --static --dir ./dist --domain example.com\n") |
| 131 | if baseDomain != "" { | ||
| 132 | fmt.Println(" 3. Initialize a git-deployed app:") | ||
| 133 | fmt.Printf(" ship init myapp\n") | ||
| 134 | } | ||
| 135 | return nil | ||
| 136 | } | ||
| 137 | |||
| 138 | func setupGitDeploy(client *ssh.Client, baseDomain string, hostState *state.Host) error { | ||
| 139 | fmt.Println("-> Installing Docker...") | ||
| 140 | dockerCommands := []string{ | ||
| 141 | "apt-get install -y ca-certificates curl gnupg", | ||
| 142 | "install -m 0755 -d /etc/apt/keyrings", | ||
| 143 | "curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc", | ||
| 144 | "chmod a+r /etc/apt/keyrings/docker.asc", | ||
| 145 | `echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo ${VERSION_CODENAME}) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null`, | ||
| 146 | "apt-get update", | ||
| 147 | "apt-get install -y docker-ce docker-ce-cli containerd.io", | ||
| 148 | } | ||
| 149 | for _, cmd := range dockerCommands { | ||
| 150 | if _, err := client.RunSudo(cmd); err != nil { | ||
| 151 | return fmt.Errorf("error installing Docker: %w", err) | ||
| 152 | } | ||
| 153 | } | ||
| 154 | fmt.Println(" Docker installed") | ||
| 155 | |||
| 156 | fmt.Println("-> Installing git and fcgiwrap...") | ||
| 157 | if _, err := client.RunSudo("apt-get install -y git fcgiwrap"); err != nil { | ||
| 158 | return fmt.Errorf("error installing git/fcgiwrap: %w", err) | ||
| 159 | } | ||
| 160 | fmt.Println(" git and fcgiwrap installed") | ||
| 161 | |||
| 162 | fmt.Println("-> Creating git user...") | ||
| 163 | // Create git user (ignore error if already exists) | ||
| 164 | client.RunSudo("useradd -r -m -d /home/git -s $(which git-shell) git") | ||
| 165 | if _, err := client.RunSudo("usermod -aG docker git"); err != nil { | ||
| 166 | return fmt.Errorf("error adding git user to docker group: %w", err) | ||
| 167 | } | ||
| 168 | fmt.Println(" git user created") | ||
| 169 | |||
| 170 | fmt.Println("-> Copying SSH keys to git user...") | ||
| 171 | copyKeysCommands := []string{ | ||
| 172 | "mkdir -p /home/git/.ssh", | ||
| 173 | "cp ~/.ssh/authorized_keys /home/git/.ssh/authorized_keys", | ||
| 174 | "chown -R git:git /home/git/.ssh", | ||
| 175 | "chmod 700 /home/git/.ssh", | ||
| 176 | "chmod 600 /home/git/.ssh/authorized_keys", | ||
| 177 | } | ||
| 178 | for _, cmd := range copyKeysCommands { | ||
| 179 | if _, err := client.RunSudo(cmd); err != nil { | ||
| 180 | return fmt.Errorf("error copying SSH keys: %w", err) | ||
| 181 | } | ||
| 182 | } | ||
| 183 | fmt.Println(" SSH keys copied") | ||
| 184 | |||
| 185 | fmt.Println("-> Creating /srv/git...") | ||
| 186 | if _, err := client.RunSudo("mkdir -p /srv/git"); err != nil { | ||
| 187 | return fmt.Errorf("error creating /srv/git: %w", err) | ||
| 188 | } | ||
| 189 | if _, err := client.RunSudo("chown git:git /srv/git"); err != nil { | ||
| 190 | return fmt.Errorf("error setting /srv/git ownership: %w", err) | ||
| 191 | } | ||
| 192 | fmt.Println(" /srv/git created") | ||
| 193 | |||
| 194 | fmt.Println("-> Writing sudoers for git user...") | ||
| 195 | sudoersContent := `git ALL=(ALL) NOPASSWD: /bin/systemctl restart *, /bin/systemctl daemon-reload, /bin/systemctl reload caddy, /bin/systemctl enable *, /bin/cp * /etc/systemd/system/*, /bin/cp * /etc/caddy/sites-enabled/*, /bin/mkdir -p /var/lib/*, /bin/mkdir -p /var/www/*, /bin/chown * | ||
| 196 | ` | ||
| 197 | if err := client.WriteSudoFile("/etc/sudoers.d/ship-git", sudoersContent); err != nil { | ||
| 198 | return fmt.Errorf("error writing sudoers: %w", err) | ||
| 199 | } | ||
| 200 | if _, err := client.RunSudo("chmod 440 /etc/sudoers.d/ship-git"); err != nil { | ||
| 201 | return fmt.Errorf("error setting sudoers permissions: %w", err) | ||
| 202 | } | ||
| 203 | fmt.Println(" sudoers configured") | ||
| 204 | |||
| 205 | fmt.Println("-> Writing vanity import template...") | ||
| 206 | vanityHTML := `<!DOCTYPE html> | ||
| 207 | <html><head> | ||
| 208 | {{$path := trimPrefix "/" .Req.URL.Path}} | ||
| 209 | {{$parts := splitList "/" $path}} | ||
| 210 | {{$module := first $parts}} | ||
| 211 | <meta name="go-import" content="{{.Host}}/{{$module}} git https://{{.Host}}/{{$module}}.git"> | ||
| 212 | </head> | ||
| 213 | <body>go get {{.Host}}/{{$module}}</body> | ||
| 214 | </html> | ||
| 215 | ` | ||
| 216 | if _, err := client.RunSudo("mkdir -p /opt/ship/vanity"); err != nil { | ||
| 217 | return fmt.Errorf("error creating vanity directory: %w", err) | ||
| 218 | } | ||
| 219 | if err := client.WriteSudoFile("/opt/ship/vanity/index.html", vanityHTML); err != nil { | ||
| 220 | return fmt.Errorf("error writing vanity template: %w", err) | ||
| 221 | } | ||
| 222 | fmt.Println(" vanity template written") | ||
| 223 | |||
| 224 | fmt.Println("-> Writing base domain Caddy config...") | ||
| 225 | codeCaddyContent, err := templates.CodeCaddy(map[string]string{ | ||
| 226 | "BaseDomain": baseDomain, | ||
| 227 | }) | ||
| 228 | if err != nil { | ||
| 229 | return fmt.Errorf("error generating code caddy config: %w", err) | ||
| 230 | } | ||
| 231 | if err := client.WriteSudoFile("/etc/caddy/sites-enabled/ship-code.caddy", codeCaddyContent); err != nil { | ||
| 232 | return fmt.Errorf("error writing code caddy config: %w", err) | ||
| 233 | } | ||
| 234 | fmt.Println(" base domain Caddy config written") | ||
| 235 | |||
| 236 | fmt.Println("-> Starting Docker and fcgiwrap...") | ||
| 237 | if _, err := client.RunSudo("systemctl enable docker fcgiwrap"); err != nil { | ||
| 238 | return fmt.Errorf("error enabling services: %w", err) | ||
| 239 | } | ||
| 240 | if _, err := client.RunSudo("systemctl start docker fcgiwrap"); err != nil { | ||
| 241 | return fmt.Errorf("error starting services: %w", err) | ||
| 242 | } | ||
| 243 | fmt.Println(" Docker and fcgiwrap started") | ||
| 244 | |||
| 245 | fmt.Println("-> Reloading Caddy...") | ||
| 246 | if _, err := client.RunSudo("systemctl reload caddy"); err != nil { | ||
| 247 | return fmt.Errorf("error reloading Caddy: %w", err) | ||
| 248 | } | ||
| 249 | fmt.Println(" Caddy reloaded") | ||
| 250 | |||
| 251 | hostState.GitSetup = true | ||
| 252 | fmt.Println(" Git deployment setup complete") | ||
| 122 | return nil | 253 | return nil |
| 123 | } | 254 | } |
| 124 | 255 | ||
diff --git a/cmd/ship/init.go b/cmd/ship/init.go new file mode 100644 index 0000000..167347d --- /dev/null +++ b/cmd/ship/init.go | |||
| @@ -0,0 +1,247 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | "os" | ||
| 6 | "path/filepath" | ||
| 7 | "strconv" | ||
| 8 | |||
| 9 | "github.com/bdw/ship/internal/ssh" | ||
| 10 | "github.com/bdw/ship/internal/state" | ||
| 11 | "github.com/bdw/ship/internal/templates" | ||
| 12 | "github.com/spf13/cobra" | ||
| 13 | ) | ||
| 14 | |||
| 15 | var initCmd = &cobra.Command{ | ||
| 16 | Use: "init <name>", | ||
| 17 | Short: "Initialize a git-deployed project", | ||
| 18 | Long: `Create a bare git repo on the VPS and generate local .ship/ config files. | ||
| 19 | |||
| 20 | Pushing to the remote triggers an automatic docker build and deploy (for apps) | ||
| 21 | or a static file checkout (for static sites). | ||
| 22 | |||
| 23 | Examples: | ||
| 24 | # Initialize an app (Docker-based) | ||
| 25 | ship init myapp | ||
| 26 | |||
| 27 | # Initialize with a custom domain | ||
| 28 | ship init myapp --domain custom.example.com | ||
| 29 | |||
| 30 | # Initialize a static site | ||
| 31 | ship init mysite --static`, | ||
| 32 | Args: cobra.ExactArgs(1), | ||
| 33 | RunE: runInit, | ||
| 34 | } | ||
| 35 | |||
| 36 | func init() { | ||
| 37 | initCmd.Flags().Bool("static", false, "Initialize as static site") | ||
| 38 | initCmd.Flags().String("domain", "", "Custom domain (default: name.basedomain)") | ||
| 39 | } | ||
| 40 | |||
| 41 | func runInit(cmd *cobra.Command, args []string) error { | ||
| 42 | name := args[0] | ||
| 43 | static, _ := cmd.Flags().GetBool("static") | ||
| 44 | domain, _ := cmd.Flags().GetString("domain") | ||
| 45 | |||
| 46 | st, err := state.Load() | ||
| 47 | if err != nil { | ||
| 48 | return fmt.Errorf("error loading state: %w", err) | ||
| 49 | } | ||
| 50 | |||
| 51 | host := hostFlag | ||
| 52 | if host == "" { | ||
| 53 | host = st.GetDefaultHost() | ||
| 54 | } | ||
| 55 | if host == "" { | ||
| 56 | return fmt.Errorf("--host is required") | ||
| 57 | } | ||
| 58 | |||
| 59 | hostState := st.GetHost(host) | ||
| 60 | if !hostState.GitSetup { | ||
| 61 | return fmt.Errorf("git deployment not set up on %s (run 'ship host init --base-domain example.com' first)", host) | ||
| 62 | } | ||
| 63 | |||
| 64 | // Check if app already exists | ||
| 65 | if _, err := st.GetApp(host, name); err == nil { | ||
| 66 | return fmt.Errorf("app %s already exists", name) | ||
| 67 | } | ||
| 68 | |||
| 69 | // Resolve domain | ||
| 70 | if domain == "" && hostState.BaseDomain != "" { | ||
| 71 | domain = name + "." + hostState.BaseDomain | ||
| 72 | } | ||
| 73 | if domain == "" { | ||
| 74 | return fmt.Errorf("--domain required (or configure base domain)") | ||
| 75 | } | ||
| 76 | |||
| 77 | appType := "git-app" | ||
| 78 | if static { | ||
| 79 | appType = "git-static" | ||
| 80 | } | ||
| 81 | |||
| 82 | // Allocate port for apps | ||
| 83 | port := 0 | ||
| 84 | if !static { | ||
| 85 | port = st.AllocatePort(host) | ||
| 86 | } | ||
| 87 | |||
| 88 | fmt.Printf("Initializing %s: %s\n", appType, name) | ||
| 89 | |||
| 90 | client, err := ssh.Connect(host) | ||
| 91 | if err != nil { | ||
| 92 | return fmt.Errorf("error connecting to VPS: %w", err) | ||
| 93 | } | ||
| 94 | defer client.Close() | ||
| 95 | |||
| 96 | // Create bare repo | ||
| 97 | fmt.Println("-> Creating bare git repo...") | ||
| 98 | repo := fmt.Sprintf("/srv/git/%s.git", name) | ||
| 99 | if _, err := client.RunSudo(fmt.Sprintf("sudo -u git git init --bare %s", repo)); err != nil { | ||
| 100 | return fmt.Errorf("error creating bare repo: %w", err) | ||
| 101 | } | ||
| 102 | |||
| 103 | if static { | ||
| 104 | // Create web root | ||
| 105 | fmt.Println("-> Creating web root...") | ||
| 106 | if _, err := client.RunSudo(fmt.Sprintf("mkdir -p /var/www/%s", name)); err != nil { | ||
| 107 | return fmt.Errorf("error creating web root: %w", err) | ||
| 108 | } | ||
| 109 | if _, err := client.RunSudo(fmt.Sprintf("chown git:git /var/www/%s", name)); err != nil { | ||
| 110 | return fmt.Errorf("error setting web root ownership: %w", err) | ||
| 111 | } | ||
| 112 | |||
| 113 | // Write post-receive hook | ||
| 114 | fmt.Println("-> Writing post-receive hook...") | ||
| 115 | hookContent, err := templates.PostReceiveHookStatic(map[string]string{ | ||
| 116 | "Name": name, | ||
| 117 | }) | ||
| 118 | if err != nil { | ||
| 119 | return fmt.Errorf("error generating hook: %w", err) | ||
| 120 | } | ||
| 121 | hookPath := fmt.Sprintf("%s/hooks/post-receive", repo) | ||
| 122 | if err := client.WriteSudoFile(hookPath, hookContent); err != nil { | ||
| 123 | return fmt.Errorf("error writing hook: %w", err) | ||
| 124 | } | ||
| 125 | if _, err := client.RunSudo(fmt.Sprintf("chmod +x %s", hookPath)); err != nil { | ||
| 126 | return fmt.Errorf("error making hook executable: %w", err) | ||
| 127 | } | ||
| 128 | if _, err := client.RunSudo(fmt.Sprintf("chown git:git %s", hookPath)); err != nil { | ||
| 129 | return fmt.Errorf("error setting hook ownership: %w", err) | ||
| 130 | } | ||
| 131 | } else { | ||
| 132 | // Create directories for app | ||
| 133 | fmt.Println("-> Creating app directories...") | ||
| 134 | dirs := []string{ | ||
| 135 | fmt.Sprintf("/var/lib/%s/data", name), | ||
| 136 | fmt.Sprintf("/var/lib/%s/src", name), | ||
| 137 | } | ||
| 138 | for _, dir := range dirs { | ||
| 139 | if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", dir)); err != nil { | ||
| 140 | return fmt.Errorf("error creating directory %s: %w", dir, err) | ||
| 141 | } | ||
| 142 | } | ||
| 143 | if _, err := client.RunSudo(fmt.Sprintf("chown -R git:git /var/lib/%s", name)); err != nil { | ||
| 144 | return fmt.Errorf("error setting directory ownership: %w", err) | ||
| 145 | } | ||
| 146 | |||
| 147 | // Create env file | ||
| 148 | fmt.Println("-> Creating environment file...") | ||
| 149 | envContent := fmt.Sprintf("PORT=%d\nDATA_DIR=/data\n", port) | ||
| 150 | envPath := fmt.Sprintf("/etc/ship/env/%s.env", name) | ||
| 151 | if err := client.WriteSudoFile(envPath, envContent); err != nil { | ||
| 152 | return fmt.Errorf("error creating env file: %w", err) | ||
| 153 | } | ||
| 154 | |||
| 155 | // Write post-receive hook | ||
| 156 | fmt.Println("-> Writing post-receive hook...") | ||
| 157 | hookContent, err := templates.PostReceiveHook(map[string]string{ | ||
| 158 | "Name": name, | ||
| 159 | }) | ||
| 160 | if err != nil { | ||
| 161 | return fmt.Errorf("error generating hook: %w", err) | ||
| 162 | } | ||
| 163 | hookPath := fmt.Sprintf("%s/hooks/post-receive", repo) | ||
| 164 | if err := client.WriteSudoFile(hookPath, hookContent); err != nil { | ||
| 165 | return fmt.Errorf("error writing hook: %w", err) | ||
| 166 | } | ||
| 167 | if _, err := client.RunSudo(fmt.Sprintf("chmod +x %s", hookPath)); err != nil { | ||
| 168 | return fmt.Errorf("error making hook executable: %w", err) | ||
| 169 | } | ||
| 170 | if _, err := client.RunSudo(fmt.Sprintf("chown git:git %s", hookPath)); err != nil { | ||
| 171 | return fmt.Errorf("error setting hook ownership: %w", err) | ||
| 172 | } | ||
| 173 | } | ||
| 174 | |||
| 175 | // Save state | ||
| 176 | st.AddApp(host, name, &state.App{ | ||
| 177 | Type: appType, | ||
| 178 | Domain: domain, | ||
| 179 | Port: port, | ||
| 180 | Repo: repo, | ||
| 181 | }) | ||
| 182 | if err := st.Save(); err != nil { | ||
| 183 | return fmt.Errorf("error saving state: %w", err) | ||
| 184 | } | ||
| 185 | |||
| 186 | // Generate local .ship/ files | ||
| 187 | fmt.Println("-> Generating local .ship/ config...") | ||
| 188 | if err := os.MkdirAll(".ship", 0755); err != nil { | ||
| 189 | return fmt.Errorf("error creating .ship directory: %w", err) | ||
| 190 | } | ||
| 191 | |||
| 192 | if static { | ||
| 193 | caddyContent, err := templates.DefaultStaticCaddy(map[string]string{ | ||
| 194 | "Domain": domain, | ||
| 195 | "Name": name, | ||
| 196 | }) | ||
| 197 | if err != nil { | ||
| 198 | return fmt.Errorf("error generating Caddyfile: %w", err) | ||
| 199 | } | ||
| 200 | if err := os.WriteFile(filepath.Join(".ship", "Caddyfile"), []byte(caddyContent), 0644); err != nil { | ||
| 201 | return fmt.Errorf("error writing Caddyfile: %w", err) | ||
| 202 | } | ||
| 203 | } else { | ||
| 204 | caddyContent, err := templates.DefaultAppCaddy(map[string]string{ | ||
| 205 | "Domain": domain, | ||
| 206 | "Port": strconv.Itoa(port), | ||
| 207 | }) | ||
| 208 | if err != nil { | ||
| 209 | return fmt.Errorf("error generating Caddyfile: %w", err) | ||
| 210 | } | ||
| 211 | if err := os.WriteFile(filepath.Join(".ship", "Caddyfile"), []byte(caddyContent), 0644); err != nil { | ||
| 212 | return fmt.Errorf("error writing Caddyfile: %w", err) | ||
| 213 | } | ||
| 214 | |||
| 215 | serviceContent, err := templates.DockerService(map[string]string{ | ||
| 216 | "Name": name, | ||
| 217 | "Port": strconv.Itoa(port), | ||
| 218 | }) | ||
| 219 | if err != nil { | ||
| 220 | return fmt.Errorf("error generating service file: %w", err) | ||
| 221 | } | ||
| 222 | if err := os.WriteFile(filepath.Join(".ship", "service"), []byte(serviceContent), 0644); err != nil { | ||
| 223 | return fmt.Errorf("error writing service file: %w", err) | ||
| 224 | } | ||
| 225 | } | ||
| 226 | |||
| 227 | // Resolve SSH hostname for git remote URL | ||
| 228 | sshHost := host | ||
| 229 | |||
| 230 | fmt.Printf("\nProject initialized: %s\n", name) | ||
| 231 | fmt.Println("\nGenerated:") | ||
| 232 | fmt.Println(" .ship/Caddyfile — Caddy config (edit to customize routing)") | ||
| 233 | if !static { | ||
| 234 | fmt.Println(" .ship/service — systemd unit (edit to customize resources, ports)") | ||
| 235 | } | ||
| 236 | fmt.Println("\nNext steps:") | ||
| 237 | if static { | ||
| 238 | fmt.Println(" git add .ship/") | ||
| 239 | } else { | ||
| 240 | fmt.Println(" git add .ship/ Dockerfile") | ||
| 241 | } | ||
| 242 | fmt.Println(" git commit -m \"initial deploy\"") | ||
| 243 | fmt.Printf(" git remote add ship git@%s:%s\n", sshHost, repo) | ||
| 244 | fmt.Println(" git push ship main") | ||
| 245 | |||
| 246 | return nil | ||
| 247 | } | ||
diff --git a/cmd/ship/list.go b/cmd/ship/list.go index 404ca68..a10b2ca 100644 --- a/cmd/ship/list.go +++ b/cmd/ship/list.go | |||
| @@ -40,7 +40,7 @@ func runList(cmd *cobra.Command, args []string) error { | |||
| 40 | fmt.Fprintln(w, "NAME\tTYPE\tDOMAIN\tPORT") | 40 | fmt.Fprintln(w, "NAME\tTYPE\tDOMAIN\tPORT") |
| 41 | for name, app := range apps { | 41 | for name, app := range apps { |
| 42 | port := "" | 42 | port := "" |
| 43 | if app.Type == "app" { | 43 | if app.Type == "app" || app.Type == "git-app" { |
| 44 | port = fmt.Sprintf(":%d", app.Port) | 44 | port = fmt.Sprintf(":%d", app.Port) |
| 45 | } | 45 | } |
| 46 | domain := app.Domain | 46 | domain := app.Domain |
diff --git a/cmd/ship/logs.go b/cmd/ship/logs.go index 1932c18..4d85fe1 100644 --- a/cmd/ship/logs.go +++ b/cmd/ship/logs.go | |||
| @@ -44,7 +44,7 @@ func runLogs(cmd *cobra.Command, args []string) error { | |||
| 44 | return err | 44 | return err |
| 45 | } | 45 | } |
| 46 | 46 | ||
| 47 | if app.Type != "app" { | 47 | if app.Type != "app" && app.Type != "git-app" { |
| 48 | return fmt.Errorf("logs are only available for apps, not static sites") | 48 | return fmt.Errorf("logs are only available for apps, not static sites") |
| 49 | } | 49 | } |
| 50 | 50 | ||
diff --git a/cmd/ship/remove.go b/cmd/ship/remove.go index 922eb8f..8380e87 100644 --- a/cmd/ship/remove.go +++ b/cmd/ship/remove.go | |||
| @@ -46,7 +46,8 @@ func runRemove(cmd *cobra.Command, args []string) error { | |||
| 46 | } | 46 | } |
| 47 | defer client.Close() | 47 | defer client.Close() |
| 48 | 48 | ||
| 49 | if app.Type == "app" { | 49 | switch app.Type { |
| 50 | case "app": | ||
| 50 | fmt.Println("-> Stopping service...") | 51 | fmt.Println("-> Stopping service...") |
| 51 | client.RunSudo(fmt.Sprintf("systemctl stop %s", name)) | 52 | client.RunSudo(fmt.Sprintf("systemctl stop %s", name)) |
| 52 | client.RunSudo(fmt.Sprintf("systemctl disable %s", name)) | 53 | client.RunSudo(fmt.Sprintf("systemctl disable %s", name)) |
| @@ -58,7 +59,29 @@ func runRemove(cmd *cobra.Command, args []string) error { | |||
| 58 | client.RunSudo(fmt.Sprintf("rm -rf /var/lib/%s", name)) | 59 | client.RunSudo(fmt.Sprintf("rm -rf /var/lib/%s", name)) |
| 59 | client.RunSudo(fmt.Sprintf("rm -f /etc/ship/env/%s.env", name)) | 60 | client.RunSudo(fmt.Sprintf("rm -f /etc/ship/env/%s.env", name)) |
| 60 | client.RunSudo(fmt.Sprintf("userdel %s", name)) | 61 | client.RunSudo(fmt.Sprintf("userdel %s", name)) |
| 61 | } else { | 62 | |
| 63 | case "git-app": | ||
| 64 | fmt.Println("-> Stopping service...") | ||
| 65 | client.RunSudo(fmt.Sprintf("systemctl stop %s", name)) | ||
| 66 | client.RunSudo(fmt.Sprintf("systemctl disable %s", name)) | ||
| 67 | |||
| 68 | client.RunSudo(fmt.Sprintf("rm -f /etc/systemd/system/%s.service", name)) | ||
| 69 | client.RunSudo("systemctl daemon-reload") | ||
| 70 | |||
| 71 | fmt.Println("-> Removing Docker image...") | ||
| 72 | client.RunSudo(fmt.Sprintf("docker rmi %s:latest", name)) | ||
| 73 | |||
| 74 | fmt.Println("-> Removing files...") | ||
| 75 | client.RunSudo(fmt.Sprintf("rm -rf /var/lib/%s", name)) | ||
| 76 | client.RunSudo(fmt.Sprintf("rm -rf /srv/git/%s.git", name)) | ||
| 77 | client.RunSudo(fmt.Sprintf("rm -f /etc/ship/env/%s.env", name)) | ||
| 78 | |||
| 79 | case "git-static": | ||
| 80 | fmt.Println("-> Removing files...") | ||
| 81 | client.RunSudo(fmt.Sprintf("rm -rf /var/www/%s", name)) | ||
| 82 | client.RunSudo(fmt.Sprintf("rm -rf /srv/git/%s.git", name)) | ||
| 83 | |||
| 84 | default: // "static" | ||
| 62 | fmt.Println("-> Removing files...") | 85 | fmt.Println("-> Removing files...") |
| 63 | client.RunSudo(fmt.Sprintf("rm -rf /var/www/%s", name)) | 86 | client.RunSudo(fmt.Sprintf("rm -rf /var/www/%s", name)) |
| 64 | } | 87 | } |
diff --git a/cmd/ship/restart.go b/cmd/ship/restart.go index 2c74c62..404624a 100644 --- a/cmd/ship/restart.go +++ b/cmd/ship/restart.go | |||
| @@ -37,7 +37,7 @@ func runRestart(cmd *cobra.Command, args []string) error { | |||
| 37 | return err | 37 | return err |
| 38 | } | 38 | } |
| 39 | 39 | ||
| 40 | if app.Type != "app" { | 40 | if app.Type != "app" && app.Type != "git-app" { |
| 41 | return fmt.Errorf("restart is only available for apps, not static sites") | 41 | return fmt.Errorf("restart is only available for apps, not static sites") |
| 42 | } | 42 | } |
| 43 | 43 | ||
diff --git a/cmd/ship/root.go b/cmd/ship/root.go index 837fd4c..93280f5 100644 --- a/cmd/ship/root.go +++ b/cmd/ship/root.go | |||
| @@ -88,6 +88,8 @@ func init() { | |||
| 88 | rootCmd.AddCommand(statusCmd) | 88 | rootCmd.AddCommand(statusCmd) |
| 89 | rootCmd.AddCommand(restartCmd) | 89 | rootCmd.AddCommand(restartCmd) |
| 90 | rootCmd.AddCommand(removeCmd) | 90 | rootCmd.AddCommand(removeCmd) |
| 91 | rootCmd.AddCommand(initCmd) | ||
| 92 | rootCmd.AddCommand(deployGitCmd) | ||
| 91 | rootCmd.AddCommand(env.Cmd) | 93 | rootCmd.AddCommand(env.Cmd) |
| 92 | rootCmd.AddCommand(host.Cmd) | 94 | rootCmd.AddCommand(host.Cmd) |
| 93 | rootCmd.AddCommand(uiCmd) | 95 | rootCmd.AddCommand(uiCmd) |
diff --git a/cmd/ship/status.go b/cmd/ship/status.go index 03c548b..536ec8c 100644 --- a/cmd/ship/status.go +++ b/cmd/ship/status.go | |||
| @@ -37,7 +37,7 @@ func runStatus(cmd *cobra.Command, args []string) error { | |||
| 37 | return err | 37 | return err |
| 38 | } | 38 | } |
| 39 | 39 | ||
| 40 | if app.Type != "app" { | 40 | if app.Type != "app" && app.Type != "git-app" { |
| 41 | return fmt.Errorf("status is only available for apps, not static sites") | 41 | return fmt.Errorf("status is only available for apps, not static sites") |
| 42 | } | 42 | } |
| 43 | 43 | ||
