drukbox-python-sdk 0.2.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.
@@ -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.2.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
@@ -73,6 +73,7 @@ Public exports live in `drukbox_sdk`:
73
73
  - `SandboxTemplate`
74
74
  - `Secret`
75
75
  - `Issuer`
76
+ - `ServiceAccount`
76
77
  - `DoctorReport` and `DoctorCheck`
77
78
  - `HTTPProxy` and `HTTPProxyAttachment`
78
79
  - `SandboxAPIError` and typed subclasses for auth, not found, conflict,
@@ -86,6 +87,7 @@ Supported host operations:
86
87
  - `list_hosts`
87
88
  - `renew_host`
88
89
  - `delete_host`
90
+ - `refresh_secret`
89
91
  - `doctor`
90
92
  - `aclose`
91
93
 
@@ -127,6 +129,24 @@ The response carries no secret. A service name Drukbox does not know raises
127
129
  `expires_at` to extend by the service's default TTL; renewal never makes a host
128
130
  permanent.
129
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
+
130
150
  Supported template operations:
131
151
 
132
152
  - `create_template`
@@ -153,7 +173,8 @@ proxy at a host's backing VM (the host must be `bootstrapping` or `active`).
153
173
 
154
174
  `doctor` fetches `GET /doctor` — read-only dependency health. The service
155
175
  runs one cheap, non-mutating probe per dependency (database, active VM
156
- 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
157
178
  on `DoctorReport.ok` rather than the HTTP status. A failed `DoctorCheck`
158
179
  carries a stable `hint` slug for remediation.
159
180
 
@@ -60,6 +60,7 @@ Public exports live in `drukbox_sdk`:
60
60
  - `SandboxTemplate`
61
61
  - `Secret`
62
62
  - `Issuer`
63
+ - `ServiceAccount`
63
64
  - `DoctorReport` and `DoctorCheck`
64
65
  - `HTTPProxy` and `HTTPProxyAttachment`
65
66
  - `SandboxAPIError` and typed subclasses for auth, not found, conflict,
@@ -73,6 +74,7 @@ Supported host operations:
73
74
  - `list_hosts`
74
75
  - `renew_host`
75
76
  - `delete_host`
77
+ - `refresh_secret`
76
78
  - `doctor`
77
79
  - `aclose`
78
80
 
@@ -114,6 +116,24 @@ The response carries no secret. A service name Drukbox does not know raises
114
116
  `expires_at` to extend by the service's default TTL; renewal never makes a host
115
117
  permanent.
116
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
+
117
137
  Supported template operations:
118
138
 
119
139
  - `create_template`
@@ -140,7 +160,8 @@ proxy at a host's backing VM (the host must be `bootstrapping` or `active`).
140
160
 
141
161
  `doctor` fetches `GET /doctor` — read-only dependency health. The service
142
162
  runs one cheap, non-mutating probe per dependency (database, active VM
143
- 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
144
165
  on `DoctorReport.ok` rather than the HTTP status. A failed `DoctorCheck`
145
166
  carries a stable `hint` slug for remediation.
146
167
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "drukbox-python-sdk"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Async Python client for Drukbox."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -14,6 +14,7 @@ from .api import (
14
14
  SandboxHost,
15
15
  SandboxTemplate,
16
16
  Secret,
17
+ ServiceAccount,
17
18
  )
18
19
  from .exceptions import (
19
20
  SandboxAPIError,
@@ -44,4 +45,5 @@ __all__ = [
44
45
  "SandboxUnavailableError",
45
46
  "SandboxValidationError",
46
47
  "Secret",
48
+ "ServiceAccount",
47
49
  ]
@@ -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
@@ -196,6 +201,15 @@ class DoctorReport:
196
201
  checks: list[DoctorCheck]
197
202
 
198
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
+
199
213
  @dataclass(frozen=True)
200
214
  class HTTPProxy:
201
215
  """An account-bound HTTP proxy as returned by the service.
@@ -401,6 +415,18 @@ class SandboxAPI:
401
415
  async def delete_host(self, host_id: uuid.UUID | str) -> None:
402
416
  await self._request("DELETE", f"/hosts/{host_id}")
403
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
+
404
430
  async def create_template(
405
431
  self,
406
432
  *,
@@ -466,6 +492,27 @@ class SandboxAPI:
466
492
  checks=[DoctorCheck(**check) for check in data["checks"]],
467
493
  )
468
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
+
469
516
  async def create_http_proxy(
470
517
  self,
471
518
  *,
@@ -35,6 +35,7 @@ from drukbox_sdk import (
35
35
  SandboxUnavailableError,
36
36
  SandboxValidationError,
37
37
  Secret,
38
+ ServiceAccount,
38
39
  )
39
40
 
40
41
  BASE_URL = "https://sandbox.test"
@@ -81,6 +82,7 @@ def _host_payload(**overrides: Any) -> dict[str, Any]:
81
82
  payload: dict[str, Any] = {
82
83
  "id": str(uuid4()),
83
84
  "name": "host-abc",
85
+ "service_account": "admin",
84
86
  "status": "provisioning",
85
87
  "provider": "exe",
86
88
  "image": "ghcr.io/drukbox/sandbox:test",
@@ -421,6 +423,83 @@ async def test_list_hosts_parses_each_record(api: SandboxAPI):
421
423
  assert {h.id for h in hosts} == {p["id"] for p in payloads}
422
424
 
423
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
+
424
503
  @respx.mock
425
504
  async def test_delete_host_swallows_204(api: SandboxAPI):
426
505
  host_id = uuid4()