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
2 changes: 1 addition & 1 deletion docs/runware_serverless_apps.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,6 @@ runware serverless apps [flags]
* [runware serverless apps stop](runware_serverless_apps_stop.md) - Stop a serverless application
* [runware serverless apps tasks](runware_serverless_apps_tasks.md) - List and inspect application tasks
* [runware serverless apps usage](runware_serverless_apps_usage.md) - Show usage for a serverless application
* [runware serverless apps versions](runware_serverless_apps_versions.md) - Inspect application versions
* [runware serverless apps versions](runware_serverless_apps_versions.md) - Manage application versions
* [runware serverless apps workers](runware_serverless_apps_workers.md) - List workers for a serverless application

8 changes: 8 additions & 0 deletions docs/runware_serverless_apps_invoke.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ to poll until the task is completed or failed.
expires, the command polls the returned task id; it never treats expiry as
a failure and never resubmits.

A client-generated task id is sent with every invoke. Omit --task-id to
generate one. Resubmitting the same id returns the task it already names
instead of starting a second run.

```
runware serverless apps invoke <appId> <endpointPath> [flags]
```
Expand All @@ -32,6 +36,9 @@ runware serverless apps invoke <appId> <endpointPath> [flags]

# async invoke and poll
runware serverless apps invoke my-app infer --wait -f payload.json

# retry a lost response without starting a second task
runware serverless apps invoke my-app infer --task-id 7c9e6679-7425-40de-944b-e07fc1f90ae7 -f payload.json
```

### Options
Expand All @@ -41,6 +48,7 @@ runware serverless apps invoke <appId> <endpointPath> [flags]
-h, --help help for invoke
--poll-interval duration Polling interval when waiting for a task (default 2s)
--sync Use sync invocation and wait for a terminal task
--task-id string Client task id (UUID); generated if omitted
--wait Poll until the task is completed or failed
```

Expand Down
5 changes: 3 additions & 2 deletions docs/runware_serverless_apps_versions.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
## runware serverless apps versions

Inspect application versions
Manage application versions

### Synopsis

List and inspect immutable versions of a serverless application.
List, inspect, and activate immutable versions of a serverless application.

```
runware serverless apps versions [flags]
Expand All @@ -28,6 +28,7 @@ runware serverless apps versions [flags]
### SEE ALSO

* [runware serverless apps](runware_serverless_apps.md) - Manage deployed serverless applications
* [runware serverless apps versions activate](runware_serverless_apps_versions_activate.md) - Activate a ready application version
* [runware serverless apps versions list](runware_serverless_apps_versions_list.md) - List versions of a serverless application
* [runware serverless apps versions show](runware_serverless_apps_versions_show.md) - Show a version of a serverless application

50 changes: 50 additions & 0 deletions docs/runware_serverless_apps_versions_activate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
## runware serverless apps versions activate

Activate a ready application version

### Synopsis

Activate a ready version by number, including rollback to an older version.

The server accepts the deploy and returns immediately with the updated app.
Worker rollout is asynchronous; this command does not wait until workers are
healthy. Re-activating the currently active version is permitted and re-applies
it. On a stopped app the version is recorded and applied on resume.

A missing app is 404. A missing version, a version that is not ready, or an
app that is deleting is 409.

```
runware serverless apps versions activate <appId> <versionNumber> [flags]
```

### Examples

```
# list versions, then activate one
runware serverless apps versions list my-app
runware serverless apps versions activate my-app 2

# roll back to an older ready version
runware serverless apps versions activate my-app 1
```

### Options

```
-h, --help help for activate
```

### Options inherited from parent commands

```
--debug Show full debug output
-F, --format string CLI output format: table, json, yaml (default "table")
--transport string Transport protocol: ws (WebSocket) or http (REST) (default "ws")
-v, --verbose Show request/response details
```

### SEE ALSO

* [runware serverless apps versions](runware_serverless_apps_versions.md) - Manage application versions

2 changes: 1 addition & 1 deletion docs/runware_serverless_apps_versions_list.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,5 @@ runware serverless apps versions list <appId> [flags]

### SEE ALSO

* [runware serverless apps versions](runware_serverless_apps_versions.md) - Inspect application versions
* [runware serverless apps versions](runware_serverless_apps_versions.md) - Manage application versions

2 changes: 1 addition & 1 deletion docs/runware_serverless_apps_versions_show.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,5 @@ runware serverless apps versions show <appId> <versionNumber> [flags]

### SEE ALSO

* [runware serverless apps versions](runware_serverless_apps_versions.md) - Inspect application versions
* [runware serverless apps versions](runware_serverless_apps_versions.md) - Manage application versions

36 changes: 33 additions & 3 deletions internal/api/serverless/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ const defaultTimeout = 30 * time.Second
const createAppTimeout = 5 * time.Minute

// invokeSyncTimeout bounds startSyncTask. It must exceed the platform wait
// window so a 504/202 with taskId is received; a client-side timeout would
// lose the id and force a resubmit (a second billable run).
// window so a 202 with the accepted task is received; a client-side timeout
// would lose the response and force a resubmit (a second billable run unless
// the same client task id is reused).
const invokeSyncTimeout = 5 * time.Minute

// GpuType is the public catalogue entry for a supported GPU type.
Expand Down Expand Up @@ -66,6 +67,9 @@ type AppUpdate = gen.AppUpdate
// WorkerConfigPatch is a partial worker configuration for updateApp.
type WorkerConfigPatch = gen.WorkerConfigPatch

// DeployRequest is the request body for DeployVersion.
type DeployRequest = gen.DeployRequest

// ListAppsParams are optional filters for ListApps.
type ListAppsParams = gen.ListAppsParams

Expand Down Expand Up @@ -94,7 +98,8 @@ type Task = gen.Task
type TaskStatus = gen.TaskStatus

// TaskPayload is the JSON object forwarded to an endpoint handler.
type TaskPayload = gen.TaskPayload
// It is the TaskInvocation.payload member, not the request body itself.
type TaskPayload = map[string]interface{}

// ListTasksParams are optional filters for ListTasks.
type ListTasksParams = gen.ListTasksParams
Expand Down Expand Up @@ -447,6 +452,31 @@ func (c *Client) DeleteApp(ctx context.Context, appID string) (*App, error) {
})
}

// DeployVersion activates a ready version by number. Worker rollout is
// asynchronous; the 202 App reflects the persisted intent (activeVersionId
// and status), not healthy workers. An older ready number is a rollback.
func (c *Client) DeployVersion(ctx context.Context, appID string, versionNumber int32) (*App, error) {
if c.apiKey == "" {
return nil, transport.ErrNoAPIKey
}

resp, err := c.inner.DeployVersionWithResponse(ctx, appID, DeployRequest{
VersionNumber: versionNumber,
})
if err != nil {
return nil, fmt.Errorf("deploy version: %w", err)
}

c.logResponse(ctx, resp.HTTPResponse, resp.Body)

return acceptedApp("deploy version", resp.StatusCode(), resp.JSON202, resp.Body, lifecycleProblems{
Unauthorized: resp.ApplicationproblemJSON401,
Forbidden: resp.ApplicationproblemJSON403,
NotFound: resp.ApplicationproblemJSON404,
Conflict: resp.ApplicationproblemJSON409,
})
}

// lifecycleProblems are typed RFC 9457 bodies bound by the generated client.
type lifecycleProblems struct {
Unauthorized *gen.ProblemDetails
Expand Down
103 changes: 103 additions & 0 deletions internal/api/serverless/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -908,6 +908,109 @@ func TestGetVersion_NoAPIKey(t *testing.T) {
}
}

func TestDeployVersion(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
want := "/v1/apps/" + testAppID + "/deploy"
if r.Method != http.MethodPost || r.URL.Path != want {
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
}
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read body: %v", err)
}
var req DeployRequest
if err := json.Unmarshal(body, &req); err != nil {
t.Errorf("body is not JSON: %s", body)
}
if req.VersionNumber != testVersionNumber {
t.Errorf("versionNumber = %d, want %d", req.VersionNumber, testVersionNumber)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
_, _ = w.Write([]byte(`{
"appId":"my-app",
"appName":"My App",
"status":"initializing",
"activeVersionId":"` + testVersionID + `",
"configuration":{"maxWorkers":1,"idleTtlSecs":60,"scalingDelaySecs":10,"minWorkers":0,"gpusPerWorker":1,"concurrency":1,"computeType":"gpu"},
"environmentVariables":[],
"secrets":[],
"createdAt":"2026-07-30T12:00:00Z",
"updatedAt":"2026-07-30T12:00:00Z"
}`))
}))
defer srv.Close()

c := newClient("test-key", srv.URL, slog.Default(), srv.Client())
app, err := c.DeployVersion(context.Background(), testAppID, testVersionNumber)
if err != nil {
t.Fatalf("DeployVersion: %v", err)
}
if app.AppId != testAppID {
t.Fatalf("unexpected app: %+v", app)
}
if app.ActiveVersionId == nil || app.ActiveVersionId.String() != testVersionID {
t.Fatalf("unexpected activeVersionId: %+v", app.ActiveVersionId)
}
}

func TestDeployVersion_Conflict(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(http.StatusConflict)
_, _ = w.Write([]byte(`{"type":"about:blank","title":"Conflict","status":409,"detail":"Version is not ready"}`))
}))
defer srv.Close()

c := newClient("test-key", srv.URL, slog.Default(), srv.Client())
_, err := c.DeployVersion(context.Background(), testAppID, testVersionNumber)
var re *transport.RunwareError
if !errors.As(err, &re) {
t.Fatalf("expected *transport.RunwareError, got %T: %v", err, err)
}
if re.Code != transport.CodeValidation {
t.Errorf("expected CodeValidation, got %v", re.Code)
}
if re.StatusCode != http.StatusConflict {
t.Errorf("expected status 409, got %d", re.StatusCode)
}
if re.Message != "Version is not ready" {
t.Errorf("unexpected message: %q", re.Message)
}
}

func TestDeployVersion_NotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"type":"about:blank","title":"Not Found","status":404,"detail":"No app 'missing' exists"}`))
}))
defer srv.Close()

c := newClient("test-key", srv.URL, slog.Default(), srv.Client())
_, err := c.DeployVersion(context.Background(), "missing", testVersionNumber)
var re *transport.RunwareError
if !errors.As(err, &re) {
t.Fatalf("expected *transport.RunwareError, got %T: %v", err, err)
}
if re.Code != transport.CodeNotFound {
t.Errorf("expected CodeNotFound, got %v", re.Code)
}
if re.StatusCode != http.StatusNotFound {
t.Errorf("expected status 404, got %d", re.StatusCode)
}
if re.Message != "No app 'missing' exists" {
t.Errorf("unexpected message: %q", re.Message)
}
}

func TestDeployVersion_NoAPIKey(t *testing.T) {
c := NewClient("", "https://example.invalid", slog.Default())
if _, err := c.DeployVersion(context.Background(), testAppID, testVersionNumber); !errors.Is(err, transport.ErrNoAPIKey) {
t.Fatalf("expected ErrNoAPIKey, got %v", err)
}
}

func TestListWorkers(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
want := "/v1/apps/" + testAppID + "/workers"
Expand Down
Loading