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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ Versions follow [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Added

- Sandbox labels on create and response models, AND-matched label filtering in
`list_sandboxes()`, and label merging through `SandboxInstance.set_labels()`.

### Fixed

- Redact plaintext sandbox access tokens from response representations to
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,27 @@ Timeout and retry delays are in seconds.
As an alternative, `Client()` reads `CREATEOS_API_KEY` and
`CREATEOS_SANDBOX_BASE_URL`. Explicit constructor arguments take precedence.

## Organize sandboxes with labels

```python
from createos import CreateSandboxRequest, ListSandboxesOptions

sandbox = client.create_sandbox(
CreateSandboxRequest(
shape="s-1vcpu-1gb",
rootfs="alpine:3.20",
labels={"team": "backend", "environment": "test"},
)
)
matches = client.list_sandboxes(
ListSandboxesOptions(labels={"team": "backend"})
)
sandbox.set_labels({"environment": "production"})
```

List filters use AND semantics. `set_labels()` merges the supplied keys into
the existing label map.

## Delegate access to one sandbox

The owner can create one token for a sandbox. Creation and rotation return the
Expand Down
6 changes: 6 additions & 0 deletions src/createos/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import json
import os
from urllib.parse import quote

Expand Down Expand Up @@ -142,6 +143,7 @@ def create_sandbox(
ingress_enabled=request.ingress_enabled,
ingress_url_template=created.ingress_url_template,
name=created.name,
labels=created.labels,
spawn_milliseconds=created.spawn_milliseconds,
shape=created.shape,
rootfs=created.rootfs,
Expand Down Expand Up @@ -169,6 +171,10 @@ def list_sandboxes(
"""Return sandbox handles visible to the caller."""
options = options or ListSandboxesOptions()
params = {"status": str(options.status)} if options.status else {}
if options.labels:
params["labels"] = json.dumps(
options.labels, separators=(",", ":"), sort_keys=True
)
items: list[Sandbox] = []
offset = 0
while options.limit <= 0 or len(items) < options.limit:
Expand Down
8 changes: 8 additions & 0 deletions src/createos/instance.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,14 @@ def set_ingress(self, enabled: bool) -> SandboxInstance:
self._update(_one(Sandbox, data))
return self

def set_labels(self, labels: dict[str, str]) -> SandboxInstance:
"""Merge labels into the sandbox's existing labels."""
data = self._transport.request(
"PATCH", self._path(""), body={"labels": labels}
)
self._update(_one(Sandbox, data))
return self

def set_auto_pause(self, timeout: float | None) -> SandboxInstance:
"""Set the idle timeout in seconds, or disable it with ``None``."""
body: dict[str, bool | int]
Expand Down
6 changes: 6 additions & 0 deletions src/createos/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,9 @@ class CreateSandboxRequest(Model):
shape: str = ""
rootfs: str = _json("rootfs", omit_default=True, default="")
name: str = _json("name", omit_default=True, default="")
labels: dict[str, str] = _json(
"labels", omit_default=True, default_factory=dict
)
networks: list[NetworkEntry] = _json(
"networks", omit_default=True, default_factory=list
)
Expand Down Expand Up @@ -430,6 +433,7 @@ class RequestOptions:
class ListSandboxesOptions(RequestOptions):
limit: int = 0
status: SandboxStatus | None = None
labels: dict[str, str] = field(default_factory=dict)


@dataclass(slots=True)
Expand Down Expand Up @@ -547,6 +551,7 @@ class CreateSandboxResponse(Model):
id: str
status: SandboxStatus
name: str | None = None
labels: dict[str, str] = field(default_factory=dict)
ip_address: str = _json("ip", default="")
shape: str = ""
rootfs: str | None = None
Expand All @@ -571,6 +576,7 @@ class Sandbox(Model):
ingress_enabled: bool = False
ingress_url_template: str = ""
name: str | None = None
labels: dict[str, str] = field(default_factory=dict)
running_at: datetime | None = None
destroyed_at: datetime | None = None
spawn_milliseconds: float = _json("spawn_ms", default=0.0)
Expand Down
48 changes: 46 additions & 2 deletions tests/test_sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,13 @@
CreateSandboxRequest,
ExecStreamEventType,
ForkSandboxRequest,
ListSandboxesOptions,
ManagedProcessConnectEvent,
ProtocolError,
RequestOptions,
RunCommandRequest,
Sandbox,
SandboxInstance,
SandboxStatus,
)

Expand Down Expand Up @@ -218,6 +221,7 @@ def handler(request):
"id": "sb-1",
"status": "paused",
"name": "demo",
"labels": {"team": "backend"},
"ip": "10.0.0.2",
"shape": "s-4vcpu-4gb",
"mem_mib": 4096,
Expand All @@ -233,12 +237,17 @@ def handler(request):
)
sandbox = client.create_sandbox(
CreateSandboxRequest(
shape="s-4vcpu-4gb", name="demo", ingress_enabled=True
shape="s-4vcpu-4gb",
name="demo",
labels={"team": "backend"},
ingress_enabled=True,
)
)
assert requests[0].url.path == "/api/v1/sandboxes"
assert json.loads(requests[0].content)["labels"] == {"team": "backend"}
assert sandbox.id == "sb-1"
assert sandbox.status is SandboxStatus.PAUSED
assert sandbox.data.labels == {"team": "backend"}
assert sandbox.files and sandbox.processes and sandbox.computer.mouse
assert sandbox.preview_url(8080) == "https://8080-sb-1.example.test"

Expand Down Expand Up @@ -339,6 +348,10 @@ def test_list_sandboxes_walks_pages():
offsets = []

def handler(request):
assert (
request.url.params["labels"]
== '{"environment":"test","team":"backend"}'
)
offset = int(request.url.params["offset"])
offsets.append(offset)
rows = [
Expand All @@ -361,11 +374,42 @@ def handler(request):
base_url="https://example.test",
http_client=mock_client(handler),
)
sandboxes = client.list_sandboxes()
sandboxes = client.list_sandboxes(
ListSandboxesOptions(labels={"team": "backend", "environment": "test"})
)
assert [item.id for item in sandboxes] == ["sb-0", "sb-1", "sb-2"]
assert offsets == [0, 2]


def test_set_labels_patches_and_updates_state():
def handler(request):
assert request.method == "PATCH"
assert json.loads(request.content) == {"labels": {"team": "platform"}}
return envelope(
{
"id": "sb-1",
"status": "running",
"labels": {"environment": "test", "team": "platform"},
}
)

client = Client(
api_key="key",
base_url="https://example.test",
http_client=mock_client(handler),
)
sandbox = SandboxInstance(
client._transport,
Sandbox(id="sb-1", status=SandboxStatus.RUNNING),
)

sandbox.set_labels({"team": "platform"})
assert sandbox.data.labels == {
"environment": "test",
"team": "platform",
}


def test_command_stream_projects_frames_and_accepts_sse():
payload = (
b': keepalive\ndata: {"stdout":"hi","stderr":"warn"}\n{"exit_code":0}\n'
Expand Down
Loading