package main import ( "fmt" "net/http" "strconv" "strings" "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 and set ownership for upload user, _ := client.Run("whoami") user = strings.TrimSpace(user) if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", remotePath)); err != nil { return output.Err(output.ErrUploadFailed, "failed to create directory: "+err.Error()) } if _, err := client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", user, user, remotePath)); err != nil { return output.Err(output.ErrUploadFailed, "failed to set directory ownership: "+err.Error()) } // Upload files using rsync if err := client.UploadDir(ctx.Path, remotePath); err != nil { return output.Err(output.ErrUploadFailed, err.Error()) } // Set ownership back to www-data 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) }