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
3 changes: 2 additions & 1 deletion docs/runware_serverless_deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
75 changes: 71 additions & 4 deletions internal/cmd/serverless/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -315,6 +316,7 @@ endpoint on purpose is allowed.`,
},
})
if isHTTPConflict(err) {
update = true
app, err = client.UpdateApp(cmd.Context(), id, serverlessapi.AppUpdate{
AppSource: &appSource,
})
Expand All @@ -325,9 +327,16 @@ endpoint on purpose is allowed.`,
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 All @@ -347,6 +356,10 @@ endpoint on purpose is allowed.`,
reportEndpointSetChange(cmd.ErrOrStderr(), compareEndpointSets(endpointsBefore, paths))
}
}
} else if wait && !update && app.Status == serverlessapi.AppStatusActive {
Comment thread
wilsonsilva marked this conversation as resolved.
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 {
Expand Down Expand Up @@ -456,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
Expand Down
10 changes: 10 additions & 0 deletions internal/cmd/serverless/deploy_endpoints.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
21 changes: 21 additions & 0 deletions internal/cmd/serverless/deploy_endpoints_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -344,3 +344,24 @@ func TestDeployEndpointPathsSurfacesTheError(t *testing.T) {
t.Fatal("expected an error for 500")
}
}

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+"'") {
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)
}
}
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) {
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