diff --git a/CHANGELOG.md b/CHANGELOG.md index b28a011..56c486d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 9e6c794..b8244a4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/createos/client.py b/src/createos/client.py index fd50ada..d67d8fa 100644 --- a/src/createos/client.py +++ b/src/createos/client.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import os from urllib.parse import quote @@ -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, @@ -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: diff --git a/src/createos/instance.py b/src/createos/instance.py index 5ceec36..a35baae 100644 --- a/src/createos/instance.py +++ b/src/createos/instance.py @@ -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] diff --git a/src/createos/models.py b/src/createos/models.py index 16ce737..c235cc3 100644 --- a/src/createos/models.py +++ b/src/createos/models.py @@ -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 ) @@ -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) @@ -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 @@ -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) diff --git a/tests/test_sdk.py b/tests/test_sdk.py index 58a2f8f..4a6b240 100644 --- a/tests/test_sdk.py +++ b/tests/test_sdk.py @@ -13,10 +13,13 @@ CreateSandboxRequest, ExecStreamEventType, ForkSandboxRequest, + ListSandboxesOptions, ManagedProcessConnectEvent, ProtocolError, RequestOptions, RunCommandRequest, + Sandbox, + SandboxInstance, SandboxStatus, ) @@ -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, @@ -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" @@ -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 = [ @@ -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'