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.
- {drukbox_python_sdk-0.1.0 → drukbox_python_sdk-0.3.0}/AGENTS.md +4 -1
- {drukbox_python_sdk-0.1.0 → drukbox_python_sdk-0.3.0}/PKG-INFO +53 -4
- {drukbox_python_sdk-0.1.0 → drukbox_python_sdk-0.3.0}/README.md +52 -3
- {drukbox_python_sdk-0.1.0 → drukbox_python_sdk-0.3.0}/pyproject.toml +1 -1
- {drukbox_python_sdk-0.1.0 → drukbox_python_sdk-0.3.0}/src/drukbox_sdk/__init__.py +6 -0
- {drukbox_python_sdk-0.1.0 → drukbox_python_sdk-0.3.0}/src/drukbox_sdk/api.py +98 -1
- {drukbox_python_sdk-0.1.0 → drukbox_python_sdk-0.3.0}/src/drukbox_sdk/exceptions.py +6 -5
- {drukbox_python_sdk-0.1.0 → drukbox_python_sdk-0.3.0}/tests/test_api.py +191 -0
- {drukbox_python_sdk-0.1.0 → drukbox_python_sdk-0.3.0}/.github/workflows/on-main-merge.yml +0 -0
- {drukbox_python_sdk-0.1.0 → drukbox_python_sdk-0.3.0}/.github/workflows/on-pull-request.yml +0 -0
- {drukbox_python_sdk-0.1.0 → drukbox_python_sdk-0.3.0}/.github/workflows/release.yml +0 -0
- {drukbox_python_sdk-0.1.0 → drukbox_python_sdk-0.3.0}/.gitignore +0 -0
- {drukbox_python_sdk-0.1.0 → drukbox_python_sdk-0.3.0}/LICENSE +0 -0
- {drukbox_python_sdk-0.1.0 → drukbox_python_sdk-0.3.0}/tests/__init__.py +0 -0
|
@@ -25,7 +25,7 @@ object is a sandbox host.
|
|
|
25
25
|
|
|
26
26
|
```text
|
|
27
27
|
src/drukbox_sdk/
|
|
28
|
-
api.py # SandboxAPI, records,
|
|
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.
|
|
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`,
|
|
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,
|
|
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`,
|
|
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,
|
|
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
|
|
|
@@ -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.
|
|
41
|
-
the
|
|
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,
|
|
57
|
-
proxy target URL carrying a path or credentials
|
|
58
|
-
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|