drukbox-python-sdk 0.0.1__py3-none-any.whl

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.
@@ -0,0 +1,95 @@
1
+ Metadata-Version: 2.4
2
+ Name: drukbox-python-sdk
3
+ Version: 0.0.1
4
+ Summary: Async Python client for Drukbox.
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: httpx>=0.28
7
+ Description-Content-Type: text/markdown
8
+
9
+ # drukbox-python-sdk
10
+
11
+ Async Python client for the [Drukbox] host API.
12
+
13
+ The SDK provisions sandbox VMs, reads host state, deletes hosts, and
14
+ returns the SSH connection details a caller needs. It speaks HTTP only:
15
+ SSH sessions, file transfer, command execution, and retry orchestration
16
+ belong in the caller.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pip install drukbox-python-sdk
22
+ uv add drukbox-python-sdk
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ ```python
28
+ from drukbox_sdk import SandboxAPI
29
+
30
+ sandbox = SandboxAPI(
31
+ base_url="https://sandbox.internal.ts.net",
32
+ token="...",
33
+ )
34
+
35
+ try:
36
+ host = await sandbox.create_host(
37
+ image="ghcr.io/drukbox/sandbox:abc123",
38
+ env={"FOO": "bar"},
39
+ idempotency_key="agent-run-42",
40
+ )
41
+ # Use host.external_ssh_host (or host.internal_ssh_host when the
42
+ # service runs with Tailscale enabled), host.external_ssh_port,
43
+ # and host.known_hosts with asyncssh or another SSH client.
44
+ finally:
45
+ await sandbox.delete_host(host.id)
46
+ await sandbox.aclose()
47
+ ```
48
+
49
+ `create_host` blocks until the host is `active` — typically ~10–30s, up to a
50
+ few minutes worst case. The SDK's default `timeout` (300s) covers this. Pass
51
+ an `idempotency_key` for retry safety: a retry with the same key after a
52
+ successful provision returns the original host instead of creating a duplicate.
53
+
54
+ `SandboxAPI.from_env(prefix="SANDBOX_")` reads
55
+ `SANDBOX_SERVICE_URL`, `SANDBOX_SERVICE_TOKEN`, and optional
56
+ `SANDBOX_SERVICE_TIMEOUT`.
57
+
58
+ ## Contract
59
+
60
+ Public exports live in `drukbox_sdk`:
61
+
62
+ - `SandboxAPI`
63
+ - `SandboxHost`
64
+ - `SandboxAPIError` and typed subclasses for auth, not found, conflict,
65
+ unavailable, and unclassified response errors
66
+
67
+ Supported host operations:
68
+
69
+ - `create_host`
70
+ - `get_host`
71
+ - `attach`
72
+ - `list_hosts`
73
+ - `delete_host`
74
+ - `aclose`
75
+
76
+ `create_host` supports the service's optional `image`, `env`, `expires_at`,
77
+ and `Idempotency-Key` inputs.
78
+
79
+ The SDK does not mint Tailscale auth keys, manage ACLs, establish SSH,
80
+ provision Linux users, transfer files, or run remote commands.
81
+
82
+ ## Development
83
+
84
+ ```bash
85
+ uv sync
86
+ uv run ruff check
87
+ uv run ruff format --check
88
+ uv run pyright
89
+ uv run pytest
90
+ ```
91
+
92
+ Tests use `respx` to fake the Drukbox HTTP API. They do not need a
93
+ real network, VM provider, or Drukbox service.
94
+
95
+ [Drukbox]: https://github.com/clawhaven/drukbox
@@ -0,0 +1,6 @@
1
+ drukbox_sdk/__init__.py,sha256=KJmc7_s4WLMBbD_IlMoILfGvfFNcaR8cu7TbPeKHDs4,658
2
+ drukbox_sdk/api.py,sha256=wz-xjcg04FdLm8DIFSJhC-OQaaX5K0R7sZRJrTuD5RE,11940
3
+ drukbox_sdk/exceptions.py,sha256=eGtoDqmyt5vF0f4AJ4XmOatEe1vVl9KDOfI2YygdA2k,1811
4
+ drukbox_python_sdk-0.0.1.dist-info/METADATA,sha256=PAEEpxMHuVJIuxp_dR6l4380XBSUA_bhuO3diqmInbg,2456
5
+ drukbox_python_sdk-0.0.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
6
+ drukbox_python_sdk-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,31 @@
1
+ """Async Python client for Drukbox.
2
+
3
+ Public surface — anything not re-exported here is an implementation
4
+ detail and may change without notice.
5
+ """
6
+
7
+ from .api import (
8
+ SandboxAPI,
9
+ SandboxHost,
10
+ )
11
+ from .exceptions import (
12
+ SandboxAPIError,
13
+ SandboxAuthError,
14
+ SandboxConflictError,
15
+ SandboxNotFoundError,
16
+ SandboxProvisioningError,
17
+ SandboxResponseError,
18
+ SandboxUnavailableError,
19
+ )
20
+
21
+ __all__ = [
22
+ "SandboxAPI",
23
+ "SandboxAPIError",
24
+ "SandboxAuthError",
25
+ "SandboxConflictError",
26
+ "SandboxHost",
27
+ "SandboxNotFoundError",
28
+ "SandboxProvisioningError",
29
+ "SandboxResponseError",
30
+ "SandboxUnavailableError",
31
+ ]
drukbox_sdk/api.py ADDED
@@ -0,0 +1,313 @@
1
+ """Async HTTP client for Drukbox.
2
+
3
+ The service is the canonical owner of host provisioning, Tailscale
4
+ auth-key minting and device discovery, exe.dev VM lifecycle, and host
5
+ teardown. This SDK is a thin wrapper around its HTTP API; everything
6
+ that needs to happen *inside* a provisioned VM (SSH, file transfer,
7
+ command execution) is the caller's responsibility — the SDK hands back
8
+ the host record with ``external_ssh_host`` / ``external_ssh_port`` /
9
+ ``internal_ssh_host`` / ``known_hosts`` and stops there.
10
+
11
+ Usage::
12
+
13
+ from drukbox_sdk import SandboxAPI
14
+
15
+ sandbox = SandboxAPI(
16
+ base_url="https://sandbox.internal.ts.net",
17
+ token="...",
18
+ )
19
+ try:
20
+ host = await sandbox.create_host(
21
+ image="ghcr.io/.../sandbox:abc123",
22
+ idempotency_key="agent-run-42",
23
+ )
24
+ # ... use host.external_ssh_host etc. with asyncssh ...
25
+ finally:
26
+ await sandbox.delete_host(host.id)
27
+ await sandbox.aclose()
28
+
29
+ For env-backed config use :meth:`SandboxAPI.from_env`.
30
+ """
31
+
32
+ import asyncio
33
+ import os
34
+ import uuid
35
+ from dataclasses import dataclass, fields
36
+ from datetime import datetime
37
+ from typing import Any, Self
38
+
39
+ import httpx
40
+
41
+ from .exceptions import (
42
+ SandboxAuthError,
43
+ SandboxConflictError,
44
+ SandboxNotFoundError,
45
+ SandboxProvisioningError,
46
+ SandboxResponseError,
47
+ SandboxUnavailableError,
48
+ )
49
+
50
+ # Explicit pool budget. The httpx defaults (100 / 20) are plenty for
51
+ # the SDK's traffic shape (a handful of provisioning calls per agent
52
+ # run), but keeping the values visible at the import site means a
53
+ # future bump in concurrency does not silently exhaust the pool.
54
+ _SANDBOX_HTTP_LIMITS = httpx.Limits(
55
+ max_connections=20,
56
+ max_keepalive_connections=5,
57
+ )
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class SandboxHost:
62
+ """Snapshot of a provisioned host as returned by the service.
63
+
64
+ The shape mirrors the Drukbox ``Host`` schema. ``external_ssh_host``
65
+ is always populated by the VM provider; ``internal_ssh_host`` is
66
+ populated only when the service runs with Tailscale enabled (MagicDNS
67
+ name on the tailnet). The internal path is always reached on port 22
68
+ by Tailscale convention, so there is no ``internal_ssh_port``.
69
+ Callers pick whichever path they can reach and dial it themselves —
70
+ this SDK doesn't speak SSH.
71
+ """
72
+
73
+ id: str
74
+ name: str
75
+ status: str
76
+ provider: str
77
+ image: str
78
+ external_ssh_host: str
79
+ external_ssh_port: int
80
+ internal_ssh_host: str | None
81
+ known_hosts: str
82
+ tailscale_device_id: str | None
83
+ last_error: str
84
+ created_at: str
85
+ updated_at: str
86
+ activated_at: str | None
87
+ expires_at: str | None
88
+
89
+
90
+ _SANDBOX_HOST_FIELDS = {field.name for field in fields(SandboxHost)}
91
+
92
+
93
+ def _parse_sandbox_host(data: dict[str, Any]) -> SandboxHost:
94
+ """Build a :class:`SandboxHost` picking only known fields.
95
+
96
+ Defensive against the service adding new fields — those flow
97
+ through harmlessly without breaking the SDK on the unsuspecting
98
+ caller's side.
99
+ """
100
+
101
+ return SandboxHost(**{key: data[key] for key in _SANDBOX_HOST_FIELDS})
102
+
103
+
104
+ class SandboxAPI:
105
+ """Async client for Drukbox.
106
+
107
+ Construct one per process (or one per consuming subsystem) and
108
+ reuse it. The internal ``httpx.AsyncClient`` is loop-aware: if the
109
+ client gets used from a different event loop than the one it was
110
+ created on (e.g. an ASGI request handler vs. a CLI command vs. a
111
+ cron job all using the same SDK instance via module-level state),
112
+ it transparently rebinds to the running loop on next use. This is
113
+ a known foot-gun with long-lived ``httpx.AsyncClient`` instances;
114
+ we handle it here so callers don't have to.
115
+
116
+ Always call :meth:`aclose` during graceful shutdown.
117
+ """
118
+
119
+ def __init__(self, *, base_url: str, token: str, timeout: float = 300.0) -> None:
120
+ # The default covers the worst-case server-side budget for inline
121
+ # `POST /hosts`: Tailscale device discovery (~180s) + ssh-keyscan
122
+ # retries (~30s) + provider + network jitter. A tighter timeout that
123
+ # fires while the server is still provisioning leaves an orphan VM
124
+ # on the provider side until the janitor reaps it.
125
+ self.base_url = base_url.rstrip("/")
126
+ self.token = token
127
+ self.timeout = timeout
128
+ self._client: httpx.AsyncClient | None = None
129
+ # The bound event loop is recorded the first time a client is
130
+ # created. httpx clients are tied to the loop they were
131
+ # instantiated on; reusing one across loops raises at request
132
+ # time. Track the loop here so cross-loop reuse rebinds
133
+ # instead of crashing.
134
+ self._client_loop: asyncio.AbstractEventLoop | None = None
135
+ # Concurrent first-callers can both observe ``self._client is
136
+ # None`` and race to create two clients, leaking one. Serialize
137
+ # the initialization path with a lazy-allocated lock.
138
+ self._client_lock: asyncio.Lock | None = None
139
+
140
+ @classmethod
141
+ def from_env(cls, *, prefix: str = "SANDBOX_") -> Self:
142
+ """Build from ``{prefix}SERVICE_URL`` / ``{prefix}SERVICE_TOKEN``
143
+ / ``{prefix}SERVICE_TIMEOUT`` env vars.
144
+
145
+ Convenience for callers that don't want to thread settings
146
+ through their own config layer. The constructor stays the
147
+ canonical entry point — this just reads the env once.
148
+ """
149
+
150
+ url = os.environ[f"{prefix}SERVICE_URL"]
151
+ token = os.environ[f"{prefix}SERVICE_TOKEN"]
152
+ timeout = float(os.environ.get(f"{prefix}SERVICE_TIMEOUT", "300"))
153
+ return cls(base_url=url, token=token, timeout=timeout)
154
+
155
+ # ------------------------------------------------------------------
156
+ # Host lifecycle
157
+ # ------------------------------------------------------------------
158
+
159
+ async def create_host(
160
+ self,
161
+ *,
162
+ env: dict[str, str] | None = None,
163
+ expires_at: datetime | None = None,
164
+ idempotency_key: str | None = None,
165
+ image: str | None = None,
166
+ ) -> SandboxHost:
167
+ """Provision a new host.
168
+
169
+ The service provisions inline; this call blocks until the host
170
+ is ``active`` (~10-30s typical, up to a few minutes in the worst
171
+ case). Raises :class:`SandboxProvisioningError` if the service
172
+ reaches its own provisioning failure (502); raises
173
+ :class:`SandboxResponseError` on transport failure or unexpected
174
+ status.
175
+
176
+ ``idempotency_key`` is sent as the service's ``Idempotency-Key``
177
+ header. Useful for retry safety on flaky networks — a retry with
178
+ the same key after a successful provision returns the original
179
+ host instead of creating a duplicate.
180
+ """
181
+
182
+ payload: dict[str, Any] = {}
183
+ if env is not None:
184
+ payload["env"] = env
185
+ if expires_at is not None:
186
+ payload["expires_at"] = expires_at.isoformat()
187
+ if image is not None:
188
+ payload["image"] = image
189
+
190
+ headers: dict[str, str] = {}
191
+ if idempotency_key is not None:
192
+ headers["Idempotency-Key"] = idempotency_key
193
+
194
+ data = await self._request("POST", "/hosts", json=payload, headers=headers)
195
+ assert isinstance(data, dict)
196
+ return _parse_sandbox_host(data)
197
+
198
+ async def get_host(self, host_id: uuid.UUID | str) -> SandboxHost:
199
+ data = await self._request("GET", f"/hosts/{host_id}")
200
+ assert isinstance(data, dict)
201
+ return _parse_sandbox_host(data)
202
+
203
+ async def attach(self, host_id: uuid.UUID | str) -> SandboxHost:
204
+ """Alias for :meth:`get_host` that reads better at call sites
205
+ coming back to a host they provisioned in an earlier process.
206
+
207
+ Same wire call; the rename only exists so a restart-resumption
208
+ path doesn't have to start with ``get_host`` (which would read
209
+ like "go discover a host" when the intent is "reattach to one
210
+ I already know about").
211
+ """
212
+
213
+ return await self.get_host(host_id)
214
+
215
+ async def list_hosts(self) -> list[SandboxHost]:
216
+ data = await self._request("GET", "/hosts")
217
+ assert isinstance(data, list)
218
+ return [_parse_sandbox_host(item) for item in data]
219
+
220
+ async def delete_host(self, host_id: uuid.UUID | str) -> None:
221
+ await self._request("DELETE", f"/hosts/{host_id}")
222
+
223
+ async def aclose(self) -> None:
224
+ if self._client is None:
225
+ return
226
+ await self._client.aclose()
227
+ self._client = None
228
+ self._client_loop = None
229
+
230
+ # ------------------------------------------------------------------
231
+ # Internals
232
+ # ------------------------------------------------------------------
233
+
234
+ async def _request(
235
+ self,
236
+ method: str,
237
+ path: str,
238
+ *,
239
+ headers: dict[str, str] | None = None,
240
+ json: dict[str, Any] | None = None,
241
+ ) -> dict[str, Any] | list[Any]:
242
+ client = await self._get_client()
243
+ request_headers = {
244
+ "Authorization": f"Bearer {self.token}",
245
+ "Accept": "application/json",
246
+ }
247
+ if headers is not None:
248
+ request_headers.update(headers)
249
+
250
+ try:
251
+ response = await client.request(
252
+ method,
253
+ f"{self.base_url}{path}",
254
+ json=json,
255
+ headers=request_headers,
256
+ timeout=self.timeout,
257
+ )
258
+ except httpx.RequestError as exc:
259
+ raise SandboxUnavailableError(f"Sandbox service transport failed: {exc}") from exc
260
+
261
+ if response.status_code == 204:
262
+ return {}
263
+
264
+ try:
265
+ json_response = response.json()
266
+ except ValueError as exc:
267
+ raise SandboxResponseError("Sandbox service returned non-JSON output") from exc
268
+
269
+ if response.status_code in {401, 403}:
270
+ raise SandboxAuthError(json_response.get("detail", "auth failed"))
271
+
272
+ if response.status_code == 404:
273
+ raise SandboxNotFoundError(json_response.get("detail", "not found"))
274
+
275
+ if response.status_code == 409:
276
+ raise SandboxConflictError(json_response.get("detail", "conflict"))
277
+
278
+ if response.status_code == 502:
279
+ raise SandboxProvisioningError(json_response.get("detail", "provisioning failed"))
280
+
281
+ if response.status_code == 503:
282
+ raise SandboxUnavailableError(json_response.get("detail", "service unavailable"))
283
+
284
+ if response.status_code >= 400:
285
+ raise SandboxResponseError(json_response.get("detail", "error"))
286
+ return json_response
287
+
288
+ async def _get_client(self) -> httpx.AsyncClient:
289
+ running_loop = asyncio.get_running_loop()
290
+ # Fast-path: client exists and is bound to the current loop.
291
+ if self._client is not None and self._client_loop is running_loop:
292
+ return self._client
293
+ # Slow-path: either no client yet, or the client is bound to a
294
+ # stale loop (e.g. fixture teardown). Serialize through the
295
+ # lock.
296
+ if self._client_lock is None:
297
+ self._client_lock = asyncio.Lock()
298
+ async with self._client_lock:
299
+ # Re-check under the lock; another coroutine may have raced
300
+ # ahead.
301
+ if self._client is not None and self._client_loop is running_loop:
302
+ return self._client
303
+ if self._client is not None:
304
+ # Stale-loop client: closing on its own loop is unsafe,
305
+ # so drop the reference and rely on GC. httpx will emit
306
+ # an "unclosed client" warning, which is the correct
307
+ # signal that the process-level lifecycle hook
308
+ # (e.g. ASGI lifespan) didn't run on the previous loop.
309
+ self._client = None
310
+ self._client_loop = None
311
+ self._client = httpx.AsyncClient(limits=_SANDBOX_HTTP_LIMITS)
312
+ self._client_loop = running_loop
313
+ return self._client
@@ -0,0 +1,54 @@
1
+ """Typed errors for Drukbox interactions.
2
+
3
+ The hierarchy lets callers distinguish "I can't reach the service at
4
+ all" from "the service told me no" and lets them narrow on common HTTP
5
+ shapes (auth, not-found, conflict) without parsing status codes
6
+ themselves.
7
+ """
8
+
9
+
10
+ class SandboxUnavailableError(RuntimeError):
11
+ """Sandbox service is unreachable or transiently broken.
12
+
13
+ Raised for both transport-level failures (DNS, connection refused,
14
+ TLS, timeouts) and 503 responses from the service itself. Both
15
+ signal "retry with backoff" rather than "the request was rejected."
16
+ Distinct from :class:`SandboxAPIError`, which represents the
17
+ service deliberately refusing a request.
18
+ """
19
+
20
+
21
+ class SandboxAPIError(RuntimeError):
22
+ """Base for any error the sandbox service returned via HTTP."""
23
+
24
+
25
+ class SandboxAuthError(SandboxAPIError):
26
+ """401/403 from the sandbox service. Token wrong, missing, or revoked."""
27
+
28
+
29
+ class SandboxNotFoundError(SandboxAPIError):
30
+ """404 — the resource ID isn't known to the sandbox service.
31
+
32
+ Common cause during host-attach paths: the host was already torn
33
+ down by a sibling worker / housekeeping sweep.
34
+ """
35
+
36
+
37
+ class SandboxConflictError(SandboxAPIError):
38
+ """409 — the operation conflicts with the current state.
39
+
40
+ e.g. provisioning a Linux user on a host that already has one with
41
+ the same name.
42
+ """
43
+
44
+
45
+ class SandboxProvisioningError(SandboxAPIError):
46
+ """502 — the service tried to provision the host and the provider,
47
+ Tailscale, or ssh-keyscan step failed. The host row stays in
48
+ ``error`` state on the service side; call :meth:`delete_host` to
49
+ release any partial provider state.
50
+ """
51
+
52
+
53
+ class SandboxResponseError(SandboxAPIError):
54
+ """Everything else the service returned that the SDK doesn't classify."""