Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions docs/runware_serverless_apps_scale.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,46 @@

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 <appId> [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
Expand Down
52 changes: 52 additions & 0 deletions internal/api/serverless/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 == "" {
Expand Down
110 changes: 110 additions & 0 deletions internal/api/serverless/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package serverless

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
Expand Down Expand Up @@ -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"
Expand Down
12 changes: 1 addition & 11 deletions internal/cmd/serverless/apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func newAppsCmd(logger *log.Logger) *cobra.Command {
newAppsBuildsCmd(logger),
newAppsLogsCmd(),
newAppsWorkersCmd(logger),
newAppsScaleCmd(),
newAppsScaleCmd(logger),
newAppsUsageCmd(),
newAppsStopCmd(),
newAppsResumeCmd(),
Expand Down Expand Up @@ -346,16 +346,6 @@ func listPageParams(limit int, cursor string) (*serverlessapi.Limit, *serverless
return limitOut, cursorOut
}

func newAppsScaleCmd() *cobra.Command {
return stubLeaf(
"scale <appId>",
"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 <appId>",
Expand Down
118 changes: 118 additions & 0 deletions internal/cmd/serverless/apps_scale.go
Original file line number Diff line number Diff line change
@@ -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 <appId>",
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
}
Loading