diff --git a/cmd/oc/internal/commands/scaling.go b/cmd/oc/internal/commands/scaling.go index 29741b4c8..6f94e57d6 100644 --- a/cmd/oc/internal/commands/scaling.go +++ b/cmd/oc/internal/commands/scaling.go @@ -8,38 +8,79 @@ import ( ) var sandboxScaleCmd = &cobra.Command{ - Use: "scale ", - Short: "Manually resize a sandbox", - Long: `Manually resize a sandbox to a specific memory tier. CPU follows memory -per the platform's tier table (e.g. 8 GB → 2 vCPU, 16 GB → 4 vCPU). Allowed tiers: -1024, 4096, 8192, 16384 MB. Contact us for enterprise sizing above 16 GB. + Use: "scale []", + Short: "Manually resize a sandbox (memory and/or disk)", + Long: `Manually resize a sandbox. Any subset of memory and disk may be supplied; +unspecified dimensions are left alone. Combining them in one call applies both +changes atomically and records a single billing event. -A manual scale disables autoscale on this sandbox as a side effect; re- -enable with 'oc sandbox autoscale --on' if you want it back. +Memory tiers (positional arg or --memory-mb): 1024, 4096, 8192, 16384, 32768, +65536 MB. CPU scales proportionally (e.g. 8 GB → 2 vCPU, 16 GB → 4 vCPU). + +Disk (--disk-mb): 20480–262144 MB. Grow completes in ~1–2s online. Shrink is +refused when the guest fs used bytes leave <500 MB safety margin. + +A manual memory scale disables autoscale on this sandbox; re-enable with +'oc sandbox autoscale --on' if you want it back. Disk-only scale leaves +autoscale alone. Errors: - scaling_locked Sandbox has a scaling lock — unlock first. - 402 Payment... Requested size exceeds your plan cap.`, - Args: cobra.ExactArgs(2), + scaling_locked Sandbox has a scaling lock — unlock first. + sandbox_hibernated Sandbox is hibernated — wake first. + oom_floor Memory shrink would OOM-kill the guest. + shrink_refused Disk shrink would leave <500 MB safety margin. + 402 Payment... Requested size exceeds your plan cap.`, + Args: cobra.RangeArgs(1, 2), RunE: func(cmd *cobra.Command, args []string) error { c := client.FromContext(cmd.Context()) - var memoryMB int - if _, err := fmt.Sscanf(args[1], "%d", &memoryMB); err != nil || memoryMB <= 0 { - return fmt.Errorf("invalid memory-mb: %s", args[1]) + memoryMB, _ := cmd.Flags().GetInt("memory-mb") + diskMB, _ := cmd.Flags().GetInt("disk-mb") + + // Backwards-compat: positional memory-mb overrides --memory-mb. + if len(args) == 2 { + if _, err := fmt.Sscanf(args[1], "%d", &memoryMB); err != nil || memoryMB <= 0 { + return fmt.Errorf("invalid memory-mb: %s", args[1]) + } + } + if memoryMB <= 0 && diskMB <= 0 { + return fmt.Errorf("at least one of , --memory-mb, or --disk-mb must be provided") + } + + body := map[string]int{} + if memoryMB > 0 { + body["memoryMB"] = memoryMB + } + if diskMB > 0 { + body["diskMB"] = diskMB } var resp struct { SandboxID string `json:"sandboxID"` MemoryMB int `json:"memoryMB"` CPUPercent int `json:"cpuPercent"` + DiskMB int `json:"diskMB"` } - if err := c.Post(cmd.Context(), "/sandboxes/"+args[0]+"/scale", map[string]int{"memoryMB": memoryMB}, &resp); err != nil { + if err := c.Post(cmd.Context(), "/sandboxes/"+args[0]+"/scale", body, &resp); err != nil { return err } printer.Print(resp, func() { - fmt.Printf("Scaled %s to %dMB / %d%% CPU\n", resp.SandboxID, resp.MemoryMB, resp.CPUPercent) + parts := []string{} + if resp.MemoryMB > 0 { + parts = append(parts, fmt.Sprintf("%dMB / %d%% CPU", resp.MemoryMB, resp.CPUPercent)) + } + if resp.DiskMB > 0 { + parts = append(parts, fmt.Sprintf("disk %dMB", resp.DiskMB)) + } + out := "no dimensions" + if len(parts) > 0 { + out = parts[0] + for _, p := range parts[1:] { + out += ", " + p + } + } + fmt.Printf("Scaled %s to %s\n", resp.SandboxID, out) }) return nil }, @@ -193,6 +234,9 @@ func setScalingLock(cmd *cobra.Command, sandboxID string, locked bool) error { } func init() { + sandboxScaleCmd.Flags().Int("memory-mb", 0, "Target memory in MB (allowed: 1024, 4096, 8192, 16384, 32768, 65536)") + sandboxScaleCmd.Flags().Int("disk-mb", 0, "Target workspace disk in MB (20480–262144)") + sandboxAutoscaleCmd.Flags().Bool("on", false, "Enable autoscale (requires --min and --max)") sandboxAutoscaleCmd.Flags().Bool("off", false, "Disable autoscale") sandboxAutoscaleCmd.Flags().Int("min", 0, "Minimum memory tier in MB (allowed: 1024, 4096, 8192, 16384)") diff --git a/docs/api-reference/sandboxes/scale.mdx b/docs/api-reference/sandboxes/scale.mdx new file mode 100644 index 000000000..667b195f4 --- /dev/null +++ b/docs/api-reference/sandboxes/scale.mdx @@ -0,0 +1,63 @@ +--- +title: 'Scale Sandbox' +api: 'POST /api/sandboxes/{id}/scale' +--- + +Manually resize a running sandbox. Any subset of `memoryMB` and `diskMB` may be supplied; unspecified dimensions are left alone. Combining them applies both changes atomically and records a single billing event. + +For sandbox-driven or platform-driven resize, see the [Elasticity guide](/sandboxes/elasticity). + + + Sandbox ID + + + + Target memory in MB. Must be an allowed tier: `1024`, `4096`, `8192`, `16384`, `32768`, or `65536`. CPU scales proportionally (1 vCPU per ~4 GB up to 16 GB / 4 vCPU). Memory grow above the VM's initial ceiling is served by virtio-mem hotplug; shrink below the guest's working set is refused with `oom_floor`. + + + + Target workspace disk size in MB. Range `20480`–`262144` (20 GB–256 GB). Grow is applied online (QMP `block_resize` + `resize2fs`) and completes in ~1–2 seconds without pausing the guest. The new size persists across hibernate, wake, fork, and migration. Shrink is refused when the guest filesystem's used bytes leave less than a 500 MB safety margin below the target (`shrink_refused`). + + +At least one of `memoryMB` or `diskMB` must be provided. + +## Side effects + +A manual **memory** scale disables the per-sandbox autoscaler (explicit user intent overrides the loop). A disk-only scale leaves the autoscaler alone — autoscale doesn't drive disk. Re-enable memory autoscale via [`PUT /api/sandboxes/{id}/autoscale`](/api-reference/sandboxes) if you want size to track load again. + +## Response + + +```json 200 Combined resize +{ + "sandboxID": "sb-abc123", + "workerID": "w-use2-abc123", + "memoryMB": 8192, + "cpuPercent": 200, + "diskMB": 40960, + "migrated": false, + "ok": true, + "autoscaleDisabled": false +} +``` + + +Fields present in the response reflect the dimensions that were changed. A disk-only scale returns `memoryMB: 0` and `cpuPercent: 0` (no memory change) plus `diskMB` set to the applied value. + +`migrated: true` indicates the sandbox was moved to a larger worker to satisfy a memory grow request that the current worker couldn't fit. + +## Errors + +- **`400 Bad Request`** — no dimension supplied, `diskMB` below the 20 GB floor / above the 256 GB cap, or `memoryMB` not in the allowed tier table. +- **`402 Payment Required`** — requested size exceeds the org's plan cap (free tier is capped at 4 GB memory / 20 GB disk). +- **`403 Forbidden` `scaling_locked`** — the sandbox has a scaling lock active. Unlock via [`PUT /api/sandboxes/{id}/scaling-lock`](/api-reference/sandboxes) first. +- **`403 Forbidden`** — `diskMB` exceeds the org's `MaxDiskMB` cap. +- **`409 Conflict` `oom_floor`** — memory shrink would force a guest OOM-kill (current working set exceeds the requested size). Free memory inside the guest, then retry. +- **`409 Conflict` `sandbox_hibernated`** — sandbox is hibernated. Call [`POST /api/sandboxes/{id}/wake`](/api-reference/sandboxes/wake) first, then retry. +- **`shrink_refused`** (500 with structured code) — a disk shrink would leave less than 500 MB above the guest's used bytes. Free space inside the guest first. + +## Billing + +Every accepted scale records a `sandbox_scale_events` row with the resulting `memory_mb`, `cpu_percent`, and `disk_mb`. Usage aggregation groups by all three dimensions, so a mixed sequence of resizes over a billing period is attributed against the correct envelope for each slice. + +Disk over the 20 GB included allowance is billed at **$0.0000001 per GB-second (≈ $0.26 per GB-month)** for the lifetime of the sandbox — running or hibernated. diff --git a/docs/docs.json b/docs/docs.json index 0cd7fb39d..1594d5159 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -207,6 +207,7 @@ "api-reference/sandboxes/get", "api-reference/sandboxes/delete", "api-reference/sandboxes/set-timeout", + "api-reference/sandboxes/scale", "api-reference/sandboxes/hibernate", "api-reference/sandboxes/wake", { diff --git a/docs/reference/cli/scaling.mdx b/docs/reference/cli/scaling.mdx index 62f42815c..cc37d826d 100644 --- a/docs/reference/cli/scaling.mdx +++ b/docs/reference/cli/scaling.mdx @@ -63,20 +63,37 @@ The asymmetry is deliberate: rapid response when the user notices lag, conservat --- -## `oc sandbox scale ` +## `oc sandbox scale []` -Manually resize a sandbox to a specific memory tier. [HTTP API →](/api-reference/sandboxes/scale) +Manually resize a sandbox — memory and/or workspace disk. Any subset of the dimensions may be supplied; unspecified dimensions are left alone. Combining them in one call applies both changes atomically and records a single billing event. [HTTP API →](/api-reference/sandboxes/scale) ```bash +# Memory only (positional; backwards-compatible with old form) oc sandbox scale sb-abc123 8192 # Scaled sb-abc123 to 8192MB / 400% CPU + +# Disk only +oc sandbox scale sb-abc123 --disk-mb 40960 +# Scaled sb-abc123 to disk 40960MB + +# Both, atomically +oc sandbox scale sb-abc123 --memory-mb 8192 --disk-mb 40960 +# Scaled sb-abc123 to 8192MB / 400% CPU, disk 40960MB ``` -A manual scale **disables autoscale** on this sandbox as a side effect — explicit intent overrides the loop. Re-enable with `oc sandbox autoscale --on` if you want size to track load again. +**Flags** + +- `--memory-mb ` — target memory tier (1024, 4096, 8192, 16384, 32768, 65536). Positional `` still accepted for backwards compat and overrides the flag when both are given. +- `--disk-mb ` — target workspace disk (20480–262144). Grow completes in ~1–2s online. Shrink is refused when guest fs used bytes leave <500 MB safety margin. + +A manual **memory** scale disables autoscale on this sandbox as a side effect — explicit intent overrides the loop. Re-enable with `oc sandbox autoscale --on` if you want size to track load again. A disk-only scale leaves autoscale alone. **Errors** - `scaling_locked` — the sandbox has a scaling lock active. Run `oc sandbox unlock ` first. +- `sandbox_hibernated` — the sandbox is hibernated. Call `oc sandbox wake ` first. +- `oom_floor` — memory shrink would OOM-kill the guest. +- `shrink_refused` — disk shrink would leave less than 500 MB above the guest's used bytes. - `402 Payment Required` — requested size exceeds your plan cap. --- diff --git a/docs/reference/python-sdk/scaling.mdx b/docs/reference/python-sdk/scaling.mdx index b8138a6fa..6d8d71fa8 100644 --- a/docs/reference/python-sdk/scaling.mdx +++ b/docs/reference/python-sdk/scaling.mdx @@ -21,20 +21,27 @@ CPU follows memory per the platform's tier table. You don't pick CPU separately. --- -## `sandbox.scale(memory_mb)` +## `sandbox.scale(memory_mb=..., disk_mb=...)` -Manually resize the sandbox. [HTTP API →](/api-reference/sandboxes/scale) +Manually resize the sandbox. Any subset of the dimensions may be supplied; unspecified dimensions are left alone. Combining them applies both changes atomically and records a single billing event. [HTTP API →](/api-reference/sandboxes/scale) - - Target memory in MB. + + Target memory in MB (allowed tiers: 1024, 4096, 8192, 16384, 32768, 65536). CPU scales proportionally. -**Returns:** `dict` with `sandboxID`, `memoryMB`, `cpuPercent`. + + Target workspace disk size in MB (20480–262144, i.e. 20–256 GB). Grow is online; shrink is refused if the guest fs would leave <500 MB safety margin. + + +**Returns:** `dict` with `sandboxID`, and whichever of `memoryMB` / `cpuPercent` / `diskMB` were applied. **Raises:** +- `ValueError` — neither `memory_mb` nor `disk_mb` was supplied. - `ScalingLockedError` — sandbox has a scaling lock active. -- `PlanLimitError` — `memory_mb` exceeds the org's plan cap. +- `PlanLimitError` — requested size exceeds the org's plan cap. +- Error with `code="sandbox_hibernated"` — sandbox is hibernated; call `sandbox.wake()` first. +- Error with `code="shrink_refused"` — disk shrink would leave less than 500 MB above the guest fs used bytes. ```python from opencomputer import Sandbox, ScalingLockedError, PlanLimitError @@ -42,8 +49,9 @@ from opencomputer import Sandbox, ScalingLockedError, PlanLimitError sandbox = await Sandbox.connect("sb-abc123") try: - result = await sandbox.scale(memory_mb=8192) - print(f"scaled to {result['memoryMB']}MB / {result['cpuPercent']}% CPU") + # Grow memory and disk in one call + result = await sandbox.scale(memory_mb=8192, disk_mb=40960) + print(f"scaled to {result['memoryMB']}MB / {result['cpuPercent']}% CPU, disk {result['diskMB']}MB") except ScalingLockedError: print("sandbox is locked — unlock to scale") except PlanLimitError: diff --git a/docs/reference/typescript-sdk/scaling.mdx b/docs/reference/typescript-sdk/scaling.mdx index 0b01de583..026400c20 100644 --- a/docs/reference/typescript-sdk/scaling.mdx +++ b/docs/reference/typescript-sdk/scaling.mdx @@ -23,18 +23,24 @@ CPU follows memory per the platform's tier table. You don't pick CPU separately. ## `sandbox.scale(opts)` -Manually resize the sandbox. [HTTP API →](/api-reference/sandboxes/scale) +Manually resize the sandbox. Any subset of the dimensions may be supplied; unspecified dimensions are left alone. Combining them in one call applies both changes atomically and records a single billing event. [HTTP API →](/api-reference/sandboxes/scale) - - Target memory in MB. + + Target memory in MB (allowed tiers: 1024, 4096, 8192, 16384, 32768, 65536). CPU scales proportionally. -**Returns:** `Promise<{ sandboxID: string; memoryMB: number; cpuPercent: number }>` + + Target workspace disk size in MB (20480–262144, i.e. 20–256 GB). Grow is online; shrink is refused if the guest fs would leave <500 MB safety margin. + + +**Returns:** `Promise<{ sandboxID: string; memoryMB?: number; cpuPercent?: number; diskMB?: number }>` **Throws:** - `ScalingLockedError` — sandbox has a scaling lock active. -- `PlanLimitError` — `memoryMB` exceeds the org's plan cap. +- `PlanLimitError` — requested size exceeds the org's plan cap. +- Error with `code: "sandbox_hibernated"` — sandbox is hibernated; call `sandbox.wake()` first. +- Error with `code: "shrink_refused"` — disk shrink would leave less than 500 MB above the guest fs used bytes. ```typescript import { Sandbox, ScalingLockedError, PlanLimitError } from "@opencomputer/sdk"; @@ -42,8 +48,9 @@ import { Sandbox, ScalingLockedError, PlanLimitError } from "@opencomputer/sdk"; const sandbox = await Sandbox.connect("sb-abc123"); try { - const result = await sandbox.scale({ memoryMB: 8192 }); - console.log(`scaled to ${result.memoryMB}MB / ${result.cpuPercent}% CPU`); + // Grow memory and disk in one call + const result = await sandbox.scale({ memoryMB: 8192, diskMB: 40960 }); + console.log(`scaled to ${result.memoryMB}MB / ${result.cpuPercent}% CPU, disk ${result.diskMB}MB`); } catch (err) { if (err instanceof ScalingLockedError) { console.warn("sandbox is locked — unlock to scale"); diff --git a/docs/sandboxes/elasticity.mdx b/docs/sandboxes/elasticity.mdx index 5a1333c5b..1e182ed8e 100644 --- a/docs/sandboxes/elasticity.mdx +++ b/docs/sandboxes/elasticity.mdx @@ -3,7 +3,9 @@ title: "Elasticity" description: "Dynamically scale sandbox memory and CPU — automatically, manually, or via the in-VM API" --- -A sandbox's memory and CPU can be resized at runtime. The platform's recommended path is **Autoscaling** — opt the sandbox in once and we resize it for you based on observed memory pressure. Lower-level controls are also available if you want to drive sizing yourself or freeze it. +A sandbox's memory, CPU, and workspace disk can be resized at runtime. The platform's recommended path is **Autoscaling** — opt the sandbox in once and we resize it for you based on observed memory pressure. Lower-level controls are also available if you want to drive sizing yourself or freeze it. + +Autoscale covers memory + CPU. Disk is manual only — grow it explicitly via `scale({ diskMB })` when your workload needs more room. The new size persists across hibernate, wake, fork, and migration. CPU scales proportionally to memory in all modes (1 vCPU per ~4 GB up to 16 GB / 4 vCPU). The permissible memory tiers are: @@ -109,35 +111,57 @@ curl -sf "$API/api/sandboxes/$SANDBOX_ID/autoscale" -H "X-API-Key: $API_KEY" ## Manual Scaling (Control Plane) -When you want to resize once — predictable size for a benchmark, a one-off scale-up before a known-heavy task, an operator response to an alert — call `scale` from your application or operator tooling: +When you want to resize once — predictable size for a benchmark, a one-off scale-up before a known-heavy task, an operator response to an alert — call `scale` from your application or operator tooling. + +Any subset of `memoryMB` and `diskMB` may be supplied; unspecified dimensions are left alone. Combining them in one call applies both changes atomically and records a single billing event. ```typescript TypeScript +// Resize memory only await sandbox.scale({ memoryMB: 4096 }); + +// Grow workspace disk only +await sandbox.scale({ diskMB: 40960 }); + +// Both at once (atomic) +await sandbox.scale({ memoryMB: 8192, diskMB: 61440 }); ``` ```python Python await sandbox.scale(memory_mb=4096) +await sandbox.scale(disk_mb=40960) +await sandbox.scale(memory_mb=8192, disk_mb=61440) ``` ```bash CLI -oc sandbox scale sb-abc123 4096 +oc sandbox scale sb-abc123 4096 # memory only +oc sandbox scale sb-abc123 --disk-mb 40960 # disk only ``` ```bash HTTP curl -sf -X POST "$API/api/sandboxes/$SANDBOX_ID/scale" \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ - -d '{"memoryMB": 4096}' + -d '{"memoryMB": 8192, "diskMB": 61440}' ``` -A manual scale **disables autoscale** on the sandbox — explicit user intent overrides the loop. Re-enable it later if you want size to track load again. +A manual **memory** scale disables autoscale on the sandbox — explicit user intent overrides the loop. Re-enable it later if you want size to track load again. A disk-only scale leaves autoscale alone (autoscale doesn't drive disk). + +### Disk sizing behavior + +Disk grow completes in ~1–2 seconds — the qcow2 backing file is extended and the ext4 filesystem is resized online without pausing the guest. The new size persists through hibernate, wake, fork, and migration; nothing you need to do afterwards. + +**Billing.** Disk over the 20 GB included allowance is billed at **$0.0000001 per GB-second (≈ $0.26 per GB-month)** for the lifetime of the sandbox — running or hibernated. Growing a sandbox to 40 GB and then hibernating it still meters the 20 GB overage. + +**Shrink is guarded.** A shrink is refused if the guest filesystem's used bytes would leave less than a 500 MB safety margin below the new size. Free space inside the guest first (delete files, then wait for the fs metadata to settle) or fork to a smaller sandbox instead. -The endpoint can return: +### Response codes - **403 `scaling_locked`** — the sandbox is locked. See [Locking Resources](#locking-resources). -- **409 `oom_floor`** — the requested size would force a guest OOM-kill because the current working set exceeds it. Free memory inside the guest, then retry. +- **409 `oom_floor`** — the requested memory size would force a guest OOM-kill because the current working set exceeds it. Free memory inside the guest, then retry. +- **409 `sandbox_hibernated`** — the sandbox is hibernated. Call `POST /wake` first, then retry the resize. +- **`shrink_refused`** — a disk shrink would leave less than 500 MB free above the guest's used bytes. - **402 Payment Required** — the requested size exceeds your plan cap. ## In-VM Scaling diff --git a/docs/sandboxes/overview.mdx b/docs/sandboxes/overview.mdx index 685cceb33..5d34e538e 100644 --- a/docs/sandboxes/overview.mdx +++ b/docs/sandboxes/overview.mdx @@ -94,7 +94,8 @@ curl -X POST https://app.opencomputer.dev/api/sandboxes \ **Disk sizing (closed beta).** Every sandbox ships with a 20GB workspace on `/home/sandbox`. You can request up to 256GB via `diskMB`; any GB above the - 20GB baseline is metered per-second at a rate comparable to AWS EBS gp3. + 20GB baseline is metered at **$0.0000001 per GB-second (≈ $0.26 per + GB-month)**, billed for the lifetime of the sandbox — running or hibernated. Larger disk limits are gated per-organization during the beta — [book a call](https://cal.com/team/digger/opencomputer-founder-chat) to have your org's ceiling raised. diff --git a/internal/api/sandbox.go b/internal/api/sandbox.go index 59454be23..101603d9d 100644 --- a/internal/api/sandbox.go +++ b/internal/api/sandbox.go @@ -1670,7 +1670,7 @@ func (s *Server) setLimits(c echo.Context) error { // Server mode: dispatch to worker via gRPC if s.workerRegistry != nil { - return s.setLimitsRemote(c, id, 0, maxMemoryBytes, cpuMaxUsec, cpuPeriodUsec) + return s.setLimitsRemote(c, id, 0, maxMemoryBytes, cpuMaxUsec, cpuPeriodUsec, 0) } // Combined mode: apply locally @@ -1698,26 +1698,63 @@ func (s *Server) scaleSandbox(c echo.Context) error { var req struct { MemoryMB int `json:"memoryMB"` + DiskMB int `json:"diskMB"` } - if err := c.Bind(&req); err != nil || req.MemoryMB <= 0 { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "memoryMB is required and must be positive"}) + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"}) + } + if req.MemoryMB <= 0 && req.DiskMB <= 0 { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "memoryMB or diskMB is required and must be positive"}) } - // Validate memory against allowed tiers - vcpus, err := types.ValidateMemoryMB(req.MemoryMB) - if err != nil { - return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + // Validate memory against allowed tiers (only when memory is being changed). + var vcpus int + if req.MemoryMB > 0 { + v, err := types.ValidateMemoryMB(req.MemoryMB) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + vcpus = v } - // Free tier: block scaling beyond 4GB. Plan comes from the cap-token + // Validate disk bounds when supplied. Matches the create-time envelope + // (internal/api/sandbox.go:153-165) so runtime resize can't reach sizes + // that would have been rejected at create. + if req.DiskMB > 0 { + if req.DiskMB < 20480 { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "diskMB must be at least 20480 (20GB)"}) + } + if req.DiskMB > 262144 { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "diskMB cannot exceed 262144 (256GB)"}) + } + } + + // Free tier: block scaling beyond 4GB / 20GB. Plan comes from the cap-token // (edge-authoritative) when present, else the cell-PG copy. Autumn orgs are // metered, not tiered, so the ceiling doesn't apply to them. if orgID, hasOrg := auth.GetOrgID(c); hasOrg { - if s.effectivePlan(c, orgID) == "free" && s.effectiveBillingProvider(c, orgID) != "autumn" && req.MemoryMB > 4096 { + plan := s.effectivePlan(c, orgID) + provider := s.effectiveBillingProvider(c, orgID) + if req.MemoryMB > 0 && plan == "free" && provider != "autumn" && req.MemoryMB > 4096 { return c.JSON(http.StatusPaymentRequired, map[string]string{ "error": "upgrade to pro for larger instances", }) } + if req.DiskMB > 0 && plan == "free" && provider != "autumn" && req.DiskMB > 20480 { + return c.JSON(http.StatusPaymentRequired, map[string]string{ + "error": "upgrade to pro for larger disks", + }) + } + // Org disk cap (mirrors the create-time check). + if req.DiskMB > 0 && s.store != nil { + if org, err := s.store.GetOrg(c.Request().Context(), orgID); err == nil && org != nil { + if org.MaxDiskMB > 0 && req.DiskMB > org.MaxDiskMB { + return c.JSON(http.StatusForbidden, map[string]string{ + "error": fmt.Sprintf("disk size %dMB exceeds org limit of %dMB", req.DiskMB, org.MaxDiskMB), + }) + } + } + } } // Scaling lock: refuse if the user has explicitly pinned this sandbox's @@ -1732,10 +1769,17 @@ func (s *Server) scaleSandbox(c echo.Context) error { } } - cpuPercent := vcpus * 100 - maxMemoryBytes := int64(req.MemoryMB) * 1024 * 1024 - cpuMaxUsec := int64(cpuPercent) * 1000 - cpuPeriodUsec := int64(100000) + var cpuPercent int + var maxMemoryBytes, cpuMaxUsec, cpuPeriodUsec, diskBytes int64 + if req.MemoryMB > 0 { + cpuPercent = vcpus * 100 + maxMemoryBytes = int64(req.MemoryMB) * 1024 * 1024 + cpuMaxUsec = int64(cpuPercent) * 1000 + cpuPeriodUsec = int64(100000) + } + if req.DiskMB > 0 { + diskBytes = int64(req.DiskMB) * 1024 * 1024 + } // Manual scale disables autoscale. Rationale: a user explicitly setting a // size has signalled they want predictability — letting the autoscaler @@ -1743,8 +1787,9 @@ func (s *Server) scaleSandbox(c echo.Context) error { // Best-effort — failure to disable is logged but doesn't fail the scale. // We capture whether it WAS enabled so the response can flag the side- // effect to SDK callers (otherwise autoscale silently flips off). + // Only applies when memory changes — disk-only resize leaves autoscale alone. var autoscaleWasEnabled bool - if s.store != nil { + if s.store != nil && req.MemoryMB > 0 { if enabled, _, _, err := s.store.GetSandboxAutoscale(c.Request().Context(), id); err == nil { autoscaleWasEnabled = enabled } @@ -1753,24 +1798,36 @@ func (s *Server) scaleSandbox(c echo.Context) error { } } c.Set("autoscaleWasEnabled", autoscaleWasEnabled) + c.Set("scaleRequestedDiskMB", req.DiskMB) if s.workerRegistry != nil { - return s.setLimitsRemote(c, id, 0, maxMemoryBytes, cpuMaxUsec, cpuPeriodUsec) + return s.setLimitsRemote(c, id, 0, maxMemoryBytes, cpuMaxUsec, cpuPeriodUsec, diskBytes) } if s.manager == nil { return c.JSON(http.StatusServiceUnavailable, errSandboxNotAvailable) } - if err := s.manager.SetResourceLimits(c.Request().Context(), id, 0, maxMemoryBytes, cpuMaxUsec, cpuPeriodUsec); err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + if maxMemoryBytes > 0 { + if err := s.manager.SetResourceLimits(c.Request().Context(), id, 0, maxMemoryBytes, cpuMaxUsec, cpuPeriodUsec); err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } } - return c.JSON(http.StatusOK, map[string]interface{}{ - "sandboxID": id, - "memoryMB": req.MemoryMB, - "cpuPercent": cpuPercent, - }) + if diskBytes > 0 { + if err := s.manager.ResizeSandboxDisk(c.Request().Context(), id, diskBytes); err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + } + resp := map[string]interface{}{"sandboxID": id} + if req.MemoryMB > 0 { + resp["memoryMB"] = req.MemoryMB + resp["cpuPercent"] = cpuPercent + } + if req.DiskMB > 0 { + resp["diskMB"] = req.DiskMB + } + return c.JSON(http.StatusOK, resp) } -func (s *Server) setLimitsRemote(c echo.Context, sandboxID string, maxPids int32, maxMemoryBytes, cpuMaxUsec, cpuPeriodUsec int64) error { +func (s *Server) setLimitsRemote(c echo.Context, sandboxID string, maxPids int32, maxMemoryBytes, cpuMaxUsec, cpuPeriodUsec, diskBytes int64) error { if s.store == nil { return c.JSON(http.StatusServiceUnavailable, map[string]string{ "error": "database not configured", @@ -1783,6 +1840,18 @@ func (s *Server) setLimitsRemote(c echo.Context, sandboxID string, maxPids int32 if err != nil { return c.JSON(http.StatusNotFound, map[string]string{"error": "sandbox not found"}) } + if session.Status == "hibernated" { + // v1: return a structured 409 so SDK callers can auto-wake + retry. + // A follow-up will fold the wake→resize→re-hibernate round-trip into + // this handler once wake/hibernate are refactored to expose internal + // helpers (currently they only exist as HTTP handlers that write to + // echo.Context, which we can't compose from here without a refactor + // bigger than the resize change itself). + return c.JSON(http.StatusConflict, map[string]any{ + "error": "sandbox is hibernated — call POST /wake first, then retry", + "code": "sandbox_hibernated", + }) + } if session.Status != "running" { return c.JSON(http.StatusBadRequest, map[string]string{"error": "sandbox is not running"}) } @@ -1792,6 +1861,7 @@ func (s *Server) setLimitsRemote(c echo.Context, sandboxID string, maxPids int32 if requestedCPUs < 1 { requestedCPUs = 1 } + requestedDiskMB := int(diskBytes / (1024 * 1024)) workerID := session.WorkerID @@ -1808,6 +1878,7 @@ func (s *Server) setLimitsRemote(c echo.Context, sandboxID string, maxPids int32 MaxMemoryBytes: maxMemoryBytes, CpuMaxUsec: cpuMaxUsec, CpuPeriodUsec: cpuPeriodUsec, + DiskBytes: diskBytes, }) cancel() @@ -1884,6 +1955,7 @@ func (s *Server) setLimitsRemote(c echo.Context, sandboxID string, maxPids int32 MaxMemoryBytes: maxMemoryBytes, CpuMaxUsec: cpuMaxUsec, CpuPeriodUsec: cpuPeriodUsec, + DiskBytes: diskBytes, }) retryCancel() @@ -1934,6 +2006,9 @@ func (s *Server) setLimitsRemote(c echo.Context, sandboxID string, maxPids int32 "migrated": migrated, "ok": true, } + if requestedDiskMB > 0 { + resp["diskMB"] = requestedDiskMB + } // Surface the autoscale-was-disabled side-effect when the request came // from /scale (scaleSandbox stashes this on the echo context). Quiet // when called from /limits (no autoscale toggle there). diff --git a/internal/db/usage.go b/internal/db/usage.go index 6ad49b33e..d759e0fc7 100644 --- a/internal/db/usage.go +++ b/internal/db/usage.go @@ -31,8 +31,15 @@ type UsageSample struct { } // RecordScaleEvent ends the current scale event (if any) and starts a new one. -// diskMB is the workspace disk size at this point — pass 0 to inherit from the -// most recent scale event (disk doesn't change at runtime). +// Any dimension passed as 0 is inherited from the most recent event for this +// sandbox — required because a scale call may touch a strict subset of the +// three (e.g. disk-only resize sends memoryMB=0, cpuPct=0). Without this the +// new event would attribute zero memory/CPU going forward and break billing +// aggregation, which GROUP BYs on (memory_mb, cpu_percent, disk_mb). +// +// If there's no prior event to inherit from (shouldn't happen — create always +// records one first), fall back to platform defaults so a row is never billed +// against 0-valued dimensions. func (s *Store) RecordScaleEvent(ctx context.Context, sandboxID, orgID string, memoryMB, cpuPct, diskMB int) error { tx, err := s.pool.Begin(ctx) if err != nil { @@ -40,17 +47,33 @@ func (s *Store) RecordScaleEvent(ctx context.Context, sandboxID, orgID string, m } defer tx.Rollback(ctx) - if diskMB <= 0 { - // Inherit from the most recent open or closed event for this sandbox. - var prev int + if memoryMB <= 0 || cpuPct <= 0 || diskMB <= 0 { + var prevMem, prevCPU, prevDisk int err = tx.QueryRow(ctx, - `SELECT disk_mb FROM sandbox_scale_events + `SELECT memory_mb, cpu_percent, disk_mb FROM sandbox_scale_events WHERE sandbox_id = $1 - ORDER BY started_at DESC LIMIT 1`, sandboxID).Scan(&prev) - if err == nil && prev > 0 { - diskMB = prev - } else { - diskMB = 20480 // fall back to default 20GB + ORDER BY started_at DESC LIMIT 1`, sandboxID).Scan(&prevMem, &prevCPU, &prevDisk) + hasPrior := err == nil + if memoryMB <= 0 { + if hasPrior && prevMem > 0 { + memoryMB = prevMem + } else { + memoryMB = 1024 // platform default + } + } + if cpuPct <= 0 { + if hasPrior && prevCPU > 0 { + cpuPct = prevCPU + } else { + cpuPct = 100 // platform default (1 vCPU) + } + } + if diskMB <= 0 { + if hasPrior && prevDisk > 0 { + diskMB = prevDisk + } else { + diskMB = 20480 // platform default (20GB) + } } } diff --git a/internal/qemu/manager.go b/internal/qemu/manager.go index a50a10908..2a6996f94 100644 --- a/internal/qemu/manager.go +++ b/internal/qemu/manager.go @@ -2934,6 +2934,105 @@ func (m *Manager) SetResourceLimits(ctx context.Context, sandboxID string, maxPi return vm.agent.SetResourceLimits(ctx, maxPids, maxMemoryBytes, cpuMaxUsec, cpuPeriodUsec) } +// ResizeSandboxDisk grows (or, guarded, shrinks) the customer disk of a running +// sandbox via QMP block_resize — which both resizes the backing qcow2 AND fires +// the virtio-blk capacity-change event to the guest atomically. Followed by +// agent-side resize2fs to grow the ext4 online. +// +// Note: we do NOT call `qemu-img resize` externally — the qcow2 is opened by +// the running QEMU with a write lock, and external mutation would fail or race +// with in-flight IO. QMP block_resize is the correct online path (the offline +// ResizeWorkspace helper is only used pre-launch during golden-create). +// +// Shrink is refused unless the guest filesystem's used bytes leave a 500 MB +// safety margin below the target — fail-closed on stats errors during shrink +// (mirrors the memory OOM-floor pattern at :2857). +func (m *Manager) ResizeSandboxDisk(ctx context.Context, sandboxID string, newDiskBytes int64) error { + if newDiskBytes <= 0 { + return fmt.Errorf("disk size must be positive") + } + vm, err := m.getReadyVM(ctx, sandboxID) + if err != nil { + return err + } + if vm.qmp == nil { + return fmt.Errorf("qmp not available for %s", sandboxID) + } + if vm.agent == nil { + return fmt.Errorf("agent not available for %s", sandboxID) + } + + // Pick the customer disk based on layout. In the split (legacy) topology + // the customer's /home/sandbox lives on vdb; in merged (current default) + // the whole 20 GB rootfs is customer-writable and there is no vdb. + var diskPath, guestDev string + var usedBytesFn func(*pb.StatsResponse) uint64 + if IsMerged(vm.diskLayout) { + diskPath = detectDrivePath(vm.sandboxDir, "rootfs") + guestDev = "/dev/vda" + usedBytesFn = func(s *pb.StatsResponse) uint64 { return s.RootfsUsedBytes } + } else { + diskPath = detectDrivePath(vm.sandboxDir, "workspace") + guestDev = "/dev/vdb" + usedBytesFn = func(s *pb.StatsResponse) uint64 { return s.WorkspaceUsedBytes } + } + + // Shrink guard: pull live guest fs usage and refuse if it would leave less + // than a 500 MB safety margin. Fail-closed on stats errors — better to + // bounce back to the caller (who can retry) than truncate live data. + // Trivially passes on any grow (used << newDiskBytes). + const shrinkMarginBytes = 500 * 1024 * 1024 + statsCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + stats, statsErr := vm.agent.Stats(statsCtx) + cancel() + if statsErr != nil { + return fmt.Errorf("cannot verify guest fs usage: %w", statsErr) + } + usedBytes := usedBytesFn(stats) + floor := int64(usedBytes) + shrinkMarginBytes + if newDiskBytes < floor { + return fmt.Errorf("shrink_refused: target %dMB below guest fs floor (%dMB used, need ≥ %dMB)", + newDiskBytes/(1024*1024), int64(usedBytes)/(1024*1024), floor/(1024*1024)) + } + + // Find the virtio-blk device by matching the backing file, then push the + // new geometry into the running guest. block_resize is a no-op if the + // device is already at the requested size, so we don't gate on that. + devs, err := vm.qmp.QueryBlock() + if err != nil { + return fmt.Errorf("query-block: %w", err) + } + var devID string + for _, d := range devs { + if d.Inserted.File == diskPath { + devID = d.Device + break + } + } + if devID == "" { + return fmt.Errorf("device for %s not found in query-block", diskPath) + } + if err := vm.qmp.BlockResize(devID, newDiskBytes); err != nil { + return fmt.Errorf("block_resize: %w", err) + } + + // Grow the ext4 online. Try the raw device first, fall back to partition + // 1 in case a future template adds partitioning. + resizeCtx, resizeCancel := context.WithTimeout(ctx, 30*time.Second) + defer resizeCancel() + if _, err := vm.agent.Exec(resizeCtx, &pb.ExecRequest{ + Command: "/bin/sh", + Args: []string{"-c", fmt.Sprintf("resize2fs %s 2>/dev/null || resize2fs %s1", guestDev, guestDev)}, + RunAsRoot: true, + }); err != nil { + return fmt.Errorf("resize2fs %s: %w", guestDev, err) + } + + log.Printf("qemu: %s disk resized: %s → %dMB (%s)", + sandboxID, guestDev, newDiskBytes/(1024*1024), diskPath) + return nil +} + // UpdateSandboxSecret refreshes the proxy session value for one secret name. // Returns (true, nil) on success, (false, nil) if there's no session for the // sandbox or the secret name isn't on the session — both treated as transient diff --git a/internal/sandbox/interface.go b/internal/sandbox/interface.go index 2e0aaa6ce..1acf3a1ef 100644 --- a/internal/sandbox/interface.go +++ b/internal/sandbox/interface.go @@ -82,6 +82,11 @@ type Manager interface { // Resource limits SetResourceLimits(ctx context.Context, sandboxID string, maxPids int32, maxMemoryBytes, cpuMaxUsec, cpuPeriodUsec int64) error + // ResizeSandboxDisk grows or shrinks the customer disk of a running sandbox + // (rootfs.qcow2 in merged layout, workspace.qcow2 in split). Shrink is + // refused when the guest filesystem's used bytes leave no safety margin. + ResizeSandboxDisk(ctx context.Context, sandboxID string, newDiskBytes int64) error + // UpdateSandboxSecret refreshes the proxy session value for one secret name // (env var name) without changing the sealed token id seen by the sandbox. // Used by the secret-store-update flow to push new values to running diff --git a/internal/worker/grpc_server.go b/internal/worker/grpc_server.go index 69b76254a..0942ff1fb 100644 --- a/internal/worker/grpc_server.go +++ b/internal/worker/grpc_server.go @@ -1460,21 +1460,37 @@ func downloadAndExtract(ctx context.Context, store *storage.CheckpointStore, s3K return extract(tmpPath, dest) } -// SetSandboxLimits adjusts resource limits (memory, CPU, PIDs) on a running sandbox. -// Memory increases trigger virtio-mem hotplug; decreases adjust cgroup limits only. +// SetSandboxLimits adjusts resource limits (memory, CPU, PIDs, disk) on a +// running sandbox. Memory increases trigger virtio-mem hotplug; decreases +// adjust cgroup limits only. Disk resizes qemu-img → block_resize → resize2fs +// (grow always; shrink guarded by guest fs usage). +// +// Any of the three (memory, cpu, disk) may be zero to leave that dimension +// unchanged. RecordScaleEvent inherits from the prior event when a dimension +// is unspecified. func (s *GRPCServer) SetSandboxLimits(ctx context.Context, req *pb.SetSandboxLimitsRequest) (*pb.SetSandboxLimitsResponse, error) { - if err := s.manager.SetResourceLimits(ctx, req.SandboxId, req.MaxPids, req.MaxMemoryBytes, req.CpuMaxUsec, req.CpuPeriodUsec); err != nil { - return nil, fmt.Errorf("set resource limits: %w", err) + if req.MaxMemoryBytes > 0 || req.CpuMaxUsec > 0 { + if err := s.manager.SetResourceLimits(ctx, req.SandboxId, req.MaxPids, req.MaxMemoryBytes, req.CpuMaxUsec, req.CpuPeriodUsec); err != nil { + return nil, fmt.Errorf("set resource limits: %w", err) + } + } + + if req.DiskBytes > 0 { + if err := s.manager.ResizeSandboxDisk(ctx, req.SandboxId, req.DiskBytes); err != nil { + return nil, fmt.Errorf("resize disk: %w", err) + } } - // Record scale event for billing. Disk size is not affected by SetSandboxLimits; - // pass 0 so RecordScaleEvent inherits disk_mb from the prior event. - if s.store != nil && req.MaxMemoryBytes > 0 { + // Record scale event for billing. GetOrgUsage groups by (memory_mb, cpu_percent, + // disk_mb); dimensions the caller didn't touch stay at 0 so RecordScaleEvent + // inherits them from the prior event, keeping usage attribution correct. + if s.store != nil && (req.MaxMemoryBytes > 0 || req.DiskBytes > 0) { memMB := int(req.MaxMemoryBytes / (1024 * 1024)) cpuPct := int(req.CpuMaxUsec / 1000) // 100000us → 100% + diskMB := int(req.DiskBytes / (1024 * 1024)) orgID, _ := s.store.GetSandboxOrgID(ctx, req.SandboxId) if orgID != "" { - if err := s.store.RecordScaleEvent(ctx, req.SandboxId, orgID, memMB, cpuPct, 0); err != nil { + if err := s.store.RecordScaleEvent(ctx, req.SandboxId, orgID, memMB, cpuPct, diskMB); err != nil { log.Printf("grpc: failed to record scale event for %s: %v", req.SandboxId, err) } } diff --git a/proto/worker/worker.pb.go b/proto/worker/worker.pb.go index 732873087..4a7772e03 100644 --- a/proto/worker/worker.pb.go +++ b/proto/worker/worker.pb.go @@ -3532,8 +3532,13 @@ type SetSandboxLimitsRequest struct { MaxMemoryBytes int64 `protobuf:"varint,3,opt,name=max_memory_bytes,json=maxMemoryBytes,proto3" json:"max_memory_bytes,omitempty"` CpuMaxUsec int64 `protobuf:"varint,4,opt,name=cpu_max_usec,json=cpuMaxUsec,proto3" json:"cpu_max_usec,omitempty"` CpuPeriodUsec int64 `protobuf:"varint,5,opt,name=cpu_period_usec,json=cpuPeriodUsec,proto3" json:"cpu_period_usec,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Target customer-disk size in bytes. 0 = leave unchanged (existing callers + // that only adjust memory/cpu remain unaffected). Grow or shrink is applied + // via qemu-img resize → QMP block_resize → guest resize2fs; shrink is + // refused if the guest filesystem's used bytes leave no safety margin. + DiskBytes int64 `protobuf:"varint,6,opt,name=disk_bytes,json=diskBytes,proto3" json:"disk_bytes,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SetSandboxLimitsRequest) Reset() { @@ -3601,6 +3606,13 @@ func (x *SetSandboxLimitsRequest) GetCpuPeriodUsec() int64 { return 0 } +func (x *SetSandboxLimitsRequest) GetDiskBytes() int64 { + if x != nil { + return x.DiskBytes + } + return 0 +} + type SetSandboxLimitsResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -4756,7 +4768,7 @@ const file_proto_worker_worker_proto_rawDesc = "" + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12#\n" + "\rcheckpoint_id\x18\x02 \x01(\tR\fcheckpointId\"5\n" + "\x19RestoreCheckpointResponse\x12\x18\n" + - "\asuccess\x18\x01 \x01(\bR\asuccess\"\xc7\x01\n" + + "\asuccess\x18\x01 \x01(\bR\asuccess\"\xe6\x01\n" + "\x17SetSandboxLimitsRequest\x12\x1d\n" + "\n" + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x19\n" + @@ -4764,7 +4776,9 @@ const file_proto_worker_worker_proto_rawDesc = "" + "\x10max_memory_bytes\x18\x03 \x01(\x03R\x0emaxMemoryBytes\x12 \n" + "\fcpu_max_usec\x18\x04 \x01(\x03R\n" + "cpuMaxUsec\x12&\n" + - "\x0fcpu_period_usec\x18\x05 \x01(\x03R\rcpuPeriodUsec\"\x1a\n" + + "\x0fcpu_period_usec\x18\x05 \x01(\x03R\rcpuPeriodUsec\x12\x1d\n" + + "\n" + + "disk_bytes\x18\x06 \x01(\x03R\tdiskBytes\"\x1a\n" + "\x18SetSandboxLimitsResponse\";\n" + "\x14PreCopyDrivesRequest\x12\x1d\n" + "\n" + diff --git a/proto/worker/worker.proto b/proto/worker/worker.proto index 344001705..b97c02d38 100644 --- a/proto/worker/worker.proto +++ b/proto/worker/worker.proto @@ -465,6 +465,11 @@ message SetSandboxLimitsRequest { int64 max_memory_bytes = 3; int64 cpu_max_usec = 4; int64 cpu_period_usec = 5; + // Target customer-disk size in bytes. 0 = leave unchanged (existing callers + // that only adjust memory/cpu remain unaffected). Grow or shrink is applied + // via qemu-img resize → QMP block_resize → guest resize2fs; shrink is + // refused if the guest filesystem's used bytes leave no safety margin. + int64 disk_bytes = 6; } message SetSandboxLimitsResponse {} diff --git a/sdks/python/opencomputer/sandbox.py b/sdks/python/opencomputer/sandbox.py index 628f9904f..5f3197b21 100644 --- a/sdks/python/opencomputer/sandbox.py +++ b/sdks/python/opencomputer/sandbox.py @@ -367,28 +367,50 @@ async def is_running(self) -> bool: except httpx.HTTPStatusError: return False - async def scale(self, memory_mb: int) -> dict: - """Manually resize the sandbox to a specific memory tier. + async def scale( + self, + memory_mb: int | None = None, + disk_mb: int | None = None, + ) -> dict: + """Manually resize the sandbox. - CPU is bundled with memory per the platform's tier table (e.g. 8 GB - → 4 vCPU). Allowed tiers: 1024, 4096, 8192, 16384, 32768, 65536 MB. + Any subset of ``memory_mb`` and ``disk_mb`` may be supplied; unspecified + dimensions are left alone. Combining them applies both changes atomically + and records a single billing event. - A manual scale disables autoscale on this sandbox as a side effect. - Re-enable with :meth:`set_autoscale` if you want size to track load. + ``memory_mb`` bundles CPU per the platform's tier table (e.g. 8 GB + → 4 vCPU); allowed tiers: 1024, 4096, 8192, 16384, 32768, 65536 MB. + ``disk_mb`` grows (or, guarded, shrinks) the customer workspace disk + online — 20480–262144 (20 GB–256 GB). The new size persists across + hibernate, wake, fork, and migration. + + A manual **memory** scale disables autoscale on this sandbox as a side + effect. Re-enable with :meth:`set_autoscale` if you want size to track + load. A disk-only scale leaves autoscale alone. Args: memory_mb: Target memory tier in MB. + disk_mb: Target workspace disk size in MB. Raises: + ValueError: Neither ``memory_mb`` nor ``disk_mb`` was provided. ScalingLockedError: The sandbox has a scaling lock active. - PlanLimitError: ``memory_mb`` exceeds the org's plan cap. + PlanLimitError: Requested size exceeds the org's plan cap. Returns: - Dict with ``sandboxID``, ``memoryMB``, ``cpuPercent``. + Dict with ``sandboxID``, and whichever of ``memoryMB`` / + ``cpuPercent`` / ``diskMB`` were applied. """ + if not memory_mb and not disk_mb: + raise ValueError("scale: at least one of memory_mb or disk_mb must be provided") + body: dict = {} + if memory_mb: + body["memoryMB"] = memory_mb + if disk_mb: + body["diskMB"] = disk_mb resp = await self._client.post( f"/sandboxes/{self.sandbox_id}/scale", - json={"memoryMB": memory_mb}, + json=body, ) if resp.status_code >= 400: _raise_scaling_error(resp, "scale") diff --git a/sdks/typescript/src/sandbox.ts b/sdks/typescript/src/sandbox.ts index 11370ba0c..53f30a866 100644 --- a/sdks/typescript/src/sandbox.ts +++ b/sdks/typescript/src/sandbox.ts @@ -556,27 +556,41 @@ export class Sandbox { } /** - * Manually resize the sandbox to a specific memory tier. CPU is bundled - * with memory per the platform's tier table (e.g. 8 GB → 4 vCPU). Allowed - * tiers: 1024, 4096, 8192, 16384, 32768, 65536 MB. + * Manually resize the sandbox. Any subset of `memoryMB` and `diskMB` may be + * supplied; unspecified dimensions are left alone. Combining them applies + * both changes atomically and records a single billing event. * - * Side effect: a manual scale disables autoscale on this sandbox. If you - * want size to track load again, call `setAutoscale({ enabled: true, ... })` - * after. + * `memoryMB` bundles CPU per the platform's tier table (e.g. 8 GB → 4 vCPU); + * allowed tiers are 1024, 4096, 8192, 16384, 32768, 65536 MB. `diskMB` grows + * (or, guarded, shrinks) the customer workspace disk online — 20480–262144 + * (20 GB–256 GB). The new size persists across hibernate, wake, fork, and + * migration. + * + * Side effect: a manual **memory** scale disables autoscale on this sandbox + * (call `setAutoscale({ enabled: true, ... })` to turn it back on). A + * disk-only scale leaves autoscale alone — autoscale doesn't drive disk. * * Throws `ScalingLockedError` if the sandbox has a scaling lock; throws - * `PlanLimitError` if the requested size exceeds your plan cap. + * `PlanLimitError` if the requested size exceeds your plan cap. Disk shrink + * is refused (400) when the guest filesystem's used bytes leave <500 MB + * safety margin below the target. * * [HTTP API →](/api-reference/sandboxes/scale) */ - async scale(opts: { memoryMB: number }): Promise { + async scale(opts: { memoryMB?: number; diskMB?: number }): Promise { + if (!opts.memoryMB && !opts.diskMB) { + throw new Error("scale: at least one of memoryMB or diskMB must be provided"); + } + const body: { memoryMB?: number; diskMB?: number } = {}; + if (opts.memoryMB) body.memoryMB = opts.memoryMB; + if (opts.diskMB) body.diskMB = opts.diskMB; const resp = await fetch(`${this.apiUrl}/sandboxes/${this.sandboxId}/scale`, { method: "POST", headers: { "Content-Type": "application/json", ...(this.apiKey ? { "X-API-Key": this.apiKey } : {}), }, - body: JSON.stringify({ memoryMB: opts.memoryMB }), + body: JSON.stringify(body), }); if (!resp.ok) {