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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
32 changes: 30 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions src/drukbox_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@
DoctorReport,
HTTPProxy,
HTTPProxyAttachment,
Issuer,
SandboxAPI,
SandboxHost,
SandboxTemplate,
Secret,
)
from .exceptions import (
SandboxAPIError,
Expand All @@ -29,6 +31,7 @@
"DoctorReport",
"HTTPProxy",
"HTTPProxyAttachment",
"Issuer",
"SandboxAPI",
"SandboxAPIError",
"SandboxAuthError",
Expand All @@ -40,4 +43,5 @@
"SandboxTemplate",
"SandboxUnavailableError",
"SandboxValidationError",
"Secret",
]
52 changes: 51 additions & 1 deletion src/drukbox_sdk/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

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

Expand Down
11 changes: 6 additions & 5 deletions src/drukbox_sdk/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""


Expand All @@ -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.
"""


Expand Down
112 changes: 112 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,6 +25,7 @@
from drukbox_sdk import (
HTTPProxy,
HTTPProxyAttachment,
Issuer,
SandboxAPI,
SandboxAuthError,
SandboxConflictError,
Expand All @@ -32,6 +34,7 @@
SandboxResponseError,
SandboxUnavailableError,
SandboxValidationError,
Secret,
)

BASE_URL = "https://sandbox.test"
Expand Down Expand Up @@ -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")
Expand Down
Loading