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..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") + 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,6 +97,9 @@ 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 err := validateGPUsPerWorkerFlag(cmd, 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..b239fb7 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(), gpusPerWorkerValuesText()) { + 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..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,6 +229,9 @@ paths and an invoke example once the application is active.`, if err := validateDeployArgs(cmd, args, containerDir); err != nil { return err } + if err := validateGPUsPerWorkerFlag(cmd, gpusPerWorker); err != nil { + return err + } if name == "" { name = id } @@ -388,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") + 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") @@ -417,6 +421,43 @@ func validateCreateDeployGPU(gpuType string) error { return nil } +// 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 { + 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 validateGPUsPerWorker(n) +} + 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..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" @@ -254,6 +255,31 @@ 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(), 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)