Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 64 additions & 3 deletions internal/cmd/serverless/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (non-blocking): waitForAppDeploy copies the poll loop of serverlessapi.Client.WaitApp, and WaitApp now has no caller outside its tests. Add a per-poll callback to WaitApp and keep only the build lookup here; this also removes the second hard-coded 2 * time.Second next to defaultAppPollInterval.

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
Expand Down
77 changes: 77 additions & 0 deletions internal/cmd/serverless/deploy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"

"github.com/google/uuid"
serverlessapi "github.com/runware/runware-cli/internal/api/serverless"
Expand Down Expand Up @@ -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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (non-blocking): The PR says "A build list that cannot be read does not fail the wait", but no test covers it. Add a case where /builds returns 500 and the wait still returns the active app with the no-build message.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed: TestWaitForAppDeploy_ContinuesWhenBuildsFail covers a 500 from /builds; the wait still returns the active app with the no-build message.

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)
Expand Down
Loading