Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,13 @@ gh devlake deploy local --dir ./devlake
gh devlake configure full
```

After setup, open Grafana at **http://localhost:3002** (admin / admin). DORA and Copilot dashboards will populate after the first sync completes.
After setup, open the URL bundle printed by `gh devlake deploy local`. Local deploys normally use `8080/4000/3002`, and automatically fall back once to `8085/4004/3004` when the default ports are already in use. DORA and Copilot dashboards will populate after the first sync completes.
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

The port bundle ordering here (8080/4000/3002 and 8085/4004/3004) is inconsistent with other docs/help text that use 8080/3002/4000 and 8085/3004/4004. Consider using the same ordering everywhere to reduce user confusion.

Suggested change
After setup, open the URL bundle printed by `gh devlake deploy local`. Local deploys normally use `8080/4000/3002`, and automatically fall back once to `8085/4004/3004` when the default ports are already in use. DORA and Copilot dashboards will populate after the first sync completes.
After setup, open the URL bundle printed by `gh devlake deploy local`. Local deploys normally use `8080/3002/4000`, and automatically fall back once to `8085/3004/4004` when the default ports are already in use. DORA and Copilot dashboards will populate after the first sync completes.

Copilot uses AI. Check for mistakes.

| Service | URL |
|---------|-----|
| Grafana | http://localhost:3002 (admin/admin) |
| Config UI | http://localhost:4000 |
| Backend API | http://localhost:8080 |
| Grafana | http://localhost:3002 or http://localhost:3004 (admin/admin) |
| Config UI | http://localhost:4000 or http://localhost:4004 |
| Backend API | http://localhost:8080 or http://localhost:8085 |

---

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

import (
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"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 using regexp)
re := regexp.MustCompile(`(?i)bind for 0\.0\.0\.0:(\d+)`)
if matches := re.FindStringSubmatch(errStr); len(matches) > 1 {
port := matches[1]
if isValidPort(port) {
return port
}
}

// 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 {
port := rest[:end]
if isValidPort(port) {
return port
}
}
}

// 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 {
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 {
port := rest[:i]
if isValidPort(port) {
return port
}
}
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
}
}
// Parse to int and validate range 1-65535
port := 0
for _, ch := range s {
port = port*10 + int(ch-'0')
}
return port >= 1 && port <= 65535
}

// 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.
// If customHeader is provided, it replaces the default "Port conflict detected" header.
// If nextSteps is provided, it replaces the default "Then re-run: gh devlake deploy local" text.
func printDockerPortConflictError(de *DeployError, customHeader string, nextSteps string) {
// Print header
if customHeader != "" {
// Normalize header to ensure consistent spacing (blank line before emoji-prefixed steps)
normalizedHeader := customHeader
if !strings.HasPrefix(normalizedHeader, "\n") {
normalizedHeader = "\n" + normalizedHeader
}
fmt.Println(normalizedHeader)
} else {
if de.Port != "" {
fmt.Printf("\n❌ Port conflict detected: port %s is already in use.\n", de.Port)
} else {
fmt.Println("\n❌ Port conflict detected: a required port is already in use.")
}
}

// Print container info and stop commands
if de.Container != "" {
fmt.Printf(" Container holding the port: %s\n", de.Container)

if de.ComposeFile != "" {
fmt.Println(" Stop it with:")
fmt.Printf(" docker compose -f \"%s\" down\n", de.ComposeFile)
} else {
fmt.Println(" Stop it with:")
fmt.Printf(" docker stop %s\n", de.Container)
}
} else if de.Port != "" {
fmt.Println(" Find what's using it:")
fmt.Println(" docker ps --format \"table {{.Names}}\\t{{.Ports}}\"")
}

// Print next steps
if nextSteps != "" {
fmt.Println(nextSteps)
} else {
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")
}
Loading
Loading