aboutsummaryrefslogtreecommitdiffstats
path: root/cmd/ship/deploy_impl.go
diff options
context:
space:
mode:
Diffstat (limited to 'cmd/ship/deploy_impl.go')
-rw-r--r--cmd/ship/deploy_impl.go394
1 files changed, 394 insertions, 0 deletions
diff --git a/cmd/ship/deploy_impl.go b/cmd/ship/deploy_impl.go
new file mode 100644
index 0000000..bfec9d3
--- /dev/null
+++ b/cmd/ship/deploy_impl.go
@@ -0,0 +1,394 @@
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 // Determine container port
124 containerPort := ctx.Opts.ContainerPort
125 if containerPort == 0 {
126 containerPort = 80
127 }
128
129 // Generate and write env file
130 // Use containerPort so the app listens on the correct port inside the container
131 envContent := fmt.Sprintf("PORT=%d\nSHIP_NAME=%s\nSHIP_URL=%s\n", containerPort, name, ctx.URL)
132 for _, e := range ctx.Opts.Env {
133 envContent += e + "\n"
134 }
135 envPath := fmt.Sprintf("/etc/ship/env/%s.env", name)
136 if _, err := client.RunSudo("mkdir -p /etc/ship/env"); err != nil {
137 // Continue, directory might exist
138 }
139 if err := client.WriteSudoFile(envPath, envContent); err != nil {
140 return output.Err(output.ErrServiceFailed, "failed to write env file: "+err.Error())
141 }
142
143 // Generate systemd unit
144 service, err := templates.DockerService(map[string]string{
145 "Name": name,
146 "Port": strconv.Itoa(port),
147 "ContainerPort": strconv.Itoa(containerPort),
148 })
149 if err != nil {
150 return output.Err(output.ErrServiceFailed, "failed to generate systemd unit: "+err.Error())
151 }
152
153 servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", name)
154 if err := client.WriteSudoFile(servicePath, service); err != nil {
155 return output.Err(output.ErrServiceFailed, "failed to write systemd unit: "+err.Error())
156 }
157
158 // Generate Caddyfile only if it doesn't exist (preserve manual edits)
159 caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name)
160 caddyExists, _ := client.Run(fmt.Sprintf("test -f %s && echo exists", caddyPath))
161 if strings.TrimSpace(caddyExists) != "exists" {
162 caddyfile, err := templates.AppCaddy(map[string]string{
163 "Domain": ctx.URL[8:], // Strip https://
164 "Port": strconv.Itoa(port),
165 })
166 if err != nil {
167 return output.Err(output.ErrCaddyFailed, "failed to generate Caddyfile: "+err.Error())
168 }
169
170 if err := client.WriteSudoFile(caddyPath, caddyfile); err != nil {
171 return output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error())
172 }
173 }
174
175 // Reload systemd and start service
176 if _, err := client.RunSudo("systemctl daemon-reload"); err != nil {
177 return output.Err(output.ErrServiceFailed, "failed to reload systemd: "+err.Error())
178 }
179 if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil {
180 return output.Err(output.ErrServiceFailed, "failed to start service: "+err.Error())
181 }
182
183 // Reload Caddy
184 if _, err := client.RunSudo("systemctl reload caddy"); err != nil {
185 return output.Err(output.ErrCaddyFailed, "failed to reload Caddy: "+err.Error())
186 }
187
188 return nil
189}
190
191// deployBinaryV2 deploys a pre-built binary
192// 1. Allocate port
193// 2. scp binary to /usr/local/bin/<name>
194// 3. Create user for service
195// 4. Generate systemd unit and env file
196// 5. Generate Caddyfile
197// 6. Start service, reload Caddy
198func deployBinaryV2(ctx *deployContext) *output.ErrorResponse {
199 client, err := ssh.Connect(ctx.SSHHost)
200 if err != nil {
201 return output.Err(output.ErrSSHConnectFailed, err.Error())
202 }
203 defer client.Close()
204
205 name := ctx.Name
206
207 // Allocate port on server
208 port, err := allocatePort(client, name)
209 if err != nil {
210 return output.Err(output.ErrServiceFailed, "failed to allocate port: "+err.Error())
211 }
212
213 binaryPath := fmt.Sprintf("/usr/local/bin/%s", name)
214 workDir := fmt.Sprintf("/var/lib/%s", name)
215
216 // Upload binary
217 if err := client.Upload(ctx.Path, "/tmp/"+name); err != nil {
218 return output.Err(output.ErrUploadFailed, err.Error())
219 }
220
221 // Move to final location and set permissions
222 if _, err := client.RunSudo(fmt.Sprintf("mv /tmp/%s %s && chmod +x %s", name, binaryPath, binaryPath)); err != nil {
223 return output.Err(output.ErrUploadFailed, "failed to install binary: "+err.Error())
224 }
225
226 // Create work directory
227 if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", workDir)); err != nil {
228 return output.Err(output.ErrServiceFailed, "failed to create work directory: "+err.Error())
229 }
230
231 // Create service user (ignore error if exists)
232 client.RunSudo(fmt.Sprintf("useradd -r -s /bin/false %s 2>/dev/null || true", name))
233 client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", name, name, workDir))
234
235 // Generate and write env file
236 envContent := fmt.Sprintf("PORT=%d\nSHIP_NAME=%s\nSHIP_URL=%s\n", port, name, ctx.URL)
237 for _, e := range ctx.Opts.Env {
238 envContent += e + "\n"
239 }
240 envPath := fmt.Sprintf("/etc/ship/env/%s.env", name)
241 if _, err := client.RunSudo("mkdir -p /etc/ship/env"); err != nil {
242 // Continue
243 }
244 if err := client.WriteSudoFile(envPath, envContent); err != nil {
245 return output.Err(output.ErrServiceFailed, "failed to write env file: "+err.Error())
246 }
247
248 // Generate systemd unit
249 service, err := templates.SystemdService(map[string]string{
250 "Name": name,
251 "User": name,
252 "WorkDir": workDir,
253 "EnvFile": envPath,
254 "BinaryPath": binaryPath,
255 "Args": "",
256 })
257 if err != nil {
258 return output.Err(output.ErrServiceFailed, "failed to generate systemd unit: "+err.Error())
259 }
260
261 servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", name)
262 if err := client.WriteSudoFile(servicePath, service); err != nil {
263 return output.Err(output.ErrServiceFailed, "failed to write systemd unit: "+err.Error())
264 }
265
266 // Generate Caddyfile only if it doesn't exist (preserve manual edits)
267 caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name)
268 caddyExists, _ := client.Run(fmt.Sprintf("test -f %s && echo exists", caddyPath))
269 if strings.TrimSpace(caddyExists) != "exists" {
270 caddyfile, err := templates.AppCaddy(map[string]string{
271 "Domain": ctx.URL[8:], // Strip https://
272 "Port": strconv.Itoa(port),
273 })
274 if err != nil {
275 return output.Err(output.ErrCaddyFailed, "failed to generate Caddyfile: "+err.Error())
276 }
277
278 if err := client.WriteSudoFile(caddyPath, caddyfile); err != nil {
279 return output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error())
280 }
281 }
282
283 // Reload systemd and start service
284 if _, err := client.RunSudo("systemctl daemon-reload"); err != nil {
285 return output.Err(output.ErrServiceFailed, "failed to reload systemd: "+err.Error())
286 }
287 if _, err := client.RunSudo(fmt.Sprintf("systemctl enable --now %s", name)); err != nil {
288 return output.Err(output.ErrServiceFailed, "failed to start service: "+err.Error())
289 }
290
291 // Reload Caddy
292 if _, err := client.RunSudo("systemctl reload caddy"); err != nil {
293 return output.Err(output.ErrCaddyFailed, "failed to reload Caddy: "+err.Error())
294 }
295
296 return nil
297}
298
299// allocatePort allocates or retrieves a port for a service
300// Uses atomic increment on /etc/ship/next_port to avoid collisions
301func allocatePort(client *ssh.Client, name string) (int, error) {
302 portFile := fmt.Sprintf("/etc/ship/ports/%s", name)
303
304 // Try to read existing port for this app
305 out, err := client.Run(fmt.Sprintf("cat %s 2>/dev/null || echo ''", portFile))
306 if err == nil && out != "" {
307 out = strings.TrimSpace(out)
308 if port, err := strconv.Atoi(out); err == nil && port > 0 {
309 return port, nil
310 }
311 }
312
313 // Allocate new port atomically using flock
314 // Scans existing port files to avoid collisions even if next_port is stale
315 allocScript := `flock -x /etc/ship/.port.lock sh -c 'mkdir -p /etc/ship/ports; NEXT=$(cat /etc/ship/next_port 2>/dev/null || echo 9000); MAX=8999; for f in /etc/ship/ports/*; do [ -f "$f" ] && P=$(cat "$f" 2>/dev/null) && [ "$P" -gt "$MAX" ] 2>/dev/null && MAX=$P; done; PORT=$(( NEXT > MAX ? NEXT : MAX + 1 )); echo $((PORT + 1)) > /etc/ship/next_port; echo $PORT'`
316 out, err = client.RunSudo(allocScript)
317 if err != nil {
318 return 0, fmt.Errorf("failed to allocate port: %w", err)
319 }
320
321 port, err := strconv.Atoi(strings.TrimSpace(out))
322 if err != nil {
323 return 0, fmt.Errorf("invalid port allocated: %s", out)
324 }
325
326 // Write port allocation for this app
327 if err := client.WriteSudoFile(portFile, strconv.Itoa(port)); err != nil {
328 return 0, err
329 }
330
331 return port, nil
332}
333
334// setTTLV2 sets auto-expiry for a deploy
335func setTTLV2(ctx *deployContext, ttl time.Duration) error {
336 client, err := ssh.Connect(ctx.SSHHost)
337 if err != nil {
338 return err
339 }
340 defer client.Close()
341
342 expires := time.Now().Add(ttl).Unix()
343 ttlPath := fmt.Sprintf("/etc/ship/ttl/%s", ctx.Name)
344
345 if _, err := client.RunSudo("mkdir -p /etc/ship/ttl"); err != nil {
346 return err
347 }
348
349 return client.WriteSudoFile(ttlPath, strconv.FormatInt(expires, 10))
350}
351
352// runHealthCheck verifies the deploy is responding
353func runHealthCheck(url, endpoint string) (*output.HealthResult, *output.ErrorResponse) {
354 fullURL := url + endpoint
355
356 // Wait for app to start
357 time.Sleep(2 * time.Second)
358
359 var lastErr error
360 var lastStatus int
361
362 for i := 0; i < 15; i++ {
363 start := time.Now()
364 resp, err := http.Get(fullURL)
365 latency := time.Since(start).Milliseconds()
366
367 if err != nil {
368 lastErr = err
369 time.Sleep(2 * time.Second)
370 continue
371 }
372 resp.Body.Close()
373 lastStatus = resp.StatusCode
374
375 if resp.StatusCode >= 200 && resp.StatusCode < 400 {
376 return &output.HealthResult{
377 Endpoint: endpoint,
378 Status: resp.StatusCode,
379 LatencyMs: latency,
380 }, nil
381 }
382
383 time.Sleep(2 * time.Second)
384 }
385
386 msg := fmt.Sprintf("health check failed after 30s: ")
387 if lastErr != nil {
388 msg += lastErr.Error()
389 } else {
390 msg += fmt.Sprintf("status %d", lastStatus)
391 }
392
393 return nil, output.Err(output.ErrHealthCheckFailed, msg)
394}