Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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.

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

import (
"fmt"
"os"
Comment on lines +1 to +5
"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
}
Comment on lines +189 to +205

// 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 != "" {
fmt.Println(customHeader)
} 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