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
12 changes: 10 additions & 2 deletions docs/runware_serverless_apps_workers.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@ List and inspect workers for a serverless application

### Synopsis

List workers observed for an application, including terminal stopped rows
until they are purged.
List workers observed for an application.

The default state is all: terminal stopped rows stay in the page until they
are purged. Pass --state live to drop them. --state live with --status stopped
is refused by the API (422), because an empty page would read as "this app
has never run".

```
runware serverless apps workers <appId> [flags]
Expand All @@ -17,6 +21,9 @@ runware serverless apps workers <appId> [flags]
# list workers for an application
runware serverless apps workers my-app

# omit terminal stopped rows
runware serverless apps workers my-app --state live

# filter by status
runware serverless apps workers my-app --status ready

Expand All @@ -30,6 +37,7 @@ runware serverless apps workers <appId> [flags]
--cursor string Pagination cursor from a previous nextCursor
-h, --help help for workers
--limit int Maximum number of workers to return (1-100)
--state string Include stopped rows (all, the API default) or drop them (live)
--status string Filter by status (ready, busy, pending, …)
```

Expand Down
9 changes: 8 additions & 1 deletion docs/runware_serverless_deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ copies). The directory is zipped and uploaded as source type container. Runware
builds a hosted wrapper image from that archive; the version records a buildId,
not a customer image reference. Invalid container.yaml is rejected on create
(400 if it cannot be parsed, 422 if it breaks a rule). The app stays
initializing until that first build rolls out.
initializing until that first build rolls out. Pass --wait to poll until the
application is active or failed. A successful wait is not a live worker:
minWorkers=0 stays scaled to zero until the first invoke.

--container cannot be combined with an entry file, --src-dir, --base-image, or
--requirement.
Expand Down Expand Up @@ -76,6 +78,9 @@ runware serverless deploy [file] [flags]

# deploy a container source (Dockerfile + container.yaml at the directory root)
runware serverless deploy --id my-app --gpu-type h100 --container ./wrapper

# wait until the first rollout is active or failed
runware serverless deploy ./app.py --id my-app --gpu-type h100 --wait
```

### Options
Expand All @@ -93,10 +98,12 @@ runware serverless deploy [file] [flags]
--max-workers int32 Maximum number of workers (default 1)
--min-workers int32 Minimum number of workers
--name string Display name (defaults to --id)
--poll-interval duration Polling interval when waiting for the application (default 2s)
--requirement stringArray Additional pip package to install (repeatable; code deploys only)
--scaling-delay int32 Scaling delay in seconds (default 10)
--src-dir string Directory to package as the application source (default: the working directory; code deploys only)
--volume stringArray Absolute path inside the app backed by persistent node-local storage (repeatable)
--wait Poll until the application is active or failed
```

### Options inherited from parent commands
Expand Down
58 changes: 58 additions & 0 deletions internal/api/serverless/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,12 +128,30 @@ type Worker = gen.Worker
// AppStatus is an app lifecycle status.
type AppStatus = gen.AppStatus

const (
AppStatusActive AppStatus = gen.AppStatusActive
AppStatusDeleted AppStatus = gen.AppStatusDeleted
AppStatusDeleting AppStatus = gen.AppStatusDeleting
AppStatusFailed AppStatus = gen.AppStatusFailed
AppStatusInitializing AppStatus = gen.AppStatusInitializing
AppStatusStopped AppStatus = gen.AppStatusStopped
AppStatusStopping AppStatus = gen.AppStatusStopping
)

// AppSort is a listApps ordering.
type AppSort = gen.AppSort

// WorkerStatus is a worker lifecycle status.
type WorkerStatus = gen.WorkerStatus

// WorkerStateFilter selects whether listWorkers includes terminal stopped rows.
type WorkerStateFilter = gen.WorkerStateFilter

const (
WorkerStateFilterAll WorkerStateFilter = gen.WorkerStateFilterAll
WorkerStateFilterLive WorkerStateFilter = gen.WorkerStateFilterLive
)

// Limit is a page size for cursor-paginated list endpoints.
type Limit = gen.Limit

Expand Down Expand Up @@ -360,6 +378,46 @@ func (c *Client) GetApp(ctx context.Context, appID string) (*App, error) {
}
}

// defaultAppPollInterval is used when WaitApp is called with a non-positive interval.
const defaultAppPollInterval = 2 * time.Second

// WaitApp polls getApp until the application leaves initializing/stopping.
// Active is success; failed, stopped, deleting, and deleted are also terminal
// so a create or redeploy wait cannot spin forever. The caller inspects Status.
func (c *Client) WaitApp(ctx context.Context, appID string, interval time.Duration) (*App, error) {
if interval <= 0 {
interval = defaultAppPollInterval
}

for {
app, err := c.GetApp(ctx, appID)
if err != nil {
return nil, err
}
if AppDeployTerminal(app.Status) {
return app, nil
}

timer := time.NewTimer(interval)
select {
case <-ctx.Done():
timer.Stop()
return nil, ctx.Err()
case <-timer.C:
}
}
}

// AppDeployTerminal reports whether WaitApp would stop polling for status.
func AppDeployTerminal(status AppStatus) bool {
switch status {
case AppStatusActive, AppStatusFailed, AppStatusStopped, AppStatusDeleting, AppStatusDeleted:
return true
default:
return false
}
}

// UpdateApp patches an app in place. Omitted fields are left unchanged.
// Currently persisted: appName and configuration.
func (c *Client) UpdateApp(ctx context.Context, appID string, body AppUpdate) (*App, error) {
Expand Down
111 changes: 108 additions & 3 deletions internal/api/serverless/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import (
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"

"github.com/google/uuid"
"github.com/runware/runware-cli/internal/api/transport"
Expand Down Expand Up @@ -191,7 +193,7 @@ func TestCreateApp(t *testing.T) {
if err != nil {
t.Fatalf("CreateApp: %v", err)
}
if app.AppId != testAppID || string(app.Status) != "initializing" {
if app.AppId != testAppID || app.Status != AppStatusInitializing {
t.Errorf("unexpected app: %+v", app)
}
}
Expand Down Expand Up @@ -477,11 +479,94 @@ func TestGetApp(t *testing.T) {
if err != nil {
t.Fatalf("GetApp: %v", err)
}
if app.AppId != testAppID || string(app.Status) != "initializing" {
if app.AppId != testAppID || app.Status != AppStatusInitializing {
t.Errorf("unexpected app: %+v", app)
}
}

func TestWaitApp_PollsUntilActive(t *testing.T) {
var gets atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/v1/apps/"+testAppID {
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
}
n := gets.Add(1)
w.Header().Set("Content-Type", "application/json")
status := AppStatusInitializing
if n > 1 {
status = AppStatusActive
}
_, _ = w.Write([]byte(testAppJSON(status)))
}))
defer srv.Close()

c := newClient("test-key", srv.URL, slog.Default(), srv.Client())
app, err := c.WaitApp(context.Background(), testAppID, time.Millisecond)
if err != nil {
t.Fatalf("WaitApp: %v", err)
}
if app.Status != AppStatusActive {
t.Fatalf("unexpected app: %+v", app)
}
if gets.Load() < 2 {
t.Fatalf("expected at least 2 GETs, got %d", gets.Load())
}
}

func TestWaitApp_FailedIsTerminal(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(testAppJSON(AppStatusFailed)))
}))
defer srv.Close()

c := newClient("test-key", srv.URL, slog.Default(), srv.Client())
app, err := c.WaitApp(context.Background(), testAppID, time.Millisecond)
if err != nil {
t.Fatalf("WaitApp: %v", err)
}
if app.Status != AppStatusFailed {
t.Fatalf("unexpected app: %+v", app)
}
}

func TestAppDeployTerminal(t *testing.T) {
terminal := []AppStatus{
AppStatusActive,
AppStatusFailed,
AppStatusStopped,
AppStatusDeleting,
AppStatusDeleted,
}
for _, status := range terminal {
if !AppDeployTerminal(status) {
t.Errorf("%s should be terminal", status)
}
}
for _, status := range []AppStatus{AppStatusInitializing, AppStatusStopping} {
if AppDeployTerminal(status) {
t.Errorf("%s should keep polling", status)
}
}
}

func TestWaitApp_ContextCanceled(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(testAppJSON(AppStatusInitializing)))
}))
defer srv.Close()

ctx, cancel := context.WithCancel(context.Background())
cancel()

c := newClient("test-key", srv.URL, slog.Default(), srv.Client())
_, err := c.WaitApp(ctx, testAppID, time.Second)
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected context.Canceled, got %v", err)
}
}

func TestGetApp_NotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/problem+json")
Expand Down Expand Up @@ -1273,6 +1358,9 @@ func TestListWorkers(t *testing.T) {
if got := r.URL.Query().Get("status"); got != testStatusReady {
t.Errorf("status query = %q, want ready", got)
}
if got := r.URL.Query().Get("state"); got != string(WorkerStateFilterLive) {
t.Errorf("state query = %q, want live", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{
"id":"` + testWorkerID + `",
Expand All @@ -1287,8 +1375,12 @@ func TestListWorkers(t *testing.T) {
defer srv.Close()

status := WorkerStatus(testStatusReady)
state := WorkerStateFilterLive
c := newClient("test-key", srv.URL, slog.Default(), srv.Client())
page, err := c.ListWorkers(context.Background(), testAppID, &ListWorkersParams{Status: &status})
page, err := c.ListWorkers(context.Background(), testAppID, &ListWorkersParams{
Status: &status,
State: &state,
})
if err != nil {
t.Fatalf("ListWorkers: %v", err)
}
Expand Down Expand Up @@ -1361,3 +1453,16 @@ func TestGetWorker_NoAPIKey(t *testing.T) {
t.Fatalf("expected ErrNoAPIKey, got %v", err)
}
}

func testAppJSON(status AppStatus) string {
return `{
"appId":"my-app",
"appName":"My App",
"status":"` + string(status) + `",
"configuration":{"maxWorkers":1,"idleTtlSecs":60,"scalingDelaySecs":10,"minWorkers":0,"gpusPerWorker":1,"concurrency":1,"gracefulStopTtlSecs":120,"computeType":"gpu"},
"environmentVariables":[],
"secrets":[],
"createdAt":"2026-07-30T12:00:00Z",
"updatedAt":"2026-07-30T12:00:00Z"
}`
}
32 changes: 28 additions & 4 deletions internal/cmd/serverless/apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,16 +244,24 @@ func newAppsWorkersCmd(logger *log.Logger) *cobra.Command {
limit int
cursor string
status string
state string
)

cmd := &cobra.Command{
Use: "workers <appId>",
Short: "List and inspect workers for a serverless application",
Long: `List workers observed for an application, including terminal stopped rows
until they are purged.`,
Long: `List workers observed for an application.

The default state is all: terminal stopped rows stay in the page until they
are purged. Pass --state live to drop them. --state live with --status stopped
is refused by the API (422), because an empty page would read as "this app
has never run".`,
Example: ` # list workers for an application
runware serverless apps workers my-app

# omit terminal stopped rows
runware serverless apps workers my-app --state live

# filter by status
runware serverless apps workers my-app --status ready

Expand All @@ -269,11 +277,16 @@ until they are purged.`,
if err != nil {
return err
}
stateVal, err := parseWorkerState(state)
if err != nil {
return err
}
var params *serverlessapi.ListWorkersParams
if limit > 0 || cursor != "" || status != "" {
if limit > 0 || cursor != "" || status != "" || state != "" {
params = &serverlessapi.ListWorkersParams{}
params.Limit, params.Cursor = listPageParams(limit, cursor)
params.Status = statusVal
params.State = stateVal
}

spin := cmdutil.NewSpinner(fmt.Sprintf("Fetching workers for %s...", id))
Expand All @@ -287,13 +300,14 @@ until they are purged.`,
}
spin.Stop()

return printPage(cmdutil.FormatFor(cmd), page, workersResult(page.Data), cmd.ErrOrStderr(), extraStatusCursorFlag(status))
return printPage(cmdutil.FormatFor(cmd), page, workersResult(page.Data), cmd.ErrOrStderr(), extraWorkersCursorFlags(state, status))
},
}

cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of workers to return (1-100)")
cmd.Flags().StringVar(&cursor, "cursor", "", "Pagination cursor from a previous nextCursor")
cmd.Flags().StringVar(&status, "status", "", "Filter by status (ready, busy, pending, …)")
cmd.Flags().StringVar(&state, "state", "", "Include stopped rows (all, the API default) or drop them (live)")
cmd.AddCommand(newAppsWorkersShowCmd(logger))
return cmd
}
Expand Down Expand Up @@ -371,6 +385,10 @@ func parseWorkerStatus(status string) (*serverlessapi.WorkerStatus, error) {
return parseValidFlag[serverlessapi.WorkerStatus]("--status", status, "pending, pulling, loading, ready, busy, unhealthy, draining, stopping, or stopped")
}

func parseWorkerState(state string) (*serverlessapi.WorkerStateFilter, error) {
return parseValidFlag[serverlessapi.WorkerStateFilter]("--state", state, "live or all")
}

// extraListCursorFlags repeats the apps-list filter flags a next-page --cursor is bound to.
func extraListCursorFlags(query, gpuType, sort, status string) string {
parts := make([]string, 0, 4)
Expand All @@ -386,6 +404,12 @@ func extraStatusCursorFlag(value string) string {
return strings.Join(appendFlag(nil, "--status", value), " ")
}

// extraWorkersCursorFlags repeats workers list filters a next-page --cursor is bound to.
func extraWorkersCursorFlags(state, status string) string {
parts := appendFlag(nil, "--state", state)
return strings.Join(appendFlag(parts, "--status", status), " ")
}

func appendFlag(parts []string, name, value string) []string {
if value == "" {
return parts
Expand Down
Loading