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(-) 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