diff --git a/README.md b/README.md index fb2597d..a47e930 100644 --- a/README.md +++ b/README.md @@ -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, @@ -73,6 +74,7 @@ Supported host operations: - `list_hosts` - `renew_host` - `delete_host` +- `refresh_secret` - `doctor` - `aclose` @@ -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` @@ -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. diff --git a/src/drukbox_sdk/__init__.py b/src/drukbox_sdk/__init__.py index 0fee9e5..d15f3e9 100644 --- a/src/drukbox_sdk/__init__.py +++ b/src/drukbox_sdk/__init__.py @@ -14,6 +14,7 @@ SandboxHost, SandboxTemplate, Secret, + ServiceAccount, ) from .exceptions import ( SandboxAPIError, @@ -44,4 +45,5 @@ "SandboxUnavailableError", "SandboxValidationError", "Secret", + "ServiceAccount", ] diff --git a/src/drukbox_sdk/api.py b/src/drukbox_sdk/api.py index 1ea1fe5..380b088 100644 --- a/src/drukbox_sdk/api.py +++ b/src/drukbox_sdk/api.py @@ -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 @@ -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. @@ -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, *, @@ -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, *, diff --git a/tests/test_api.py b/tests/test_api.py index f0d94aa..7b39d89 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -35,6 +35,7 @@ SandboxUnavailableError, SandboxValidationError, Secret, + ServiceAccount, ) BASE_URL = "https://sandbox.test" @@ -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", @@ -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()