aboutsummaryrefslogtreecommitdiffstats
path: root/cmd/ship/deploy_impl_v2.go
diff options
context:
space:
mode:
authorClawd <ai@clawd.bot>2026-02-17 08:11:19 -0800
committerClawd <ai@clawd.bot>2026-02-17 08:11:19 -0800
commit6f02ec84a8299fc5577f147cc8741c8a4b162b64 (patch)
tree020f3690e92732dcba723be0cfaef649f46de137 /cmd/ship/deploy_impl_v2.go
parent4b5a2656df13181b637c59c29ff31751e11cf22a (diff)
parent05ea98df57599775c1d5bfea336012b075531670 (diff)
Merge agent-mode: v2 rewrite complete
- Removed all v1 code (-2800 lines) - Simplified state to just default_host + base_domain - Atomic port allocation via flock - --container-port flag for Docker - Custom domains shown in ship list - Caddyfiles preserved on redeploy - JSON output by default, --pretty for humans
Diffstat (limited to 'cmd/ship/deploy_impl_v2.go')
-rw-r--r--cmd/ship/deploy_impl_v2.go397
1 files changed, 397 insertions, 0 deletions
diff --git a/cmd/ship/deploy_impl_v2.go b/cmd/ship/deploy_impl_v2.go
new file mode 100644
index 0000000..5b68dc3
--- /dev/null
+++ b/cmd/ship/deploy_impl_v2.go
@@ -0,0 +1,397 @@
1package main
2
3import (
4 "fmt"
5 "net/http"
6 "strconv"
7 "strings"
8 "time"
9
10 "github.com/bdw/ship/internal/output"
11 "github.com/bdw/ship/internal/ssh"
12 "github.com/bdw/ship/internal/templates"
13)
14
15// deployStaticV2 deploys a static site
16// 1. rsync path to /var/www/<name>/
17// 2. Generate and upload Caddyfile
18// 3. Reload Caddy
19func deployStaticV2(ctx *deployContext) *output.ErrorResponse {
20 client, err := ssh.Connect(ctx.SSHHost)
21 if err != nil {
22 return output.Err(output.ErrSSHConnectFailed, err.Error())
23 }
24 defer client.Close()
25
26 name := ctx.Name
27 remotePath := fmt.Sprintf("/var/www/%s", name)
28
29 // Create directory and set ownership for upload
30 user, _ := client.Run("whoami")
31 user = strings.TrimSpace(user)
32 if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", remotePath)); err != nil {
33 return output.Err(output.ErrUploadFailed, "failed to create directory: "+err.Error())
34 }
35 if _, err := client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", user, user, remotePath)); err != nil {
36 return output.Err(output.ErrUploadFailed, "failed to set directory ownership: "+err.Error())
37 }
38
39 // Upload files using rsync
40 if err := client.UploadDir(ctx.Path, remotePath); err != nil {
41 return output.Err(output.ErrUploadFailed, err.Error())
42 }
43
44 // Set ownership back to www-data
45 if _, err := client.RunSudo(fmt.Sprintf("chown -R www-data:www-data %s", remotePath)); err != nil {
46 // Non-fatal, continue
47 }
48
49 // Generate Caddyfile only if it doesn't exist (preserve manual edits)
50 caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name)
51 caddyExists, _ := client.Run(fmt.Sprintf("test -f %s && echo exists", caddyPath))
52 if strings.TrimSpace(caddyExists) != "exists" {
53 caddyfile, err := templates.StaticCaddy(map[string]string{
54 "Domain": ctx.URL[8:], // Strip https://
55 "RootDir": remotePath,
56 "Name": name,
57 })
58 if err != nil {
59 return output.Err(output.ErrCaddyFailed, "failed to generate Caddyfile: "+err.Error())
60 }
61
62 if err := client.WriteSudoFile(caddyPath, caddyfile); err != nil {
63 return output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error())
64 }
65 }
66
67 // Reload Caddy
68 if _, err := client.RunSudo("systemctl reload caddy"); err != nil {
69 return output.Err(output.ErrCaddyFailed, "failed to reload Caddy: "+err.Error())
70 }
71
72 return nil
73}
74
75// deployDockerV2 deploys a Docker-based app
76// 1. Allocate port
77// 2. rsync path to /var/lib/<name>/src/
78// 3. docker build
79// 4. Generate systemd unit and env file
80// 5. Generate Caddyfile
81// 6. Start service, reload Caddy
82func deployDockerV2(ctx *deployContext) *output.ErrorResponse {
83 client, err := ssh.Connect(ctx.SSHHost)
84 if err != nil {
85 return output.Err(output.ErrSSHConnectFailed, err.Error())
86 }
87 defer client.Close()
88
89 name := ctx.Name
90
91 // Allocate port on server
92 port, err := allocatePort(client, name)
93 if err != nil {
94 return output.Err(output.ErrServiceFailed, "failed to allocate port: "+err.Error())
95 }
96
97 srcPath := fmt.Sprintf("/var/lib/%s/src", name)
98 dataPath := fmt.Sprintf("/var/lib/%s/data", name)
99
100 // Create directories
101 if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s %s", srcPath, dataPath)); err != nil {
102 return output.Err(output.ErrUploadFailed, "failed to create directories: "+err.Error())
103 }
104
105 // Set ownership for upload
106 user, _ := client.Run("whoami")
107 user = strings.TrimSpace(user)
108 if _, err := client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", user, user, srcPath)); err != nil {
109 return output.Err(output.ErrUploadFailed, "failed to set directory ownership: "+err.Error())
110 }
111
112 // Upload source
113 if err := client.UploadDir(ctx.Path, srcPath); err != nil {
114 return output.Err(output.ErrUploadFailed, err.Error())
115 }
116
117 // Docker build
118 buildCmd := fmt.Sprintf("docker build -t %s:latest %s", name, srcPath)
119 if _, err := client.RunSudo(buildCmd); err != nil {
120 return output.Err(output.ErrBuildFailed, err.Error())
121 }
122
123 // Generate and write env file
124 envContent := fmt.Sprintf("PORT=%d\nSHIP_NAME=%s\nSHIP_URL=%s\n", port, name, ctx.URL)
125 for _, e := range ctx.Opts.Env {
126 envContent += e + "\n"
127 }
128 envPath := fmt.Sprintf("/etc/ship/env/%s.env", name)
129 if _, err := client.RunSudo("mkdir -p /etc/ship/env"); err != nil {
130 // Continue, directory might exist
131 }
132 if err := client.WriteSudoFile(envPath, envContent); err != nil {
133 return output.Err(output.ErrServiceFailed, "failed to write env file: "+err.Error())
134 }
135
136 // Generate systemd unit
137 containerPort := ctx.Opts.ContainerPort
138 if containerPort == 0 {
139 containerPort = 80
140 }
141 service, err := templates.DockerService(map[string]string{
142 "Name": name,
143 "Port": strconv.Itoa(port),
144 "ContainerPort": strconv.Itoa(containerPort),
145 })
146 if err != nil {
147 return output.Err(output.ErrServiceFailed, "failed to generate systemd unit: "+err.Error())
148 }
149
150 servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", name)
151 if err := client.WriteSudoFile(servicePath, service); err != nil {
152 return output.Err(output.ErrServiceFailed, "failed to write systemd unit: "+err.Error())
153 }
154
155 // Generate Caddyfile only if it doesn't exist (preserve manual edits)
156 caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name)
157 caddyExists, _ := client.Run(fmt.Sprintf("test -f %s && echo exists", caddyPath))
158 if strings.TrimSpace(caddyExists) != "exists" {
159 caddyfile, err := templates.AppCaddy(map[string]string{
160 "Domain": ctx.URL[8:], // Strip https://
161 "Port": strconv.Itoa(port),
162 })
163 if err != nil {
164 return output.Err(output.ErrCaddyFailed, "failed to generate Caddyfile: "+err.Error())
165 }
166
167 if err := client.WriteSudoFile(caddyPath, caddyfile); err != nil {
168 return output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error())
169 }
170 }
171
172 // Reload systemd and start service
173 if _, err := client.RunSudo("systemctl daemon-reload"); err != nil {
174 return output.Err(output.ErrServiceFailed, "failed to reload systemd: "+err.Error())
175 }
176 if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil {
177 return output.Err(output.ErrServiceFailed, "failed to start service: "+err.Error())
178 }
179
180 // Reload Caddy
181 if _, err := client.RunSudo("systemctl reload caddy"); err != nil {
182 return output.Err(output.ErrCaddyFailed, "failed to reload Caddy: "+err.Error())
183 }
184
185 return nil
186}
187
188// deployBinaryV2 deploys a pre-built binary
189// 1. Allocate port
190// 2. scp binary to /usr/local/bin/<name>
191// 3. Create user for service
192// 4. Generate systemd unit and env file
193// 5. Generate Caddyfile
194// 6. Start service, reload Caddy
195func deployBinaryV2(ctx *deployContext) *output.ErrorResponse {
196 client, err := ssh.Connect(ctx.SSHHost)
197 if err != nil {
198 return output.Err(output.ErrSSHConnectFailed, err.Error())
199 }
200 defer client.Close()
201
202 name := ctx.Name
203
204 // Allocate port on server
205 port, err := allocatePort(client, name)
206 if err != nil {
207 return output.Err(output.ErrServiceFailed, "failed to allocate port: "+err.Error())
208 }
209
210 binaryPath := fmt.Sprintf("/usr/local/bin/%s", name)
211 workDir := fmt.Sprintf("/var/lib/%s", name)
212
213 // Upload binary
214 if err := client.Upload(ctx.Path, "/tmp/"+name); err != nil {
215 return output.Err(output.ErrUploadFailed, err.Error())
216 }
217
218 // Move to final location and set permissions
219 if _, err := client.RunSudo(fmt.Sprintf("mv /tmp/%s %s && chmod +x %s", name, binaryPath, binaryPath)); err != nil {
220 return output.Err(output.ErrUploadFailed, "failed to install binary: "+err.Error())
221 }
222
223 // Create work directory
224 if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", workDir)); err != nil {
225 return output.Err(output.ErrServiceFailed, "failed to create work directory: "+err.Error())
226 }
227
228 // Create service user (ignore error if exists)
229 client.RunSudo(fmt.Sprintf("useradd -r -s /bin/false %s 2>/dev/null || true", name))
230 client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", name, name, workDir))
231
232 // Generate and write env file
233 envContent := fmt.Sprintf("PORT=%d\nSHIP_NAME=%s\nSHIP_URL=%s\n", port, name, ctx.URL)
234 for _, e := range ctx.Opts.Env {
235 envContent += e + "\n"
236 }
237 envPath := fmt.Sprintf("/etc/ship/env/%s.env", name)
238 if _, err := client.RunSudo("mkdir -p /etc/ship/env"); err != nil {
239 // Continue
240 }
241 if err := client.WriteSudoFile(envPath, envContent); err != nil {
242 return output.Err(output.ErrServiceFailed, "failed to write env file: "+err.Error())
243 }
244
245 // Generate systemd unit
246 service, err := templates.SystemdService(map[string]string{
247 "Name": name,
248 "User": name,
249 "WorkDir": workDir,
250 "EnvFile": envPath,
251 "BinaryPath": binaryPath,
252 "Args": "",
253 })
254 if err != nil {
255 return output.Err(output.ErrServiceFailed, "failed to generate systemd unit: "+err.Error())
256 }
257
258 servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", name)
259 if err := client.WriteSudoFile(servicePath, service); err != nil {
260 return output.Err(output.ErrServiceFailed, "failed to write systemd unit: "+err.Error())
261 }
262
263 // Generate Caddyfile only if it doesn't exist (preserve manual edits)
264 caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name)
265 caddyExists, _ := client.Run(fmt.Sprintf("test -f %s && echo exists", caddyPath))
266 if strings.TrimSpace(caddyExists) != "exists" {
267 caddyfile, err := templates.AppCaddy(map[string]string{
268 "Domain": ctx.URL[8:], // Strip https://
269 "Port": strconv.Itoa(port),
270 })
271 if err != nil {
272 return output.Err(output.ErrCaddyFailed, "failed to generate Caddyfile: "+err.Error())
273 }
274
275 if err := client.WriteSudoFile(caddyPath, caddyfile); err != nil {
276 return output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error())
277 }
278 }
279
280 // Reload systemd and start service
281 if _, err := client.RunSudo("systemctl daemon-reload"); err != nil {
282 return output.Err(output.ErrServiceFailed, "failed to reload systemd: "+err.Error())
283 }
284 if _, err := client.RunSudo(fmt.Sprintf("systemctl enable --now %s", name)); err != nil {
285 return output.Err(output.ErrServiceFailed, "failed to start service: "+err.Error())
286 }
287
288 // Reload Caddy
289 if _, err := client.RunSudo("systemctl reload caddy"); err != nil {
290 return output.Err(output.ErrCaddyFailed, "failed to reload Caddy: "+err.Error())
291 }
292
293 return nil
294}
295
296// allocatePort allocates or retrieves a port for a service
297// Uses atomic increment on /etc/ship/next_port to avoid collisions
298func allocatePort(client *ssh.Client, name string) (int, error) {
299 portFile := fmt.Sprintf("/etc/ship/ports/%s", name)
300
301 // Try to read existing port for this app
302 out, err := client.Run(fmt.Sprintf("cat %s 2>/dev/null || echo ''", portFile))
303 if err == nil && out != "" {
304 out = strings.TrimSpace(out)
305 if port, err := strconv.Atoi(out); err == nil && port > 0 {
306 return port, nil
307 }
308 }
309
310 // Allocate new port atomically using flock
311 // This reads next_port, increments it, and writes back while holding a lock
312 allocScript := `
313flock -x /etc/ship/.port.lock sh -c '
314 mkdir -p /etc/ship/ports
315 PORT=$(cat /etc/ship/next_port 2>/dev/null || echo 9000)
316 echo $((PORT + 1)) > /etc/ship/next_port
317 echo $PORT
318'`
319 out, err = client.RunSudo(allocScript)
320 if err != nil {
321 return 0, fmt.Errorf("failed to allocate port: %w", err)
322 }
323
324 port, err := strconv.Atoi(strings.TrimSpace(out))
325 if err != nil {
326 return 0, fmt.Errorf("invalid port allocated: %s", out)
327 }
328
329 // Write port allocation for this app
330 if err := client.WriteSudoFile(portFile, strconv.Itoa(port)); err != nil {
331 return 0, err
332 }
333
334 return port, nil
335}
336
337// setTTLV2 sets auto-expiry for a deploy
338func setTTLV2(ctx *deployContext, ttl time.Duration) error {
339 client, err := ssh.Connect(ctx.SSHHost)
340 if err != nil {
341 return err
342 }
343 defer client.Close()
344
345 expires := time.Now().Add(ttl).Unix()
346 ttlPath := fmt.Sprintf("/etc/ship/ttl/%s", ctx.Name)
347
348 if _, err := client.RunSudo("mkdir -p /etc/ship/ttl"); err != nil {
349 return err
350 }
351
352 return client.WriteSudoFile(ttlPath, strconv.FormatInt(expires, 10))
353}
354
355// runHealthCheck verifies the deploy is responding
356func runHealthCheck(url, endpoint string) (*output.HealthResult, *output.ErrorResponse) {
357 fullURL := url + endpoint
358
359 // Wait for app to start
360 time.Sleep(2 * time.Second)
361
362 var lastErr error
363 var lastStatus int
364
365 for i := 0; i < 15; i++ {
366 start := time.Now()
367 resp, err := http.Get(fullURL)
368 latency := time.Since(start).Milliseconds()
369
370 if err != nil {
371 lastErr = err
372 time.Sleep(2 * time.Second)
373 continue
374 }
375 resp.Body.Close()
376 lastStatus = resp.StatusCode
377
378 if resp.StatusCode >= 200 && resp.StatusCode < 400 {
379 return &output.HealthResult{
380 Endpoint: endpoint,
381 Status: resp.StatusCode,
382 LatencyMs: latency,
383 }, nil
384 }
385
386 time.Sleep(2 * time.Second)
387 }
388
389 msg := fmt.Sprintf("health check failed after 30s: ")
390 if lastErr != nil {
391 msg += lastErr.Error()
392 } else {
393 msg += fmt.Sprintf("status %d", lastStatus)
394 }
395
396 return nil, output.Err(output.ErrHealthCheckFailed, msg)
397}