diff --git a/cmd/deploy_azure.go b/cmd/deploy_azure.go index 14e9a12..a922622 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,23 @@ 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๐Ÿงน 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{ @@ -146,10 +164,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) } } @@ -163,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...") @@ -289,6 +318,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) } @@ -425,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, @@ -438,3 +478,83 @@ 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 { + 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_local.go b/cmd/deploy_local.go index 917282c..3c67d16 100644 --- a/cmd/deploy_local.go +++ b/cmd/deploy_local.go @@ -71,6 +71,24 @@ func runDeployLocal(cmd *cobra.Command, args []string) error { } } + // โ”€โ”€ Check for existing deployment โ”€โ”€ + _, resumeAction := detectExistingLocalDeployment(deployLocalDir) + if resumeAction != "" { + 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 +195,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: %w", err) } fmt.Println(" โœ… Docker found") @@ -518,10 +539,10 @@ 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") + 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") @@ -536,7 +557,141 @@ 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:") + + // 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: %w", err) } 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 && !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") + devComposePath := filepath.Join(absDir, "docker-compose-dev.yml") + 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 + 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.Printf(" โ€ข %s\n", composeFileName) + } + 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 { + // 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)) + } + } + + // Remove state file + stateFile := filepath.Join(absDir, ".devlake-local.json") + if _, err := os.Stat(stateFile); err == nil { + if err := os.Remove(stateFile); err != nil { + fmt.Printf("\nโš ๏ธ Failed to remove local state file %s: %v\n", stateFile, err) + } + } + + return nil +} diff --git a/internal/devlake/discovery.go b/internal/devlake/discovery.go index e5e3ecd..d1cabf0 100644 --- a/internal/devlake/discovery.go +++ b/internal/devlake/discovery.go @@ -106,6 +106,12 @@ 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 { + baseURL = strings.TrimRight(baseURL, "/") + return pingURL(baseURL) +} + func pingURL(baseURL string) error { client := &http.Client{Timeout: 5 * time.Second} resp, err := client.Get(baseURL + "/ping")