Skip to content
Merged
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
8 changes: 7 additions & 1 deletion packages/prime-sandboxes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,15 +181,21 @@ from prime_sandboxes import APIClient, SandboxClient

volumes = SandboxClient(APIClient()).volumes
volume = volumes.create("dataset", team_id="team-id")
volume = volumes.create("dataset", size_gb=512) # 100 GiB when omitted
page = volumes.list(team_id="team-id", page=1, per_page=50)
volume = volumes.get("dataset", team_id="team-id")
volume = volumes.grow("dataset", 512)
volumes.delete("dataset", team_id="team-id") # 204, returns None
```

Omit `team_id` (and the team in the client config) to work in the personal
scope instead. `volumes` is a namespace on the sandbox client: it reaches
the platform through the client's own transport and owns no transport or
lifecycle of its own.
lifecycle of its own. `size_gb` is the volume's capacity in whole GiB;
`Volume.size_gb` is null when the volume uses the default base-image size.
`grow` is grow-only: the server rejects a size at or below the current
effective size, and the new size applies to sandboxes created after the
change — running attachments keep their size.

Mount volumes when creating a sandbox. Each mount names the volume and the
absolute guest path to mount it at; a read-only mount cannot publish:
Expand Down
3 changes: 3 additions & 0 deletions packages/prime-sandboxes/src/prime_sandboxes/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,9 @@ class Volume(BaseModel):
name: str
user_id: Optional[str] = None
team_id: Optional[str] = None
# Capacity in whole GiB (100 GiB unless set at create or grown via
# resize). Absent on responses from servers older than the field.
size_gb: Optional[int] = None
# Newest durable publish, or null for a volume that was never published.
# Older responses may omit the field entirely.
head_checkpoint_id: Optional[str] = None
Expand Down
56 changes: 52 additions & 4 deletions packages/prime-sandboxes/src/prime_sandboxes/volumes.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,20 @@ class Volumes:
def __init__(self, parent: "SandboxClient"):
self._parent = parent

def create(self, name: str, *, team_id: Optional[str] = None) -> Volume:
"""Create an empty volume in the caller's personal scope or in a team."""
def create(
self, name: str, *, team_id: Optional[str] = None, size_gb: Optional[int] = None
) -> Volume:
"""Create an empty volume in the caller's personal scope or in a team.

``size_gb`` is the requested capacity in whole GiB; omitted means
the base image size (100 GiB).
"""
payload: dict[str, object] = {"name": name}
resolved_team_id = _resolved_team_id(self._parent.client, team_id)
if resolved_team_id:
payload["team_id"] = resolved_team_id
if size_gb is not None:
payload["size_gb"] = size_gb
response = self._parent.client.request("POST", "/sandbox/volumes", json=payload)
return Volume.model_validate(response)

Expand Down Expand Up @@ -93,6 +101,22 @@ def delete(self, name: str, *, team_id: Optional[str] = None) -> None:
params=_name_params(self._parent.client, team_id),
)

def grow(self, name: str, size_gb: int, *, team_id: Optional[str] = None) -> Volume:
"""Grow a volume's capacity to ``size_gb`` whole GiB.

Grow-only: the server rejects a size at or below the volume's
current effective size (its set size, or the 100 GiB base image
size when unset). The new size applies to sandboxes created after
the change; running attachments keep their current size.
"""
response = self._parent.client.request(
"PATCH",
_volume_path(name),
json={"size_gb": size_gb},
params=_name_params(self._parent.client, team_id),
)
return Volume.model_validate(response)

def publish(
self, sandbox_id: str, name: str, force: bool = False, wait: bool = True
) -> SandboxCheckpoint:
Expand Down Expand Up @@ -127,12 +151,20 @@ class AsyncVolumes:
def __init__(self, parent: "AsyncSandboxClient"):
self._parent = parent

async def create(self, name: str, *, team_id: Optional[str] = None) -> Volume:
"""Create an empty volume in the caller's personal scope or in a team."""
async def create(
self, name: str, *, team_id: Optional[str] = None, size_gb: Optional[int] = None
) -> Volume:
"""Create an empty volume in the caller's personal scope or in a team.

``size_gb`` is the requested capacity in whole GiB; omitted means
the base image size (100 GiB).
"""
payload: dict[str, object] = {"name": name}
resolved_team_id = _resolved_team_id(self._parent.client, team_id)
if resolved_team_id:
payload["team_id"] = resolved_team_id
if size_gb is not None:
payload["size_gb"] = size_gb
response = await self._parent.client.request("POST", "/sandbox/volumes", json=payload)
return Volume.model_validate(response)

Expand Down Expand Up @@ -173,6 +205,22 @@ async def delete(self, name: str, *, team_id: Optional[str] = None) -> None:
params=_name_params(self._parent.client, team_id),
)

async def grow(self, name: str, size_gb: int, *, team_id: Optional[str] = None) -> Volume:
"""Grow a volume's capacity to ``size_gb`` whole GiB.

Grow-only: the server rejects a size at or below the volume's
current effective size (its set size, or the 100 GiB base image
size when unset). The new size applies to sandboxes created after
the change; running attachments keep their current size.
"""
response = await self._parent.client.request(
"PATCH",
_volume_path(name),
json={"size_gb": size_gb},
params=_name_params(self._parent.client, team_id),
)
return Volume.model_validate(response)

async def publish(
self, sandbox_id: str, name: str, force: bool = False, wait: bool = True
) -> SandboxCheckpoint:
Expand Down
79 changes: 79 additions & 0 deletions packages/prime-sandboxes/tests/test_volumes_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,24 @@ def test_volume_client_create_personal_scope_omits_team_id():
assert captured["json"] == {"name": "dataset"}


def test_volume_client_create_sends_requested_size_gb():
captured: dict[str, Any] = {}
volumes = _volumes(_volume_response(), captured)

volumes.create("dataset", size_gb=512)

assert captured["json"] == {"name": "dataset", "size_gb": 512}


def test_volume_client_create_omits_size_gb_when_unset():
captured: dict[str, Any] = {}
volumes = _volumes(_volume_response(), captured)

volumes.create("dataset")

assert captured["json"] == {"name": "dataset"}


def test_volume_client_list_forwards_pagination_and_explicit_team():
captured: dict[str, Any] = {}
response_body = _volume_list_response()
Expand Down Expand Up @@ -214,6 +232,40 @@ def test_volume_client_delete_personal_scope_omits_team_id():
assert captured["params"] == {}


def test_volume_client_grow_sends_patch_with_size_gb():
captured: dict[str, Any] = {}
volumes = _volumes(_volume_response(size_gb=512), captured, team_id="team-configured")

volume = volumes.grow("dataset", 512, team_id="team-explicit")

assert captured == {
"method": "PATCH",
"path": "/sandbox/volumes/dataset",
"json": {"size_gb": 512},
"params": {"team_id": "team-explicit"},
}
assert isinstance(volume, Volume)
assert volume.size_gb == 512


def test_volume_client_grow_uses_configured_team_by_default():
captured: dict[str, Any] = {}
volumes = _volumes(_volume_response(size_gb=512), captured, team_id="team-configured")

volumes.grow("dataset", 512)

assert captured["params"] == {"team_id": "team-configured"}


def test_volume_client_grow_quotes_names_outside_the_valid_character_set():
captured: dict[str, Any] = {}
volumes = _volumes(_volume_response(), captured)

volumes.grow("a/b", 512)

assert captured["path"] == "/sandbox/volumes/a%2Fb"


def test_async_volume_namespace_is_cached_on_the_parent_client():
async def scenario() -> None:
async with AsyncSandboxClient(api_key="test-key") as client:
Expand Down Expand Up @@ -252,6 +304,24 @@ async def request(
assert volume.id == "cuid-1"
assert captured["json"] == {"name": "dataset", "team_id": "team-explicit"}

response = _volume_response(size_gb=512)
grown = await client.volumes.create("dataset", team_id="team-explicit", size_gb=512)
assert isinstance(grown, Volume)
assert captured["json"] == {
"name": "dataset",
"team_id": "team-explicit",
"size_gb": 512,
}

response = _volume_response(size_gb=512)
grown = await client.volumes.grow("dataset", 512, team_id="team-explicit")
assert isinstance(grown, Volume)
assert grown.size_gb == 512
assert captured["method"] == "PATCH"
assert captured["path"] == "/sandbox/volumes/dataset"
assert captured["json"] == {"size_gb": 512}
assert captured["params"] == {"team_id": "team-explicit"}

response = _volume_list_response()
page = await client.volumes.list(page=2, per_page=25)
assert isinstance(page, VolumeListResponse)
Expand Down Expand Up @@ -623,6 +693,15 @@ def test_volume_model_parses_head_checkpoint_id():
assert response.volumes[0].head_checkpoint_id is None


def test_volume_model_parses_size_gb():
volume = Volume.model_validate(_volume_response(size_gb=512))
assert volume.size_gb == 512

# Older responses omit the field entirely; null means the base image size.
assert Volume.model_validate(_volume_response()).size_gb is None
assert VolumeListResponse.model_validate(_volume_list_response()).volumes[0].size_gb is None


def test_volume_model_parses_storage_fields():
volume = Volume.model_validate(
_volume_response(
Expand Down
3 changes: 2 additions & 1 deletion packages/prime/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,10 +208,11 @@ prime sandbox download <sandbox-id> /remote/file.txt ./local/
# Attach persistent volumes (repeatable; :ro mounts read-only)
prime sandbox create --volume data:/data --volume cache:/mnt:ro python:3.11

# Manage volumes
# Manage volumes (create takes --size-gb; default capacity is 100 GiB)
prime sandbox volumes create data
prime sandbox volumes list [--page N] [--num N] [--json]
prime sandbox volumes get data
prime sandbox volumes grow data --size-gb 512
prime sandbox volumes delete data

# Publish a sandbox's private volume writes as a durable checkpoint
Expand Down
52 changes: 47 additions & 5 deletions packages/prime/src/prime_cli/commands/sandbox_volumes.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@


VOLUMES_LIST_JSON_HELP = json_output_help(
".volumes[] = {id, name, user_id?, team_id?, created_at, occupied_bytes}",
".volumes[] = {id, name, user_id?, team_id?, size_gb?, created_at, occupied_bytes}",
".total = number",
".page = number",
".per_page = number",
Expand Down Expand Up @@ -104,13 +104,18 @@ def _print_account_storage(used_bytes: int, limit_gb: Optional[int]) -> None:
console.print(line)


def _volume_capacity(volume: Volume) -> str:
return f"{volume.size_gb} GiB" if volume.size_gb is not None else "-"


def _print_volume(volume: Volume) -> None:
console.print(f"Name: [cyan]{escape(volume.name)}[/cyan]")
console.print(f"ID: {escape(volume.id)}")
console.print(f"Scope: {_volume_scope(volume)}")
# None until the first publish reaches DURABLE, then the newest one.
head = volume.head_checkpoint_id
console.print(f"Head checkpoint: {escape(head) if head else 'none (never published)'}")
console.print(f"Capacity: {_volume_capacity(volume)}")
console.print(f"Size: {format_file_size(volume.occupied_bytes)}")
if volume.account_storage_used_bytes is not None:
_print_account_storage(volume.account_storage_used_bytes, volume.account_storage_limit_gb)
Expand Down Expand Up @@ -201,7 +206,9 @@ def _print_retained_importer(
)


def _import_volume(name: str, team_id: Optional[str], from_path: str) -> None:
def _import_volume(
name: str, team_id: Optional[str], from_path: str, size_gb: Optional[int]
) -> None:
# Absolute before rsync: a relative source would be ambiguous in the rsync
# argv (rsync reads bare words as remote paths, not only the dest).
path = Path(from_path).expanduser().resolve()
Expand All @@ -224,7 +231,7 @@ def _import_volume(name: str, team_id: Optional[str], from_path: str) -> None:
)

try:
sandbox_client.volumes.create(name, team_id=team_id)
sandbox_client.volumes.create(name, team_id=team_id, size_gb=size_gb)
except APIError as error:
# Prefix match on the SDK's exact format ("HTTP <status>: <body>"):
# a detail string that merely mentions "HTTP 409" is not a conflict.
Expand Down Expand Up @@ -346,15 +353,18 @@ def create(
from_path: Optional[str] = typer.Option(
None, "--from", help="Import this local directory into the new volume"
),
size_gb: Optional[int] = typer.Option(
None, "--size-gb", help="Volume capacity in GiB (default: 100)"
),
) -> None:
"""Create a volume, optionally importing a local directory."""
try:
if from_path is None:
volume = _sandbox_client().volumes.create(name, team_id=team_id)
volume = _sandbox_client().volumes.create(name, team_id=team_id, size_gb=size_gb)
console.print(f"[green]✓[/green] Created volume {escape(volume.name)}")
_print_volume(volume)
return
_import_volume(name, team_id, from_path)
_import_volume(name, team_id, from_path, size_gb)
except typer.Exit:
raise
except KeyboardInterrupt:
Expand Down Expand Up @@ -428,6 +438,7 @@ def list_volumes(
("Name", "cyan"),
("Scope", "white"),
("Size", "white"),
("Capacity", "white"),
("Created", "white"),
("ID", "dim"),
],
Expand All @@ -437,6 +448,7 @@ def list_volumes(
escape(volume.name),
_volume_scope(volume),
format_file_size(volume.occupied_bytes),
_volume_capacity(volume),
human_age(volume.created_at),
escape(volume.id),
)
Expand Down Expand Up @@ -500,6 +512,36 @@ def delete(
console.print(f"[green]✓[/green] Deleted volume {escape(name)}")


@app.command()
def grow(
name: str = typer.Argument(..., help="Volume name"),
size_gb: int = typer.Option(
..., "--size-gb", help="New capacity in GiB; must be larger than the current size"
),
team_id: Optional[str] = typer.Option(
None, help="Team ID (uses config team_id if not specified)"
),
) -> None:
"""Grow a volume's capacity. Applies to sandboxes created after the change."""
try:
volume = _sandbox_client().volumes.grow(name, size_gb, team_id=team_id)
except UnauthorizedError as e:
console.print(f"[red]Unauthorized:[/red] {escape(str(e))}")
raise typer.Exit(1)
except PaymentRequiredError as e:
console.print(f"[red]Payment Required:[/red] {escape(str(e))}")
raise typer.Exit(1)
except APIError as e:
console.print(f"[red]Error:[/red] {escape(str(e))}")
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]Unexpected error:[/red] {escape(str(e))}")
console.print_exception(show_locals=False)
raise typer.Exit(1)
console.print(f"[green]✓[/green] Grew volume {escape(name)} to {size_gb} GiB")
_print_volume(volume)


@app.command()
def publish(
name: str = typer.Argument(..., help="Volume name to publish"),
Expand Down
Loading
Loading