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
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ Public exports live in `drukbox_sdk`:
- `SandboxTemplate`
- `Secret`
- `Issuer`
- `ServiceAccount`
- `DoctorReport` and `DoctorCheck`
- `HTTPProxy` and `HTTPProxyAttachment`
- `SandboxAPIError` and typed subclasses for auth, not found, conflict,
Expand All @@ -73,6 +74,7 @@ Supported host operations:
- `list_hosts`
- `renew_host`
- `delete_host`
- `refresh_secret`
- `doctor`
- `aclose`

Expand Down Expand Up @@ -114,6 +116,24 @@ The response carries no secret. A service name Drukbox does not know raises
`expires_at` to extend by the service's default TTL; renewal never makes a host
permanent.

`SandboxHost.service_account` names the service account that created or
claimed the host, `admin` for an admin key, or `None` for an unclaimed warm
host.

`refresh_secret` makes the service forget the current value of one secret on
a host and fetch a new one from its issuer. A static secret raises
`SandboxConflictError`; an issuer or exchange that did not answer raises
`SandboxUnavailableError`.

Supported service account operations, admin key only:

- `create_service_account`
- `remove_service_account`

`create_service_account` returns a `ServiceAccount` with the token the
service hands out once. Store it. `remove_service_account` revokes it on the
next request.

Supported template operations:

- `create_template`
Expand All @@ -140,7 +160,8 @@ proxy at a host's backing VM (the host must be `bootstrapping` or `active`).

`doctor` fetches `GET /doctor` — read-only dependency health. The service
runs one cheap, non-mutating probe per dependency (database, active VM
provider, Tailscale when enabled) and always responds 200, so callers branch
provider, secrets exchange, Tailscale when enabled) and always responds 200,
so callers branch
on `DoctorReport.ok` rather than the HTTP status. A failed `DoctorCheck`
carries a stable `hint` slug for remediation.

Expand Down
2 changes: 2 additions & 0 deletions src/drukbox_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
SandboxHost,
SandboxTemplate,
Secret,
ServiceAccount,
)
from .exceptions import (
SandboxAPIError,
Expand Down Expand Up @@ -44,4 +45,5 @@
"SandboxUnavailableError",
"SandboxValidationError",
"Secret",
"ServiceAccount",
]
47 changes: 47 additions & 0 deletions src/drukbox_sdk/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,15 @@ class SandboxHost:
different SSH auth model (exe.dev's edge proxy, Tailscale ACLs
via tailscaled-SSH) return None here. A later ``get_host`` call
always returns None — the key is not persisted server-side.

``service_account`` names the service account that created or claimed
the host, ``"admin"`` for an admin key, or None for an unclaimed warm
host.
"""

id: str
name: str
service_account: str | None
status: str
provider: str
image: str
Expand Down Expand Up @@ -196,6 +201,15 @@ class DoctorReport:
checks: list[DoctorCheck]


@dataclass(frozen=True)
class ServiceAccount:
"""A name the service knows a caller by, and the token that caller
presents. The service returns the token once, at creation."""

name: str
token: str = field(repr=False)


@dataclass(frozen=True)
class HTTPProxy:
"""An account-bound HTTP proxy as returned by the service.
Expand Down Expand Up @@ -401,6 +415,18 @@ async def renew_host(
async def delete_host(self, host_id: uuid.UUID | str) -> None:
await self._request("DELETE", f"/hosts/{host_id}")

async def refresh_secret(self, host_id: uuid.UUID | str, service: str) -> None:
"""Make the service forget the current value of ``service`` on the
host and fetch a new one from its issuer.

Raises :class:`SandboxNotFoundError` for an unknown host or secret,
:class:`SandboxConflictError` for a secret with a static value, and
:class:`SandboxUnavailableError` when the exchange or the issuer did
not answer.
"""

await self._request("POST", f"/hosts/{host_id}/secrets/{service}/refresh")

async def create_template(
self,
*,
Expand Down Expand Up @@ -466,6 +492,27 @@ async def doctor(self) -> DoctorReport:
checks=[DoctorCheck(**check) for check in data["checks"]],
)

async def create_service_account(self, name: str) -> ServiceAccount:
"""Create a service account and return its token.

Only an admin key from the service's ``SERVICE_TOKENS`` may call
this. A name that already exists raises
:class:`SandboxConflictError`; an invalid name raises
:class:`SandboxValidationError`.
"""

data = await self._request("POST", "/service-accounts", json={"name": name})
return ServiceAccount(**data)

async def remove_service_account(self, name: str) -> None:
"""Revoke a service account's token and remove the account.

An unknown name raises :class:`SandboxNotFoundError`; the ``admin``
account raises :class:`SandboxConflictError`.
"""

await self._request("DELETE", f"/service-accounts/{name}")

async def create_http_proxy(
self,
*,
Expand Down
79 changes: 79 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
SandboxUnavailableError,
SandboxValidationError,
Secret,
ServiceAccount,
)

BASE_URL = "https://sandbox.test"
Expand Down Expand Up @@ -81,6 +82,7 @@ def _host_payload(**overrides: Any) -> dict[str, Any]:
payload: dict[str, Any] = {
"id": str(uuid4()),
"name": "host-abc",
"service_account": "admin",
"status": "provisioning",
"provider": "exe",
"image": "ghcr.io/drukbox/sandbox:test",
Expand Down Expand Up @@ -421,6 +423,83 @@ async def test_list_hosts_parses_each_record(api: SandboxAPI):
assert {h.id for h in hosts} == {p["id"] for p in payloads}


@respx.mock
async def test_host_carries_its_service_account(api: SandboxAPI):
respx.get(f"{BASE_URL}/hosts/h1").mock(
return_value=httpx.Response(200, json=_host_payload(service_account="druks-eu"))
)

host = await api.get_host("h1")

assert host.service_account == "druks-eu"


@respx.mock
async def test_refresh_secret_posts_and_accepts_204(api: SandboxAPI):
route = respx.post(f"{BASE_URL}/hosts/h1/secrets/github/refresh").mock(
return_value=httpx.Response(204)
)

await api.refresh_secret("h1", "github")

assert route.called
assert not route.calls.last.request.content


@respx.mock
@pytest.mark.parametrize(
"status, error",
[
(404, SandboxNotFoundError),
(409, SandboxConflictError),
(503, SandboxUnavailableError),
],
)
async def test_refresh_secret_raises_the_typed_error(
api: SandboxAPI, status: int, error: type[Exception]
):
respx.post(f"{BASE_URL}/hosts/h1/secrets/github/refresh").mock(
return_value=httpx.Response(status, json={"detail": "no"})
)

with pytest.raises(error):
await api.refresh_secret("h1", "github")


@respx.mock
async def test_create_service_account_returns_the_token_once(api: SandboxAPI):
route = respx.post(f"{BASE_URL}/service-accounts").mock(
return_value=httpx.Response(201, json={"name": "druks-eu", "token": "drkb_secret"})
)

account = await api.create_service_account("druks-eu")

assert json.loads(route.calls.last.request.content) == {"name": "druks-eu"}
assert account == ServiceAccount(name="druks-eu", token="drkb_secret")
assert "drkb_secret" not in repr(account)


@respx.mock
async def test_create_service_account_409_raises_conflict(api: SandboxAPI):
respx.post(f"{BASE_URL}/service-accounts").mock(
return_value=httpx.Response(409, json={"detail": "exists"})
)

with pytest.raises(SandboxConflictError):
await api.create_service_account("druks-eu")


@respx.mock
async def test_remove_service_account_swallows_204(api: SandboxAPI):
route = respx.delete(f"{BASE_URL}/service-accounts/druks-eu").mock(
return_value=httpx.Response(204)
)

await api.remove_service_account("druks-eu")

assert route.called


@respx.mock
async def test_delete_host_swallows_204(api: SandboxAPI):
host_id = uuid4()
Expand Down
Loading