diff --git a/AGENTS.md b/AGENTS.md index edec327..3f68aa3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ object is a sandbox host. ```text src/drukbox_sdk/ - api.py # SandboxAPI, records, parsers, HTTP request handling + api.py # SandboxAPI, records (SandboxHost, Secret, Issuer, ...), HTTP request handling exceptions.py # SDK exception hierarchy __init__.py # Public exports tests/ diff --git a/README.md b/README.md index 5d58435..fb2597d 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,8 @@ Public exports live in `drukbox_sdk`: - `SandboxAPI` - `SandboxHost` - `SandboxTemplate` +- `Secret` +- `Issuer` - `DoctorReport` and `DoctorCheck` - `HTTPProxy` and `HTTPProxyAttachment` - `SandboxAPIError` and typed subclasses for auth, not found, conflict, @@ -75,13 +77,39 @@ Supported host operations: - `aclose` `create_host` supports the service's optional `image`, `env`, `expires_at`, -`permanent`, `provider`, `instance_type`, `disk_gb`, `template`, and -`Idempotency-Key` inputs. `instance_type` (provider-native size, e.g. +`permanent`, `provider`, `instance_type`, `disk_gb`, `template`, `secrets`, +and `Idempotency-Key` inputs. `instance_type` (provider-native size, e.g. `t3.xlarge` / `cx33`) and `disk_gb` pin the VM shape; omit either for the provider default. Omit `expires_at` for the default lease, pass a datetime for an explicit expiry, or pass `permanent=True` for a never-reaped host. `template` accepts a template ID. An explicit `image` wins over it. +`secrets` gives the box credentials it never sees. Each entry is a `Secret`, +a value the caller holds, or an `Issuer`, a URL Drukbox fetches the value +from. The box gets a placeholder per entry in the service's variable, and the +Drukbox swaps it on the way out. A custom service names its `host` and +`auth_variable`: + +```python +from drukbox_sdk import Issuer, Secret + +host = await sandbox.create_host( + secrets={ + "anthropic": Issuer( + "https://mint.example/box-1/anthropic", + headers={"Authorization": "Bearer ..."}, + refresh="1h", + ), + "openai": Secret("sk-..."), + "acme": Secret("ak_...", host="api.acme.test", auth_variable="ACME_TOKEN"), + }, +) +``` + +The response carries no secret. A service name Drukbox does not know raises +`SandboxValidationError`. A deployment without the secrets proxy raises +`SandboxConflictError`. + `renew_host` extends a host's lease via `POST /hosts/{id}/renew`. Omit `expires_at` to extend by the service's default TTL; renewal never makes a host permanent. diff --git a/pyproject.toml b/pyproject.toml index c6c6f6f..48fc5c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "drukbox-python-sdk" -version = "0.1.0" +version = "0.2.0" description = "Async Python client for Drukbox." readme = "README.md" license = "MIT" diff --git a/src/drukbox_sdk/__init__.py b/src/drukbox_sdk/__init__.py index 68950d0..0fee9e5 100644 --- a/src/drukbox_sdk/__init__.py +++ b/src/drukbox_sdk/__init__.py @@ -9,9 +9,11 @@ DoctorReport, HTTPProxy, HTTPProxyAttachment, + Issuer, SandboxAPI, SandboxHost, SandboxTemplate, + Secret, ) from .exceptions import ( SandboxAPIError, @@ -29,6 +31,7 @@ "DoctorReport", "HTTPProxy", "HTTPProxyAttachment", + "Issuer", "SandboxAPI", "SandboxAPIError", "SandboxAuthError", @@ -40,4 +43,5 @@ "SandboxTemplate", "SandboxUnavailableError", "SandboxValidationError", + "Secret", ] diff --git a/src/drukbox_sdk/api.py b/src/drukbox_sdk/api.py index 25da261..1ea1fe5 100644 --- a/src/drukbox_sdk/api.py +++ b/src/drukbox_sdk/api.py @@ -39,7 +39,7 @@ import asyncio import os import uuid -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime from typing import Any, Self @@ -108,6 +108,50 @@ class SandboxHost: expires_at: str | None +@dataclass(frozen=True, kw_only=True) +class _Service: + """The custom service fields of a secret. None is unset. An empty + ``auth_prefix`` is set, and Drukbox reads it as no prefix.""" + + host: str | None = None + auth_variable: str | None = None + auth_header: str | None = None + auth_prefix: str | None = None + + def get_service(self) -> dict[str, Any]: + fields = { + "host": self.host, + "auth_variable": self.auth_variable, + "auth_header": self.auth_header, + "auth_prefix": self.auth_prefix, + } + return {name: value for name, value in fields.items() if value is not None} + + +@dataclass(frozen=True) +class Secret(_Service): + """A credential the caller holds.""" + + value: str = field(repr=False) + + def entry(self) -> dict[str, Any]: + return {**self.get_service(), "value": self.value} + + +@dataclass(frozen=True) +class Issuer(_Service): + """A credential Drukbox fetches from ``url`` with ``headers``, and again + every ``refresh`` when the answer carries no ``expires_at``.""" + + url: str + headers: dict[str, str] = field(repr=False) + refresh: str + + def entry(self) -> dict[str, Any]: + issuer = {"url": self.url, "headers": dict(self.headers), "refresh": self.refresh} + return {**self.get_service(), "issuer": issuer} + + @dataclass(frozen=True) class SandboxTemplate: """Snapshot of a reusable sandbox template as returned by the service. @@ -240,6 +284,7 @@ async def create_host( instance_type: str | None = None, permanent: bool = False, provider: str | None = None, + secrets: dict[str, Secret | Issuer] | None = None, template: uuid.UUID | str | None = None, ) -> SandboxHost: """Provision a new host. @@ -274,6 +319,9 @@ async def create_host( Lease: omit ``expires_at`` for the service's default lease, pass a ``datetime`` for an explicit expiry, or pass ``permanent=True`` for a host the janitor never reaps. + + ``secrets`` maps a service name to a :class:`Secret` or an + :class:`Issuer`. The response carries no secret. """ if expires_at and permanent: @@ -295,6 +343,8 @@ async def create_host( payload["instance_type"] = instance_type if provider is not None: payload["provider"] = provider + if secrets: + payload["secrets"] = {name: entry.entry() for name, entry in secrets.items()} if template is not None: payload["template"] = str(template) diff --git a/src/drukbox_sdk/exceptions.py b/src/drukbox_sdk/exceptions.py index 0be05ec..0bcf1fc 100644 --- a/src/drukbox_sdk/exceptions.py +++ b/src/drukbox_sdk/exceptions.py @@ -37,8 +37,8 @@ class SandboxNotFoundError(SandboxAPIError): class SandboxConflictError(SandboxAPIError): """409 — the operation conflicts with the current state. - e.g. provisioning a Linux user on a host that already has one with - the same name. + e.g. a template that is still building, or secrets on a deployment + without the secrets proxy configured. """ @@ -53,9 +53,10 @@ class SandboxProvisioningError(SandboxAPIError): class SandboxValidationError(SandboxAPIError): """422 — the service rejected the request payload as invalid. - e.g. an env key that isn't a valid environment-variable name, or a - proxy target URL carrying a path or credentials. The request is - malformed, so retrying it unchanged won't help — fix the input. + e.g. an env key that isn't a valid environment-variable name, a + proxy target URL carrying a path or credentials, or a secret for a + service name Drukbox does not know. The request is malformed, so + retrying it unchanged won't help — fix the input. """ diff --git a/tests/test_api.py b/tests/test_api.py index c867f04..f0d94aa 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -12,6 +12,7 @@ # pyright: reportPrivateUsage=false import asyncio +import json from collections.abc import AsyncGenerator from datetime import UTC, datetime from typing import Any @@ -24,6 +25,7 @@ from drukbox_sdk import ( HTTPProxy, HTTPProxyAttachment, + Issuer, SandboxAPI, SandboxAuthError, SandboxConflictError, @@ -32,6 +34,7 @@ SandboxResponseError, SandboxUnavailableError, SandboxValidationError, + Secret, ) BASE_URL = "https://sandbox.test" @@ -268,6 +271,115 @@ async def test_create_host_409_when_template_is_building(api: SandboxAPI): await api.create_host(template=template_id) +@respx.mock +async def test_create_host_sends_secrets_with_only_the_fields_set(api: SandboxAPI): + route = respx.post(f"{BASE_URL}/hosts").mock( + return_value=httpx.Response(201, json=_host_payload()), + ) + + await api.create_host( + secrets={ + "anthropic": Issuer( + "https://mint.test/box-1/anthropic", + headers={"Authorization": "Bearer d2d"}, + refresh="1h", + ), + "openai": Secret("sk-real"), + "acme": Secret("ak_real", host="api.acme.test", auth_variable="ACME_TOKEN"), + }, + ) + + body = json.loads(route.calls.last.request.content) + assert body["secrets"] == { + "anthropic": { + "issuer": { + "url": "https://mint.test/box-1/anthropic", + "headers": {"Authorization": "Bearer d2d"}, + "refresh": "1h", + } + }, + "openai": {"value": "sk-real"}, + "acme": {"value": "ak_real", "host": "api.acme.test", "auth_variable": "ACME_TOKEN"}, + } + + +@respx.mock +async def test_create_host_omits_secrets_when_unset(api: SandboxAPI): + route = respx.post(f"{BASE_URL}/hosts").mock( + return_value=httpx.Response(201, json=_host_payload()), + ) + + await api.create_host() + + assert "secrets" not in json.loads(route.calls.last.request.content) + + +@respx.mock +async def test_create_host_keeps_an_empty_auth_prefix(api: SandboxAPI): + route = respx.post(f"{BASE_URL}/hosts").mock( + return_value=httpx.Response(201, json=_host_payload()), + ) + custom = {"host": "api.acme.test", "auth_variable": "ACME_TOKEN", "auth_prefix": ""} + + await api.create_host( + secrets={ + "acme": Secret("ak_real", **custom), + "acme-minted": Issuer("https://mint.test/acme", {"X-Key": "k"}, "1h", **custom), + }, + ) + + body = json.loads(route.calls.last.request.content) + assert body["secrets"]["acme"]["auth_prefix"] == "" + assert body["secrets"]["acme-minted"]["auth_prefix"] == "" + + +def test_a_record_never_shows_its_credential(): + secret = Secret("VALUE_MARKER", host="api.acme.test", auth_variable="ACME_TOKEN") + issuer = Issuer("https://mint.test/acme", {"Authorization": "ISSUER_MARKER"}, "1h") + + shown = repr({"acme": secret, "minted": issuer}) + + assert "VALUE_MARKER" not in shown + assert "ISSUER_MARKER" not in shown + assert "api.acme.test" in shown and "https://mint.test/acme" in shown + + +def test_the_custom_service_fields_are_keywords(): + with pytest.raises(TypeError): + Secret("ak_real", "api.acme.test") # type: ignore[misc] + with pytest.raises(TypeError): + Issuer("https://mint.test", {"X-Key": "k"}, "1h", "api.acme.test") # type: ignore[misc] + + +@respx.mock +async def test_create_host_409_without_the_secrets_proxy_raises_conflict(api: SandboxAPI): + respx.post(f"{BASE_URL}/hosts").mock( + return_value=httpx.Response( + 409, + json={ + "detail": "secrets need the secrets proxy, which this deployment lacks", + "error_code": "SECRETS_PROXY_NOT_CONFIGURED", + }, + ), + ) + + with pytest.raises(SandboxConflictError, match="secrets proxy"): + await api.create_host(secrets={"openai": Secret("sk-real")}) + + +@respx.mock +async def test_create_host_422_for_an_unknown_secret_service_raises_validation(api: SandboxAPI): + respx.post(f"{BASE_URL}/hosts").mock( + return_value=httpx.Response( + 422, + json={"detail": [{"loc": ["body", "secrets"], "msg": "unknown secret service 'acme'"}]}, + ), + ) + + with pytest.raises(SandboxValidationError, match="unknown secret service"): + await api.create_host(secrets={"acme": Secret("ak_real")}) + + @respx.mock async def test_get_host_returns_parsed_host(api: SandboxAPI): payload = _host_payload(status="active")