From f84a6097e49bfbf96123a8b85a021af9a5858466 Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Tue, 17 Mar 2026 00:39:28 +0000 Subject: [PATCH 01/13] Initial plan From a14bd0603be117d20c41b6210f7983e10a2d5ba0 Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Tue, 17 Mar 2026 00:45:08 +0000 Subject: [PATCH 02/13] Add error classification and bounded port fallback for local deploy Co-authored-by: ewega <26189114+ewega@users.noreply.github.com> --- cmd/deploy_errors.go | 278 ++++++++++++++++++++++++++++++++++++++ cmd/deploy_errors_test.go | 175 ++++++++++++++++++++++++ cmd/deploy_local.go | 214 +++++++++++++++++------------ 3 files changed, 577 insertions(+), 90 deletions(-) create mode 100644 cmd/deploy_errors.go create mode 100644 cmd/deploy_errors_test.go diff --git a/cmd/deploy_errors.go b/cmd/deploy_errors.go new file mode 100644 index 0000000..0685306 --- /dev/null +++ b/cmd/deploy_errors.go @@ -0,0 +1,278 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// DeployErrorClass represents a known failure class during deployment. +type DeployErrorClass string + +const ( + ErrorClassDockerPortConflict DeployErrorClass = "docker_port_conflict" + ErrorClassDockerBindFailed DeployErrorClass = "docker_bind_failed" + ErrorClassAzureAuth DeployErrorClass = "azure_auth" + ErrorClassAzureMySQLStopped DeployErrorClass = "azure_mysql_stopped" + ErrorClassAzureKeyVault DeployErrorClass = "azure_keyvault_softdelete" + ErrorClassUnknown DeployErrorClass = "unknown" +) + +// DeployError represents a classified deployment error with recovery context. +type DeployError struct { + Class DeployErrorClass + OriginalErr error + Port string // For port conflict errors + Container string // For port conflict errors + ComposeFile string // For port conflict errors + Message string // Human-readable classification +} + +// classifyDockerComposeError inspects a docker compose error and returns +// a classified error with recovery context. This covers: +// - "port is already allocated" +// - "Bind for 0.0.0.0:PORT" +// - "ports are not available" / "Ports are not available" +// - "address already in use" +// - "failed programming external connectivity" +func classifyDockerComposeError(err error) *DeployError { + if err == nil { + return nil + } + + errStr := err.Error() + errStrLower := strings.ToLower(errStr) + + // Port conflict patterns (case-insensitive) + portConflictPatterns := []string{ + "port is already allocated", + "bind for", + "ports are not available", + "address already in use", + "failed programming external connectivity", + } + + isPortConflict := false + for _, pattern := range portConflictPatterns { + if strings.Contains(errStrLower, pattern) { + isPortConflict = true + break + } + } + + if !isPortConflict { + return &DeployError{ + Class: ErrorClassUnknown, + OriginalErr: err, + Message: "Docker Compose failed", + } + } + + // Extract port number from various error formats + port := extractPortFromError(errStr) + + result := &DeployError{ + Class: ErrorClassDockerPortConflict, + OriginalErr: err, + Port: port, + Message: "Docker port conflict detected", + } + + // Try to identify owning container + if port != "" { + container, composeFile := findPortOwner(port) + result.Container = container + result.ComposeFile = composeFile + } + + return result +} + +// extractPortFromError extracts the port number from various Docker error formats: +// - "Bind for 0.0.0.0:8080: failed: port is already allocated" +// - "Error response from daemon: Ports are not available: exposing port TCP 0.0.0.0:8080" +// - "bind: address already in use (listening on [::]:8080)" +// - "failed programming external connectivity on endpoint devlake (8080/tcp)" +func extractPortFromError(errStr string) string { + // Pattern 1: "Bind for 0.0.0.0:PORT" (case-insensitive) + lowerStr := strings.ToLower(errStr) + if idx := strings.Index(lowerStr, "bind for 0.0.0.0:"); idx != -1 { + rest := errStr[idx+len("bind for 0.0.0.0:"):] + if end := strings.IndexAny(rest, " :\n"); end > 0 { + return rest[:end] + } + } + + // Pattern 2: "exposing port TCP 0.0.0.0:PORT" + if idx := strings.Index(errStr, "0.0.0.0:"); idx != -1 { + rest := errStr[idx+len("0.0.0.0:"):] + if end := strings.IndexAny(rest, " ->\n"); end > 0 { + return rest[:end] + } + } + + // Pattern 3: "listening on [::]:PORT" or "[::]PORT" + if idx := strings.Index(errStr, "[::]"); idx != -1 { + rest := errStr[idx+len("[::]"):] + // Skip potential colon separator + if strings.HasPrefix(rest, ":") { + rest = rest[1:] + } + if end := strings.IndexAny(rest, " )\n"); end > 0 { + return rest[:end] + } + // If no delimiter found, but there are digits, use them + if len(rest) > 0 { + for i, ch := range rest { + if ch < '0' || ch > '9' { + if i > 0 { + return rest[:i] + } + break + } + } + } + } + + // Pattern 4: "(PORT/tcp)" or "(PORT/udp)" in endpoint errors + if idx := strings.Index(errStr, "("); idx != -1 { + rest := errStr[idx+1:] + if end := strings.Index(rest, "/tcp)"); end > 0 { + port := strings.TrimSpace(rest[:end]) + if isValidPort(port) { + return port + } + } + if end := strings.Index(rest, "/udp)"); end > 0 { + port := strings.TrimSpace(rest[:end]) + if isValidPort(port) { + return port + } + } + } + + // Pattern 5: Generic port number extraction (last resort) + // Look for sequences like ":8080" or " 8080 " in the error + for _, candidate := range strings.Fields(errStr) { + // Try splitting by colons + if strings.Contains(candidate, ":") { + parts := strings.Split(candidate, ":") + for _, part := range parts { + part = strings.Trim(part, "(),[]") + if isValidPort(part) { + return part + } + } + } + // Try the field itself (for cases like "[::] 3002") + cleaned := strings.Trim(candidate, "(),[]") + if isValidPort(cleaned) { + return cleaned + } + } + + return "" +} + +// isValidPort checks if a string looks like a valid port number (all digits, 1-65535). +func isValidPort(s string) bool { + if len(s) < 1 || len(s) > 5 { + return false + } + for _, ch := range s { + if ch < '0' || ch > '9' { + return false + } + } + // Basic range check (ports are 1-65535) + if len(s) == 5 { + // Quick check: if > 65535, invalid + if s > "65535" { + return false + } + } + return true +} + +// findPortOwner queries Docker to find which container is using the specified port. +// Returns (containerName, composeFilePath). +func findPortOwner(port string) (string, string) { + out, err := exec.Command( + "docker", + "ps", + "--filter", "publish="+port, + "--format", "{{.Names}}\t{{.Label \"com.docker.compose.project.config_files\"}}\t{{.Label \"com.docker.compose.project.working_dir\"}}", + ).Output() + + if err != nil || len(strings.TrimSpace(string(out))) == 0 { + return "", "" + } + + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + parts := strings.SplitN(lines[0], "\t", 3) + + containerName := parts[0] + configFiles := "" + workDir := "" + + if len(parts) >= 2 { + configFiles = strings.TrimSpace(parts[1]) + } + if len(parts) == 3 { + workDir = strings.TrimSpace(parts[2]) + } + + // Prefer the exact compose file path Docker recorded + if configFiles != "" { + configFile := strings.Split(configFiles, ";")[0] + configFile = strings.TrimSpace(configFile) + if configFile != "" { + if _, statErr := os.Stat(configFile); statErr == nil { + return containerName, configFile + } + } + } + + // Fallback: assume docker-compose.yml under working_dir + if workDir != "" { + composePath := filepath.Join(workDir, "docker-compose.yml") + if _, statErr := os.Stat(composePath); statErr == nil { + return containerName, composePath + } + } + + return containerName, "" +} + +// printDockerPortConflictError prints a user-friendly error message for port conflicts +// with actionable remediation steps. +func printDockerPortConflictError(de *DeployError) { + fmt.Println() + if de.Port != "" { + fmt.Printf("ā Port conflict detected: port %s is already in use.\n", de.Port) + } else { + fmt.Println("ā Port conflict detected: a required port is already in use.") + } + + if de.Container != "" { + fmt.Printf(" Container holding the port: %s\n", de.Container) + + if de.ComposeFile != "" { + fmt.Println("\n Stop it with:") + fmt.Printf(" docker compose -f \"%s\" down\n", de.ComposeFile) + } else { + fmt.Println("\n Stop it with:") + fmt.Printf(" docker stop %s\n", de.Container) + } + } else if de.Port != "" { + fmt.Println("\n Find what's using it:") + fmt.Println(" docker ps --format \"table {{.Names}}\\t{{.Ports}}\"") + } + + fmt.Println("\n Then re-run:") + fmt.Println(" gh devlake deploy local") + fmt.Println("\nš” To clean up partial artifacts:") + fmt.Println(" gh devlake cleanup --local --force") +} diff --git a/cmd/deploy_errors_test.go b/cmd/deploy_errors_test.go new file mode 100644 index 0000000..30cb081 --- /dev/null +++ b/cmd/deploy_errors_test.go @@ -0,0 +1,175 @@ +package cmd + +import ( + "errors" + "testing" +) + +func TestExtractPortFromError(t *testing.T) { + tests := []struct { + name string + errStr string + wantPort string + }{ + { + name: "bind for pattern", + errStr: "Error response from daemon: driver failed: Bind for 0.0.0.0:8080 failed: port is already allocated", + wantPort: "8080", + }, + { + name: "exposing port pattern", + errStr: "Error response from daemon: Ports are not available: exposing port TCP 0.0.0.0:8080", + wantPort: "8080", + }, + { + name: "ipv6 listening pattern with colon", + errStr: "bind: address already in use (listening on [::]:8080)", + wantPort: "8080", + }, + { + name: "ipv6 listening pattern without colon", + errStr: "bind: address already in use (listening on [::] 3002)", + wantPort: "3002", + }, + { + name: "port in error context", + errStr: "failed programming external connectivity on endpoint devlake (8080/tcp)", + wantPort: "8080", + }, + { + name: "alternate port 8085", + errStr: "Error response from daemon: driver failed: Bind for 0.0.0.0:8085 failed: port is already allocated", + wantPort: "8085", + }, + { + name: "grafana port 3002", + errStr: "Bind for 0.0.0.0:3002: failed: port is already allocated", + wantPort: "3002", + }, + { + name: "config-ui port 4000", + errStr: "Ports are not available: exposing port TCP 0.0.0.0:4000", + wantPort: "4000", + }, + { + name: "no port in error", + errStr: "Error response from daemon: some other error", + wantPort: "", + }, + { + name: "empty error", + errStr: "", + wantPort: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractPortFromError(tt.errStr) + if got != tt.wantPort { + t.Errorf("extractPortFromError() = %q, want %q", got, tt.wantPort) + } + }) + } +} + +func TestClassifyDockerComposeError(t *testing.T) { + tests := []struct { + name string + err error + wantClass DeployErrorClass + wantPort string + wantNilErr bool + }{ + { + name: "port already allocated", + err: errors.New("Error response from daemon: driver failed: Bind for 0.0.0.0:8080 failed: port is already allocated"), + wantClass: ErrorClassDockerPortConflict, + wantPort: "8080", + }, + { + name: "ports are not available", + err: errors.New("Error response from daemon: Ports are not available: exposing port TCP 0.0.0.0:8080"), + wantClass: ErrorClassDockerPortConflict, + wantPort: "8080", + }, + { + name: "address already in use", + err: errors.New("bind: address already in use (listening on [::]:8080)"), + wantClass: ErrorClassDockerPortConflict, + wantPort: "8080", + }, + { + name: "failed programming external connectivity", + err: errors.New("failed programming external connectivity on endpoint devlake"), + wantClass: ErrorClassDockerPortConflict, + wantPort: "", + }, + { + name: "unknown docker error", + err: errors.New("Error response from daemon: some other error"), + wantClass: ErrorClassUnknown, + wantPort: "", + }, + { + name: "nil error", + err: nil, + wantNilErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := classifyDockerComposeError(tt.err) + + if tt.wantNilErr { + if got != nil { + t.Errorf("classifyDockerComposeError() = %v, want nil", got) + } + return + } + + if got == nil { + t.Fatal("classifyDockerComposeError() returned nil, want non-nil") + } + + if got.Class != tt.wantClass { + t.Errorf("classifyDockerComposeError().Class = %v, want %v", got.Class, tt.wantClass) + } + + if got.Port != tt.wantPort { + t.Errorf("classifyDockerComposeError().Port = %q, want %q", got.Port, tt.wantPort) + } + + if got.OriginalErr != tt.err { + t.Errorf("classifyDockerComposeError().OriginalErr = %v, want %v", got.OriginalErr, tt.err) + } + }) + } +} + +func TestClassifyDockerComposeError_AllPatterns(t *testing.T) { + // Test that all documented port conflict patterns are recognized + patterns := []string{ + "port is already allocated", + "Bind for 0.0.0.0:8080", + "ports are not available", + "address already in use", + "failed programming external connectivity", + } + + for _, pattern := range patterns { + t.Run(pattern, func(t *testing.T) { + err := errors.New("Error: " + pattern) + got := classifyDockerComposeError(err) + + if got == nil { + t.Fatal("classifyDockerComposeError() returned nil, want non-nil") + } + + if got.Class != ErrorClassDockerPortConflict { + t.Errorf("Pattern %q not classified as port conflict, got %v", pattern, got.Class) + } + }) + } +} diff --git a/cmd/deploy_local.go b/cmd/deploy_local.go index 917282c..7fa0339 100644 --- a/cmd/deploy_local.go +++ b/cmd/deploy_local.go @@ -3,7 +3,6 @@ package cmd import ( "fmt" "os" - "os/exec" "path/filepath" "strings" "time" @@ -190,7 +189,11 @@ func runDeployLocal(cmd *cobra.Command, args []string) error { if deployLocalSource == "fork" { services = []string{"mysql", "devlake", "grafana", "config-ui"} } - backendURL, err := startLocalContainers(absDir, buildImages, services...) + + // Allow alternate port bundle for official/fork (not custom) + allowPortFallback := deployLocalSource != "custom" + + backendURL, err := startLocalContainers(absDir, buildImages, allowPortFallback, services...) if err != nil { return err } @@ -424,10 +427,12 @@ func copyDir(src, dst string) error { // startLocalContainers runs docker compose up -d and polls until DevLake is healthy. // If build is true, images are rebuilt from local Dockerfiles (fork mode). +// If allowPortFallback is true, the function will retry once with alternate ports (8085/3004/4004) +// when a port conflict is detected on the default bundle (8080/3002/4000). // If services are specified, only those services are started (used by fork mode // to avoid starting unnecessary services like postgres/authproxy). // Returns the backend URL on success. -func startLocalContainers(dir string, build bool, services ...string) (string, error) { +func startLocalContainers(dir string, build, allowPortFallback bool, services ...string) (string, error) { absDir, _ := filepath.Abs(dir) if build { fmt.Printf("\nš³ Building and starting containers in %s...\n", absDir) @@ -435,100 +440,82 @@ func startLocalContainers(dir string, build bool, services ...string) (string, e } else { fmt.Printf("\nš³ Starting containers in %s...\n", absDir) } - if err := dockerpkg.ComposeUp(absDir, build, services...); err != nil { - // Give a friendlier error for port conflicts - errStr := err.Error() - if strings.Contains(errStr, "port is already allocated") || strings.Contains(errStr, "Bind for") { - // Extract the port number from the error - port := "" - if idx := strings.Index(errStr, "Bind for 0.0.0.0:"); idx != -1 { - rest := errStr[idx+len("Bind for 0.0.0.0:"):] - if end := strings.IndexAny(rest, " \n"); end > 0 { - port = rest[:end] - } - } - fmt.Println() - if port != "" { - fmt.Printf("ā Port conflict: %s is already in use.\n", port) - } else { - fmt.Println("ā Port conflict: a required port is already in use.") - } + // Attempt 1: Default ports + err := dockerpkg.ComposeUp(absDir, build, services...) + if err == nil { + fmt.Println(" ā Containers starting") + return waitAndDetectBackendURL(absDir) + } - // Ask Docker which container owns the port - conflictCmd := "" - if port != "" { - out, dockerErr := exec.Command( - "docker", - "ps", - "--filter", - "publish="+port, - "--format", - "{{.Names}}\t{{.Label \"com.docker.compose.project.config_files\"}}\t{{.Label \"com.docker.compose.project.working_dir\"}}", - ).Output() - if dockerErr == nil && len(strings.TrimSpace(string(out))) > 0 { - lines := strings.Split(strings.TrimSpace(string(out)), "\n") - // Use the first match - parts := strings.SplitN(lines[0], "\t", 3) - containerName := parts[0] - configFiles := "" - workDir := "" - if len(parts) >= 2 { - configFiles = strings.TrimSpace(parts[1]) - } - if len(parts) == 3 { - workDir = strings.TrimSpace(parts[2]) - } - fmt.Printf(" Container holding the port: %s\n", containerName) - // Prefer the exact compose file path Docker recorded (most reliable). - if configFiles != "" { - configFile := strings.Split(configFiles, ";")[0] - configFile = strings.TrimSpace(configFile) - if configFile != "" { - if _, statErr := os.Stat(configFile); statErr == nil { - fmt.Println("\n Stop it with:") - fmt.Printf(" docker compose -f \"%s\" down\n", configFile) - conflictCmd = fmt.Sprintf("docker compose -f \"%s\" down", configFile) - } else { - fmt.Println("\n Stop it with:") - fmt.Printf(" docker stop %s\n", containerName) - fmt.Printf("\n ā ļø Compose file not found at: %s\n", configFile) - fmt.Println(" (It may have been moved/deleted since the container was created.)") - conflictCmd = "docker stop " + containerName - } - } - } else if workDir != "" { - // Fallback for older Docker versions: assume docker-compose.yml under working_dir. - composePath := filepath.Join(workDir, "docker-compose.yml") - if _, statErr := os.Stat(composePath); statErr == nil { - fmt.Println("\n Stop it with:") - fmt.Printf(" docker compose -f \"%s\" down\n", composePath) - conflictCmd = fmt.Sprintf("docker compose -f \"%s\" down", composePath) - } - } - if conflictCmd == "" { - fmt.Println("\n Stop it with:") - fmt.Printf(" docker stop %s\n", containerName) - conflictCmd = "docker stop " + containerName - } - } - } - if conflictCmd == "" { - fmt.Println("\n Find what's using it:") - fmt.Println(" docker ps --format \"table {{.Names}}\\t{{.Ports}}\"") - } - fmt.Println("\n Then re-run:") - fmt.Println(" gh devlake init") - fmt.Println("\nš” To clean up partial artifacts:") - fmt.Println(" gh devlake cleanup --local --force") - return "", fmt.Errorf("port conflict ā stop the conflicting container and retry") - } + // Classify the error + deployErr := classifyDockerComposeError(err) + if deployErr == nil || deployErr.Class != ErrorClassDockerPortConflict { + // Not a port conflict or unknown error - print general cleanup and fail fmt.Println("\nš” To clean up partial artifacts:") fmt.Println(" gh devlake cleanup --local --force") return "", err } - fmt.Println(" ā Containers starting") + // Port conflict detected + if !allowPortFallback { + // Custom deployments don't get auto-fallback - print friendly error + printDockerPortConflictError(deployErr) + return "", fmt.Errorf("port conflict ā stop the conflicting container and retry") + } + + // Bounded recovery: Try alternate port bundle once + fmt.Println() + fmt.Printf("š§ Port conflict detected on default ports (8080/3002/4000)\n") + if deployErr.Port != "" { + fmt.Printf(" Port %s is in use", deployErr.Port) + if deployErr.Container != "" { + fmt.Printf(" by container: %s", deployErr.Container) + } + fmt.Println() + } + fmt.Println() + fmt.Println("š Retrying with alternate ports (8085/3004/4004)...") + + // Rewrite port mappings in docker-compose.yml or docker-compose-dev.yml + composePath := filepath.Join(absDir, "docker-compose.yml") + if _, err := os.Stat(composePath); os.IsNotExist(err) { + composePath = filepath.Join(absDir, "docker-compose-dev.yml") + } + + if err := rewriteComposePorts(composePath); err != nil { + fmt.Printf(" ā ļø Could not rewrite ports: %v\n", err) + printDockerPortConflictError(deployErr) + return "", fmt.Errorf("port conflict and failed to apply alternate ports: %w", err) + } + + fmt.Println(" ā Ports updated in compose file") + + // Attempt 2: Retry with alternate ports + fmt.Println("\n Starting containers with alternate ports...") + err = dockerpkg.ComposeUp(absDir, build, services...) + if err != nil { + // Second attempt failed - classify again + retryErr := classifyDockerComposeError(err) + if retryErr != nil && retryErr.Class == ErrorClassDockerPortConflict { + fmt.Println() + fmt.Println("ā Alternate ports are also in use.") + printDockerPortConflictError(retryErr) + fmt.Println("\n Both default (8080/3002/4000) and alternate (8085/3004/4004) port bundles are occupied.") + fmt.Println(" Free at least one bundle, then retry deployment.") + } else { + fmt.Println("\nš” To clean up partial artifacts:") + fmt.Println(" gh devlake cleanup --local --force") + } + return "", fmt.Errorf("deployment failed after port fallback: %w", err) + } + + fmt.Println(" ā Containers starting on alternate ports") + return waitAndDetectBackendURL(absDir) +} + +// waitAndDetectBackendURL polls both possible backend URLs and returns the responsive one. +func waitAndDetectBackendURL(dir string) (string, error) { backendURLCandidates := []string{"http://localhost:8080", "http://localhost:8085"} fmt.Println("\nā³ Waiting for DevLake to be ready...") fmt.Println(" Giving MySQL time to initialize (this takes ~30s on first run)...") @@ -540,3 +527,50 @@ func startLocalContainers(dir string, build bool, services ...string) (string, e } return backendURL, nil } + +// rewriteComposePorts rewrites the port mappings in a docker-compose.yml file +// from the default bundle (8080/3002/4000) to the alternate bundle (8085/3004/4004). +func rewriteComposePorts(composePath string) error { + data, err := os.ReadFile(composePath) + if err != nil { + return fmt.Errorf("reading compose file: %w", err) + } + + content := string(data) + + // Port mapping patterns: + // - "8080:8080" -> "8085:8080" (external:internal) + // - "3002:3002" -> "3004:3002" + // - "4000:4000" -> "4004:4000" + portMappings := map[string]string{ + "8080:8080": "8085:8080", + "- 8080:8080": "- 8085:8080", + "\"8080:8080\"": "\"8085:8080\"", + "'8080:8080'": "'8085:8080'", + + "3002:3002": "3004:3002", + "- 3002:3002": "- 3004:3002", + "\"3002:3002\"": "\"3004:3002\"", + "'3002:3002'": "'3004:3002'", + + "4000:4000": "4004:4000", + "- 4000:4000": "- 4004:4000", + "\"4000:4000\"": "\"4004:4000\"", + "'4000:4000'": "'4004:4000'", + } + + modified := content + for old, new := range portMappings { + modified = strings.ReplaceAll(modified, old, new) + } + + if modified == content { + return fmt.Errorf("no port mappings found to rewrite (expected 8080/3002/4000)") + } + + if err := os.WriteFile(composePath, []byte(modified), 0644); err != nil { + return fmt.Errorf("writing compose file: %w", err) + } + + return nil +} From 86a3eb02fb303af9c434282fae5ea47f8bd830bd Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Tue, 17 Mar 2026 00:47:17 +0000 Subject: [PATCH 03/13] Add port rewriting tests, normalize Azure recovery, update docs Co-authored-by: ewega <26189114+ewega@users.noreply.github.com> --- cmd/deploy_azure.go | 17 +++- cmd/deploy_local_test.go | 169 ++++++++++++++++++++++++++++++++++++++- docs/deploy.md | 37 +++++++-- 3 files changed, 211 insertions(+), 12 deletions(-) diff --git a/cmd/deploy_azure.go b/cmd/deploy_azure.go index 14e9a12..36983b8 100644 --- a/cmd/deploy_azure.go +++ b/cmd/deploy_azure.go @@ -144,7 +144,9 @@ func runDeployAzure(cmd *cobra.Command, args []string) error { fmt.Println("\nš Checking Azure CLI login...") acct, err := azure.CheckLogin() if err != nil { - fmt.Println(" Not logged in. Running az login...") + // Bounded recovery: Auto-login (single attempt) + fmt.Println(" ā Not logged in") + fmt.Println("\nš§ Recovery: Running az login...") if loginErr := azure.Login(); loginErr != nil { return fmt.Errorf("az login failed: %w", loginErr) } @@ -152,6 +154,7 @@ func runDeployAzure(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("still not logged in after az login: %w", err) } + fmt.Println(" ā Recovery successful") } fmt.Printf(" Logged in as: %s\n", acct.User.Name) @@ -240,9 +243,12 @@ func runDeployAzure(cmd *cobra.Command, args []string) error { fmt.Println("\nšļø Checking MySQL state...") state, err := azure.MySQLState(mysqlName, azureRG) if err == nil && state == "Stopped" { - fmt.Println(" MySQL is stopped. Starting...") + // Bounded recovery: Start stopped MySQL (single attempt) + fmt.Println(" ā MySQL is stopped") + fmt.Println("\nš§ Recovery: Starting MySQL...") if err := azure.MySQLStart(mysqlName, azureRG); err != nil { fmt.Printf(" ā ļø Could not start MySQL: %v\n", err) + fmt.Println(" Continuing deployment ā MySQL may start later") } else { fmt.Println(" Waiting 30s for MySQL...") time.Sleep(30 * time.Second) @@ -258,11 +264,14 @@ func runDeployAzure(cmd *cobra.Command, args []string) error { kvName := fmt.Sprintf("%skv%s", azureBaseName, suffix) found, _ := azure.CheckSoftDeletedKeyVault(kvName) if found { - fmt.Printf("\nš Key Vault %q found in soft-deleted state, purging...\n", kvName) + // Bounded recovery: Purge soft-deleted Key Vault (single attempt) + fmt.Printf("\nš Key Vault conflict detected\n") + fmt.Printf(" Key Vault %q is in soft-deleted state\n", kvName) + fmt.Println("\nš§ Recovery: Purging soft-deleted Key Vault...") if err := azure.PurgeKeyVault(kvName, azureLocation); err != nil { return fmt.Errorf("failed to purge soft-deleted Key Vault %q: %w\nManual fix: az keyvault purge --name %s --location %s", kvName, err, kvName, azureLocation) } - fmt.Println(" ā Key Vault purged") + fmt.Println(" ā Key Vault purged ā deployment can proceed") } // āā Deploy infrastructure āā diff --git a/cmd/deploy_local_test.go b/cmd/deploy_local_test.go index b575b10..973cc97 100644 --- a/cmd/deploy_local_test.go +++ b/cmd/deploy_local_test.go @@ -1,6 +1,11 @@ package cmd -import "testing" +import ( + "os" + "path/filepath" + "strings" + "testing" +) func TestRewritePoetryInstallLine_RewritesInstallerLine(t *testing.T) { input := "FROM python:3.9-slim-bookworm\nRUN curl -sSL https://install.python-poetry.org | python3 -\n" @@ -38,3 +43,165 @@ func TestRewritePoetryInstallLine_NoChangeWhenLineMissing(t *testing.T) { t.Fatalf("content changed unexpectedly") } } + +func TestRewriteComposePorts(t *testing.T) { + tests := []struct { + name string + input string + wantContain []string + wantErr bool + }{ + { + name: "standard docker-compose format", + input: `version: '3' +services: + devlake: + ports: + - 8080:8080 + grafana: + ports: + - 3002:3002 + config-ui: + ports: + - 4000:4000 +`, + wantContain: []string{"8085:8080", "3004:3002", "4004:4000"}, + wantErr: false, + }, + { + name: "quoted port mappings", + input: `version: '3' +services: + devlake: + ports: + - "8080:8080" + grafana: + ports: + - "3002:3002" + config-ui: + ports: + - "4000:4000" +`, + wantContain: []string{"8085:8080", "3004:3002", "4004:4000"}, + wantErr: false, + }, + { + name: "single-quoted port mappings", + input: `version: '3' +services: + devlake: + ports: + - '8080:8080' + grafana: + ports: + - '3002:3002' + config-ui: + ports: + - '4000:4000' +`, + wantContain: []string{"8085:8080", "3004:3002", "4004:4000"}, + wantErr: false, + }, + { + name: "mixed port formats", + input: `version: '3' +services: + devlake: + ports: + - 8080:8080 + grafana: + ports: + - "3002:3002" + config-ui: + ports: + - '4000:4000' +`, + wantContain: []string{"8085:8080", "3004:3002", "4004:4000"}, + wantErr: false, + }, + { + name: "no matching ports", + input: `version: '3' +services: + mysql: + ports: + - 3306:3306 +`, + wantErr: true, + }, + { + name: "already rewritten ports (alternate bundle)", + input: `version: '3' +services: + devlake: + ports: + - 8085:8080 + grafana: + ports: + - 3004:3002 + config-ui: + ports: + - 4004:4000 +`, + wantErr: true, // No changes made + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create temp file + tmpDir := t.TempDir() + composePath := filepath.Join(tmpDir, "docker-compose.yml") + if err := os.WriteFile(composePath, []byte(tt.input), 0644); err != nil { + t.Fatalf("Failed to write test compose file: %v", err) + } + + // Run rewrite + err := rewriteComposePorts(composePath) + + if tt.wantErr { + if err == nil { + t.Errorf("rewriteComposePorts() expected error, got nil") + } + return + } + + if err != nil { + t.Errorf("rewriteComposePorts() unexpected error: %v", err) + return + } + + // Read result + result, err := os.ReadFile(composePath) + if err != nil { + t.Fatalf("Failed to read result: %v", err) + } + resultStr := string(result) + + // Check expected content + for _, want := range tt.wantContain { + if !strings.Contains(resultStr, want) { + t.Errorf("rewriteComposePorts() result missing %q\nResult:\n%s", want, resultStr) + } + } + + // Ensure old ports are gone + oldPorts := []string{"8080:8080", "3002:3002", "4000:4000"} + for _, old := range oldPorts { + if strings.Contains(resultStr, old) { + t.Errorf("rewriteComposePorts() result still contains old port %q", old) + } + } + }) + } +} + +func TestRewriteComposePorts_FileNotFound(t *testing.T) { + err := rewriteComposePorts("/nonexistent/path/docker-compose.yml") + if err == nil { + t.Error("rewriteComposePorts() expected error for nonexistent file, got nil") + } + if !strings.Contains(err.Error(), "reading compose file") { + t.Errorf("rewriteComposePorts() error = %v, want error about reading compose file", err) + } +} diff --git a/docs/deploy.md b/docs/deploy.md index 32e4b81..e21f972 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -40,9 +40,11 @@ Wait ~2ā3 minutes for all services to start. | Service | URL | Default Credentials | |---------|-----|---------------------| -| Backend API | http://localhost:8080 | ā | -| Config UI | http://localhost:4000 | ā | -| Grafana | http://localhost:3002 | admin / admin | +| Backend API | http://localhost:8080 or http://localhost:8085 | ā | +| Config UI | http://localhost:4000 or http://localhost:4004 | ā | +| Grafana | http://localhost:3002 or http://localhost:3004 | admin / admin | + +**Port Fallback**: When deploying with `--source official` or `--source fork`, the CLI automatically recovers from port conflicts by retrying with alternate ports (`8085/3004/4004`). Custom deployments require manual port conflict resolution. ### Examples @@ -64,6 +66,14 @@ docker compose up -d - `docker compose up` is NOT run automatically ā this lets you inspect or customize `.env` first. - To tear down: `gh devlake cleanup --local` or `docker compose down` from the target directory. +#### Deployment Resilience + +The CLI includes bounded recovery for common Docker errors: + +- **Port conflicts**: When deploying with official or fork images, the CLI detects port conflicts (patterns: `port is already allocated`, `ports are not available`, `address already in use`, `failed programming external connectivity`) and automatically retries with alternate ports (`8085/3004/4004`). Recovery is bounded to a single retry. +- **Custom deployments**: Port conflicts in custom deployments require manual resolution ā the CLI will identify the conflicting container and suggest remediation commands. +- **State checkpointing**: Deployment state is saved early to enable cleanup even when deployment fails mid-flight. + --- ## deploy azure @@ -89,13 +99,15 @@ gh devlake deploy azure [flags] ### What It Does -1. Checks Azure CLI login (runs `az login` if needed) +1. Checks Azure CLI login (runs `az login` if needed ā **bounded recovery**) 2. Creates the resource group (saves partial state immediately for safe cleanup) 3. Generates MySQL password and encryption secret via Key Vault 4. Optionally builds Docker images and pushes to Azure Container Registry (when `--official` is not set) -5. Deploys infrastructure via Bicep templates (Container Instances + MySQL + Key Vault) -6. Waits for the backend to respond, then triggers DB migration -7. Saves `.devlake-azure.json` state file with endpoints, resource names, and subscription info +5. Checks for stopped MySQL servers and starts them (**bounded recovery**) +6. Checks for soft-deleted Key Vaults and purges them before deployment (**bounded recovery**) +7. Deploys infrastructure via Bicep templates (Container Instances + MySQL + Key Vault) +8. Waits for the backend to respond, then triggers DB migration +9. Saves `.devlake-azure.json` state file with endpoints, resource names, and subscription info ### Cost Estimate @@ -128,6 +140,17 @@ gh devlake deploy azure - Service endpoints are printed at the end of a successful deployment and saved to `.devlake-azure.json`. - The Bicep templates are embedded in the binary ā no external template files needed. +#### Azure Deployment Resilience + +The CLI includes bounded recovery for known Azure failure modes: + +- **Missing authentication**: Automatically runs `az login` when not logged in (single attempt). +- **Stopped MySQL servers**: Detects stopped MySQL Flexible Servers and starts them before deployment (single attempt, non-fatal). +- **Soft-deleted Key Vaults**: Detects and purges soft-deleted Key Vaults that conflict with the deployment (single attempt). +- **State checkpointing**: Partial state file is written immediately after Resource Group creation to enable cleanup even when deployment fails mid-flight. + +All recovery actions are bounded to a single retry and report clear detection ā repair ā outcome messages. + ### Tear Down ```bash From e358254546081a4b43d6bde655556862bdf3260c Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Tue, 17 Mar 2026 01:40:34 +0000 Subject: [PATCH 04/13] Address PR review: Fix port validation, regex boundaries, companion URLs, docs, and terminal output Co-authored-by: ewega <26189114+ewega@users.noreply.github.com> --- cmd/deploy_errors.go | 37 ++++++++++++++--------- cmd/deploy_errors_test.go | 15 ++++++++++ cmd/deploy_local.go | 63 ++++++++++++++++++++++----------------- cmd/deploy_local_test.go | 16 ++++++++++ docs/deploy.md | 7 +++-- 5 files changed, 94 insertions(+), 44 deletions(-) diff --git a/cmd/deploy_errors.go b/cmd/deploy_errors.go index 0685306..29424d7 100644 --- a/cmd/deploy_errors.go +++ b/cmd/deploy_errors.go @@ -101,7 +101,10 @@ func extractPortFromError(errStr string) string { if idx := strings.Index(lowerStr, "bind for 0.0.0.0:"); idx != -1 { rest := errStr[idx+len("bind for 0.0.0.0:"):] if end := strings.IndexAny(rest, " :\n"); end > 0 { - return rest[:end] + port := rest[:end] + if isValidPort(port) { + return port + } } } @@ -109,7 +112,10 @@ func extractPortFromError(errStr string) string { if idx := strings.Index(errStr, "0.0.0.0:"); idx != -1 { rest := errStr[idx+len("0.0.0.0:"):] if end := strings.IndexAny(rest, " ->\n"); end > 0 { - return rest[:end] + port := rest[:end] + if isValidPort(port) { + return port + } } } @@ -121,14 +127,20 @@ func extractPortFromError(errStr string) string { rest = rest[1:] } if end := strings.IndexAny(rest, " )\n"); end > 0 { - return rest[:end] + port := rest[:end] + if isValidPort(port) { + return port + } } // If no delimiter found, but there are digits, use them if len(rest) > 0 { for i, ch := range rest { if ch < '0' || ch > '9' { if i > 0 { - return rest[:i] + port := rest[:i] + if isValidPort(port) { + return port + } } break } @@ -186,14 +198,12 @@ func isValidPort(s string) bool { return false } } - // Basic range check (ports are 1-65535) - if len(s) == 5 { - // Quick check: if > 65535, invalid - if s > "65535" { - return false - } + // Parse to int and validate range 1-65535 + port := 0 + for _, ch := range s { + port = port*10 + int(ch-'0') } - return true + return port >= 1 && port <= 65535 } // findPortOwner queries Docker to find which container is using the specified port. @@ -249,11 +259,10 @@ func findPortOwner(port string) (string, string) { // printDockerPortConflictError prints a user-friendly error message for port conflicts // with actionable remediation steps. func printDockerPortConflictError(de *DeployError) { - fmt.Println() if de.Port != "" { - fmt.Printf("ā Port conflict detected: port %s is already in use.\n", de.Port) + fmt.Printf("\nā Port conflict detected: port %s is already in use.\n", de.Port) } else { - fmt.Println("ā Port conflict detected: a required port is already in use.") + fmt.Println("\nā Port conflict detected: a required port is already in use.") } if de.Container != "" { diff --git a/cmd/deploy_errors_test.go b/cmd/deploy_errors_test.go index 30cb081..3ffc83e 100644 --- a/cmd/deploy_errors_test.go +++ b/cmd/deploy_errors_test.go @@ -61,6 +61,21 @@ func TestExtractPortFromError(t *testing.T) { errStr: "", wantPort: "", }, + { + name: "port 0 should be rejected", + errStr: "Error response from daemon: Bind for 0.0.0.0:0 failed", + wantPort: "", + }, + { + name: "port 00000 should be rejected", + errStr: "Error response from daemon: Bind for 0.0.0.0:00000 failed", + wantPort: "", + }, + { + name: "port 65536 should be rejected", + errStr: "Error response from daemon: Bind for 0.0.0.0:65536 failed", + wantPort: "", + }, } for _, tt := range tests { diff --git a/cmd/deploy_local.go b/cmd/deploy_local.go index 7fa0339..5caa60f 100644 --- a/cmd/deploy_local.go +++ b/cmd/deploy_local.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "regexp" "strings" "time" @@ -215,8 +216,10 @@ func runDeployLocal(cmd *cobra.Command, args []string) error { if !deployLocalQuiet { printBanner("ā DevLake is running!") fmt.Printf("\n Backend API: %s\n", backendURL) - fmt.Println(" Config UI: http://localhost:4000") - fmt.Println(" Grafana: http://localhost:3002 (admin/admin)") + // Infer companion URLs based on which backend port responded + grafanaURL, configUIURL := inferCompanionURLs(backendURL) + fmt.Printf(" Config UI: %s\n", configUIURL) + fmt.Printf(" Grafana: %s (admin/admin)\n", grafanaURL) fmt.Println("\nTo stop/remove DevLake:") fmt.Printf(" cd \"%s\" && gh devlake cleanup\n", absDir) } @@ -465,8 +468,7 @@ func startLocalContainers(dir string, build, allowPortFallback bool, services .. } // Bounded recovery: Try alternate port bundle once - fmt.Println() - fmt.Printf("š§ Port conflict detected on default ports (8080/3002/4000)\n") + fmt.Println("\nš§ Port conflict detected on default ports (8080/3002/4000)") if deployErr.Port != "" { fmt.Printf(" Port %s is in use", deployErr.Port) if deployErr.Container != "" { @@ -474,8 +476,7 @@ func startLocalContainers(dir string, build, allowPortFallback bool, services .. } fmt.Println() } - fmt.Println() - fmt.Println("š Retrying with alternate ports (8085/3004/4004)...") + fmt.Println("\nš Retrying with alternate ports (8085/3004/4004)...") // Rewrite port mappings in docker-compose.yml or docker-compose-dev.yml composePath := filepath.Join(absDir, "docker-compose.yml") @@ -530,6 +531,7 @@ func waitAndDetectBackendURL(dir string) (string, error) { // rewriteComposePorts rewrites the port mappings in a docker-compose.yml file // from the default bundle (8080/3002/4000) to the alternate bundle (8085/3004/4004). +// Uses regex with proper boundaries to avoid rewriting custom ports like 18080:8080. func rewriteComposePorts(composePath string) error { data, err := os.ReadFile(composePath) if err != nil { @@ -537,31 +539,26 @@ func rewriteComposePorts(composePath string) error { } content := string(data) + modified := content - // Port mapping patterns: - // - "8080:8080" -> "8085:8080" (external:internal) - // - "3002:3002" -> "3004:3002" - // - "4000:4000" -> "4004:4000" - portMappings := map[string]string{ - "8080:8080": "8085:8080", - "- 8080:8080": "- 8085:8080", - "\"8080:8080\"": "\"8085:8080\"", - "'8080:8080'": "'8085:8080'", - - "3002:3002": "3004:3002", - "- 3002:3002": "- 3004:3002", - "\"3002:3002\"": "\"3004:3002\"", - "'3002:3002'": "'3004:3002'", - - "4000:4000": "4004:4000", - "- 4000:4000": "- 4004:4000", - "\"4000:4000\"": "\"4004:4000\"", - "'4000:4000'": "'4004:4000'", + // Port mapping patterns with regex boundaries + // Match: "- 8080:8080" or "- "8080:8080"" or "- '8080:8080'" at start of list item + // Avoid: "- 18080:8080" (custom host port that contains 8080) + portReplacements := []struct { + pattern string + replacement string + }{ + // Backend: 8080:8080 -> 8085:8080 + {`(?m)(^\s*-\s*)["']?8080:8080["']?`, `${1}8085:8080`}, + // Grafana: 3002:3002 -> 3004:3002 + {`(?m)(^\s*-\s*)["']?3002:3002["']?`, `${1}3004:3002`}, + // Config UI: 4000:4000 -> 4004:4000 + {`(?m)(^\s*-\s*)["']?4000:4000["']?`, `${1}4004:4000`}, } - modified := content - for old, new := range portMappings { - modified = strings.ReplaceAll(modified, old, new) + for _, repl := range portReplacements { + re := regexp.MustCompile(repl.pattern) + modified = re.ReplaceAllString(modified, repl.replacement) } if modified == content { @@ -574,3 +571,13 @@ func rewriteComposePorts(composePath string) error { return nil } + +// inferCompanionURLs returns the Grafana and Config UI URLs based on the backend URL. +// Backend on 8080 -> Grafana on 3002, Config UI on 4000 +// Backend on 8085 -> Grafana on 3004, Config UI on 4004 +func inferCompanionURLs(backendURL string) (grafanaURL, configUIURL string) { + if strings.Contains(backendURL, ":8085") { + return "http://localhost:3004", "http://localhost:4004" + } + return "http://localhost:3002", "http://localhost:4000" +} diff --git a/cmd/deploy_local_test.go b/cmd/deploy_local_test.go index 973cc97..412b385 100644 --- a/cmd/deploy_local_test.go +++ b/cmd/deploy_local_test.go @@ -145,6 +145,22 @@ services: `, wantErr: true, // No changes made }, + { + name: "custom host port should not be rewritten", + input: `version: '3' +services: + devlake: + ports: + - 18080:8080 + grafana: + ports: + - 13002:3002 + config-ui: + ports: + - 14000:4000 +`, + wantErr: true, // No matching ports to rewrite + }, } for _, tt := range tests { diff --git a/docs/deploy.md b/docs/deploy.md index e21f972..1760a7f 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -18,6 +18,9 @@ gh devlake deploy local [flags] |------|---------|-------------| | `--dir` | `.` | Target directory for Docker Compose files | | `--version` | `latest` | DevLake release version (e.g., `v1.0.2`) | +| `--source` | *(interactive if omitted)* | Image source: `official`, `fork`, or `custom` | +| `--repo-url` | | Repository URL to clone (for `fork` source) | +| `--start` | `true` | Start containers after setup | ### What It Does @@ -70,8 +73,8 @@ docker compose up -d The CLI includes bounded recovery for common Docker errors: -- **Port conflicts**: When deploying with official or fork images, the CLI detects port conflicts (patterns: `port is already allocated`, `ports are not available`, `address already in use`, `failed programming external connectivity`) and automatically retries with alternate ports (`8085/3004/4004`). Recovery is bounded to a single retry. -- **Custom deployments**: Port conflicts in custom deployments require manual resolution ā the CLI will identify the conflicting container and suggest remediation commands. +- **Port conflicts**: When deploying with `--source official` or `--source fork`, the CLI detects port conflicts (patterns: `port is already allocated`, `bind for`, `ports are not available`, `address already in use`, `failed programming external connectivity`) and automatically retries with alternate ports (`8085/3004/4004`). Recovery is bounded to a single retry. +- **Custom deployments**: Port conflicts in `--source custom` deployments require manual resolution ā the CLI will identify the conflicting container and suggest remediation commands. - **State checkpointing**: Deployment state is saved early to enable cleanup even when deployment fails mid-flight. --- From 2aa3731399eafc6b55135498355a9ef31154a47e Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Tue, 17 Mar 2026 01:50:43 +0000 Subject: [PATCH 05/13] Fix port-fallback detection and terminal output formatting - Add composeFileHasDefaultPorts() to detect if compose file is already on alternate bundle - Skip rewrite and show proper error when conflict is already on alternate ports (8085/3004/4004) - Fix emoji-prefixed step to include leading newline per terminal output standards - Fix typo in comment (extra quote) Co-authored-by: ewega <26189114+ewega@users.noreply.github.com> --- cmd/deploy_local.go | 59 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 50 insertions(+), 9 deletions(-) diff --git a/cmd/deploy_local.go b/cmd/deploy_local.go index 5caa60f..289fcdc 100644 --- a/cmd/deploy_local.go +++ b/cmd/deploy_local.go @@ -468,6 +468,30 @@ func startLocalContainers(dir string, build, allowPortFallback bool, services .. } // Bounded recovery: Try alternate port bundle once + // Find compose file + composePath := filepath.Join(absDir, "docker-compose.yml") + if _, err := os.Stat(composePath); os.IsNotExist(err) { + composePath = filepath.Join(absDir, "docker-compose-dev.yml") + } + + // Check if compose file is already on alternate ports + if !composeFileHasDefaultPorts(composePath) { + // Compose file is already on alternate ports (8085/3004/4004) + fmt.Println("\nā Port conflict detected on alternate ports (8085/3004/4004)") + if deployErr.Port != "" { + fmt.Printf(" Port %s is in use", deployErr.Port) + if deployErr.Container != "" { + fmt.Printf(" by container: %s", deployErr.Container) + } + fmt.Println() + } + printDockerPortConflictError(deployErr) + fmt.Println("\n Both default (8080/3002/4000) and alternate (8085/3004/4004) port bundles are occupied.") + fmt.Println(" Free at least one bundle, then retry deployment.") + return "", fmt.Errorf("port conflict on alternate ports") + } + + // Compose file has default ports - try rewriting to alternate bundle fmt.Println("\nš§ Port conflict detected on default ports (8080/3002/4000)") if deployErr.Port != "" { fmt.Printf(" Port %s is in use", deployErr.Port) @@ -478,12 +502,6 @@ func startLocalContainers(dir string, build, allowPortFallback bool, services .. } fmt.Println("\nš Retrying with alternate ports (8085/3004/4004)...") - // Rewrite port mappings in docker-compose.yml or docker-compose-dev.yml - composePath := filepath.Join(absDir, "docker-compose.yml") - if _, err := os.Stat(composePath); os.IsNotExist(err) { - composePath = filepath.Join(absDir, "docker-compose-dev.yml") - } - if err := rewriteComposePorts(composePath); err != nil { fmt.Printf(" ā ļø Could not rewrite ports: %v\n", err) printDockerPortConflictError(deployErr) @@ -499,8 +517,7 @@ func startLocalContainers(dir string, build, allowPortFallback bool, services .. // Second attempt failed - classify again retryErr := classifyDockerComposeError(err) if retryErr != nil && retryErr.Class == ErrorClassDockerPortConflict { - fmt.Println() - fmt.Println("ā Alternate ports are also in use.") + fmt.Println("\nā Alternate ports are also in use.") printDockerPortConflictError(retryErr) fmt.Println("\n Both default (8080/3002/4000) and alternate (8085/3004/4004) port bundles are occupied.") fmt.Println(" Free at least one bundle, then retry deployment.") @@ -529,6 +546,30 @@ func waitAndDetectBackendURL(dir string) (string, error) { return backendURL, nil } +// composeFileHasDefaultPorts checks if a compose file contains the default port bundle (8080/3002/4000). +// Returns true if any of the default ports are found, false if the file is already on alternate ports. +func composeFileHasDefaultPorts(composePath string) bool { + data, err := os.ReadFile(composePath) + if err != nil { + return false // Assume default if we can't read + } + + content := string(data) + // Check if any default port mapping is present + defaultPatterns := []string{ + `8080:8080`, + `3002:3002`, + `4000:4000`, + } + + for _, pattern := range defaultPatterns { + if strings.Contains(content, pattern) { + return true + } + } + return false +} + // rewriteComposePorts rewrites the port mappings in a docker-compose.yml file // from the default bundle (8080/3002/4000) to the alternate bundle (8085/3004/4004). // Uses regex with proper boundaries to avoid rewriting custom ports like 18080:8080. @@ -542,7 +583,7 @@ func rewriteComposePorts(composePath string) error { modified := content // Port mapping patterns with regex boundaries - // Match: "- 8080:8080" or "- "8080:8080"" or "- '8080:8080'" at start of list item + // Match: "- 8080:8080" or "- "8080:8080" or "- '8080:8080'" at start of list item // Avoid: "- 18080:8080" (custom host port that contains 8080) portReplacements := []struct { pattern string From c9b44019c9d1c2ddf6b582553a6b8b1b8b5a398a Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Tue, 17 Mar 2026 02:13:11 +0000 Subject: [PATCH 06/13] Fix deploy-time error recovery messaging and docs - Reconcile docs/deploy.md with --start=true default behavior - Fix composeFileHasDefaultPorts to return true on read error (surfaces I/O errors deterministically) - Adjust alternate-port conflict message to reflect only what is known (no false "both bundles occupied" claim) - Remove leading newlines from sub-items in deploy_local.go and deploy_errors.go per terminal output standards Co-authored-by: ewega <26189114+ewega@users.noreply.github.com> --- cmd/deploy_errors.go | 8 ++++---- cmd/deploy_local.go | 8 ++++---- docs/deploy.md | 14 +++++++------- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/cmd/deploy_errors.go b/cmd/deploy_errors.go index 29424d7..cd9c918 100644 --- a/cmd/deploy_errors.go +++ b/cmd/deploy_errors.go @@ -269,18 +269,18 @@ func printDockerPortConflictError(de *DeployError) { fmt.Printf(" Container holding the port: %s\n", de.Container) if de.ComposeFile != "" { - fmt.Println("\n Stop it with:") + fmt.Println(" Stop it with:") fmt.Printf(" docker compose -f \"%s\" down\n", de.ComposeFile) } else { - fmt.Println("\n Stop it with:") + fmt.Println(" Stop it with:") fmt.Printf(" docker stop %s\n", de.Container) } } else if de.Port != "" { - fmt.Println("\n Find what's using it:") + fmt.Println(" Find what's using it:") fmt.Println(" docker ps --format \"table {{.Names}}\\t{{.Ports}}\"") } - fmt.Println("\n Then re-run:") + fmt.Println(" Then re-run:") fmt.Println(" gh devlake deploy local") fmt.Println("\nš” To clean up partial artifacts:") fmt.Println(" gh devlake cleanup --local --force") diff --git a/cmd/deploy_local.go b/cmd/deploy_local.go index 289fcdc..03dfda4 100644 --- a/cmd/deploy_local.go +++ b/cmd/deploy_local.go @@ -486,8 +486,8 @@ func startLocalContainers(dir string, build, allowPortFallback bool, services .. fmt.Println() } printDockerPortConflictError(deployErr) - fmt.Println("\n Both default (8080/3002/4000) and alternate (8085/3004/4004) port bundles are occupied.") - fmt.Println(" Free at least one bundle, then retry deployment.") + fmt.Println("\n The alternate port bundle is already in use.") + fmt.Println(" Free ports 8085/3004/4004, then retry deployment.") return "", fmt.Errorf("port conflict on alternate ports") } @@ -511,7 +511,7 @@ func startLocalContainers(dir string, build, allowPortFallback bool, services .. fmt.Println(" ā Ports updated in compose file") // Attempt 2: Retry with alternate ports - fmt.Println("\n Starting containers with alternate ports...") + fmt.Println(" Starting containers with alternate ports...") err = dockerpkg.ComposeUp(absDir, build, services...) if err != nil { // Second attempt failed - classify again @@ -551,7 +551,7 @@ func waitAndDetectBackendURL(dir string) (string, error) { func composeFileHasDefaultPorts(composePath string) bool { data, err := os.ReadFile(composePath) if err != nil { - return false // Assume default if we can't read + return true // Assume default if we can't read - let rewriteComposePorts surface the I/O error } content := string(data) diff --git a/docs/deploy.md b/docs/deploy.md index 1760a7f..c20e43c 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -32,13 +32,15 @@ gh devlake deploy local [flags] ### After Running +By default (`--start=true`), containers start automatically and you can access the endpoints immediately (wait ~2ā3 minutes for all services to initialize). + +To stage files without starting containers, use `--start=false`, then run: + ```bash cd