From ca3ce488a53ccc7fb5c2ff4dd94c26244150b7a6 Mon Sep 17 00:00:00 2001 From: ryank90 Date: Tue, 22 Sep 2026 17:32:25 +0100 Subject: [PATCH 1/3] fix(serverless): print endpoints after a first deploy --wait already warned when an update moved an endpoint. A first deploy that becomes active now prints the paths and an invoke example. --- docs/runware_serverless_deploy.md | 3 ++- internal/cmd/serverless/deploy.go | 7 ++++++- internal/cmd/serverless/deploy_endpoints.go | 10 ++++++++++ .../cmd/serverless/deploy_endpoints_test.go | 18 ++++++++++++++++++ 4 files changed, 36 insertions(+), 2 deletions(-) diff --git a/docs/runware_serverless_deploy.md b/docs/runware_serverless_deploy.md index 3cc25f6..eef4126 100644 --- a/docs/runware_serverless_deploy.md +++ b/docs/runware_serverless_deploy.md @@ -59,7 +59,8 @@ A code app's endpoint path is its handler's method name with underscores turned into hyphens, so renaming a method moves a public endpoint and 404s its callers. Updating an existing application with --wait reports what the deploy did to the endpoint set once the rollout lands. It is a report, not a gate: renaming an -endpoint on purpose is allowed. +endpoint on purpose is allowed. A first deploy with --wait prints the endpoint +paths and an invoke example once the application is active. ``` runware serverless deploy [file] [flags] diff --git a/internal/cmd/serverless/deploy.go b/internal/cmd/serverless/deploy.go index b87ce33..f22769e 100644 --- a/internal/cmd/serverless/deploy.go +++ b/internal/cmd/serverless/deploy.go @@ -189,7 +189,8 @@ A code app's endpoint path is its handler's method name with underscores turned into hyphens, so renaming a method moves a public endpoint and 404s its callers. Updating an existing application with --wait reports what the deploy did to the endpoint set once the rollout lands. It is a report, not a gate: renaming an -endpoint on purpose is allowed.`, +endpoint on purpose is allowed. A first deploy with --wait prints the endpoint +paths and an invoke example once the application is active.`, Example: ` # deploy the current directory, with app.py as the entry point runware serverless deploy ./app.py --id my-app --gpu-type h100 @@ -347,6 +348,10 @@ endpoint on purpose is allowed.`, reportEndpointSetChange(cmd.ErrOrStderr(), compareEndpointSets(endpointsBefore, paths)) } } + } else if wait && !update && app.Status == serverlessapi.AppStatusActive { + if paths, err := deployEndpointPaths(cmd.Context(), client, app.AppId); err == nil { + reportDeployEndpoints(cmd.ErrOrStderr(), app.AppId, paths) + } } if err := output.Print(cmdutil.FormatFor(cmd), appResult(*app)); err != nil { diff --git a/internal/cmd/serverless/deploy_endpoints.go b/internal/cmd/serverless/deploy_endpoints.go index 5b19775..93ebf29 100644 --- a/internal/cmd/serverless/deploy_endpoints.go +++ b/internal/cmd/serverless/deploy_endpoints.go @@ -204,6 +204,16 @@ func reportEndpointSetChange(w io.Writer, change endpointSetChange) { } } +// reportDeployEndpoints tells the customer how to call a first deploy. w must +// be stderr: stdout carries the app record --format json promises. +func reportDeployEndpoints(w io.Writer, appID string, paths []string) { + if len(paths) == 0 { + return + } + _, _ = fmt.Fprintf(w, "Endpoints: %s\n", quotedPaths(paths)) + _, _ = fmt.Fprintf(w, "Invoke: runware serverless apps invoke %s %s -f payload.json\n", appID, paths[0]) +} + func quotedPaths(paths []string) string { out := "" for i, path := range paths { diff --git a/internal/cmd/serverless/deploy_endpoints_test.go b/internal/cmd/serverless/deploy_endpoints_test.go index fe32a13..d488bc4 100644 --- a/internal/cmd/serverless/deploy_endpoints_test.go +++ b/internal/cmd/serverless/deploy_endpoints_test.go @@ -344,3 +344,21 @@ func TestDeployEndpointPathsSurfacesTheError(t *testing.T) { t.Fatal("expected an error for 500") } } + +func TestReportDeployEndpoints(t *testing.T) { + var out bytes.Buffer + reportDeployEndpoints(&out, testAppID, nil) + if out.Len() != 0 { + t.Fatalf("empty set wrote %q", out.String()) + } + + reportDeployEndpoints(&out, testAppID, []string{testEndpointPath, testOtherEndpointPath}) + got := out.String() + if !strings.Contains(got, "'"+testEndpointPath+"'") || !strings.Contains(got, "'"+testOtherEndpointPath+"'") { + t.Fatalf("paths missing: %q", got) + } + wantInvoke := "Invoke: runware serverless apps invoke " + testAppID + " " + testEndpointPath + " -f payload.json\n" + if !strings.Contains(got, wantInvoke) { + t.Fatalf("invoke example missing: %q", got) + } +} From acf894b8f7fb4df833fe2006a274f0fe0e4755f9 Mon Sep 17 00:00:00 2001 From: ryank90 Date: Tue, 22 Sep 2026 19:58:21 +0100 Subject: [PATCH 2/3] fix(serverless): treat a create 409 as an update A racing create falls through to UpdateApp but still printed first-deploy endpoints, which can be the outgoing version's paths. --- internal/cmd/serverless/deploy.go | 1 + internal/cmd/serverless/deploy_endpoints_test.go | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/cmd/serverless/deploy.go b/internal/cmd/serverless/deploy.go index f22769e..9e7bf3f 100644 --- a/internal/cmd/serverless/deploy.go +++ b/internal/cmd/serverless/deploy.go @@ -316,6 +316,7 @@ paths and an invoke example once the application is active.`, }, }) if isHTTPConflict(err) { + update = true app, err = client.UpdateApp(cmd.Context(), id, serverlessapi.AppUpdate{ AppSource: &appSource, }) diff --git a/internal/cmd/serverless/deploy_endpoints_test.go b/internal/cmd/serverless/deploy_endpoints_test.go index d488bc4..cb022ce 100644 --- a/internal/cmd/serverless/deploy_endpoints_test.go +++ b/internal/cmd/serverless/deploy_endpoints_test.go @@ -345,13 +345,16 @@ func TestDeployEndpointPathsSurfacesTheError(t *testing.T) { } } -func TestReportDeployEndpoints(t *testing.T) { +func TestReportDeployEndpoints_SaysNothingWhenThereAreNoPaths(t *testing.T) { var out bytes.Buffer reportDeployEndpoints(&out, testAppID, nil) if out.Len() != 0 { t.Fatalf("empty set wrote %q", out.String()) } +} +func TestReportDeployEndpoints_PrintsPathsAndAnInvokeExample(t *testing.T) { + var out bytes.Buffer reportDeployEndpoints(&out, testAppID, []string{testEndpointPath, testOtherEndpointPath}) got := out.String() if !strings.Contains(got, "'"+testEndpointPath+"'") || !strings.Contains(got, "'"+testOtherEndpointPath+"'") { From 6cab6df7e2ae9d0e01a9075342bf4779fdf29fed Mon Sep 17 00:00:00 2001 From: Ryan Kerry Date: Wed, 23 Sep 2026 19:05:58 +0100 Subject: [PATCH 3/3] fix(serverless): show build status while deploy --wait polls (#135) ## Summary - `deploy --wait` reports the application status and the newest build status while the image build is still running. - The line is printed again only when that status changes. A build list that cannot be read does not fail the wait. ## Test plan - [ ] `runware serverless deploy ./app.py --id my-app --gpu-type h100 --wait` prints `Waiting for application my-app (initializing, build building)...` while the build runs - [ ] The line changes when the build status changes, and the command still exits when the app is active or failed --- internal/cmd/serverless/deploy.go | 67 +++++++++++++++++++++- internal/cmd/serverless/deploy_test.go | 77 ++++++++++++++++++++++++++ 2 files changed, 141 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..00eb34b 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,82 @@ 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 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)