From 8094639aa2d5095af512d4e943fcb4af801aef07 Mon Sep 17 00:00:00 2001 From: Clawd Date: Sun, 15 Feb 2026 18:49:30 -0800 Subject: feat(v2): add CLI structure and deploy orchestration - cmd/ship/root_v2.go: new CLI with ship [PATH] as primary command - cmd/ship/deploy_v2.go: deploy orchestration with context struct - Placeholder implementations for static/docker/binary deploys - Placeholder subcommands (list, status, logs, remove, host) - Support for --name, --health, --ttl, --env flags - SHIP_PRETTY env var support Next: implement actual deploy flows --- cmd/ship/deploy_v2.go | 247 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 cmd/ship/deploy_v2.go (limited to 'cmd/ship/deploy_v2.go') diff --git a/cmd/ship/deploy_v2.go b/cmd/ship/deploy_v2.go new file mode 100644 index 0000000..7642b38 --- /dev/null +++ b/cmd/ship/deploy_v2.go @@ -0,0 +1,247 @@ +package main + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "regexp" + "strings" + "time" + + "github.com/bdw/ship/internal/detect" + "github.com/bdw/ship/internal/output" + "github.com/bdw/ship/internal/state" +) + +// deployV2 implements the new agent-first deploy interface. +// Usage: ship [PATH] [FLAGS] +// PATH defaults to "." if not provided. +func deployV2(path string, opts deployV2Options) { + start := time.Now() + + // Validate name if provided + if opts.Name != "" { + if err := validateName(opts.Name); err != nil { + output.PrintAndExit(err) + } + } + + // Parse TTL if provided + var ttlDuration time.Duration + if opts.TTL != "" { + var err error + ttlDuration, err = parseTTL(opts.TTL) + if err != nil { + output.PrintAndExit(output.Err(output.ErrInvalidTTL, err.Error())) + } + } + + // Get host configuration + st, err := state.Load() + if err != nil { + output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "failed to load state: "+err.Error())) + } + + hostName := opts.Host + if hostName == "" { + hostName = st.DefaultHost + } + if hostName == "" { + output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified and no default host configured. Run: ship host init")) + } + + hostConfig := st.GetHost(hostName) + if hostConfig.BaseDomain == "" { + output.PrintAndExit(output.Err(output.ErrHostNotConfigured, fmt.Sprintf("host %q has no base domain configured. Run: ship host init", hostName))) + } + + // Auto-detect project type + result := detect.Detect(path) + if result.Error != nil { + output.PrintAndExit(result.Error) + } + + // Generate name if not provided + name := opts.Name + if name == "" { + name = generateName() + } + + // Build URL + url := fmt.Sprintf("https://%s.%s", name, hostConfig.BaseDomain) + + // Build deploy context + ctx := &deployContext{ + SSHHost: hostName, + HostConfig: hostConfig, + Name: name, + Path: result.Path, + URL: url, + Opts: opts, + } + + // Deploy based on type + var deployErr *output.ErrorResponse + switch result.Type { + case detect.TypeStatic: + deployErr = deployStaticV2(ctx) + case detect.TypeDocker: + deployErr = deployDockerV2(ctx) + case detect.TypeBinary: + deployErr = deployBinaryV2(ctx) + } + + if deployErr != nil { + deployErr.Name = name + deployErr.URL = url + output.PrintAndExit(deployErr) + } + + // Set TTL if specified + if ttlDuration > 0 { + if err := setTTLV2(ctx, ttlDuration); err != nil { + // Non-fatal, deploy succeeded + // TODO: log warning + } + } + + // Health check + var healthResult *output.HealthResult + if opts.Health != "" || result.Type == detect.TypeStatic { + endpoint := opts.Health + if endpoint == "" { + endpoint = "/" + } + healthResult, deployErr = runHealthCheck(url, endpoint) + if deployErr != nil { + deployErr.Name = name + deployErr.URL = url + output.PrintAndExit(deployErr) + } + } + + // Build response + resp := &output.DeployResponse{ + Status: "ok", + Name: name, + URL: url, + Type: string(result.Type), + TookMs: time.Since(start).Milliseconds(), + Health: healthResult, + } + + if ttlDuration > 0 { + resp.Expires = time.Now().Add(ttlDuration).UTC().Format(time.RFC3339) + } + + output.PrintAndExit(resp) +} + +type deployV2Options struct { + Name string + Host string + Health string + TTL string + Env []string + EnvFile string + Pretty bool +} + +// deployContext holds all info needed for a deploy +type deployContext struct { + SSHHost string // SSH connection string (config alias or user@host) + HostConfig *state.Host // Host configuration + Name string // Deploy name + Path string // Local path to deploy + URL string // Full URL after deploy + Opts deployV2Options +} + +// validateName checks if name matches allowed pattern +func validateName(name string) *output.ErrorResponse { + // Must be lowercase alphanumeric with hyphens, 1-63 chars + pattern := regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$|^[a-z0-9]$`) + if !pattern.MatchString(name) { + return output.Err(output.ErrInvalidName, + "name must be lowercase alphanumeric with hyphens, 1-63 characters") + } + return nil +} + +// generateName creates a random deploy name +func generateName() string { + bytes := make([]byte, 3) + rand.Read(bytes) + return "ship-" + hex.EncodeToString(bytes) +} + +// parseTTL converts duration strings like "1h", "7d" to time.Duration +func parseTTL(s string) (time.Duration, error) { + s = strings.TrimSpace(s) + if s == "" { + return 0, nil + } + + // Handle days specially (not supported by time.ParseDuration) + if strings.HasSuffix(s, "d") { + days := strings.TrimSuffix(s, "d") + var d int + _, err := fmt.Sscanf(days, "%d", &d) + if err != nil { + return 0, fmt.Errorf("invalid TTL: %s", s) + } + return time.Duration(d) * 24 * time.Hour, nil + } + + d, err := time.ParseDuration(s) + if err != nil { + return 0, fmt.Errorf("invalid TTL: %s", s) + } + 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 +} -- cgit v1.2.3 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) --- PROGRESS.md | 17 ++- cmd/ship/deploy_impl_v2.go | 365 +++++++++++++++++++++++++++++++++++++++++++++ cmd/ship/deploy_v2.go | 46 +----- 3 files changed, 376 insertions(+), 52 deletions(-) create mode 100644 cmd/ship/deploy_impl_v2.go (limited to 'cmd/ship/deploy_v2.go') diff --git a/PROGRESS.md b/PROGRESS.md index f38c02d..c91fc1b 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -12,20 +12,23 @@ Tracking rebuilding ship for agent-first JSON interface. - [x] New CLI structure (`ship [PATH]` as primary) - [x] Deploy orchestration with placeholder implementations -## Current Phase: Deploy Implementations -- [ ] Static site deploy (rsync + Caddyfile) -- [ ] Docker deploy (build + systemd + Caddy) -- [ ] Binary deploy (scp + systemd + Caddy) -- [ ] Health check implementation +## Current Phase: Subcommand Implementations +- [x] Static site deploy (rsync + Caddyfile) +- [x] Docker deploy (build + systemd + Caddy) +- [x] Binary deploy (scp + systemd + Caddy) +- [x] Health check implementation +- [x] TTL support (server-side) +- [x] Port allocation (server-side) ## Upcoming -- [ ] TTL support + cleanup timer +- [ ] TTL cleanup timer (server-side cron) - [ ] `ship host init` (update to match spec) - [ ] `ship list/status/logs/remove` implementations -- [ ] Wire up v2 commands in main.go +- [ ] Wire up v2 commands in main.go (feature flag or replace) ## Commits - `5b88935` feat(v2): add output and detect packages +- `8094639` feat(v2): add CLI structure and deploy orchestration --- 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 From 14703575578221ad40547147618354503e3005ae Mon Sep 17 00:00:00 2001 From: Clawd Date: Sun, 15 Feb 2026 18:55:50 -0800 Subject: fix: resolve build errors with v1 code - Rename validateName to validateNameV2 to avoid conflict - Fix host status to print JSON directly (remove unused resp) Builds successfully now --- cmd/ship/deploy_v2.go | 6 +++--- cmd/ship/host_v2.go | 17 +++++++---------- 2 files changed, 10 insertions(+), 13 deletions(-) (limited to 'cmd/ship/deploy_v2.go') diff --git a/cmd/ship/deploy_v2.go b/cmd/ship/deploy_v2.go index 16e12db..af4e3a7 100644 --- a/cmd/ship/deploy_v2.go +++ b/cmd/ship/deploy_v2.go @@ -21,7 +21,7 @@ func deployV2(path string, opts deployV2Options) { // Validate name if provided if opts.Name != "" { - if err := validateName(opts.Name); err != nil { + if err := validateNameV2(opts.Name); err != nil { output.PrintAndExit(err) } } @@ -157,8 +157,8 @@ type deployContext struct { Opts deployV2Options } -// validateName checks if name matches allowed pattern -func validateName(name string) *output.ErrorResponse { +// validateNameV2 checks if name matches allowed pattern +func validateNameV2(name string) *output.ErrorResponse { // Must be lowercase alphanumeric with hyphens, 1-63 chars pattern := regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$|^[a-z0-9]$`) if !pattern.MatchString(name) { diff --git a/cmd/ship/host_v2.go b/cmd/ship/host_v2.go index 0d70f5d..dec8b59 100644 --- a/cmd/ship/host_v2.go +++ b/cmd/ship/host_v2.go @@ -272,16 +272,13 @@ var hostStatusV2Cmd = &cobra.Command{ caddyStatus, _ := client.RunSudo("systemctl is-active caddy") dockerStatus, _ := client.RunSudo("systemctl is-active docker") - resp := map[string]interface{}{ - "status": "ok", - "host": hostName, - "domain": hostConfig.BaseDomain, - "caddy": strings.TrimSpace(caddyStatus) == "active", - "docker": strings.TrimSpace(dockerStatus) == "active", - } - - // Use JSON encoder directly since this is a custom response - output.Print(&output.ListResponse{Status: "ok"}) // Placeholder + // Print as JSON directly (custom response type) + fmt.Printf(`{"status":"ok","host":%q,"domain":%q,"caddy":%t,"docker":%t}`+"\n", + hostName, + hostConfig.BaseDomain, + strings.TrimSpace(caddyStatus) == "active", + strings.TrimSpace(dockerStatus) == "active", + ) return nil }, } -- cgit v1.2.3 From a48a911cc29ec7571ac07008bea3801745d5c00d Mon Sep 17 00:00:00 2001 From: Clawd Date: Mon, 16 Feb 2026 17:02:36 -0800 Subject: Add --domain flag support to v2 deploy Allows specifying a custom domain instead of using the auto-generated subdomain pattern. Usage: ship . --domain bdw.to --- cmd/ship/deploy_v2.go | 10 ++++++++-- cmd/ship/root_v2.go | 1 + 2 files changed, 9 insertions(+), 2 deletions(-) (limited to 'cmd/ship/deploy_v2.go') diff --git a/cmd/ship/deploy_v2.go b/cmd/ship/deploy_v2.go index af4e3a7..b636709 100644 --- a/cmd/ship/deploy_v2.go +++ b/cmd/ship/deploy_v2.go @@ -67,8 +67,13 @@ func deployV2(path string, opts deployV2Options) { name = generateName() } - // Build URL - url := fmt.Sprintf("https://%s.%s", name, hostConfig.BaseDomain) + // Build URL: use custom domain if provided, otherwise use subdomain + var url string + if opts.Domain != "" { + url = fmt.Sprintf("https://%s", opts.Domain) + } else { + url = fmt.Sprintf("https://%s.%s", name, hostConfig.BaseDomain) + } // Build deploy context ctx := &deployContext{ @@ -140,6 +145,7 @@ func deployV2(path string, opts deployV2Options) { type deployV2Options struct { Name string Host string + Domain string Health string TTL string Env []string diff --git a/cmd/ship/root_v2.go b/cmd/ship/root_v2.go index e886a7e..1be6745 100644 --- a/cmd/ship/root_v2.go +++ b/cmd/ship/root_v2.go @@ -70,6 +70,7 @@ func runDeployV2(cmd *cobra.Command, args []string) error { // Get flag values opts.Name, _ = cmd.Flags().GetString("name") + opts.Domain, _ = cmd.Flags().GetString("domain") opts.Health, _ = cmd.Flags().GetString("health") opts.TTL, _ = cmd.Flags().GetString("ttl") opts.Env, _ = cmd.Flags().GetStringArray("env") -- cgit v1.2.3 From d97bb6f53eefd2139115d39bca7e17d565222472 Mon Sep 17 00:00:00 2001 From: Clawd Date: Tue, 17 Feb 2026 07:59:50 -0800 Subject: Fix port collision bug, add --container-port flag Port allocation: - Use atomic flock-based allocation via /etc/ship/next_port - Prevents race conditions when multiple deploys run concurrently - Each app still gets its port stored in /etc/ship/ports/ Docker container port: - Add --container-port flag (default 80) - Template now uses {{.ContainerPort}} instead of hardcoded 80 - Supports containers that listen on 8080, 3000, etc. --- cmd/ship/deploy_impl_v2.go | 45 +++++++++++++++++++++++++--------------- cmd/ship/deploy_v2.go | 17 ++++++++------- cmd/ship/root_v2.go | 2 ++ internal/templates/templates.go | 2 +- ship-new | Bin 12401121 -> 12400369 bytes 5 files changed, 40 insertions(+), 26 deletions(-) (limited to 'cmd/ship/deploy_v2.go') diff --git a/cmd/ship/deploy_impl_v2.go b/cmd/ship/deploy_impl_v2.go index e2989b2..9ff674e 100644 --- a/cmd/ship/deploy_impl_v2.go +++ b/cmd/ship/deploy_impl_v2.go @@ -132,9 +132,14 @@ func deployDockerV2(ctx *deployContext) *output.ErrorResponse { } // Generate systemd unit + containerPort := ctx.Opts.ContainerPort + if containerPort == 0 { + containerPort = 80 + } service, err := templates.DockerService(map[string]string{ - "Name": name, - "Port": strconv.Itoa(port), + "Name": name, + "Port": strconv.Itoa(port), + "ContainerPort": strconv.Itoa(containerPort), }) if err != nil { return output.Err(output.ErrServiceFailed, "failed to generate systemd unit: "+err.Error()) @@ -281,38 +286,44 @@ func deployBinaryV2(ctx *deployContext) *output.ErrorResponse { } // allocatePort allocates or retrieves a port for a service +// Uses atomic increment on /etc/ship/next_port to avoid collisions func allocatePort(client *ssh.Client, name string) (int, error) { portFile := fmt.Sprintf("/etc/ship/ports/%s", name) - // Try to read existing port + // Try to read existing port for this app 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 { + out = strings.TrimSpace(out) + if port, err := strconv.Atoi(out); 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") + // Allocate new port atomically using flock + // This reads next_port, increments it, and writes back while holding a lock + allocScript := ` +flock -x /etc/ship/.port.lock sh -c ' + mkdir -p /etc/ship/ports + PORT=$(cat /etc/ship/next_port 2>/dev/null || echo 9000) + echo $((PORT + 1)) > /etc/ship/next_port + echo $PORT +'` + out, err = client.RunSudo(allocScript) if err != nil { - out = "" + return 0, fmt.Errorf("failed to allocate port: %w", err) } - nextPort := 9000 - if out != "" { - if lastPort, err := strconv.Atoi(out[:len(out)-1]); err == nil { - nextPort = lastPort + 1 - } + port, err := strconv.Atoi(strings.TrimSpace(out)) + if err != nil { + return 0, fmt.Errorf("invalid port allocated: %s", out) } - // Write port allocation - if err := client.WriteSudoFile(portFile, strconv.Itoa(nextPort)); err != nil { + // Write port allocation for this app + if err := client.WriteSudoFile(portFile, strconv.Itoa(port)); err != nil { return 0, err } - return nextPort, nil + return port, nil } // setTTLV2 sets auto-expiry for a deploy diff --git a/cmd/ship/deploy_v2.go b/cmd/ship/deploy_v2.go index b636709..7d498b2 100644 --- a/cmd/ship/deploy_v2.go +++ b/cmd/ship/deploy_v2.go @@ -143,14 +143,15 @@ func deployV2(path string, opts deployV2Options) { } type deployV2Options struct { - Name string - Host string - Domain string - Health string - TTL string - Env []string - EnvFile string - Pretty bool + Name string + Host string + Domain string + Health string + TTL string + Env []string + EnvFile string + ContainerPort int // Port the container listens on (default 80 for Docker) + Pretty bool } // deployContext holds all info needed for a deploy diff --git a/cmd/ship/root_v2.go b/cmd/ship/root_v2.go index 4101d4e..aa81d1e 100644 --- a/cmd/ship/root_v2.go +++ b/cmd/ship/root_v2.go @@ -43,6 +43,7 @@ func initV2() { rootV2Cmd.Flags().String("ttl", "", "Auto-delete after duration (e.g., 1h, 7d)") rootV2Cmd.Flags().StringArray("env", nil, "Environment variable (KEY=VALUE)") rootV2Cmd.Flags().String("env-file", "", "Path to .env file") + rootV2Cmd.Flags().Int("container-port", 80, "Port the container listens on (Docker only)") // Check for SHIP_PRETTY env var if os.Getenv("SHIP_PRETTY") == "1" { @@ -78,6 +79,7 @@ func runDeployV2(cmd *cobra.Command, args []string) error { opts.TTL, _ = cmd.Flags().GetString("ttl") opts.Env, _ = cmd.Flags().GetStringArray("env") opts.EnvFile, _ = cmd.Flags().GetString("env-file") + opts.ContainerPort, _ = cmd.Flags().GetInt("container-port") // deployV2 handles all output and exits deployV2(path, opts) diff --git a/internal/templates/templates.go b/internal/templates/templates.go index f1b9a9e..2163f47 100644 --- a/internal/templates/templates.go +++ b/internal/templates/templates.go @@ -216,7 +216,7 @@ Requires=docker.service Type=simple ExecStartPre=-/usr/bin/docker rm -f {{.Name}} ExecStart=/usr/bin/docker run --rm --name {{.Name}} \ - -p 127.0.0.1:{{.Port}}:80 \ + -p 127.0.0.1:{{.Port}}:{{.ContainerPort}} \ --env-file /etc/ship/env/{{.Name}}.env \ -v /var/lib/{{.Name}}/data:/data \ {{.Name}}:latest diff --git a/ship-new b/ship-new index 814585f..cd3667d 100755 Binary files a/ship-new and b/ship-new differ -- cgit v1.2.3