Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
f84a609
Initial plan
Claude Mar 17, 2026
a14bd06
Add error classification and bounded port fallback for local deploy
Claude Mar 17, 2026
86a3eb0
Add port rewriting tests, normalize Azure recovery, update docs
Claude Mar 17, 2026
e358254
Address PR review: Fix port validation, regex boundaries, companion U…
Claude Mar 17, 2026
2aa3731
Fix port-fallback detection and terminal output formatting
Claude Mar 17, 2026
c9b4401
Fix deploy-time error recovery messaging and docs
Claude Mar 17, 2026
714f39b
Fix extractPortFromError byte-slice bug and add custom port bundle de…
Claude Mar 17, 2026
52de0b1
Update deploy local docs intro to reflect auto-start default
Claude Mar 17, 2026
33d2e45
Fix custom-port readiness check and docs; update detectPortBundle com…
Claude Mar 17, 2026
9d996ca
Refactor port-conflict error handling to eliminate duplication and pr…
Claude Mar 17, 2026
1ee34da
Align deploy local help/docs and cover port fallback
ewega Mar 17, 2026
ce9a0f5
fix deploy local port conflict messaging
ewega Mar 17, 2026
11ef152
Fix compose file detection and docs consistency
Claude Mar 17, 2026
0df339b
Fix status docs and error wrapping consistency
Claude Mar 22, 2026
c517bca
Remove unused composeFileHasDefaultPorts helper
Claude Mar 24, 2026
afdb558
Clarify compose file references and URL mappings
Claude Mar 25, 2026
78c70a3
Add backup/atomic writes and improve port-conflict UX
Claude Mar 25, 2026
30e443a
Clarify auto-start as default in deploy local docs
Claude Mar 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions cmd/deploy_azure.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,14 +144,17 @@ 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)
}
acct, err = azure.CheckLogin()
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)

Expand Down Expand Up @@ -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)
Expand All @@ -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 ──
Expand Down
278 changes: 278 additions & 0 deletions cmd/deploy_errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
package cmd

Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file appears to have Windows CRLF line endings (e.g., lines show trailing \r in searches). The repo is predominantly LF; please convert this file to LF to avoid noisy diffs and potential tooling issues on Unix environments.

Copilot uses AI. Check for mistakes.
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")
}
Loading
Loading