From 146393527c20df3717711eced83fb9f58f96c884 Mon Sep 17 00:00:00 2001 From: Clawd Date: Sun, 15 Feb 2026 18:50:54 -0800 Subject: feat(v2): implement deploy flows - deploy_impl_v2.go: full implementations for static, docker, binary deploys - Static: rsync + Caddyfile generation + reload - Docker: rsync + build + systemd unit + env file + Caddyfile - Binary: scp + systemd unit + service user + env file + Caddyfile - Port allocation: server-side in /etc/ship/ports/ - TTL: server-side in /etc/ship/ttl/ - Health checks: HTTP GET with 30s retry loop All deploy types now functional (pending Go compilation test) --- cmd/ship/deploy_impl_v2.go | 365 +++++++++++++++++++++++++++++++++++++++++++++ cmd/ship/deploy_v2.go | 46 +----- 2 files changed, 366 insertions(+), 45 deletions(-) create mode 100644 cmd/ship/deploy_impl_v2.go (limited to 'cmd/ship') 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 @@ +package main + +import ( + "fmt" + "net/http" + "strconv" + "time" + + "github.com/bdw/ship/internal/output" + "github.com/bdw/ship/internal/ssh" + "github.com/bdw/ship/internal/templates" +) + +// deployStaticV2 deploys a static site +// 1. rsync path to /var/www// +// 2. Generate and upload Caddyfile +// 3. Reload Caddy +func deployStaticV2(ctx *deployContext) *output.ErrorResponse { + client, err := ssh.Connect(ctx.SSHHost) + if err != nil { + return output.Err(output.ErrSSHConnectFailed, err.Error()) + } + defer client.Close() + + name := ctx.Name + remotePath := fmt.Sprintf("/var/www/%s", name) + + // Create directory + if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", remotePath)); err != nil { + return output.Err(output.ErrUploadFailed, "failed to create directory: "+err.Error()) + } + + // Upload files using rsync + if err := client.UploadDir(ctx.Path, remotePath); err != nil { + return output.Err(output.ErrUploadFailed, err.Error()) + } + + // Set ownership + if _, err := client.RunSudo(fmt.Sprintf("chown -R www-data:www-data %s", remotePath)); err != nil { + // Non-fatal, continue + } + + // Generate Caddyfile + caddyfile, err := templates.StaticCaddy(map[string]string{ + "Domain": ctx.URL[8:], // Strip https:// + "RootDir": remotePath, + "Name": name, + }) + if err != nil { + return output.Err(output.ErrCaddyFailed, "failed to generate Caddyfile: "+err.Error()) + } + + // Upload Caddyfile + caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) + if err := client.WriteSudoFile(caddyPath, caddyfile); err != nil { + return output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error()) + } + + // Reload Caddy + if _, err := client.RunSudo("systemctl reload caddy"); err != nil { + return output.Err(output.ErrCaddyFailed, "failed to reload Caddy: "+err.Error()) + } + + return nil +} + +// deployDockerV2 deploys a Docker-based app +// 1. Allocate port +// 2. rsync path to /var/lib//src/ +// 3. docker build +// 4. Generate systemd unit and env file +// 5. Generate Caddyfile +// 6. Start service, reload Caddy +func deployDockerV2(ctx *deployContext) *output.ErrorResponse { + client, err := ssh.Connect(ctx.SSHHost) + if err != nil { + return output.Err(output.ErrSSHConnectFailed, err.Error()) + } + defer client.Close() + + name := ctx.Name + + // Allocate port on server + port, err := allocatePort(client, name) + if err != nil { + return output.Err(output.ErrServiceFailed, "failed to allocate port: "+err.Error()) + } + + srcPath := fmt.Sprintf("/var/lib/%s/src", name) + dataPath := fmt.Sprintf("/var/lib/%s/data", name) + + // Create directories + if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s %s", srcPath, dataPath)); err != nil { + return output.Err(output.ErrUploadFailed, "failed to create directories: "+err.Error()) + } + + // Upload source + if err := client.UploadDir(ctx.Path, srcPath); err != nil { + return output.Err(output.ErrUploadFailed, err.Error()) + } + + // Docker build + buildCmd := fmt.Sprintf("docker build -t %s:latest %s", name, srcPath) + if _, err := client.RunSudo(buildCmd); err != nil { + return output.Err(output.ErrBuildFailed, err.Error()) + } + + // Generate and write env file + envContent := fmt.Sprintf("PORT=%d\nSHIP_NAME=%s\nSHIP_URL=%s\n", port, name, ctx.URL) + for _, e := range ctx.Opts.Env { + envContent += e + "\n" + } + envPath := fmt.Sprintf("/etc/ship/env/%s.env", name) + if _, err := client.RunSudo("mkdir -p /etc/ship/env"); err != nil { + // Continue, directory might exist + } + if err := client.WriteSudoFile(envPath, envContent); err != nil { + return output.Err(output.ErrServiceFailed, "failed to write env file: "+err.Error()) + } + + // Generate systemd unit + service, err := templates.DockerService(map[string]string{ + "Name": name, + "Port": strconv.Itoa(port), + }) + if err != nil { + return output.Err(output.ErrServiceFailed, "failed to generate systemd unit: "+err.Error()) + } + + servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", name) + if err := client.WriteSudoFile(servicePath, service); err != nil { + return output.Err(output.ErrServiceFailed, "failed to write systemd unit: "+err.Error()) + } + + // Generate Caddyfile + caddyfile, err := templates.AppCaddy(map[string]string{ + "Domain": ctx.URL[8:], // Strip https:// + "Port": strconv.Itoa(port), + }) + if err != nil { + return output.Err(output.ErrCaddyFailed, "failed to generate Caddyfile: "+err.Error()) + } + + caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) + if err := client.WriteSudoFile(caddyPath, caddyfile); err != nil { + return output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error()) + } + + // Reload systemd and start service + if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { + return output.Err(output.ErrServiceFailed, "failed to reload systemd: "+err.Error()) + } + if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { + return output.Err(output.ErrServiceFailed, "failed to start service: "+err.Error()) + } + + // Reload Caddy + if _, err := client.RunSudo("systemctl reload caddy"); err != nil { + return output.Err(output.ErrCaddyFailed, "failed to reload Caddy: "+err.Error()) + } + + return nil +} + +// deployBinaryV2 deploys a pre-built binary +// 1. Allocate port +// 2. scp binary to /usr/local/bin/ +// 3. Create user for service +// 4. Generate systemd unit and env file +// 5. Generate Caddyfile +// 6. Start service, reload Caddy +func deployBinaryV2(ctx *deployContext) *output.ErrorResponse { + client, err := ssh.Connect(ctx.SSHHost) + if err != nil { + return output.Err(output.ErrSSHConnectFailed, err.Error()) + } + defer client.Close() + + name := ctx.Name + + // Allocate port on server + port, err := allocatePort(client, name) + if err != nil { + return output.Err(output.ErrServiceFailed, "failed to allocate port: "+err.Error()) + } + + binaryPath := fmt.Sprintf("/usr/local/bin/%s", name) + workDir := fmt.Sprintf("/var/lib/%s", name) + + // Upload binary + if err := client.Upload(ctx.Path, "/tmp/"+name); err != nil { + return output.Err(output.ErrUploadFailed, err.Error()) + } + + // Move to final location and set permissions + if _, err := client.RunSudo(fmt.Sprintf("mv /tmp/%s %s && chmod +x %s", name, binaryPath, binaryPath)); err != nil { + return output.Err(output.ErrUploadFailed, "failed to install binary: "+err.Error()) + } + + // Create work directory + if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", workDir)); err != nil { + return output.Err(output.ErrServiceFailed, "failed to create work directory: "+err.Error()) + } + + // Create service user (ignore error if exists) + client.RunSudo(fmt.Sprintf("useradd -r -s /bin/false %s 2>/dev/null || true", name)) + client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", name, name, workDir)) + + // Generate and write env file + envContent := fmt.Sprintf("PORT=%d\nSHIP_NAME=%s\nSHIP_URL=%s\n", port, name, ctx.URL) + for _, e := range ctx.Opts.Env { + envContent += e + "\n" + } + envPath := fmt.Sprintf("/etc/ship/env/%s.env", name) + if _, err := client.RunSudo("mkdir -p /etc/ship/env"); err != nil { + // Continue + } + if err := client.WriteSudoFile(envPath, envContent); err != nil { + return output.Err(output.ErrServiceFailed, "failed to write env file: "+err.Error()) + } + + // Generate systemd unit + service, err := templates.SystemdService(map[string]string{ + "Name": name, + "User": name, + "WorkDir": workDir, + "EnvFile": envPath, + "BinaryPath": binaryPath, + "Args": "", + }) + if err != nil { + return output.Err(output.ErrServiceFailed, "failed to generate systemd unit: "+err.Error()) + } + + servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", name) + if err := client.WriteSudoFile(servicePath, service); err != nil { + return output.Err(output.ErrServiceFailed, "failed to write systemd unit: "+err.Error()) + } + + // Generate Caddyfile + caddyfile, err := templates.AppCaddy(map[string]string{ + "Domain": ctx.URL[8:], // Strip https:// + "Port": strconv.Itoa(port), + }) + if err != nil { + return output.Err(output.ErrCaddyFailed, "failed to generate Caddyfile: "+err.Error()) + } + + caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) + if err := client.WriteSudoFile(caddyPath, caddyfile); err != nil { + return output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error()) + } + + // Reload systemd and start service + if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { + return output.Err(output.ErrServiceFailed, "failed to reload systemd: "+err.Error()) + } + if _, err := client.RunSudo(fmt.Sprintf("systemctl enable --now %s", name)); err != nil { + return output.Err(output.ErrServiceFailed, "failed to start service: "+err.Error()) + } + + // Reload Caddy + if _, err := client.RunSudo("systemctl reload caddy"); err != nil { + return output.Err(output.ErrCaddyFailed, "failed to reload Caddy: "+err.Error()) + } + + return nil +} + +// allocatePort allocates or retrieves a port for a service +func allocatePort(client *ssh.Client, name string) (int, error) { + portFile := fmt.Sprintf("/etc/ship/ports/%s", name) + + // Try to read existing port + out, err := client.Run(fmt.Sprintf("cat %s 2>/dev/null || echo ''", portFile)) + if err == nil && out != "" { + port, err := strconv.Atoi(out[:len(out)-1]) // Strip newline + if err == nil && port > 0 { + return port, nil + } + } + + // Allocate new port + // Find highest used port and increment + 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") + if err != nil { + out = "" + } + + nextPort := 9000 + if out != "" { + if lastPort, err := strconv.Atoi(out[:len(out)-1]); err == nil { + nextPort = lastPort + 1 + } + } + + // Write port allocation + if err := client.WriteSudoFile(portFile, strconv.Itoa(nextPort)); err != nil { + return 0, err + } + + return nextPort, nil +} + +// setTTLV2 sets auto-expiry for a deploy +func setTTLV2(ctx *deployContext, ttl time.Duration) error { + client, err := ssh.Connect(ctx.SSHHost) + if err != nil { + return err + } + defer client.Close() + + expires := time.Now().Add(ttl).Unix() + ttlPath := fmt.Sprintf("/etc/ship/ttl/%s", ctx.Name) + + if _, err := client.RunSudo("mkdir -p /etc/ship/ttl"); err != nil { + return err + } + + return client.WriteSudoFile(ttlPath, strconv.FormatInt(expires, 10)) +} + +// runHealthCheck verifies the deploy is responding +func runHealthCheck(url, endpoint string) (*output.HealthResult, *output.ErrorResponse) { + fullURL := url + endpoint + + // Wait for app to start + time.Sleep(2 * time.Second) + + var lastErr error + var lastStatus int + + for i := 0; i < 15; i++ { + start := time.Now() + resp, err := http.Get(fullURL) + latency := time.Since(start).Milliseconds() + + if err != nil { + lastErr = err + time.Sleep(2 * time.Second) + continue + } + resp.Body.Close() + lastStatus = resp.StatusCode + + if resp.StatusCode >= 200 && resp.StatusCode < 400 { + return &output.HealthResult{ + Endpoint: endpoint, + Status: resp.StatusCode, + LatencyMs: latency, + }, nil + } + + time.Sleep(2 * time.Second) + } + + msg := fmt.Sprintf("health check failed after 30s: ") + if lastErr != nil { + msg += lastErr.Error() + } else { + msg += fmt.Sprintf("status %d", lastStatus) + } + + return nil, output.Err(output.ErrHealthCheckFailed, msg) +} diff --git a/cmd/ship/deploy_v2.go b/cmd/ship/deploy_v2.go index 7642b38..16e12db 100644 --- a/cmd/ship/deploy_v2.go +++ b/cmd/ship/deploy_v2.go @@ -200,48 +200,4 @@ func parseTTL(s string) (time.Duration, error) { return d, nil } -// Placeholder implementations - to be filled in - -func deployStaticV2(ctx *deployContext) *output.ErrorResponse { - // TODO: implement - // 1. rsync ctx.Path to /var/www// - // 2. Generate and upload Caddyfile - // 3. Reload Caddy - return nil -} - -func deployDockerV2(ctx *deployContext) *output.ErrorResponse { - // TODO: implement - // 1. rsync ctx.Path to /var/lib//src/ - // 2. docker build - // 3. Generate systemd unit and Caddyfile - // 4. Start service, reload Caddy - return nil -} - -func deployBinaryV2(ctx *deployContext) *output.ErrorResponse { - // TODO: implement - // 1. scp binary to /usr/local/bin/ - // 2. Generate systemd unit and Caddyfile - // 3. Start service, reload Caddy - return nil -} - -func setTTLV2(ctx *deployContext, ttl time.Duration) error { - // TODO: implement - // Write Unix timestamp to /etc/ship/ttl/ - return nil -} - -func runHealthCheck(url, endpoint string) (*output.HealthResult, *output.ErrorResponse) { - // TODO: implement - // 1. Wait 2s - // 2. GET url+endpoint - // 3. Retry up to 15 times (30s total) - // 4. Return result or error - return &output.HealthResult{ - Endpoint: endpoint, - Status: 200, - LatencyMs: 50, - }, nil -} +// Deploy implementations are in deploy_impl_v2.go -- cgit v1.2.3