From 6248b5c803a6458975154bc09ff383517413354e Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Tue, 24 Mar 2026 21:04:12 +0000 Subject: [PATCH 01/11] Initial plan From 8d8811304d913740799f480d15738c9a08cdd7b5 Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Tue, 24 Mar 2026 21:12:45 +0000 Subject: [PATCH 02/11] Implement deployment recovery and improve error messages - Add detection of existing local deployments with resume/restart/abort options - Add detection of existing Azure deployments with intelligent recovery - Preserve existing ENCRYPTION_SECRET and .env files when resuming - Improve Docker availability error with step-by-step recovery guide - Enhance DevLake readiness timeout error with troubleshooting steps - Improve Azure login and Bicep deployment failure messages - Export PingURL function for deployment status checks Co-authored-by: ewega <26189114+ewega@users.noreply.github.com> Agent-Logs-Url: https://github.com/DevExpGbb/gh-devlake/sessions/40eca0b0-6be0-4722-8bd1-148642db1d45 --- cmd/deploy_azure.go | 120 ++++++++++++++++++++++++++++ cmd/deploy_local.go | 142 ++++++++++++++++++++++++++++++++-- internal/devlake/discovery.go | 5 ++ 3 files changed, 262 insertions(+), 5 deletions(-) diff --git a/cmd/deploy_azure.go b/cmd/deploy_azure.go index 14e9a12..d067bb5 100644 --- a/cmd/deploy_azure.go +++ b/cmd/deploy_azure.go @@ -10,6 +10,7 @@ import ( "time" "github.com/DevExpGBB/gh-devlake/internal/azure" + "github.com/DevExpGBB/gh-devlake/internal/devlake" dockerpkg "github.com/DevExpGBB/gh-devlake/internal/docker" "github.com/DevExpGBB/gh-devlake/internal/gitclone" "github.com/DevExpGBB/gh-devlake/internal/prompt" @@ -73,6 +74,27 @@ func runDeployAzure(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to create directory %s: %w", deployAzureDir, err) } + // โ”€โ”€ Check for existing Azure deployment โ”€โ”€ + if existingState, resumeAction := detectExistingAzureDeployment(deployAzureDir); existingState != nil { + switch resumeAction { + case "abort": + return nil + case "restart": + fmt.Println("\n๐Ÿงน Cleaning up existing Azure deployment...") + fmt.Println(" Note: This will delete all Azure resources in the resource group") + if !prompt.Confirm("Proceed with cleanup?") { + return nil + } + // User should run cleanup manually - we don't want to auto-delete Azure resources + fmt.Println("\n Please run: gh devlake cleanup --azure") + fmt.Println(" Then re-run: gh devlake deploy azure") + return nil + case "resume": + // Continue with the deployment - may update existing resources + fmt.Println("\n Continuing with deployment (will update existing resources)...") + } + } + // โ”€โ”€ Interactive image-source prompt (when no explicit flag set) โ”€โ”€ if !cmd.Flags().Changed("official") && !cmd.Flags().Changed("repo-url") { imageChoices := []string{ @@ -146,10 +168,21 @@ func runDeployAzure(cmd *cobra.Command, args []string) error { if err != nil { fmt.Println(" Not logged in. Running az login...") if loginErr := azure.Login(); loginErr != nil { + fmt.Println("\n๐Ÿ’ก Azure CLI login failed") + fmt.Println(" Recovery steps:") + fmt.Println(" 1. Install Azure CLI: https://docs.microsoft.com/cli/azure/install-azure-cli") + fmt.Println(" 2. Run: az login") + fmt.Println(" 3. Follow the browser authentication flow") + fmt.Println(" 4. Re-run: gh devlake deploy azure") return fmt.Errorf("az login failed: %w", loginErr) } acct, err = azure.CheckLogin() if err != nil { + fmt.Println("\n๐Ÿ’ก Still not authenticated after login") + fmt.Println(" Try:") + fmt.Println(" โ€ข Run 'az account list' to see your subscriptions") + fmt.Println(" โ€ข Run 'az account set --subscription ' if needed") + fmt.Println(" โ€ข Check Azure CLI version: az --version") return fmt.Errorf("still not logged in after az login: %w", err) } } @@ -289,6 +322,16 @@ func runDeployAzure(cmd *cobra.Command, args []string) error { deployment, err := azure.DeployBicep(azureRG, templatePath, params) if err != nil { + fmt.Println("\nโŒ Bicep deployment failed") + fmt.Println("\n๐Ÿ’ก Troubleshooting steps:") + fmt.Println(" 1. Check Azure portal for deployment details:") + fmt.Printf(" https://portal.azure.com/#blade/HubsExtension/DeploymentDetailsBlade/resourceGroup/%s\n", azureRG) + fmt.Println(" 2. Check if quota limits were exceeded in your subscription") + fmt.Println(" 3. Verify the resource group location supports all required services") + fmt.Println(" 4. Check for service principal or permission issues") + fmt.Println("\n To retry:") + fmt.Println(" โ€ข If partial deployment exists, re-run will attempt to continue") + fmt.Println(" โ€ข To start fresh: gh devlake cleanup --azure, then deploy again") return fmt.Errorf("Bicep deployment failed: %w", err) } @@ -438,3 +481,80 @@ func savePartialAzureState(rg, region string) { fmt.Fprintf(os.Stderr, "โš ๏ธ Could not save early state checkpoint: %v\n", err) } } + +// detectExistingAzureDeployment checks for existing Azure deployment state and prompts for action. +// Returns any existing state data and the user's choice: "resume", "restart", or "abort". +func detectExistingAzureDeployment(dir string) (map[string]any, string) { + if deployAzureQuiet { + // When called from init wizard, don't prompt + return nil, "" + } + + absDir, _ := filepath.Abs(dir) + stateFile := filepath.Join(absDir, ".devlake-azure.json") + + // Check for state file + data, err := os.ReadFile(stateFile) + if err != nil { + // No state file found - proceed normally + return nil, "" + } + + var state map[string]any + if err := json.Unmarshal(data, &state); err != nil { + // State file is corrupted - warn and proceed + fmt.Printf("\nโš ๏ธ Found .devlake-azure.json but could not parse it: %v\n", err) + return nil, "" + } + + // Display existing deployment info + fmt.Println("\n๐Ÿ“‹ Found existing Azure deployment:") + if deployedAt, ok := state["deployedAt"].(string); ok { + fmt.Printf(" Deployed: %s\n", deployedAt) + } + if rg, ok := state["resourceGroup"].(string); ok { + fmt.Printf(" Resource Group: %s\n", rg) + } + if region, ok := state["region"].(string); ok { + fmt.Printf(" Region: %s\n", region) + } + + // Check if this is a partial deployment (failed mid-way) + isPartial := false + if partial, ok := state["partial"].(bool); ok && partial { + fmt.Println(" Status: โš ๏ธ Partial deployment (may have failed)") + isPartial = true + } + + // Check if endpoints are available and reachable + if endpoints, ok := state["endpoints"].(map[string]any); ok { + if backend, ok := endpoints["backend"].(string); ok && backend != "" { + fmt.Printf(" Backend: %s\n", backend) + if err := devlake.PingURL(backend); err == nil { + fmt.Println(" Status: โœ… Running") + } else { + fmt.Println(" Status: โš ๏ธ Not responding (may be stopped)") + } + } + } + + fmt.Println() + choices := []string{ + "resume - Continue/update existing deployment", + "restart - Clean up and start fresh (requires manual cleanup)", + "abort - Exit without making changes", + } + + if isPartial { + // For partial deployments, recommend resume + choices[0] = "resume - Continue deployment from where it failed (recommended)" + } + + choice := prompt.Select("What would you like to do?", choices) + if choice == "" { + return state, "abort" + } + + action := strings.SplitN(choice, " ", 2)[0] + return state, action +} diff --git a/cmd/deploy_local.go b/cmd/deploy_local.go index 917282c..1378448 100644 --- a/cmd/deploy_local.go +++ b/cmd/deploy_local.go @@ -71,6 +71,23 @@ func runDeployLocal(cmd *cobra.Command, args []string) error { } } + // โ”€โ”€ Check for existing deployment โ”€โ”€ + if existingState, resumeAction := detectExistingLocalDeployment(deployLocalDir); existingState != nil { + switch resumeAction { + case "abort": + return nil + case "restart": + fmt.Println("\n๐Ÿงน Cleaning up existing deployment...") + if err := cleanupLocalQuiet(deployLocalDir); err != nil { + fmt.Printf(" โš ๏ธ Cleanup encountered issues: %v\n", err) + fmt.Println(" Continuing with deployment...") + } + case "resume": + // Continue with the deployment - existing artifacts will be reused + fmt.Println("\n Continuing with existing deployment artifacts...") + } + } + // โ”€โ”€ Interactive image-source prompt (when no explicit flag set) โ”€โ”€ if deployLocalSource == "" { imageChoices := []string{ @@ -177,9 +194,12 @@ func runDeployLocal(cmd *cobra.Command, args []string) error { fmt.Println("\n๐Ÿณ Checking Docker...") if err := dockerpkg.CheckAvailable(); err != nil { fmt.Println(" โŒ Docker not found or not running") - fmt.Println(" Install Docker Desktop: https://docs.docker.com/get-docker") - fmt.Println(" Start Docker Desktop, then re-run: gh devlake deploy local") - return fmt.Errorf("Docker is not available โ€” start Docker Desktop and retry") + fmt.Println("\n๐Ÿ’ก Recovery steps:") + fmt.Println(" 1. Install Docker Desktop: https://docs.docker.com/get-docker") + fmt.Println(" 2. Start Docker Desktop and wait for it to fully initialize") + fmt.Println(" 3. Verify Docker is running: docker ps") + fmt.Println(" 4. Re-run this command: gh devlake deploy local") + return fmt.Errorf("Docker is not available โ€” follow recovery steps above") } fmt.Println(" โœ… Docker found") @@ -518,7 +538,7 @@ func startLocalContainers(dir string, build bool, services ...string) (string, e fmt.Println(" docker ps --format \"table {{.Names}}\\t{{.Ports}}\"") } fmt.Println("\n Then re-run:") - fmt.Println(" gh devlake init") + fmt.Println(" gh devlake deploy local") 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") @@ -536,7 +556,119 @@ func startLocalContainers(dir string, build bool, services ...string) (string, e backendURL, err := waitForReadyAny(backendURLCandidates, 36, 10*time.Second) if err != nil { - return "", fmt.Errorf("DevLake not ready after 6 minutes โ€” check: docker compose logs devlake: %w", err) + fmt.Println("\nโŒ DevLake not ready after 6 minutes") + fmt.Println("\n๐Ÿ’ก Troubleshooting steps:") + fmt.Printf(" 1. Check container logs: docker compose -f \"%s/docker-compose.yml\" logs devlake\n", absDir) + fmt.Println(" 2. Verify all containers are running: docker compose ps") + fmt.Println(" 3. Check MySQL initialization: docker compose logs mysql") + fmt.Println(" 4. If containers keep restarting, check: docker compose logs") + fmt.Println("\n Common issues:") + fmt.Println(" โ€ข MySQL takes longer on first run (database initialization)") + fmt.Println(" โ€ข Insufficient Docker resources (increase memory in Docker Desktop settings)") + fmt.Println(" โ€ข Port conflicts (check docker compose logs for 'address already in use')") + return "", fmt.Errorf("DevLake not ready โ€” check logs for details") } return backendURL, nil } + +// detectExistingLocalDeployment checks for existing deployment artifacts and prompts for action. +// Returns the existing state (if found) and the user's choice: "resume", "restart", or "abort". +func detectExistingLocalDeployment(dir string) (*devlake.State, string) { + if deployLocalQuiet { + // When called from init wizard, don't prompt + return nil, "" + } + + absDir, _ := filepath.Abs(dir) + stateFile := filepath.Join(absDir, ".devlake-local.json") + + // Check for state file + state, err := devlake.LoadState(stateFile) + if err != nil || state == nil { + // No state file or failed to load - check for docker-compose.yml + .env + composePath := filepath.Join(absDir, "docker-compose.yml") + devComposePath := filepath.Join(absDir, "docker-compose-dev.yml") + envPath := filepath.Join(absDir, ".env") + + hasCompose := false + if _, err := os.Stat(composePath); err == nil { + hasCompose = true + } else if _, err := os.Stat(devComposePath); err == nil { + hasCompose = true + } + + hasEnv := false + if _, err := os.Stat(envPath); err == nil { + hasEnv = true + } + + // If we have artifacts but no state file, it might be a partial deployment + if hasCompose || hasEnv { + fmt.Println("\n๐Ÿ“‹ Found existing deployment artifacts:") + if hasCompose { + fmt.Println(" โ€ข docker-compose.yml") + } + if hasEnv { + fmt.Println(" โ€ข .env file") + } + } else { + // No artifacts found - proceed normally + return nil, "" + } + } else { + // State file exists - check if deployment is running + fmt.Println("\n๐Ÿ“‹ Found existing deployment:") + fmt.Printf(" Deployed: %s\n", state.DeployedAt) + if state.Endpoints.Backend != "" { + fmt.Printf(" Backend: %s\n", state.Endpoints.Backend) + + // Check if backend is still running + if err := devlake.PingURL(state.Endpoints.Backend); err == nil { + fmt.Println(" Status: โœ… Running") + } else { + fmt.Println(" Status: โš ๏ธ Not responding (may be stopped)") + } + } + } + + fmt.Println() + choices := []string{ + "resume - Continue with existing artifacts (recommended for recovery)", + "restart - Clean up and start fresh", + "abort - Exit without making changes", + } + choice := prompt.Select("What would you like to do?", choices) + if choice == "" { + return state, "abort" + } + + action := strings.SplitN(choice, " ", 2)[0] + return state, action +} + +// cleanupLocalQuiet performs cleanup of local deployment without prompts (used for restart). +func cleanupLocalQuiet(dir string) error { + absDir, _ := filepath.Abs(dir) + + // Stop containers if compose file exists + composePath := filepath.Join(absDir, "docker-compose.yml") + devComposePath := filepath.Join(absDir, "docker-compose-dev.yml") + + if _, err := os.Stat(composePath); err == nil { + if err := dockerpkg.ComposeDown(absDir); err != nil { + return fmt.Errorf("docker compose down failed: %w", err) + } + } else if _, err := os.Stat(devComposePath); err == nil { + if err := dockerpkg.ComposeDown(absDir); err != nil { + return fmt.Errorf("docker compose down failed: %w", err) + } + } + + // Remove state file + stateFile := filepath.Join(absDir, ".devlake-local.json") + if _, err := os.Stat(stateFile); err == nil { + os.Remove(stateFile) + } + + return nil +} diff --git a/internal/devlake/discovery.go b/internal/devlake/discovery.go index e5e3ecd..61bcd78 100644 --- a/internal/devlake/discovery.go +++ b/internal/devlake/discovery.go @@ -106,6 +106,11 @@ func inferLocalCompanionURLs(backendURL string) (grafanaURL, configUIURL string) return "", "" } +// PingURL checks if a DevLake backend is reachable at the given URL. +func PingURL(baseURL string) error { + return pingURL(baseURL) +} + func pingURL(baseURL string) error { client := &http.Client{Timeout: 5 * time.Second} resp, err := client.Get(baseURL + "/ping") From 45a8fef4b7664d77a96664ccf2cccc0d28b98914 Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Wed, 25 Mar 2026 20:14:21 +0000 Subject: [PATCH 03/11] Address PR review feedback - Fix Azure restart prompt to not ask for cleanup confirmation when it doesn't perform cleanup - Fix local deployment detection to honor user choice even when only artifacts exist (no state file) - Fix docker-compose-dev.yml cleanup by explicitly using -f flag - Fix misleading compose file name display to show actual file found - Add trailing slash normalization in PingURL to match Discover() behavior - Fix Azure partial state path mismatch by writing to deployAzureDir consistently Agent-Logs-Url: https://github.com/DevExpGbb/gh-devlake/sessions/66b97b76-9583-4ab4-826e-ade3b8a58ac1 Co-authored-by: ewega <26189114+ewega@users.noreply.github.com> --- cmd/deploy_azure.go | 13 +++++-------- cmd/deploy_local.go | 16 ++++++++++++---- internal/devlake/discovery.go | 1 + 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/cmd/deploy_azure.go b/cmd/deploy_azure.go index d067bb5..1bb7ce5 100644 --- a/cmd/deploy_azure.go +++ b/cmd/deploy_azure.go @@ -80,12 +80,8 @@ func runDeployAzure(cmd *cobra.Command, args []string) error { case "abort": return nil case "restart": - fmt.Println("\n๐Ÿงน Cleaning up existing Azure deployment...") + fmt.Println("\n๐Ÿงน To restart, you need to clean up the existing deployment first") fmt.Println(" Note: This will delete all Azure resources in the resource group") - if !prompt.Confirm("Proceed with cleanup?") { - return nil - } - // User should run cleanup manually - we don't want to auto-delete Azure resources fmt.Println("\n Please run: gh devlake cleanup --azure") fmt.Println(" Then re-run: gh devlake deploy azure") return nil @@ -196,7 +192,7 @@ func runDeployAzure(cmd *cobra.Command, args []string) error { fmt.Println(" โœ… Resource Group created") // โ”€โ”€ Write early checkpoint โ€” ensures cleanup works even if deployment fails โ”€โ”€ - savePartialAzureState(azureRG, azureLocation) + savePartialAzureState(deployAzureDir, azureRG, azureLocation) // โ”€โ”€ Generate secrets โ”€โ”€ fmt.Println("\n๐Ÿ” Generating secrets...") @@ -468,8 +464,9 @@ func conditionalACR() any { // Resource Group is created so that cleanup --azure always has a breadcrumb, // even when the deployment fails mid-flight (e.g. Docker build errors). // The full state write at the end of a successful deployment overwrites this. -func savePartialAzureState(rg, region string) { - stateFile := ".devlake-azure.json" +func savePartialAzureState(dir, rg, region string) { + absDir, _ := filepath.Abs(dir) + stateFile := filepath.Join(absDir, ".devlake-azure.json") partial := map[string]any{ "deployedAt": time.Now().Format(time.RFC3339), "resourceGroup": rg, diff --git a/cmd/deploy_local.go b/cmd/deploy_local.go index 1378448..76db787 100644 --- a/cmd/deploy_local.go +++ b/cmd/deploy_local.go @@ -72,7 +72,8 @@ func runDeployLocal(cmd *cobra.Command, args []string) error { } // โ”€โ”€ Check for existing deployment โ”€โ”€ - if existingState, resumeAction := detectExistingLocalDeployment(deployLocalDir); existingState != nil { + _, resumeAction := detectExistingLocalDeployment(deployLocalDir) + if resumeAction != "" { switch resumeAction { case "abort": return nil @@ -591,10 +592,13 @@ func detectExistingLocalDeployment(dir string) (*devlake.State, string) { envPath := filepath.Join(absDir, ".env") hasCompose := false + composeFileName := "" if _, err := os.Stat(composePath); err == nil { hasCompose = true + composeFileName = "docker-compose.yml" } else if _, err := os.Stat(devComposePath); err == nil { hasCompose = true + composeFileName = "docker-compose-dev.yml" } hasEnv := false @@ -606,7 +610,7 @@ func detectExistingLocalDeployment(dir string) (*devlake.State, string) { if hasCompose || hasEnv { fmt.Println("\n๐Ÿ“‹ Found existing deployment artifacts:") if hasCompose { - fmt.Println(" โ€ข docker-compose.yml") + fmt.Printf(" โ€ข %s\n", composeFileName) } if hasEnv { fmt.Println(" โ€ข .env file") @@ -659,8 +663,12 @@ func cleanupLocalQuiet(dir string) error { return fmt.Errorf("docker compose down failed: %w", err) } } else if _, err := os.Stat(devComposePath); err == nil { - if err := dockerpkg.ComposeDown(absDir); err != nil { - return fmt.Errorf("docker compose down failed: %w", err) + // For docker-compose-dev.yml, we need to run docker compose explicitly + // since ComposeDown expects docker-compose.yml by default + cmd := exec.Command("docker", "compose", "-f", devComposePath, "down", "--rmi", "local") + cmd.Dir = absDir + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("docker compose down failed: %s\n%s", err, string(out)) } } diff --git a/internal/devlake/discovery.go b/internal/devlake/discovery.go index 61bcd78..d1cabf0 100644 --- a/internal/devlake/discovery.go +++ b/internal/devlake/discovery.go @@ -108,6 +108,7 @@ func inferLocalCompanionURLs(backendURL string) (grafanaURL, configUIURL string) // PingURL checks if a DevLake backend is reachable at the given URL. func PingURL(baseURL string) error { + baseURL = strings.TrimRight(baseURL, "/") return pingURL(baseURL) } From cddbf9201a0dff36bfa412934faa9750a774af39 Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Wed, 25 Mar 2026 22:30:42 +0000 Subject: [PATCH 04/11] Improve error handling and messaging robustness - Add warning for non-IsNotExist errors when reading Azure state file - Wrap underlying errors with %w for Docker availability, readiness timeout, and port conflicts - Add warning for non-IsNotExist errors when loading local deployment state - Add warning when state file removal fails during cleanup - Detect actual compose file (docker-compose.yml vs docker-compose-dev.yml) in troubleshooting output Agent-Logs-Url: https://github.com/DevExpGbb/gh-devlake/sessions/96938f2c-308a-42bb-a21d-6de79e11ddd1 Co-authored-by: ewega <26189114+ewega@users.noreply.github.com> --- cmd/deploy_azure.go | 5 ++++- cmd/deploy_local.go | 31 +++++++++++++++++++++++-------- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/cmd/deploy_azure.go b/cmd/deploy_azure.go index 1bb7ce5..916f70c 100644 --- a/cmd/deploy_azure.go +++ b/cmd/deploy_azure.go @@ -493,7 +493,10 @@ func detectExistingAzureDeployment(dir string) (map[string]any, string) { // Check for state file data, err := os.ReadFile(stateFile) if err != nil { - // No state file found - proceed normally + if !os.IsNotExist(err) { + fmt.Printf("\nโš ๏ธ Could not read Azure state file %s: %v\n", stateFile, err) + } + // No state file found or unreadable - proceed without state return nil, "" } diff --git a/cmd/deploy_local.go b/cmd/deploy_local.go index 76db787..d7fb521 100644 --- a/cmd/deploy_local.go +++ b/cmd/deploy_local.go @@ -200,7 +200,7 @@ func runDeployLocal(cmd *cobra.Command, args []string) error { fmt.Println(" 2. Start Docker Desktop and wait for it to fully initialize") fmt.Println(" 3. Verify Docker is running: docker ps") fmt.Println(" 4. Re-run this command: gh devlake deploy local") - return fmt.Errorf("Docker is not available โ€” follow recovery steps above") + return fmt.Errorf("Docker is not available โ€” follow recovery steps above: %w", err) } fmt.Println(" โœ… Docker found") @@ -542,7 +542,7 @@ func startLocalContainers(dir string, build bool, services ...string) (string, e fmt.Println(" gh devlake deploy local") 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") + return "", fmt.Errorf("port conflict โ€” stop the conflicting container and retry: %w", err) } fmt.Println("\n๐Ÿ’ก To clean up partial artifacts:") fmt.Println(" gh devlake cleanup --local --force") @@ -559,15 +559,25 @@ func startLocalContainers(dir string, build bool, services ...string) (string, e if err != nil { fmt.Println("\nโŒ DevLake not ready after 6 minutes") fmt.Println("\n๐Ÿ’ก Troubleshooting steps:") - fmt.Printf(" 1. Check container logs: docker compose -f \"%s/docker-compose.yml\" logs devlake\n", absDir) - fmt.Println(" 2. Verify all containers are running: docker compose ps") - fmt.Println(" 3. Check MySQL initialization: docker compose logs mysql") - fmt.Println(" 4. If containers keep restarting, check: docker compose logs") + + // Detect which compose file exists + composeFile := "docker-compose.yml" + if _, statErr := os.Stat(filepath.Join(absDir, "docker-compose.yml")); os.IsNotExist(statErr) { + if _, statErr := os.Stat(filepath.Join(absDir, "docker-compose-dev.yml")); statErr == nil { + composeFile = "docker-compose-dev.yml" + } + } + composePath := filepath.Join(absDir, composeFile) + + fmt.Printf(" 1. Check container logs: docker compose -f \"%s\" logs devlake\n", composePath) + fmt.Printf(" 2. Verify all containers are running: docker compose -f \"%s\" ps\n", composePath) + fmt.Printf(" 3. Check MySQL initialization: docker compose -f \"%s\" logs mysql\n", composePath) + fmt.Printf(" 4. If containers keep restarting, check: docker compose -f \"%s\" logs\n", composePath) fmt.Println("\n Common issues:") fmt.Println(" โ€ข MySQL takes longer on first run (database initialization)") fmt.Println(" โ€ข Insufficient Docker resources (increase memory in Docker Desktop settings)") fmt.Println(" โ€ข Port conflicts (check docker compose logs for 'address already in use')") - return "", fmt.Errorf("DevLake not ready โ€” check logs for details") + return "", fmt.Errorf("DevLake not ready โ€” check logs for details: %w", err) } return backendURL, nil } @@ -585,6 +595,9 @@ func detectExistingLocalDeployment(dir string) (*devlake.State, string) { // Check for state file state, err := devlake.LoadState(stateFile) + if err != nil && !os.IsNotExist(err) { + fmt.Printf("\nโš ๏ธ Unable to read local deployment state from %s: %v\n", stateFile, err) + } if err != nil || state == nil { // No state file or failed to load - check for docker-compose.yml + .env composePath := filepath.Join(absDir, "docker-compose.yml") @@ -675,7 +688,9 @@ func cleanupLocalQuiet(dir string) error { // Remove state file stateFile := filepath.Join(absDir, ".devlake-local.json") if _, err := os.Stat(stateFile); err == nil { - os.Remove(stateFile) + if err := os.Remove(stateFile); err != nil { + fmt.Printf("\nโš ๏ธ Failed to remove local state file %s: %v\n", stateFile, err) + } } return nil From 9220def69a21ba5eefa6540080c7296faf1b552d Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Mon, 30 Mar 2026 17:23:06 +0000 Subject: [PATCH 05/11] Fix Azure restart terminal output formatting Remove leading newline from sub-item prints in Azure restart branch to comply with terminal output rules (sub-items stay tight under parent). Agent-Logs-Url: https://github.com/DevExpGbb/gh-devlake/sessions/90bd3b50-b12a-4ade-83d8-2179478a7638 Co-authored-by: ewega <26189114+ewega@users.noreply.github.com> --- cmd/deploy_azure.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/deploy_azure.go b/cmd/deploy_azure.go index 916f70c..a922622 100644 --- a/cmd/deploy_azure.go +++ b/cmd/deploy_azure.go @@ -82,7 +82,7 @@ func runDeployAzure(cmd *cobra.Command, args []string) error { case "restart": fmt.Println("\n๐Ÿงน To restart, you need to clean up the existing deployment first") fmt.Println(" Note: This will delete all Azure resources in the resource group") - fmt.Println("\n Please run: gh devlake cleanup --azure") + fmt.Println(" Please run: gh devlake cleanup --azure") fmt.Println(" Then re-run: gh devlake deploy azure") return nil case "resume": From f8ff86be1e4b9a4aa87cffe8f97c7ce22041e51b Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Mon, 30 Mar 2026 19:02:52 +0000 Subject: [PATCH 06/11] Fix error wrapping in cleanupLocalQuiet Use %w instead of %s for error wrapping in docker-compose-dev.yml cleanup to maintain error chain consistency with project conventions. Agent-Logs-Url: https://github.com/DevExpGbb/gh-devlake/sessions/e30e7315-18a1-4487-a5ce-e9040332e4ae Co-authored-by: ewega <26189114+ewega@users.noreply.github.com> --- cmd/deploy_local.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/deploy_local.go b/cmd/deploy_local.go index d7fb521..3c67d16 100644 --- a/cmd/deploy_local.go +++ b/cmd/deploy_local.go @@ -681,7 +681,7 @@ func cleanupLocalQuiet(dir string) error { cmd := exec.Command("docker", "compose", "-f", devComposePath, "down", "--rmi", "local") cmd.Dir = absDir if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("docker compose down failed: %s\n%s", err, string(out)) + return fmt.Errorf("docker compose down failed: %w\n%s", err, string(out)) } } From 49c0d2b1aa2344fa48d7d148b4e9bb5217f7e114 Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Thu, 21 May 2026 17:36:12 +0000 Subject: [PATCH 07/11] Fix Azure cleanup guidance and add comprehensive tests - Include --dir flag in Azure restart/cleanup guidance when deploying to non-current directory - Add unit tests for PingURL with trailing slash normalization - Add unit tests for detectExistingLocalDeployment and cleanupLocalQuiet - Add unit tests for detectExistingAzureDeployment and savePartialAzureState - All tests pass (go test ./...) Agent-Logs-Url: https://github.com/DevExpGbb/gh-devlake/sessions/46dcf077-124b-4376-8e92-33bac7ab9572 Co-authored-by: ewega <26189114+ewega@users.noreply.github.com> --- cmd/deploy_azure.go | 1129 ++++++++++++++-------------- cmd/deploy_azure_test.go | 110 +++ cmd/deploy_local_test.go | 79 +- internal/devlake/discovery_test.go | 49 ++ 4 files changed, 806 insertions(+), 561 deletions(-) create mode 100644 cmd/deploy_azure_test.go diff --git a/cmd/deploy_azure.go b/cmd/deploy_azure.go index a922622..8d5a1a5 100644 --- a/cmd/deploy_azure.go +++ b/cmd/deploy_azure.go @@ -1,560 +1,569 @@ -package cmd - -import ( - "encoding/json" - "fmt" - "net/http" - "os" - "path/filepath" - "strings" - "time" - - "github.com/DevExpGBB/gh-devlake/internal/azure" - "github.com/DevExpGBB/gh-devlake/internal/devlake" - dockerpkg "github.com/DevExpGBB/gh-devlake/internal/docker" - "github.com/DevExpGBB/gh-devlake/internal/gitclone" - "github.com/DevExpGBB/gh-devlake/internal/prompt" - "github.com/DevExpGBB/gh-devlake/internal/secrets" - "github.com/spf13/cobra" -) - -var ( - azureRG string - azureLocation string - azureBaseName string - azureSkipImageBuild bool - azureRepoURL string - azureOfficial bool - deployAzureDir string - deployAzureQuiet bool // suppress "Next Steps" when called from init wizard -) - -func newDeployAzureCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "azure", - Short: "Deploy DevLake to Azure Container Apps", - Long: `Provisions DevLake on Azure using Container Instances, Azure Database for MySQL, -and (optionally) Azure Container Registry. - -Example: - gh devlake deploy azure --resource-group devlake-rg --location eastus - gh devlake deploy azure --resource-group devlake-rg --location eastus --official`, - RunE: runDeployAzure, - } - - cmd.Flags().StringVar(&azureRG, "resource-group", "", "Azure Resource Group name") - cmd.Flags().StringVar(&azureLocation, "location", "", "Azure region") - cmd.Flags().StringVar(&azureBaseName, "base-name", "devlake", "Base name for Azure resources") - cmd.Flags().BoolVar(&azureSkipImageBuild, "skip-image-build", false, "Skip Docker image building") - cmd.Flags().StringVar(&azureRepoURL, "repo-url", "", "Clone a remote DevLake repository for building") - cmd.Flags().BoolVar(&azureOfficial, "official", false, "Use official Apache images from Docker Hub (no ACR)") - cmd.Flags().StringVar(&deployAzureDir, "dir", ".", "Directory to save deployment state (.devlake-azure.json)") - - return cmd -} - -// Common Azure regions for interactive selection. -var azureRegions = []string{ - "eastus", "eastus2", "westus2", "westus3", - "centralus", "northeurope", "westeurope", - "southeastasia", "australiaeast", "uksouth", -} - -func runDeployAzure(cmd *cobra.Command, args []string) error { - // Suggest a dedicated directory unless already in the right place or called from init - if !deployAzureQuiet { - if suggestDedicatedDir("azure", "gh devlake deploy azure") { - return nil - } - } - if deployAzureDir == "" { - deployAzureDir = "." - } - if err := os.MkdirAll(deployAzureDir, 0755); err != nil { - return fmt.Errorf("failed to create directory %s: %w", deployAzureDir, err) - } - - // โ”€โ”€ Check for existing Azure deployment โ”€โ”€ - if existingState, resumeAction := detectExistingAzureDeployment(deployAzureDir); existingState != nil { - switch resumeAction { - case "abort": - return nil - case "restart": - fmt.Println("\n๐Ÿงน To restart, you need to clean up the existing deployment first") - fmt.Println(" Note: This will delete all Azure resources in the resource group") - fmt.Println(" Please run: gh devlake cleanup --azure") - fmt.Println(" Then re-run: gh devlake deploy azure") - return nil - case "resume": - // Continue with the deployment - may update existing resources - fmt.Println("\n Continuing with deployment (will update existing resources)...") - } - } - - // โ”€โ”€ Interactive image-source prompt (when no explicit flag set) โ”€โ”€ - if !cmd.Flags().Changed("official") && !cmd.Flags().Changed("repo-url") { - imageChoices := []string{ - "official - Apache DevLake images from Docker Hub (recommended)", - "fork - Clone a DevLake repo and build from source", - "custom - Use a local repo or pre-built images", - } - fmt.Println() - imgChoice := prompt.Select("Which DevLake images to use?", imageChoices) - if imgChoice == "" { - return fmt.Errorf("image choice is required") - } - switch { - case strings.HasPrefix(imgChoice, "official"): - azureOfficial = true - case strings.HasPrefix(imgChoice, "fork"): - azureOfficial = false - if azureRepoURL == "" { - azureRepoURL = prompt.ReadLine(fmt.Sprintf("Repository URL [%s]", gitclone.DefaultForkURL)) - if azureRepoURL == "" { - azureRepoURL = gitclone.DefaultForkURL - } - } - default: // custom - azureOfficial = false - if azureRepoURL == "" { - azureRepoURL = prompt.ReadLine("Path or URL to DevLake repo (leave blank to auto-detect)") - } - } - } - - // โ”€โ”€ Interactive prompts for missing required flags โ”€โ”€ - if azureLocation == "" { - azureLocation = prompt.SelectWithOther("Select Azure region", azureRegions, true) - if azureLocation == "" { - return fmt.Errorf("--location is required") - } - } - if azureRG == "" { - azureRG = prompt.ReadLine("Resource group name (e.g. devlake-rg)") - if azureRG == "" { - return fmt.Errorf("--resource-group is required") - } - } - - suffix := azure.Suffix(azureRG) - acrName := "devlakeacr" + suffix - - fmt.Println() - if azureOfficial { - printBanner("DevLake Azure Deployment (Official)") - fmt.Println("\nUsing official Apache DevLake images from Docker Hub") - azureSkipImageBuild = true - } else { - printBanner("DevLake Azure Deployment") - } - - fmt.Printf("\n๐Ÿ“‹ Configuration:\n") - fmt.Printf(" Resource Group: %s\n", azureRG) - fmt.Printf(" Location: %s\n", azureLocation) - fmt.Printf(" Base Name: %s\n", azureBaseName) - if !azureOfficial { - fmt.Printf(" ACR Name: %s\n", acrName) - } else { - fmt.Println(" Images: Official (Docker Hub)") - } - - // โ”€โ”€ Check Azure login โ”€โ”€ - fmt.Println("\n๐Ÿ”‘ Checking Azure CLI login...") - acct, err := azure.CheckLogin() - if err != nil { - fmt.Println(" Not logged in. Running az login...") - if loginErr := azure.Login(); loginErr != nil { - fmt.Println("\n๐Ÿ’ก Azure CLI login failed") - fmt.Println(" Recovery steps:") - fmt.Println(" 1. Install Azure CLI: https://docs.microsoft.com/cli/azure/install-azure-cli") - fmt.Println(" 2. Run: az login") - fmt.Println(" 3. Follow the browser authentication flow") - fmt.Println(" 4. Re-run: gh devlake deploy azure") - return fmt.Errorf("az login failed: %w", loginErr) - } - acct, err = azure.CheckLogin() - if err != nil { - fmt.Println("\n๐Ÿ’ก Still not authenticated after login") - fmt.Println(" Try:") - fmt.Println(" โ€ข Run 'az account list' to see your subscriptions") - fmt.Println(" โ€ข Run 'az account set --subscription ' if needed") - fmt.Println(" โ€ข Check Azure CLI version: az --version") - return fmt.Errorf("still not logged in after az login: %w", err) - } - } - fmt.Printf(" Logged in as: %s\n", acct.User.Name) - - // โ”€โ”€ Create Resource Group โ”€โ”€ - fmt.Println("\n๐Ÿ“ฆ Creating Resource Group...") - if err := azure.CreateResourceGroup(azureRG, azureLocation); err != nil { - return err - } - fmt.Println(" โœ… Resource Group created") - - // โ”€โ”€ Write early checkpoint โ€” ensures cleanup works even if deployment fails โ”€โ”€ - savePartialAzureState(deployAzureDir, azureRG, azureLocation) - - // โ”€โ”€ Generate secrets โ”€โ”€ - fmt.Println("\n๐Ÿ” Generating secrets...") - mysqlPwd, err := secrets.MySQLPassword() - if err != nil { - return err - } - encSecret, err := secrets.EncryptionSecret(32) - if err != nil { - return err - } - fmt.Println(" โœ… Secrets generated") - - // โ”€โ”€ Build and push images (if needed) โ”€โ”€ - if !azureSkipImageBuild { - repoRoot, err := findRepoRoot() - if err != nil { - return err - } - if azureRepoURL != "" { - defer os.RemoveAll(repoRoot) - if err := applyPoetryPinWorkaround(repoRoot); err != nil { - fmt.Printf(" โš ๏ธ Could not apply temporary Poetry pin workaround: %v\n", err) - } else { - fmt.Printf(" โš ๏ธ Applied temporary Poetry pin workaround (poetry==%s) for fork builds\n", poetryWorkaroundVersion) - } - } - fmt.Printf("\n๐Ÿ—๏ธ Building Docker images from %s...\n", repoRoot) - - // Create ACR (idempotent โ€” safe for re-runs) - fmt.Println(" Creating Container Registry...") - if err := azure.CreateACR(acrName, azureRG, azureLocation); err != nil { - return fmt.Errorf("failed to create ACR: %w", err) - } - fmt.Println(" โœ… Container Registry ready") - - acrServer := acrName + ".azurecr.io" - - fmt.Println("\n Logging into ACR...") - if err := azure.ACRLogin(acrName); err != nil { - return err - } - - images := []struct { - name string - dockerfile string - context string - }{ - {"devlake-backend", "backend/Dockerfile", filepath.Join(repoRoot, "backend")}, - {"devlake-config-ui", "config-ui/Dockerfile", filepath.Join(repoRoot, "config-ui")}, - {"devlake-grafana", "grafana/Dockerfile", filepath.Join(repoRoot, "grafana")}, - } - - for _, img := range images { - fmt.Printf("\n Building %s...\n", img.name) - localTag := img.name + ":latest" - if err := dockerpkg.Build(localTag, filepath.Join(repoRoot, img.dockerfile), img.context); err != nil { - fmt.Fprintf(os.Stderr, "\n โŒ Docker build failed for %s.\n", img.name) - fmt.Fprintf(os.Stderr, " Tip: re-run with --official to skip building and use\n") - fmt.Fprintf(os.Stderr, " official Apache DevLake images from Docker Hub instead.\n") - return fmt.Errorf("docker build failed for %s: %w", img.name, err) - } - remoteTag := acrServer + "/" + localTag - fmt.Printf(" Pushing %s...\n", img.name) - if err := dockerpkg.TagAndPush(localTag, remoteTag); err != nil { - return err - } - } - fmt.Println("\n โœ… All images pushed") - } - - // โ”€โ”€ Check MySQL state โ”€โ”€ - mysqlName := fmt.Sprintf("%smysql%s", azureBaseName, suffix) - fmt.Println("\n๐Ÿ—„๏ธ Checking MySQL state...") - state, err := azure.MySQLState(mysqlName, azureRG) - if err == nil && state == "Stopped" { - fmt.Println(" MySQL is stopped. Starting...") - if err := azure.MySQLStart(mysqlName, azureRG); err != nil { - fmt.Printf(" โš ๏ธ Could not start MySQL: %v\n", err) - } else { - fmt.Println(" Waiting 30s for MySQL...") - time.Sleep(30 * time.Second) - fmt.Println(" โœ… MySQL started") - } - } else if state != "" { - fmt.Printf(" MySQL state: %s\n", state) - } else { - fmt.Println(" MySQL not yet created (will be created by Bicep)") - } - - // โ”€โ”€ Check for soft-deleted Key Vault โ”€โ”€ - 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) - 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") - } - - // โ”€โ”€ Deploy infrastructure โ”€โ”€ - fmt.Println("\n๐Ÿš€ Deploying infrastructure with Bicep...") - templateName := "main.bicep" - if azureOfficial { - templateName = "main-official.bicep" - } - templatePath, cleanup, err := azure.WriteTemplate(templateName) - if err != nil { - return err - } - defer cleanup() - - params := map[string]string{ - "baseName": azureBaseName, - "uniqueSuffix": suffix, - "mysqlAdminPassword": mysqlPwd, - "encryptionSecret": encSecret, - } - if !azureOfficial { - params["acrName"] = acrName - } - - deployment, err := azure.DeployBicep(azureRG, templatePath, params) - if err != nil { - fmt.Println("\nโŒ Bicep deployment failed") - fmt.Println("\n๐Ÿ’ก Troubleshooting steps:") - fmt.Println(" 1. Check Azure portal for deployment details:") - fmt.Printf(" https://portal.azure.com/#blade/HubsExtension/DeploymentDetailsBlade/resourceGroup/%s\n", azureRG) - fmt.Println(" 2. Check if quota limits were exceeded in your subscription") - fmt.Println(" 3. Verify the resource group location supports all required services") - fmt.Println(" 4. Check for service principal or permission issues") - fmt.Println("\n To retry:") - fmt.Println(" โ€ข If partial deployment exists, re-run will attempt to continue") - fmt.Println(" โ€ข To start fresh: gh devlake cleanup --azure, then deploy again") - return fmt.Errorf("Bicep deployment failed: %w", err) - } - - printBanner("โœ… Deployment Complete!") - fmt.Printf("\nEndpoints:\n") - fmt.Printf(" Backend API: %s\n", deployment.BackendEndpoint) - fmt.Printf(" Config UI: %s\n", deployment.ConfigUIEndpoint) - fmt.Printf(" Grafana: %s\n", deployment.GrafanaEndpoint) - - // โ”€โ”€ Wait for backend and trigger migration โ”€โ”€ - fmt.Println("\nโณ Waiting for backend to start...") - backendReady := waitForReady(deployment.BackendEndpoint, 30, 10*time.Second) == nil - - if backendReady { - fmt.Println(" โœ… Backend is responding!") - fmt.Println("\n๐Ÿ”„ Triggering database migration...") - httpClient := &http.Client{Timeout: 5 * time.Second} - resp, err := httpClient.Get(deployment.BackendEndpoint + "/proceed-db-migration") - if err == nil { - resp.Body.Close() - fmt.Println(" โœ… Migration triggered") - } else { - fmt.Printf(" โš ๏ธ Migration may need manual trigger: %v\n", err) - } - } else { - fmt.Println(" Backend not ready after 30 attempts.") - fmt.Printf(" Trigger migration manually: GET %s/proceed-db-migration\n", deployment.BackendEndpoint) - } - - // โ”€โ”€ Save state file โ”€โ”€ - stateFile := filepath.Join(deployAzureDir, ".devlake-azure.json") - containers := []string{ - fmt.Sprintf("%s-backend-%s", azureBaseName, suffix), - fmt.Sprintf("%s-grafana-%s", azureBaseName, suffix), - fmt.Sprintf("%s-ui-%s", azureBaseName, suffix), - } - - kvName = deployment.KeyVaultName - if kvName == "" { - kvName = fmt.Sprintf("%skv%s", azureBaseName, suffix) - } - - // Write a combined state file: Azure-specific metadata + DevLake discovery fields - combinedState := map[string]any{ - "deployedAt": time.Now().Format(time.RFC3339), - "method": methodName(), - "subscription": acct.Name, - "subscriptionId": acct.ID, - "resourceGroup": azureRG, - "region": azureLocation, - "suffix": suffix, - "useOfficialImages": azureOfficial, - "resources": map[string]any{ - "acr": conditionalACR(), - "keyVault": kvName, - "mysql": mysqlName, - "database": "lake", - "containers": containers, - }, - "endpoints": map[string]string{ - "backend": deployment.BackendEndpoint, - "grafana": deployment.GrafanaEndpoint, - "configUi": deployment.ConfigUIEndpoint, - }, - } - - data, _ := json.MarshalIndent(combinedState, "", " ") - if err := os.WriteFile(stateFile, data, 0644); err != nil { - fmt.Fprintf(os.Stderr, "โš ๏ธ Could not save state file: %v\n", err) - } else { - fmt.Printf("\n๐Ÿ’พ State saved to %s\n", stateFile) - if deployAzureDir != "." { - fmt.Println(" Next commands should be run from this directory:") - fmt.Println(" PowerShell:") - fmt.Printf(" Set-Location \"%s\"\n", deployAzureDir) - fmt.Println(" Bash/Zsh:") - fmt.Printf(" cd \"%s\"\n", deployAzureDir) - } - } - - if !deployAzureQuiet { - fmt.Println("\nNext steps:") - fmt.Println(" 1. Wait 2-3 minutes for containers to start") - fmt.Printf(" 2. Open Config UI: %s\n", deployment.ConfigUIEndpoint) - fmt.Println(" 3. Configure your data sources") - fmt.Printf("\nTo cleanup: gh devlake cleanup --azure\n") - } - - return nil -} - -func findRepoRoot() (string, error) { - if azureRepoURL != "" { - tmpDir, err := os.MkdirTemp("", "devlake-clone-*") - if err != nil { - return "", err - } - fmt.Printf(" Cloning %s...\n", azureRepoURL) - if err := gitclone.Clone(azureRepoURL, tmpDir); err != nil { - return "", err - } - return tmpDir, nil - } - - // Walk up looking for backend/Dockerfile - dir, _ := os.Getwd() - for dir != "" && dir != filepath.Dir(dir) { - if _, err := os.Stat(filepath.Join(dir, "backend", "Dockerfile")); err == nil { - return dir, nil - } - dir = filepath.Dir(dir) - } - return "", fmt.Errorf("could not find DevLake repo root.\n" + - "Options:\n" + - " --repo-url Clone a fork with the custom Dockerfile\n" + - " --official Use official Apache images (no build needed)") -} - -func methodName() string { - if azureOfficial { - return "bicep-official" - } - return "bicep" -} - -func conditionalACR() any { - if azureOfficial { - return nil - } - return "devlakeacr" + azure.Suffix(azureRG) -} - -// savePartialAzureState writes a minimal state file immediately after the -// Resource Group is created so that cleanup --azure always has a breadcrumb, -// even when the deployment fails mid-flight (e.g. Docker build errors). -// The full state write at the end of a successful deployment overwrites this. -func savePartialAzureState(dir, rg, region string) { - absDir, _ := filepath.Abs(dir) - stateFile := filepath.Join(absDir, ".devlake-azure.json") - partial := map[string]any{ - "deployedAt": time.Now().Format(time.RFC3339), - "resourceGroup": rg, - "region": region, - "partial": true, - } - data, _ := json.MarshalIndent(partial, "", " ") - if err := os.WriteFile(stateFile, data, 0644); err != nil { - fmt.Fprintf(os.Stderr, "โš ๏ธ Could not save early state checkpoint: %v\n", err) - } -} - -// detectExistingAzureDeployment checks for existing Azure deployment state and prompts for action. -// Returns any existing state data and the user's choice: "resume", "restart", or "abort". -func detectExistingAzureDeployment(dir string) (map[string]any, string) { - if deployAzureQuiet { - // When called from init wizard, don't prompt - return nil, "" - } - - absDir, _ := filepath.Abs(dir) - stateFile := filepath.Join(absDir, ".devlake-azure.json") - - // Check for state file - data, err := os.ReadFile(stateFile) - if err != nil { - if !os.IsNotExist(err) { - fmt.Printf("\nโš ๏ธ Could not read Azure state file %s: %v\n", stateFile, err) - } - // No state file found or unreadable - proceed without state - return nil, "" - } - - var state map[string]any - if err := json.Unmarshal(data, &state); err != nil { - // State file is corrupted - warn and proceed - fmt.Printf("\nโš ๏ธ Found .devlake-azure.json but could not parse it: %v\n", err) - return nil, "" - } - - // Display existing deployment info - fmt.Println("\n๐Ÿ“‹ Found existing Azure deployment:") - if deployedAt, ok := state["deployedAt"].(string); ok { - fmt.Printf(" Deployed: %s\n", deployedAt) - } - if rg, ok := state["resourceGroup"].(string); ok { - fmt.Printf(" Resource Group: %s\n", rg) - } - if region, ok := state["region"].(string); ok { - fmt.Printf(" Region: %s\n", region) - } - - // Check if this is a partial deployment (failed mid-way) - isPartial := false - if partial, ok := state["partial"].(bool); ok && partial { - fmt.Println(" Status: โš ๏ธ Partial deployment (may have failed)") - isPartial = true - } - - // Check if endpoints are available and reachable - if endpoints, ok := state["endpoints"].(map[string]any); ok { - if backend, ok := endpoints["backend"].(string); ok && backend != "" { - fmt.Printf(" Backend: %s\n", backend) - if err := devlake.PingURL(backend); err == nil { - fmt.Println(" Status: โœ… Running") - } else { - fmt.Println(" Status: โš ๏ธ Not responding (may be stopped)") - } - } - } - - fmt.Println() - choices := []string{ - "resume - Continue/update existing deployment", - "restart - Clean up and start fresh (requires manual cleanup)", - "abort - Exit without making changes", - } - - if isPartial { - // For partial deployments, recommend resume - choices[0] = "resume - Continue deployment from where it failed (recommended)" - } - - choice := prompt.Select("What would you like to do?", choices) - if choice == "" { - return state, "abort" - } - - action := strings.SplitN(choice, " ", 2)[0] - return state, action -} +package cmd + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/DevExpGBB/gh-devlake/internal/azure" + "github.com/DevExpGBB/gh-devlake/internal/devlake" + dockerpkg "github.com/DevExpGBB/gh-devlake/internal/docker" + "github.com/DevExpGBB/gh-devlake/internal/gitclone" + "github.com/DevExpGBB/gh-devlake/internal/prompt" + "github.com/DevExpGBB/gh-devlake/internal/secrets" + "github.com/spf13/cobra" +) + +var ( + azureRG string + azureLocation string + azureBaseName string + azureSkipImageBuild bool + azureRepoURL string + azureOfficial bool + deployAzureDir string + deployAzureQuiet bool // suppress "Next Steps" when called from init wizard +) + +func newDeployAzureCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "azure", + Short: "Deploy DevLake to Azure Container Apps", + Long: `Provisions DevLake on Azure using Container Instances, Azure Database for MySQL, +and (optionally) Azure Container Registry. + +Example: + gh devlake deploy azure --resource-group devlake-rg --location eastus + gh devlake deploy azure --resource-group devlake-rg --location eastus --official`, + RunE: runDeployAzure, + } + + cmd.Flags().StringVar(&azureRG, "resource-group", "", "Azure Resource Group name") + cmd.Flags().StringVar(&azureLocation, "location", "", "Azure region") + cmd.Flags().StringVar(&azureBaseName, "base-name", "devlake", "Base name for Azure resources") + cmd.Flags().BoolVar(&azureSkipImageBuild, "skip-image-build", false, "Skip Docker image building") + cmd.Flags().StringVar(&azureRepoURL, "repo-url", "", "Clone a remote DevLake repository for building") + cmd.Flags().BoolVar(&azureOfficial, "official", false, "Use official Apache images from Docker Hub (no ACR)") + cmd.Flags().StringVar(&deployAzureDir, "dir", ".", "Directory to save deployment state (.devlake-azure.json)") + + return cmd +} + +// Common Azure regions for interactive selection. +var azureRegions = []string{ + "eastus", "eastus2", "westus2", "westus3", + "centralus", "northeurope", "westeurope", + "southeastasia", "australiaeast", "uksouth", +} + +func runDeployAzure(cmd *cobra.Command, args []string) error { + // Suggest a dedicated directory unless already in the right place or called from init + if !deployAzureQuiet { + if suggestDedicatedDir("azure", "gh devlake deploy azure") { + return nil + } + } + if deployAzureDir == "" { + deployAzureDir = "." + } + if err := os.MkdirAll(deployAzureDir, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", deployAzureDir, err) + } + + // โ”€โ”€ Check for existing Azure deployment โ”€โ”€ + if existingState, resumeAction := detectExistingAzureDeployment(deployAzureDir); existingState != nil { + switch resumeAction { + case "abort": + return nil + case "restart": + fmt.Println("\n๐Ÿงน To restart, you need to clean up the existing deployment first") + fmt.Println(" Note: This will delete all Azure resources in the resource group") + if deployAzureDir != "." { + fmt.Printf(" Please run: gh devlake cleanup --azure --dir \"%s\"\n", deployAzureDir) + fmt.Printf(" Then re-run: gh devlake deploy azure --dir \"%s\"\n", deployAzureDir) + } else { + fmt.Println(" Please run: gh devlake cleanup --azure") + fmt.Println(" Then re-run: gh devlake deploy azure") + } + return nil + case "resume": + // Continue with the deployment - may update existing resources + fmt.Println("\n Continuing with deployment (will update existing resources)...") + } + } + + // โ”€โ”€ Interactive image-source prompt (when no explicit flag set) โ”€โ”€ + if !cmd.Flags().Changed("official") && !cmd.Flags().Changed("repo-url") { + imageChoices := []string{ + "official - Apache DevLake images from Docker Hub (recommended)", + "fork - Clone a DevLake repo and build from source", + "custom - Use a local repo or pre-built images", + } + fmt.Println() + imgChoice := prompt.Select("Which DevLake images to use?", imageChoices) + if imgChoice == "" { + return fmt.Errorf("image choice is required") + } + switch { + case strings.HasPrefix(imgChoice, "official"): + azureOfficial = true + case strings.HasPrefix(imgChoice, "fork"): + azureOfficial = false + if azureRepoURL == "" { + azureRepoURL = prompt.ReadLine(fmt.Sprintf("Repository URL [%s]", gitclone.DefaultForkURL)) + if azureRepoURL == "" { + azureRepoURL = gitclone.DefaultForkURL + } + } + default: // custom + azureOfficial = false + if azureRepoURL == "" { + azureRepoURL = prompt.ReadLine("Path or URL to DevLake repo (leave blank to auto-detect)") + } + } + } + + // โ”€โ”€ Interactive prompts for missing required flags โ”€โ”€ + if azureLocation == "" { + azureLocation = prompt.SelectWithOther("Select Azure region", azureRegions, true) + if azureLocation == "" { + return fmt.Errorf("--location is required") + } + } + if azureRG == "" { + azureRG = prompt.ReadLine("Resource group name (e.g. devlake-rg)") + if azureRG == "" { + return fmt.Errorf("--resource-group is required") + } + } + + suffix := azure.Suffix(azureRG) + acrName := "devlakeacr" + suffix + + fmt.Println() + if azureOfficial { + printBanner("DevLake Azure Deployment (Official)") + fmt.Println("\nUsing official Apache DevLake images from Docker Hub") + azureSkipImageBuild = true + } else { + printBanner("DevLake Azure Deployment") + } + + fmt.Printf("\n๐Ÿ“‹ Configuration:\n") + fmt.Printf(" Resource Group: %s\n", azureRG) + fmt.Printf(" Location: %s\n", azureLocation) + fmt.Printf(" Base Name: %s\n", azureBaseName) + if !azureOfficial { + fmt.Printf(" ACR Name: %s\n", acrName) + } else { + fmt.Println(" Images: Official (Docker Hub)") + } + + // โ”€โ”€ Check Azure login โ”€โ”€ + fmt.Println("\n๐Ÿ”‘ Checking Azure CLI login...") + acct, err := azure.CheckLogin() + if err != nil { + fmt.Println(" Not logged in. Running az login...") + if loginErr := azure.Login(); loginErr != nil { + fmt.Println("\n๐Ÿ’ก Azure CLI login failed") + fmt.Println(" Recovery steps:") + fmt.Println(" 1. Install Azure CLI: https://docs.microsoft.com/cli/azure/install-azure-cli") + fmt.Println(" 2. Run: az login") + fmt.Println(" 3. Follow the browser authentication flow") + fmt.Println(" 4. Re-run: gh devlake deploy azure") + return fmt.Errorf("az login failed: %w", loginErr) + } + acct, err = azure.CheckLogin() + if err != nil { + fmt.Println("\n๐Ÿ’ก Still not authenticated after login") + fmt.Println(" Try:") + fmt.Println(" โ€ข Run 'az account list' to see your subscriptions") + fmt.Println(" โ€ข Run 'az account set --subscription ' if needed") + fmt.Println(" โ€ข Check Azure CLI version: az --version") + return fmt.Errorf("still not logged in after az login: %w", err) + } + } + fmt.Printf(" Logged in as: %s\n", acct.User.Name) + + // โ”€โ”€ Create Resource Group โ”€โ”€ + fmt.Println("\n๐Ÿ“ฆ Creating Resource Group...") + if err := azure.CreateResourceGroup(azureRG, azureLocation); err != nil { + return err + } + fmt.Println(" โœ… Resource Group created") + + // โ”€โ”€ Write early checkpoint โ€” ensures cleanup works even if deployment fails โ”€โ”€ + savePartialAzureState(deployAzureDir, azureRG, azureLocation) + + // โ”€โ”€ Generate secrets โ”€โ”€ + fmt.Println("\n๐Ÿ” Generating secrets...") + mysqlPwd, err := secrets.MySQLPassword() + if err != nil { + return err + } + encSecret, err := secrets.EncryptionSecret(32) + if err != nil { + return err + } + fmt.Println(" โœ… Secrets generated") + + // โ”€โ”€ Build and push images (if needed) โ”€โ”€ + if !azureSkipImageBuild { + repoRoot, err := findRepoRoot() + if err != nil { + return err + } + if azureRepoURL != "" { + defer os.RemoveAll(repoRoot) + if err := applyPoetryPinWorkaround(repoRoot); err != nil { + fmt.Printf(" โš ๏ธ Could not apply temporary Poetry pin workaround: %v\n", err) + } else { + fmt.Printf(" โš ๏ธ Applied temporary Poetry pin workaround (poetry==%s) for fork builds\n", poetryWorkaroundVersion) + } + } + fmt.Printf("\n๐Ÿ—๏ธ Building Docker images from %s...\n", repoRoot) + + // Create ACR (idempotent โ€” safe for re-runs) + fmt.Println(" Creating Container Registry...") + if err := azure.CreateACR(acrName, azureRG, azureLocation); err != nil { + return fmt.Errorf("failed to create ACR: %w", err) + } + fmt.Println(" โœ… Container Registry ready") + + acrServer := acrName + ".azurecr.io" + + fmt.Println("\n Logging into ACR...") + if err := azure.ACRLogin(acrName); err != nil { + return err + } + + images := []struct { + name string + dockerfile string + context string + }{ + {"devlake-backend", "backend/Dockerfile", filepath.Join(repoRoot, "backend")}, + {"devlake-config-ui", "config-ui/Dockerfile", filepath.Join(repoRoot, "config-ui")}, + {"devlake-grafana", "grafana/Dockerfile", filepath.Join(repoRoot, "grafana")}, + } + + for _, img := range images { + fmt.Printf("\n Building %s...\n", img.name) + localTag := img.name + ":latest" + if err := dockerpkg.Build(localTag, filepath.Join(repoRoot, img.dockerfile), img.context); err != nil { + fmt.Fprintf(os.Stderr, "\n โŒ Docker build failed for %s.\n", img.name) + fmt.Fprintf(os.Stderr, " Tip: re-run with --official to skip building and use\n") + fmt.Fprintf(os.Stderr, " official Apache DevLake images from Docker Hub instead.\n") + return fmt.Errorf("docker build failed for %s: %w", img.name, err) + } + remoteTag := acrServer + "/" + localTag + fmt.Printf(" Pushing %s...\n", img.name) + if err := dockerpkg.TagAndPush(localTag, remoteTag); err != nil { + return err + } + } + fmt.Println("\n โœ… All images pushed") + } + + // โ”€โ”€ Check MySQL state โ”€โ”€ + mysqlName := fmt.Sprintf("%smysql%s", azureBaseName, suffix) + fmt.Println("\n๐Ÿ—„๏ธ Checking MySQL state...") + state, err := azure.MySQLState(mysqlName, azureRG) + if err == nil && state == "Stopped" { + fmt.Println(" MySQL is stopped. Starting...") + if err := azure.MySQLStart(mysqlName, azureRG); err != nil { + fmt.Printf(" โš ๏ธ Could not start MySQL: %v\n", err) + } else { + fmt.Println(" Waiting 30s for MySQL...") + time.Sleep(30 * time.Second) + fmt.Println(" โœ… MySQL started") + } + } else if state != "" { + fmt.Printf(" MySQL state: %s\n", state) + } else { + fmt.Println(" MySQL not yet created (will be created by Bicep)") + } + + // โ”€โ”€ Check for soft-deleted Key Vault โ”€โ”€ + 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) + 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") + } + + // โ”€โ”€ Deploy infrastructure โ”€โ”€ + fmt.Println("\n๐Ÿš€ Deploying infrastructure with Bicep...") + templateName := "main.bicep" + if azureOfficial { + templateName = "main-official.bicep" + } + templatePath, cleanup, err := azure.WriteTemplate(templateName) + if err != nil { + return err + } + defer cleanup() + + params := map[string]string{ + "baseName": azureBaseName, + "uniqueSuffix": suffix, + "mysqlAdminPassword": mysqlPwd, + "encryptionSecret": encSecret, + } + if !azureOfficial { + params["acrName"] = acrName + } + + deployment, err := azure.DeployBicep(azureRG, templatePath, params) + if err != nil { + fmt.Println("\nโŒ Bicep deployment failed") + fmt.Println("\n๐Ÿ’ก Troubleshooting steps:") + fmt.Println(" 1. Check Azure portal for deployment details:") + fmt.Printf(" https://portal.azure.com/#blade/HubsExtension/DeploymentDetailsBlade/resourceGroup/%s\n", azureRG) + fmt.Println(" 2. Check if quota limits were exceeded in your subscription") + fmt.Println(" 3. Verify the resource group location supports all required services") + fmt.Println(" 4. Check for service principal or permission issues") + fmt.Println("\n To retry:") + fmt.Println(" โ€ข If partial deployment exists, re-run will attempt to continue") + if deployAzureDir != "." { + fmt.Printf(" โ€ข To start fresh: gh devlake cleanup --azure --dir \"%s\", then deploy again\n", deployAzureDir) + } else { + fmt.Println(" โ€ข To start fresh: gh devlake cleanup --azure, then deploy again") + } + return fmt.Errorf("Bicep deployment failed: %w", err) + } + + printBanner("โœ… Deployment Complete!") + fmt.Printf("\nEndpoints:\n") + fmt.Printf(" Backend API: %s\n", deployment.BackendEndpoint) + fmt.Printf(" Config UI: %s\n", deployment.ConfigUIEndpoint) + fmt.Printf(" Grafana: %s\n", deployment.GrafanaEndpoint) + + // โ”€โ”€ Wait for backend and trigger migration โ”€โ”€ + fmt.Println("\nโณ Waiting for backend to start...") + backendReady := waitForReady(deployment.BackendEndpoint, 30, 10*time.Second) == nil + + if backendReady { + fmt.Println(" โœ… Backend is responding!") + fmt.Println("\n๐Ÿ”„ Triggering database migration...") + httpClient := &http.Client{Timeout: 5 * time.Second} + resp, err := httpClient.Get(deployment.BackendEndpoint + "/proceed-db-migration") + if err == nil { + resp.Body.Close() + fmt.Println(" โœ… Migration triggered") + } else { + fmt.Printf(" โš ๏ธ Migration may need manual trigger: %v\n", err) + } + } else { + fmt.Println(" Backend not ready after 30 attempts.") + fmt.Printf(" Trigger migration manually: GET %s/proceed-db-migration\n", deployment.BackendEndpoint) + } + + // โ”€โ”€ Save state file โ”€โ”€ + stateFile := filepath.Join(deployAzureDir, ".devlake-azure.json") + containers := []string{ + fmt.Sprintf("%s-backend-%s", azureBaseName, suffix), + fmt.Sprintf("%s-grafana-%s", azureBaseName, suffix), + fmt.Sprintf("%s-ui-%s", azureBaseName, suffix), + } + + kvName = deployment.KeyVaultName + if kvName == "" { + kvName = fmt.Sprintf("%skv%s", azureBaseName, suffix) + } + + // Write a combined state file: Azure-specific metadata + DevLake discovery fields + combinedState := map[string]any{ + "deployedAt": time.Now().Format(time.RFC3339), + "method": methodName(), + "subscription": acct.Name, + "subscriptionId": acct.ID, + "resourceGroup": azureRG, + "region": azureLocation, + "suffix": suffix, + "useOfficialImages": azureOfficial, + "resources": map[string]any{ + "acr": conditionalACR(), + "keyVault": kvName, + "mysql": mysqlName, + "database": "lake", + "containers": containers, + }, + "endpoints": map[string]string{ + "backend": deployment.BackendEndpoint, + "grafana": deployment.GrafanaEndpoint, + "configUi": deployment.ConfigUIEndpoint, + }, + } + + data, _ := json.MarshalIndent(combinedState, "", " ") + if err := os.WriteFile(stateFile, data, 0644); err != nil { + fmt.Fprintf(os.Stderr, "โš ๏ธ Could not save state file: %v\n", err) + } else { + fmt.Printf("\n๐Ÿ’พ State saved to %s\n", stateFile) + if deployAzureDir != "." { + fmt.Println(" Next commands should be run from this directory:") + fmt.Println(" PowerShell:") + fmt.Printf(" Set-Location \"%s\"\n", deployAzureDir) + fmt.Println(" Bash/Zsh:") + fmt.Printf(" cd \"%s\"\n", deployAzureDir) + } + } + + if !deployAzureQuiet { + fmt.Println("\nNext steps:") + fmt.Println(" 1. Wait 2-3 minutes for containers to start") + fmt.Printf(" 2. Open Config UI: %s\n", deployment.ConfigUIEndpoint) + fmt.Println(" 3. Configure your data sources") + fmt.Printf("\nTo cleanup: gh devlake cleanup --azure\n") + } + + return nil +} + +func findRepoRoot() (string, error) { + if azureRepoURL != "" { + tmpDir, err := os.MkdirTemp("", "devlake-clone-*") + if err != nil { + return "", err + } + fmt.Printf(" Cloning %s...\n", azureRepoURL) + if err := gitclone.Clone(azureRepoURL, tmpDir); err != nil { + return "", err + } + return tmpDir, nil + } + + // Walk up looking for backend/Dockerfile + dir, _ := os.Getwd() + for dir != "" && dir != filepath.Dir(dir) { + if _, err := os.Stat(filepath.Join(dir, "backend", "Dockerfile")); err == nil { + return dir, nil + } + dir = filepath.Dir(dir) + } + return "", fmt.Errorf("could not find DevLake repo root.\n" + + "Options:\n" + + " --repo-url Clone a fork with the custom Dockerfile\n" + + " --official Use official Apache images (no build needed)") +} + +func methodName() string { + if azureOfficial { + return "bicep-official" + } + return "bicep" +} + +func conditionalACR() any { + if azureOfficial { + return nil + } + return "devlakeacr" + azure.Suffix(azureRG) +} + +// savePartialAzureState writes a minimal state file immediately after the +// Resource Group is created so that cleanup --azure always has a breadcrumb, +// even when the deployment fails mid-flight (e.g. Docker build errors). +// The full state write at the end of a successful deployment overwrites this. +func savePartialAzureState(dir, rg, region string) { + absDir, _ := filepath.Abs(dir) + stateFile := filepath.Join(absDir, ".devlake-azure.json") + partial := map[string]any{ + "deployedAt": time.Now().Format(time.RFC3339), + "resourceGroup": rg, + "region": region, + "partial": true, + } + data, _ := json.MarshalIndent(partial, "", " ") + if err := os.WriteFile(stateFile, data, 0644); err != nil { + fmt.Fprintf(os.Stderr, "โš ๏ธ Could not save early state checkpoint: %v\n", err) + } +} + +// detectExistingAzureDeployment checks for existing Azure deployment state and prompts for action. +// Returns any existing state data and the user's choice: "resume", "restart", or "abort". +func detectExistingAzureDeployment(dir string) (map[string]any, string) { + if deployAzureQuiet { + // When called from init wizard, don't prompt + return nil, "" + } + + absDir, _ := filepath.Abs(dir) + stateFile := filepath.Join(absDir, ".devlake-azure.json") + + // Check for state file + data, err := os.ReadFile(stateFile) + if err != nil { + if !os.IsNotExist(err) { + fmt.Printf("\nโš ๏ธ Could not read Azure state file %s: %v\n", stateFile, err) + } + // No state file found or unreadable - proceed without state + return nil, "" + } + + var state map[string]any + if err := json.Unmarshal(data, &state); err != nil { + // State file is corrupted - warn and proceed + fmt.Printf("\nโš ๏ธ Found .devlake-azure.json but could not parse it: %v\n", err) + return nil, "" + } + + // Display existing deployment info + fmt.Println("\n๐Ÿ“‹ Found existing Azure deployment:") + if deployedAt, ok := state["deployedAt"].(string); ok { + fmt.Printf(" Deployed: %s\n", deployedAt) + } + if rg, ok := state["resourceGroup"].(string); ok { + fmt.Printf(" Resource Group: %s\n", rg) + } + if region, ok := state["region"].(string); ok { + fmt.Printf(" Region: %s\n", region) + } + + // Check if this is a partial deployment (failed mid-way) + isPartial := false + if partial, ok := state["partial"].(bool); ok && partial { + fmt.Println(" Status: โš ๏ธ Partial deployment (may have failed)") + isPartial = true + } + + // Check if endpoints are available and reachable + if endpoints, ok := state["endpoints"].(map[string]any); ok { + if backend, ok := endpoints["backend"].(string); ok && backend != "" { + fmt.Printf(" Backend: %s\n", backend) + if err := devlake.PingURL(backend); err == nil { + fmt.Println(" Status: โœ… Running") + } else { + fmt.Println(" Status: โš ๏ธ Not responding (may be stopped)") + } + } + } + + fmt.Println() + choices := []string{ + "resume - Continue/update existing deployment", + "restart - Clean up and start fresh (requires manual cleanup)", + "abort - Exit without making changes", + } + + if isPartial { + // For partial deployments, recommend resume + choices[0] = "resume - Continue deployment from where it failed (recommended)" + } + + choice := prompt.Select("What would you like to do?", choices) + if choice == "" { + return state, "abort" + } + + action := strings.SplitN(choice, " ", 2)[0] + return state, action +} diff --git a/cmd/deploy_azure_test.go b/cmd/deploy_azure_test.go new file mode 100644 index 0000000..2b532ba --- /dev/null +++ b/cmd/deploy_azure_test.go @@ -0,0 +1,110 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" +) + +// TestDetectExistingAzureDeployment_NoState tests detection when no state file exists. +func TestDetectExistingAzureDeployment_NoState(t *testing.T) { + tmpDir := t.TempDir() + + // Set quiet mode to avoid prompts + oldQuiet := deployAzureQuiet + deployAzureQuiet = true + defer func() { deployAzureQuiet = oldQuiet }() + + state, action := detectExistingAzureDeployment(tmpDir) + if state != nil { + t.Errorf("expected nil state, got %+v", state) + } + if action != "" { + t.Errorf("expected empty action, got %q", action) + } +} + +// TestDetectExistingAzureDeployment_QuietMode tests that quiet mode skips detection. +func TestDetectExistingAzureDeployment_QuietMode(t *testing.T) { + tmpDir := t.TempDir() + + // Create a state file + stateFile := filepath.Join(tmpDir, ".devlake-azure.json") + stateData := `{"deployedAt":"2024-01-01T00:00:00Z","resourceGroup":"test-rg","region":"eastus"}` + if err := os.WriteFile(stateFile, []byte(stateData), 0644); err != nil { + t.Fatal(err) + } + + // Set quiet mode + oldQuiet := deployAzureQuiet + deployAzureQuiet = true + defer func() { deployAzureQuiet = oldQuiet }() + + state, action := detectExistingAzureDeployment(tmpDir) + if state != nil { + t.Errorf("expected nil state in quiet mode, got %+v", state) + } + if action != "" { + t.Errorf("expected empty action in quiet mode, got %q", action) + } +} + +// TestDetectExistingAzureDeployment_CorruptedState tests handling of corrupted state file. +func TestDetectExistingAzureDeployment_CorruptedState(t *testing.T) { + tmpDir := t.TempDir() + + // Create a corrupted state file + stateFile := filepath.Join(tmpDir, ".devlake-azure.json") + if err := os.WriteFile(stateFile, []byte("not valid json{"), 0644); err != nil { + t.Fatal(err) + } + + // Set quiet mode to avoid prompts + oldQuiet := deployAzureQuiet + deployAzureQuiet = true + defer func() { deployAzureQuiet = oldQuiet }() + + state, action := detectExistingAzureDeployment(tmpDir) + if state != nil { + t.Errorf("expected nil state for corrupted file, got %+v", state) + } + if action != "" { + t.Errorf("expected empty action for corrupted file, got %q", action) + } +} + +// TestSavePartialAzureState tests the partial state saving functionality. +func TestSavePartialAzureState(t *testing.T) { + tmpDir := t.TempDir() + + savePartialAzureState(tmpDir, "test-rg", "eastus") + + stateFile := filepath.Join(tmpDir, ".devlake-azure.json") + if _, err := os.Stat(stateFile); os.IsNotExist(err) { + t.Fatal("expected state file to be created") + } + + data, err := os.ReadFile(stateFile) + if err != nil { + t.Fatal(err) + } + + // Check that the file contains expected content + content := string(data) + if !containsAll(content, "test-rg", "eastus", "partial") { + t.Errorf("state file missing expected content: %s", content) + } +} + +func containsAll(s string, substrs ...string) bool { + for _, substr := range substrs { + if !contains(s, substr) { + return false + } + } + return true +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && (s[:len(substr)] == substr || contains(s[1:], substr))) +} diff --git a/cmd/deploy_local_test.go b/cmd/deploy_local_test.go index b575b10..5f1cb2a 100644 --- a/cmd/deploy_local_test.go +++ b/cmd/deploy_local_test.go @@ -1,6 +1,10 @@ package cmd -import "testing" +import ( + "os" + "path/filepath" + "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 +42,76 @@ func TestRewritePoetryInstallLine_NoChangeWhenLineMissing(t *testing.T) { t.Fatalf("content changed unexpectedly") } } + +// TestDetectExistingLocalDeployment_NoArtifacts tests detection when no artifacts exist. +func TestDetectExistingLocalDeployment_NoArtifacts(t *testing.T) { + // Create a temp directory with no artifacts + tmpDir := t.TempDir() + + // Set quiet mode to avoid prompts + oldQuiet := deployLocalQuiet + deployLocalQuiet = true + defer func() { deployLocalQuiet = oldQuiet }() + + state, action := detectExistingLocalDeployment(tmpDir) + if state != nil { + t.Errorf("expected nil state, got %+v", state) + } + if action != "" { + t.Errorf("expected empty action, got %q", action) + } +} + +// TestDetectExistingLocalDeployment_QuietMode tests that quiet mode skips detection. +func TestDetectExistingLocalDeployment_QuietMode(t *testing.T) { + tmpDir := t.TempDir() + + // Create some artifacts + composePath := filepath.Join(tmpDir, "docker-compose.yml") + if err := os.WriteFile(composePath, []byte("version: '3'\n"), 0644); err != nil { + t.Fatal(err) + } + + // Set quiet mode + oldQuiet := deployLocalQuiet + deployLocalQuiet = true + defer func() { deployLocalQuiet = oldQuiet }() + + state, action := detectExistingLocalDeployment(tmpDir) + if state != nil { + t.Errorf("expected nil state in quiet mode, got %+v", state) + } + if action != "" { + t.Errorf("expected empty action in quiet mode, got %q", action) + } +} + +// TestCleanupLocalQuiet_NoArtifacts tests cleanup when no artifacts exist. +func TestCleanupLocalQuiet_NoArtifacts(t *testing.T) { + tmpDir := t.TempDir() + + err := cleanupLocalQuiet(tmpDir) + if err != nil { + t.Errorf("expected no error when no artifacts exist, got %v", err) + } +} + +// TestCleanupLocalQuiet_StateFileRemoval tests state file removal. +func TestCleanupLocalQuiet_StateFileRemoval(t *testing.T) { + tmpDir := t.TempDir() + + // Create a state file + stateFile := filepath.Join(tmpDir, ".devlake-local.json") + stateData := `{"deployed_at":"2024-01-01T00:00:00Z","endpoints":{"backend":"http://localhost:8080"}}` + if err := os.WriteFile(stateFile, []byte(stateData), 0644); err != nil { + t.Fatal(err) + } + + // Run cleanup (will fail on docker compose down, but should still attempt state file removal) + _ = cleanupLocalQuiet(tmpDir) + + // Check that state file was removed + if _, err := os.Stat(stateFile); !os.IsNotExist(err) { + t.Errorf("expected state file to be removed, but it still exists") + } +} diff --git a/internal/devlake/discovery_test.go b/internal/devlake/discovery_test.go index 29daa54..ceea7ec 100644 --- a/internal/devlake/discovery_test.go +++ b/internal/devlake/discovery_test.go @@ -427,3 +427,52 @@ func TestPingURLNon200Status(t *testing.T) { t.Error("expected error for non-200 status, got nil") } } + +// TestPingURL tests the PingURL function with trailing slash normalization. +func TestPingURL(t *testing.T) { + // Create a test server that responds to /ping + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/ping" { + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + tests := []struct { + name string + baseURL string + wantErr bool + }{ + { + name: "URL without trailing slash", + baseURL: server.URL, + wantErr: false, + }, + { + name: "URL with trailing slash", + baseURL: server.URL + "/", + wantErr: false, + }, + { + name: "URL with multiple trailing slashes", + baseURL: server.URL + "///", + wantErr: false, + }, + { + name: "unreachable URL", + baseURL: "http://127.0.0.1:0", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := PingURL(tt.baseURL) + if (err != nil) != tt.wantErr { + t.Errorf("PingURL() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} From ff154b292a7b277bb867f3ada62db176f52f2df7 Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Fri, 22 May 2026 09:59:53 +0000 Subject: [PATCH 08/11] Address all code review findings - Fixed Azure cleanup guidance to use --state-file instead of --dir - Fixed indentation in deploy_azure.go restart branch - Removed leading \n from sub-items in Azure restart guidance - Fixed deployLocalQuiet to allow detection while suppressing prompts (auto-resume in quiet mode) - Updated 'restart' wording to reflect actual cleanup scope (only stops containers and removes images) - Fixed resume message formatting (now uses emoji prefix instead of 3-space indent) - Refactored cleanupLocalQuiet to use docker package helper (ComposeDownWithFile) - Refactored tests to properly test detection logic in quiet mode - Replaced custom contains() helper with strings.Contains in tests - Added unit test for PingURL with trailing slash normalization (already existed) All validation passed: go build, go vet, go test Agent-Logs-Url: https://github.com/DevExpGbb/gh-devlake/sessions/a4aab852-8650-47f9-b3d7-2ca1ff4b5a9e Co-authored-by: ewega <26189114+ewega@users.noreply.github.com> --- cmd/deploy_azure.go | 22 ++++++++------- cmd/deploy_azure_test.go | 48 ++++++++++++++------------------ cmd/deploy_local.go | 29 ++++++++++--------- cmd/deploy_local_test.go | 60 ++++++++++++++++++++++++++++------------ internal/docker/build.go | 17 ++++++++++-- 5 files changed, 107 insertions(+), 69 deletions(-) diff --git a/cmd/deploy_azure.go b/cmd/deploy_azure.go index 8d5a1a5..d4e03b4 100644 --- a/cmd/deploy_azure.go +++ b/cmd/deploy_azure.go @@ -82,17 +82,18 @@ func runDeployAzure(cmd *cobra.Command, args []string) error { case "restart": fmt.Println("\n๐Ÿงน To restart, you need to clean up the existing deployment first") fmt.Println(" Note: This will delete all Azure resources in the resource group") - if deployAzureDir != "." { - fmt.Printf(" Please run: gh devlake cleanup --azure --dir \"%s\"\n", deployAzureDir) - fmt.Printf(" Then re-run: gh devlake deploy azure --dir \"%s\"\n", deployAzureDir) - } else { - fmt.Println(" Please run: gh devlake cleanup --azure") - fmt.Println(" Then re-run: gh devlake deploy azure") - } - return nil + if deployAzureDir != "." { + stateFile := filepath.Join(deployAzureDir, ".devlake-azure.json") + fmt.Printf(" Please run: gh devlake cleanup --azure --state-file \"%s\"\n", stateFile) + fmt.Printf(" Then re-run: gh devlake deploy azure --dir \"%s\"\n", deployAzureDir) + } else { + fmt.Println(" Please run: gh devlake cleanup --azure") + fmt.Println(" Then re-run: gh devlake deploy azure") + } + return nil case "resume": // Continue with the deployment - may update existing resources - fmt.Println("\n Continuing with deployment (will update existing resources)...") + fmt.Println("\n๐Ÿš€ Continuing with deployment (will update existing resources)...") } } @@ -333,7 +334,8 @@ func runDeployAzure(cmd *cobra.Command, args []string) error { fmt.Println("\n To retry:") fmt.Println(" โ€ข If partial deployment exists, re-run will attempt to continue") if deployAzureDir != "." { - fmt.Printf(" โ€ข To start fresh: gh devlake cleanup --azure --dir \"%s\", then deploy again\n", deployAzureDir) + stateFile := filepath.Join(deployAzureDir, ".devlake-azure.json") + fmt.Printf(" โ€ข To start fresh: gh devlake cleanup --azure --state-file \"%s\", then deploy again\n", stateFile) } else { fmt.Println(" โ€ข To start fresh: gh devlake cleanup --azure, then deploy again") } diff --git a/cmd/deploy_azure_test.go b/cmd/deploy_azure_test.go index 2b532ba..0e5a677 100644 --- a/cmd/deploy_azure_test.go +++ b/cmd/deploy_azure_test.go @@ -3,18 +3,19 @@ package cmd import ( "os" "path/filepath" + "strings" "testing" ) // TestDetectExistingAzureDeployment_NoState tests detection when no state file exists. func TestDetectExistingAzureDeployment_NoState(t *testing.T) { tmpDir := t.TempDir() - + // Set quiet mode to avoid prompts oldQuiet := deployAzureQuiet deployAzureQuiet = true defer func() { deployAzureQuiet = oldQuiet }() - + state, action := detectExistingAzureDeployment(tmpDir) if state != nil { t.Errorf("expected nil state, got %+v", state) @@ -24,22 +25,22 @@ func TestDetectExistingAzureDeployment_NoState(t *testing.T) { } } -// TestDetectExistingAzureDeployment_QuietMode tests that quiet mode skips detection. +// TestDetectExistingAzureDeployment_QuietMode tests that quiet mode skips prompts but still detects state. func TestDetectExistingAzureDeployment_QuietMode(t *testing.T) { tmpDir := t.TempDir() - + // Create a state file stateFile := filepath.Join(tmpDir, ".devlake-azure.json") stateData := `{"deployedAt":"2024-01-01T00:00:00Z","resourceGroup":"test-rg","region":"eastus"}` if err := os.WriteFile(stateFile, []byte(stateData), 0644); err != nil { t.Fatal(err) } - + // Set quiet mode oldQuiet := deployAzureQuiet deployAzureQuiet = true defer func() { deployAzureQuiet = oldQuiet }() - + state, action := detectExistingAzureDeployment(tmpDir) if state != nil { t.Errorf("expected nil state in quiet mode, got %+v", state) @@ -52,18 +53,18 @@ func TestDetectExistingAzureDeployment_QuietMode(t *testing.T) { // TestDetectExistingAzureDeployment_CorruptedState tests handling of corrupted state file. func TestDetectExistingAzureDeployment_CorruptedState(t *testing.T) { tmpDir := t.TempDir() - + // Create a corrupted state file stateFile := filepath.Join(tmpDir, ".devlake-azure.json") if err := os.WriteFile(stateFile, []byte("not valid json{"), 0644); err != nil { t.Fatal(err) } - + // Set quiet mode to avoid prompts oldQuiet := deployAzureQuiet deployAzureQuiet = true defer func() { deployAzureQuiet = oldQuiet }() - + state, action := detectExistingAzureDeployment(tmpDir) if state != nil { t.Errorf("expected nil state for corrupted file, got %+v", state) @@ -76,35 +77,28 @@ func TestDetectExistingAzureDeployment_CorruptedState(t *testing.T) { // TestSavePartialAzureState tests the partial state saving functionality. func TestSavePartialAzureState(t *testing.T) { tmpDir := t.TempDir() - + savePartialAzureState(tmpDir, "test-rg", "eastus") - + stateFile := filepath.Join(tmpDir, ".devlake-azure.json") if _, err := os.Stat(stateFile); os.IsNotExist(err) { t.Fatal("expected state file to be created") } - + data, err := os.ReadFile(stateFile) if err != nil { t.Fatal(err) } - + // Check that the file contains expected content content := string(data) - if !containsAll(content, "test-rg", "eastus", "partial") { - t.Errorf("state file missing expected content: %s", content) + if !strings.Contains(content, "test-rg") { + t.Errorf("state file missing 'test-rg': %s", content) } -} - -func containsAll(s string, substrs ...string) bool { - for _, substr := range substrs { - if !contains(s, substr) { - return false - } + if !strings.Contains(content, "eastus") { + t.Errorf("state file missing 'eastus': %s", content) + } + if !strings.Contains(content, "partial") { + t.Errorf("state file missing 'partial': %s", content) } - return true -} - -func contains(s, substr string) bool { - return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && (s[:len(substr)] == substr || contains(s[1:], substr))) } diff --git a/cmd/deploy_local.go b/cmd/deploy_local.go index 3c67d16..beb4ff2 100644 --- a/cmd/deploy_local.go +++ b/cmd/deploy_local.go @@ -85,7 +85,7 @@ func runDeployLocal(cmd *cobra.Command, args []string) error { } case "resume": // Continue with the deployment - existing artifacts will be reused - fmt.Println("\n Continuing with existing deployment artifacts...") + fmt.Println("\n๐Ÿ”„ Continuing with existing deployment artifacts...") } } @@ -584,12 +584,8 @@ func startLocalContainers(dir string, build bool, services ...string) (string, e // detectExistingLocalDeployment checks for existing deployment artifacts and prompts for action. // Returns the existing state (if found) and the user's choice: "resume", "restart", or "abort". +// In quiet mode, returns "resume" as the default action if artifacts exist. func detectExistingLocalDeployment(dir string) (*devlake.State, string) { - if deployLocalQuiet { - // When called from init wizard, don't prompt - return nil, "" - } - absDir, _ := filepath.Abs(dir) stateFile := filepath.Join(absDir, ".devlake-local.json") @@ -621,6 +617,10 @@ func detectExistingLocalDeployment(dir string) (*devlake.State, string) { // If we have artifacts but no state file, it might be a partial deployment if hasCompose || hasEnv { + if deployLocalQuiet { + // In quiet mode, default to resume + return nil, "resume" + } fmt.Println("\n๐Ÿ“‹ Found existing deployment artifacts:") if hasCompose { fmt.Printf(" โ€ข %s\n", composeFileName) @@ -634,6 +634,10 @@ func detectExistingLocalDeployment(dir string) (*devlake.State, string) { } } else { // State file exists - check if deployment is running + if deployLocalQuiet { + // In quiet mode, default to resume + return state, "resume" + } fmt.Println("\n๐Ÿ“‹ Found existing deployment:") fmt.Printf(" Deployed: %s\n", state.DeployedAt) if state.Endpoints.Backend != "" { @@ -651,7 +655,7 @@ func detectExistingLocalDeployment(dir string) (*devlake.State, string) { fmt.Println() choices := []string{ "resume - Continue with existing artifacts (recommended for recovery)", - "restart - Clean up and start fresh", + "restart - Stop containers and remove local images", "abort - Exit without making changes", } choice := prompt.Select("What would you like to do?", choices) @@ -664,6 +668,8 @@ func detectExistingLocalDeployment(dir string) (*devlake.State, string) { } // cleanupLocalQuiet performs cleanup of local deployment without prompts (used for restart). +// Note: This only stops containers and removes local images. It does NOT remove .env, +// docker-compose*.yml files, or data volumes. func cleanupLocalQuiet(dir string) error { absDir, _ := filepath.Abs(dir) @@ -676,12 +682,9 @@ func cleanupLocalQuiet(dir string) error { return fmt.Errorf("docker compose down failed: %w", err) } } else if _, err := os.Stat(devComposePath); err == nil { - // For docker-compose-dev.yml, we need to run docker compose explicitly - // since ComposeDown expects docker-compose.yml by default - cmd := exec.Command("docker", "compose", "-f", devComposePath, "down", "--rmi", "local") - cmd.Dir = absDir - if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("docker compose down failed: %w\n%s", err, string(out)) + // For docker-compose-dev.yml, use the explicit file variant + if err := dockerpkg.ComposeDownWithFile(absDir, devComposePath); err != nil { + return fmt.Errorf("docker compose down failed: %w", err) } } diff --git a/cmd/deploy_local_test.go b/cmd/deploy_local_test.go index 5f1cb2a..d4a3737 100644 --- a/cmd/deploy_local_test.go +++ b/cmd/deploy_local_test.go @@ -47,49 +47,75 @@ func TestRewritePoetryInstallLine_NoChangeWhenLineMissing(t *testing.T) { func TestDetectExistingLocalDeployment_NoArtifacts(t *testing.T) { // Create a temp directory with no artifacts tmpDir := t.TempDir() - - // Set quiet mode to avoid prompts + + // Don't use quiet mode to test actual detection behavior oldQuiet := deployLocalQuiet - deployLocalQuiet = true + deployLocalQuiet = false defer func() { deployLocalQuiet = oldQuiet }() - + + // Note: This would normally prompt, but with no artifacts it should return immediately state, action := detectExistingLocalDeployment(tmpDir) if state != nil { t.Errorf("expected nil state, got %+v", state) } if action != "" { - t.Errorf("expected empty action, got %q", action) + t.Errorf("expected empty action when no artifacts, got %q", action) } } -// TestDetectExistingLocalDeployment_QuietMode tests that quiet mode skips detection. -func TestDetectExistingLocalDeployment_QuietMode(t *testing.T) { +// TestDetectExistingLocalDeployment_QuietModeWithArtifacts tests that quiet mode returns "resume" when artifacts exist. +func TestDetectExistingLocalDeployment_QuietModeWithArtifacts(t *testing.T) { tmpDir := t.TempDir() - + // Create some artifacts composePath := filepath.Join(tmpDir, "docker-compose.yml") if err := os.WriteFile(composePath, []byte("version: '3'\n"), 0644); err != nil { t.Fatal(err) } - + // Set quiet mode oldQuiet := deployLocalQuiet deployLocalQuiet = true defer func() { deployLocalQuiet = oldQuiet }() - + state, action := detectExistingLocalDeployment(tmpDir) if state != nil { - t.Errorf("expected nil state in quiet mode, got %+v", state) + t.Errorf("expected nil state (no state file), got %+v", state) } - if action != "" { - t.Errorf("expected empty action in quiet mode, got %q", action) + if action != "resume" { + t.Errorf("expected 'resume' action in quiet mode with artifacts, got %q", action) + } +} + +// TestDetectExistingLocalDeployment_QuietModeWithState tests that quiet mode returns "resume" when state file exists. +func TestDetectExistingLocalDeployment_QuietModeWithState(t *testing.T) { + tmpDir := t.TempDir() + + // Create a state file + stateFile := filepath.Join(tmpDir, ".devlake-local.json") + stateData := `{"deployed_at":"2024-01-01T00:00:00Z","endpoints":{"backend":"http://localhost:8080"}}` + if err := os.WriteFile(stateFile, []byte(stateData), 0644); err != nil { + t.Fatal(err) + } + + // Set quiet mode + oldQuiet := deployLocalQuiet + deployLocalQuiet = true + defer func() { deployLocalQuiet = oldQuiet }() + + state, action := detectExistingLocalDeployment(tmpDir) + if state == nil { + t.Errorf("expected state to be loaded, got nil") + } + if action != "resume" { + t.Errorf("expected 'resume' action in quiet mode with state, got %q", action) } } // TestCleanupLocalQuiet_NoArtifacts tests cleanup when no artifacts exist. func TestCleanupLocalQuiet_NoArtifacts(t *testing.T) { tmpDir := t.TempDir() - + err := cleanupLocalQuiet(tmpDir) if err != nil { t.Errorf("expected no error when no artifacts exist, got %v", err) @@ -99,17 +125,17 @@ func TestCleanupLocalQuiet_NoArtifacts(t *testing.T) { // TestCleanupLocalQuiet_StateFileRemoval tests state file removal. func TestCleanupLocalQuiet_StateFileRemoval(t *testing.T) { tmpDir := t.TempDir() - + // Create a state file stateFile := filepath.Join(tmpDir, ".devlake-local.json") stateData := `{"deployed_at":"2024-01-01T00:00:00Z","endpoints":{"backend":"http://localhost:8080"}}` if err := os.WriteFile(stateFile, []byte(stateData), 0644); err != nil { t.Fatal(err) } - + // Run cleanup (will fail on docker compose down, but should still attempt state file removal) _ = cleanupLocalQuiet(tmpDir) - + // Check that state file was removed if _, err := os.Stat(stateFile); !os.IsNotExist(err) { t.Errorf("expected state file to be removed, but it still exists") diff --git a/internal/docker/build.go b/internal/docker/build.go index 0c0f3a9..a252ab6 100644 --- a/internal/docker/build.go +++ b/internal/docker/build.go @@ -46,15 +46,28 @@ func TagAndPush(localTag, remoteTag string) error { // ComposeDown runs docker compose down in the specified directory. // If removeVolumes is true, it passes the -v flag to also remove data volumes. // Images built from local Dockerfiles are always removed (--rmi local). +// An optional composeFile parameter can specify an alternate compose file (e.g., docker-compose-dev.yml). func ComposeDown(dir string, removeVolumes ...bool) error { - args := []string{"compose", "down", "--rmi", "local"} + return ComposeDownWithFile(dir, "", removeVolumes...) +} + +// ComposeDownWithFile runs docker compose down with an optional compose file override. +// If composeFile is empty, docker compose will use its default file discovery. +// If removeVolumes is true, it passes the -v flag to also remove data volumes. +// Images built from local Dockerfiles are always removed (--rmi local). +func ComposeDownWithFile(dir string, composeFile string, removeVolumes ...bool) error { + args := []string{"compose"} + if composeFile != "" { + args = append(args, "-f", composeFile) + } + args = append(args, "down", "--rmi", "local") if len(removeVolumes) > 0 && removeVolumes[0] { args = append(args, "-v") } cmd := execCommand("docker", args...) cmd.Dir = dir if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("docker compose down failed: %s\n%s", err, string(out)) + return fmt.Errorf("docker compose down failed: %w\n%s", err, string(out)) } return nil } From 3474c31f26c152d867da559606c40a1e993e56c8 Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Fri, 22 May 2026 11:22:52 +0000 Subject: [PATCH 09/11] Address all Code Review Agent findings from latest review cycle - Make cleanupLocalQuiet best-effort (continue state cleanup even when compose down fails) - Fix test fixture JSON to use correct 'deployedAt' tag instead of 'deployed_at' - Simplify LoadState error check (remove redundant os.IsNotExist check) - Fix ComposeDown doc comment (remove mention of non-existent parameter) - Update test comments to accurately reflect quiet mode behavior - Update cleanupLocalQuiet test comment to match actual behavior Agent-Logs-Url: https://github.com/DevExpGbb/gh-devlake/sessions/527d6a3c-d45c-40fd-945e-c47d8a7e9e49 Co-authored-by: ewega <26189114+ewega@users.noreply.github.com> --- cmd/deploy_azure_test.go | 2 +- cmd/deploy_local.go | 17 ++++++++++------- cmd/deploy_local_test.go | 6 +++--- internal/docker/build.go | 1 - 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/cmd/deploy_azure_test.go b/cmd/deploy_azure_test.go index 0e5a677..aa4a2ee 100644 --- a/cmd/deploy_azure_test.go +++ b/cmd/deploy_azure_test.go @@ -25,7 +25,7 @@ func TestDetectExistingAzureDeployment_NoState(t *testing.T) { } } -// TestDetectExistingAzureDeployment_QuietMode tests that quiet mode skips prompts but still detects state. +// TestDetectExistingAzureDeployment_QuietMode tests that quiet mode skips detection and prompting. func TestDetectExistingAzureDeployment_QuietMode(t *testing.T) { tmpDir := t.TempDir() diff --git a/cmd/deploy_local.go b/cmd/deploy_local.go index beb4ff2..aa2b568 100644 --- a/cmd/deploy_local.go +++ b/cmd/deploy_local.go @@ -591,10 +591,11 @@ func detectExistingLocalDeployment(dir string) (*devlake.State, string) { // Check for state file state, err := devlake.LoadState(stateFile) - if err != nil && !os.IsNotExist(err) { + if err != nil { + // LoadState returns (nil, nil) for missing files, so only non-nil errors are real failures fmt.Printf("\nโš ๏ธ Unable to read local deployment state from %s: %v\n", stateFile, err) } - if err != nil || state == nil { + if state == nil { // No state file or failed to load - check for docker-compose.yml + .env composePath := filepath.Join(absDir, "docker-compose.yml") devComposePath := filepath.Join(absDir, "docker-compose-dev.yml") @@ -670,25 +671,27 @@ func detectExistingLocalDeployment(dir string) (*devlake.State, string) { // cleanupLocalQuiet performs cleanup of local deployment without prompts (used for restart). // Note: This only stops containers and removes local images. It does NOT remove .env, // docker-compose*.yml files, or data volumes. +// This is a best-effort cleanup - state file removal is attempted even if compose down fails. func cleanupLocalQuiet(dir string) error { absDir, _ := filepath.Abs(dir) - // Stop containers if compose file exists + // Stop containers if compose file exists (best-effort) composePath := filepath.Join(absDir, "docker-compose.yml") devComposePath := filepath.Join(absDir, "docker-compose-dev.yml") + var composeErr error if _, err := os.Stat(composePath); err == nil { if err := dockerpkg.ComposeDown(absDir); err != nil { - return fmt.Errorf("docker compose down failed: %w", err) + composeErr = fmt.Errorf("docker compose down failed: %w", err) } } else if _, err := os.Stat(devComposePath); err == nil { // For docker-compose-dev.yml, use the explicit file variant if err := dockerpkg.ComposeDownWithFile(absDir, devComposePath); err != nil { - return fmt.Errorf("docker compose down failed: %w", err) + composeErr = fmt.Errorf("docker compose down failed: %w", err) } } - // Remove state file + // Remove state file (even if compose down failed) stateFile := filepath.Join(absDir, ".devlake-local.json") if _, err := os.Stat(stateFile); err == nil { if err := os.Remove(stateFile); err != nil { @@ -696,5 +699,5 @@ func cleanupLocalQuiet(dir string) error { } } - return nil + return composeErr } diff --git a/cmd/deploy_local_test.go b/cmd/deploy_local_test.go index d4a3737..9f68472 100644 --- a/cmd/deploy_local_test.go +++ b/cmd/deploy_local_test.go @@ -93,7 +93,7 @@ func TestDetectExistingLocalDeployment_QuietModeWithState(t *testing.T) { // Create a state file stateFile := filepath.Join(tmpDir, ".devlake-local.json") - stateData := `{"deployed_at":"2024-01-01T00:00:00Z","endpoints":{"backend":"http://localhost:8080"}}` + stateData := `{"deployedAt":"2024-01-01T00:00:00Z","endpoints":{"backend":"http://localhost:8080"}}` if err := os.WriteFile(stateFile, []byte(stateData), 0644); err != nil { t.Fatal(err) } @@ -128,12 +128,12 @@ func TestCleanupLocalQuiet_StateFileRemoval(t *testing.T) { // Create a state file stateFile := filepath.Join(tmpDir, ".devlake-local.json") - stateData := `{"deployed_at":"2024-01-01T00:00:00Z","endpoints":{"backend":"http://localhost:8080"}}` + stateData := `{"deployedAt":"2024-01-01T00:00:00Z","endpoints":{"backend":"http://localhost:8080"}}` if err := os.WriteFile(stateFile, []byte(stateData), 0644); err != nil { t.Fatal(err) } - // Run cleanup (will fail on docker compose down, but should still attempt state file removal) + // Run cleanup (no compose file exists, so only state removal will be attempted) _ = cleanupLocalQuiet(tmpDir) // Check that state file was removed diff --git a/internal/docker/build.go b/internal/docker/build.go index a252ab6..c4a096c 100644 --- a/internal/docker/build.go +++ b/internal/docker/build.go @@ -46,7 +46,6 @@ func TagAndPush(localTag, remoteTag string) error { // ComposeDown runs docker compose down in the specified directory. // If removeVolumes is true, it passes the -v flag to also remove data volumes. // Images built from local Dockerfiles are always removed (--rmi local). -// An optional composeFile parameter can specify an alternate compose file (e.g., docker-compose-dev.yml). func ComposeDown(dir string, removeVolumes ...bool) error { return ComposeDownWithFile(dir, "", removeVolumes...) } From e6818e0b477f0d6ce2c194e43fcbfeb9b5a94a50 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 13:26:06 +0000 Subject: [PATCH 10/11] Address review comments: fix port conflict message, improve comments, use structured type, add PingURLQuick Co-authored-by: ewega <26189114+ewega@users.noreply.github.com> --- cmd/deploy_azure.go | 63 +++++++++++++++++------------- cmd/deploy_local.go | 13 +++--- internal/devlake/discovery.go | 21 +++++++--- internal/devlake/discovery_test.go | 5 ++- 4 files changed, 62 insertions(+), 40 deletions(-) diff --git a/cmd/deploy_azure.go b/cmd/deploy_azure.go index d4e03b4..e05801c 100644 --- a/cmd/deploy_azure.go +++ b/cmd/deploy_azure.go @@ -471,6 +471,19 @@ func conditionalACR() any { return "devlakeacr" + azure.Suffix(azureRG) } +// azureDeploymentState holds the fields read from .devlake-azure.json for deployment detection. +// It covers both partial checkpoints (written by savePartialAzureState) and the full state +// written at the end of a successful deployment (unknown fields are ignored on unmarshal). +type azureDeploymentState struct { + DeployedAt string `json:"deployedAt"` + ResourceGroup string `json:"resourceGroup"` + Region string `json:"region"` + Partial bool `json:"partial"` + Endpoints struct { + Backend string `json:"backend"` + } `json:"endpoints"` +} + // savePartialAzureState writes a minimal state file immediately after the // Resource Group is created so that cleanup --azure always has a breadcrumb, // even when the deployment fails mid-flight (e.g. Docker build errors). @@ -478,11 +491,11 @@ func conditionalACR() any { func savePartialAzureState(dir, rg, region string) { absDir, _ := filepath.Abs(dir) stateFile := filepath.Join(absDir, ".devlake-azure.json") - partial := map[string]any{ - "deployedAt": time.Now().Format(time.RFC3339), - "resourceGroup": rg, - "region": region, - "partial": true, + partial := azureDeploymentState{ + DeployedAt: time.Now().Format(time.RFC3339), + ResourceGroup: rg, + Region: region, + Partial: true, } data, _ := json.MarshalIndent(partial, "", " ") if err := os.WriteFile(stateFile, data, 0644); err != nil { @@ -492,7 +505,7 @@ func savePartialAzureState(dir, rg, region string) { // detectExistingAzureDeployment checks for existing Azure deployment state and prompts for action. // Returns any existing state data and the user's choice: "resume", "restart", or "abort". -func detectExistingAzureDeployment(dir string) (map[string]any, string) { +func detectExistingAzureDeployment(dir string) (*azureDeploymentState, string) { if deployAzureQuiet { // When called from init wizard, don't prompt return nil, "" @@ -511,7 +524,7 @@ func detectExistingAzureDeployment(dir string) (map[string]any, string) { return nil, "" } - var state map[string]any + var state azureDeploymentState if err := json.Unmarshal(data, &state); err != nil { // State file is corrupted - warn and proceed fmt.Printf("\nโš ๏ธ Found .devlake-azure.json but could not parse it: %v\n", err) @@ -520,32 +533,28 @@ func detectExistingAzureDeployment(dir string) (map[string]any, string) { // Display existing deployment info fmt.Println("\n๐Ÿ“‹ Found existing Azure deployment:") - if deployedAt, ok := state["deployedAt"].(string); ok { - fmt.Printf(" Deployed: %s\n", deployedAt) + if state.DeployedAt != "" { + fmt.Printf(" Deployed: %s\n", state.DeployedAt) } - if rg, ok := state["resourceGroup"].(string); ok { - fmt.Printf(" Resource Group: %s\n", rg) + if state.ResourceGroup != "" { + fmt.Printf(" Resource Group: %s\n", state.ResourceGroup) } - if region, ok := state["region"].(string); ok { - fmt.Printf(" Region: %s\n", region) + if state.Region != "" { + fmt.Printf(" Region: %s\n", state.Region) } // Check if this is a partial deployment (failed mid-way) - isPartial := false - if partial, ok := state["partial"].(bool); ok && partial { + if state.Partial { fmt.Println(" Status: โš ๏ธ Partial deployment (may have failed)") - isPartial = true } // Check if endpoints are available and reachable - if endpoints, ok := state["endpoints"].(map[string]any); ok { - if backend, ok := endpoints["backend"].(string); ok && backend != "" { - fmt.Printf(" Backend: %s\n", backend) - if err := devlake.PingURL(backend); err == nil { - fmt.Println(" Status: โœ… Running") - } else { - fmt.Println(" Status: โš ๏ธ Not responding (may be stopped)") - } + if state.Endpoints.Backend != "" { + fmt.Printf(" Backend: %s\n", state.Endpoints.Backend) + if err := devlake.PingURLQuick(state.Endpoints.Backend); err == nil { + fmt.Println(" Status: โœ… Running") + } else { + fmt.Println(" Status: โš ๏ธ Not responding (may be stopped)") } } @@ -556,16 +565,16 @@ func detectExistingAzureDeployment(dir string) (map[string]any, string) { "abort - Exit without making changes", } - if isPartial { + if state.Partial { // For partial deployments, recommend resume choices[0] = "resume - Continue deployment from where it failed (recommended)" } choice := prompt.Select("What would you like to do?", choices) if choice == "" { - return state, "abort" + return &state, "abort" } action := strings.SplitN(choice, " ", 2)[0] - return state, action + return &state, action } diff --git a/cmd/deploy_local.go b/cmd/deploy_local.go index aa2b568..b685e23 100644 --- a/cmd/deploy_local.go +++ b/cmd/deploy_local.go @@ -538,7 +538,7 @@ func startLocalContainers(dir string, build bool, services ...string) (string, e 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("\n After stopping the conflicting container, re-run:") fmt.Println(" gh devlake deploy local") fmt.Println("\n๐Ÿ’ก To clean up partial artifacts:") fmt.Println(" gh devlake cleanup --local --force") @@ -596,7 +596,9 @@ func detectExistingLocalDeployment(dir string) (*devlake.State, string) { fmt.Printf("\nโš ๏ธ Unable to read local deployment state from %s: %v\n", stateFile, err) } if state == nil { - // No state file or failed to load - check for docker-compose.yml + .env + // No state file found (LoadState returns nil without error for missing files), + // or the state file existed but failed to parse (warning already printed above). + // Either way, check for other deployment artifacts: docker-compose.yml + .env composePath := filepath.Join(absDir, "docker-compose.yml") devComposePath := filepath.Join(absDir, "docker-compose-dev.yml") envPath := filepath.Join(absDir, ".env") @@ -645,7 +647,7 @@ func detectExistingLocalDeployment(dir string) (*devlake.State, string) { fmt.Printf(" Backend: %s\n", state.Endpoints.Backend) // Check if backend is still running - if err := devlake.PingURL(state.Endpoints.Backend); err == nil { + if err := devlake.PingURLQuick(state.Endpoints.Backend); err == nil { fmt.Println(" Status: โœ… Running") } else { fmt.Println(" Status: โš ๏ธ Not responding (may be stopped)") @@ -669,8 +671,9 @@ func detectExistingLocalDeployment(dir string) (*devlake.State, string) { } // cleanupLocalQuiet performs cleanup of local deployment without prompts (used for restart). -// Note: This only stops containers and removes local images. It does NOT remove .env, -// docker-compose*.yml files, or data volumes. +// It runs `docker compose down --rmi local`, which stops running containers and removes +// images built from local Dockerfiles. It does NOT remove .env, docker-compose*.yml files, +// or data volumes. // This is a best-effort cleanup - state file removal is attempted even if compose down fails. func cleanupLocalQuiet(dir string) error { absDir, _ := filepath.Abs(dir) diff --git a/internal/devlake/discovery.go b/internal/devlake/discovery.go index d1cabf0..9142b6a 100644 --- a/internal/devlake/discovery.go +++ b/internal/devlake/discovery.go @@ -23,7 +23,7 @@ func Discover(explicitURL string) (*DiscoveryResult, error) { // 1. Explicit URL if explicitURL != "" { url := strings.TrimRight(explicitURL, "/") - if err := pingURL(url); err != nil { + if err := pingURL(url, 5*time.Second); err != nil { return nil, fmt.Errorf("cannot reach DevLake at %s: %w", url, err) } grafanaURL, configUIURL := inferLocalCompanionURLs(url) @@ -49,7 +49,7 @@ func Discover(explicitURL string) (*DiscoveryResult, error) { {"http://localhost:8085", "http://localhost:3004", "http://localhost:4004"}, } for _, c := range candidates { - if err := pingURL(c.url); err == nil { + if err := pingURL(c.url, 5*time.Second); err == nil { return &DiscoveryResult{ URL: c.url, GrafanaURL: c.grafana, @@ -78,7 +78,7 @@ func tryStateFile(path string) *DiscoveryResult { return nil } - if err := pingURL(url); err != nil { + if err := pingURL(url, 5*time.Second); err != nil { fmt.Fprintf(os.Stderr, " โš ๏ธ Found DevLake URL in %s: %s\n", filepath.Base(path), url) fmt.Fprintf(os.Stderr, " Could not reach /ping: %v\n", err) return nil @@ -107,13 +107,22 @@ func inferLocalCompanionURLs(backendURL string) (grafanaURL, configUIURL string) } // PingURL checks if a DevLake backend is reachable at the given URL. +// It uses a 5-second timeout, suitable for startup polling where the backend may be slow to respond. +// For status-check contexts where a fast failure is preferred, use PingURLQuick. func PingURL(baseURL string) error { baseURL = strings.TrimRight(baseURL, "/") - return pingURL(baseURL) + return pingURL(baseURL, 5*time.Second) } -func pingURL(baseURL string) error { - client := &http.Client{Timeout: 5 * time.Second} +// PingURLQuick checks if a DevLake backend is reachable using a short 2-second timeout. +// Intended for pre-prompt status checks where blocking for 5 seconds would degrade UX. +func PingURLQuick(baseURL string) error { + baseURL = strings.TrimRight(baseURL, "/") + return pingURL(baseURL, 2*time.Second) +} + +func pingURL(baseURL string, timeout time.Duration) error { + client := &http.Client{Timeout: timeout} resp, err := client.Get(baseURL + "/ping") if err != nil { return err diff --git a/internal/devlake/discovery_test.go b/internal/devlake/discovery_test.go index ceea7ec..f0f40af 100644 --- a/internal/devlake/discovery_test.go +++ b/internal/devlake/discovery_test.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "testing" + "time" ) func closedLocalURL(t *testing.T) string { @@ -403,7 +404,7 @@ func TestPingURLSuccess(t *testing.T) { })) defer srv.Close() - err := pingURL(srv.URL) + err := pingURL(srv.URL, 5*time.Second) if err != nil { t.Errorf("unexpected error: %v", err) } @@ -422,7 +423,7 @@ func TestPingURLNon200Status(t *testing.T) { })) defer srv.Close() - err := pingURL(srv.URL) + err := pingURL(srv.URL, 5*time.Second) if err == nil { t.Error("expected error for non-200 status, got nil") } From 5fc3362169687243fdfb71454385bf799060b05a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 13:27:10 +0000 Subject: [PATCH 11/11] Refine doc comments for savePartialAzureState and cleanupLocalQuiet Co-authored-by: ewega <26189114+ewega@users.noreply.github.com> --- cmd/deploy_azure.go | 2 +- cmd/deploy_local.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/deploy_azure.go b/cmd/deploy_azure.go index e05801c..031c2c9 100644 --- a/cmd/deploy_azure.go +++ b/cmd/deploy_azure.go @@ -484,7 +484,7 @@ type azureDeploymentState struct { } `json:"endpoints"` } -// savePartialAzureState writes a minimal state file immediately after the +// savePartialAzureState writes a minimal state file to dir immediately after the // Resource Group is created so that cleanup --azure always has a breadcrumb, // even when the deployment fails mid-flight (e.g. Docker build errors). // The full state write at the end of a successful deployment overwrites this. diff --git a/cmd/deploy_local.go b/cmd/deploy_local.go index b685e23..96f4c24 100644 --- a/cmd/deploy_local.go +++ b/cmd/deploy_local.go @@ -674,7 +674,7 @@ func detectExistingLocalDeployment(dir string) (*devlake.State, string) { // It runs `docker compose down --rmi local`, which stops running containers and removes // images built from local Dockerfiles. It does NOT remove .env, docker-compose*.yml files, // or data volumes. -// This is a best-effort cleanup - state file removal is attempted even if compose down fails. +// State file removal is always attempted as a final step, even if compose down fails. func cleanupLocalQuiet(dir string) error { absDir, _ := filepath.Abs(dir)