Skip to content

Commit f460329

Browse files
authored
Send secrets on create_host (#6)
POST /hosts takes secrets, and the SDK did not send them, so a caller could not give a box a credential. create_host now takes secrets, a map from a service name to a Secret, a value the caller holds, or an Issuer, a URL the service fetches the value from. Both are frozen records. The custom service fields, host, auth_variable, auth_header, and auth_prefix, are keywords, and only the fields a caller sets go on the wire. The response carries no secret, and SandboxHost does not change. The records' repr never shows the value or the issuer's headers, so a log line with the secrets map exposes nothing. An empty auth_prefix goes on the wire, since Drukbox reads it as no prefix. A 409 for a deployment without the secrets proxy maps to SandboxConflictError. A 422 for a service name Drukbox does not know maps to SandboxValidationError. Both mappings existed. Their docstrings name the new causes. The README shows the surface. Version 0.2.0.
1 parent 506d82a commit f460329

7 files changed

Lines changed: 205 additions & 10 deletions

File tree

‎AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ object is a sandbox host.
2525

2626
```text
2727
src/drukbox_sdk/
28-
api.py # SandboxAPI, records, parsers, HTTP request handling
28+
api.py # SandboxAPI, records (SandboxHost, Secret, Issuer, ...), HTTP request handling
2929
exceptions.py # SDK exception hierarchy
3030
__init__.py # Public exports
3131
tests/

‎README.md‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ Public exports live in `drukbox_sdk`:
5858
- `SandboxAPI`
5959
- `SandboxHost`
6060
- `SandboxTemplate`
61+
- `Secret`
62+
- `Issuer`
6163
- `DoctorReport` and `DoctorCheck`
6264
- `HTTPProxy` and `HTTPProxyAttachment`
6365
- `SandboxAPIError` and typed subclasses for auth, not found, conflict,
@@ -75,13 +77,39 @@ Supported host operations:
7577
- `aclose`
7678

7779
`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.
8082
`t3.xlarge` / `cx33`) and `disk_gb` pin the VM shape; omit either for the
8183
provider default. Omit `expires_at` for the default lease, pass a datetime for
8284
an explicit expiry, or pass `permanent=True` for a never-reaped host.
8385
`template` accepts a template ID. An explicit `image` wins over it.
8486

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+
85113
`renew_host` extends a host's lease via `POST /hosts/{id}/renew`. Omit
86114
`expires_at` to extend by the service's default TTL; renewal never makes a host
87115
permanent.

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "drukbox-python-sdk"
7-
version = "0.1.0"
7+
version = "0.2.0"
88
description = "Async Python client for Drukbox."
99
readme = "README.md"
1010
license = "MIT"

‎src/drukbox_sdk/__init__.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@
99
DoctorReport,
1010
HTTPProxy,
1111
HTTPProxyAttachment,
12+
Issuer,
1213
SandboxAPI,
1314
SandboxHost,
1415
SandboxTemplate,
16+
Secret,
1517
)
1618
from .exceptions import (
1719
SandboxAPIError,
@@ -29,6 +31,7 @@
2931
"DoctorReport",
3032
"HTTPProxy",
3133
"HTTPProxyAttachment",
34+
"Issuer",
3235
"SandboxAPI",
3336
"SandboxAPIError",
3437
"SandboxAuthError",
@@ -40,4 +43,5 @@
4043
"SandboxTemplate",
4144
"SandboxUnavailableError",
4245
"SandboxValidationError",
46+
"Secret",
4347
]

‎src/drukbox_sdk/api.py‎

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
import asyncio
4040
import os
4141
import uuid
42-
from dataclasses import dataclass
42+
from dataclasses import dataclass, field
4343
from datetime import datetime
4444
from typing import Any, Self
4545

@@ -108,6 +108,50 @@ class SandboxHost:
108108
expires_at: str | None
109109

110110

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+
111155
@dataclass(frozen=True)
112156
class SandboxTemplate:
113157
"""Snapshot of a reusable sandbox template as returned by the service.
@@ -240,6 +284,7 @@ async def create_host(
240284
instance_type: str | None = None,
241285
permanent: bool = False,
242286
provider: str | None = None,
287+
secrets: dict[str, Secret | Issuer] | None = None,
243288
template: uuid.UUID | str | None = None,
244289
) -> SandboxHost:
245290
"""Provision a new host.
@@ -274,6 +319,9 @@ async def create_host(
274319
Lease: omit ``expires_at`` for the service's default lease, pass a
275320
``datetime`` for an explicit expiry, or pass ``permanent=True`` for
276321
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.
277325
"""
278326

279327
if expires_at and permanent:
@@ -295,6 +343,8 @@ async def create_host(
295343
payload["instance_type"] = instance_type
296344
if provider is not None:
297345
payload["provider"] = provider
346+
if secrets:
347+
payload["secrets"] = {name: entry.entry() for name, entry in secrets.items()}
298348
if template is not None:
299349
payload["template"] = str(template)
300350

‎src/drukbox_sdk/exceptions.py‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ class SandboxNotFoundError(SandboxAPIError):
3737
class SandboxConflictError(SandboxAPIError):
3838
"""409 — the operation conflicts with the current state.
3939
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.
4242
"""
4343

4444

@@ -53,9 +53,10 @@ class SandboxProvisioningError(SandboxAPIError):
5353
class SandboxValidationError(SandboxAPIError):
5454
"""422 — the service rejected the request payload as invalid.
5555
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.
5960
"""
6061

6162

‎tests/test_api.py‎

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# pyright: reportPrivateUsage=false
1313

1414
import asyncio
15+
import json
1516
from collections.abc import AsyncGenerator
1617
from datetime import UTC, datetime
1718
from typing import Any
@@ -24,6 +25,7 @@
2425
from drukbox_sdk import (
2526
HTTPProxy,
2627
HTTPProxyAttachment,
28+
Issuer,
2729
SandboxAPI,
2830
SandboxAuthError,
2931
SandboxConflictError,
@@ -32,6 +34,7 @@
3234
SandboxResponseError,
3335
SandboxUnavailableError,
3436
SandboxValidationError,
37+
Secret,
3538
)
3639

3740
BASE_URL = "https://sandbox.test"
@@ -268,6 +271,115 @@ async def test_create_host_409_when_template_is_building(api: SandboxAPI):
268271
await api.create_host(template=template_id)
269272

270273

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+
271383
@respx.mock
272384
async def test_get_host_returns_parsed_host(api: SandboxAPI):
273385
payload = _host_payload(status="active")

0 commit comments

Comments
 (0)