From 97a9b6314dd000ca47691a9cf25c547217d36922 Mon Sep 17 00:00:00 2001 From: ryank90 Date: Tue, 22 Sep 2026 16:15:40 +0100 Subject: [PATCH 1/2] fix(serverless): reject an invalid --gpus-per-worker before upload Create and scale only accept 1, 2, 4, or 8. Deploy was sending any integer and learning that from a 422 after the source archive had already uploaded. --- docs/runware_serverless_apps_scale.md | 2 +- docs/runware_serverless_deploy.md | 2 +- internal/cmd/serverless/apps_scale.go | 7 ++++++- internal/cmd/serverless/apps_scale_test.go | 11 +++++++++++ internal/cmd/serverless/deploy.go | 23 +++++++++++++++++++++- internal/cmd/serverless/deploy_test.go | 14 +++++++++++++ 6 files changed, 55 insertions(+), 4 deletions(-) diff --git a/docs/runware_serverless_apps_scale.md b/docs/runware_serverless_apps_scale.md index 9098a0e..9fd1a1b 100644 --- a/docs/runware_serverless_apps_scale.md +++ b/docs/runware_serverless_apps_scale.md @@ -34,7 +34,7 @@ runware serverless apps scale [flags] --available-workers-pct int32 Idle-worker buffer as a percentage of load (0-100) --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_deploy.md b/docs/runware_serverless_deploy.md index 3d7365d..a64b4a6 100644 --- a/docs/runware_serverless_deploy.md +++ b/docs/runware_serverless_deploy.md @@ -111,7 +111,7 @@ runware serverless deploy [file] [flags] --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) + --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) diff --git a/internal/cmd/serverless/apps_scale.go b/internal/cmd/serverless/apps_scale.go index d0ae003..255a44c 100644 --- a/internal/cmd/serverless/apps_scale.go +++ b/internal/cmd/serverless/apps_scale.go @@ -87,7 +87,7 @@ func bindScaleFlags(cmd *cobra.Command, flags *scaleFlags) { f.Int32Var(&flags.idleTTL, "idle-ttl", 0, "Idle TTL in seconds before scaling down") f.Int32Var(&flags.scalingDelay, "scaling-delay", 0, "Scaling delay in seconds") 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)") @@ -97,6 +97,11 @@ func bindScaleFlags(cmd *cobra.Command, flags *scaleFlags) { // were explicitly set. Unchanged flags are omitted so existing values are // not cleared. func workerConfigPatchFromFlags(cmd *cobra.Command, flags scaleFlags) (*serverlessapi.WorkerConfigPatch, error) { + if cmd.Flags().Changed("gpus-per-worker") { + if err := validateGPUsPerWorker(flags.gpusPerWorker); err != nil { + return nil, err + } + } patch := &serverlessapi.WorkerConfigPatch{ MaxWorkers: optionalInt32Ptr(cmd, "max-workers", flags.maxWorkers), MinWorkers: optionalInt32Ptr(cmd, "min-workers", flags.minWorkers), diff --git a/internal/cmd/serverless/apps_scale_test.go b/internal/cmd/serverless/apps_scale_test.go index b6ec537..be5a2e7 100644 --- a/internal/cmd/serverless/apps_scale_test.go +++ b/internal/cmd/serverless/apps_scale_test.go @@ -86,6 +86,17 @@ func TestWorkerConfigPatchFromFlags_EachFlag(t *testing.T) { } } +func TestWorkerConfigPatchFromFlags_RejectsGPUsPerWorker(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("workerConfigPatchFromFlags = %v, want an allowed-values error", err) + } +} + func TestWorkerConfigPatchFromFlags_RequiresAFlag(t *testing.T) { cmd, flags := newScaleFlagCmd() if err := cmd.ParseFlags([]string{}); err != nil { diff --git a/internal/cmd/serverless/deploy.go b/internal/cmd/serverless/deploy.go index 8aa8a3a..f5c8610 100644 --- a/internal/cmd/serverless/deploy.go +++ b/internal/cmd/serverless/deploy.go @@ -228,6 +228,11 @@ paths and an invoke example once the application is active.`, if err := validateDeployArgs(cmd, args, containerDir); err != nil { return err } + if cmd.Flags().Changed("gpus-per-worker") { + if err := validateGPUsPerWorker(gpusPerWorker); err != nil { + return err + } + } if name == "" { name = id } @@ -388,7 +393,7 @@ paths and an invoke example once the application is active.`, 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().Int32Var(&gpusPerWorker, "gpus-per-worker", 1, "GPUs allocated per worker (1, 2, 4, or 8)") 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") @@ -417,6 +422,22 @@ func validateCreateDeployGPU(gpuType string) error { return nil } +// gpusPerWorkerValues are the group sizes create and scale accept. The API +// rejects anything else with 422, which on deploy is after the archive upload. +var gpusPerWorkerValues = map[int32]struct{}{ + 1: {}, + 2: {}, + 4: {}, + 8: {}, +} + +func validateGPUsPerWorker(n int32) error { + if _, ok := gpusPerWorkerValues[n]; ok { + return nil + } + return fmt.Errorf("--gpus-per-worker must be 1, 2, 4, or 8") +} + 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 60010fa..c409b53 100644 --- a/internal/cmd/serverless/deploy_test.go +++ b/internal/cmd/serverless/deploy_test.go @@ -254,6 +254,20 @@ func TestExistingApp(t *testing.T) { } } +func TestValidateGPUsPerWorker(t *testing.T) { + for _, n := range []int32{1, 2, 4, 8} { + if err := validateGPUsPerWorker(n); err != nil { + t.Errorf("validateGPUsPerWorker(%d): %v", n, err) + } + } + for _, n := range []int32{0, 3, 5, 16} { + err := validateGPUsPerWorker(n) + if err == nil || !strings.Contains(err.Error(), "1, 2, 4, or 8") { + t.Errorf("validateGPUsPerWorker(%d) = %v, want an allowed-values error", n, err) + } + } +} + func TestValidateCreateDeployGPU(t *testing.T) { if err := validateCreateDeployGPU(""); err == nil || !strings.Contains(err.Error(), "--gpu-type") { t.Fatalf("empty: %v", err) From d50116ade282f16a8c5f7018ffa71a52c13294cb Mon Sep 17 00:00:00 2001 From: ryank90 Date: Thu, 24 Sep 2026 15:39:21 +0100 Subject: [PATCH 2/2] fix(serverless): share the --gpus-per-worker allowlist text One slice drives the help, the error, and the local check, and deploy rejects an invalid value before it builds the archive. --- internal/cmd/serverless/apps_scale.go | 8 ++-- internal/cmd/serverless/apps_scale_test.go | 2 +- internal/cmd/serverless/deploy.go | 48 +++++++++++++++------- internal/cmd/serverless/deploy_test.go | 14 ++++++- 4 files changed, 51 insertions(+), 21 deletions(-) diff --git a/internal/cmd/serverless/apps_scale.go b/internal/cmd/serverless/apps_scale.go index 255a44c..4c870ae 100644 --- a/internal/cmd/serverless/apps_scale.go +++ b/internal/cmd/serverless/apps_scale.go @@ -87,7 +87,7 @@ func bindScaleFlags(cmd *cobra.Command, flags *scaleFlags) { f.Int32Var(&flags.idleTTL, "idle-ttl", 0, "Idle TTL in seconds before scaling down") f.Int32Var(&flags.scalingDelay, "scaling-delay", 0, "Scaling delay in seconds") 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 (1, 2, 4, or 8)") + f.Int32Var(&flags.gpusPerWorker, "gpus-per-worker", 0, "GPUs allocated per worker ("+gpusPerWorkerValuesText()+")") 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)") @@ -97,10 +97,8 @@ func bindScaleFlags(cmd *cobra.Command, flags *scaleFlags) { // were explicitly set. Unchanged flags are omitted so existing values are // not cleared. func workerConfigPatchFromFlags(cmd *cobra.Command, flags scaleFlags) (*serverlessapi.WorkerConfigPatch, error) { - if cmd.Flags().Changed("gpus-per-worker") { - if err := validateGPUsPerWorker(flags.gpusPerWorker); err != nil { - return nil, err - } + if err := validateGPUsPerWorkerFlag(cmd, flags.gpusPerWorker); err != nil { + return nil, err } patch := &serverlessapi.WorkerConfigPatch{ MaxWorkers: optionalInt32Ptr(cmd, "max-workers", flags.maxWorkers), diff --git a/internal/cmd/serverless/apps_scale_test.go b/internal/cmd/serverless/apps_scale_test.go index be5a2e7..b239fb7 100644 --- a/internal/cmd/serverless/apps_scale_test.go +++ b/internal/cmd/serverless/apps_scale_test.go @@ -92,7 +92,7 @@ func TestWorkerConfigPatchFromFlags_RejectsGPUsPerWorker(t *testing.T) { t.Fatalf("ParseFlags: %v", err) } _, err := workerConfigPatchFromFlags(cmd, *flags) - if err == nil || !strings.Contains(err.Error(), "1, 2, 4, or 8") { + if err == nil || !strings.Contains(err.Error(), gpusPerWorkerValuesText()) { t.Fatalf("workerConfigPatchFromFlags = %v, want an allowed-values error", err) } } diff --git a/internal/cmd/serverless/deploy.go b/internal/cmd/serverless/deploy.go index f5c8610..4ab7c95 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" @@ -228,10 +229,8 @@ paths and an invoke example once the application is active.`, if err := validateDeployArgs(cmd, args, containerDir); err != nil { return err } - if cmd.Flags().Changed("gpus-per-worker") { - if err := validateGPUsPerWorker(gpusPerWorker); err != nil { - return err - } + if err := validateGPUsPerWorkerFlag(cmd, gpusPerWorker); err != nil { + return err } if name == "" { name = id @@ -393,7 +392,7 @@ paths and an invoke example once the application is active.`, 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 (1, 2, 4, or 8)") + cmd.Flags().Int32Var(&gpusPerWorker, "gpus-per-worker", 1, "GPUs allocated per worker ("+gpusPerWorkerValuesText()+")") 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") @@ -422,20 +421,41 @@ func validateCreateDeployGPU(gpuType string) error { return nil } -// gpusPerWorkerValues are the group sizes create and scale accept. The API -// rejects anything else with 422, which on deploy is after the archive upload. -var gpusPerWorkerValues = map[int32]struct{}{ - 1: {}, - 2: {}, - 4: {}, - 8: {}, +// gpusPerWorkerAllowed are the group sizes create and scale accept, in +// display order. The API rejects anything else with 422, which on deploy +// is after the archive upload. +var gpusPerWorkerAllowed = []int32{ + 1, + 2, + 4, + 8, +} + +func gpusPerWorkerValuesText() string { + parts := make([]string, len(gpusPerWorkerAllowed)) + for i, n := range gpusPerWorkerAllowed { + parts[i] = fmt.Sprint(n) + } + if len(parts) == 1 { + return parts[0] + } + return strings.Join(parts[:len(parts)-1], ", ") + ", or " + parts[len(parts)-1] } func validateGPUsPerWorker(n int32) error { - if _, ok := gpusPerWorkerValues[n]; ok { + for _, allowed := range gpusPerWorkerAllowed { + if n == allowed { + return nil + } + } + return fmt.Errorf("--gpus-per-worker must be %s", gpusPerWorkerValuesText()) +} + +func validateGPUsPerWorkerFlag(cmd *cobra.Command, n int32) error { + if !cmd.Flags().Changed("gpus-per-worker") { return nil } - return fmt.Errorf("--gpus-per-worker must be 1, 2, 4, or 8") + return validateGPUsPerWorker(n) } func validateUpdateDeployFlags(cmd *cobra.Command) error { diff --git a/internal/cmd/serverless/deploy_test.go b/internal/cmd/serverless/deploy_test.go index c409b53..c5531b0 100644 --- a/internal/cmd/serverless/deploy_test.go +++ b/internal/cmd/serverless/deploy_test.go @@ -1,6 +1,7 @@ package serverless import ( + "bytes" "context" "encoding/json" "log/slog" @@ -262,12 +263,23 @@ func TestValidateGPUsPerWorker(t *testing.T) { } for _, n := range []int32{0, 3, 5, 16} { err := validateGPUsPerWorker(n) - if err == nil || !strings.Contains(err.Error(), "1, 2, 4, or 8") { + if err == nil || !strings.Contains(err.Error(), gpusPerWorkerValuesText()) { t.Errorf("validateGPUsPerWorker(%d) = %v, want an allowed-values error", n, err) } } } +func TestDeploy_RejectsInvalidGPUsPerWorkerBeforeUpload(t *testing.T) { + cmd := newDeployCmd(nil) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{testModelFile, "--id", testAppID, "--gpus-per-worker", "3"}) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), gpusPerWorkerValuesText()) { + t.Fatalf("err = %v", err) + } +} + func TestValidateCreateDeployGPU(t *testing.T) { if err := validateCreateDeployGPU(""); err == nil || !strings.Contains(err.Error(), "--gpu-type") { t.Fatalf("empty: %v", err)