diff --git a/docs/runware_serverless_apps_scale.md b/docs/runware_serverless_apps_scale.md index 5fed19c..5752248 100644 --- a/docs/runware_serverless_apps_scale.md +++ b/docs/runware_serverless_apps_scale.md @@ -2,6 +2,15 @@ Scale a serverless application +### Synopsis + +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. + +The server rejects unsupported or invalid fields with HTTP 422. + ``` runware serverless apps scale [flags] ``` @@ -9,14 +18,30 @@ runware serverless apps scale [flags] ### Examples ``` - # update scaling configuration for an application - runware serverless apps scale my-app + # set the worker cap + runware serverless apps scale my-app --max-workers 2 + + # 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) + runware serverless apps scale my-app --gpu-type h100 ``` ### Options ``` - -h, --help help for scale + --available-workers-pct int32 Idle-worker buffer as a percentage of load (0-100) + --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 + -h, --help help for scale + --idle-ttl int32 Idle TTL in seconds before scaling down + --max-workers int32 Maximum number of workers + --min-available-workers int32 Minimum idle workers kept as a buffer + --min-workers int32 Minimum number of workers (0 = scale to zero) + --scaling-delay int32 Scaling delay in seconds ``` ### Options inherited from parent commands diff --git a/internal/api/serverless/client.go b/internal/api/serverless/client.go index 42f990f..f7b89ed 100644 --- a/internal/api/serverless/client.go +++ b/internal/api/serverless/client.go @@ -46,9 +46,18 @@ type CodeSourceUpsert = gen.CodeSourceUpsert // CodebaseSource is the zipped customer code payload. type CodebaseSource = gen.CodebaseSource +// WorkerConfig is the live worker configuration on a deployment. +type WorkerConfig = gen.WorkerConfig + // WorkerConfigCreate is the worker configuration supplied at create time. type WorkerConfigCreate = gen.WorkerConfigCreate +// DeploymentUpdate is the request body for updateDeployment. +type DeploymentUpdate = gen.DeploymentUpdate + +// WorkerConfigPatch is a partial worker configuration for updateDeployment. +type WorkerConfigPatch = gen.WorkerConfigPatch + // ListDeploymentsParams are optional filters for ListDeployments. type ListDeploymentsParams = gen.ListDeploymentsParams @@ -330,6 +339,49 @@ func (c *Client) GetDeployment(ctx context.Context, deploymentID string) (*Deplo } } +// UpdateDeployment patches a deployment in place. Omitted fields are left +// unchanged. Currently persisted: deploymentName and configuration. +func (c *Client) UpdateDeployment(ctx context.Context, deploymentID string, body DeploymentUpdate) (*Deployment, error) { + if c.apiKey == "" { + return nil, transport.ErrNoAPIKey + } + + resp, err := c.inner.UpdateDeploymentWithResponse(ctx, deploymentID, body) + if err != nil { + return nil, fmt.Errorf("update deployment: %w", err) + } + + if c.logger != nil && c.logger.Enabled(ctx, slog.LevelDebug) { + c.logger.Debug("serverless response", //nolint:errcheck,gosec + "path", "/v1/deployments/"+deploymentID, + "status", resp.StatusCode(), + "body", string(resp.Body), + ) + } + + switch resp.StatusCode() { + case http.StatusOK: + if resp.JSON200 == nil { + return nil, fmt.Errorf("update deployment: empty 200 response") + } + return resp.JSON200, nil + case http.StatusBadRequest: + return nil, problemToError(resp.ApplicationproblemJSON400, http.StatusBadRequest) + case http.StatusUnauthorized: + return nil, problemToError(resp.ApplicationproblemJSON401, http.StatusUnauthorized) + case http.StatusForbidden: + return nil, problemToError(resp.ApplicationproblemJSON403, http.StatusForbidden) + case http.StatusNotFound: + return nil, problemToError(resp.ApplicationproblemJSON404, http.StatusNotFound) + case http.StatusConflict: + return nil, problemToError(resp.ApplicationproblemJSON409, http.StatusConflict) + case http.StatusUnprocessableEntity: + return nil, problemToError(resp.ApplicationproblemJSON422, http.StatusUnprocessableEntity) + default: + return nil, problemFromBody(resp.Body, resp.StatusCode()) + } +} + // ListEndpoints returns a page of endpoints for a deployment. func (c *Client) ListEndpoints(ctx context.Context, deploymentID string, params *ListEndpointsParams) (Page[Endpoint], error) { if c.apiKey == "" { diff --git a/internal/api/serverless/client_test.go b/internal/api/serverless/client_test.go index df7b83c..3a77883 100644 --- a/internal/api/serverless/client_test.go +++ b/internal/api/serverless/client_test.go @@ -2,8 +2,10 @@ package serverless import ( "context" + "encoding/json" "errors" "fmt" + "io" "log/slog" "net/http" "net/http/httptest" @@ -416,6 +418,114 @@ func TestGetDeployment_NotFound(t *testing.T) { } } +func TestUpdateDeployment(t *testing.T) { + maxWorkers := int32(2) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch || r.URL.Path != "/v1/deployments/"+testDeploymentID { + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + raw, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + var body DeploymentUpdate + if err := json.Unmarshal(raw, &body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body.Configuration == nil || body.Configuration.MaxWorkers == nil || *body.Configuration.MaxWorkers != maxWorkers { + t.Errorf("unexpected body: %s", raw) + } + if body.DeploymentName != nil || body.DeploymentSource != nil || body.Secrets != nil || body.EnvironmentVariables != nil || body.Endpoints != nil { + t.Errorf("patch included out-of-scope fields: %s", raw) + } + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(raw, &rawMap); err != nil { + t.Fatalf("decode raw map: %v", err) + } + if len(rawMap) != 1 { + t.Errorf("expected only configuration in body, got %s", raw) + } + var cfg map[string]any + if err := json.Unmarshal(rawMap["configuration"], &cfg); err != nil { + t.Fatalf("decode configuration: %v", err) + } + if len(cfg) != 1 { + t.Errorf("omitted flags should not appear in configuration: %s", raw) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "deploymentId":"my-app", + "deploymentName":"My App", + "status":"active", + "configuration":{"maxWorkers":2,"idleTtlSecs":60,"scalingDelaySecs":10,"minWorkers":0,"gpusPerWorker":1,"concurrency":1,"computeType":"gpu"}, + "environmentVariables":[], + "secrets":[], + "createdAt":"2026-07-30T12:00:00Z", + "updatedAt":"2026-07-30T12:00:00Z" + }`)) + })) + defer srv.Close() + + c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) + dep, err := c.UpdateDeployment(context.Background(), testDeploymentID, DeploymentUpdate{ + Configuration: &WorkerConfigPatch{ + MaxWorkers: &maxWorkers, + }, + }) + if err != nil { + t.Fatalf("UpdateDeployment: %v", err) + } + if dep.DeploymentId != testDeploymentID || dep.Configuration.MaxWorkers != maxWorkers { + t.Errorf("unexpected deployment: %+v", dep) + } +} + +func TestUpdateDeployment_Unprocessable(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{ + "type":"about:blank", + "title":"Unprocessable Entity", + "status":422, + "detail":"maxWorkers must be at least 1", + "errors":[{"detail":"must be at least 1","pointer":"/configuration/maxWorkers"}] + }`)) + })) + defer srv.Close() + + c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) + zero := int32(0) + _, err := c.UpdateDeployment(context.Background(), testDeploymentID, DeploymentUpdate{ + Configuration: &WorkerConfigPatch{ + MaxWorkers: &zero, + }, + }) + var re *transport.RunwareError + if !errors.As(err, &re) { + t.Fatalf("expected *transport.RunwareError, got %T: %v", err, err) + } + if re.Code != transport.CodeValidation { + t.Errorf("expected CodeValidation, got %v", re.Code) + } + if re.StatusCode != http.StatusUnprocessableEntity { + t.Errorf("expected status 422, got %d", re.StatusCode) + } + if !strings.Contains(re.Message, "maxWorkers must be at least 1") { + t.Errorf("missing detail: %q", re.Message) + } + if !strings.Contains(re.Message, "/configuration/maxWorkers: must be at least 1") { + t.Errorf("missing field error: %q", re.Message) + } +} + +func TestUpdateDeployment_NoAPIKey(t *testing.T) { + c := NewClient("", "https://example.invalid", slog.Default()) + if _, err := c.UpdateDeployment(context.Background(), testDeploymentID, DeploymentUpdate{}); !errors.Is(err, transport.ErrNoAPIKey) { + t.Fatalf("expected ErrNoAPIKey, got %v", err) + } +} + func TestListEndpoints(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { want := "/v1/deployments/" + testDeploymentID + "/endpoints" diff --git a/internal/cmd/serverless/apps.go b/internal/cmd/serverless/apps.go index f7acd05..2711101 100644 --- a/internal/cmd/serverless/apps.go +++ b/internal/cmd/serverless/apps.go @@ -28,7 +28,7 @@ func newAppsCmd(logger *log.Logger) *cobra.Command { newAppsBuildsCmd(logger), newAppsLogsCmd(), newAppsWorkersCmd(logger), - newAppsScaleCmd(), + newAppsScaleCmd(logger), newAppsUsageCmd(), newAppsStopCmd(), newAppsResumeCmd(), @@ -346,16 +346,6 @@ func listPageParams(limit int, cursor string) (*serverlessapi.Limit, *serverless return limitOut, cursorOut } -func newAppsScaleCmd() *cobra.Command { - return stubLeaf( - "scale ", - "Scale a serverless application", - ` # update scaling configuration for an application - runware serverless apps scale my-app`, - cobra.ExactArgs(1), - ) -} - func newAppsUsageCmd() *cobra.Command { return stubLeaf( "usage ", diff --git a/internal/cmd/serverless/apps_scale.go b/internal/cmd/serverless/apps_scale.go new file mode 100644 index 0000000..1f00b3f --- /dev/null +++ b/internal/cmd/serverless/apps_scale.go @@ -0,0 +1,118 @@ +package serverless + +import ( + "fmt" + "log/slog" + + "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/runware/runware-cli/internal/output" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// scaleFlags are worker-config values bound to apps scale flags. +type scaleFlags struct { + maxWorkers int32 + minWorkers int32 + idleTTL int32 + scalingDelay int32 + concurrency int32 + gpuType string + gpusPerWorker int32 + fallbackGPUType string + minAvailableWorkers int32 + availableWorkersPct int32 +} + +func newAppsScaleCmd(logger *log.Logger) *cobra.Command { + var flags scaleFlags + + cmd := &cobra.Command{ + Use: "scale ", + 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. + +The server rejects unsupported or invalid fields with HTTP 422.`, + Example: ` # set the worker cap + runware serverless apps scale my-app --max-workers 2 + + # 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) + runware serverless apps scale my-app --gpu-type h100`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id := args[0] + patch, err := workerConfigPatchFromFlags(cmd, flags) + if err != nil { + return err + } + + spin := cmdutil.NewSpinner(fmt.Sprintf("Updating application %s...", id)) + spin.Start() + + client := serverlessapi.NewClient(config.GetAPIKey(), config.GetServerlessBaseURL(), slog.New(logger)) + dep, err := client.UpdateDeployment(cmd.Context(), id, serverlessapi.DeploymentUpdate{ + Configuration: patch, + }) + if err != nil { + spin.Stop() + return err + } + spin.Stop() + + return output.Print(cmdutil.FormatFor(cmd), deploymentResult(*dep)) + }, + } + + bindScaleFlags(cmd, &flags) + var names []string + cmd.LocalFlags().VisitAll(func(f *pflag.Flag) { + names = append(names, f.Name) + }) + cmd.MarkFlagsOneRequired(names...) + return cmd +} + +func bindScaleFlags(cmd *cobra.Command, flags *scaleFlags) { + f := cmd.Flags() + f.Int32Var(&flags.maxWorkers, "max-workers", 0, "Maximum number of workers") + f.Int32Var(&flags.minWorkers, "min-workers", 0, "Minimum number of workers (0 = scale to zero)") + 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.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.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)") +} + +// workerConfigPatchFromFlags builds a partial configuration from flags that +// were explicitly set. Unchanged flags are omitted so existing values are +// not cleared. +func workerConfigPatchFromFlags(cmd *cobra.Command, flags scaleFlags) (*serverlessapi.WorkerConfigPatch, error) { + patch := &serverlessapi.WorkerConfigPatch{ + MaxWorkers: optionalInt32Ptr(cmd, "max-workers", flags.maxWorkers), + MinWorkers: optionalInt32Ptr(cmd, "min-workers", flags.minWorkers), + IdleTtlSecs: optionalInt32Ptr(cmd, "idle-ttl", flags.idleTTL), + ScalingDelaySecs: optionalInt32Ptr(cmd, "scaling-delay", flags.scalingDelay), + Concurrency: optionalInt32Ptr(cmd, "concurrency", flags.concurrency), + GpuType: optionalFlagStringPtr(cmd, "gpu-type", flags.gpuType), + GpusPerWorker: optionalInt32Ptr(cmd, "gpus-per-worker", flags.gpusPerWorker), + FallbackGpuType: optionalFlagStringPtr(cmd, "fallback-gpu-type", flags.fallbackGPUType), + MinAvailableWorkers: optionalInt32Ptr(cmd, "min-available-workers", flags.minAvailableWorkers), + AvailableWorkersPct: optionalInt32Ptr(cmd, "available-workers-pct", flags.availableWorkersPct), + } + if *patch == (serverlessapi.WorkerConfigPatch{}) { + return nil, fmt.Errorf("at least one scaling flag is required") + } + return patch, nil +} diff --git a/internal/cmd/serverless/apps_scale_test.go b/internal/cmd/serverless/apps_scale_test.go new file mode 100644 index 0000000..ee83bfc --- /dev/null +++ b/internal/cmd/serverless/apps_scale_test.go @@ -0,0 +1,109 @@ +package serverless + +import ( + "encoding/json" + "strings" + "testing" + + serverlessapi "github.com/runware/runware-cli/internal/api/serverless" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +func TestWorkerConfigPatchFromFlags_EachFlag(t *testing.T) { + cases := []struct { + args []string + key string + want any + }{ + {[]string{"--max-workers", "2"}, "maxWorkers", float64(2)}, + {[]string{"--min-workers", "0"}, "minWorkers", float64(0)}, + {[]string{"--idle-ttl", "120"}, "idleTtlSecs", float64(120)}, + {[]string{"--scaling-delay", "15"}, "scalingDelaySecs", float64(15)}, + {[]string{"--concurrency", "4"}, "concurrency", float64(4)}, + {[]string{"--gpu-type", 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)}, + {[]string{"--available-workers-pct", "50"}, "availableWorkersPct", float64(50)}, + } + + registered, _ := newScaleFlagCmd() + tested := make(map[string]struct{}, len(cases)) + for _, tc := range cases { + name := strings.TrimPrefix(tc.args[0], "--") + tested[name] = struct{}{} + + cmd, flags := newScaleFlagCmd() + if err := cmd.ParseFlags(tc.args); err != nil { + t.Fatalf("%s: ParseFlags: %v", name, err) + } + + patch, err := workerConfigPatchFromFlags(cmd, *flags) + if err != nil { + t.Fatalf("%s: workerConfigPatchFromFlags: %v", name, err) + } + + raw, err := json.Marshal(patch) + if err != nil { + t.Fatalf("%s: marshal patch: %v", name, err) + } + var got map[string]any + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("%s: unmarshal patch: %v", name, err) + } + if len(got) != 1 { + t.Fatalf("%s: omitted flags should not appear, got %s", name, raw) + } + if got[tc.key] != tc.want { + t.Fatalf("%s: JSON %s=%v, want %v (%s)", name, tc.key, got[tc.key], tc.want, raw) + } + + wrap, err := json.Marshal(serverlessapi.DeploymentUpdate{Configuration: patch}) + if err != nil { + t.Fatalf("%s: marshal update: %v", name, err) + } + var outer map[string]json.RawMessage + if err := json.Unmarshal(wrap, &outer); err != nil { + t.Fatalf("%s: unmarshal update: %v", name, err) + } + if len(outer) != 1 { + t.Fatalf("%s: update body should only include configuration, got %s", name, wrap) + } + if _, ok := outer["configuration"]; !ok { + t.Fatalf("%s: missing configuration: %s", name, wrap) + } + } + + registered.LocalFlags().VisitAll(func(f *pflag.Flag) { + if _, ok := tested[f.Name]; !ok { + t.Errorf("flag %q is registered but has no patch test", f.Name) + } + }) + for name := range tested { + if registered.LocalFlags().Lookup(name) == nil { + t.Errorf("patch test has %q but bindScaleFlags did not register it", name) + } + } +} + +func TestWorkerConfigPatchFromFlags_RequiresAFlag(t *testing.T) { + cmd, flags := newScaleFlagCmd() + if err := cmd.ParseFlags([]string{}); err != nil { + t.Fatalf("ParseFlags: %v", err) + } + _, err := workerConfigPatchFromFlags(cmd, *flags) + if err == nil { + t.Fatal("expected error when no scaling flags are set") + } + if !strings.Contains(err.Error(), "at least one scaling flag") { + t.Fatalf("unexpected error: %v", err) + } +} + +func newScaleFlagCmd() (*cobra.Command, *scaleFlags) { + cmd := &cobra.Command{Use: "scale"} + flags := &scaleFlags{} + bindScaleFlags(cmd, flags) + return cmd, flags +} diff --git a/internal/cmd/serverless/deploy.go b/internal/cmd/serverless/deploy.go index c30b657..9d77f27 100644 --- a/internal/cmd/serverless/deploy.go +++ b/internal/cmd/serverless/deploy.go @@ -127,12 +127,19 @@ func optionalStringPtr(v string) *string { } // optionalInt32Ptr returns a pointer when the flag was explicitly changed from -// its default, so omitted API fields keep server defaults where applicable. -// For create-required sibling fields we always send the flag value via the -// non-pointer fields; this helper is only for optional WorkerConfigCreate keys. +// its default, so omitted API fields keep existing or server-default values. func optionalInt32Ptr(cmd *cobra.Command, name string, v int32) *int32 { if !cmd.Flags().Changed(name) { return nil } return &v } + +// optionalFlagStringPtr returns a pointer when the flag was explicitly set, +// including an empty string, so omitted flags stay omitted. +func optionalFlagStringPtr(cmd *cobra.Command, name, v string) *string { + if !cmd.Flags().Changed(name) { + return nil + } + return &v +} diff --git a/internal/cmd/serverless/display.go b/internal/cmd/serverless/display.go index 374fbae..51bfc5c 100644 --- a/internal/cmd/serverless/display.go +++ b/internal/cmd/serverless/display.go @@ -22,22 +22,47 @@ const ( colApp = "App" colKey = "Key" colEnvVar = "Env var" + + colComputeType = "Compute type" + colGPUType = "GPU type" + colFallbackGPUType = "Fallback GPU type" + colGPUsPerWorker = "GPUs per worker" + colMinWorkers = "Min workers" + colMaxWorkers = "Max workers" + colMinAvailableWorkers = "Min available workers" + colAvailableWorkersPct = "Available workers %" + colIdleTTL = "Idle TTL (s)" + colScalingDelay = "Scaling delay (s)" + colConcurrency = "Concurrency" ) // deploymentResult wraps a single deployment for table/json/yaml display. type deploymentResult serverlessapi.Deployment func (r deploymentResult) Headers() []string { - return []string{colID, colName, colStatus, colCreated} + return []string{colField, colValue} } func (r deploymentResult) Rows() [][]any { - return [][]any{{ - r.DeploymentId, - r.DeploymentName, - string(r.Status), - r.CreatedAt.Format(time.RFC3339), - }} + cfg := r.Configuration + return [][]any{ + {colID, r.DeploymentId}, + {colName, r.DeploymentName}, + {colStatus, string(r.Status)}, + {colCreated, r.CreatedAt.Format(time.RFC3339)}, + {colUpdated, r.UpdatedAt.Format(time.RFC3339)}, + {colComputeType, string(cfg.ComputeType)}, + {colGPUType, formatOptionalString(cfg.GpuType)}, + {colFallbackGPUType, formatOptionalString(cfg.FallbackGpuType)}, + {colGPUsPerWorker, cfg.GpusPerWorker}, + {colMinWorkers, cfg.MinWorkers}, + {colMaxWorkers, cfg.MaxWorkers}, + {colMinAvailableWorkers, formatOptionalInt32(cfg.MinAvailableWorkers)}, + {colAvailableWorkersPct, formatOptionalInt32(cfg.AvailableWorkersPct)}, + {colIdleTTL, cfg.IdleTtlSecs}, + {colScalingDelay, cfg.ScalingDelaySecs}, + {colConcurrency, cfg.Concurrency}, + } } // deploymentsResult wraps a deployment list for table display. diff --git a/internal/cmd/serverless/display_test.go b/internal/cmd/serverless/display_test.go index 9fd9498..04526fa 100644 --- a/internal/cmd/serverless/display_test.go +++ b/internal/cmd/serverless/display_test.go @@ -15,6 +15,7 @@ const ( testAppID = "my-app" testEnvKey = "MY_KEY" testEnvValue = "hello" + testGPUType = "h100" ) func TestListPageParams(t *testing.T) { @@ -119,7 +120,7 @@ func TestParseWorkerStatus(t *testing.T) { } func TestExtraListCursorFlags(t *testing.T) { - got := extraListCursorFlags("demo", "h100", "name", "active") + got := extraListCursorFlags("demo", testGPUType, "name", "active") want := "--query demo --gpu-type h100 --sort name --status active" if got != want { t.Fatalf("got %q, want %q", got, want) @@ -155,6 +156,63 @@ func TestExtraCursorFlag(t *testing.T) { } } +func TestDeploymentResult_IncludesConfiguration(t *testing.T) { + gpu := testGPUType + created := time.Date(2026, 7, 30, 12, 0, 0, 0, time.UTC) + r := deploymentResult{ + DeploymentId: testAppID, + DeploymentName: "My App", + Status: "active", + CreatedAt: created, + UpdatedAt: created, + Configuration: serverlessapi.WorkerConfig{ + ComputeType: "gpu", + GpuType: &gpu, + GpusPerWorker: 1, + MinWorkers: 0, + MaxWorkers: 2, + IdleTtlSecs: 60, + ScalingDelaySecs: 10, + Concurrency: 1, + }, + } + if got := r.Headers(); len(got) != 2 || got[0] != colField || got[1] != colValue { + t.Fatalf("headers: %v", got) + } + rows := r.Rows() + createdAt := created.Format(time.RFC3339) + want := map[string]any{ + colID: testAppID, + colName: "My App", + colStatus: "active", + colCreated: createdAt, + colUpdated: createdAt, + colComputeType: "gpu", + colGPUType: testGPUType, + colFallbackGPUType: "", + colGPUsPerWorker: int32(1), + colMinWorkers: int32(0), + colMaxWorkers: int32(2), + colMinAvailableWorkers: "", + colAvailableWorkersPct: "", + colIdleTTL: int32(60), + colScalingDelay: int32(10), + colConcurrency: int32(1), + } + got := make(map[string]any, len(rows)) + for _, row := range rows { + got[row[0].(string)] = row[1] + } + if len(got) != len(want) { + t.Fatalf("row count %d, want %d: %v", len(got), len(want), got) + } + for field, value := range want { + if got[field] != value { + t.Errorf("%s: got %#v, want %#v", field, got[field], value) + } + } +} + func TestPrintPage_TableWritesNextCursorToErrOut(t *testing.T) { next := "page-2" page := serverlessapi.Page[serverlessapi.Deployment]{