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.
- {drukbox_python_sdk-0.2.0 → drukbox_python_sdk-0.3.0}/AGENTS.md +3 -0
- {drukbox_python_sdk-0.2.0 → drukbox_python_sdk-0.3.0}/PKG-INFO +23 -2
- {drukbox_python_sdk-0.2.0 → drukbox_python_sdk-0.3.0}/README.md +22 -1
- {drukbox_python_sdk-0.2.0 → drukbox_python_sdk-0.3.0}/pyproject.toml +1 -1
- {drukbox_python_sdk-0.2.0 → drukbox_python_sdk-0.3.0}/src/drukbox_sdk/__init__.py +2 -0
- {drukbox_python_sdk-0.2.0 → drukbox_python_sdk-0.3.0}/src/drukbox_sdk/api.py +47 -0
- {drukbox_python_sdk-0.2.0 → drukbox_python_sdk-0.3.0}/tests/test_api.py +79 -0
- {drukbox_python_sdk-0.2.0 → drukbox_python_sdk-0.3.0}/.github/workflows/on-main-merge.yml +0 -0
- {drukbox_python_sdk-0.2.0 → drukbox_python_sdk-0.3.0}/.github/workflows/on-pull-request.yml +0 -0
- {drukbox_python_sdk-0.2.0 → drukbox_python_sdk-0.3.0}/.github/workflows/release.yml +0 -0
- {drukbox_python_sdk-0.2.0 → drukbox_python_sdk-0.3.0}/.gitignore +0 -0
- {drukbox_python_sdk-0.2.0 → drukbox_python_sdk-0.3.0}/LICENSE +0 -0
- {drukbox_python_sdk-0.2.0 → drukbox_python_sdk-0.3.0}/src/drukbox_sdk/exceptions.py +0 -0
- {drukbox_python_sdk-0.2.0 → drukbox_python_sdk-0.3.0}/tests/__init__.py +0 -0
|
@@ -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.
|
|
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,
|
|
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,
|
|
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
|
|
|
@@ -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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|