drukbox-python-sdk 0.1.0__tar.gz → 0.2.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/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: drukbox-python-sdk
3
- Version: 0.1.0
3
+ Version: 0.2.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,8 @@ Public exports live in `drukbox_sdk`:
71
71
  - `SandboxAPI`
72
72
  - `SandboxHost`
73
73
  - `SandboxTemplate`
74
+ - `Secret`
75
+ - `Issuer`
74
76
  - `DoctorReport` and `DoctorCheck`
75
77
  - `HTTPProxy` and `HTTPProxyAttachment`
76
78
  - `SandboxAPIError` and typed subclasses for auth, not found, conflict,
@@ -88,13 +90,39 @@ Supported host operations:
88
90
  - `aclose`
89
91
 
90
92
  `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.
93
+ `permanent`, `provider`, `instance_type`, `disk_gb`, `template`, `secrets`,
94
+ and `Idempotency-Key` inputs. `instance_type` (provider-native size, e.g.
93
95
  `t3.xlarge` / `cx33`) and `disk_gb` pin the VM shape; omit either for the
94
96
  provider default. Omit `expires_at` for the default lease, pass a datetime for
95
97
  an explicit expiry, or pass `permanent=True` for a never-reaped host.
96
98
  `template` accepts a template ID. An explicit `image` wins over it.
97
99
 
100
+ `secrets` gives the box credentials it never sees. Each entry is a `Secret`,
101
+ a value the caller holds, or an `Issuer`, a URL Drukbox fetches the value
102
+ from. The box gets a placeholder per entry in the service's variable, and the
103
+ Drukbox swaps it on the way out. A custom service names its `host` and
104
+ `auth_variable`:
105
+
106
+ ```python
107
+ from drukbox_sdk import Issuer, Secret
108
+
109
+ host = await sandbox.create_host(
110
+ secrets={
111
+ "anthropic": Issuer(
112
+ "https://mint.example/box-1/anthropic",
113
+ headers={"Authorization": "Bearer ..."},
114
+ refresh="1h",
115
+ ),
116
+ "openai": Secret("sk-..."),
117
+ "acme": Secret("ak_...", host="api.acme.test", auth_variable="ACME_TOKEN"),
118
+ },
119
+ )
120
+ ```
121
+
122
+ The response carries no secret. A service name Drukbox does not know raises
123
+ `SandboxValidationError`. A deployment without the secrets proxy raises
124
+ `SandboxConflictError`.
125
+
98
126
  `renew_host` extends a host's lease via `POST /hosts/{id}/renew`. Omit
99
127
  `expires_at` to extend by the service's default TTL; renewal never makes a host
100
128
  permanent.
@@ -58,6 +58,8 @@ Public exports live in `drukbox_sdk`:
58
58
  - `SandboxAPI`
59
59
  - `SandboxHost`
60
60
  - `SandboxTemplate`
61
+ - `Secret`
62
+ - `Issuer`
61
63
  - `DoctorReport` and `DoctorCheck`
62
64
  - `HTTPProxy` and `HTTPProxyAttachment`
63
65
  - `SandboxAPIError` and typed subclasses for auth, not found, conflict,
@@ -75,13 +77,39 @@ Supported host operations:
75
77
  - `aclose`
76
78
 
77
79
  `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.
80
+ `permanent`, `provider`, `instance_type`, `disk_gb`, `template`, `secrets`,
81
+ and `Idempotency-Key` inputs. `instance_type` (provider-native size, e.g.
80
82
  `t3.xlarge` / `cx33`) and `disk_gb` pin the VM shape; omit either for the
81
83
  provider default. Omit `expires_at` for the default lease, pass a datetime for
82
84
  an explicit expiry, or pass `permanent=True` for a never-reaped host.
83
85
  `template` accepts a template ID. An explicit `image` wins over it.
84
86
 
87
+ `secrets` gives the box credentials it never sees. Each entry is a `Secret`,
88
+ a value the caller holds, or an `Issuer`, a URL Drukbox fetches the value
89
+ from. The box gets a placeholder per entry in the service's variable, and the
90
+ Drukbox swaps it on the way out. A custom service names its `host` and
91
+ `auth_variable`:
92
+
93
+ ```python
94
+ from drukbox_sdk import Issuer, Secret
95
+
96
+ host = await sandbox.create_host(
97
+ secrets={
98
+ "anthropic": Issuer(
99
+ "https://mint.example/box-1/anthropic",
100
+ headers={"Authorization": "Bearer ..."},
101
+ refresh="1h",
102
+ ),
103
+ "openai": Secret("sk-..."),
104
+ "acme": Secret("ak_...", host="api.acme.test", auth_variable="ACME_TOKEN"),
105
+ },
106
+ )
107
+ ```
108
+
109
+ The response carries no secret. A service name Drukbox does not know raises
110
+ `SandboxValidationError`. A deployment without the secrets proxy raises
111
+ `SandboxConflictError`.
112
+
85
113
  `renew_host` extends a host's lease via `POST /hosts/{id}/renew`. Omit
86
114
  `expires_at` to extend by the service's default TTL; renewal never makes a host
87
115
  permanent.
@@ -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.2.0"
8
8
  description = "Async Python client for Drukbox."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -9,9 +9,11 @@ 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,
15
17
  )
16
18
  from .exceptions import (
17
19
  SandboxAPIError,
@@ -29,6 +31,7 @@ __all__ = [
29
31
  "DoctorReport",
30
32
  "HTTPProxy",
31
33
  "HTTPProxyAttachment",
34
+ "Issuer",
32
35
  "SandboxAPI",
33
36
  "SandboxAPIError",
34
37
  "SandboxAuthError",
@@ -40,4 +43,5 @@ __all__ = [
40
43
  "SandboxTemplate",
41
44
  "SandboxUnavailableError",
42
45
  "SandboxValidationError",
46
+ "Secret",
43
47
  ]
@@ -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
 
@@ -108,6 +108,50 @@ class SandboxHost:
108
108
  expires_at: str | None
109
109
 
110
110
 
111
+ @dataclass(frozen=True, kw_only=True)
112
+ class _Service:
113
+ """The custom service fields of a secret. None is unset. An empty
114
+ ``auth_prefix`` is set, and Drukbox reads it as no prefix."""
115
+
116
+ host: str | None = None
117
+ auth_variable: str | None = None
118
+ auth_header: str | None = None
119
+ auth_prefix: str | None = None
120
+
121
+ def get_service(self) -> dict[str, Any]:
122
+ fields = {
123
+ "host": self.host,
124
+ "auth_variable": self.auth_variable,
125
+ "auth_header": self.auth_header,
126
+ "auth_prefix": self.auth_prefix,
127
+ }
128
+ return {name: value for name, value in fields.items() if value is not None}
129
+
130
+
131
+ @dataclass(frozen=True)
132
+ class Secret(_Service):
133
+ """A credential the caller holds."""
134
+
135
+ value: str = field(repr=False)
136
+
137
+ def entry(self) -> dict[str, Any]:
138
+ return {**self.get_service(), "value": self.value}
139
+
140
+
141
+ @dataclass(frozen=True)
142
+ class Issuer(_Service):
143
+ """A credential Drukbox fetches from ``url`` with ``headers``, and again
144
+ every ``refresh`` when the answer carries no ``expires_at``."""
145
+
146
+ url: str
147
+ headers: dict[str, str] = field(repr=False)
148
+ refresh: str
149
+
150
+ def entry(self) -> dict[str, Any]:
151
+ issuer = {"url": self.url, "headers": dict(self.headers), "refresh": self.refresh}
152
+ return {**self.get_service(), "issuer": issuer}
153
+
154
+
111
155
  @dataclass(frozen=True)
112
156
  class SandboxTemplate:
113
157
  """Snapshot of a reusable sandbox template as returned by the service.
@@ -240,6 +284,7 @@ class SandboxAPI:
240
284
  instance_type: str | None = None,
241
285
  permanent: bool = False,
242
286
  provider: str | None = None,
287
+ secrets: dict[str, Secret | Issuer] | None = None,
243
288
  template: uuid.UUID | str | None = None,
244
289
  ) -> SandboxHost:
245
290
  """Provision a new host.
@@ -274,6 +319,9 @@ class SandboxAPI:
274
319
  Lease: omit ``expires_at`` for the service's default lease, pass a
275
320
  ``datetime`` for an explicit expiry, or pass ``permanent=True`` for
276
321
  a host the janitor never reaps.
322
+
323
+ ``secrets`` maps a service name to a :class:`Secret` or an
324
+ :class:`Issuer`. The response carries no secret.
277
325
  """
278
326
 
279
327
  if expires_at and permanent:
@@ -295,6 +343,8 @@ class SandboxAPI:
295
343
  payload["instance_type"] = instance_type
296
344
  if provider is not None:
297
345
  payload["provider"] = provider
346
+ if secrets:
347
+ payload["secrets"] = {name: entry.entry() for name, entry in secrets.items()}
298
348
  if template is not None:
299
349
  payload["template"] = str(template)
300
350
 
@@ -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,7 @@ from drukbox_sdk import (
32
34
  SandboxResponseError,
33
35
  SandboxUnavailableError,
34
36
  SandboxValidationError,
37
+ Secret,
35
38
  )
36
39
 
37
40
  BASE_URL = "https://sandbox.test"
@@ -268,6 +271,115 @@ async def test_create_host_409_when_template_is_building(api: SandboxAPI):
268
271
  await api.create_host(template=template_id)
269
272
 
270
273
 
274
+ @respx.mock
275
+ async def test_create_host_sends_secrets_with_only_the_fields_set(api: SandboxAPI):
276
+ route = respx.post(f"{BASE_URL}/hosts").mock(
277
+ return_value=httpx.Response(201, json=_host_payload()),
278
+ )
279
+
280
+ await api.create_host(
281
+ secrets={
282
+ "anthropic": Issuer(
283
+ "https://mint.test/box-1/anthropic",
284
+ headers={"Authorization": "Bearer d2d"},
285
+ refresh="1h",
286
+ ),
287
+ "openai": Secret("sk-real"),
288
+ "acme": Secret("ak_real", host="api.acme.test", auth_variable="ACME_TOKEN"),
289
+ },
290
+ )
291
+
292
+ body = json.loads(route.calls.last.request.content)
293
+ assert body["secrets"] == {
294
+ "anthropic": {
295
+ "issuer": {
296
+ "url": "https://mint.test/box-1/anthropic",
297
+ "headers": {"Authorization": "Bearer d2d"},
298
+ "refresh": "1h",
299
+ }
300
+ },
301
+ "openai": {"value": "sk-real"},
302
+ "acme": {"value": "ak_real", "host": "api.acme.test", "auth_variable": "ACME_TOKEN"},
303
+ }
304
+
305
+
306
+ @respx.mock
307
+ async def test_create_host_omits_secrets_when_unset(api: SandboxAPI):
308
+ route = respx.post(f"{BASE_URL}/hosts").mock(
309
+ return_value=httpx.Response(201, json=_host_payload()),
310
+ )
311
+
312
+ await api.create_host()
313
+
314
+ assert "secrets" not in json.loads(route.calls.last.request.content)
315
+
316
+
317
+ @respx.mock
318
+ async def test_create_host_keeps_an_empty_auth_prefix(api: SandboxAPI):
319
+ route = respx.post(f"{BASE_URL}/hosts").mock(
320
+ return_value=httpx.Response(201, json=_host_payload()),
321
+ )
322
+ custom = {"host": "api.acme.test", "auth_variable": "ACME_TOKEN", "auth_prefix": ""}
323
+
324
+ await api.create_host(
325
+ secrets={
326
+ "acme": Secret("ak_real", **custom),
327
+ "acme-minted": Issuer("https://mint.test/acme", {"X-Key": "k"}, "1h", **custom),
328
+ },
329
+ )
330
+
331
+ body = json.loads(route.calls.last.request.content)
332
+ assert body["secrets"]["acme"]["auth_prefix"] == ""
333
+ assert body["secrets"]["acme-minted"]["auth_prefix"] == ""
334
+
335
+
336
+ def test_a_record_never_shows_its_credential():
337
+ secret = Secret("VALUE_MARKER", host="api.acme.test", auth_variable="ACME_TOKEN")
338
+ issuer = Issuer("https://mint.test/acme", {"Authorization": "ISSUER_MARKER"}, "1h")
339
+
340
+ shown = repr({"acme": secret, "minted": issuer})
341
+
342
+ assert "VALUE_MARKER" not in shown
343
+ assert "ISSUER_MARKER" not in shown
344
+ assert "api.acme.test" in shown and "https://mint.test/acme" in shown
345
+
346
+
347
+ def test_the_custom_service_fields_are_keywords():
348
+ with pytest.raises(TypeError):
349
+ Secret("ak_real", "api.acme.test") # type: ignore[misc]
350
+ with pytest.raises(TypeError):
351
+ Issuer("https://mint.test", {"X-Key": "k"}, "1h", "api.acme.test") # type: ignore[misc]
352
+
353
+
354
+ @respx.mock
355
+ async def test_create_host_409_without_the_secrets_proxy_raises_conflict(api: SandboxAPI):
356
+ respx.post(f"{BASE_URL}/hosts").mock(
357
+ return_value=httpx.Response(
358
+ 409,
359
+ json={
360
+ "detail": "secrets need the secrets proxy, which this deployment lacks",
361
+ "error_code": "SECRETS_PROXY_NOT_CONFIGURED",
362
+ },
363
+ ),
364
+ )
365
+
366
+ with pytest.raises(SandboxConflictError, match="secrets proxy"):
367
+ await api.create_host(secrets={"openai": Secret("sk-real")})
368
+
369
+
370
+ @respx.mock
371
+ async def test_create_host_422_for_an_unknown_secret_service_raises_validation(api: SandboxAPI):
372
+ respx.post(f"{BASE_URL}/hosts").mock(
373
+ return_value=httpx.Response(
374
+ 422,
375
+ json={"detail": [{"loc": ["body", "secrets"], "msg": "unknown secret service 'acme'"}]},
376
+ ),
377
+ )
378
+
379
+ with pytest.raises(SandboxValidationError, match="unknown secret service"):
380
+ await api.create_host(secrets={"acme": Secret("ak_real")})
381
+
382
+
271
383
  @respx.mock
272
384
  async def test_get_host_returns_parsed_host(api: SandboxAPI):
273
385
  payload = _host_payload(status="active")