diff --git a/cmd/deploy_azure.go b/cmd/deploy_azure.go index 14e9a12..ff4a7ba 100644 --- a/cmd/deploy_azure.go +++ b/cmd/deploy_azure.go @@ -3,7 +3,6 @@ package cmd import ( "encoding/json" "fmt" - "net/http" "os" "path/filepath" "strings" @@ -304,14 +303,10 @@ func runDeployAzure(cmd *cobra.Command, args []string) error { 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) + if err := triggerAndWaitForMigration(deployment.BackendEndpoint); err != nil { + fmt.Printf(" āš ļø %v\n", err) + fmt.Printf(" Trigger migration manually if needed: GET %s/proceed-db-migration\n", deployment.BackendEndpoint) + fmt.Println(" Migration may still be running — proceeding anyway") } } else { fmt.Println(" Backend not ready after 30 attempts.") diff --git a/cmd/deploy_local.go b/cmd/deploy_local.go index 917282c..61104ac 100644 --- a/cmd/deploy_local.go +++ b/cmd/deploy_local.go @@ -8,7 +8,6 @@ import ( "strings" "time" - "github.com/DevExpGBB/gh-devlake/internal/devlake" dockerpkg "github.com/DevExpGBB/gh-devlake/internal/docker" "github.com/DevExpGBB/gh-devlake/internal/download" "github.com/DevExpGBB/gh-devlake/internal/gitclone" @@ -196,17 +195,10 @@ func runDeployLocal(cmd *cobra.Command, args []string) error { } cfgURL = backendURL - fmt.Println("\nšŸ”„ Triggering database migration...") - migClient := devlake.NewClient(backendURL) - if err := migClient.TriggerMigration(); err != nil { - fmt.Printf(" āš ļø Migration may need manual trigger: %v\n", err) - } else { - fmt.Println(" āœ… Migration triggered") - fmt.Println("\nā³ Waiting for migration to complete...") - if err := waitForMigration(backendURL, 60, 5*time.Second); err != nil { - fmt.Printf(" āš ļø %v\n", err) - fmt.Println(" Migration may still be running — proceeding anyway") - } + if err := triggerAndWaitForMigration(backendURL); err != nil { + fmt.Printf(" āš ļø %v\n", err) + fmt.Printf(" Trigger migration manually if needed: GET %s/proceed-db-migration\n", backendURL) + fmt.Println(" Migration may still be running — proceeding anyway") } if !deployLocalQuiet { diff --git a/cmd/helpers.go b/cmd/helpers.go index edfe91a..f21580b 100644 --- a/cmd/helpers.go +++ b/cmd/helpers.go @@ -221,19 +221,64 @@ func waitForReadyAny(baseURLs []string, maxAttempts int, interval time.Duration) // During migration the API returns 428 (Precondition Required). func waitForMigration(baseURL string, maxAttempts int, interval time.Duration) error { httpClient := &http.Client{Timeout: 5 * time.Second} + lastStatus := 0 for attempt := 1; attempt <= maxAttempts; attempt++ { resp, err := httpClient.Get(baseURL + "/ping") if err == nil { + lastStatus = resp.StatusCode resp.Body.Close() if resp.StatusCode == http.StatusOK { fmt.Println(" āœ… Migration complete!") return nil } } - fmt.Printf(" Migrating... (%d/%d)\n", attempt, maxAttempts) + statusSuffix := "" + if lastStatus != 0 { + statusSuffix = fmt.Sprintf(", status=%d", lastStatus) + } + fmt.Printf(" Migrating... (%d/%d%s)\n", attempt, maxAttempts, statusSuffix) time.Sleep(interval) } - return fmt.Errorf("migration did not complete after %d attempts", maxAttempts) + statusSuffix := "" + if lastStatus != 0 { + statusSuffix = fmt.Sprintf(" (last status %d)", lastStatus) + } + return fmt.Errorf("migration did not complete after %d attempts%s", maxAttempts, statusSuffix) +} + +func triggerAndWaitForMigration(baseURL string) error { + return triggerAndWaitForMigrationWithClient(baseURL, devlake.NewClient(baseURL), 3, 10*time.Second, 60, 5*time.Second) +} + +func triggerAndWaitForMigrationWithClient(baseURL string, devlakeClient *devlake.Client, triggerAttempts int, triggerInterval time.Duration, waitAttempts int, waitInterval time.Duration) error { + fmt.Println("\nšŸ”„ Triggering database migration...") + + var lastErr error + for attempt := 1; attempt <= triggerAttempts; attempt++ { + err := devlakeClient.TriggerMigration() + if err == nil { + fmt.Println(" āœ… Migration triggered") + break + } + lastErr = err + fmt.Printf(" āš ļø Trigger attempt %d/%d failed: %v\n", attempt, triggerAttempts, err) + if attempt < triggerAttempts { + fmt.Println(" DevLake may still be starting or migration may already be running — retrying...") + time.Sleep(triggerInterval) + } + } + + fmt.Println("\nā³ Waiting for migration to complete...") + if lastErr != nil { + fmt.Println(" Continuing to monitor migration status anyway...") + } + if err := waitForMigration(baseURL, waitAttempts, waitInterval); err != nil { + if lastErr != nil { + return fmt.Errorf("migration trigger failed earlier (%v) and waiting for migration completion also failed: %w", lastErr, err) + } + return err + } + return nil } // ── Scope orchestration ───────────────────────────────────────── diff --git a/cmd/helpers_migration_test.go b/cmd/helpers_migration_test.go new file mode 100644 index 0000000..35a2105 --- /dev/null +++ b/cmd/helpers_migration_test.go @@ -0,0 +1,88 @@ +package cmd + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/DevExpGBB/gh-devlake/internal/devlake" +) + +func TestTriggerAndWaitForMigrationWithClient_CompletesAfterTriggerTimeout(t *testing.T) { + triggerCalls := 0 + pingCalls := 0 + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/proceed-db-migration": + triggerCalls++ + time.Sleep(25 * time.Millisecond) + w.WriteHeader(http.StatusOK) + case "/ping": + pingCalls++ + if pingCalls == 1 { + w.WriteHeader(http.StatusPreconditionRequired) + return + } + w.WriteHeader(http.StatusOK) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + client := &devlake.Client{ + BaseURL: srv.URL, + HTTPClient: &http.Client{ + Timeout: 5 * time.Millisecond, + }, + } + + err := triggerAndWaitForMigrationWithClient(srv.URL, client, 1, time.Millisecond, 3, time.Millisecond) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if triggerCalls != 1 { + t.Fatalf("trigger calls = %d, want 1", triggerCalls) + } + if pingCalls != 2 { + t.Fatalf("ping calls = %d, want 2", pingCalls) + } +} + +func TestTriggerAndWaitForMigrationWithClient_RetriesBeforeWaiting(t *testing.T) { + triggerCalls := 0 + pingCalls := 0 + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/proceed-db-migration": + triggerCalls++ + if triggerCalls == 1 { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + w.WriteHeader(http.StatusOK) + case "/ping": + pingCalls++ + w.WriteHeader(http.StatusOK) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + client := devlake.NewClient(srv.URL) + + err := triggerAndWaitForMigrationWithClient(srv.URL, client, 2, time.Millisecond, 2, time.Millisecond) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if triggerCalls != 2 { + t.Fatalf("trigger calls = %d, want 2", triggerCalls) + } + if pingCalls != 1 { + t.Fatalf("ping calls = %d, want 1", pingCalls) + } +} diff --git a/internal/devlake/client.go b/internal/devlake/client.go index 9831e3a..e868567 100644 --- a/internal/devlake/client.go +++ b/internal/devlake/client.go @@ -1,15 +1,16 @@ // Package devlake provides an HTTP client for the DevLake REST API. package devlake -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "time" -) +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) // Client wraps HTTP calls to the DevLake backend API. type Client struct { @@ -506,12 +507,20 @@ func (c *Client) SearchRemoteScopes(plugin string, connID int, search string, pa // TriggerMigration triggers the DevLake database migration endpoint. func (c *Client) TriggerMigration() error { resp, err := c.HTTPClient.Get(c.BaseURL + "/proceed-db-migration") - if err != nil { - return err - } - resp.Body.Close() - return nil -} + if err != nil { + return fmt.Errorf("triggering migration: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + bodyText := strings.TrimSpace(string(body)) + if bodyText != "" { + return fmt.Errorf("DevLake returned status %d: %s", resp.StatusCode, bodyText) + } + return fmt.Errorf("DevLake returned status %d", resp.StatusCode) + } + return nil +} // PipelineListResponse is the response from GET /pipelines. type PipelineListResponse struct { diff --git a/internal/devlake/client_test.go b/internal/devlake/client_test.go index ea5efec..950c128 100644 --- a/internal/devlake/client_test.go +++ b/internal/devlake/client_test.go @@ -909,6 +909,53 @@ func TestHealth(t *testing.T) { } } +func TestTriggerMigration(t *testing.T) { + tests := []struct { + name string + statusCode int + wantErr bool + }{ + { + name: "success", + statusCode: http.StatusOK, + }, + { + name: "no content", + statusCode: http.StatusNoContent, + }, + { + name: "server error", + statusCode: http.StatusServiceUnavailable, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/proceed-db-migration" { + t.Errorf("path = %s, want /proceed-db-migration", r.URL.Path) + } + w.WriteHeader(tt.statusCode) + })) + defer srv.Close() + + client := NewClient(srv.URL) + err := client.TriggerMigration() + + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + // TestTestSavedConnection tests the TestSavedConnection method. func TestTestSavedConnection(t *testing.T) { tests := []struct {