drukbox-python-sdk 0.2.0__tar.gz → 0.4.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.4.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
@@ -59,6 +59,8 @@ finally:
59
59
  few minutes worst case. The SDK's default `timeout` (300s) covers this. Pass
60
60
  an `idempotency_key` for retry safety: a retry with the same key after a
61
61
  successful provision returns the original host instead of creating a duplicate.
62
+ A provider at its host limit raises `SandboxCapacityError`, a
63
+ `SandboxUnavailableError` whose `retry_after` gives the seconds to wait.
62
64
 
63
65
  `SandboxAPI.from_env(prefix="SANDBOX_")` reads
64
66
  `SANDBOX_SERVICE_URL`, `SANDBOX_SERVICE_TOKEN`, and optional
@@ -73,6 +75,7 @@ Public exports live in `drukbox_sdk`:
73
75
  - `SandboxTemplate`
74
76
  - `Secret`
75
77
  - `Issuer`
78
+ - `ServiceAccount`
76
79
  - `DoctorReport` and `DoctorCheck`
77
80
  - `HTTPProxy` and `HTTPProxyAttachment`
78
81
  - `SandboxAPIError` and typed subclasses for auth, not found, conflict,
@@ -86,6 +89,7 @@ Supported host operations:
86
89
  - `list_hosts`
87
90
  - `renew_host`
88
91
  - `delete_host`
92
+ - `refresh_secret`
89
93
  - `doctor`
90
94
  - `aclose`
91
95
 
@@ -127,6 +131,24 @@ The response carries no secret. A service name Drukbox does not know raises
127
131
  `expires_at` to extend by the service's default TTL; renewal never makes a host
128
132
  permanent.
129
133
 
134
+ `SandboxHost.service_account` names the service account that created or
135
+ claimed the host, `admin` for an admin key, or `None` for an unclaimed warm
136
+ host.
137
+
138
+ `refresh_secret` makes the service forget the current value of one secret on
139
+ a host and fetch a new one from its issuer. A static secret raises
140
+ `SandboxConflictError`; an issuer or exchange that did not answer raises
141
+ `SandboxUnavailableError`.
142
+
143
+ Supported service account operations, admin key only:
144
+
145
+ - `create_service_account`
146
+ - `remove_service_account`
147
+
148
+ `create_service_account` returns a `ServiceAccount` with the token the
149
+ service hands out once. Store it. `remove_service_account` revokes it on the
150
+ next request.
151
+
130
152
  Supported template operations:
131
153
 
132
154
  - `create_template`
@@ -153,7 +175,8 @@ proxy at a host's backing VM (the host must be `bootstrapping` or `active`).
153
175
 
154
176
  `doctor` fetches `GET /doctor` — read-only dependency health. The service
155
177
  runs one cheap, non-mutating probe per dependency (database, active VM
156
- provider, Tailscale when enabled) and always responds 200, so callers branch
178
+ provider, secrets exchange, Tailscale when enabled) and always responds 200,
179
+ so callers branch
157
180
  on `DoctorReport.ok` rather than the HTTP status. A failed `DoctorCheck`
158
181
  carries a stable `hint` slug for remediation.
159
182
 
@@ -46,6 +46,8 @@ finally:
46
46
  few minutes worst case. The SDK's default `timeout` (300s) covers this. Pass
47
47
  an `idempotency_key` for retry safety: a retry with the same key after a
48
48
  successful provision returns the original host instead of creating a duplicate.
49
+ A provider at its host limit raises `SandboxCapacityError`, a
50
+ `SandboxUnavailableError` whose `retry_after` gives the seconds to wait.
49
51
 
50
52
  `SandboxAPI.from_env(prefix="SANDBOX_")` reads
51
53
  `SANDBOX_SERVICE_URL`, `SANDBOX_SERVICE_TOKEN`, and optional
@@ -60,6 +62,7 @@ Public exports live in `drukbox_sdk`:
60
62
  - `SandboxTemplate`
61
63
  - `Secret`
62
64
  - `Issuer`
65
+ - `ServiceAccount`
63
66
  - `DoctorReport` and `DoctorCheck`
64
67
  - `HTTPProxy` and `HTTPProxyAttachment`
65
68
  - `SandboxAPIError` and typed subclasses for auth, not found, conflict,
@@ -73,6 +76,7 @@ Supported host operations:
73
76
  - `list_hosts`
74
77
  - `renew_host`
75
78
  - `delete_host`
79
+ - `refresh_secret`
76
80
  - `doctor`
77
81
  - `aclose`
78
82
 
@@ -114,6 +118,24 @@ The response carries no secret. A service name Drukbox does not know raises
114
118
  `expires_at` to extend by the service's default TTL; renewal never makes a host
115
119
  permanent.
116
120
 
121
+ `SandboxHost.service_account` names the service account that created or
122
+ claimed the host, `admin` for an admin key, or `None` for an unclaimed warm
123
+ host.
124
+
125
+ `refresh_secret` makes the service forget the current value of one secret on
126
+ a host and fetch a new one from its issuer. A static secret raises
127
+ `SandboxConflictError`; an issuer or exchange that did not answer raises
128
+ `SandboxUnavailableError`.
129
+
130
+ Supported service account operations, admin key only:
131
+
132
+ - `create_service_account`
133
+ - `remove_service_account`
134
+
135
+ `create_service_account` returns a `ServiceAccount` with the token the
136
+ service hands out once. Store it. `remove_service_account` revokes it on the
137
+ next request.
138
+
117
139
  Supported template operations:
118
140
 
119
141
  - `create_template`
@@ -140,7 +162,8 @@ proxy at a host's backing VM (the host must be `bootstrapping` or `active`).
140
162
 
141
163
  `doctor` fetches `GET /doctor` — read-only dependency health. The service
142
164
  runs one cheap, non-mutating probe per dependency (database, active VM
143
- provider, Tailscale when enabled) and always responds 200, so callers branch
165
+ provider, secrets exchange, Tailscale when enabled) and always responds 200,
166
+ so callers branch
144
167
  on `DoctorReport.ok` rather than the HTTP status. A failed `DoctorCheck`
145
168
  carries a stable `hint` slug for remediation.
146
169
 
@@ -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.4.0"
8
8
  description = "Async Python client for Drukbox."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -14,10 +14,12 @@ from .api import (
14
14
  SandboxHost,
15
15
  SandboxTemplate,
16
16
  Secret,
17
+ ServiceAccount,
17
18
  )
18
19
  from .exceptions import (
19
20
  SandboxAPIError,
20
21
  SandboxAuthError,
22
+ SandboxCapacityError,
21
23
  SandboxConflictError,
22
24
  SandboxNotFoundError,
23
25
  SandboxProvisioningError,
@@ -35,6 +37,7 @@ __all__ = [
35
37
  "SandboxAPI",
36
38
  "SandboxAPIError",
37
39
  "SandboxAuthError",
40
+ "SandboxCapacityError",
38
41
  "SandboxConflictError",
39
42
  "SandboxHost",
40
43
  "SandboxNotFoundError",
@@ -44,4 +47,5 @@ __all__ = [
44
47
  "SandboxUnavailableError",
45
48
  "SandboxValidationError",
46
49
  "Secret",
50
+ "ServiceAccount",
47
51
  ]
@@ -47,6 +47,7 @@ import httpx
47
47
 
48
48
  from .exceptions import (
49
49
  SandboxAuthError,
50
+ SandboxCapacityError,
50
51
  SandboxConflictError,
51
52
  SandboxNotFoundError,
52
53
  SandboxProvisioningError,
@@ -85,10 +86,15 @@ class SandboxHost:
85
86
  different SSH auth model (exe.dev's edge proxy, Tailscale ACLs
86
87
  via tailscaled-SSH) return None here. A later ``get_host`` call
87
88
  always returns None — the key is not persisted server-side.
89
+
90
+ ``service_account`` names the service account that created or claimed
91
+ the host, ``"admin"`` for an admin key, or None for an unclaimed warm
92
+ host.
88
93
  """
89
94
 
90
95
  id: str
91
96
  name: str
97
+ service_account: str | None
92
98
  status: str
93
99
  provider: str
94
100
  image: str
@@ -196,6 +202,15 @@ class DoctorReport:
196
202
  checks: list[DoctorCheck]
197
203
 
198
204
 
205
+ @dataclass(frozen=True)
206
+ class ServiceAccount:
207
+ """A name the service knows a caller by, and the token that caller
208
+ presents. The service returns the token once, at creation."""
209
+
210
+ name: str
211
+ token: str = field(repr=False)
212
+
213
+
199
214
  @dataclass(frozen=True)
200
215
  class HTTPProxy:
201
216
  """An account-bound HTTP proxy as returned by the service.
@@ -401,6 +416,18 @@ class SandboxAPI:
401
416
  async def delete_host(self, host_id: uuid.UUID | str) -> None:
402
417
  await self._request("DELETE", f"/hosts/{host_id}")
403
418
 
419
+ async def refresh_secret(self, host_id: uuid.UUID | str, service: str) -> None:
420
+ """Make the service forget the current value of ``service`` on the
421
+ host and fetch a new one from its issuer.
422
+
423
+ Raises :class:`SandboxNotFoundError` for an unknown host or secret,
424
+ :class:`SandboxConflictError` for a secret with a static value, and
425
+ :class:`SandboxUnavailableError` when the exchange or the issuer did
426
+ not answer.
427
+ """
428
+
429
+ await self._request("POST", f"/hosts/{host_id}/secrets/{service}/refresh")
430
+
404
431
  async def create_template(
405
432
  self,
406
433
  *,
@@ -409,14 +436,15 @@ class SandboxAPI:
409
436
  base_image: str | None = None,
410
437
  label: str | None = None,
411
438
  ) -> SandboxTemplate:
412
- """Start a template build and return immediately.
413
-
414
- The service responds 202 with ``status="building"``. Poll
415
- :meth:`get_template` until the status becomes ``"available"`` or
416
- ``"failed"``. A failed record carries the reason in
417
- ``last_error``. A repeated post with the same provider, base
418
- image, and setup script returns the existing record without a
419
- rebuild.
439
+ """Start a template build.
440
+
441
+ The service pulls the base image, then responds 202 with
442
+ ``status="building"``. Poll :meth:`get_template` until the status
443
+ becomes ``"available"`` or ``"failed"``. A failed record carries the
444
+ reason in ``last_error``. A repeated post with the same provider,
445
+ base image digest, and setup script returns the existing record. A
446
+ ``"failed"`` record builds again and keeps its ``last_error`` until
447
+ that build ends.
420
448
  """
421
449
 
422
450
  payload: dict[str, Any] = {"setup_script": setup_script}
@@ -466,6 +494,27 @@ class SandboxAPI:
466
494
  checks=[DoctorCheck(**check) for check in data["checks"]],
467
495
  )
468
496
 
497
+ async def create_service_account(self, name: str) -> ServiceAccount:
498
+ """Create a service account and return its token.
499
+
500
+ Only an admin key from the service's ``SERVICE_TOKENS`` may call
501
+ this. A name that already exists raises
502
+ :class:`SandboxConflictError`; an invalid name raises
503
+ :class:`SandboxValidationError`.
504
+ """
505
+
506
+ data = await self._request("POST", "/service-accounts", json={"name": name})
507
+ return ServiceAccount(**data)
508
+
509
+ async def remove_service_account(self, name: str) -> None:
510
+ """Revoke a service account's token and remove the account.
511
+
512
+ An unknown name raises :class:`SandboxNotFoundError`; the ``admin``
513
+ account raises :class:`SandboxConflictError`.
514
+ """
515
+
516
+ await self._request("DELETE", f"/service-accounts/{name}")
517
+
469
518
  async def create_http_proxy(
470
519
  self,
471
520
  *,
@@ -567,6 +616,11 @@ class SandboxAPI:
567
616
  raise SandboxProvisioningError(json_response.get("detail", "provisioning failed"))
568
617
 
569
618
  if response.status_code == 503:
619
+ if json_response.get("error_code") == "CAPACITY":
620
+ raise SandboxCapacityError(
621
+ json_response.get("detail", "provider at host limit"),
622
+ retry_after=int(response.headers["Retry-After"]),
623
+ )
570
624
  raise SandboxUnavailableError(json_response.get("detail", "service unavailable"))
571
625
 
572
626
  if response.status_code == 422:
@@ -18,6 +18,18 @@ class SandboxUnavailableError(RuntimeError):
18
18
  """
19
19
 
20
20
 
21
+ class SandboxCapacityError(SandboxUnavailableError):
22
+ """503 — the provider is at its host limit.
23
+
24
+ ``retry_after`` is the service's ``Retry-After`` value in seconds:
25
+ wait at least that long before the next create.
26
+ """
27
+
28
+ def __init__(self, message: str, retry_after: int) -> None:
29
+ super().__init__(message)
30
+ self.retry_after = retry_after
31
+
32
+
21
33
  class SandboxAPIError(RuntimeError):
22
34
  """Base for any error the sandbox service returned via HTTP."""
23
35
 
@@ -28,6 +28,7 @@ from drukbox_sdk import (
28
28
  Issuer,
29
29
  SandboxAPI,
30
30
  SandboxAuthError,
31
+ SandboxCapacityError,
31
32
  SandboxConflictError,
32
33
  SandboxNotFoundError,
33
34
  SandboxProvisioningError,
@@ -35,6 +36,7 @@ from drukbox_sdk import (
35
36
  SandboxUnavailableError,
36
37
  SandboxValidationError,
37
38
  Secret,
39
+ ServiceAccount,
38
40
  )
39
41
 
40
42
  BASE_URL = "https://sandbox.test"
@@ -81,6 +83,7 @@ def _host_payload(**overrides: Any) -> dict[str, Any]:
81
83
  payload: dict[str, Any] = {
82
84
  "id": str(uuid4()),
83
85
  "name": "host-abc",
86
+ "service_account": "admin",
84
87
  "status": "provisioning",
85
88
  "provider": "exe",
86
89
  "image": "ghcr.io/drukbox/sandbox:test",
@@ -421,6 +424,83 @@ async def test_list_hosts_parses_each_record(api: SandboxAPI):
421
424
  assert {h.id for h in hosts} == {p["id"] for p in payloads}
422
425
 
423
426
 
427
+ @respx.mock
428
+ async def test_host_carries_its_service_account(api: SandboxAPI):
429
+ respx.get(f"{BASE_URL}/hosts/h1").mock(
430
+ return_value=httpx.Response(200, json=_host_payload(service_account="druks-eu"))
431
+ )
432
+
433
+ host = await api.get_host("h1")
434
+
435
+ assert host.service_account == "druks-eu"
436
+
437
+
438
+ @respx.mock
439
+ async def test_refresh_secret_posts_and_accepts_204(api: SandboxAPI):
440
+ route = respx.post(f"{BASE_URL}/hosts/h1/secrets/github/refresh").mock(
441
+ return_value=httpx.Response(204)
442
+ )
443
+
444
+ await api.refresh_secret("h1", "github")
445
+
446
+ assert route.called
447
+ assert not route.calls.last.request.content
448
+
449
+
450
+ @respx.mock
451
+ @pytest.mark.parametrize(
452
+ "status, error",
453
+ [
454
+ (404, SandboxNotFoundError),
455
+ (409, SandboxConflictError),
456
+ (503, SandboxUnavailableError),
457
+ ],
458
+ )
459
+ async def test_refresh_secret_raises_the_typed_error(
460
+ api: SandboxAPI, status: int, error: type[Exception]
461
+ ):
462
+ respx.post(f"{BASE_URL}/hosts/h1/secrets/github/refresh").mock(
463
+ return_value=httpx.Response(status, json={"detail": "no"})
464
+ )
465
+
466
+ with pytest.raises(error):
467
+ await api.refresh_secret("h1", "github")
468
+
469
+
470
+ @respx.mock
471
+ async def test_create_service_account_returns_the_token_once(api: SandboxAPI):
472
+ route = respx.post(f"{BASE_URL}/service-accounts").mock(
473
+ return_value=httpx.Response(201, json={"name": "druks-eu", "token": "drkb_secret"})
474
+ )
475
+
476
+ account = await api.create_service_account("druks-eu")
477
+
478
+ assert json.loads(route.calls.last.request.content) == {"name": "druks-eu"}
479
+ assert account == ServiceAccount(name="druks-eu", token="drkb_secret")
480
+ assert "drkb_secret" not in repr(account)
481
+
482
+
483
+ @respx.mock
484
+ async def test_create_service_account_409_raises_conflict(api: SandboxAPI):
485
+ respx.post(f"{BASE_URL}/service-accounts").mock(
486
+ return_value=httpx.Response(409, json={"detail": "exists"})
487
+ )
488
+
489
+ with pytest.raises(SandboxConflictError):
490
+ await api.create_service_account("druks-eu")
491
+
492
+
493
+ @respx.mock
494
+ async def test_remove_service_account_swallows_204(api: SandboxAPI):
495
+ route = respx.delete(f"{BASE_URL}/service-accounts/druks-eu").mock(
496
+ return_value=httpx.Response(204)
497
+ )
498
+
499
+ await api.remove_service_account("druks-eu")
500
+
501
+ assert route.called
502
+
503
+
424
504
  @respx.mock
425
505
  async def test_delete_host_swallows_204(api: SandboxAPI):
426
506
  host_id = uuid4()
@@ -717,6 +797,27 @@ async def test_503_raises_sandbox_unavailable_error(api: SandboxAPI):
717
797
  await api.list_hosts()
718
798
 
719
799
 
800
+ @respx.mock
801
+ async def test_capacity_503_raises_sandbox_capacity_error(api: SandboxAPI):
802
+ """A 503 with error_code CAPACITY is a host-limit refusal, not an
803
+ outage. It stays a SandboxUnavailableError so existing retries keep
804
+ working, and carries the service's Retry-After in seconds."""
805
+
806
+ respx.post(f"{BASE_URL}/hosts").mock(
807
+ return_value=httpx.Response(
808
+ 503,
809
+ json={"detail": "provider 'exe' is at its host limit", "error_code": "CAPACITY"},
810
+ headers={"Retry-After": "30"},
811
+ ),
812
+ )
813
+
814
+ with pytest.raises(SandboxCapacityError, match="host limit") as exc_info:
815
+ await api.create_host()
816
+
817
+ assert isinstance(exc_info.value, SandboxUnavailableError)
818
+ assert exc_info.value.retry_after == 30
819
+
820
+
720
821
  @respx.mock
721
822
  async def test_non_json_body_raises_sandbox_response_error(api: SandboxAPI):
722
823
  respx.get(f"{BASE_URL}/hosts").mock(