diff --git a/README.md b/README.md index f81c0dc..de5f3cb 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A command-line tool for interacting with the [Runware](https://runware.ai) API. Built in Go, distributed as a single static binary. -Run image generation, video generation, audio generation, 3D, upscaling, background removal, captioning, search models, and more. +Run image generation, video generation, audio generation, 3D, upscaling, background removal, captioning, search models, deploy serverless applications, and more. ## Install @@ -47,6 +47,9 @@ runware run runware:400@1 positivePrompt="a chess match in the park" width=1024 # Check your account details runware account details + +# Deploy a serverless application +runware serverless deploy ./app.py --id my-app --gpu-type h100 ``` ## Commands @@ -212,6 +215,20 @@ runware result # Resume waiting for an async task by UUID Use when `runware run` was interrupted before a task completed. The taskUUID is printed when the task is first submitted. +### `runware serverless` — applications + +Deploy and manage Runware serverless applications. + +```shell +runware serverless deploy ./app.py --id my-app --gpu-type h100 +runware serverless apps list +runware serverless apps show my-app +runware serverless apps invoke my-app infer -f payload.json --wait +runware serverless apps logs my-app --follow +``` + +Full command reference is under [docs/runware_serverless.md](./docs/runware_serverless.md). + ### Other ```shell diff --git a/api/serverless/openapi.yaml b/api/serverless/openapi.yaml index d2ffedf..c13df8b 100644 --- a/api/serverless/openapi.yaml +++ b/api/serverless/openapi.yaml @@ -434,6 +434,8 @@ paths: $ref: '#/components/schemas/App' '401': $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' '503': $ref: '#/components/responses/ServiceUnavailable' '403': @@ -545,6 +547,8 @@ paths: $ref: '#/components/schemas/App' '401': $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' '503': $ref: '#/components/responses/ServiceUnavailable' '403': @@ -829,6 +833,8 @@ paths: $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' '503': $ref: '#/components/responses/ServiceUnavailable' '403': @@ -898,6 +904,8 @@ paths: $ref: '#/components/schemas/App' '401': $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' '503': $ref: '#/components/responses/ServiceUnavailable' '403': @@ -906,6 +914,8 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/ValidationError' /v1/apps/{appId}/favourite: put: @@ -1317,7 +1327,7 @@ paths: terminal `stopped` rows until purged). Omitted `versionId` scopes the page to the app's `activeVersionId`. An app with no active version therefore answers an empty default page — not that it has no workers, only that - none are pinned. Optional `state` and `status` narrow the page further; + none are pinned. Optional `state`, `status` and `q` narrow the page further; a cursor must be replayed under the same filters it was issued with. operationId: listWorkers parameters: @@ -1353,6 +1363,18 @@ paths: required: false schema: $ref: '#/components/schemas/WorkerStatus' + - name: q + in: query + required: false + description: > + Case-insensitive literal substring match against `id`, `podName`, + `nodeName` and `versionId`. A worker matching any field is returned + within the selected version, state and status filters. Omit `q` to + disable search. Cursors must retain the same search term, ignoring case. + schema: + type: string + minLength: 1 + maxLength: 100 responses: '200': description: A page of workers @@ -2327,15 +2349,20 @@ paths: - Observability summary: Read one page of a named log query description: > - Returns one page of log entries, newest first, with an opaque cursor for the next - page when one exists. Query ids and their supported selectors are listed by the - insights catalogue. + Returns one page of log entries in the requested `sort` order, newest first by + default, with opaque cursors for the neighbouring pages when they exist. + `nextCursor` continues in the `sort` order and `prevCursor` goes back against it, + so a client can walk a window in either direction from either end. A cursor is only + valid for the `sort` it was issued under; reusing one under the other ordering + returns `400`. Query ids and their supported selectors are listed by the insights + catalogue. operationId: getLogEntries parameters: - $ref: '#/components/parameters/QueryId' - $ref: '#/components/parameters/MetricWindow' - $ref: '#/components/parameters/Limit' - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/LogSort' - $ref: '#/components/parameters/SelectorDeployment' - $ref: '#/components/parameters/SelectorEndpoint' responses: @@ -2497,9 +2524,20 @@ components: name: cursor in: query required: false - description: Opaque pagination cursor returned as `nextCursor` by a previous call. + description: > + Opaque pagination cursor returned by a previous call, as `nextCursor` or, on the + operations that offer one, `prevCursor`. schema: type: string + LogSort: + name: sort + in: query + required: false + description: > + Ordering of a log page and the direction `nextCursor` moves in. Only + `getLogEntries` accepts it. + schema: + $ref: '#/components/schemas/LogSort' QueryId: name: queryId @@ -2686,6 +2724,18 @@ components: application/problem+json: schema: $ref: '#/components/schemas/ProblemDetails' + PaymentRequired: + description: > + The organization's credit cannot cover the capacity the request asks for. + The problem `type` is `insufficient-credit` when the available balance + is short, and `credit-suspended` when a refund took back credit already + spent and every allocation is refused until the balance is funded back. + Either way `shortfall` is the amount to add before retrying: the request + is unchanged by the refusal and succeeds as sent once the credit is there. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' ServiceUnavailable: description: A required service is temporarily unavailable content: @@ -3038,6 +3088,15 @@ components: - name - activity - errorRate + LogSort: + type: string + description: > + Ordering of a log page. `newest` is the default: the first row is the + newest entry and `nextCursor` walks older. `oldest` reverses that. + default: newest + enum: + - newest + - oldest BuildStatus: type: string description: Build/validation lifecycle status. @@ -3463,6 +3522,30 @@ components: allOf: - $ref: '#/components/schemas/WorkerConfig' description: Live worker configuration. Updated via `PATCH /apps/{appId}`. + effectiveMaxWorkers: + type: integer + format: int32 + minimum: 0 + nullable: true + readOnly: true + description: > + The worker ceiling the last deploy actually applied, reduced where the + organization's credit balance did not back the whole range. The autoscaler + cannot grow past it. + + + It describes what was applied, not what is configured now, and the two can + differ. It is taken from the `maxWorkers` of the version that was deployed, + so deploying an older version applies that version's ceiling; and a later + `PATCH` of `configuration.maxWorkers` does not change it until the next + deploy. Read it beside `configuration.maxWorkers` rather than as a bound on + it. + + + `null` means nothing has been applied yet. It is recalculated on every + deploy. A committed credit top-up also recalculates a reduced ceiling and + restores the funded range automatically; KEDA then grows the workload from + queue demand without a customer redeploy. runtime: $ref: '#/components/schemas/AppRuntime' secrets: @@ -3722,7 +3805,10 @@ components: type: integer format: int32 default: 1 - description: One GPU per worker is currently supported. Historical apps may contain another value. + description: > + GPUs granted to one worker pod. Create and update accept only the group + sizes the cluster grants indivisibly, since a worker holds its GPUs as one + such group. Historical apps may contain another value. minWorkers: type: integer format: int32 @@ -3818,9 +3904,19 @@ components: gpusPerWorker: type: integer format: int32 - minimum: 0 + enum: [1, 2, 4, 8] + x-go-type: int32 default: 1 - description: Only 1 is currently supported. Any other value returns 422. + description: > + GPUs granted to one worker pod. A worker holds its GPUs as one group the + cluster grants indivisibly, so the count is one of the advertised group + sizes rather than any number in a range. The value must also be a group + size admitted by the cluster backing the chosen `gpuType`: a count above 1 + that cluster does not grant is rejected with a 422 naming + `/configuration/gpusPerWorker`, since the pod could never be scheduled. + A value above 1 requires an image built after the multi-GPU worker + entrypoint; older images serve a single rank while holding every granted + GPU. minWorkers: type: integer format: int32 @@ -3899,8 +3995,18 @@ components: gpusPerWorker: type: integer format: int32 - minimum: 0 - description: Only 1 is currently supported. Any other value returns 422. + enum: [1, 2, 4, 8] + x-go-type: int32 + description: > + GPUs granted to one worker pod. A worker holds its GPUs as one group the + cluster grants indivisibly, so the count is one of the advertised group + sizes rather than any number in a range. The value must also be a group + size admitted by the cluster backing the chosen `gpuType`: a count above 1 + that cluster does not grant is rejected with a 422 naming + `/configuration/gpusPerWorker`, since the pod could never be scheduled. + A value above 1 requires an image built after the multi-GPU worker + entrypoint; older images serve a single rank while holding every granted + GPU. minWorkers: type: integer format: int32 @@ -5164,6 +5270,13 @@ components: per offending request field. items: $ref: '#/components/schemas/ProblemError' + shortfall: + type: string + description: > + Extension member. Present on `402` credit refusals. The amount of + credit to add before retrying the same request. Units match the + platform credit display. + example: "12.50" ProblemError: type: object @@ -5316,6 +5429,11 @@ components: nextCursor: type: string description: Opaque; absent on the last page. + prevCursor: + type: string + description: > + Opaque; absent on the first page. Walks the opposite direction of + `nextCursor` under the same `sort`. LogEntry: type: object required: [time, body] diff --git a/docs/runware.md b/docs/runware.md index de30215..d3dd448 100644 --- a/docs/runware.md +++ b/docs/runware.md @@ -5,7 +5,7 @@ CLI tool for the Runware API ### Synopsis A command-line tool for interacting with the Runware API. -Run image generation, video generation, audio generation, 3D, upscaling, background removal, captioning, search models, and more. +Run image generation, video generation, audio generation, 3D, upscaling, background removal, captioning, search models, deploy serverless applications, and more. Use of Runware services is subject to our Terms of Service (https://runware.ai/terms) and Privacy Policy (https://runware.ai/privacy). diff --git a/docs/runware_serverless_apps.md b/docs/runware_serverless_apps.md index 2a2c60b..9054462 100644 --- a/docs/runware_serverless_apps.md +++ b/docs/runware_serverless_apps.md @@ -32,10 +32,12 @@ runware serverless apps [flags] * [runware serverless apps delete](runware_serverless_apps_delete.md) - Delete a serverless application * [runware serverless apps endpoints](runware_serverless_apps_endpoints.md) - List and inspect endpoints for a serverless application * [runware serverless apps env](runware_serverless_apps_env.md) - Manage plain-text environment variables for an application +* [runware serverless apps errors](runware_serverless_apps_errors.md) - List failed inference requests for a serverless application * [runware serverless apps events](runware_serverless_apps_events.md) - List events for a serverless application * [runware serverless apps invoke](runware_serverless_apps_invoke.md) - Invoke an application endpoint * [runware serverless apps list](runware_serverless_apps_list.md) - List serverless applications * [runware serverless apps logs](runware_serverless_apps_logs.md) - Show or follow logs for a serverless application +* [runware serverless apps rename](runware_serverless_apps_rename.md) - Rename a serverless application * [runware serverless apps resume](runware_serverless_apps_resume.md) - Resume a stopped serverless application * [runware serverless apps scale](runware_serverless_apps_scale.md) - Scale a serverless application * [runware serverless apps show](runware_serverless_apps_show.md) - Show details for a serverless application diff --git a/docs/runware_serverless_apps_env_set.md b/docs/runware_serverless_apps_env_set.md index 40c2861..7bcf80b 100644 --- a/docs/runware_serverless_apps_env_set.md +++ b/docs/runware_serverless_apps_env_set.md @@ -9,6 +9,10 @@ Create or update one plain-text environment variable. Prefer --value-file so the value is not visible in process lists; use --value-file - to read from stdin. +A write that changes the stored value records a new version and rolls live +workers when the application can take one. A write that leaves the value +unchanged records no version and does not roll. + The server rejects (HTTP 422) reserved platform names, names that collide with an attached secret's injected env var, and adding a binding past the 100-variable-plus-secret ceiling. Overwriting an existing key is always diff --git a/docs/runware_serverless_apps_errors.md b/docs/runware_serverless_apps_errors.md new file mode 100644 index 0000000..6062c76 --- /dev/null +++ b/docs/runware_serverless_apps_errors.md @@ -0,0 +1,51 @@ +## runware serverless apps errors + +List failed inference requests for a serverless application + +### Synopsis + +List failed inference requests for an application, newest first. + +Omit --status-class to include both 4xx and 5xx. The cursor is only valid with +the same --window and --status-class it was issued under. + +``` +runware serverless apps errors [flags] +``` + +### Examples + +``` + # list recent request errors + runware serverless apps errors my-app + + # last 24 hours of 5xx only + runware serverless apps errors my-app --window 24h --status-class 5xx + + # page through results + runware serverless apps errors my-app --limit 50 --cursor +``` + +### Options + +``` + --cursor string Pagination cursor from a previous nextCursor (reuse the same --window/--status-class) + -h, --help help for errors + --limit int Maximum number of errors to return (1-100) + --status-class string Filter by status class (4xx or 5xx) + --window string Time window (1h, 6h, 24h, 7d, or 30d) +``` + +### 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](runware_serverless_apps.md) - Manage deployed serverless applications + diff --git a/docs/runware_serverless_apps_invoke.md b/docs/runware_serverless_apps_invoke.md index 3f5e081..414954c 100644 --- a/docs/runware_serverless_apps_invoke.md +++ b/docs/runware_serverless_apps_invoke.md @@ -49,6 +49,7 @@ runware serverless apps invoke [flags] --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 + --timeout duration Maximum time to wait (0 = no limit) --wait Poll until the task is completed or failed ``` diff --git a/docs/runware_serverless_apps_list.md b/docs/runware_serverless_apps_list.md index 80d6a0a..6fa7ac1 100644 --- a/docs/runware_serverless_apps_list.md +++ b/docs/runware_serverless_apps_list.md @@ -33,7 +33,7 @@ runware serverless apps list [flags] -h, --help help for list --limit int Maximum number of applications to return (1-100) --query string Filter by substring on name or ID - --sort string Sort order (createdAt (default), name, activity, or errorRate) + --sort string Sort order (createdAt (default) or name) --status string Filter by status (active, initializing, stopped, …) ``` diff --git a/docs/runware_serverless_apps_logs.md b/docs/runware_serverless_apps_logs.md index b696cea..7eab3c4 100644 --- a/docs/runware_serverless_apps_logs.md +++ b/docs/runware_serverless_apps_logs.md @@ -4,17 +4,19 @@ Show or follow logs for a serverless application ### Synopsis -Show recent application logs, oldest first, and optionally follow new ones. - -The recent page is read from the runtime log query over --window (default 1h), -and --limit and --cursor page through it. With --follow the command prints the -recent page, then streams new entries until interrupted; the stream reconnects -when the server ends it, and waits for the stream to open when a gateway -answers first, which is what an application that has written nothing does. The -live stream has no window, so --window, --limit and --cursor apply to the -recent page only, and --cursor cannot be combined with --follow. Entries -written between the recent page and the start of the stream, or while the -stream reconnects, can be missed or repeated. +Show recent application logs and optionally follow new ones. + +The recent page is read from the runtime log query over --window (default 1h). +--sort oldest (the default) lists oldest first; --sort newest lists newest +first. --limit and --cursor page through the window; nextCursor continues in +the sort order and prevCursor walks the other way. With --follow the command +prints the recent page, then streams new entries until interrupted; the stream +reconnects when the server ends it, and waits for the stream to open when a +gateway answers first, which is what an application that has written nothing +does. The live stream has no window, so --window, --limit, --cursor and --sort +apply to the recent page only, and --cursor cannot be combined with --follow. +Entries written between the recent page and the start of the stream, or while +the stream reconnects, can be missed or repeated. In table format each entry is one line: time, level and message. In json or yaml format the recent page is printed as one document; with --follow every @@ -43,10 +45,11 @@ runware serverless apps logs [flags] ### Options ``` - --cursor string Pagination cursor from a previous nextCursor + --cursor string Pagination cursor from a previous nextCursor or prevCursor -f, --follow Stream new log entries until interrupted -h, --help help for logs --limit int Maximum number of entries on the recent page (1-100, default 20) + --sort string Page order (oldest or newest) (default "oldest") --window string Time window for the recent page (1h, 6h, 24h, 7d, or 30d) (default "1h") ``` diff --git a/docs/runware_serverless_apps_rename.md b/docs/runware_serverless_apps_rename.md new file mode 100644 index 0000000..c24c949 --- /dev/null +++ b/docs/runware_serverless_apps_rename.md @@ -0,0 +1,41 @@ +## runware serverless apps rename + +Rename a serverless application + +### Synopsis + +Change the display name of a serverless application. + +The application ID is immutable. A name-only update records a version and does +not pin or roll workers. + +``` +runware serverless apps rename [flags] +``` + +### Examples + +``` + # rename an application + runware serverless apps rename my-app "Image generator" +``` + +### Options + +``` + -h, --help help for rename +``` + +### 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](runware_serverless_apps.md) - Manage deployed serverless applications + diff --git a/docs/runware_serverless_apps_resume.md b/docs/runware_serverless_apps_resume.md index 517bd64..782f9a1 100644 --- a/docs/runware_serverless_apps_resume.md +++ b/docs/runware_serverless_apps_resume.md @@ -7,8 +7,8 @@ Resume a stopped serverless application Resume a stopped serverless application. The server accepts the resume and returns immediately with status initializing. -Worker start is asynchronous; this command does not wait until the application -is active. The application must be stopped. +Worker start is asynchronous. Pass --wait to poll until the application is +active or failed. The application must be stopped. ``` runware serverless apps resume [flags] @@ -19,12 +19,18 @@ runware serverless apps resume [flags] ``` # resume a stopped application runware serverless apps resume my-app + + # wait until the application is active or failed + runware serverless apps resume my-app --wait ``` ### Options ``` - -h, --help help for resume + -h, --help help for resume + --poll-interval duration Polling interval when waiting for the application (default 2s) + --timeout duration Maximum time to wait (0 = no limit) + --wait Poll until the application is active or failed ``` ### Options inherited from parent commands diff --git a/docs/runware_serverless_apps_scale.md b/docs/runware_serverless_apps_scale.md index 5752248..2256063 100644 --- a/docs/runware_serverless_apps_scale.md +++ b/docs/runware_serverless_apps_scale.md @@ -35,7 +35,7 @@ runware serverless apps scale [flags] --concurrency int32 Max tasks a single worker handles simultaneously --fallback-gpu-type string Secondary GPU type if the preferred type is unavailable --gpu-type string Preferred GPU type ID (see 'serverless gpus') - --gpus-per-worker int32 GPUs allocated per worker + --gpus-per-worker int32 GPUs allocated per worker (1, 2, 4, or 8) -h, --help help for scale --idle-ttl int32 Idle TTL in seconds before scaling down --max-workers int32 Maximum number of workers diff --git a/docs/runware_serverless_apps_versions_activate.md b/docs/runware_serverless_apps_versions_activate.md index 35e6038..d5fddda 100644 --- a/docs/runware_serverless_apps_versions_activate.md +++ b/docs/runware_serverless_apps_versions_activate.md @@ -7,9 +7,9 @@ Activate a ready application version 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. +Worker rollout is asynchronous. Pass --wait to poll until the application is +active or failed. 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. @@ -27,12 +27,18 @@ runware serverless apps versions activate [flags] # roll back to an older ready version runware serverless apps versions activate my-app 1 + + # wait until the rollout is active or failed + runware serverless apps versions activate my-app 2 --wait ``` ### Options ``` - -h, --help help for activate + -h, --help help for activate + --poll-interval duration Polling interval when waiting for the application (default 2s) + --timeout duration Maximum time to wait (0 = no limit) + --wait Poll until the application is active or failed ``` ### Options inherited from parent commands diff --git a/docs/runware_serverless_apps_workers.md b/docs/runware_serverless_apps_workers.md index 99da7cc..f3e5e3c 100644 --- a/docs/runware_serverless_apps_workers.md +++ b/docs/runware_serverless_apps_workers.md @@ -6,6 +6,10 @@ List and inspect workers for a serverless application List workers observed for an application. +The default page is the active version only. An application with no active +version therefore answers an empty default page — not that it has no workers. +Pass --version all to include every version, or --version to scope to one. + 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 @@ -18,9 +22,12 @@ runware serverless apps workers [flags] ### Examples ``` - # list workers for an application + # list workers for the active version runware serverless apps workers my-app + # include workers from every version + runware serverless apps workers my-app --version all + # omit terminal stopped rows runware serverless apps workers my-app --state live @@ -34,11 +41,12 @@ runware serverless apps workers [flags] ### Options ``` - --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, …) + --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, …) + --version string Scope to a version ID, or all (default: the active version) ``` ### Options inherited from parent commands diff --git a/docs/runware_serverless_deploy.md b/docs/runware_serverless_deploy.md index c940003..07be170 100644 --- a/docs/runware_serverless_deploy.md +++ b/docs/runware_serverless_deploy.md @@ -9,9 +9,10 @@ Create or update a serverless application from Python code or a container source A first deploy with a new --id creates the application. A later deploy with the same --id uploads a new source, records version N+1, and rolls it when the build is ready. Create-only flags (--gpu-type, worker settings, --volume, ---env, --env-file, --name) apply only to create; passing them when the -application already exists is an error. Change workers with 'apps scale' and -environment with 'apps env'. A source update on a stopped application is 409. +--env, --env-file, --secret, --name) apply only to create; passing them when the +application already exists is an error. Change workers with 'apps scale', +environment with 'apps env', and the display name with 'apps rename'. A source +update on a stopped application is 409. A code deploy takes a Python entry file. The whole source directory is zipped and submitted as the application source, so the entry file can import its own @@ -40,17 +41,17 @@ what a project keeps out of version control is a different question from what it ships. Either way .env files are never uploaded, and neither are .git, __pycache__, .venv, node_modules or the usual build and tool caches. -Environment variables must be supplied at create with --env or --env-file. An -app's environment is frozen into the version this command creates, which is -what the worker is rendered from, so setting one afterwards with 'apps env set' -stores it without it ever reaching a pod. Prefer --env-file for anything secret: -a value passed as --env is visible in the process list and recorded in shell -history. +Environment variables can be supplied at create with --env or --env-file, or +changed later with 'apps env set', which records a new version and rolls live +workers when the value changes. Prefer --env-file for anything secret: a value +passed as --env is visible in the process list and recorded in shell history. +Attach existing organisation secrets at create with --secret so the first +rollout carries them; attach and detach after create also roll live workers. -Anything the app downloads at runtime belongs on a --volume. The app runs in a -sandbox whose filesystem is part of the checkpointed state, so an unmounted -download is copied into every checkpoint and fetched again on every cold start. -A volume keeps it out of both. +Anything the app downloads at runtime belongs on a --volume. Volumes are +immutable after create. The app runs in a sandbox whose filesystem is part of +the checkpointed state, so an unmounted download is copied into every checkpoint +and fetched again on every cold start. A volume keeps it out of both. Worker settings are supplied via flags on create. Endpoints are derived server-side from the SDK (code) or from container.yaml (container). @@ -97,24 +98,30 @@ runware serverless deploy [file] [flags] ### Options ``` - --base-image string Builder base image (code deploys only) (default "python:3.11-slim") - --container string Directory whose root contains Dockerfile and container.yaml - --env stringArray Environment variable as KEY=VALUE (repeatable) - --env-file stringArray File of KEY=VALUE lines to read environment variables from (repeatable) - --gpu-type string GPU type ID (see 'serverless gpus'; required when creating) - --gpus-per-worker int32 GPUs allocated per worker (default 1) - -h, --help help for deploy - --id string Application ID (immutable, lowercase slug) - --idle-ttl int32 Idle TTL in seconds before scaling down (default 60) - --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 + --available-workers-pct int32 Idle-worker buffer as a percentage of load (0-100) + --base-image string Builder base image (code deploys only) (default "python:3.11-slim") + --concurrency int32 Max tasks a single worker handles simultaneously (default 1) + --container string Directory whose root contains Dockerfile and container.yaml + --env stringArray Environment variable as KEY=VALUE (repeatable) + --env-file stringArray File of KEY=VALUE lines to read environment variables from (repeatable) + --fallback-gpu-type string Secondary GPU type if the preferred type is unavailable + --gpu-type string GPU type ID (see 'serverless gpus'; required when creating) + --gpus-per-worker int32 GPUs allocated per worker (1, 2, 4, or 8) (default 1) + -h, --help help for deploy + --id string Application ID (immutable, lowercase slug) + --idle-ttl int32 Idle TTL in seconds before scaling down (default 60) + --max-workers int32 Maximum number of workers (default 1) + --min-available-workers int32 Minimum idle workers kept as a buffer + --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) + --secret stringArray Attach an organisation secret as NAME or NAME=ENV_VAR (repeatable) + --src-dir string Directory to package as the application source (default: the working directory; code deploys only) + --timeout duration Maximum time to wait (0 = no limit) + --volume stringArray Absolute path inside the app backed by persistent node-local storage (repeatable; immutable after create) + --wait Poll until the application is active or failed ``` ### Options inherited from parent commands diff --git a/docs/runware_serverless_secrets_attach.md b/docs/runware_serverless_secrets_attach.md index 33ea154..e923e81 100644 --- a/docs/runware_serverless_secrets_attach.md +++ b/docs/runware_serverless_secrets_attach.md @@ -4,11 +4,12 @@ Attach an organisation secret to an application ### Synopsis -Record that an organisation secret is attached to an application, optionally -under a different environment variable name. +Attach an organisation secret to an application, optionally under a different +environment variable name. -The organisation secret must already exist (see 'secrets set'). This is a -control-plane association only in this API release — it does not roll workers. +The organisation secret must already exist (see 'secrets set'). Attach rolls +live workers so the value reaches running pods. PATCH secrets-replace still +does not roll. ``` runware serverless secrets attach [flags] diff --git a/docs/runware_serverless_secrets_detach.md b/docs/runware_serverless_secrets_detach.md index 0278c7d..85e3602 100644 --- a/docs/runware_serverless_secrets_detach.md +++ b/docs/runware_serverless_secrets_detach.md @@ -4,8 +4,8 @@ Detach a secret from an application ### Synopsis -Remove the control-plane attachment from an application. Does not remove the -organisation secret. +Detach an organisation secret from an application. Live workers are rolled so +the value is removed from running pods. Does not remove the organisation secret. ``` runware serverless secrets detach [flags] diff --git a/docs/runware_serverless_secrets_set.md b/docs/runware_serverless_secrets_set.md index e2df83a..4171a6b 100644 --- a/docs/runware_serverless_secrets_set.md +++ b/docs/runware_serverless_secrets_set.md @@ -6,9 +6,11 @@ Create or update an organisation secret Create an organisation-scoped secret, or update its value if the name already exists. -This does not attach the secret to an application. Use 'secrets attach' for that. -The secret value is never printed. Prefer --value-file so the value is not visible -in process lists; use --value-file - to read from stdin. +This does not attach the secret to an application. Use 'secrets attach' for that, +or pass --secret on create. Updating a value rolls every live application that +already attaches this secret. The secret value is never printed. Prefer +--value-file so the value is not visible in process lists; use --value-file - +to read from stdin. ``` runware serverless secrets set [flags] diff --git a/internal/api/serverless/client.go b/internal/api/serverless/client.go index 4316152..f31e23e 100644 --- a/internal/api/serverless/client.go +++ b/internal/api/serverless/client.go @@ -128,6 +128,9 @@ const ( // Endpoint is an app HTTP endpoint. type Endpoint = gen.Endpoint +// EndpointRuntime is latest-window traffic for one endpoint. +type EndpointRuntime = gen.EndpointRuntime + // Version is a deployed application version. type Version = gen.Version @@ -321,6 +324,8 @@ func (c *Client) CreateApp(ctx context.Context, body AppCreate) (*App, error) { return nil, problemToError(resp.ApplicationproblemJSON400, http.StatusBadRequest) case http.StatusUnauthorized: return nil, problemToError(resp.ApplicationproblemJSON401, http.StatusUnauthorized) + case http.StatusPaymentRequired: + return nil, problemToError(resp.ApplicationproblemJSON402, http.StatusPaymentRequired) case http.StatusForbidden: return nil, problemToError(resp.ApplicationproblemJSON403, http.StatusForbidden) case http.StatusConflict: @@ -459,6 +464,8 @@ func (c *Client) UpdateApp(ctx context.Context, appID string, body AppUpdate) (* return nil, problemToError(resp.ApplicationproblemJSON400, http.StatusBadRequest) case http.StatusUnauthorized: return nil, problemToError(resp.ApplicationproblemJSON401, http.StatusUnauthorized) + case http.StatusPaymentRequired: + return nil, problemToError(resp.ApplicationproblemJSON402, http.StatusPaymentRequired) case http.StatusForbidden: return nil, problemToError(resp.ApplicationproblemJSON403, http.StatusForbidden) case http.StatusNotFound: @@ -509,10 +516,12 @@ func (c *Client) ResumeApp(ctx context.Context, appID string) (*App, error) { c.logResponse(ctx, resp.HTTPResponse, resp.Body) return acceptedApp("resume app", resp.StatusCode(), resp.JSON202, resp.Body, lifecycleProblems{ - Unauthorized: resp.ApplicationproblemJSON401, - Forbidden: resp.ApplicationproblemJSON403, - NotFound: resp.ApplicationproblemJSON404, - Conflict: resp.ApplicationproblemJSON409, + Unauthorized: resp.ApplicationproblemJSON401, + PaymentRequired: resp.ApplicationproblemJSON402, + Forbidden: resp.ApplicationproblemJSON403, + NotFound: resp.ApplicationproblemJSON404, + Conflict: resp.ApplicationproblemJSON409, + Unprocessable: resp.ApplicationproblemJSON422, }) } @@ -555,19 +564,23 @@ func (c *Client) DeployVersion(ctx context.Context, appID string, versionNumber 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, + Unauthorized: resp.ApplicationproblemJSON401, + PaymentRequired: resp.ApplicationproblemJSON402, + Forbidden: resp.ApplicationproblemJSON403, + NotFound: resp.ApplicationproblemJSON404, + Conflict: resp.ApplicationproblemJSON409, + Unprocessable: resp.ApplicationproblemJSON422, }) } // lifecycleProblems are typed RFC 9457 bodies bound by the generated client. type lifecycleProblems struct { - Unauthorized *gen.ProblemDetails - Forbidden *gen.ProblemDetails - NotFound *gen.ProblemDetails - Conflict *gen.ProblemDetails + Unauthorized *gen.ProblemDetails + PaymentRequired *gen.ProblemDetails + Forbidden *gen.ProblemDetails + NotFound *gen.ProblemDetails + Conflict *gen.ProblemDetails + Unprocessable *gen.ProblemDetails } func acceptedApp(op string, status int, app *App, body []byte, problems lifecycleProblems) (*App, error) { @@ -579,6 +592,8 @@ func acceptedApp(op string, status int, app *App, body []byte, problems lifecycl return app, nil case http.StatusUnauthorized: return nil, problemToError(problems.Unauthorized, http.StatusUnauthorized) + case http.StatusPaymentRequired: + return nil, problemToError(problems.PaymentRequired, http.StatusPaymentRequired) case http.StatusForbidden: return nil, problemToError(problems.Forbidden, http.StatusForbidden) case http.StatusNotFound: @@ -588,6 +603,11 @@ func acceptedApp(op string, status int, app *App, body []byte, problems lifecycl return nil, problemToError(problems.Conflict, http.StatusConflict) } return nil, problemFromBody(body, status) + case http.StatusUnprocessableEntity: + if problems.Unprocessable != nil { + return nil, problemToError(problems.Unprocessable, http.StatusUnprocessableEntity) + } + return nil, problemFromBody(body, status) default: return nil, problemFromBody(body, status) } @@ -931,6 +951,57 @@ func (c *Client) ListAppEvents(ctx context.Context, appID string, params *ListAp } } +// ListAppErrorsParams filters the request-error page for an app. +type ListAppErrorsParams = gen.ListAppErrorsParams + +// ListAppErrorsParamsWindow is the closed set of time windows listAppErrors accepts. +type ListAppErrorsParamsWindow = gen.ListAppErrorsParamsWindow + +// ListAppErrorsParamsStatusClass filters listAppErrors by 4xx or 5xx. +type ListAppErrorsParamsStatusClass = gen.ListAppErrorsParamsStatusClass + +// ListAppErrors returns one page of failed inference requests for an app. +func (c *Client) ListAppErrors(ctx context.Context, appID string, params *ListAppErrorsParams) (Page[LogEntry], error) { + if c.apiKey == "" { + return Page[LogEntry]{}, transport.ErrNoAPIKey + } + + resp, err := c.inner.ListAppErrorsWithResponse(ctx, appID, params) + if err != nil { + return Page[LogEntry]{}, fmt.Errorf("list app errors: %w", err) + } + + c.logResponse(ctx, resp.HTTPResponse, resp.Body) + + switch resp.StatusCode() { + case http.StatusOK: + if resp.JSON200 == nil { + return pageOf[LogEntry](nil, nil), nil + } + return pageOf(&resp.JSON200.Data, resp.JSON200.NextCursor), nil + case http.StatusBadRequest: + return Page[LogEntry]{}, problemToError(resp.ApplicationproblemJSON400, http.StatusBadRequest) + case http.StatusUnauthorized: + return Page[LogEntry]{}, problemToError(resp.ApplicationproblemJSON401, http.StatusUnauthorized) + case http.StatusForbidden: + return Page[LogEntry]{}, problemToError(resp.ApplicationproblemJSON403, http.StatusForbidden) + case http.StatusNotFound: + return Page[LogEntry]{}, problemToError(resp.ApplicationproblemJSON404, http.StatusNotFound) + case http.StatusUnprocessableEntity: + return Page[LogEntry]{}, problemToError(resp.ApplicationproblemJSON422, http.StatusUnprocessableEntity) + case http.StatusTooManyRequests: + return Page[LogEntry]{}, problemToError(resp.ApplicationproblemJSON429, http.StatusTooManyRequests) + case http.StatusBadGateway: + return Page[LogEntry]{}, problemToError(resp.ApplicationproblemJSON502, http.StatusBadGateway) + case http.StatusServiceUnavailable: + return Page[LogEntry]{}, problemToError(resp.ApplicationproblemJSON503, http.StatusServiceUnavailable) + case http.StatusGatewayTimeout: + return Page[LogEntry]{}, problemToError(resp.ApplicationproblemJSON504, http.StatusGatewayTimeout) + default: + return Page[LogEntry]{}, problemFromBody(resp.Body, resp.StatusCode()) + } +} + // NewCodeAppSource builds an appSource for a code-based create or update. func NewCodeAppSource(src CodeSourceUpsert) (AppSourceUpsert, error) { var source gen.AppSourceUpsert_Source diff --git a/internal/api/serverless/errors.go b/internal/api/serverless/errors.go index 84c601b..c1d309e 100644 --- a/internal/api/serverless/errors.go +++ b/internal/api/serverless/errors.go @@ -29,6 +29,9 @@ func problemToError(p *gen.ProblemDetails, statusCode int) error { if extra := formatProblemErrors(p.Errors); extra != "" { msg = msg + "\n" + extra } + if p.Shortfall != nil && *p.Shortfall != "" { + msg = msg + "\n shortfall: " + *p.Shortfall + } } return transport.CreateRunwareError( rawCodeForStatus(statusCode), @@ -69,7 +72,7 @@ func problemFromBody(body []byte, statusCode int) error { } func isProblemDetails(p gen.ProblemDetails) bool { - return p.Title != "" || p.Type != "" || p.Status != 0 || (p.Detail != nil && *p.Detail != "") || (p.Errors != nil && len(*p.Errors) > 0) + return p.Title != "" || p.Type != "" || p.Status != 0 || (p.Detail != nil && *p.Detail != "") || (p.Errors != nil && len(*p.Errors) > 0) || (p.Shortfall != nil && *p.Shortfall != "") } // rawCodeForStatus maps an HTTP status to a raw error code string that @@ -86,6 +89,8 @@ func rawCodeForStatus(statusCode int) string { return "conflict" case http.StatusBadRequest, http.StatusUnprocessableEntity: return "validationFailed" + case http.StatusPaymentRequired: + return "paymentRequired" default: // Must be a key in transport.serverErrorCodes so DeriveCode returns // CodeServerError rather than CodeUnknown. diff --git a/internal/api/serverless/errors_test.go b/internal/api/serverless/errors_test.go index d02b207..98e4ff7 100644 --- a/internal/api/serverless/errors_test.go +++ b/internal/api/serverless/errors_test.go @@ -38,3 +38,32 @@ func TestProblemToError_IncludesValidationErrors(t *testing.T) { t.Errorf("missing field error: %q", re.Message) } } + +func TestProblemToError_PaymentRequiredIncludesShortfall(t *testing.T) { + detail := "Organization credit cannot cover the requested capacity" + shortfall := "12.50" + p := &gen.ProblemDetails{ + Title: "Payment Required", + Status: 402, + Detail: &detail, + Shortfall: &shortfall, + } + + err := problemToError(p, http.StatusPaymentRequired) + var re *transport.RunwareError + if !errors.As(err, &re) { + t.Fatalf("expected *transport.RunwareError, got %T: %v", err, err) + } + if re.Code != transport.CodeQuota { + t.Errorf("expected CodeQuota, got %v", re.Code) + } + if re.StatusCode != http.StatusPaymentRequired { + t.Errorf("status %d, want 402", re.StatusCode) + } + if !strings.Contains(re.Message, detail) { + t.Errorf("missing problem detail: %q", re.Message) + } + if !strings.Contains(re.Message, "shortfall: 12.50") { + t.Errorf("missing shortfall: %q", re.Message) + } +} diff --git a/internal/api/serverless/gen/client.gen.go b/internal/api/serverless/gen/client.gen.go index 09c7856..955be0a 100644 --- a/internal/api/serverless/gen/client.gen.go +++ b/internal/api/serverless/gen/client.gen.go @@ -238,6 +238,24 @@ func (e GpuAvailability) Valid() bool { } } +// Defines values for LogSort. +const ( + Newest LogSort = "newest" + Oldest LogSort = "oldest" +) + +// Valid indicates whether the value is a known member of the LogSort enum. +func (e LogSort) Valid() bool { + switch e { + case Newest: + return true + case Oldest: + return true + default: + return false + } +} + // Defines values for SecretType. const ( Generic SecretType = "generic" @@ -691,6 +709,13 @@ type App struct { Configuration WorkerConfig `json:"configuration"` CreatedAt time.Time `json:"createdAt"` + // EffectiveMaxWorkers The worker ceiling the last deploy actually applied, reduced where the organization's credit balance did not back the whole range. The autoscaler cannot grow past it. + // + // It describes what was applied, not what is configured now, and the two can differ. It is taken from the `maxWorkers` of the version that was deployed, so deploying an older version applies that version's ceiling; and a later `PATCH` of `configuration.maxWorkers` does not change it until the next deploy. Read it beside `configuration.maxWorkers` rather than as a bound on it. + // + // `null` means nothing has been applied yet. It is recalculated on every deploy. A committed credit top-up also recalculates a reduced ceiling and restores the funded range automatically; KEDA then grows the workload from queue demand without a customer redeploy. + EffectiveMaxWorkers *int32 `json:"effectiveMaxWorkers,omitempty"` + // EnvironmentVariables Plain-text environment variables for this app. Populated on single-app responses (get, update, stop, resume, delete, deploy, favourite). List of apps returns an empty array to avoid an N+1 per page row — use the `/environment-variables` endpoints to page the set. EnvironmentVariables []EnvironmentVariable `json:"environmentVariables"` @@ -1164,8 +1189,14 @@ type LogEntryPage struct { // NextCursor Opaque; absent on the last page. NextCursor *string `json:"nextCursor,omitempty"` + + // PrevCursor Opaque; absent on the first page. Walks the opposite direction of `nextCursor` under the same `sort`. + PrevCursor *string `json:"prevCursor,omitempty"` } +// LogSort Ordering of a log page. `newest` is the default: the first row is the newest entry and `nextCursor` walks older. `oldest` reverses that. +type LogSort string + // MetricSeries defines model for MetricSeries. type MetricSeries struct { Aggregation string `json:"aggregation"` @@ -1245,6 +1276,12 @@ type ProblemDetails struct { // RequestId Extension member. Correlation id for this request, echoed in the `X-Request-Id` response header; quote it when reporting problems. RequestId *string `json:"requestId,omitempty"` + // Shortfall Extension member. Present on `402` credit refusals. The amount of credit to add before retrying the same request. Units match the platform credit display. + // + // + // Example: 12.50 + Shortfall *string `json:"shortfall,omitempty"` + // Status HTTP status code generated for this occurrence. // // Example: 404 @@ -1749,7 +1786,7 @@ type WorkerConfig struct { // GpuType Preferred GPU type. Absent (or null) only on historical apps created before a GPU type was required. GpuType *GpuTypeId `json:"gpuType,omitempty"` - // GpusPerWorker One GPU per worker is currently supported. Historical apps may contain another value. + // GpusPerWorker GPUs granted to one worker pod. Create and update accept only the group sizes the cluster grants indivisibly, since a worker holds its GPUs as one such group. Historical apps may contain another value. GpusPerWorker int32 `json:"gpusPerWorker"` Id openapi_types.UUID `json:"id"` @@ -1783,7 +1820,7 @@ type WorkerConfigCreate struct { // GpuType GPU type the workers run on. Required: omitting it (or sending null) is a 422 before a build or deploy starts, because a GPU app with no type is unpinned and the deployer would render NVIDIA defaults. Must match an `id` returned by `GET /v1/gpu-types` that currently has admitted capacity. GpuType GpuTypeId `json:"gpuType"` - // GpusPerWorker Only 1 is currently supported. Any other value returns 422. + // GpusPerWorker GPUs granted to one worker pod. A worker holds its GPUs as one group the cluster grants indivisibly, so the count is one of the advertised group sizes rather than any number in a range. The value must also be a group size admitted by the cluster backing the chosen `gpuType`: a count above 1 that cluster does not grant is rejected with a 422 naming `/configuration/gpusPerWorker`, since the pod could never be scheduled. A value above 1 requires an image built after the multi-GPU worker entrypoint; older images serve a single rank while holding every granted GPU. GpusPerWorker *int32 `json:"gpusPerWorker,omitempty"` IdleTtlSecs int32 `json:"idleTtlSecs"` MaxWorkers int32 `json:"maxWorkers"` @@ -1806,7 +1843,7 @@ type WorkerConfigPatch struct { // GpuType Preferred GPU type. Omit to leave unchanged. Rejected with a 422 when no capacity is currently offered for the type (it does not appear in `GET /v1/gpu-types`). GpuType *GpuTypeId `json:"gpuType,omitempty"` - // GpusPerWorker Only 1 is currently supported. Any other value returns 422. + // GpusPerWorker GPUs granted to one worker pod. A worker holds its GPUs as one group the cluster grants indivisibly, so the count is one of the advertised group sizes rather than any number in a range. The value must also be a group size admitted by the cluster backing the chosen `gpuType`: a count above 1 that cluster does not grant is rejected with a 422 naming `/configuration/gpusPerWorker`, since the pod could never be scheduled. A value above 1 requires an image built after the multi-GPU worker entrypoint; older images serve a single rank while holding every granted GPU. GpusPerWorker *int32 `json:"gpusPerWorker,omitempty"` IdleTtlSecs *int32 `json:"idleTtlSecs,omitempty"` MaxWorkers *int32 `json:"maxWorkers,omitempty"` @@ -1889,6 +1926,9 @@ type InternalServerError = ProblemDetails // NotFound RFC 9457 problem details. Every error response from this API uses this schema with media type `application/problem+json`. `type` is a URI that identifies the problem class and dereferences to its documentation; clients should switch on `type` (not `status` or `detail`, which are not stable identifiers). Additional members beyond those below may appear. type NotFound = ProblemDetails +// PaymentRequired RFC 9457 problem details. Every error response from this API uses this schema with media type `application/problem+json`. `type` is a URI that identifies the problem class and dereferences to its documentation; clients should switch on `type` (not `status` or `detail`, which are not stable identifiers). Additional members beyond those below may appear. +type PaymentRequired = ProblemDetails + // ServiceUnavailable RFC 9457 problem details. Every error response from this API uses this schema with media type `application/problem+json`. `type` is a URI that identifies the problem class and dereferences to its documentation; clients should switch on `type` (not `status` or `detail`, which are not stable identifiers). Additional members beyond those below may appear. type ServiceUnavailable = ProblemDetails @@ -1909,7 +1949,7 @@ type ListAppsParams struct { // Limit Maximum number of items to return. Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"` - // Cursor Opaque pagination cursor returned as `nextCursor` by a previous call. + // Cursor Opaque pagination cursor returned by a previous call, as `nextCursor` or, on the operations that offer one, `prevCursor`. Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` // Status Return only apps in this status. @@ -1928,7 +1968,7 @@ type ListBuildsParams struct { // Limit Maximum number of items to return. Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"` - // Cursor Opaque pagination cursor returned as `nextCursor` by a previous call. + // Cursor Opaque pagination cursor returned by a previous call, as `nextCursor` or, on the operations that offer one, `prevCursor`. Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` } @@ -1937,7 +1977,7 @@ type ListEndpointsParams struct { // Limit Maximum number of items to return. Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"` - // Cursor Opaque pagination cursor returned as `nextCursor` by a previous call. + // Cursor Opaque pagination cursor returned by a previous call, as `nextCursor` or, on the operations that offer one, `prevCursor`. Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` } @@ -1946,7 +1986,7 @@ type ListAppEnvironmentVariablesParams struct { // Limit Maximum number of items to return. Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"` - // Cursor Opaque pagination cursor returned as `nextCursor` by a previous call. + // Cursor Opaque pagination cursor returned by a previous call, as `nextCursor` or, on the operations that offer one, `prevCursor`. Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` } @@ -1955,7 +1995,7 @@ type ListAppErrorsParams struct { // Limit Maximum number of items to return. Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"` - // Cursor Opaque pagination cursor returned as `nextCursor` by a previous call. + // Cursor Opaque pagination cursor returned by a previous call, as `nextCursor` or, on the operations that offer one, `prevCursor`. Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` // Window The range to search. Same closed ladder as the metrics queries. Defaults to the last 24 hours. @@ -1976,7 +2016,7 @@ type ListAppEventsParams struct { // Limit Maximum number of items to return. Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"` - // Cursor Opaque pagination cursor returned as `nextCursor` by a previous call. + // Cursor Opaque pagination cursor returned by a previous call, as `nextCursor` or, on the operations that offer one, `prevCursor`. Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` Type *AppEventType `form:"type,omitempty" json:"type,omitempty"` } @@ -1986,7 +2026,7 @@ type ListAppSecretsParams struct { // Limit Maximum number of items to return. Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"` - // Cursor Opaque pagination cursor returned as `nextCursor` by a previous call. + // Cursor Opaque pagination cursor returned by a previous call, as `nextCursor` or, on the operations that offer one, `prevCursor`. Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` } @@ -1995,7 +2035,7 @@ type ListTasksParams struct { // Limit Maximum number of items to return. Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"` - // Cursor Opaque pagination cursor returned as `nextCursor` by a previous call. + // Cursor Opaque pagination cursor returned by a previous call, as `nextCursor` or, on the operations that offer one, `prevCursor`. Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` Status *TaskStatus `form:"status,omitempty" json:"status,omitempty"` } @@ -2005,7 +2045,7 @@ type ListVersionsParams struct { // Limit Maximum number of items to return. Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"` - // Cursor Opaque pagination cursor returned as `nextCursor` by a previous call. + // Cursor Opaque pagination cursor returned by a previous call, as `nextCursor` or, on the operations that offer one, `prevCursor`. Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` } @@ -2014,7 +2054,7 @@ type ListWorkersParams struct { // Limit Maximum number of items to return. Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"` - // Cursor Opaque pagination cursor returned as `nextCursor` by a previous call. + // Cursor Opaque pagination cursor returned by a previous call, as `nextCursor` or, on the operations that offer one, `prevCursor`. Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` // VersionId Scope the page to one version. The default is the app's `activeVersionId`. When that field is unset the default page is empty: the app has no active version, not that it has no workers. Send `all` (any case) to include every version. An empty value is refused. A cursor must be replayed under the same version scope it was issued with. @@ -2025,6 +2065,9 @@ type ListWorkersParams struct { // A `state` of `live` with a `status` of `stopped` is a contradiction and is refused, because an empty page would read as "this app has never run". State *WorkerStateFilter `form:"state,omitempty" json:"state,omitempty"` Status *WorkerStatus `form:"status,omitempty" json:"status,omitempty"` + + // Q Case-insensitive literal substring match against `id`, `podName`, `nodeName` and `versionId`. A worker matching any field is returned within the selected version, state and status filters. Omit `q` to disable search. Cursors must retain the same search term, ignoring case. + Q *string `form:"q,omitempty" json:"q,omitempty"` } // GetLogEntriesParams defines parameters for GetLogEntries. @@ -2035,9 +2078,12 @@ type GetLogEntriesParams struct { // Limit Maximum number of items to return. Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"` - // Cursor Opaque pagination cursor returned as `nextCursor` by a previous call. + // Cursor Opaque pagination cursor returned by a previous call, as `nextCursor` or, on the operations that offer one, `prevCursor`. Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` + // Sort Ordering of a log page and the direction `nextCursor` moves in. Only `getLogEntries` accepts it. + Sort *LogSort `form:"sort,omitempty" json:"sort,omitempty"` + // Deployment Narrow to one app. Deployment *SelectorDeployment `form:"deployment,omitempty" json:"deployment,omitempty"` @@ -2101,7 +2147,7 @@ type ListSecretsParams struct { // Limit Maximum number of items to return. Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"` - // Cursor Opaque pagination cursor returned as `nextCursor` by a previous call. + // Cursor Opaque pagination cursor returned by a previous call, as `nextCursor` or, on the operations that offer one, `prevCursor`. Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` } @@ -2110,7 +2156,7 @@ type ListUsageEventsParams struct { // Limit Maximum number of items to return. Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"` - // Cursor Opaque pagination cursor returned as `nextCursor` by a previous call. + // Cursor Opaque pagination cursor returned by a previous call, as `nextCursor` or, on the operations that offer one, `prevCursor`. Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` AppId *AppId `form:"appId,omitempty" json:"appId,omitempty"` @@ -2738,7 +2784,7 @@ type ClientInterface interface { // ListWorkers List workers // - // Returns a newest-first page of workers observed for the app (including terminal `stopped` rows until purged). Omitted `versionId` scopes the page to the app's `activeVersionId`. An app with no active version therefore answers an empty default page — not that it has no workers, only that none are pinned. Optional `state` and `status` narrow the page further; a cursor must be replayed under the same filters it was issued with. + // Returns a newest-first page of workers observed for the app (including terminal `stopped` rows until purged). Omitted `versionId` scopes the page to the app's `activeVersionId`. An app with no active version therefore answers an empty default page — not that it has no workers, only that none are pinned. Optional `state`, `status` and `q` narrow the page further; a cursor must be replayed under the same filters it was issued with. // // Corresponds with GET /v1/apps/{appId}/workers (the `ListWorkers` operationId). ListWorkers(ctx context.Context, appId AppId, params *ListWorkersParams, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -2766,7 +2812,7 @@ type ClientInterface interface { // GetLogEntries Read one page of a named log query // - // Returns one page of log entries, newest first, with an opaque cursor for the next page when one exists. Query ids and their supported selectors are listed by the insights catalogue. + // Returns one page of log entries in the requested `sort` order, newest first by default, with opaque cursors for the neighbouring pages when they exist. `nextCursor` continues in the `sort` order and `prevCursor` goes back against it, so a client can walk a window in either direction from either end. A cursor is only valid for the `sort` it was issued under; reusing one under the other ordering returns `400`. Query ids and their supported selectors are listed by the insights catalogue. // // Corresponds with GET /v1/logs/queries/{queryId}/entries (the `GetLogEntries` operationId). GetLogEntries(ctx context.Context, queryId QueryId, params *GetLogEntriesParams, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -3684,7 +3730,7 @@ func (c *Client) GetVersion(ctx context.Context, appId AppId, versionNumber int3 // ListWorkers List workers // -// Returns a newest-first page of workers observed for the app (including terminal `stopped` rows until purged). Omitted `versionId` scopes the page to the app's `activeVersionId`. An app with no active version therefore answers an empty default page — not that it has no workers, only that none are pinned. Optional `state` and `status` narrow the page further; a cursor must be replayed under the same filters it was issued with. +// Returns a newest-first page of workers observed for the app (including terminal `stopped` rows until purged). Omitted `versionId` scopes the page to the app's `activeVersionId`. An app with no active version therefore answers an empty default page — not that it has no workers, only that none are pinned. Optional `state`, `status` and `q` narrow the page further; a cursor must be replayed under the same filters it was issued with. // // Corresponds with GET /v1/apps/{appId}/workers (the `ListWorkers` operationId). func (c *Client) ListWorkers(ctx context.Context, appId AppId, params *ListWorkersParams, reqEditors ...RequestEditorFn) (*http.Response, error) { @@ -3752,7 +3798,7 @@ func (c *Client) GetGpuType(ctx context.Context, gpuTypeId GpuTypeId, reqEditors // GetLogEntries Read one page of a named log query // -// Returns one page of log entries, newest first, with an opaque cursor for the next page when one exists. Query ids and their supported selectors are listed by the insights catalogue. +// Returns one page of log entries in the requested `sort` order, newest first by default, with opaque cursors for the neighbouring pages when they exist. `nextCursor` continues in the `sort` order and `prevCursor` goes back against it, so a client can walk a window in either direction from either end. A cursor is only valid for the `sort` it was issued under; reusing one under the other ordering returns `400`. Query ids and their supported selectors are listed by the insights catalogue. // // Corresponds with GET /v1/logs/queries/{queryId}/entries (the `GetLogEntries` operationId). func (c *Client) GetLogEntries(ctx context.Context, queryId QueryId, params *GetLogEntriesParams, reqEditors ...RequestEditorFn) (*http.Response, error) { @@ -5852,6 +5898,18 @@ func NewListWorkersRequest(server string, appId AppId, params *ListWorkersParams } + if params.Q != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "q", *params.Q, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + if encoded := queryValues.Encode(); encoded != "" { rawQueryFragments = append(rawQueryFragments, encoded) } @@ -6035,6 +6093,18 @@ func NewGetLogEntriesRequest(server string, queryId QueryId, params *GetLogEntri } + if params.Sort != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "sort", *params.Sort, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + if params.Deployment != nil { if queryFrag, err := runtime.StyleParamWithOptions("form", true, "deployment", *params.Deployment, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { @@ -7391,7 +7461,7 @@ type ClientWithResponsesInterface interface { // ListWorkersWithResponse List workers // - // Returns a newest-first page of workers observed for the app (including terminal `stopped` rows until purged). Omitted `versionId` scopes the page to the app's `activeVersionId`. An app with no active version therefore answers an empty default page — not that it has no workers, only that none are pinned. Optional `state` and `status` narrow the page further; a cursor must be replayed under the same filters it was issued with. + // Returns a newest-first page of workers observed for the app (including terminal `stopped` rows until purged). Omitted `versionId` scopes the page to the app's `activeVersionId`. An app with no active version therefore answers an empty default page — not that it has no workers, only that none are pinned. Optional `state`, `status` and `q` narrow the page further; a cursor must be replayed under the same filters it was issued with. // // Returns a wrapper object for the known response body format(s). // @@ -7427,7 +7497,7 @@ type ClientWithResponsesInterface interface { // GetLogEntriesWithResponse Read one page of a named log query // - // Returns one page of log entries, newest first, with an opaque cursor for the next page when one exists. Query ids and their supported selectors are listed by the insights catalogue. + // Returns one page of log entries in the requested `sort` order, newest first by default, with opaque cursors for the neighbouring pages when they exist. `nextCursor` continues in the `sort` order and `prevCursor` goes back against it, so a client can walk a window in either direction from either end. A cursor is only valid for the `sort` it was issued under; reusing one under the other ordering returns `400`. Query ids and their supported selectors are listed by the insights catalogue. // // Returns a wrapper object for the known response body format(s). // @@ -7778,6 +7848,8 @@ type CreateAppResponse struct { ApplicationproblemJSON400 *BadRequest // ApplicationproblemJSON401 the response for an HTTP 401 `application/problem+json` response ApplicationproblemJSON401 *Unauthorized + // ApplicationproblemJSON402 the response for an HTTP 402 `application/problem+json` response + ApplicationproblemJSON402 *PaymentRequired // ApplicationproblemJSON403 the response for an HTTP 403 `application/problem+json` response ApplicationproblemJSON403 *Forbidden // ApplicationproblemJSON404 the response for an HTTP 404 `application/problem+json` response @@ -7805,6 +7877,11 @@ func (r CreateAppResponse) GetApplicationproblemJSON401() *Unauthorized { return r.ApplicationproblemJSON401 } +// GetApplicationproblemJSON402 returns the response for an HTTP 402 `application/problem+json` response +func (r CreateAppResponse) GetApplicationproblemJSON402() *PaymentRequired { + return r.ApplicationproblemJSON402 +} + // GetApplicationproblemJSON403 returns the response for an HTTP 403 `application/problem+json` response func (r CreateAppResponse) GetApplicationproblemJSON403() *Forbidden { return r.ApplicationproblemJSON403 @@ -8006,6 +8083,8 @@ type UpdateAppResponse struct { ApplicationproblemJSON400 *BadRequest // ApplicationproblemJSON401 the response for an HTTP 401 `application/problem+json` response ApplicationproblemJSON401 *Unauthorized + // ApplicationproblemJSON402 the response for an HTTP 402 `application/problem+json` response + ApplicationproblemJSON402 *PaymentRequired // ApplicationproblemJSON403 the response for an HTTP 403 `application/problem+json` response ApplicationproblemJSON403 *Forbidden // ApplicationproblemJSON404 the response for an HTTP 404 `application/problem+json` response @@ -8033,6 +8112,11 @@ func (r UpdateAppResponse) GetApplicationproblemJSON401() *Unauthorized { return r.ApplicationproblemJSON401 } +// GetApplicationproblemJSON402 returns the response for an HTTP 402 `application/problem+json` response +func (r UpdateAppResponse) GetApplicationproblemJSON402() *PaymentRequired { + return r.ApplicationproblemJSON402 +} + // GetApplicationproblemJSON403 returns the response for an HTTP 403 `application/problem+json` response func (r UpdateAppResponse) GetApplicationproblemJSON403() *Forbidden { return r.ApplicationproblemJSON403 @@ -8354,6 +8438,8 @@ type DeployVersionResponse struct { ApplicationproblemJSON400 *BadRequest // ApplicationproblemJSON401 the response for an HTTP 401 `application/problem+json` response ApplicationproblemJSON401 *Unauthorized + // ApplicationproblemJSON402 the response for an HTTP 402 `application/problem+json` response + ApplicationproblemJSON402 *PaymentRequired // ApplicationproblemJSON403 the response for an HTTP 403 `application/problem+json` response ApplicationproblemJSON403 *Forbidden // ApplicationproblemJSON404 the response for an HTTP 404 `application/problem+json` response @@ -8381,6 +8467,11 @@ func (r DeployVersionResponse) GetApplicationproblemJSON401() *Unauthorized { return r.ApplicationproblemJSON401 } +// GetApplicationproblemJSON402 returns the response for an HTTP 402 `application/problem+json` response +func (r DeployVersionResponse) GetApplicationproblemJSON402() *PaymentRequired { + return r.ApplicationproblemJSON402 +} + // GetApplicationproblemJSON403 returns the response for an HTTP 403 `application/problem+json` response func (r DeployVersionResponse) GetApplicationproblemJSON403() *Forbidden { return r.ApplicationproblemJSON403 @@ -9395,12 +9486,16 @@ type ResumeAppResponse struct { JSON202 *App // ApplicationproblemJSON401 the response for an HTTP 401 `application/problem+json` response ApplicationproblemJSON401 *Unauthorized + // ApplicationproblemJSON402 the response for an HTTP 402 `application/problem+json` response + ApplicationproblemJSON402 *PaymentRequired // ApplicationproblemJSON403 the response for an HTTP 403 `application/problem+json` response ApplicationproblemJSON403 *Forbidden // ApplicationproblemJSON404 the response for an HTTP 404 `application/problem+json` response ApplicationproblemJSON404 *NotFound // ApplicationproblemJSON409 the response for an HTTP 409 `application/problem+json` response ApplicationproblemJSON409 *Conflict + // ApplicationproblemJSON422 the response for an HTTP 422 `application/problem+json` response + ApplicationproblemJSON422 *ValidationError // ApplicationproblemJSON503 the response for an HTTP 503 `application/problem+json` response ApplicationproblemJSON503 *ServiceUnavailable } @@ -9415,6 +9510,11 @@ func (r ResumeAppResponse) GetApplicationproblemJSON401() *Unauthorized { return r.ApplicationproblemJSON401 } +// GetApplicationproblemJSON402 returns the response for an HTTP 402 `application/problem+json` response +func (r ResumeAppResponse) GetApplicationproblemJSON402() *PaymentRequired { + return r.ApplicationproblemJSON402 +} + // GetApplicationproblemJSON403 returns the response for an HTTP 403 `application/problem+json` response func (r ResumeAppResponse) GetApplicationproblemJSON403() *Forbidden { return r.ApplicationproblemJSON403 @@ -9430,6 +9530,11 @@ func (r ResumeAppResponse) GetApplicationproblemJSON409() *Conflict { return r.ApplicationproblemJSON409 } +// GetApplicationproblemJSON422 returns the response for an HTTP 422 `application/problem+json` response +func (r ResumeAppResponse) GetApplicationproblemJSON422() *ValidationError { + return r.ApplicationproblemJSON422 +} + // GetApplicationproblemJSON503 returns the response for an HTTP 503 `application/problem+json` response func (r ResumeAppResponse) GetApplicationproblemJSON503() *ServiceUnavailable { return r.ApplicationproblemJSON503 @@ -12573,7 +12678,7 @@ func (c *ClientWithResponses) GetVersionWithResponse(ctx context.Context, appId // ListWorkersWithResponse List workers // -// Returns a newest-first page of workers observed for the app (including terminal `stopped` rows until purged). Omitted `versionId` scopes the page to the app's `activeVersionId`. An app with no active version therefore answers an empty default page — not that it has no workers, only that none are pinned. Optional `state` and `status` narrow the page further; a cursor must be replayed under the same filters it was issued with. +// Returns a newest-first page of workers observed for the app (including terminal `stopped` rows until purged). Omitted `versionId` scopes the page to the app's `activeVersionId`. An app with no active version therefore answers an empty default page — not that it has no workers, only that none are pinned. Optional `state`, `status` and `q` narrow the page further; a cursor must be replayed under the same filters it was issued with. // // Returns a wrapper object for the known response body format(s). // @@ -12633,7 +12738,7 @@ func (c *ClientWithResponses) GetGpuTypeWithResponse(ctx context.Context, gpuTyp // GetLogEntriesWithResponse Read one page of a named log query // -// Returns one page of log entries, newest first, with an opaque cursor for the next page when one exists. Query ids and their supported selectors are listed by the insights catalogue. +// Returns one page of log entries in the requested `sort` order, newest first by default, with opaque cursors for the neighbouring pages when they exist. `nextCursor` continues in the `sort` order and `prevCursor` goes back against it, so a client can walk a window in either direction from either end. A cursor is only valid for the `sort` it was issued under; reusing one under the other ordering returns `400`. Query ids and their supported selectors are listed by the insights catalogue. // // Returns a wrapper object for the known response body format(s). // @@ -13088,6 +13193,13 @@ func ParseCreateAppResponse(rsp *http.Response) (*CreateAppResponse, error) { } response.ApplicationproblemJSON401 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 402: + var dest PaymentRequired + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON402 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: var dest Forbidden if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -13271,6 +13383,13 @@ func ParseUpdateAppResponse(rsp *http.Response) (*UpdateAppResponse, error) { } response.ApplicationproblemJSON401 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 402: + var dest PaymentRequired + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON402 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: var dest Forbidden if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -13554,6 +13673,13 @@ func ParseDeployVersionResponse(rsp *http.Response) (*DeployVersionResponse, err } response.ApplicationproblemJSON401 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 402: + var dest PaymentRequired + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON402 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: var dest Forbidden if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -14399,6 +14525,13 @@ func ParseResumeAppResponse(rsp *http.Response) (*ResumeAppResponse, error) { } response.ApplicationproblemJSON401 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 402: + var dest PaymentRequired + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON402 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: var dest Forbidden if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -14420,6 +14553,13 @@ func ParseResumeAppResponse(rsp *http.Response) (*ResumeAppResponse, error) { } response.ApplicationproblemJSON409 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ValidationError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: var dest ServiceUnavailable if err := json.Unmarshal(bodyBytes, &dest); err != nil { diff --git a/internal/api/serverless/logs.go b/internal/api/serverless/logs.go index 9598951..5ce2c6f 100644 --- a/internal/api/serverless/logs.go +++ b/internal/api/serverless/logs.go @@ -16,10 +16,20 @@ import ( // LogEntry is one application log line. type LogEntry = gen.LogEntry -// LogEntryPage is one page of log entries, newest first. +// LogEntryPage is one page of log entries in the requested sort order. type LogEntryPage = gen.LogEntryPage -// GetLogEntriesParams narrows a log page: window, page size, cursor, app and endpoint. +// LogSort is the ordering of a log page. +type LogSort = gen.LogSort + +const ( + // LogSortNewest lists newest first; nextCursor walks older. + LogSortNewest LogSort = gen.Newest + // LogSortOldest lists oldest first; nextCursor walks newer. + LogSortOldest LogSort = gen.Oldest +) + +// GetLogEntriesParams narrows a log page: window, page size, cursor, sort, app and endpoint. type GetLogEntriesParams = gen.GetLogEntriesParams // LogWindow is the closed set of time windows a log query accepts. diff --git a/internal/cmd/root.go b/internal/cmd/root.go index d2583b5..08fb977 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -43,7 +43,7 @@ func NewRootCmd(logger *log.Logger) *cobra.Command { Use: "runware", Short: "CLI tool for the Runware API", Long: `A command-line tool for interacting with the Runware API. -Run image generation, video generation, audio generation, 3D, upscaling, background removal, captioning, search models, and more. +Run image generation, video generation, audio generation, 3D, upscaling, background removal, captioning, search models, deploy serverless applications, and more. Use of Runware services is subject to our Terms of Service (https://runware.ai/terms) and Privacy Policy (https://runware.ai/privacy).`, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { diff --git a/internal/cmd/serverless/apps.go b/internal/cmd/serverless/apps.go index 54bc398..bc8a15e 100644 --- a/internal/cmd/serverless/apps.go +++ b/internal/cmd/serverless/apps.go @@ -31,9 +31,11 @@ func newAppsCmd(logger *log.Logger) *cobra.Command { newAppsBuildsCmd(logger), newAppsLogsCmd(logger), newAppsEventsCmd(logger), + newAppsErrorsCmd(logger), newAppsWorkersCmd(logger), newAppsScaleCmd(logger), newAppsUsageCmd(logger), + newAppsRenameCmd(logger), newAppsStopCmd(logger), newAppsResumeCmd(logger), newAppsDeleteCmd(logger), @@ -112,7 +114,7 @@ func newAppsListCmd(logger *log.Logger) *cobra.Command { cmd.Flags().StringVar(&status, "status", "", "Filter by status (active, initializing, stopped, …)") cmd.Flags().StringVar(&query, "query", "", "Filter by substring on name or ID") cmd.Flags().StringVar(&gpuType, "gpu-type", "", "Filter by GPU type (see 'serverless gpus')") - cmd.Flags().StringVar(&sort, "sort", "", "Sort order (createdAt (default), name, activity, or errorRate)") + cmd.Flags().StringVar(&sort, "sort", "", "Sort order (createdAt (default) or name)") return cmd } @@ -143,6 +145,39 @@ func newAppsShowCmd(logger *log.Logger) *cobra.Command { } } +func newAppsRenameCmd(logger *log.Logger) *cobra.Command { + return &cobra.Command{ + Use: "rename ", + Short: "Rename a serverless application", + Long: `Change the display name of a serverless application. + +The application ID is immutable. A name-only update records a version and does +not pin or roll workers.`, + Example: ` # rename an application + runware serverless apps rename my-app "Image generator"`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + id := args[0] + name := args[1] + + spin := cmdutil.NewSpinner(fmt.Sprintf("Renaming application %s...", id)) + spin.Start() + + client := serverlessapi.NewClient(config.GetAPIKey(), config.GetServerlessBaseURL(), slog.New(logger)) + app, err := client.UpdateApp(cmd.Context(), id, serverlessapi.AppUpdate{ + AppName: &name, + }) + if err != nil { + spin.Stop() + return err + } + spin.Stop() + + return output.Print(cmdutil.FormatFor(cmd), appResult(*app)) + }, + } +} + func newAppsEndpointsCmd(logger *log.Logger) *cobra.Command { var ( limit int @@ -287,10 +322,11 @@ for worker output.`, func newAppsWorkersCmd(logger *log.Logger) *cobra.Command { var ( - limit int - cursor string - status string - state string + limit int + cursor string + status string + state string + version string ) cmd := &cobra.Command{ @@ -298,13 +334,20 @@ func newAppsWorkersCmd(logger *log.Logger) *cobra.Command { Short: "List and inspect workers for a serverless application", Long: `List workers observed for an application. +The default page is the active version only. An application with no active +version therefore answers an empty default page — not that it has no workers. +Pass --version all to include every version, or --version to scope to one. + 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 + Example: ` # list workers for the active version runware serverless apps workers my-app + # include workers from every version + runware serverless apps workers my-app --version all + # omit terminal stopped rows runware serverless apps workers my-app --state live @@ -328,11 +371,12 @@ has never run".`, return err } var params *serverlessapi.ListWorkersParams - if limit > 0 || cursor != "" || status != "" || state != "" { + if limit > 0 || cursor != "" || status != "" || state != "" || version != "" { params = &serverlessapi.ListWorkersParams{} params.Limit, params.Cursor = listPageParams(limit, cursor) params.Status = statusVal params.State = stateVal + params.VersionId = optionalStringPtr(version) } spin := cmdutil.NewSpinner(fmt.Sprintf("Fetching workers for %s...", id)) @@ -346,7 +390,7 @@ has never run".`, } spin.Stop() - return printPage(cmdutil.FormatFor(cmd), page, workersResult(page.Data), cmd.ErrOrStderr(), extraWorkersCursorFlags(state, status)) + return printPage(cmdutil.FormatFor(cmd), page, workersResult(page.Data), cmd.ErrOrStderr(), extraWorkersCursorFlags(state, status, version)) }, } @@ -354,6 +398,7 @@ has never run".`, 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.Flags().StringVar(&version, "version", "", "Scope to a version ID, or all (default: the active version)") cmd.AddCommand(newAppsWorkersShowCmd(logger)) return cmd } @@ -420,7 +465,12 @@ func parseValidFlag[T validListFlag](flag, value, want string) (*T, error) { } func parseAppSort(sort string) (*serverlessapi.AppSort, error) { - return parseValidFlag[serverlessapi.AppSort]("--sort", sort, "createdAt, name, activity, or errorRate") + switch sort { + case "activity", "errorRate": + return nil, fmt.Errorf("--sort %s is not available yet; traffic ranks are not collected. Use createdAt or name", sort) + default: + return parseValidFlag[serverlessapi.AppSort]("--sort", sort, "createdAt or name") + } } func parseAppStatus(status string) (*serverlessapi.AppStatus, error) { @@ -455,9 +505,10 @@ func extraStatusCursorFlag(value string) string { } // extraWorkersCursorFlags repeats workers list filters a next-page --cursor is bound to. -func extraWorkersCursorFlags(state, status string) string { +func extraWorkersCursorFlags(state, status, version string) string { parts := appendFlag(nil, "--state", state) - return strings.Join(appendFlag(parts, "--status", status), " ") + parts = appendFlag(parts, "--status", status) + return strings.Join(appendFlag(parts, "--version", version), " ") } func extraTypeCursorFlag(value string) string { diff --git a/internal/cmd/serverless/apps_errors.go b/internal/cmd/serverless/apps_errors.go new file mode 100644 index 0000000..af7cde4 --- /dev/null +++ b/internal/cmd/serverless/apps_errors.go @@ -0,0 +1,88 @@ +package serverless + +import ( + "fmt" + "log/slog" + "strings" + + "github.com/charmbracelet/log" + serverlessapi "github.com/runware/runware-cli/internal/api/serverless" + "github.com/runware/runware-cli/internal/cmdutil" + "github.com/runware/runware-cli/internal/config" + "github.com/spf13/cobra" +) + +const errorStatusClasses = "4xx or 5xx" + +func newAppsErrorsCmd(logger *log.Logger) *cobra.Command { + var ( + limit int + cursor string + window string + statusClass string + ) + + cmd := &cobra.Command{ + Use: "errors ", + Short: "List failed inference requests for a serverless application", + Long: `List failed inference requests for an application, newest first. + +Omit --status-class to include both 4xx and 5xx. The cursor is only valid with +the same --window and --status-class it was issued under.`, + Example: ` # list recent request errors + runware serverless apps errors my-app + + # last 24 hours of 5xx only + runware serverless apps errors my-app --window 24h --status-class 5xx + + # page through results + runware serverless apps errors my-app --limit 50 --cursor `, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateListLimit(limit); err != nil { + return err + } + windowVal, err := parseValidFlag[serverlessapi.ListAppErrorsParamsWindow]("--window", window, logWindows) + if err != nil { + return err + } + classVal, err := parseValidFlag[serverlessapi.ListAppErrorsParamsStatusClass]("--status-class", statusClass, errorStatusClasses) + if err != nil { + return err + } + + id := args[0] + var params *serverlessapi.ListAppErrorsParams + if limit > 0 || cursor != "" || window != "" || statusClass != "" { + params = &serverlessapi.ListAppErrorsParams{} + params.Limit, params.Cursor = listPageParams(limit, cursor) + params.Window = windowVal + params.StatusClass = classVal + } + + spin := cmdutil.NewSpinner(fmt.Sprintf("Fetching errors for %s...", id)) + spin.Start() + + client := serverlessapi.NewClient(config.GetAPIKey(), config.GetServerlessBaseURL(), slog.New(logger)) + page, err := client.ListAppErrors(cmd.Context(), id, params) + if err != nil { + spin.Stop() + return err + } + spin.Stop() + + return printPage(cmdutil.FormatFor(cmd), page, requestErrorsResult(page.Data), cmd.ErrOrStderr(), extraErrorsCursorFlags(window, statusClass)) + }, + } + + cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of errors to return (1-100)") + cmd.Flags().StringVar(&cursor, "cursor", "", "Pagination cursor from a previous nextCursor (reuse the same --window/--status-class)") + cmd.Flags().StringVar(&window, "window", "", "Time window ("+logWindows+")") + cmd.Flags().StringVar(&statusClass, "status-class", "", "Filter by status class ("+errorStatusClasses+")") + return cmd +} + +func extraErrorsCursorFlags(window, statusClass string) string { + parts := appendFlag(nil, "--window", window) + return strings.Join(appendFlag(parts, "--status-class", statusClass), " ") +} diff --git a/internal/cmd/serverless/apps_invoke.go b/internal/cmd/serverless/apps_invoke.go index c0d3664..e7b98a4 100644 --- a/internal/cmd/serverless/apps_invoke.go +++ b/internal/cmd/serverless/apps_invoke.go @@ -1,7 +1,9 @@ package serverless import ( + "context" "encoding/json" + "errors" "fmt" "io" "log/slog" @@ -19,6 +21,7 @@ func newAppsInvokeCmd(logger *log.Logger) *cobra.Command { var ( sync bool wait bool + timeout time.Duration bodyFile string taskID string pollInterval time.Duration @@ -81,9 +84,14 @@ instead of starting a second run.`, if shouldWait && task.Status == serverlessapi.TaskStatusPending { _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Task %s accepted; waiting...\n", task.Id) spin.SetMessage(fmt.Sprintf("Waiting for task %s...", task.Id)) - task, err = client.WaitTask(cmd.Context(), appID, task.Id, pollInterval) + waitCtx, cancel := waitContext(cmd.Context(), timeout) + task, err = client.WaitTask(waitCtx, appID, task.Id, pollInterval) + cancel() if err != nil { spin.Stop() + if errors.Is(err, context.DeadlineExceeded) && timeout > 0 { + return fmt.Errorf("timed out waiting for task %s after %s", task.Id, timeout) + } return err } } @@ -98,6 +106,7 @@ instead of starting a second run.`, cmd.Flags().BoolVar(&sync, "sync", false, "Use sync invocation and wait for a terminal task") cmd.Flags().BoolVar(&wait, "wait", false, "Poll until the task is completed or failed") + cmd.Flags().DurationVar(&timeout, "timeout", 0, "Maximum time to wait (0 = no limit)") cmd.Flags().StringVarP(&bodyFile, "body", "f", "", "JSON payload file, or - for stdin (default {})") cmd.Flags().StringVar(&taskID, "task-id", "", "Client task id (UUID); generated if omitted") cmd.Flags().DurationVar(&pollInterval, "poll-interval", 2*time.Second, "Polling interval when waiting for a task") diff --git a/internal/cmd/serverless/apps_lifecycle.go b/internal/cmd/serverless/apps_lifecycle.go index 1ee75b3..ccbbace 100644 --- a/internal/cmd/serverless/apps_lifecycle.go +++ b/internal/cmd/serverless/apps_lifecycle.go @@ -9,6 +9,7 @@ import ( "log/slog" "os" "strings" + "time" "github.com/charmbracelet/log" serverlessapi "github.com/runware/runware-cli/internal/api/serverless" @@ -38,27 +39,42 @@ is stopped. The application must be active.`, runware serverless apps stop my-app`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return runLifecycle(cmd, logger, args[0], "Stopping", (*serverlessapi.Client).StopApp) + return runLifecycle(cmd, logger, args[0], "Stopping", (*serverlessapi.Client).StopApp, waitOptions{}) }, } } func newAppsResumeCmd(logger *log.Logger) *cobra.Command { - return &cobra.Command{ + var ( + wait bool + timeout time.Duration + pollInterval time.Duration + ) + + cmd := &cobra.Command{ Use: "resume ", Short: "Resume a stopped serverless application", Long: `Resume a stopped serverless application. The server accepts the resume and returns immediately with status initializing. -Worker start is asynchronous; this command does not wait until the application -is active. The application must be stopped.`, +Worker start is asynchronous. Pass --wait to poll until the application is +active or failed. The application must be stopped.`, Example: ` # resume a stopped application - runware serverless apps resume my-app`, + runware serverless apps resume my-app + + # wait until the application is active or failed + runware serverless apps resume my-app --wait`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return runLifecycle(cmd, logger, args[0], "Resuming", (*serverlessapi.Client).ResumeApp) + return runLifecycle(cmd, logger, args[0], "Resuming", (*serverlessapi.Client).ResumeApp, waitOptions{ + Wait: wait, + Timeout: timeout, + Interval: pollInterval, + }) }, } + addAppWaitFlags(cmd, &wait, &timeout, &pollInterval) + return cmd } func newAppsDeleteCmd(logger *log.Logger) *cobra.Command { @@ -88,7 +104,7 @@ Confirmation is required unless --yes or --force is passed.`, if err := confirmDelete("application "+id, yes || force, cmd.InOrStdin(), cmd.ErrOrStderr(), stdinIsTerminal(cmd.InOrStdin()), config.GetAPIKey()); err != nil { return err } - return runLifecycle(cmd, logger, id, "Deleting", (*serverlessapi.Client).DeleteApp) + return runLifecycle(cmd, logger, id, "Deleting", (*serverlessapi.Client).DeleteApp, waitOptions{}) }, } @@ -98,7 +114,13 @@ Confirmation is required unless --yes or --force is passed.`, type lifecycleAction func(*serverlessapi.Client, context.Context, string) (*serverlessapi.App, error) -func runLifecycle(cmd *cobra.Command, logger *log.Logger, id, verb string, action lifecycleAction) error { +type waitOptions struct { + Wait bool + Timeout time.Duration + Interval time.Duration +} + +func runLifecycle(cmd *cobra.Command, logger *log.Logger, id, verb string, action lifecycleAction, wait waitOptions) error { spin := cmdutil.NewSpinner(fmt.Sprintf("%s application %s...", verb, id)) spin.Start() @@ -108,9 +130,24 @@ func runLifecycle(cmd *cobra.Command, logger *log.Logger, id, verb string, actio spin.Stop() return err } + if wait.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 = waitForApp(cmd.Context(), client, app.AppId, wait.Interval, wait.Timeout) + if err != nil { + spin.Stop() + return err + } + } spin.Stop() - return output.Print(cmdutil.FormatFor(cmd), appResult(*app)) + if err := output.Print(cmdutil.FormatFor(cmd), appResult(*app)); err != nil { + return err + } + if !wait.Wait { + return nil + } + return appFailedErr(cmd.Context(), client, app) } func addDeleteConfirmFlags(cmd *cobra.Command, yes, force *bool) { diff --git a/internal/cmd/serverless/apps_logs.go b/internal/cmd/serverless/apps_logs.go index 2d4a8cc..82f9d83 100644 --- a/internal/cmd/serverless/apps_logs.go +++ b/internal/cmd/serverless/apps_logs.go @@ -7,7 +7,6 @@ import ( "fmt" "io" "log/slog" - "slices" "strings" "time" @@ -37,6 +36,7 @@ type logsFlags struct { window string limit int cursor string + sort string follow bool } @@ -46,17 +46,19 @@ func newAppsLogsCmd(logger *log.Logger) *cobra.Command { cmd := &cobra.Command{ Use: "logs ", Short: "Show or follow logs for a serverless application", - Long: `Show recent application logs, oldest first, and optionally follow new ones. + Long: `Show recent application logs and optionally follow new ones. -The recent page is read from the runtime log query over --window (default 1h), -and --limit and --cursor page through it. With --follow the command prints the -recent page, then streams new entries until interrupted; the stream reconnects -when the server ends it, and waits for the stream to open when a gateway -answers first, which is what an application that has written nothing does. The -live stream has no window, so --window, --limit and --cursor apply to the -recent page only, and --cursor cannot be combined with --follow. Entries -written between the recent page and the start of the stream, or while the -stream reconnects, can be missed or repeated. +The recent page is read from the runtime log query over --window (default 1h). +--sort oldest (the default) lists oldest first; --sort newest lists newest +first. --limit and --cursor page through the window; nextCursor continues in +the sort order and prevCursor walks the other way. With --follow the command +prints the recent page, then streams new entries until interrupted; the stream +reconnects when the server ends it, and waits for the stream to open when a +gateway answers first, which is what an application that has written nothing +does. The live stream has no window, so --window, --limit, --cursor and --sort +apply to the recent page only, and --cursor cannot be combined with --follow. +Entries written between the recent page and the start of the stream, or while +the stream reconnects, can be missed or repeated. In table format each entry is one line: time, level and message. In json or yaml format the recent page is printed as one document; with --follow every @@ -104,7 +106,7 @@ entry is printed as one JSON object per line.`, } emit := logEmitter(format, out) - for _, entry := range slices.Backward(page.Entries) { + for _, entry := range page.Entries { if err := emit(entry); err != nil { return err } @@ -118,7 +120,8 @@ entry is printed as one JSON object per line.`, cmd.Flags().StringVar(&flags.window, "window", "1h", "Time window for the recent page ("+logWindows+")") cmd.Flags().IntVar(&flags.limit, "limit", 0, "Maximum number of entries on the recent page (1-100, default 20)") - cmd.Flags().StringVar(&flags.cursor, "cursor", "", "Pagination cursor from a previous nextCursor") + cmd.Flags().StringVar(&flags.cursor, "cursor", "", "Pagination cursor from a previous nextCursor or prevCursor") + cmd.Flags().StringVar(&flags.sort, "sort", "oldest", "Page order (oldest or newest)") cmd.Flags().BoolVarP(&flags.follow, "follow", "f", false, "Stream new log entries until interrupted") return cmd } @@ -135,28 +138,43 @@ func logEntriesParams(appID string, flags logsFlags) (serverlessapi.GetLogEntrie if window == nil { return serverlessapi.GetLogEntriesParams{}, fmt.Errorf("--window is required (want %s)", logWindows) } + sortVal, err := parseLogSort(flags.sort) + if err != nil { + return serverlessapi.GetLogEntriesParams{}, err + } params := serverlessapi.GetLogEntriesParams{ Window: *window, Deployment: &appID, + Sort: sortVal, } params.Limit, params.Cursor = listPageParams(flags.limit, flags.cursor) return params, nil } +func parseLogSort(sort string) (*serverlessapi.LogSort, error) { + if sort == "" { + oldest := serverlessapi.LogSortOldest + return &oldest, nil + } + return parseValidFlag[serverlessapi.LogSort]("--sort", sort, "oldest or newest") +} + // extraLogsCursorFlags repeats the filters a next-page --cursor is bound to. func extraLogsCursorFlags(flags logsFlags) string { parts := appendFlag(nil, "--window", flags.window) if flags.limit > 0 { parts = appendFlag(parts, "--limit", fmt.Sprint(flags.limit)) } + if flags.sort != "" && flags.sort != "oldest" { + parts = appendFlag(parts, "--sort", flags.sort) + } return strings.Join(parts, " ") } -// printLogPage prints one page, oldest first: as a document in json or yaml, -// as one line per entry in table format. +// printLogPage prints one page in the API sort order: as a document in json or +// yaml, as one line per entry in table format. Both neighbouring cursors are +// hinted when present. func printLogPage(format output.Format, page serverlessapi.LogEntryPage, out, errOut io.Writer, extraCursorFlags string) error { - page.Entries = slices.Clone(page.Entries) - slices.Reverse(page.Entries) switch format { case output.FormatJSON, output.FormatYAML: return output.Print(format, page) @@ -166,7 +184,7 @@ func printLogPage(format output.Format, page serverlessapi.LogEntryPage, out, er return err } } - return printNextCursor(errOut, page.NextCursor, extraCursorFlags) + return printLogCursors(errOut, page.NextCursor, page.PrevCursor, extraCursorFlags) } } diff --git a/internal/cmd/serverless/apps_logs_test.go b/internal/cmd/serverless/apps_logs_test.go index 4044279..99eaf6a 100644 --- a/internal/cmd/serverless/apps_logs_test.go +++ b/internal/cmd/serverless/apps_logs_test.go @@ -36,6 +36,9 @@ func TestLogEntriesParams_MapsFlags(t *testing.T) { if params.Limit == nil || *params.Limit != 50 || params.Cursor == nil || *params.Cursor != testLogCursor { t.Errorf("limit/cursor = %#v", params) } + if params.Sort == nil || *params.Sort != serverlessapi.LogSortOldest { + t.Errorf("sort = %#v", params.Sort) + } } func TestLogEntriesParams_OmitsUnsetOptionalFlags(t *testing.T) { @@ -46,6 +49,9 @@ func TestLogEntriesParams_OmitsUnsetOptionalFlags(t *testing.T) { if params.Limit != nil || params.Cursor != nil { t.Errorf("optional params must be nil: %#v", params) } + if params.Sort == nil || *params.Sort != serverlessapi.LogSortOldest { + t.Errorf("default sort = %#v", params.Sort) + } } // badFlagsCase pairs a rejected flag set with the error text it must produce. @@ -71,6 +77,13 @@ func TestLogEntriesParams_RejectsBadFlags(t *testing.T) { flags: logsFlags{window: ""}, want: "--window is required", }, + "sort": { + flags: logsFlags{ + window: "1h", + sort: "nope", + }, + want: "invalid --sort", + }, } for name, tc := range cases { t.Run(name, func(t *testing.T) { @@ -107,20 +120,22 @@ func TestFormatLogLine(t *testing.T) { } } -func TestPrintLogPage_TablePrintsOldestFirstAndCursorHint(t *testing.T) { +func TestPrintLogPage_TablePrintsAPIOrderAndCursors(t *testing.T) { next := testLogCursor + prev := "page-0" page := serverlessapi.LogEntryPage{ Entries: []serverlessapi.LogEntry{ - { - Time: 1750000001, - Body: testLogBodySlow, - }, { Time: 1750000000, Body: testLogBodyReady, }, + { + Time: 1750000001, + Body: testLogBodySlow, + }, }, NextCursor: &next, + PrevCursor: &prev, } var out, errOut bytes.Buffer flags := logsFlags{ @@ -134,9 +149,11 @@ func TestPrintLogPage_TablePrintsOldestFirstAndCursorHint(t *testing.T) { if len(lines) != 2 || !strings.HasSuffix(lines[0], testLogBodyReady) || !strings.HasSuffix(lines[1], testLogBodySlow) { t.Fatalf("stdout = %q", out.String()) } - want := "Next page: --window 6h --limit 50 --cursor " + testLogCursor - if !strings.Contains(errOut.String(), want) { - t.Fatalf("stderr = %q, want %q", errOut.String(), want) + if !strings.Contains(errOut.String(), "Next page: --window 6h --limit 50 --cursor "+testLogCursor) { + t.Fatalf("stderr missing next: %q", errOut.String()) + } + if !strings.Contains(errOut.String(), "Previous page: --window 6h --limit 50 --cursor "+prev) { + t.Fatalf("stderr missing prev: %q", errOut.String()) } } diff --git a/internal/cmd/serverless/apps_scale.go b/internal/cmd/serverless/apps_scale.go index 65a415f..9a95334 100644 --- a/internal/cmd/serverless/apps_scale.go +++ b/internal/cmd/serverless/apps_scale.go @@ -89,7 +89,7 @@ func bindScaleFlags(cmd *cobra.Command, flags *scaleFlags) { f.Int32Var(&flags.scalingDelay, "scaling-delay", 0, "Scaling delay in seconds") f.Int32Var(&flags.concurrency, "concurrency", 0, "Max tasks a single worker handles simultaneously") f.StringVar(&flags.gpuType, "gpu-type", "", "Preferred GPU type ID (see 'serverless gpus')") - f.Int32Var(&flags.gpusPerWorker, "gpus-per-worker", 0, "GPUs allocated per worker") + f.Int32Var(&flags.gpusPerWorker, "gpus-per-worker", 0, "GPUs allocated per worker (1, 2, 4, or 8)") f.StringVar(&flags.fallbackGPUType, "fallback-gpu-type", "", "Secondary GPU type if the preferred type is unavailable") f.Int32Var(&flags.minAvailableWorkers, "min-available-workers", 0, "Minimum idle workers kept as a buffer") f.Int32Var(&flags.availableWorkersPct, "available-workers-pct", 0, "Idle-worker buffer as a percentage of load (0-100)") @@ -111,6 +111,11 @@ func workerConfigPatchFromFlags(cmd *cobra.Command, flags scaleFlags) (*serverle MinAvailableWorkers: optionalInt32Ptr(cmd, "min-available-workers", flags.minAvailableWorkers), AvailableWorkersPct: optionalInt32Ptr(cmd, "available-workers-pct", flags.availableWorkersPct), } + if cmd.Flags().Changed("gpus-per-worker") { + if err := validateGPUsPerWorker(flags.gpusPerWorker); err != nil { + return nil, err + } + } if *patch == (serverlessapi.WorkerConfigPatch{}) { return nil, fmt.Errorf("at least one scaling flag is required") } diff --git a/internal/cmd/serverless/apps_scale_test.go b/internal/cmd/serverless/apps_scale_test.go index 4ed754b..4855371 100644 --- a/internal/cmd/serverless/apps_scale_test.go +++ b/internal/cmd/serverless/apps_scale_test.go @@ -87,6 +87,17 @@ func TestWorkerConfigPatchFromFlags_EachFlag(t *testing.T) { } } +func TestWorkerConfigPatchFromFlags_RejectsInvalidGPUsPerWorker(t *testing.T) { + cmd, flags := newScaleFlagCmd() + if err := cmd.ParseFlags([]string{"--gpus-per-worker", "3"}); err != nil { + t.Fatalf("ParseFlags: %v", err) + } + _, err := workerConfigPatchFromFlags(cmd, *flags) + if err == nil || !strings.Contains(err.Error(), "1, 2, 4, or 8") { + t.Fatalf("got %v", err) + } +} + func TestWorkerConfigPatchFromFlags_RequiresAFlag(t *testing.T) { cmd, flags := newScaleFlagCmd() if err := cmd.ParseFlags([]string{}); err != nil { diff --git a/internal/cmd/serverless/apps_tasks_test.go b/internal/cmd/serverless/apps_tasks_test.go index 7250b26..937eebf 100644 --- a/internal/cmd/serverless/apps_tasks_test.go +++ b/internal/cmd/serverless/apps_tasks_test.go @@ -33,20 +33,21 @@ func TestTaskResult_IncludesOutputAndError(t *testing.T) { errMsg := "oom killed" output := map[string]any{"ok": true} r := taskResult{ - Id: testTaskID, - AppId: testAppID, - Status: serverlessapi.TaskStatusFailed, - Error: &errMsg, - Output: &output, - CreatedAt: created, - CompletedAt: &completed, + Id: testTaskID, + AppId: testAppID, + EndpointPath: "infer", + Status: serverlessapi.TaskStatusFailed, + Error: &errMsg, + Output: &output, + CreatedAt: created, + CompletedAt: &completed, } rows := r.Rows() got := make(map[string]any, len(rows)) for _, row := range rows { got[row[0].(string)] = row[1] } - if got[colID] != testTaskID || got[colStatus] != "failed" || got[colError] != errMsg { + if got[colID] != testTaskID || got[colStatus] != "failed" || got[colError] != errMsg || got[colEndpointPath] != "infer" { t.Fatalf("rows = %#v", got) } if got["Output"] != `{"ok":true}` { @@ -58,7 +59,7 @@ func TestTasksResult_Headers(t *testing.T) { r := tasksResult{ {Id: testTaskID, Status: serverlessapi.TaskStatusPending, CreatedAt: time.Date(2026, 7, 30, 12, 0, 0, 0, time.UTC)}, } - if got := r.Headers(); len(got) != 5 { + if got := r.Headers(); len(got) != 6 { t.Fatalf("headers: %v", got) } rows := r.Rows() diff --git a/internal/cmd/serverless/apps_versions.go b/internal/cmd/serverless/apps_versions.go index 6d0b179..80f689c 100644 --- a/internal/cmd/serverless/apps_versions.go +++ b/internal/cmd/serverless/apps_versions.go @@ -4,6 +4,7 @@ import ( "fmt" "log/slog" "strconv" + "time" "github.com/charmbracelet/log" serverlessapi "github.com/runware/runware-cli/internal/api/serverless" @@ -107,15 +108,21 @@ func newAppsVersionsShowCmd(logger *log.Logger) *cobra.Command { } func newAppsVersionsActivateCmd(logger *log.Logger) *cobra.Command { - return &cobra.Command{ + var ( + wait bool + timeout time.Duration + pollInterval time.Duration + ) + + cmd := &cobra.Command{ Use: "activate ", Short: "Activate a ready application version", Long: `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. +Worker rollout is asynchronous. Pass --wait to poll until the application is +active or failed. 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.`, @@ -124,7 +131,10 @@ app that is deleting is 409.`, runware serverless apps versions activate my-app 2 # roll back to an older ready version - runware serverless apps versions activate my-app 1`, + runware serverless apps versions activate my-app 1 + + # wait until the rollout is active or failed + runware serverless apps versions activate my-app 2 --wait`, Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { appID := args[0] @@ -142,11 +152,28 @@ app that is deleting is 409.`, spin.Stop() 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 = waitForApp(cmd.Context(), client, app.AppId, pollInterval, timeout) + if err != nil { + spin.Stop() + return err + } + } spin.Stop() - return output.Print(cmdutil.FormatFor(cmd), appResult(*app)) + if err := output.Print(cmdutil.FormatFor(cmd), appResult(*app)); err != nil { + return err + } + if !wait { + return nil + } + return appFailedErr(cmd.Context(), client, app) }, } + addAppWaitFlags(cmd, &wait, &timeout, &pollInterval) + return cmd } func newAppsVersionsDeleteCmd(logger *log.Logger) *cobra.Command { diff --git a/internal/cmd/serverless/deploy.go b/internal/cmd/serverless/deploy.go index 0298501..423a587 100644 --- a/internal/cmd/serverless/deploy.go +++ b/internal/cmd/serverless/deploy.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "strings" "time" "github.com/charmbracelet/log" @@ -33,9 +34,14 @@ var createOnlyDeployFlags = []string{ "scaling-delay", "min-workers", "gpus-per-worker", + "concurrency", + "fallback-gpu-type", + "min-available-workers", + "available-workers-pct", "volume", "env", "env-file", + "secret", } // deploySource is the packed archive's type plus the fields CreateApp needs @@ -112,23 +118,29 @@ func buildDeployArchive(srcDir, containerDir, baseImage string, requirements []s func newDeployCmd(logger *log.Logger) *cobra.Command { var ( - id string - name string - maxWorkers int32 - idleTTL int32 - scalingDelay int32 - baseImage string - gpuType string - requirements []string - minWorkers int32 - gpusPerWorker int32 - srcDir string - containerDir string - volumes []string - envVars []string - envFiles []string - wait bool - pollInterval time.Duration + id string + name string + maxWorkers int32 + idleTTL int32 + scalingDelay int32 + baseImage string + gpuType string + requirements []string + minWorkers int32 + gpusPerWorker int32 + concurrency int32 + fallbackGPUType string + minAvailableWorkers int32 + availableWorkersPct int32 + srcDir string + containerDir string + volumes []string + envVars []string + envFiles []string + secrets []string + wait bool + timeout time.Duration + pollInterval time.Duration ) cmd := &cobra.Command{ @@ -139,9 +151,10 @@ func newDeployCmd(logger *log.Logger) *cobra.Command { A first deploy with a new --id creates the application. A later deploy with the same --id uploads a new source, records version N+1, and rolls it when the build is ready. Create-only flags (--gpu-type, worker settings, --volume, ---env, --env-file, --name) apply only to create; passing them when the -application already exists is an error. Change workers with 'apps scale' and -environment with 'apps env'. A source update on a stopped application is 409. +--env, --env-file, --secret, --name) apply only to create; passing them when the +application already exists is an error. Change workers with 'apps scale', +environment with 'apps env', and the display name with 'apps rename'. A source +update on a stopped application is 409. A code deploy takes a Python entry file. The whole source directory is zipped and submitted as the application source, so the entry file can import its own @@ -170,17 +183,17 @@ what a project keeps out of version control is a different question from what it ships. Either way .env files are never uploaded, and neither are .git, __pycache__, .venv, node_modules or the usual build and tool caches. -Environment variables must be supplied at create with --env or --env-file. An -app's environment is frozen into the version this command creates, which is -what the worker is rendered from, so setting one afterwards with 'apps env set' -stores it without it ever reaching a pod. Prefer --env-file for anything secret: -a value passed as --env is visible in the process list and recorded in shell -history. +Environment variables can be supplied at create with --env or --env-file, or +changed later with 'apps env set', which records a new version and rolls live +workers when the value changes. Prefer --env-file for anything secret: a value +passed as --env is visible in the process list and recorded in shell history. +Attach existing organisation secrets at create with --secret so the first +rollout carries them; attach and detach after create also roll live workers. -Anything the app downloads at runtime belongs on a --volume. The app runs in a -sandbox whose filesystem is part of the checkpointed state, so an unmounted -download is copied into every checkpoint and fetched again on every cold start. -A volume keeps it out of both. +Anything the app downloads at runtime belongs on a --volume. Volumes are +immutable after create. The app runs in a sandbox whose filesystem is part of +the checkpointed state, so an unmounted download is copied into every checkpoint +and fetched again on every cold start. A volume keeps it out of both. Worker settings are supplied via flags on create. Endpoints are derived server-side from the SDK (code) or from container.yaml (container).`, @@ -245,8 +258,12 @@ server-side from the SDK (code) or from container.yaml (container).`, var ( appVolumes *[]serverlessapi.AppVolume appEnv *map[string]string + appSecrets *[]serverlessapi.SecretAttach ) if !update { + if err := validateGPUsPerWorker(gpusPerWorker); err != nil { + return err + } appVolumes, err = buildVolumes(volumes) if err != nil { return err @@ -255,6 +272,10 @@ server-side from the SDK (code) or from container.yaml (container).`, if err != nil { return err } + appSecrets, err = parseSecretAttaches(secrets) + if err != nil { + return err + } } spin := cmdutil.NewSpinner(fmt.Sprintf("Uploading source for %s...", id)) @@ -286,13 +307,18 @@ server-side from the SDK (code) or from container.yaml (container).`, AppSource: appSource, Volumes: appVolumes, EnvironmentVariables: appEnv, + Secrets: appSecrets, Configuration: serverlessapi.WorkerConfigCreate{ - MaxWorkers: maxWorkers, - IdleTtlSecs: idleTTL, - ScalingDelaySecs: scalingDelay, - GpuType: gpuType, - MinWorkers: optionalInt32Ptr(cmd, "min-workers", minWorkers), - GpusPerWorker: optionalInt32Ptr(cmd, "gpus-per-worker", gpusPerWorker), + MaxWorkers: maxWorkers, + IdleTtlSecs: idleTTL, + ScalingDelaySecs: scalingDelay, + GpuType: gpuType, + MinWorkers: optionalInt32Ptr(cmd, "min-workers", minWorkers), + GpusPerWorker: optionalInt32Ptr(cmd, "gpus-per-worker", gpusPerWorker), + Concurrency: optionalInt32Ptr(cmd, "concurrency", concurrency), + FallbackGpuType: optionalFlagStringPtr(cmd, "fallback-gpu-type", fallbackGPUType), + MinAvailableWorkers: optionalInt32Ptr(cmd, "min-available-workers", minAvailableWorkers), + AvailableWorkersPct: optionalInt32Ptr(cmd, "available-workers-pct", availableWorkersPct), }, }) if isHTTPConflict(err) { @@ -308,7 +334,7 @@ server-side from the SDK (code) or from container.yaml (container).`, 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) + app, err = waitForApp(cmd.Context(), client, app.AppId, pollInterval, timeout) if err != nil { spin.Stop() return err @@ -328,9 +354,10 @@ server-side from the SDK (code) or from container.yaml (container).`, cmd.Flags().StringVar(&srcDir, "src-dir", "", "Directory to package as the application source (default: the working directory; code deploys only)") cmd.Flags().StringVar(&containerDir, "container", "", "Directory whose root contains Dockerfile and container.yaml") - cmd.Flags().StringArrayVar(&volumes, "volume", nil, "Absolute path inside the app backed by persistent node-local storage (repeatable)") + cmd.Flags().StringArrayVar(&volumes, "volume", nil, "Absolute path inside the app backed by persistent node-local storage (repeatable; immutable after create)") cmd.Flags().StringArrayVar(&envVars, "env", nil, "Environment variable as KEY=VALUE (repeatable)") cmd.Flags().StringArrayVar(&envFiles, "env-file", nil, "File of KEY=VALUE lines to read environment variables from (repeatable)") + cmd.Flags().StringArrayVar(&secrets, "secret", nil, "Attach an organisation secret as NAME or NAME=ENV_VAR (repeatable)") cmd.Flags().StringVar(&id, "id", "", "Application ID (immutable, lowercase slug)") cmd.Flags().StringVar(&name, "name", "", "Display name (defaults to --id)") cmd.Flags().Int32Var(&maxWorkers, "max-workers", 1, "Maximum number of workers") @@ -340,9 +367,12 @@ server-side from the SDK (code) or from container.yaml (container).`, cmd.Flags().StringVar(&gpuType, "gpu-type", "", "GPU type ID (see 'serverless gpus'; required when creating)") cmd.Flags().StringArrayVar(&requirements, "requirement", nil, "Additional pip package to install (repeatable; code deploys only)") cmd.Flags().Int32Var(&minWorkers, "min-workers", 0, "Minimum number of workers") - cmd.Flags().Int32Var(&gpusPerWorker, "gpus-per-worker", 1, "GPUs allocated per worker") - cmd.Flags().BoolVar(&wait, "wait", false, "Poll until the application is active or failed") - cmd.Flags().DurationVar(&pollInterval, "poll-interval", 2*time.Second, "Polling interval when waiting for the application") + cmd.Flags().Int32Var(&gpusPerWorker, "gpus-per-worker", 1, "GPUs allocated per worker (1, 2, 4, or 8)") + cmd.Flags().Int32Var(&concurrency, "concurrency", 1, "Max tasks a single worker handles simultaneously") + cmd.Flags().StringVar(&fallbackGPUType, "fallback-gpu-type", "", "Secondary GPU type if the preferred type is unavailable") + cmd.Flags().Int32Var(&minAvailableWorkers, "min-available-workers", 0, "Minimum idle workers kept as a buffer") + cmd.Flags().Int32Var(&availableWorkersPct, "available-workers-pct", 0, "Idle-worker buffer as a percentage of load (0-100)") + addAppWaitFlags(cmd, &wait, &timeout, &pollInterval) if err := cmd.MarkFlagRequired("id"); err != nil { panic(err) @@ -380,17 +410,60 @@ func validateUpdateDeployFlags(cmd *cobra.Command) error { func createOnlyDeployHint(name string) string { switch name { - case "gpu-type", "max-workers", "idle-ttl", "scaling-delay", "min-workers", "gpus-per-worker": + case "gpu-type", "max-workers", "idle-ttl", "scaling-delay", "min-workers", "gpus-per-worker", "concurrency", "fallback-gpu-type", "min-available-workers", "available-workers-pct": return "use 'runware serverless apps scale' to change worker configuration" case "env", "env-file": return "use 'runware serverless apps env' to change environment variables" + case "secret": + return "use 'runware serverless secrets attach' to attach secrets" case "volume": - return "volumes are set at create time and cannot be changed here" + return "volumes are immutable after create" + case "name": + return "use 'runware serverless apps rename' to change the display name" default: return "omit it when updating an existing application" } } +func validateGPUsPerWorker(n int32) error { + switch n { + case 1, 2, 4, 8: + return nil + default: + return fmt.Errorf("--gpus-per-worker must be 1, 2, 4, or 8") + } +} + +func parseSecretAttaches(values []string) (*[]serverlessapi.SecretAttach, error) { + if len(values) == 0 { + return nil, nil + } + out := make([]serverlessapi.SecretAttach, 0, len(values)) + for _, raw := range values { + name, envVar, err := splitSecretAttach(raw) + if err != nil { + return nil, err + } + attach := serverlessapi.SecretAttach{SecretName: name} + if envVar != "" { + attach.EnvVarName = &envVar + } + out = append(out, attach) + } + return &out, nil +} + +func splitSecretAttach(raw string) (name, envVar string, err error) { + name, envVar, ok := strings.Cut(raw, "=") + if !ok { + name = raw + } + if name == "" { + return "", "", fmt.Errorf("invalid --secret %q (want NAME or NAME=ENV_VAR)", raw) + } + return name, envVar, nil +} + func optionalStringSlice(vals []string) *[]string { if len(vals) == 0 { return nil diff --git a/internal/cmd/serverless/deploy_test.go b/internal/cmd/serverless/deploy_test.go index 57a7433..96d0dce 100644 --- a/internal/cmd/serverless/deploy_test.go +++ b/internal/cmd/serverless/deploy_test.go @@ -199,6 +199,12 @@ func TestNewDeployCmd_RegistersContainerFlag(t *testing.T) { if cmd.Flags().Lookup("poll-interval") == nil { t.Fatal("deploy is missing --poll-interval") } + if cmd.Flags().Lookup("timeout") == nil { + t.Fatal("deploy is missing --timeout") + } + if cmd.Flags().Lookup("secret") == nil { + t.Fatal("deploy is missing --secret") + } if cmd.Use != "deploy [file]" { t.Errorf("Use = %q, want deploy [file]", cmd.Use) } @@ -280,7 +286,9 @@ func TestValidateUpdateDeployFlags(t *testing.T) { {flags: []string{"--env", "FOO=bar"}, wantErr: "apps env"}, {flags: []string{"--env-file", envDotfile}, wantErr: "apps env"}, {flags: []string{"--volume", "/data"}, wantErr: "volumes"}, - {flags: []string{"--name", "My App"}, wantErr: "omit it"}, + {flags: []string{"--name", "My App"}, wantErr: "apps rename"}, + {flags: []string{"--secret", "FOO"}, wantErr: "secrets attach"}, + {flags: []string{"--concurrency", "2"}, wantErr: "apps scale"}, {flags: []string{"--requirement", testPipPackage}}, {flags: []string{"--base-image", "python:3.12-slim"}}, {flags: []string{testSrcDirFlag, "."}}, @@ -358,3 +366,39 @@ func TestAppFailedErr(t *testing.T) { t.Fatalf("failed without build error: %v", err) } } + +func TestValidateGPUsPerWorker(t *testing.T) { + for _, n := range []int32{1, 2, 4, 8} { + if err := validateGPUsPerWorker(n); err != nil { + t.Fatalf("%d: %v", n, err) + } + } + if err := validateGPUsPerWorker(3); err == nil || !strings.Contains(err.Error(), "1, 2, 4, or 8") { + t.Fatalf("3: %v", err) + } +} + +func TestParseSecretAttaches(t *testing.T) { + got, err := parseSecretAttaches(nil) + if err != nil || got != nil { + t.Fatalf("nil: got=%v err=%v", got, err) + } + + got, err = parseSecretAttaches([]string{"FOO", "BAR=BAZ"}) + if err != nil { + t.Fatalf("parse: %v", err) + } + if got == nil || len(*got) != 2 { + t.Fatalf("got %#v", got) + } + if (*got)[0].SecretName != "FOO" || (*got)[0].EnvVarName != nil { + t.Fatalf("FOO: %#v", (*got)[0]) + } + if (*got)[1].SecretName != "BAR" || (*got)[1].EnvVarName == nil || *(*got)[1].EnvVarName != "BAZ" { + t.Fatalf("BAR: %#v", (*got)[1]) + } + + if _, err := parseSecretAttaches([]string{"=ENV"}); err == nil { + t.Fatal("expected error for empty secret name") + } +} diff --git a/internal/cmd/serverless/display.go b/internal/cmd/serverless/display.go index 7aa0a85..b292a69 100644 --- a/internal/cmd/serverless/display.go +++ b/internal/cmd/serverless/display.go @@ -48,6 +48,15 @@ const ( colIdleTTL = "Idle TTL (s)" colScalingDelay = "Scaling delay (s)" colConcurrency = "Concurrency" + colEffectiveMaxWorkers = "Effective max workers" + colActiveWorkers = "Active workers" + colQueueDepth = "Queue depth" + colRequests24h = "Requests (24h)" + colEndpointPath = "Endpoint" + colReqPerMin = "Req/min" + colP95 = "P95 (s)" + colP99 = "P99 (s)" + colLevel = "Level" ) // appResult wraps a single app for table/json/yaml display. @@ -77,6 +86,10 @@ func (r appResult) Rows() [][]any { {colIdleTTL, cfg.IdleTtlSecs}, {colScalingDelay, cfg.ScalingDelaySecs}, {colConcurrency, cfg.Concurrency}, + {colEffectiveMaxWorkers, formatOptionalInt32(r.EffectiveMaxWorkers)}, + {colActiveWorkers, r.Runtime.ActiveWorkers}, + {colQueueDepth, formatOptionalInt64(r.Runtime.QueueDepth)}, + {colRequests24h, formatOptionalInt64(r.Runtime.Requests24h)}, } } @@ -105,7 +118,7 @@ func (r appsResult) Rows() [][]any { type endpointsResult []serverlessapi.Endpoint func (r endpointsResult) Headers() []string { - return []string{"Path", colID, colCreated} + return []string{"Path", colID, colCreated, colStatus, colReqPerMin, colP95, colP99} } func (r endpointsResult) Rows() [][]any { @@ -116,6 +129,10 @@ func (r endpointsResult) Rows() [][]any { e.Path, e.Id.String(), formatOptionalTime(e.CreatedAt), + formatEndpointStatus(e.Runtime), + formatEndpointRate(e.Runtime, func(rt serverlessapi.EndpointRuntime) *float64 { return rt.RequestsPerMinute }), + formatEndpointRate(e.Runtime, func(rt serverlessapi.EndpointRuntime) *float64 { return rt.P95RequestDuration }), + formatEndpointRate(e.Runtime, func(rt serverlessapi.EndpointRuntime) *float64 { return rt.P99RequestDuration }), } } return rows @@ -135,6 +152,10 @@ func (r endpointResult) Rows() [][]any { {colApp, r.AppId}, {colCreated, formatOptionalTime(r.CreatedAt)}, {colUpdated, formatOptionalTime(r.UpdatedAt)}, + {colStatus, formatEndpointStatus(r.Runtime)}, + {colReqPerMin, formatEndpointRate(r.Runtime, func(rt serverlessapi.EndpointRuntime) *float64 { return rt.RequestsPerMinute })}, + {colP95, formatEndpointRate(r.Runtime, func(rt serverlessapi.EndpointRuntime) *float64 { return rt.P95RequestDuration })}, + {colP99, formatEndpointRate(r.Runtime, func(rt serverlessapi.EndpointRuntime) *float64 { return rt.P99RequestDuration })}, } } @@ -262,7 +283,7 @@ func (r workerResult) Rows() [][]any { type tasksResult []serverlessapi.Task func (r tasksResult) Headers() []string { - return []string{colID, colStatus, colError, colCreated, colCompleted} + return []string{colID, colStatus, colEndpointPath, colError, colCreated, colCompleted} } func (r tasksResult) Rows() [][]any { @@ -272,6 +293,7 @@ func (r tasksResult) Rows() [][]any { rows[i] = []any{ task.Id, string(task.Status), + task.EndpointPath, formatOptionalString(task.Error), formatTaskTime(task.CreatedAt), formatOptionalTime(task.CompletedAt), @@ -291,6 +313,7 @@ func (r taskResult) Rows() [][]any { rows := [][]any{ {colID, r.Id}, {colApp, r.AppId}, + {colEndpointPath, r.EndpointPath}, {colStatus, string(r.Status)}, {colCreated, formatTaskTime(r.CreatedAt)}, {colCompleted, formatOptionalTime(r.CompletedAt)}, @@ -327,6 +350,61 @@ func formatOptionalInt32(v *int32) string { return fmt.Sprintf("%d", *v) } +func formatOptionalInt64(v *int64) string { + if v == nil { + return "" + } + return fmt.Sprintf("%d", *v) +} + +func formatEndpointStatus(rt *serverlessapi.EndpointRuntime) string { + if rt == nil || rt.Status == nil { + return "" + } + return string(*rt.Status) +} + +func formatEndpointRate(rt *serverlessapi.EndpointRuntime, field func(serverlessapi.EndpointRuntime) *float64) string { + if rt == nil { + return "" + } + return formatOptionalFloat64(field(*rt)) +} + +func formatOptionalFloat64(v *float64) string { + if v == nil { + return "" + } + return fmt.Sprintf("%.2f", *v) +} + +// requestErrorsResult wraps failed inference requests for table display. +type requestErrorsResult []serverlessapi.LogEntry + +func (r requestErrorsResult) Headers() []string { + return []string{colTime, colLevel, colMessage} +} + +func (r requestErrorsResult) Rows() [][]any { + rows := make([][]any, len(r)) + for i := range r { + entry := r[i] + rows[i] = []any{ + formatLogTime(entry), + entryLevel(entry), + entry.Body, + } + } + return rows +} + +func formatLogTime(entry serverlessapi.LogEntry) string { + if entry.Time == 0 { + return "" + } + return time.Unix(entry.Time, 0).UTC().Format(time.RFC3339) +} + // buildsResult wraps build lists for table display. Log tail is omitted. type buildsResult []serverlessapi.Build @@ -567,13 +645,24 @@ func printPage[T any](format output.Format, page serverlessapi.Page[T], table ou } func printNextCursor(errOut io.Writer, next *string, extraFlags string) error { - if next == nil || *next == "" { + return printNamedCursor(errOut, "Next page", next, extraFlags) +} + +func printLogCursors(errOut io.Writer, next, prev *string, extraFlags string) error { + if err := printNamedCursor(errOut, "Next page", next, extraFlags); err != nil { + return err + } + return printNamedCursor(errOut, "Previous page", prev, extraFlags) +} + +func printNamedCursor(errOut io.Writer, label string, cursor *string, extraFlags string) error { + if cursor == nil || *cursor == "" { return nil } - hint := "--cursor " + *next + hint := "--cursor " + *cursor if extraFlags != "" { hint = extraFlags + " " + hint } - _, err := fmt.Fprintf(errOut, "\nNext page: %s\n", hint) + _, err := fmt.Fprintf(errOut, "\n%s: %s\n", label, hint) return err } diff --git a/internal/cmd/serverless/display_test.go b/internal/cmd/serverless/display_test.go index 62056c4..c413d4b 100644 --- a/internal/cmd/serverless/display_test.go +++ b/internal/cmd/serverless/display_test.go @@ -59,12 +59,13 @@ func TestParseAppSort(t *testing.T) { t.Fatalf("unset sort: got=%v err=%v", got, err) } - got, err = parseAppSort("activity") - if err != nil { + _, err = parseAppSort("activity") + if err == nil || !strings.Contains(err.Error(), "not available yet") { t.Fatalf("activity: %v", err) } - if got == nil || *got != "activity" { - t.Fatalf("activity: got %+v", got) + _, err = parseAppSort("errorRate") + if err == nil || !strings.Contains(err.Error(), "not available yet") { + t.Fatalf("errorRate: %v", err) } got, err = parseAppSort("createdAt") @@ -184,16 +185,19 @@ func TestExtraStatusCursorFlag(t *testing.T) { } func TestExtraWorkersCursorFlags(t *testing.T) { - if got := extraWorkersCursorFlags("live", "ready"); got != "--state live --status ready" { - t.Fatalf("both: got %q", got) + if got := extraWorkersCursorFlags("live", "ready", "all"); got != "--state live --status ready --version all" { + t.Fatalf("all: got %q", got) } - if got := extraWorkersCursorFlags("live", ""); got != "--state live" { + if got := extraWorkersCursorFlags("live", "", ""); got != "--state live" { t.Fatalf("state only: got %q", got) } - if got := extraWorkersCursorFlags("", "ready"); got != "--status ready" { + if got := extraWorkersCursorFlags("", "ready", ""); got != "--status ready" { t.Fatalf("status only: got %q", got) } - if got := extraWorkersCursorFlags("", ""); got != "" { + if got := extraWorkersCursorFlags("", "", "all"); got != "--version all" { + t.Fatalf("version only: got %q", got) + } + if got := extraWorkersCursorFlags("", "", ""); got != "" { t.Fatalf("empty: got %q", got) } } @@ -321,6 +325,10 @@ func TestAppResult_IncludesConfiguration(t *testing.T) { colIdleTTL: int32(60), colScalingDelay: int32(10), colConcurrency: int32(1), + colEffectiveMaxWorkers: "", + colActiveWorkers: int64(0), + colQueueDepth: "", + colRequests24h: "", } got := make(map[string]any, len(rows)) for _, row := range rows { @@ -553,8 +561,8 @@ func TestEndpointResult(t *testing.T) { Path: "generate", CreatedAt: &created, }).Rows() - if len(rows) != 5 { - t.Fatalf("expected 5 rows, got %d", len(rows)) + if len(rows) != 9 { + t.Fatalf("expected 9 rows, got %d", len(rows)) } if rows[0][1] != "generate" || rows[2][1] != testAppID { t.Fatalf("unexpected rows %#v", rows) @@ -562,6 +570,9 @@ func TestEndpointResult(t *testing.T) { if rows[4][1] != "" { t.Fatalf("nil UpdatedAt should render empty, got %#v", rows[4][1]) } + if rows[5][1] != "" || rows[6][1] != "" || rows[7][1] != "" || rows[8][1] != "" { + t.Fatalf("omitted runtime should render empty, got %#v", rows) + } } func TestWorkerResult_NilOptionalFields(t *testing.T) { diff --git a/internal/cmd/serverless/env.go b/internal/cmd/serverless/env.go index d0566a0..6679dc3 100644 --- a/internal/cmd/serverless/env.go +++ b/internal/cmd/serverless/env.go @@ -93,6 +93,10 @@ func newAppsEnvSetCmd(logger *log.Logger) *cobra.Command { Prefer --value-file so the value is not visible in process lists; use --value-file - to read from stdin. +A write that changes the stored value records a new version and rolls live +workers when the application can take one. A write that leaves the value +unchanged records no version and does not roll. + The server rejects (HTTP 422) reserved platform names, names that collide with an attached secret's injected env var, and adding a binding past the 100-variable-plus-secret ceiling. Overwriting an existing key is always @@ -189,12 +193,9 @@ var envNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]{0,127}$`) // buildEnvironmentVariables turns --env KEY=VALUE pairs and --env-file paths into // the create request's map. // -// These belong on the CREATE request and nowhere else: an app's environment is -// frozen into its version snapshot, which is what the deployer renders from, and -// no endpoint creates a further version -- `deploy` re-applies an existing one by -// number and says so. So a variable set through the /environment-variables -// endpoints after the app exists is stored, listed back, and never reaches a -// worker. Passing it here is the only route that ends up in a pod. +// Create-time flags put the first environment set on the version this deploy +// records. After create, 'apps env set' also records a version and rolls when +// the stored value changes. // // Files are read before the inline pairs are applied, so an explicit --env wins // over a file entry with the same name. diff --git a/internal/cmd/serverless/pack.go b/internal/cmd/serverless/pack.go index 35b2656..0193376 100644 --- a/internal/cmd/serverless/pack.go +++ b/internal/cmd/serverless/pack.go @@ -22,7 +22,7 @@ const maxPackEntryBytes int64 = 10 << 20 // 10 MiB // maxPackTotalBytes bounds the archive as a whole. The per-file cap alone does // not: a virtualenv is thousands of small files and would sail past it. -const maxPackTotalBytes int64 = 25 << 20 // 25 MiB +const maxPackTotalBytes int64 = 10 << 20 // 10 MiB, the source-upload maximum // runwareIgnoreFile is the project's own exclude list, and the only one read. // diff --git a/internal/cmd/serverless/secrets.go b/internal/cmd/serverless/secrets.go index f1c823a..0dc9b19 100644 --- a/internal/cmd/serverless/secrets.go +++ b/internal/cmd/serverless/secrets.go @@ -96,9 +96,11 @@ func newSecretsSetCmd(logger *log.Logger) *cobra.Command { Short: "Create or update an organisation secret", Long: `Create an organisation-scoped secret, or update its value if the name already exists. -This does not attach the secret to an application. Use 'secrets attach' for that. -The secret value is never printed. Prefer --value-file so the value is not visible -in process lists; use --value-file - to read from stdin.`, +This does not attach the secret to an application. Use 'secrets attach' for that, +or pass --secret on create. Updating a value rolls every live application that +already attaches this secret. The secret value is never printed. Prefer +--value-file so the value is not visible in process lists; use --value-file - +to read from stdin.`, Example: ` # create or update a secret from a file runware serverless secrets set FOO --value-file ./foo.txt @@ -220,11 +222,12 @@ func newSecretsAttachCmd(logger *log.Logger) *cobra.Command { cmd := &cobra.Command{ Use: "attach ", Short: "Attach an organisation secret to an application", - Long: `Record that an organisation secret is attached to an application, optionally -under a different environment variable name. + Long: `Attach an organisation secret to an application, optionally under a different +environment variable name. -The organisation secret must already exist (see 'secrets set'). This is a -control-plane association only in this API release — it does not roll workers.`, +The organisation secret must already exist (see 'secrets set'). Attach rolls +live workers so the value reaches running pods. PATCH secrets-replace still +does not roll.`, Example: ` # attach a secret using its name as the env var runware serverless secrets attach my-app FOO @@ -265,8 +268,8 @@ func newSecretsDetachCmd(logger *log.Logger) *cobra.Command { cmd := &cobra.Command{ Use: "detach ", Short: "Detach a secret from an application", - Long: `Remove the control-plane attachment from an application. Does not remove the -organisation secret.`, + Long: `Detach an organisation secret from an application. Live workers are rolled so +the value is removed from running pods. Does not remove the organisation secret.`, Example: ` # detach a secret from an application runware serverless secrets detach my-app FOO`, Args: cobra.ExactArgs(2), diff --git a/internal/cmd/serverless/wait.go b/internal/cmd/serverless/wait.go new file mode 100644 index 0000000..f541afd --- /dev/null +++ b/internal/cmd/serverless/wait.go @@ -0,0 +1,41 @@ +package serverless + +import ( + "context" + "errors" + "fmt" + "time" + + serverlessapi "github.com/runware/runware-cli/internal/api/serverless" + "github.com/spf13/cobra" +) + +func addAppWaitFlags(cmd *cobra.Command, wait *bool, timeout, pollInterval *time.Duration) { + cmd.Flags().BoolVar(wait, "wait", false, "Poll until the application is active or failed") + cmd.Flags().DurationVar(timeout, "timeout", 0, "Maximum time to wait (0 = no limit)") + cmd.Flags().DurationVar(pollInterval, "poll-interval", 2*time.Second, "Polling interval when waiting for the application") +} + +func waitContext(parent context.Context, timeout time.Duration) (context.Context, context.CancelFunc) { + if timeout <= 0 { + return parent, func() {} + } + return context.WithTimeout(parent, timeout) +} + +func waitForApp(ctx context.Context, client *serverlessapi.Client, appID string, interval, timeout time.Duration) (*serverlessapi.App, error) { + waitCtx, cancel := waitContext(ctx, timeout) + defer cancel() + + app, err := client.WaitApp(waitCtx, appID, interval) + if err == nil { + return app, nil + } + if errors.Is(err, context.DeadlineExceeded) { + if timeout > 0 { + return nil, fmt.Errorf("timed out waiting for application %s after %s", appID, timeout) + } + return nil, fmt.Errorf("timed out waiting for application %s", appID) + } + return nil, err +}