-
Notifications
You must be signed in to change notification settings - Fork 4
Harden deploy migration flow during local and Azure deploys #148
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
644a8e8
0735ac2
f289fcd
bbbc087
63a85e0
edb7da9
4b4825f
4cbb5e2
1efb3a3
359b749
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -221,19 +221,65 @@ 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(devlake.NewClient(baseURL), 3, 10*time.Second, 60, 5*time.Second) | ||
| } | ||
|
|
||
| func triggerAndWaitForMigrationWithClient(devlakeClient *devlake.Client, triggerAttempts int, triggerInterval time.Duration, waitAttempts int, waitInterval time.Duration) error { | ||
| fmt.Println("\n🔄 Triggering database migration...") | ||
|
|
||
|
Comment on lines
+249
to
+255
|
||
| var lastErr error | ||
| for attempt := 1; attempt <= triggerAttempts; attempt++ { | ||
| err := devlakeClient.TriggerMigration() | ||
| if err == nil { | ||
| lastErr = 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...") | ||
| } | ||
|
Comment on lines
+256
to
+275
|
||
| if err := waitForMigration(devlakeClient.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 | ||
|
Comment on lines
+276
to
+280
|
||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // ── Scope orchestration ───────────────────────────────────────── | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,131 @@ | ||
| package cmd | ||
|
|
||
| import ( | ||
| "net/http" | ||
| "net/http/httptest" | ||
| "strings" | ||
| "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(client, 1, time.Millisecond, 3, time.Millisecond) | ||
| if err != nil { | ||
|
Comment on lines
+21
to
+44
|
||
| 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(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) | ||
| } | ||
| } | ||
|
Comment on lines
+55
to
+89
|
||
|
|
||
| func TestTriggerAndWaitForMigrationWithClient_TriggerEventuallySucceedsBeforeWaitFails(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.StatusPreconditionRequired) | ||
| default: | ||
| http.NotFound(w, r) | ||
| } | ||
| })) | ||
| defer srv.Close() | ||
|
|
||
| client := devlake.NewClient(srv.URL) | ||
|
|
||
| err := triggerAndWaitForMigrationWithClient(client, 2, 5*time.Millisecond, 2, 5*time.Millisecond) | ||
| if err == nil { | ||
| t.Fatal("expected error, got nil") | ||
| } | ||
| if strings.Contains(err.Error(), "migration trigger failed earlier") { | ||
| t.Fatalf("unexpected trigger failure in error: %v", err) | ||
| } | ||
| if !strings.Contains(err.Error(), "migration did not complete after 2 attempts") { | ||
| t.Fatalf("expected wait failure in error, got: %v", err) | ||
| } | ||
| if triggerCalls != 2 { | ||
| t.Fatalf("trigger calls = %d, want 2", triggerCalls) | ||
| } | ||
| if pingCalls != 2 { | ||
| t.Fatalf("ping calls = %d, want 2", pingCalls) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -8,6 +8,7 @@ import ( | |||||||||||||||||||||||||||||||||||||||||||
| "io" | ||||||||||||||||||||||||||||||||||||||||||||
| "net/http" | ||||||||||||||||||||||||||||||||||||||||||||
| "net/url" | ||||||||||||||||||||||||||||||||||||||||||||
| "strings" | ||||||||||||||||||||||||||||||||||||||||||||
| "time" | ||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -289,6 +290,29 @@ func doGet[T any](c *Client, path string) (*T, error) { | |||||||||||||||||||||||||||||||||||||||||||
| return &result, nil | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| // doGetNoBody is a helper for GET requests that only need a successful 2xx status. | ||||||||||||||||||||||||||||||||||||||||||||
| func doGetNoBody(c *Client, path string) error { | ||||||||||||||||||||||||||||||||||||||||||||
| resp, err := c.HTTPClient.Get(c.BaseURL + path) | ||||||||||||||||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||||||||||||||||
| return fmt.Errorf("GET %s: %w", path, err) | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| defer resp.Body.Close() | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| body, err := io.ReadAll(io.LimitReader(resp.Body, 512)) | ||||||||||||||||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||||||||||||||||
| return fmt.Errorf("GET %s: reading response: %w", path, err) | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices { | ||||||||||||||||||||||||||||||||||||||||||||
| return nil | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+301
to
+307
|
||||||||||||||||||||||||||||||||||||||||||||
| body, err := io.ReadAll(io.LimitReader(resp.Body, 512)) | |
| if err != nil { | |
| return fmt.Errorf("GET %s: reading response: %w", path, err) | |
| } | |
| if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices { | |
| return nil | |
| } | |
| // For successful responses, fully drain the body so the HTTP connection | |
| // can be reused, but ignore the content. | |
| if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices { | |
| _, _ = io.Copy(io.Discard, resp.Body) | |
| return nil | |
| } | |
| // For non-2xx responses, read a small snippet for the error message, | |
| // then discard the remainder to allow connection reuse. | |
| body, err := io.ReadAll(io.LimitReader(resp.Body, 512)) | |
| if err != nil { | |
| return fmt.Errorf("GET %s: reading response: %w", path, err) | |
| } | |
| _, _ = io.Copy(io.Discard, resp.Body) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -909,6 +909,67 @@ func TestHealth(t *testing.T) { | |
| } | ||
| } | ||
|
|
||
| func TestTriggerMigration(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| statusCode int | ||
| body string | ||
| wantErr bool | ||
| wantErrText string | ||
| }{ | ||
| { | ||
| name: "success", | ||
| statusCode: http.StatusOK, | ||
| }, | ||
| { | ||
| name: "no content", | ||
| statusCode: http.StatusNoContent, | ||
| }, | ||
| { | ||
| name: "server error with body", | ||
| statusCode: http.StatusServiceUnavailable, | ||
| body: "warming up", | ||
| wantErr: true, | ||
| wantErrText: "GET /proceed-db-migration: DevLake returned 503 Service Unavailable: warming up", | ||
| }, | ||
| { | ||
| name: "server error without body", | ||
| statusCode: http.StatusBadGateway, | ||
| wantErr: true, | ||
| wantErrText: "GET /proceed-db-migration: DevLake returned 502 Bad Gateway", | ||
| }, | ||
| } | ||
|
|
||
| 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) | ||
| _, _ = w.Write([]byte(tt.body)) | ||
| })) | ||
| defer srv.Close() | ||
|
|
||
| client := NewClient(srv.URL) | ||
| err := client.TriggerMigration() | ||
|
|
||
| if tt.wantErr { | ||
| if err == nil { | ||
| t.Fatal("expected error, got nil") | ||
| } | ||
| if err.Error() != tt.wantErrText { | ||
| t.Fatalf("error = %q, want %q", err.Error(), tt.wantErrText) | ||
| } | ||
| return | ||
| } | ||
| if err != nil { | ||
| t.Fatalf("unexpected error: %v", err) | ||
| } | ||
| }) | ||
|
Comment on lines
+912
to
+969
|
||
| } | ||
| } | ||
|
|
||
| // TestTestSavedConnection tests the TestSavedConnection method. | ||
| func TestTestSavedConnection(t *testing.T) { | ||
| tests := []struct { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
waitForMigrationkeepslastStatusfrom the last successful HTTP response; if a later attempt errors (timeout/DNS/etc.), the progress output can still show a stalestatus=...even though the current attempt didn’t get a response. Consider resettinglastStatusto 0 on request errors (or tracking/printing the last error separately) so the status suffix always reflects an actual response.