From b31bc95bc68fda1407d0bab968cb3c77c718e78a Mon Sep 17 00:00:00 2001 From: ryank90 Date: Tue, 22 Sep 2026 16:17:36 +0100 Subject: [PATCH 1/5] fix(serverless): describe apps scale as a version and a rollout Help said a configuration change waited for the next scaler cycle. The API records a new version with the same image and rolls the workload. --- docs/runware_serverless_apps_scale.md | 9 ++++++--- internal/cmd/serverless/apps_scale.go | 9 ++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/runware_serverless_apps_scale.md b/docs/runware_serverless_apps_scale.md index 9fd1a1b..7820f15 100644 --- a/docs/runware_serverless_apps_scale.md +++ b/docs/runware_serverless_apps_scale.md @@ -6,8 +6,11 @@ Scale a serverless application Patch live worker configuration for a serverless application. -Omitted flags are left unchanged. Configuration changes take effect on the -next scaler cycle; this command does not wait for a rollout. +Omitted flags are left unchanged. A configuration change records a new version +with the same image and rolls the workload when the app is active or +initializing. A failed app is moved to initializing and rolled. A stopped or +stopping app applies the change on resume. This command does not wait for that +rollout. A change during an in-flight rollout returns 409. The server rejects unsupported or invalid fields with HTTP 422. @@ -24,7 +27,7 @@ runware serverless apps scale [flags] # scale to zero and raise idle TTL runware serverless apps scale my-app --min-workers 0 --idle-ttl 120 - # change GPU type (applies to newly created workers) + # change GPU type; the rollout replaces workers with the new type runware serverless apps scale my-app --gpu-type h100 ``` diff --git a/internal/cmd/serverless/apps_scale.go b/internal/cmd/serverless/apps_scale.go index 4c870ae..2598c98 100644 --- a/internal/cmd/serverless/apps_scale.go +++ b/internal/cmd/serverless/apps_scale.go @@ -34,8 +34,11 @@ func newAppsScaleCmd(logger *log.Logger) *cobra.Command { Short: "Scale a serverless application", Long: `Patch live worker configuration for a serverless application. -Omitted flags are left unchanged. Configuration changes take effect on the -next scaler cycle; this command does not wait for a rollout. +Omitted flags are left unchanged. A configuration change records a new version +with the same image and rolls the workload when the app is active or +initializing. A failed app is moved to initializing and rolled. A stopped or +stopping app applies the change on resume. This command does not wait for that +rollout. A change during an in-flight rollout returns 409. The server rejects unsupported or invalid fields with HTTP 422.`, Example: ` # set the worker cap @@ -44,7 +47,7 @@ The server rejects unsupported or invalid fields with HTTP 422.`, # scale to zero and raise idle TTL runware serverless apps scale my-app --min-workers 0 --idle-ttl 120 - # change GPU type (applies to newly created workers) + # change GPU type; the rollout replaces workers with the new type runware serverless apps scale my-app --gpu-type h100`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { From 3873860b6b821121b5586c8afd57c0de277172ad Mon Sep 17 00:00:00 2001 From: Ryan Kerry Date: Wed, 23 Sep 2026 19:35:15 +0100 Subject: [PATCH 2/5] fix: update internal/cmd/serverless/apps_scale.go Co-authored-by: Wilson Silva --- internal/cmd/serverless/apps_scale.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/cmd/serverless/apps_scale.go b/internal/cmd/serverless/apps_scale.go index 2598c98..86ecfe2 100644 --- a/internal/cmd/serverless/apps_scale.go +++ b/internal/cmd/serverless/apps_scale.go @@ -40,7 +40,8 @@ initializing. A failed app is moved to initializing and rolled. A stopped or stopping app applies the change on resume. This command does not wait for that rollout. A change during an in-flight rollout returns 409. -The server rejects unsupported or invalid fields with HTTP 422.`, +The server rejects unsupported or invalid fields with HTTP 422. A capacity +increase without enough credit returns 402.`, Example: ` # set the worker cap runware serverless apps scale my-app --max-workers 2 From 5825d2629fe97ff1f3c22e12fca2670b611ad218 Mon Sep 17 00:00:00 2001 From: ryank90 Date: Tue, 22 Sep 2026 16:50:00 +0100 Subject: [PATCH 3/5] fix(serverless): set secrets and scale knobs on deploy create A first deploy could not attach an organisation secret or set the worker buffer, concurrency, or fallback GPU, and the help did not say that volumes stay fixed after create. --- internal/cmd/serverless/deploy.go | 137 ++++++++++++++++++------- internal/cmd/serverless/deploy_test.go | 36 ++++++- 2 files changed, 135 insertions(+), 38 deletions(-) diff --git a/internal/cmd/serverless/deploy.go b/internal/cmd/serverless/deploy.go index 2bbc7e1..99332b9 100644 --- a/internal/cmd/serverless/deploy.go +++ b/internal/cmd/serverless/deploy.go @@ -34,7 +34,11 @@ var createOnlyDeployFlags = []string{ "scaling-delay", "min-workers", "gpus-per-worker", + "fallback-gpu-type", + "min-available-workers", + "available-workers-pct", "volume", + "secret", "env", "env-file", } @@ -113,23 +117,27 @@ 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 + fallbackGPUType string + minAvailableWorkers int32 + availableWorkersPct int32 + srcDir string + containerDir string + volumes []string + secrets []string + envVars []string + envFiles []string + wait bool + pollInterval time.Duration ) cmd := &cobra.Command{ @@ -140,9 +148,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. +--secret, --env, --env-file, --name) apply only to create; passing them when +the application already exists is an error. Change workers with 'apps scale', +attach a secret later with 'secrets attach', and change environment with +'apps env'. 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 @@ -180,13 +189,20 @@ returns 409 and does not store the value. Prefer --env-file for anything secret: a value passed as --env is visible in the process list and recorded in shell history. -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 set +at create and cannot be changed afterwards: a later deploy that passes +--volume is rejected. 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). +--secret NAME, or NAME=ENV_VAR, attaches an existing organisation secret at +create so the first rollout carries it. Repeat the flag for more than one. +Attach or detach later with 'secrets attach' and 'secrets detach'. + +Worker settings are supplied via flags on create, including a fallback GPU +type and the idle-worker buffer. Endpoints are derived server-side from the +SDK (code) or from container.yaml (container). A code app's endpoint path is its handler's method name with underscores turned into hyphens, so renaming a method moves a public endpoint and 404s its callers. @@ -214,6 +230,10 @@ paths and an invoke example once the application is active.`, runware serverless deploy model.py --id my-app --gpu-type l40s \ --volume /root/.cache/huggingface + # attach an existing secret and keep one idle worker warm + runware serverless deploy ./app.py --id my-app --gpu-type h100 \ + --secret HF_TOKEN=HUGGING_FACE_HUB_TOKEN --min-available-workers 1 + # override worker settings and base image runware serverless deploy ./app.py --id my-app --name "My App" \ --max-workers 2 --idle-ttl 120 --gpu-type h100 \ @@ -271,6 +291,7 @@ paths and an invoke example once the application is active.`, var ( appVolumes *[]serverlessapi.AppVolume appEnv *map[string]string + appSecrets *[]serverlessapi.SecretAttach ) if !update { appVolumes, err = buildVolumes(volumes) @@ -281,6 +302,10 @@ paths and an invoke example once the application is active.`, if err != nil { return err } + appSecrets, err = parseSecretAttaches(secrets) + if err != nil { + return err + } } spin := cmdutil.NewSpinner(fmt.Sprintf("Uploading source for %s...", id)) @@ -312,13 +337,17 @@ paths and an invoke example once the application is active.`, 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), + FallbackGpuType: optionalFlagStringPtr(cmd, "fallback-gpu-type", fallbackGPUType), + MinAvailableWorkers: optionalInt32Ptr(cmd, "min-available-workers", minAvailableWorkers), + AvailableWorkersPct: optionalInt32Ptr(cmd, "available-workers-pct", availableWorkersPct), }, }) if isHTTPConflict(err) { @@ -380,7 +409,8 @@ paths and an invoke example once the application is active.`, 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; immutable after create (repeatable)") + cmd.Flags().StringArrayVar(&secrets, "secret", nil, "Organisation secret to attach at create, as NAME or NAME=ENV_VAR (repeatable)") 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().StringVar(&id, "id", "", "Application ID (immutable, lowercase slug)") @@ -393,6 +423,9 @@ paths and an invoke example once the application is active.`, 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 ("+gpusPerWorkerValuesText()+")") + 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)") 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") @@ -469,17 +502,51 @@ 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", "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 a secret" case "volume": - return "volumes are set at create time and cannot be changed here" + return "volumes are immutable after create" default: return "omit it when updating an existing application" } } +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) (string, string, error) { + name, envVar, hasEnv := strings.Cut(raw, "=") + if name == "" || !envNamePattern.MatchString(name) { + return "", "", fmt.Errorf("invalid --secret %q (want NAME or NAME=ENV_VAR)", raw) + } + if hasEnv && (envVar == "" || !envNamePattern.MatchString(envVar)) { + 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 c5531b0..9a7e31d 100644 --- a/internal/cmd/serverless/deploy_test.go +++ b/internal/cmd/serverless/deploy_test.go @@ -289,6 +289,30 @@ func TestValidateCreateDeployGPU(t *testing.T) { } } +func TestParseSecretAttaches(t *testing.T) { + got, err := parseSecretAttaches(nil) + if err != nil || got != nil { + t.Fatalf("empty: got=%v err=%v", got, err) + } + + got, err = parseSecretAttaches([]string{"ORG_TOKEN", "API_KEY=INFERENCE_KEY"}) + if err != nil || got == nil || len(*got) != 2 { + t.Fatalf("attaches: got=%v err=%v", got, err) + } + if (*got)[0].SecretName != "ORG_TOKEN" || (*got)[0].EnvVarName != nil { + t.Fatalf("name only: %#v", (*got)[0]) + } + if (*got)[1].SecretName != "API_KEY" || (*got)[1].EnvVarName == nil || *(*got)[1].EnvVarName != "INFERENCE_KEY" { + t.Fatalf("env override: %#v", (*got)[1]) + } + + for _, raw := range []string{"", "=NOPE", "NAME=", "bad-name", "1TOKEN"} { + if _, err := parseSecretAttaches([]string{raw}); err == nil || !strings.Contains(err.Error(), "invalid --secret") { + t.Fatalf("%q: got %v", raw, err) + } + } +} + func TestValidateUpdateDeployFlags(t *testing.T) { cmd := newDeployCmd(nil) if err := cmd.ParseFlags(nil); err != nil { @@ -298,15 +322,21 @@ func TestValidateUpdateDeployFlags(t *testing.T) { t.Fatalf("no create flags: %v", err) } + const scaleHint = "apps scale" cases := []struct { flags []string wantErr string }{ - {flags: []string{"--gpu-type", "h100"}, wantErr: "apps scale"}, - {flags: []string{"--max-workers", "2"}, wantErr: "apps scale"}, + {flags: []string{"--gpu-type", "h100"}, wantErr: scaleHint}, + {flags: []string{"--max-workers", "2"}, wantErr: scaleHint}, {flags: []string{"--env", "FOO=bar"}, wantErr: "apps env"}, {flags: []string{"--env-file", envDotfile}, wantErr: "apps env"}, - {flags: []string{"--volume", "/data"}, wantErr: "volumes"}, + {flags: []string{"--volume", "/data"}, wantErr: "immutable"}, + {flags: []string{"--secret", "ORG_TOKEN"}, wantErr: "secrets attach"}, + {flags: []string{"--concurrency", "2"}, wantErr: scaleHint}, + {flags: []string{"--fallback-gpu-type", "l40s"}, wantErr: scaleHint}, + {flags: []string{"--min-available-workers", "1"}, wantErr: scaleHint}, + {flags: []string{"--available-workers-pct", "10"}, wantErr: scaleHint}, {flags: []string{"--name", "My App"}, wantErr: "omit it"}, {flags: []string{"--requirement", testPipPackage}}, {flags: []string{"--base-image", "python:3.12-slim"}}, From 323620cace5d90b560c529b791af8102a20e824b Mon Sep 17 00:00:00 2001 From: ryank90 Date: Tue, 22 Sep 2026 20:22:14 +0100 Subject: [PATCH 4/5] fix(serverless): prove create carries secrets and reject a bad idle-worker percent An httptest now decodes the create body for secrets and minAvailableWorkers. --available-workers-pct is checked locally so a value outside 0-100 fails before the archive upload. --- internal/cmd/serverless/apps_scale_test.go | 2 +- internal/cmd/serverless/deploy.go | 12 +++ internal/cmd/serverless/deploy_test.go | 107 ++++++++++++++++++++- internal/cmd/serverless/display_test.go | 11 ++- internal/cmd/serverless/usage_test.go | 2 +- 5 files changed, 124 insertions(+), 10 deletions(-) diff --git a/internal/cmd/serverless/apps_scale_test.go b/internal/cmd/serverless/apps_scale_test.go index b239fb7..526e8ab 100644 --- a/internal/cmd/serverless/apps_scale_test.go +++ b/internal/cmd/serverless/apps_scale_test.go @@ -20,7 +20,7 @@ func TestWorkerConfigPatchFromFlags_EachFlag(t *testing.T) { {[]string{"--min-workers", "0"}, "minWorkers", float64(0)}, {[]string{"--idle-ttl", "120"}, "idleTtlSecs", float64(120)}, {[]string{"--scaling-delay", "15"}, "scalingDelaySecs", float64(15)}, - {[]string{"--gpu-type", testGPUType}, "gpuType", testGPUType}, + {[]string{testGPUTypeFlag, testGPUType}, "gpuType", testGPUType}, {[]string{"--gpus-per-worker", "2"}, "gpusPerWorker", float64(2)}, {[]string{"--fallback-gpu-type", testGPUType}, "fallbackGpuType", testGPUType}, {[]string{"--min-available-workers", "1"}, "minAvailableWorkers", float64(1)}, diff --git a/internal/cmd/serverless/deploy.go b/internal/cmd/serverless/deploy.go index 99332b9..3876639 100644 --- a/internal/cmd/serverless/deploy.go +++ b/internal/cmd/serverless/deploy.go @@ -252,6 +252,11 @@ paths and an invoke example once the application is active.`, if err := validateGPUsPerWorkerFlag(cmd, gpusPerWorker); err != nil { return err } + if cmd.Flags().Changed("available-workers-pct") { + if err := validateAvailableWorkersPct(availableWorkersPct); err != nil { + return err + } + } if name == "" { name = id } @@ -491,6 +496,13 @@ func validateGPUsPerWorkerFlag(cmd *cobra.Command, n int32) error { return validateGPUsPerWorker(n) } +func validateAvailableWorkersPct(n int32) error { + if n < 0 || n > 100 { + return fmt.Errorf("--available-workers-pct must be between 0 and 100") + } + return nil +} + func validateUpdateDeployFlags(cmd *cobra.Command) error { for _, name := range createOnlyDeployFlags { if cmd.Flags().Changed(name) { diff --git a/internal/cmd/serverless/deploy_test.go b/internal/cmd/serverless/deploy_test.go index 9a7e31d..c6ad94f 100644 --- a/internal/cmd/serverless/deploy_test.go +++ b/internal/cmd/serverless/deploy_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "io" "log/slog" "net/http" "net/http/httptest" @@ -11,6 +12,7 @@ import ( "testing" "time" + "github.com/charmbracelet/log" "github.com/google/uuid" serverlessapi "github.com/runware/runware-cli/internal/api/serverless" "github.com/spf13/cobra" @@ -280,8 +282,108 @@ func TestDeploy_RejectsInvalidGPUsPerWorkerBeforeUpload(t *testing.T) { } } +func TestValidateAvailableWorkersPct(t *testing.T) { + for _, n := range []int32{0, 50, 100} { + if err := validateAvailableWorkersPct(n); err != nil { + t.Errorf("validateAvailableWorkersPct(%d): %v", n, err) + } + } + for _, n := range []int32{-1, 101} { + err := validateAvailableWorkersPct(n) + if err == nil || !strings.Contains(err.Error(), "0 and 100") { + t.Errorf("validateAvailableWorkersPct(%d) = %v, want a range error", n, err) + } + } +} + +func TestDeployCreate_RejectsAvailableWorkersPctBeforeUpload(t *testing.T) { + cmd := newDeployCmd(nil) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{testModelFile, "--id", testAppID, "--available-workers-pct", "101"}) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "0 and 100") { + t.Fatalf("err = %v", err) + } +} + +func TestDeployCreate_SendsSecretsAndMinAvailableWorkers(t *testing.T) { + dir := t.TempDir() + writeTree(t, dir, map[string]string{testModelFile: testPySource}) + + var created serverlessapi.AppCreate + var creates int + stage := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer stage.Close() + + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/v1/apps/"): + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"type":"about:blank","title":"Not Found","status":404}`)) + case r.Method == http.MethodPost && r.URL.Path == "/v1/source-uploads": + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{ + "upload": {"id":"` + testSourceID + `","declaredByteLength":1,"sha256":"00","sourceType":"code","state":"pending","expiresAt":"2026-09-02T12:00:00Z","createdAt":"2026-09-02T11:00:00Z","updatedAt":"2026-09-02T11:00:00Z"}, + "transfer": {"mode":"singlePut","method":"PUT","url":"` + stage.URL + `/obj","headers":{"Content-Type":"application/zip"},"expiresAt":"2026-09-02T12:00:00Z"} + }`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/complete"): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"` + testSourceID + `","declaredByteLength":1,"sha256":"00","sourceType":"code","sourceId":"` + testSourceID + `","state":"ready","expiresAt":"2026-09-02T12:00:00Z","createdAt":"2026-09-02T11:00:00Z","updatedAt":"2026-09-02T11:00:00Z"}`)) + case r.Method == http.MethodPost && r.URL.Path == "/v1/apps": + creates++ + if err := json.NewDecoder(r.Body).Decode(&created); err != nil { + t.Errorf("decode create: %v", err) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(activeAppBody(""))) + default: + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer api.Close() + + t.Setenv("RUNWARE_API_KEY", "test-key") + t.Setenv("RUNWARE_SERVERLESS_BASE_URL", api.URL) + + cmd := newDeployCmd(log.New(io.Discard)) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{ + testModelFile, + "--src-dir", dir, + "--id", testAppID, + testGPUTypeFlag, testGPUType, + "--secret", "API_KEY=INFERENCE_KEY", + "--min-available-workers", "1", + }) + if err := cmd.Execute(); err != nil { + t.Fatalf("deploy: %v", err) + } + if creates != 1 { + t.Fatalf("creates = %d, want 1", creates) + } + if created.Secrets == nil || len(*created.Secrets) != 1 { + t.Fatalf("secrets = %#v", created.Secrets) + } + sec := (*created.Secrets)[0] + if sec.SecretName != "API_KEY" || sec.EnvVarName == nil || *sec.EnvVarName != "INFERENCE_KEY" { + t.Fatalf("secret = %#v", sec) + } + if created.Configuration.MinAvailableWorkers == nil || *created.Configuration.MinAvailableWorkers != 1 { + t.Fatalf("minAvailableWorkers = %#v", created.Configuration.MinAvailableWorkers) + } +} + func TestValidateCreateDeployGPU(t *testing.T) { - if err := validateCreateDeployGPU(""); err == nil || !strings.Contains(err.Error(), "--gpu-type") { + if err := validateCreateDeployGPU(""); err == nil || !strings.Contains(err.Error(), testGPUTypeFlag) { t.Fatalf("empty: %v", err) } if err := validateCreateDeployGPU("h100"); err != nil { @@ -327,13 +429,12 @@ func TestValidateUpdateDeployFlags(t *testing.T) { flags []string wantErr string }{ - {flags: []string{"--gpu-type", "h100"}, wantErr: scaleHint}, + {flags: []string{testGPUTypeFlag, "h100"}, wantErr: scaleHint}, {flags: []string{"--max-workers", "2"}, wantErr: scaleHint}, {flags: []string{"--env", "FOO=bar"}, wantErr: "apps env"}, {flags: []string{"--env-file", envDotfile}, wantErr: "apps env"}, {flags: []string{"--volume", "/data"}, wantErr: "immutable"}, {flags: []string{"--secret", "ORG_TOKEN"}, wantErr: "secrets attach"}, - {flags: []string{"--concurrency", "2"}, wantErr: scaleHint}, {flags: []string{"--fallback-gpu-type", "l40s"}, wantErr: scaleHint}, {flags: []string{"--min-available-workers", "1"}, wantErr: scaleHint}, {flags: []string{"--available-workers-pct", "10"}, wantErr: scaleHint}, diff --git a/internal/cmd/serverless/display_test.go b/internal/cmd/serverless/display_test.go index 80ff968..cc0876b 100644 --- a/internal/cmd/serverless/display_test.go +++ b/internal/cmd/serverless/display_test.go @@ -16,11 +16,12 @@ import ( ) const ( - testAppID = "my-app" - testEnvKey = "MY_KEY" - testEnvValue = "hello" - testGPUType = "h100" - testEventType = "error" + testAppID = "my-app" + testEnvKey = "MY_KEY" + testEnvValue = "hello" + testGPUType = "h100" + testGPUTypeFlag = "--gpu-type" + testEventType = "error" // testEndpointPath and testOtherEndpointPath are two endpoint paths on one // app, so a set is never a single element. testEndpointPath = "generate" diff --git a/internal/cmd/serverless/usage_test.go b/internal/cmd/serverless/usage_test.go index a1b0273..8395273 100644 --- a/internal/cmd/serverless/usage_test.go +++ b/internal/cmd/serverless/usage_test.go @@ -34,7 +34,7 @@ func TestUsageParamsFromFlags_ExplicitWindow(t *testing.T) { args := []string{ "--from", testUsageDate, "--to", "2026-09-15T12:00:00+02:00", - "--gpu-type", testGPUType, + testGPUTypeFlag, testGPUType, "--group-by", "app,day", "--group-by", "coverage", } From c51c0c376c1afc3a95b762ca07098aed7c375dfa Mon Sep 17 00:00:00 2001 From: ryank90 Date: Thu, 24 Sep 2026 17:41:32 +0100 Subject: [PATCH 5/5] docs: regenerate scale and deploy help after restack --- docs/runware_serverless_apps_scale.md | 3 +- docs/runware_serverless_deploy.md | 70 ++++++++++++++++----------- 2 files changed, 45 insertions(+), 28 deletions(-) diff --git a/docs/runware_serverless_apps_scale.md b/docs/runware_serverless_apps_scale.md index 7820f15..912221b 100644 --- a/docs/runware_serverless_apps_scale.md +++ b/docs/runware_serverless_apps_scale.md @@ -12,7 +12,8 @@ initializing. A failed app is moved to initializing and rolled. A stopped or stopping app applies the change on resume. This command does not wait for that rollout. A change during an in-flight rollout returns 409. -The server rejects unsupported or invalid fields with HTTP 422. +The server rejects unsupported or invalid fields with HTTP 422. A capacity +increase without enough credit returns 402. ``` runware serverless apps scale [flags] diff --git a/docs/runware_serverless_deploy.md b/docs/runware_serverless_deploy.md index 34da6e7..eac3cb1 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. +--secret, --env, --env-file, --name) apply only to create; passing them when +the application already exists is an error. Change workers with 'apps scale', +attach a secret later with 'secrets attach', and change environment with +'apps env'. 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 @@ -49,13 +50,20 @@ returns 409 and does not store the value. Prefer --env-file for anything secret: a value passed as --env is visible in the process list and recorded in shell history. -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 set +at create and cannot be changed afterwards: a later deploy that passes +--volume is rejected. 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). +--secret NAME, or NAME=ENV_VAR, attaches an existing organisation secret at +create so the first rollout carries it. Repeat the flag for more than one. +Attach or detach later with 'secrets attach' and 'secrets detach'. + +Worker settings are supplied via flags on create, including a fallback GPU +type and the idle-worker buffer. Endpoints are derived server-side from the +SDK (code) or from container.yaml (container). A code app's endpoint path is its handler's method name with underscores turned into hyphens, so renaming a method moves a public endpoint and 404s its callers. @@ -91,6 +99,10 @@ runware serverless deploy [file] [flags] runware serverless deploy model.py --id my-app --gpu-type l40s \ --volume /root/.cache/huggingface + # attach an existing secret and keep one idle worker warm + runware serverless deploy ./app.py --id my-app --gpu-type h100 \ + --secret HF_TOKEN=HUGGING_FACE_HUB_TOKEN --min-available-workers 1 + # override worker settings and base image runware serverless deploy ./app.py --id my-app --name "My App" \ --max-workers 2 --idle-ttl 120 --gpu-type h100 \ @@ -106,24 +118,28 @@ runware serverless deploy [file] [flags] ### Options ``` - --base-image string Builder base image (code deploys only; needs Python 3.12 or newer) (default "python:3.12-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 (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-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; needs Python 3.12 or newer) (default "python:3.12-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) + --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 Organisation secret to attach at create, as NAME or NAME=ENV_VAR (repeatable) + --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; immutable after create (repeatable) + --wait Poll until the application is active or failed ``` ### Options inherited from parent commands