drukbox-python-sdk 0.1.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,7 +25,7 @@ object is a sandbox host.
25
25
 
26
26
  ```text
27
27
  src/drukbox_sdk/
28
- api.py # SandboxAPI, records, parsers, HTTP request handling
28
+ api.py # SandboxAPI, records (SandboxHost, Secret, Issuer, ...), HTTP request handling
29
29
  exceptions.py # SDK exception hierarchy
30
30
  __init__.py # Public exports
31
31
  tests/
@@ -57,3 +57,6 @@ run.
57
57
  consumers unless the user asks for them.
58
58
  - Preserve user changes in the worktree; do not clean or rewrite unrelated
59
59
  files.
60
+ - A version bump rides alone: one commit (`chore: bump to X`) that touches
61
+ only `pyproject.toml`, with no other change in it. Never fold the bump into
62
+ a feature or fix commit. The release tag is cut from the bump commit.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: drukbox-python-sdk
3
- Version: 0.1.0
3
+ Version: 0.3.0
4
4
  Summary: Async Python client for Drukbox.
5
5
  Project-URL: Homepage, https://github.com/clawhaven/drukbox-python-sdk
6
6
  Project-URL: Repository, https://github.com/clawhaven/drukbox-python-sdk
@@ -71,6 +71,9 @@ Public exports live in `drukbox_sdk`:
71
71
  - `SandboxAPI`
72
72
  - `SandboxHost`
73
73
  - `SandboxTemplate`
74
+ - `Secret`
75
+ - `Issuer`
76
+ - `ServiceAccount`
74
77
  - `DoctorReport` and `DoctorCheck`
75
78
  - `HTTPProxy` and `HTTPProxyAttachment`
76
79
  - `SandboxAPIError` and typed subclasses for auth, not found, conflict,
@@ -84,21 +87,66 @@ Supported host operations:
84
87
  - `list_hosts`
85
88
  - `renew_host`
86
89
  - `delete_host`
90
+ - `refresh_secret`
87
91
  - `doctor`
88
92
  - `aclose`
89
93
 
90
94
  `create_host` supports the service's optional `image`, `env`, `expires_at`,
91
- `permanent`, `provider`, `instance_type`, `disk_gb`, `template`, and
92
- `Idempotency-Key` inputs. `instance_type` (provider-native size, e.g.
95
+ `permanent`, `provider`, `instance_type`, `disk_gb`, `template`, `secrets`,
96
+ and `Idempotency-Key` inputs. `instance_type` (provider-native size, e.g.
93
97
  `t3.xlarge` / `cx33`) and `disk_gb` pin the VM shape; omit either for the
94
98
  provider default. Omit `expires_at` for the default lease, pass a datetime for
95
99
  an explicit expiry, or pass `permanent=True` for a never-reaped host.
96
100
  `template` accepts a template ID. An explicit `image` wins over it.
97
101
 
102
+ `secrets` gives the box credentials it never sees. Each entry is a `Secret`,
103
+ a value the caller holds, or an `Issuer`, a URL Drukbox fetches the value
104
+ from. The box gets a placeholder per entry in the service's variable, and the
105
+ Drukbox swaps it on the way out. A custom service names its `host` and
106
+ `auth_variable`:
107
+
108
+ ```python
109
+ from drukbox_sdk import Issuer, Secret
110
+
111
+ host = await sandbox.create_host(
112
+ secrets={
113
+ "anthropic": Issuer(
114
+ "https://mint.example/box-1/anthropic",
115
+ headers={"Authorization": "Bearer ..."},
116
+ refresh="1h",
117
+ ),
118
+ "openai": Secret("sk-..."),
119
+ "acme": Secret("ak_...", host="api.acme.test", auth_variable="ACME_TOKEN"),
120
+ },
121
+ )
122
+ ```
123
+
124
+ The response carries no secret. A service name Drukbox does not know raises
125
+ `SandboxValidationError`. A deployment without the secrets proxy raises
126
+ `SandboxConflictError`.
127
+
98
128
  `renew_host` extends a host's lease via `POST /hosts/{id}/renew`. Omit
99
129
  `expires_at` to extend by the service's default TTL; renewal never makes a host
100
130
  permanent.
101
131
 
132
+ `SandboxHost.service_account` names the service account that created or
133
+ claimed the host, `admin` for an admin key, or `None` for an unclaimed warm
134
+ host.
135
+
136
+ `refresh_secret` makes the service forget the current value of one secret on
137
+ a host and fetch a new one from its issuer. A static secret raises
138
+ `SandboxConflictError`; an issuer or exchange that did not answer raises
139
+ `SandboxUnavailableError`.
140
+
141
+ Supported service account operations, admin key only:
142
+
143
+ - `create_service_account`
144
+ - `remove_service_account`
145
+
146
+ `create_service_account` returns a `ServiceAccount` with the token the
147
+ service hands out once. Store it. `remove_service_account` revokes it on the
148
+ next request.
149
+
102
150
  Supported template operations:
103
151
 
104
152
  - `create_template`
@@ -125,7 +173,8 @@ proxy at a host's backing VM (the host must be `bootstrapping` or `active`).
125
173
 
126
174
  `doctor` fetches `GET /doctor` — read-only dependency health. The service
127
175
  runs one cheap, non-mutating probe per dependency (database, active VM
128
- provider, Tailscale when enabled) and always responds 200, so callers branch
176
+ provider, secrets exchange, Tailscale when enabled) and always responds 200,
177
+ so callers branch
129
178
  on `DoctorReport.ok` rather than the HTTP status. A failed `DoctorCheck`
130
179
  carries a stable `hint` slug for remediation.
131
180
 
@@ -58,6 +58,9 @@ Public exports live in `drukbox_sdk`:
58
58
  - `SandboxAPI`
59
59
  - `SandboxHost`
60
60
  - `SandboxTemplate`
61
+ - `Secret`
62
+ - `Issuer`
63
+ - `ServiceAccount`
61
64
  - `DoctorReport` and `DoctorCheck`
62
65
  - `HTTPProxy` and `HTTPProxyAttachment`
63
66
  - `SandboxAPIError` and typed subclasses for auth, not found, conflict,
@@ -71,21 +74,66 @@ Supported host operations:
71
74
  - `list_hosts`
72
75
  - `renew_host`
73
76
  - `delete_host`
77
+ - `refresh_secret`
74
78
  - `doctor`
75
79
  - `aclose`
76
80
 
77
81
  `create_host` supports the service's optional `image`, `env`, `expires_at`,
78
- `permanent`, `provider`, `instance_type`, `disk_gb`, `template`, and
79
- `Idempotency-Key` inputs. `instance_type` (provider-native size, e.g.
82
+ `permanent`, `provider`, `instance_type`, `disk_gb`, `template`, `secrets`,
83
+ and `Idempotency-Key` inputs. `instance_type` (provider-native size, e.g.
80
84
  `t3.xlarge` / `cx33`) and `disk_gb` pin the VM shape; omit either for the
81
85
  provider default. Omit `expires_at` for the default lease, pass a datetime for
82
86
  an explicit expiry, or pass `permanent=True` for a never-reaped host.
83
87
  `template` accepts a template ID. An explicit `image` wins over it.
84
88
 
89
+ `secrets` gives the box credentials it never sees. Each entry is a `Secret`,
90
+ a value the caller holds, or an `Issuer`, a URL Drukbox fetches the value
91
+ from. The box gets a placeholder per entry in the service's variable, and the
92
+ Drukbox swaps it on the way out. A custom service names its `host` and
93
+ `auth_variable`:
94
+
95
+ ```python
96
+ from drukbox_sdk import Issuer, Secret
97
+
98
+ host = await sandbox.create_host(
99
+ secrets={
100
+ "anthropic": Issuer(
101
+ "https://mint.example/box-1/anthropic",
102
+ headers={"Authorization": "Bearer ..."},
103
+ refresh="1h",
104
+ ),
105
+ "openai": Secret("sk-..."),
106
+ "acme": Secret("ak_...", host="api.acme.test", auth_variable="ACME_TOKEN"),
107
+ },
108
+ )
109
+ ```
110
+
111
+ The response carries no secret. A service name Drukbox does not know raises
112
+ `SandboxValidationError`. A deployment without the secrets proxy raises
113
+ `SandboxConflictError`.
114
+
85
115
  `renew_host` extends a host's lease via `POST /hosts/{id}/renew`. Omit
86
116
  `expires_at` to extend by the service's default TTL; renewal never makes a host
87
117
  permanent.
88
118
 
119
+ `SandboxHost.service_account` names the service account that created or
120
+ claimed the host, `admin` for an admin key, or `None` for an unclaimed warm
121
+ host.
122
+
123
+ `refresh_secret` makes the service forget the current value of one secret on
124
+ a host and fetch a new one from its issuer. A static secret raises
125
+ `SandboxConflictError`; an issuer or exchange that did not answer raises
126
+ `SandboxUnavailableError`.
127
+
128
+ Supported service account operations, admin key only:
129
+
130
+ - `create_service_account`
131
+ - `remove_service_account`
132
+
133
+ `create_service_account` returns a `ServiceAccount` with the token the
134
+ service hands out once. Store it. `remove_service_account` revokes it on the
135
+ next request.
136
+
89
137
  Supported template operations:
90
138
 
91
139
  - `create_template`
@@ -112,7 +160,8 @@ proxy at a host's backing VM (the host must be `bootstrapping` or `active`).
112
160
 
113
161
  `doctor` fetches `GET /doctor` — read-only dependency health. The service
114
162
  runs one cheap, non-mutating probe per dependency (database, active VM
115
- provider, Tailscale when enabled) and always responds 200, so callers branch
163
+ provider, secrets exchange, Tailscale when enabled) and always responds 200,
164
+ so callers branch
116
165
  on `DoctorReport.ok` rather than the HTTP status. A failed `DoctorCheck`
117
166
  carries a stable `hint` slug for remediation.
118
167
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "drukbox-python-sdk"
7
- version = "0.1.0"
7
+ version = "0.3.0"
8
8
  description = "Async Python client for Drukbox."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -9,9 +9,12 @@ from .api import (
9
9
  DoctorReport,
10
10
  HTTPProxy,
11
11
  HTTPProxyAttachment,
12
+ Issuer,
12
13
  SandboxAPI,
13
14
  SandboxHost,
14
15
  SandboxTemplate,
16
+ Secret,
17
+ ServiceAccount,
15
18
  )
16
19
  from .exceptions import (
17
20
  SandboxAPIError,
@@ -29,6 +32,7 @@ __all__ = [
29
32
  "DoctorReport",
30
33
  "HTTPProxy",
31
34
  "HTTPProxyAttachment",
35
+ "Issuer",
32
36
  "SandboxAPI",
33
37
  "SandboxAPIError",
34
38
  "SandboxAuthError",
@@ -40,4 +44,6 @@ __all__ = [
40
44
  "SandboxTemplate",
41
45
  "SandboxUnavailableError",
42
46
  "SandboxValidationError",
47
+ "Secret",
48
+ "ServiceAccount",
43
49
  ]
@@ -39,7 +39,7 @@ For env-backed config use :meth:`SandboxAPI.from_env`.
39
39
  import asyncio
40
40
  import os
41
41
  import uuid
42
- from dataclasses import dataclass
42
+ from dataclasses import dataclass, field
43
43
  from datetime import datetime
44
44
  from typing import Any, Self
45
45
 
@@ -85,10 +85,15 @@ class SandboxHost:
85
85
  different SSH auth model (exe.dev's edge proxy, Tailscale ACLs
86
86
  via tailscaled-SSH) return None here. A later ``get_host`` call
87
87
  always returns None — the key is not persisted server-side.
88
+
89
+ ``service_account`` names the service account that created or claimed
90
+ the host, ``"admin"`` for an admin key, or None for an unclaimed warm
91
+ host.
88
92
  """
89
93
 
90
94
  id: str
91
95
  name: str
96
+ service_account: str | None
92
97
  status: str
93
98
  provider: str
94
99
  image: str
@@ -108,6 +113,50 @@ class SandboxHost:
108
113
  expires_at: str | None
109
114
 
110
115
 
116
+ @dataclass(frozen=True, kw_only=True)
117
+ class _Service:
118
+ """The custom service fields of a secret. None is unset. An empty
119
+ ``auth_prefix`` is set, and Drukbox reads it as no prefix."""
120
+
121
+ host: str | None = None
122
+ auth_variable: str | None = None
123
+ auth_header: str | None = None
124
+ auth_prefix: str | None = None
125
+
126
+ def get_service(self) -> dict[str, Any]:
127
+ fields = {
128
+ "host": self.host,
129
+ "auth_variable": self.auth_variable,
130
+ "auth_header": self.auth_header,
131
+ "auth_prefix": self.auth_prefix,
132
+ }
133
+ return {name: value for name, value in fields.items() if value is not None}
134
+
135
+
136
+ @dataclass(frozen=True)
137
+ class Secret(_Service):
138
+ """A credential the caller holds."""
139
+
140
+ value: str = field(repr=False)
141
+
142
+ def entry(self) -> dict[str, Any]:
143
+ return {**self.get_service(), "value": self.value}
144
+
145
+
146
+ @dataclass(frozen=True)
147
+ class Issuer(_Service):
148
+ """A credential Drukbox fetches from ``url`` with ``headers``, and again
149
+ every ``refresh`` when the answer carries no ``expires_at``."""
150
+
151
+ url: str
152
+ headers: dict[str, str] = field(repr=False)
153
+ refresh: str
154
+
155
+ def entry(self) -> dict[str, Any]:
156
+ issuer = {"url": self.url, "headers": dict(self.headers), "refresh": self.refresh}
157
+ return {**self.get_service(), "issuer": issuer}
158
+
159
+
111
160
  @dataclass(frozen=True)
112
161
  class SandboxTemplate:
113
162
  """Snapshot of a reusable sandbox template as returned by the service.
@@ -152,6 +201,15 @@ class DoctorReport:
152
201
  checks: list[DoctorCheck]
153
202
 
154
203
 
204
+ @dataclass(frozen=True)
205
+ class ServiceAccount:
206
+ """A name the service knows a caller by, and the token that caller
207
+ presents. The service returns the token once, at creation."""
208
+
209
+ name: str
210
+ token: str = field(repr=False)
211
+
212
+
155
213
  @dataclass(frozen=True)
156
214
  class HTTPProxy:
157
215
  """An account-bound HTTP proxy as returned by the service.
@@ -240,6 +298,7 @@ class SandboxAPI:
240
298
  instance_type: str | None = None,
241
299
  permanent: bool = False,
242
300
  provider: str | None = None,
301
+ secrets: dict[str, Secret | Issuer] | None = None,
243
302
  template: uuid.UUID | str | None = None,
244
303
  ) -> SandboxHost:
245
304
  """Provision a new host.
@@ -274,6 +333,9 @@ class SandboxAPI:
274
333
  Lease: omit ``expires_at`` for the service's default lease, pass a
275
334
  ``datetime`` for an explicit expiry, or pass ``permanent=True`` for
276
335
  a host the janitor never reaps.
336
+
337
+ ``secrets`` maps a service name to a :class:`Secret` or an
338
+ :class:`Issuer`. The response carries no secret.
277
339
  """
278
340
 
279
341
  if expires_at and permanent:
@@ -295,6 +357,8 @@ class SandboxAPI:
295
357
  payload["instance_type"] = instance_type
296
358
  if provider is not None:
297
359
  payload["provider"] = provider
360
+ if secrets:
361
+ payload["secrets"] = {name: entry.entry() for name, entry in secrets.items()}
298
362
  if template is not None:
299
363
  payload["template"] = str(template)
300
364
 
@@ -351,6 +415,18 @@ class SandboxAPI:
351
415
  async def delete_host(self, host_id: uuid.UUID | str) -> None:
352
416
  await self._request("DELETE", f"/hosts/{host_id}")
353
417
 
418
+ async def refresh_secret(self, host_id: uuid.UUID | str, service: str) -> None:
419
+ """Make the service forget the current value of ``service`` on the
420
+ host and fetch a new one from its issuer.
421
+
422
+ Raises :class:`SandboxNotFoundError` for an unknown host or secret,
423
+ :class:`SandboxConflictError` for a secret with a static value, and
424
+ :class:`SandboxUnavailableError` when the exchange or the issuer did
425
+ not answer.
426
+ """
427
+
428
+ await self._request("POST", f"/hosts/{host_id}/secrets/{service}/refresh")
429
+
354
430
  async def create_template(
355
431
  self,
356
432
  *,
@@ -416,6 +492,27 @@ class SandboxAPI:
416
492
  checks=[DoctorCheck(**check) for check in data["checks"]],
417
493
  )
418
494
 
495
+ async def create_service_account(self, name: str) -> ServiceAccount:
496
+ """Create a service account and return its token.
497
+
498
+ Only an admin key from the service's ``SERVICE_TOKENS`` may call
499
+ this. A name that already exists raises
500
+ :class:`SandboxConflictError`; an invalid name raises
501
+ :class:`SandboxValidationError`.
502
+ """
503
+
504
+ data = await self._request("POST", "/service-accounts", json={"name": name})
505
+ return ServiceAccount(**data)
506
+
507
+ async def remove_service_account(self, name: str) -> None:
508
+ """Revoke a service account's token and remove the account.
509
+
510
+ An unknown name raises :class:`SandboxNotFoundError`; the ``admin``
511
+ account raises :class:`SandboxConflictError`.
512
+ """
513
+
514
+ await self._request("DELETE", f"/service-accounts/{name}")
515
+
419
516
  async def create_http_proxy(
420
517
  self,
421
518
  *,
@@ -37,8 +37,8 @@ class SandboxNotFoundError(SandboxAPIError):
37
37
  class SandboxConflictError(SandboxAPIError):
38
38
  """409 — the operation conflicts with the current state.
39
39
 
40
- e.g. provisioning a Linux user on a host that already has one with
41
- the same name.
40
+ e.g. a template that is still building, or secrets on a deployment
41
+ without the secrets proxy configured.
42
42
  """
43
43
 
44
44
 
@@ -53,9 +53,10 @@ class SandboxProvisioningError(SandboxAPIError):
53
53
  class SandboxValidationError(SandboxAPIError):
54
54
  """422 — the service rejected the request payload as invalid.
55
55
 
56
- e.g. an env key that isn't a valid environment-variable name, or a
57
- proxy target URL carrying a path or credentials. The request is
58
- malformed, so retrying it unchanged won't help — fix the input.
56
+ e.g. an env key that isn't a valid environment-variable name, a
57
+ proxy target URL carrying a path or credentials, or a secret for a
58
+ service name Drukbox does not know. The request is malformed, so
59
+ retrying it unchanged won't help — fix the input.
59
60
  """
60
61
 
61
62
 
@@ -12,6 +12,7 @@ gives us a controllable transport layer so every test stays in-process.
12
12
  # pyright: reportPrivateUsage=false
13
13
 
14
14
  import asyncio
15
+ import json
15
16
  from collections.abc import AsyncGenerator
16
17
  from datetime import UTC, datetime
17
18
  from typing import Any
@@ -24,6 +25,7 @@ import respx
24
25
  from drukbox_sdk import (
25
26
  HTTPProxy,
26
27
  HTTPProxyAttachment,
28
+ Issuer,
27
29
  SandboxAPI,
28
30
  SandboxAuthError,
29
31
  SandboxConflictError,
@@ -32,6 +34,8 @@ from drukbox_sdk import (
32
34
  SandboxResponseError,
33
35
  SandboxUnavailableError,
34
36
  SandboxValidationError,
37
+ Secret,
38
+ ServiceAccount,
35
39
  )
36
40
 
37
41
  BASE_URL = "https://sandbox.test"
@@ -78,6 +82,7 @@ def _host_payload(**overrides: Any) -> dict[str, Any]:
78
82
  payload: dict[str, Any] = {
79
83
  "id": str(uuid4()),
80
84
  "name": "host-abc",
85
+ "service_account": "admin",
81
86
  "status": "provisioning",
82
87
  "provider": "exe",
83
88
  "image": "ghcr.io/drukbox/sandbox:test",
@@ -268,6 +273,115 @@ async def test_create_host_409_when_template_is_building(api: SandboxAPI):
268
273
  await api.create_host(template=template_id)
269
274
 
270
275
 
276
+ @respx.mock
277
+ async def test_create_host_sends_secrets_with_only_the_fields_set(api: SandboxAPI):
278
+ route = respx.post(f"{BASE_URL}/hosts").mock(
279
+ return_value=httpx.Response(201, json=_host_payload()),
280
+ )
281
+
282
+ await api.create_host(
283
+ secrets={
284
+ "anthropic": Issuer(
285
+ "https://mint.test/box-1/anthropic",
286
+ headers={"Authorization": "Bearer d2d"},
287
+ refresh="1h",
288
+ ),
289
+ "openai": Secret("sk-real"),
290
+ "acme": Secret("ak_real", host="api.acme.test", auth_variable="ACME_TOKEN"),
291
+ },
292
+ )
293
+
294
+ body = json.loads(route.calls.last.request.content)
295
+ assert body["secrets"] == {
296
+ "anthropic": {
297
+ "issuer": {
298
+ "url": "https://mint.test/box-1/anthropic",
299
+ "headers": {"Authorization": "Bearer d2d"},
300
+ "refresh": "1h",
301
+ }
302
+ },
303
+ "openai": {"value": "sk-real"},
304
+ "acme": {"value": "ak_real", "host": "api.acme.test", "auth_variable": "ACME_TOKEN"},
305
+ }
306
+
307
+
308
+ @respx.mock
309
+ async def test_create_host_omits_secrets_when_unset(api: SandboxAPI):
310
+ route = respx.post(f"{BASE_URL}/hosts").mock(
311
+ return_value=httpx.Response(201, json=_host_payload()),
312
+ )
313
+
314
+ await api.create_host()
315
+
316
+ assert "secrets" not in json.loads(route.calls.last.request.content)
317
+
318
+
319
+ @respx.mock
320
+ async def test_create_host_keeps_an_empty_auth_prefix(api: SandboxAPI):
321
+ route = respx.post(f"{BASE_URL}/hosts").mock(
322
+ return_value=httpx.Response(201, json=_host_payload()),
323
+ )
324
+ custom = {"host": "api.acme.test", "auth_variable": "ACME_TOKEN", "auth_prefix": ""}
325
+
326
+ await api.create_host(
327
+ secrets={
328
+ "acme": Secret("ak_real", **custom),
329
+ "acme-minted": Issuer("https://mint.test/acme", {"X-Key": "k"}, "1h", **custom),
330
+ },
331
+ )
332
+
333
+ body = json.loads(route.calls.last.request.content)
334
+ assert body["secrets"]["acme"]["auth_prefix"] == ""
335
+ assert body["secrets"]["acme-minted"]["auth_prefix"] == ""
336
+
337
+
338
+ def test_a_record_never_shows_its_credential():
339
+ secret = Secret("VALUE_MARKER", host="api.acme.test", auth_variable="ACME_TOKEN")
340
+ issuer = Issuer("https://mint.test/acme", {"Authorization": "ISSUER_MARKER"}, "1h")
341
+
342
+ shown = repr({"acme": secret, "minted": issuer})
343
+
344
+ assert "VALUE_MARKER" not in shown
345
+ assert "ISSUER_MARKER" not in shown
346
+ assert "api.acme.test" in shown and "https://mint.test/acme" in shown
347
+
348
+
349
+ def test_the_custom_service_fields_are_keywords():
350
+ with pytest.raises(TypeError):
351
+ Secret("ak_real", "api.acme.test") # type: ignore[misc]
352
+ with pytest.raises(TypeError):
353
+ Issuer("https://mint.test", {"X-Key": "k"}, "1h", "api.acme.test") # type: ignore[misc]
354
+
355
+
356
+ @respx.mock
357
+ async def test_create_host_409_without_the_secrets_proxy_raises_conflict(api: SandboxAPI):
358
+ respx.post(f"{BASE_URL}/hosts").mock(
359
+ return_value=httpx.Response(
360
+ 409,
361
+ json={
362
+ "detail": "secrets need the secrets proxy, which this deployment lacks",
363
+ "error_code": "SECRETS_PROXY_NOT_CONFIGURED",
364
+ },
365
+ ),
366
+ )
367
+
368
+ with pytest.raises(SandboxConflictError, match="secrets proxy"):
369
+ await api.create_host(secrets={"openai": Secret("sk-real")})
370
+
371
+
372
+ @respx.mock
373
+ async def test_create_host_422_for_an_unknown_secret_service_raises_validation(api: SandboxAPI):
374
+ respx.post(f"{BASE_URL}/hosts").mock(
375
+ return_value=httpx.Response(
376
+ 422,
377
+ json={"detail": [{"loc": ["body", "secrets"], "msg": "unknown secret service 'acme'"}]},
378
+ ),
379
+ )
380
+
381
+ with pytest.raises(SandboxValidationError, match="unknown secret service"):
382
+ await api.create_host(secrets={"acme": Secret("ak_real")})
383
+
384
+
271
385
  @respx.mock
272
386
  async def test_get_host_returns_parsed_host(api: SandboxAPI):
273
387
  payload = _host_payload(status="active")
@@ -309,6 +423,83 @@ async def test_list_hosts_parses_each_record(api: SandboxAPI):
309
423
  assert {h.id for h in hosts} == {p["id"] for p in payloads}
310
424
 
311
425
 
426
+ @respx.mock
427
+ async def test_host_carries_its_service_account(api: SandboxAPI):
428
+ respx.get(f"{BASE_URL}/hosts/h1").mock(
429
+ return_value=httpx.Response(200, json=_host_payload(service_account="druks-eu"))
430
+ )
431
+
432
+ host = await api.get_host("h1")
433
+
434
+ assert host.service_account == "druks-eu"
435
+
436
+
437
+ @respx.mock
438
+ async def test_refresh_secret_posts_and_accepts_204(api: SandboxAPI):
439
+ route = respx.post(f"{BASE_URL}/hosts/h1/secrets/github/refresh").mock(
440
+ return_value=httpx.Response(204)
441
+ )
442
+
443
+ await api.refresh_secret("h1", "github")
444
+
445
+ assert route.called
446
+ assert not route.calls.last.request.content
447
+
448
+
449
+ @respx.mock
450
+ @pytest.mark.parametrize(
451
+ "status, error",
452
+ [
453
+ (404, SandboxNotFoundError),
454
+ (409, SandboxConflictError),
455
+ (503, SandboxUnavailableError),
456
+ ],
457
+ )
458
+ async def test_refresh_secret_raises_the_typed_error(
459
+ api: SandboxAPI, status: int, error: type[Exception]
460
+ ):
461
+ respx.post(f"{BASE_URL}/hosts/h1/secrets/github/refresh").mock(
462
+ return_value=httpx.Response(status, json={"detail": "no"})
463
+ )
464
+
465
+ with pytest.raises(error):
466
+ await api.refresh_secret("h1", "github")
467
+
468
+
469
+ @respx.mock
470
+ async def test_create_service_account_returns_the_token_once(api: SandboxAPI):
471
+ route = respx.post(f"{BASE_URL}/service-accounts").mock(
472
+ return_value=httpx.Response(201, json={"name": "druks-eu", "token": "drkb_secret"})
473
+ )
474
+
475
+ account = await api.create_service_account("druks-eu")
476
+
477
+ assert json.loads(route.calls.last.request.content) == {"name": "druks-eu"}
478
+ assert account == ServiceAccount(name="druks-eu", token="drkb_secret")
479
+ assert "drkb_secret" not in repr(account)
480
+
481
+
482
+ @respx.mock
483
+ async def test_create_service_account_409_raises_conflict(api: SandboxAPI):
484
+ respx.post(f"{BASE_URL}/service-accounts").mock(
485
+ return_value=httpx.Response(409, json={"detail": "exists"})
486
+ )
487
+
488
+ with pytest.raises(SandboxConflictError):
489
+ await api.create_service_account("druks-eu")
490
+
491
+
492
+ @respx.mock
493
+ async def test_remove_service_account_swallows_204(api: SandboxAPI):
494
+ route = respx.delete(f"{BASE_URL}/service-accounts/druks-eu").mock(
495
+ return_value=httpx.Response(204)
496
+ )
497
+
498
+ await api.remove_service_account("druks-eu")
499
+
500
+ assert route.called
501
+
502
+
312
503
  @respx.mock
313
504
  async def test_delete_host_swallows_204(api: SandboxAPI):
314
505
  host_id = uuid4()