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
74 changes: 59 additions & 15 deletions cmd/oc/internal/commands/scaling.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,38 +8,79 @@ import (
)

var sandboxScaleCmd = &cobra.Command{
Use: "scale <sandbox-id> <memory-mb>",
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 <sandbox-id> [<memory-mb>]",
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>, --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
},
Expand Down Expand Up @@ -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)")
Expand Down
63 changes: 63 additions & 0 deletions docs/api-reference/sandboxes/scale.mdx
Original file line number Diff line number Diff line change
@@ -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).

<ParamField path="id" type="string" required>
Sandbox ID
</ParamField>

<ParamField body="memoryMB" type="integer">
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`.
</ParamField>

<ParamField body="diskMB" type="integer">
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`).
</ParamField>

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

<ResponseExample>
```json 200 Combined resize
{
"sandboxID": "sb-abc123",
"workerID": "w-use2-abc123",
"memoryMB": 8192,
"cpuPercent": 200,
"diskMB": 40960,
"migrated": false,
"ok": true,
"autoscaleDisabled": false
}
```
</ResponseExample>

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.
1 change: 1 addition & 0 deletions docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
{
Expand Down
23 changes: 20 additions & 3 deletions docs/reference/cli/scaling.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -63,20 +63,37 @@ The asymmetry is deliberate: rapid response when the user notices lag, conservat

---

## `oc sandbox scale <id> <memory-mb>`
## `oc sandbox scale <id> [<memory-mb>]`

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 <int>` — target memory tier (1024, 4096, 8192, 16384, 32768, 65536). Positional `<memory-mb>` still accepted for backwards compat and overrides the flag when both are given.
- `--disk-mb <int>` — 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 <id>` first.
- `sandbox_hibernated` — the sandbox is hibernated. Call `oc sandbox wake <id>` 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.

---
Expand Down
24 changes: 16 additions & 8 deletions docs/reference/python-sdk/scaling.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -21,29 +21,37 @@ 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)

<ParamField body="memory_mb" type="int" required>
Target memory in MB.
<ParamField body="memory_mb" type="int">
Target memory in MB (allowed tiers: 1024, 4096, 8192, 16384, 32768, 65536). CPU scales proportionally.
</ParamField>

**Returns:** `dict` with `sandboxID`, `memoryMB`, `cpuPercent`.
<ParamField body="disk_mb" type="int">
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.
</ParamField>

**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

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:
Expand Down
21 changes: 14 additions & 7 deletions docs/reference/typescript-sdk/scaling.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -23,27 +23,34 @@ 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)

<ParamField body="memoryMB" type="number" required>
Target memory in MB.
<ParamField body="memoryMB" type="number">
Target memory in MB (allowed tiers: 1024, 4096, 8192, 16384, 32768, 65536). CPU scales proportionally.
</ParamField>

**Returns:** `Promise<{ sandboxID: string; memoryMB: number; cpuPercent: number }>`
<ParamField body="diskMB" type="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.
</ParamField>

**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";

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");
Expand Down
38 changes: 31 additions & 7 deletions docs/sandboxes/elasticity.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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.

<CodeGroup>
```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}'
```
</CodeGroup>

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
Expand Down
3 changes: 2 additions & 1 deletion docs/sandboxes/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,8 @@ curl -X POST https://app.opencomputer.dev/api/sandboxes \
<Info>
**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.
Expand Down
Loading
Loading