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