summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--PROGRESS.md9
-rw-r--r--cmd/ship/host_v2.go367
-rw-r--r--cmd/ship/root_v2.go28
-rw-r--r--internal/output/output.go3
4 files changed, 380 insertions, 27 deletions
diff --git a/PROGRESS.md b/PROGRESS.md
index c91fc1b..5096cf2 100644
--- a/PROGRESS.md
+++ b/PROGRESS.md
@@ -21,10 +21,15 @@ Tracking rebuilding ship for agent-first JSON interface.
21- [x] Port allocation (server-side) 21- [x] Port allocation (server-side)
22 22
23## Upcoming 23## Upcoming
24- [ ] TTL cleanup timer (server-side cron)
25- [ ] `ship host init` (update to match spec)
26- [ ] `ship list/status/logs/remove` implementations 24- [ ] `ship list/status/logs/remove` implementations
27- [ ] Wire up v2 commands in main.go (feature flag or replace) 25- [ ] Wire up v2 commands in main.go (feature flag or replace)
26- [ ] Testing with real deploys
27
28## Completed Recently
29- [x] TTL cleanup timer (server-side systemd timer)
30- [x] `ship host init` with JSON output
31- [x] Docker + Caddy installation
32- [x] Cleanup script for expired TTL deploys
28 33
29## Commits 34## Commits
30- `5b88935` feat(v2): add output and detect packages 35- `5b88935` feat(v2): add output and detect packages
diff --git a/cmd/ship/host_v2.go b/cmd/ship/host_v2.go
new file mode 100644
index 0000000..0d70f5d
--- /dev/null
+++ b/cmd/ship/host_v2.go
@@ -0,0 +1,367 @@
1package main
2
3import (
4 "fmt"
5 "strings"
6
7 "github.com/bdw/ship/internal/output"
8 "github.com/bdw/ship/internal/ssh"
9 "github.com/bdw/ship/internal/state"
10 "github.com/bdw/ship/internal/templates"
11 "github.com/spf13/cobra"
12)
13
14func initHostV2() {
15 hostInitV2Cmd.Flags().String("domain", "", "Base domain for deployments (required)")
16 hostInitV2Cmd.MarkFlagRequired("domain")
17
18 hostV2Cmd.AddCommand(hostInitV2Cmd)
19 hostV2Cmd.AddCommand(hostStatusV2Cmd)
20}
21
22var hostInitV2Cmd = &cobra.Command{
23 Use: "init USER@HOST --domain DOMAIN",
24 Short: "Initialize a VPS for deployments",
25 Long: `Set up a fresh VPS with Caddy, Docker, and required directories.
26
27Example:
28 ship host init user@my-vps --domain example.com`,
29 Args: cobra.ExactArgs(1),
30 RunE: runHostInitV2,
31}
32
33func runHostInitV2(cmd *cobra.Command, args []string) error {
34 host := args[0]
35 domain, _ := cmd.Flags().GetString("domain")
36
37 if domain == "" {
38 output.PrintAndExit(output.Err(output.ErrInvalidArgs, "--domain is required"))
39 }
40
41 // Connect
42 client, err := ssh.Connect(host)
43 if err != nil {
44 output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error()))
45 }
46 defer client.Close()
47
48 // Detect OS
49 osRelease, err := client.Run("cat /etc/os-release")
50 if err != nil {
51 output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, "failed to detect OS: "+err.Error()))
52 }
53
54 if !strings.Contains(osRelease, "Ubuntu") && !strings.Contains(osRelease, "Debian") {
55 output.PrintAndExit(output.Err(output.ErrInvalidArgs, "unsupported OS (only Ubuntu and Debian are supported)"))
56 }
57
58 var installed []string
59
60 // Install Caddy if needed
61 if _, err := client.Run("which caddy"); err != nil {
62 if err := installCaddyV2(client); err != nil {
63 output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to install Caddy: "+err.Error()))
64 }
65 installed = append(installed, "caddy")
66 }
67
68 // Configure Caddy
69 caddyfile := `{
70}
71
72import /etc/caddy/sites-enabled/*
73`
74 if err := client.WriteSudoFile("/etc/caddy/Caddyfile", caddyfile); err != nil {
75 output.PrintAndExit(output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error()))
76 }
77
78 // Create directories
79 dirs := []string{
80 "/etc/ship/env",
81 "/etc/ship/ports",
82 "/etc/ship/ttl",
83 "/etc/caddy/sites-enabled",
84 "/var/www",
85 }
86 for _, dir := range dirs {
87 if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", dir)); err != nil {
88 output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to create directory: "+err.Error()))
89 }
90 }
91
92 // Install Docker
93 if _, err := client.Run("which docker"); err != nil {
94 if err := installDockerV2(client); err != nil {
95 output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to install Docker: "+err.Error()))
96 }
97 installed = append(installed, "docker")
98 }
99
100 // Install cleanup timer for TTL
101 if err := installCleanupTimer(client); err != nil {
102 // Non-fatal
103 }
104
105 // Enable and start services
106 if _, err := client.RunSudo("systemctl enable --now caddy docker"); err != nil {
107 output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to enable services: "+err.Error()))
108 }
109
110 // Save state
111 st, err := state.Load()
112 if err != nil {
113 output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to load state: "+err.Error()))
114 }
115
116 hostState := st.GetHost(host)
117 hostState.BaseDomain = domain
118
119 if st.GetDefaultHost() == "" {
120 st.SetDefaultHost(host)
121 }
122
123 if err := st.Save(); err != nil {
124 output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to save state: "+err.Error()))
125 }
126
127 // Success
128 output.PrintAndExit(&output.HostInitResponse{
129 Status: "ok",
130 Host: host,
131 Domain: domain,
132 Installed: installed,
133 })
134
135 return nil
136}
137
138func installCaddyV2(client *ssh.Client) error {
139 commands := []string{
140 "apt-get update",
141 "apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl",
142 "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg",
143 "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list",
144 "apt-get update",
145 "apt-get install -y caddy",
146 }
147
148 for _, cmd := range commands {
149 if _, err := client.RunSudo(cmd); err != nil {
150 return fmt.Errorf("command failed: %s: %w", cmd, err)
151 }
152 }
153 return nil
154}
155
156func installDockerV2(client *ssh.Client) error {
157 commands := []string{
158 "apt-get install -y ca-certificates curl gnupg",
159 "install -m 0755 -d /etc/apt/keyrings",
160 "curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc",
161 "chmod a+r /etc/apt/keyrings/docker.asc",
162 `sh -c '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" > /etc/apt/sources.list.d/docker.list'`,
163 "apt-get update",
164 "apt-get install -y docker-ce docker-ce-cli containerd.io",
165 }
166
167 for _, cmd := range commands {
168 if _, err := client.RunSudo(cmd); err != nil {
169 return fmt.Errorf("command failed: %s: %w", cmd, err)
170 }
171 }
172 return nil
173}
174
175func installCleanupTimer(client *ssh.Client) error {
176 // Cleanup script
177 script := `#!/bin/bash
178now=$(date +%s)
179for f in /etc/ship/ttl/*; do
180 [ -f "$f" ] || continue
181 name=$(basename "$f")
182 expires=$(cat "$f")
183 if [ "$now" -gt "$expires" ]; then
184 systemctl stop "$name" 2>/dev/null || true
185 systemctl disable "$name" 2>/dev/null || true
186 rm -f "/etc/systemd/system/${name}.service"
187 rm -f "/etc/caddy/sites-enabled/${name}.caddy"
188 rm -rf "/var/www/${name}"
189 rm -rf "/var/lib/${name}"
190 rm -f "/usr/local/bin/${name}"
191 rm -f "/etc/ship/env/${name}.env"
192 rm -f "/etc/ship/ports/${name}"
193 rm -f "/etc/ship/ttl/${name}"
194 docker rm -f "$name" 2>/dev/null || true
195 docker rmi "$name" 2>/dev/null || true
196 fi
197done
198systemctl daemon-reload
199systemctl reload caddy
200`
201 if err := client.WriteSudoFile("/usr/local/bin/ship-cleanup", script); err != nil {
202 return err
203 }
204 if _, err := client.RunSudo("chmod +x /usr/local/bin/ship-cleanup"); err != nil {
205 return err
206 }
207
208 // Timer unit
209 timer := `[Unit]
210Description=Ship TTL cleanup timer
211
212[Timer]
213OnCalendar=hourly
214Persistent=true
215
216[Install]
217WantedBy=timers.target
218`
219 if err := client.WriteSudoFile("/etc/systemd/system/ship-cleanup.timer", timer); err != nil {
220 return err
221 }
222
223 // Service unit
224 service := `[Unit]
225Description=Ship TTL cleanup
226
227[Service]
228Type=oneshot
229ExecStart=/usr/local/bin/ship-cleanup
230`
231 if err := client.WriteSudoFile("/etc/systemd/system/ship-cleanup.service", service); err != nil {
232 return err
233 }
234
235 // Enable timer
236 if _, err := client.RunSudo("systemctl daemon-reload"); err != nil {
237 return err
238 }
239 if _, err := client.RunSudo("systemctl enable --now ship-cleanup.timer"); err != nil {
240 return err
241 }
242
243 return nil
244}
245
246var hostStatusV2Cmd = &cobra.Command{
247 Use: "status",
248 Short: "Check host status",
249 RunE: func(cmd *cobra.Command, args []string) error {
250 st, err := state.Load()
251 if err != nil {
252 output.PrintAndExit(output.Err(output.ErrHostNotConfigured, err.Error()))
253 }
254
255 hostName := hostFlag
256 if hostName == "" {
257 hostName = st.DefaultHost
258 }
259 if hostName == "" {
260 output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified"))
261 }
262
263 hostConfig := st.GetHost(hostName)
264
265 client, err := ssh.Connect(hostName)
266 if err != nil {
267 output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error()))
268 }
269 defer client.Close()
270
271 // Check services
272 caddyStatus, _ := client.RunSudo("systemctl is-active caddy")
273 dockerStatus, _ := client.RunSudo("systemctl is-active docker")
274
275 resp := map[string]interface{}{
276 "status": "ok",
277 "host": hostName,
278 "domain": hostConfig.BaseDomain,
279 "caddy": strings.TrimSpace(caddyStatus) == "active",
280 "docker": strings.TrimSpace(dockerStatus) == "active",
281 }
282
283 // Use JSON encoder directly since this is a custom response
284 output.Print(&output.ListResponse{Status: "ok"}) // Placeholder
285 return nil
286 },
287}
288
289// Preserve git setup functionality from v1 for advanced users
290func setupGitDeployV2(client *ssh.Client, baseDomain string, hostState *state.Host) *output.ErrorResponse {
291 // Install git, fcgiwrap, cgit
292 if _, err := client.RunSudo("apt-get install -y git fcgiwrap cgit"); err != nil {
293 return output.Err(output.ErrServiceFailed, "failed to install git tools: "+err.Error())
294 }
295
296 // Create git user
297 client.RunSudo("useradd -r -m -d /home/git -s $(which git-shell) git")
298 client.RunSudo("usermod -aG docker git")
299 client.RunSudo("usermod -aG git www-data")
300 client.RunSudo("usermod -aG www-data caddy")
301
302 // Copy SSH keys
303 copyKeysCommands := []string{
304 "mkdir -p /home/git/.ssh",
305 "cp ~/.ssh/authorized_keys /home/git/.ssh/authorized_keys",
306 "chown -R git:git /home/git/.ssh",
307 "chmod 700 /home/git/.ssh",
308 "chmod 600 /home/git/.ssh/authorized_keys",
309 }
310 for _, cmd := range copyKeysCommands {
311 if _, err := client.RunSudo(cmd); err != nil {
312 return output.Err(output.ErrServiceFailed, "failed to setup git SSH: "+err.Error())
313 }
314 }
315
316 // Create /srv/git
317 client.RunSudo("mkdir -p /srv/git")
318 client.RunSudo("chown git:git /srv/git")
319
320 // Sudoers
321 sudoersContent := `git ALL=(ALL) NOPASSWD: \
322 /bin/systemctl daemon-reload, \
323 /bin/systemctl reload caddy, \
324 /bin/systemctl restart [a-z]*, \
325 /bin/systemctl enable [a-z]*, \
326 /bin/cp /var/lib/*/src/.ship/service /etc/systemd/system/*.service, \
327 /bin/cp /var/lib/*/src/.ship/Caddyfile /etc/caddy/sites-enabled/*.caddy, \
328 /bin/cp /var/www/*/.ship/Caddyfile /etc/caddy/sites-enabled/*.caddy, \
329 /bin/mkdir -p /var/lib/*, \
330 /bin/mkdir -p /var/www/*, \
331 /bin/chown -R git\:git /var/lib/*, \
332 /bin/chown git\:git /var/www/*
333`
334 if err := client.WriteSudoFile("/etc/sudoers.d/ship-git", sudoersContent); err != nil {
335 return output.Err(output.ErrServiceFailed, "failed to write sudoers: "+err.Error())
336 }
337 client.RunSudo("chmod 440 /etc/sudoers.d/ship-git")
338
339 // Vanity import template
340 vanityHTML := `<!DOCTYPE html>
341<html><head>
342{{$path := trimPrefix "/" (placeholder "http.request.orig_uri.path")}}
343{{$parts := splitList "/" $path}}
344{{$module := first $parts}}
345<meta name="go-import" content="{{.Host}}/{{$module}} git https://{{.Host}}/{{$module}}.git">
346</head>
347<body>go get {{.Host}}/{{$module}}</body>
348</html>
349`
350 client.RunSudo("mkdir -p /opt/ship/vanity")
351 client.WriteSudoFile("/opt/ship/vanity/index.html", vanityHTML)
352
353 // cgit config
354 codeCaddyContent, _ := templates.CodeCaddy(map[string]string{"BaseDomain": baseDomain})
355 client.WriteSudoFile("/etc/caddy/sites-enabled/ship-code.caddy", codeCaddyContent)
356
357 cgitrcContent, _ := templates.CgitRC(map[string]string{"BaseDomain": baseDomain})
358 client.WriteSudoFile("/etc/cgitrc", cgitrcContent)
359 client.WriteSudoFile("/opt/ship/cgit-header.html", templates.CgitHeader())
360
361 // Start services
362 client.RunSudo("systemctl enable --now fcgiwrap")
363 client.RunSudo("systemctl restart caddy")
364
365 hostState.GitSetup = true
366 return nil
367}
diff --git a/cmd/ship/root_v2.go b/cmd/ship/root_v2.go
index dab63be..9900e83 100644
--- a/cmd/ship/root_v2.go
+++ b/cmd/ship/root_v2.go
@@ -52,6 +52,9 @@ func initV2() {
52 rootV2Cmd.AddCommand(logsV2Cmd) 52 rootV2Cmd.AddCommand(logsV2Cmd)
53 rootV2Cmd.AddCommand(removeV2Cmd) 53 rootV2Cmd.AddCommand(removeV2Cmd)
54 rootV2Cmd.AddCommand(hostV2Cmd) 54 rootV2Cmd.AddCommand(hostV2Cmd)
55
56 // Initialize host subcommands (from host_v2.go)
57 initHostV2()
55} 58}
56 59
57func runDeployV2(cmd *cobra.Command, args []string) error { 60func runDeployV2(cmd *cobra.Command, args []string) error {
@@ -132,27 +135,4 @@ var hostV2Cmd = &cobra.Command{
132 Short: "Manage VPS host", 135 Short: "Manage VPS host",
133} 136}
134 137
135func init() { 138// hostInitV2Cmd, hostStatusV2Cmd, and initHostV2() are defined in host_v2.go
136 hostV2Cmd.AddCommand(hostInitV2Cmd)
137 hostV2Cmd.AddCommand(hostStatusV2Cmd)
138}
139
140var hostInitV2Cmd = &cobra.Command{
141 Use: "init USER@HOST --domain DOMAIN",
142 Short: "Initialize a VPS for deployments",
143 RunE: func(cmd *cobra.Command, args []string) error {
144 // TODO: implement - this is critical functionality to preserve
145 output.PrintAndExit(output.Err(output.ErrNotFound, "not implemented"))
146 return nil
147 },
148}
149
150var hostStatusV2Cmd = &cobra.Command{
151 Use: "status",
152 Short: "Check host status",
153 RunE: func(cmd *cobra.Command, args []string) error {
154 // TODO: implement
155 output.PrintAndExit(output.Err(output.ErrNotFound, "not implemented"))
156 return nil
157 },
158}
diff --git a/internal/output/output.go b/internal/output/output.go
index 13e34a3..1e1e34e 100644
--- a/internal/output/output.go
+++ b/internal/output/output.go
@@ -107,6 +107,7 @@ func (r ErrorResponse) IsError() bool { return true }
107// Error codes 107// Error codes
108const ( 108const (
109 ErrInvalidPath = "INVALID_PATH" 109 ErrInvalidPath = "INVALID_PATH"
110 ErrInvalidArgs = "INVALID_ARGS"
110 ErrUnknownProjectType = "UNKNOWN_PROJECT_TYPE" 111 ErrUnknownProjectType = "UNKNOWN_PROJECT_TYPE"
111 ErrSSHConnectFailed = "SSH_CONNECT_FAILED" 112 ErrSSHConnectFailed = "SSH_CONNECT_FAILED"
112 ErrSSHAuthFailed = "SSH_AUTH_FAILED" 113 ErrSSHAuthFailed = "SSH_AUTH_FAILED"
@@ -180,7 +181,7 @@ func exitCodeForError(code string) int {
180 return ExitSSHFailed 181 return ExitSSHFailed
181 case ErrHealthCheckFailed, ErrHealthCheckTimeout: 182 case ErrHealthCheckFailed, ErrHealthCheckTimeout:
182 return ExitHealthFailed 183 return ExitHealthFailed
183 case ErrInvalidPath, ErrInvalidTTL, ErrInvalidName, ErrHostNotConfigured: 184 case ErrInvalidPath, ErrInvalidTTL, ErrInvalidName, ErrHostNotConfigured, ErrInvalidArgs:
184 return ExitInvalidArgs 185 return ExitInvalidArgs
185 default: 186 default:
186 return ExitDeployFailed 187 return ExitDeployFailed