From 6e5d3cfe7ee612518d8dd78be2be29b6685d9b8c Mon Sep 17 00:00:00 2001 From: ryank90 Date: Tue, 22 Sep 2026 17:34:15 +0100 Subject: [PATCH 1/2] fix(serverless): show build status while deploy --wait polls A first deploy sat on one waiting line for the whole image build. The wait now says whether that build is queued or building. --- internal/cmd/serverless/deploy.go | 67 ++++++++++++++++++++++++-- internal/cmd/serverless/deploy_test.go | 43 +++++++++++++++++ 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/internal/cmd/serverless/deploy.go b/internal/cmd/serverless/deploy.go index 9e7bf3f..eec2e6e 100644 --- a/internal/cmd/serverless/deploy.go +++ b/internal/cmd/serverless/deploy.go @@ -327,9 +327,16 @@ paths and an invoke example once the application is active.`, return err } if wait && !serverlessapi.AppDeployTerminal(app.Status) { - _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Application %s is %s; waiting...\n", app.AppId, app.Status) - spin.SetMessage(fmt.Sprintf("Waiting for application %s...", app.AppId)) - app, err = client.WaitApp(cmd.Context(), app.AppId, pollInterval) + var lastStatus string + app, err = waitForAppDeploy(cmd.Context(), client, app.AppId, pollInterval, func(appStatus, buildStatus string) { + msg := deployWaitMessage(app.AppId, appStatus, buildStatus) + if msg == lastStatus { + return + } + lastStatus = msg + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%s\n", msg) + spin.SetMessage(msg) + }) if err != nil { spin.Stop() return err @@ -462,6 +469,60 @@ func optionalFlagStringPtr(cmd *cobra.Command, name, v string) *string { return &v } +// deployWaitMessage is the status line --wait prints while an image build is +// still running. buildStatus is empty when the build list cannot be read. +func deployWaitMessage(appID, appStatus, buildStatus string) string { + if buildStatus == "" { + return fmt.Sprintf("Waiting for application %s (%s)...", appID, appStatus) + } + return fmt.Sprintf("Waiting for application %s (%s, build %s)...", appID, appStatus, buildStatus) +} + +// waitForAppDeploy polls until the app reaches a terminal status. Each +// non-terminal read reports the app status and the newest build status so a +// long image build is not a silent wait. +func waitForAppDeploy( + ctx context.Context, + client *serverlessapi.Client, + appID string, + interval time.Duration, + report func(appStatus, buildStatus string), +) (*serverlessapi.App, error) { + if interval <= 0 { + interval = 2 * time.Second + } + for { + app, err := client.GetApp(ctx, appID) + if err != nil { + return nil, err + } + if serverlessapi.AppDeployTerminal(app.Status) { + return app, nil + } + if report != nil { + report(string(app.Status), latestBuildStatus(ctx, client, appID)) + } + + timer := time.NewTimer(interval) + select { + case <-ctx.Done(): + timer.Stop() + return nil, ctx.Err() + case <-timer.C: + } + } +} + +// latestBuildStatus is the newest build's status. Newest first, as listBuilds +// returns them. An unreadable list is not a failure of the wait. +func latestBuildStatus(ctx context.Context, client *serverlessapi.Client, appID string) string { + page, err := client.ListBuilds(ctx, appID, nil) + if err != nil || len(page.Data) == 0 { + return "" + } + return string(page.Data[0].Status) +} + func appFailedErr(ctx context.Context, client *serverlessapi.Client, app *serverlessapi.App) error { if app == nil { return nil diff --git a/internal/cmd/serverless/deploy_test.go b/internal/cmd/serverless/deploy_test.go index 57a7433..4714def 100644 --- a/internal/cmd/serverless/deploy_test.go +++ b/internal/cmd/serverless/deploy_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/google/uuid" serverlessapi "github.com/runware/runware-cli/internal/api/serverless" @@ -306,6 +307,48 @@ func TestValidateUpdateDeployFlags(t *testing.T) { } } +func TestDeployWaitMessage(t *testing.T) { + if got := deployWaitMessage(testAppID, "initializing", ""); got != "Waiting for application my-app (initializing)..." { + t.Fatalf("no build: %q", got) + } + if got := deployWaitMessage(testAppID, "initializing", "building"); got != "Waiting for application my-app (initializing, build building)..." { + t.Fatalf("building: %q", got) + } +} + +func TestWaitForAppDeploy_ReportsBuildStatus(t *testing.T) { + var gets int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if strings.Contains(r.URL.Path, "/builds") { + _, _ = w.Write([]byte(`{"data":[{"id":"33333333-3333-3333-3333-333333333333","status":"building","phases":[]}]}`)) + return + } + gets++ + status := string(serverlessapi.AppStatusInitializing) + if gets > 1 { + status = string(serverlessapi.AppStatusActive) + } + _, _ = w.Write([]byte(`{"appId":"` + testAppID + `","appName":"My App","status":"` + status + `","configuration":{"maxWorkers":1,"idleTtlSecs":60,"scalingDelaySecs":10,"computeType":"gpu","gpuType":"h100"},"environmentVariables":[],"secrets":[],"createdAt":"2026-07-30T12:00:00Z","updatedAt":"2026-07-30T12:00:00Z"}`)) + })) + defer srv.Close() + + var seen []string + client := serverlessapi.NewClient("test-key", srv.URL, slog.Default()) + app, err := waitForAppDeploy(context.Background(), client, testAppID, time.Millisecond, func(appStatus, buildStatus string) { + seen = append(seen, deployWaitMessage(testAppID, appStatus, buildStatus)) + }) + if err != nil { + t.Fatalf("waitForAppDeploy: %v", err) + } + if app.Status != serverlessapi.AppStatusActive { + t.Fatalf("status = %s", app.Status) + } + if len(seen) != 1 || seen[0] != "Waiting for application my-app (initializing, build building)..." { + t.Fatalf("reports = %#v", seen) + } +} + func TestAppFailedErr(t *testing.T) { if err := appFailedErr(context.Background(), nil, nil); err != nil { t.Fatalf("nil app: %v", err) From 1a65fb7b446c8c9f7fe771362b37675d3edc8fc6 Mon Sep 17 00:00:00 2001 From: ryank90 Date: Tue, 22 Sep 2026 19:59:28 +0100 Subject: [PATCH 2/2] test(serverless): keep --wait going when the build list is unreadble A 500 from /builds must not fail the poll; the status line just omits the build. --- internal/cmd/serverless/deploy_test.go | 34 ++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/internal/cmd/serverless/deploy_test.go b/internal/cmd/serverless/deploy_test.go index 4714def..00eb34b 100644 --- a/internal/cmd/serverless/deploy_test.go +++ b/internal/cmd/serverless/deploy_test.go @@ -349,6 +349,40 @@ func TestWaitForAppDeploy_ReportsBuildStatus(t *testing.T) { } } +func TestWaitForAppDeploy_ContinuesWhenBuildsFail(t *testing.T) { + var gets int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/builds") { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"unavailable"}`)) + return + } + w.Header().Set("Content-Type", "application/json") + gets++ + status := string(serverlessapi.AppStatusInitializing) + if gets > 1 { + status = string(serverlessapi.AppStatusActive) + } + _, _ = w.Write([]byte(`{"appId":"` + testAppID + `","appName":"My App","status":"` + status + `","configuration":{"maxWorkers":1,"idleTtlSecs":60,"scalingDelaySecs":10,"computeType":"gpu","gpuType":"h100"},"environmentVariables":[],"secrets":[],"createdAt":"2026-07-30T12:00:00Z","updatedAt":"2026-07-30T12:00:00Z"}`)) + })) + defer srv.Close() + + var seen []string + client := serverlessapi.NewClient("test-key", srv.URL, slog.Default()) + app, err := waitForAppDeploy(context.Background(), client, testAppID, time.Millisecond, func(appStatus, buildStatus string) { + seen = append(seen, deployWaitMessage(testAppID, appStatus, buildStatus)) + }) + if err != nil { + t.Fatalf("waitForAppDeploy: %v", err) + } + if app.Status != serverlessapi.AppStatusActive { + t.Fatalf("status = %s", app.Status) + } + if len(seen) != 1 || seen[0] != "Waiting for application my-app (initializing)..." { + t.Fatalf("reports = %#v", seen) + } +} + func TestAppFailedErr(t *testing.T) { if err := appFailedErr(context.Background(), nil, nil); err != nil { t.Fatalf("nil app: %v", err)