drukbox-python-sdk 0.0.5__tar.gz → 0.0.7__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.0.7/LICENSE +21 -0
- drukbox_python_sdk-0.0.5/README.md → drukbox_python_sdk-0.0.7/PKG-INFO +37 -1
- drukbox_python_sdk-0.0.5/PKG-INFO → drukbox_python_sdk-0.0.7/README.md +24 -9
- {drukbox_python_sdk-0.0.5 → drukbox_python_sdk-0.0.7}/pyproject.toml +8 -1
- {drukbox_python_sdk-0.0.5 → drukbox_python_sdk-0.0.7}/src/drukbox_sdk/__init__.py +6 -0
- {drukbox_python_sdk-0.0.5 → drukbox_python_sdk-0.0.7}/src/drukbox_sdk/api.py +158 -37
- {drukbox_python_sdk-0.0.5 → drukbox_python_sdk-0.0.7}/src/drukbox_sdk/exceptions.py +9 -0
- {drukbox_python_sdk-0.0.5 → drukbox_python_sdk-0.0.7}/tests/test_api.py +211 -34
- {drukbox_python_sdk-0.0.5 → drukbox_python_sdk-0.0.7}/.github/workflows/on-main-merge.yml +0 -0
- {drukbox_python_sdk-0.0.5 → drukbox_python_sdk-0.0.7}/.github/workflows/on-pull-request.yml +0 -0
- {drukbox_python_sdk-0.0.5 → drukbox_python_sdk-0.0.7}/.github/workflows/release.yml +0 -0
- {drukbox_python_sdk-0.0.5 → drukbox_python_sdk-0.0.7}/.gitignore +0 -0
- {drukbox_python_sdk-0.0.5 → drukbox_python_sdk-0.0.7}/AGENTS.md +0 -0
- {drukbox_python_sdk-0.0.5 → drukbox_python_sdk-0.0.7}/tests/__init__.py +0 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Paulo Alvarado Garcia
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: drukbox-python-sdk
|
|
3
|
+
Version: 0.0.7
|
|
4
|
+
Summary: Async Python client for Drukbox.
|
|
5
|
+
Project-URL: Homepage, https://github.com/clawhaven/drukbox-python-sdk
|
|
6
|
+
Project-URL: Repository, https://github.com/clawhaven/drukbox-python-sdk
|
|
7
|
+
Project-URL: Issues, https://github.com/clawhaven/drukbox-python-sdk/issues
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Requires-Dist: httpx>=0.28
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
1
14
|
# drukbox-python-sdk
|
|
2
15
|
|
|
3
16
|
Async Python client for the [Drukbox] host API.
|
|
@@ -58,6 +71,7 @@ Public exports live in `drukbox_sdk`:
|
|
|
58
71
|
- `SandboxAPI`
|
|
59
72
|
- `SandboxHost`
|
|
60
73
|
- `DoctorReport` and `DoctorCheck`
|
|
74
|
+
- `HTTPProxy` and `HTTPProxyAttachment`
|
|
61
75
|
- `SandboxAPIError` and typed subclasses for auth, not found, conflict,
|
|
62
76
|
unavailable, and unclassified response errors
|
|
63
77
|
|
|
@@ -67,12 +81,34 @@ Supported host operations:
|
|
|
67
81
|
- `get_host`
|
|
68
82
|
- `attach`
|
|
69
83
|
- `list_hosts`
|
|
84
|
+
- `renew_host`
|
|
70
85
|
- `delete_host`
|
|
71
86
|
- `doctor`
|
|
72
87
|
- `aclose`
|
|
73
88
|
|
|
74
89
|
`create_host` supports the service's optional `image`, `env`, `expires_at`,
|
|
75
|
-
`provider`, and `Idempotency-Key` inputs.
|
|
90
|
+
`provider`, `instance_type`, `disk_gb`, and `Idempotency-Key` inputs.
|
|
91
|
+
`instance_type` (provider-native size, e.g. `t3.xlarge` / `cx33`) and `disk_gb`
|
|
92
|
+
pin the VM shape; omit either for the provider default. `expires_at` mirrors the
|
|
93
|
+
wire contract: omit it for the default lease, pass a datetime for an explicit
|
|
94
|
+
expiry, or pass `None` for a never-reaped (permanent) host.
|
|
95
|
+
|
|
96
|
+
`renew_host` extends a host's lease via `POST /hosts/{id}/renew`. Omit
|
|
97
|
+
`expires_at` to extend by the service's default TTL; renewal never makes a host
|
|
98
|
+
permanent.
|
|
99
|
+
|
|
100
|
+
Supported HTTP-proxy operations:
|
|
101
|
+
|
|
102
|
+
- `create_http_proxy`
|
|
103
|
+
- `delete_http_proxy`
|
|
104
|
+
- `attach_http_proxy`
|
|
105
|
+
- `detach_http_proxy`
|
|
106
|
+
|
|
107
|
+
HTTP proxies are account-bound exe.dev resources, not host state — deleting a
|
|
108
|
+
host does not remove proxies fronting it. `create_http_proxy` takes an
|
|
109
|
+
origin-only `target` (scheme + host, no path/query/fragment/credentials) and at
|
|
110
|
+
least one `headers` entry; `attach_http_proxy` / `detach_http_proxy` point a
|
|
111
|
+
proxy at a host's backing VM (the host must be `bootstrapping` or `active`).
|
|
76
112
|
|
|
77
113
|
`doctor` fetches `GET /doctor` — read-only dependency health. The service
|
|
78
114
|
runs one cheap, non-mutating probe per dependency (database, active VM
|
|
@@ -1,11 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: drukbox-python-sdk
|
|
3
|
-
Version: 0.0.5
|
|
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
1
|
# drukbox-python-sdk
|
|
10
2
|
|
|
11
3
|
Async Python client for the [Drukbox] host API.
|
|
@@ -66,6 +58,7 @@ Public exports live in `drukbox_sdk`:
|
|
|
66
58
|
- `SandboxAPI`
|
|
67
59
|
- `SandboxHost`
|
|
68
60
|
- `DoctorReport` and `DoctorCheck`
|
|
61
|
+
- `HTTPProxy` and `HTTPProxyAttachment`
|
|
69
62
|
- `SandboxAPIError` and typed subclasses for auth, not found, conflict,
|
|
70
63
|
unavailable, and unclassified response errors
|
|
71
64
|
|
|
@@ -75,12 +68,34 @@ Supported host operations:
|
|
|
75
68
|
- `get_host`
|
|
76
69
|
- `attach`
|
|
77
70
|
- `list_hosts`
|
|
71
|
+
- `renew_host`
|
|
78
72
|
- `delete_host`
|
|
79
73
|
- `doctor`
|
|
80
74
|
- `aclose`
|
|
81
75
|
|
|
82
76
|
`create_host` supports the service's optional `image`, `env`, `expires_at`,
|
|
83
|
-
`provider`, and `Idempotency-Key` inputs.
|
|
77
|
+
`provider`, `instance_type`, `disk_gb`, and `Idempotency-Key` inputs.
|
|
78
|
+
`instance_type` (provider-native size, e.g. `t3.xlarge` / `cx33`) and `disk_gb`
|
|
79
|
+
pin the VM shape; omit either for the provider default. `expires_at` mirrors the
|
|
80
|
+
wire contract: omit it for the default lease, pass a datetime for an explicit
|
|
81
|
+
expiry, or pass `None` for a never-reaped (permanent) host.
|
|
82
|
+
|
|
83
|
+
`renew_host` extends a host's lease via `POST /hosts/{id}/renew`. Omit
|
|
84
|
+
`expires_at` to extend by the service's default TTL; renewal never makes a host
|
|
85
|
+
permanent.
|
|
86
|
+
|
|
87
|
+
Supported HTTP-proxy operations:
|
|
88
|
+
|
|
89
|
+
- `create_http_proxy`
|
|
90
|
+
- `delete_http_proxy`
|
|
91
|
+
- `attach_http_proxy`
|
|
92
|
+
- `detach_http_proxy`
|
|
93
|
+
|
|
94
|
+
HTTP proxies are account-bound exe.dev resources, not host state — deleting a
|
|
95
|
+
host does not remove proxies fronting it. `create_http_proxy` takes an
|
|
96
|
+
origin-only `target` (scheme + host, no path/query/fragment/credentials) and at
|
|
97
|
+
least one `headers` entry; `attach_http_proxy` / `detach_http_proxy` point a
|
|
98
|
+
proxy at a host's backing VM (the host must be `bootstrapping` or `active`).
|
|
84
99
|
|
|
85
100
|
`doctor` fetches `GET /doctor` — read-only dependency health. The service
|
|
86
101
|
runs one cheap, non-mutating probe per dependency (database, active VM
|
|
@@ -4,9 +4,11 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "drukbox-python-sdk"
|
|
7
|
-
version = "0.0.
|
|
7
|
+
version = "0.0.7"
|
|
8
8
|
description = "Async Python client for Drukbox."
|
|
9
9
|
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
10
12
|
# Tracks the lowest Python any current consumer supports. Bump only
|
|
11
13
|
# when a real consumer needs a 3.12+ feature.
|
|
12
14
|
requires-python = ">=3.11"
|
|
@@ -14,6 +16,11 @@ dependencies = [
|
|
|
14
16
|
"httpx>=0.28",
|
|
15
17
|
]
|
|
16
18
|
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://github.com/clawhaven/drukbox-python-sdk"
|
|
21
|
+
Repository = "https://github.com/clawhaven/drukbox-python-sdk"
|
|
22
|
+
Issues = "https://github.com/clawhaven/drukbox-python-sdk/issues"
|
|
23
|
+
|
|
17
24
|
[tool.hatch.build.targets.wheel]
|
|
18
25
|
packages = ["src/drukbox_sdk"]
|
|
19
26
|
|
|
@@ -7,6 +7,8 @@ detail and may change without notice.
|
|
|
7
7
|
from .api import (
|
|
8
8
|
DoctorCheck,
|
|
9
9
|
DoctorReport,
|
|
10
|
+
HTTPProxy,
|
|
11
|
+
HTTPProxyAttachment,
|
|
10
12
|
SandboxAPI,
|
|
11
13
|
SandboxHost,
|
|
12
14
|
)
|
|
@@ -18,11 +20,14 @@ from .exceptions import (
|
|
|
18
20
|
SandboxProvisioningError,
|
|
19
21
|
SandboxResponseError,
|
|
20
22
|
SandboxUnavailableError,
|
|
23
|
+
SandboxValidationError,
|
|
21
24
|
)
|
|
22
25
|
|
|
23
26
|
__all__ = [
|
|
24
27
|
"DoctorCheck",
|
|
25
28
|
"DoctorReport",
|
|
29
|
+
"HTTPProxy",
|
|
30
|
+
"HTTPProxyAttachment",
|
|
26
31
|
"SandboxAPI",
|
|
27
32
|
"SandboxAPIError",
|
|
28
33
|
"SandboxAuthError",
|
|
@@ -32,4 +37,5 @@ __all__ = [
|
|
|
32
37
|
"SandboxProvisioningError",
|
|
33
38
|
"SandboxResponseError",
|
|
34
39
|
"SandboxUnavailableError",
|
|
40
|
+
"SandboxValidationError",
|
|
35
41
|
]
|
|
@@ -32,7 +32,7 @@ For env-backed config use :meth:`SandboxAPI.from_env`.
|
|
|
32
32
|
import asyncio
|
|
33
33
|
import os
|
|
34
34
|
import uuid
|
|
35
|
-
from dataclasses import dataclass
|
|
35
|
+
from dataclasses import dataclass
|
|
36
36
|
from datetime import datetime
|
|
37
37
|
from typing import Any, Self
|
|
38
38
|
|
|
@@ -45,6 +45,7 @@ from .exceptions import (
|
|
|
45
45
|
SandboxProvisioningError,
|
|
46
46
|
SandboxResponseError,
|
|
47
47
|
SandboxUnavailableError,
|
|
48
|
+
SandboxValidationError,
|
|
48
49
|
)
|
|
49
50
|
|
|
50
51
|
# Explicit pool budget. The httpx defaults (100 / 20) are plenty for
|
|
@@ -57,6 +58,22 @@ _SANDBOX_HTTP_LIMITS = httpx.Limits(
|
|
|
57
58
|
)
|
|
58
59
|
|
|
59
60
|
|
|
61
|
+
class _Unset:
|
|
62
|
+
"""Sentinel distinguishing an omitted argument from an explicit ``None``.
|
|
63
|
+
|
|
64
|
+
``create_host`` mirrors the service's tri-state ``expires_at``: an
|
|
65
|
+
omitted field means "default lease", an explicit ``null`` means
|
|
66
|
+
"permanent". A plain ``datetime | None`` default can't tell those two
|
|
67
|
+
apart, so the omitted case uses this sentinel.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __repr__(self) -> str:
|
|
71
|
+
return "UNSET"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
_UNSET = _Unset()
|
|
75
|
+
|
|
76
|
+
|
|
60
77
|
@dataclass(frozen=True)
|
|
61
78
|
class SandboxHost:
|
|
62
79
|
"""Snapshot of a provisioned host as returned by the service.
|
|
@@ -84,6 +101,8 @@ class SandboxHost:
|
|
|
84
101
|
status: str
|
|
85
102
|
provider: str
|
|
86
103
|
image: str
|
|
104
|
+
instance_type: str | None
|
|
105
|
+
disk_gb: int | None
|
|
87
106
|
external_ssh_host: str
|
|
88
107
|
external_ssh_port: int
|
|
89
108
|
ssh_username: str
|
|
@@ -98,20 +117,6 @@ class SandboxHost:
|
|
|
98
117
|
expires_at: str | None
|
|
99
118
|
|
|
100
119
|
|
|
101
|
-
_SANDBOX_HOST_FIELDS = {field.name for field in fields(SandboxHost)}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
def _parse_sandbox_host(data: dict[str, Any]) -> SandboxHost:
|
|
105
|
-
"""Build a :class:`SandboxHost` picking only known fields.
|
|
106
|
-
|
|
107
|
-
Defensive against the service adding new fields — those flow
|
|
108
|
-
through harmlessly without breaking the SDK on the unsuspecting
|
|
109
|
-
caller's side.
|
|
110
|
-
"""
|
|
111
|
-
|
|
112
|
-
return SandboxHost(**{key: data[key] for key in _SANDBOX_HOST_FIELDS})
|
|
113
|
-
|
|
114
|
-
|
|
115
120
|
@dataclass(frozen=True)
|
|
116
121
|
class DoctorCheck:
|
|
117
122
|
"""One dependency probe. ``hint`` is a remediation slug, set only on failures."""
|
|
@@ -133,23 +138,30 @@ class DoctorReport:
|
|
|
133
138
|
checks: list[DoctorCheck]
|
|
134
139
|
|
|
135
140
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
status=data["status"],
|
|
140
|
-
detail=data["detail"],
|
|
141
|
-
latency_ms=data["latency_ms"],
|
|
142
|
-
hint=data["hint"],
|
|
143
|
-
)
|
|
141
|
+
@dataclass(frozen=True)
|
|
142
|
+
class HTTPProxy:
|
|
143
|
+
"""An account-bound HTTP proxy as returned by the service.
|
|
144
144
|
|
|
145
|
+
``status`` echoes the lifecycle step the call reached
|
|
146
|
+
(``"created"``). Proxies are account resources, not host state:
|
|
147
|
+
deleting a host they front does not remove them.
|
|
148
|
+
"""
|
|
145
149
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
150
|
+
name: str
|
|
151
|
+
status: str
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass(frozen=True)
|
|
155
|
+
class HTTPProxyAttachment:
|
|
156
|
+
"""The result of pointing an HTTP proxy at a host's backing VM.
|
|
157
|
+
|
|
158
|
+
``host_id`` is the string UUID of the attached host; ``status``
|
|
159
|
+
echoes the step reached (``"attached"``).
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
name: str
|
|
163
|
+
host_id: str
|
|
164
|
+
status: str
|
|
153
165
|
|
|
154
166
|
|
|
155
167
|
class SandboxAPI:
|
|
@@ -210,10 +222,12 @@ class SandboxAPI:
|
|
|
210
222
|
async def create_host(
|
|
211
223
|
self,
|
|
212
224
|
*,
|
|
225
|
+
disk_gb: int | None = None,
|
|
213
226
|
env: dict[str, str] | None = None,
|
|
214
|
-
expires_at: datetime | None =
|
|
227
|
+
expires_at: datetime | None | _Unset = _UNSET,
|
|
215
228
|
idempotency_key: str | None = None,
|
|
216
229
|
image: str | None = None,
|
|
230
|
+
instance_type: str | None = None,
|
|
217
231
|
provider: str | None = None,
|
|
218
232
|
) -> SandboxHost:
|
|
219
233
|
"""Provision a new host.
|
|
@@ -233,15 +247,32 @@ class SandboxAPI:
|
|
|
233
247
|
``provider`` pins which VM provider serves the request; omit it to
|
|
234
248
|
use the service default. An unknown provider raises
|
|
235
249
|
:class:`SandboxResponseError` (the service rejects it with 400).
|
|
250
|
+
|
|
251
|
+
``instance_type`` (provider-native size, e.g. ``t3.xlarge`` /
|
|
252
|
+
``cx33``) and ``disk_gb`` (root disk size) pin the VM shape; omit
|
|
253
|
+
either to use the provider's configured default. A size the active
|
|
254
|
+
provider can't serve raises :class:`SandboxResponseError` (400).
|
|
255
|
+
|
|
256
|
+
Lease (mirrors the wire contract's tri-state ``expires_at``): omit
|
|
257
|
+
it for the service's default lease, pass a ``datetime`` for an
|
|
258
|
+
explicit expiry, or pass ``None`` for a never-reaped (permanent)
|
|
259
|
+
host — exactly as an omitted field vs. an explicit ``null`` behave
|
|
260
|
+
on the API.
|
|
236
261
|
"""
|
|
237
262
|
|
|
238
263
|
payload: dict[str, Any] = {}
|
|
264
|
+
if disk_gb is not None:
|
|
265
|
+
payload["disk_gb"] = disk_gb
|
|
239
266
|
if env is not None:
|
|
240
267
|
payload["env"] = env
|
|
241
|
-
|
|
242
|
-
|
|
268
|
+
# Sentinel default lets us tell "caller omitted expires_at" (default
|
|
269
|
+
# lease) from "caller passed None" (explicit null → permanent host).
|
|
270
|
+
if not isinstance(expires_at, _Unset):
|
|
271
|
+
payload["expires_at"] = None if expires_at is None else expires_at.isoformat()
|
|
243
272
|
if image is not None:
|
|
244
273
|
payload["image"] = image
|
|
274
|
+
if instance_type is not None:
|
|
275
|
+
payload["instance_type"] = instance_type
|
|
245
276
|
if provider is not None:
|
|
246
277
|
payload["provider"] = provider
|
|
247
278
|
|
|
@@ -251,12 +282,12 @@ class SandboxAPI:
|
|
|
251
282
|
|
|
252
283
|
data = await self._request("POST", "/hosts", json=payload, headers=headers)
|
|
253
284
|
assert isinstance(data, dict)
|
|
254
|
-
return
|
|
285
|
+
return SandboxHost(**data)
|
|
255
286
|
|
|
256
287
|
async def get_host(self, host_id: uuid.UUID | str) -> SandboxHost:
|
|
257
288
|
data = await self._request("GET", f"/hosts/{host_id}")
|
|
258
289
|
assert isinstance(data, dict)
|
|
259
|
-
return
|
|
290
|
+
return SandboxHost(**data)
|
|
260
291
|
|
|
261
292
|
async def attach(self, host_id: uuid.UUID | str) -> SandboxHost:
|
|
262
293
|
"""Alias for :meth:`get_host` that reads better at call sites
|
|
@@ -273,7 +304,31 @@ class SandboxAPI:
|
|
|
273
304
|
async def list_hosts(self) -> list[SandboxHost]:
|
|
274
305
|
data = await self._request("GET", "/hosts")
|
|
275
306
|
assert isinstance(data, list)
|
|
276
|
-
return [
|
|
307
|
+
return [SandboxHost(**item) for item in data]
|
|
308
|
+
|
|
309
|
+
async def renew_host(
|
|
310
|
+
self,
|
|
311
|
+
host_id: uuid.UUID | str,
|
|
312
|
+
*,
|
|
313
|
+
expires_at: datetime | None = None,
|
|
314
|
+
) -> SandboxHost:
|
|
315
|
+
"""Extend a host's lease and return the updated record.
|
|
316
|
+
|
|
317
|
+
Omit ``expires_at`` to extend by the service's default TTL from
|
|
318
|
+
now; pass one to set an explicit future expiry. Renewal never
|
|
319
|
+
makes a host permanent — that is a create-time choice.
|
|
320
|
+
|
|
321
|
+
Raises :class:`SandboxNotFoundError` for an unknown host and
|
|
322
|
+
:class:`SandboxConflictError` when the host is in a non-renewable
|
|
323
|
+
state (still provisioning, errored, or an unclaimed pool host).
|
|
324
|
+
"""
|
|
325
|
+
|
|
326
|
+
payload: dict[str, Any] = {}
|
|
327
|
+
if expires_at is not None:
|
|
328
|
+
payload["expires_at"] = expires_at.isoformat()
|
|
329
|
+
data = await self._request("POST", f"/hosts/{host_id}/renew", json=payload)
|
|
330
|
+
assert isinstance(data, dict)
|
|
331
|
+
return SandboxHost(**data)
|
|
277
332
|
|
|
278
333
|
async def delete_host(self, host_id: uuid.UUID | str) -> None:
|
|
279
334
|
await self._request("DELETE", f"/hosts/{host_id}")
|
|
@@ -287,7 +342,65 @@ class SandboxAPI:
|
|
|
287
342
|
|
|
288
343
|
data = await self._request("GET", "/doctor")
|
|
289
344
|
assert isinstance(data, dict)
|
|
290
|
-
return
|
|
345
|
+
return DoctorReport(
|
|
346
|
+
ok=data["ok"],
|
|
347
|
+
active_provider=data["active_provider"],
|
|
348
|
+
tailscale_enabled=data["tailscale_enabled"],
|
|
349
|
+
checks=[DoctorCheck(**check) for check in data["checks"]],
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
# ------------------------------------------------------------------
|
|
353
|
+
# HTTP proxies
|
|
354
|
+
# ------------------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
async def create_http_proxy(
|
|
357
|
+
self,
|
|
358
|
+
*,
|
|
359
|
+
name: str,
|
|
360
|
+
target: str,
|
|
361
|
+
headers: dict[str, str],
|
|
362
|
+
) -> HTTPProxy:
|
|
363
|
+
"""Create an account-bound HTTP proxy in front of ``target``.
|
|
364
|
+
|
|
365
|
+
``target`` must be an origin-only URL (scheme + host, no path,
|
|
366
|
+
query, fragment, or credentials); ``headers`` must carry at least
|
|
367
|
+
one entry. The service rejects a malformed target or empty headers
|
|
368
|
+
with :class:`SandboxValidationError`; a name that already exists
|
|
369
|
+
raises :class:`SandboxConflictError`.
|
|
370
|
+
"""
|
|
371
|
+
|
|
372
|
+
payload: dict[str, Any] = {"name": name, "target": target, "headers": headers}
|
|
373
|
+
data = await self._request("POST", "/http-proxies", json=payload)
|
|
374
|
+
assert isinstance(data, dict)
|
|
375
|
+
return HTTPProxy(**data)
|
|
376
|
+
|
|
377
|
+
async def delete_http_proxy(self, name: str) -> None:
|
|
378
|
+
"""Delete an HTTP proxy by name. Unknown name raises
|
|
379
|
+
:class:`SandboxNotFoundError`."""
|
|
380
|
+
|
|
381
|
+
await self._request("DELETE", f"/http-proxies/{name}")
|
|
382
|
+
|
|
383
|
+
async def attach_http_proxy(
|
|
384
|
+
self,
|
|
385
|
+
name: str,
|
|
386
|
+
host_id: uuid.UUID | str,
|
|
387
|
+
) -> HTTPProxyAttachment:
|
|
388
|
+
"""Point proxy ``name`` at ``host_id``'s backing VM.
|
|
389
|
+
|
|
390
|
+
The host must have a backing VM (``bootstrapping`` or ``active``);
|
|
391
|
+
otherwise the service raises :class:`SandboxConflictError`. An
|
|
392
|
+
unknown host or proxy raises :class:`SandboxNotFoundError`.
|
|
393
|
+
"""
|
|
394
|
+
|
|
395
|
+
data = await self._request("POST", f"/http-proxies/{name}/hosts/{host_id}")
|
|
396
|
+
assert isinstance(data, dict)
|
|
397
|
+
return HTTPProxyAttachment(**data)
|
|
398
|
+
|
|
399
|
+
async def detach_http_proxy(self, name: str, host_id: uuid.UUID | str) -> None:
|
|
400
|
+
"""Detach proxy ``name`` from ``host_id``. Unknown host or proxy
|
|
401
|
+
raises :class:`SandboxNotFoundError`."""
|
|
402
|
+
|
|
403
|
+
await self._request("DELETE", f"/http-proxies/{name}/hosts/{host_id}")
|
|
291
404
|
|
|
292
405
|
async def aclose(self) -> None:
|
|
293
406
|
if self._client is None:
|
|
@@ -350,6 +463,14 @@ class SandboxAPI:
|
|
|
350
463
|
if response.status_code == 503:
|
|
351
464
|
raise SandboxUnavailableError(json_response.get("detail", "service unavailable"))
|
|
352
465
|
|
|
466
|
+
if response.status_code == 422:
|
|
467
|
+
# FastAPI's 422 detail is a list of error dicts; join the messages
|
|
468
|
+
# so it reads as a line, not a raw list repr.
|
|
469
|
+
detail = json_response.get("detail", "validation error")
|
|
470
|
+
if isinstance(detail, list):
|
|
471
|
+
detail = "; ".join(item.get("msg", "") for item in detail) or "validation error"
|
|
472
|
+
raise SandboxValidationError(detail)
|
|
473
|
+
|
|
353
474
|
if response.status_code >= 400:
|
|
354
475
|
raise SandboxResponseError(json_response.get("detail", "error"))
|
|
355
476
|
return json_response
|
|
@@ -50,5 +50,14 @@ class SandboxProvisioningError(SandboxAPIError):
|
|
|
50
50
|
"""
|
|
51
51
|
|
|
52
52
|
|
|
53
|
+
class SandboxValidationError(SandboxAPIError):
|
|
54
|
+
"""422 — the service rejected the request payload as invalid.
|
|
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.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
|
|
53
62
|
class SandboxResponseError(SandboxAPIError):
|
|
54
63
|
"""Everything else the service returned that the SDK doesn't classify."""
|
|
@@ -23,6 +23,8 @@ import respx
|
|
|
23
23
|
|
|
24
24
|
from drukbox_sdk import (
|
|
25
25
|
DoctorReport,
|
|
26
|
+
HTTPProxy,
|
|
27
|
+
HTTPProxyAttachment,
|
|
26
28
|
SandboxAPI,
|
|
27
29
|
SandboxAuthError,
|
|
28
30
|
SandboxConflictError,
|
|
@@ -31,6 +33,7 @@ from drukbox_sdk import (
|
|
|
31
33
|
SandboxProvisioningError,
|
|
32
34
|
SandboxResponseError,
|
|
33
35
|
SandboxUnavailableError,
|
|
36
|
+
SandboxValidationError,
|
|
34
37
|
)
|
|
35
38
|
|
|
36
39
|
BASE_URL = "https://sandbox.test"
|
|
@@ -80,6 +83,8 @@ def _host_payload(**overrides: Any) -> dict[str, Any]:
|
|
|
80
83
|
"status": "provisioning",
|
|
81
84
|
"provider": "exe",
|
|
82
85
|
"image": "ghcr.io/drukbox/sandbox:test",
|
|
86
|
+
"instance_type": None,
|
|
87
|
+
"disk_gb": None,
|
|
83
88
|
"external_ssh_host": "203.0.113.42",
|
|
84
89
|
"external_ssh_port": 22,
|
|
85
90
|
"ssh_username": "exedev",
|
|
@@ -165,9 +170,44 @@ async def test_create_host_omits_image_and_env_when_unset(api: SandboxAPI):
|
|
|
165
170
|
assert "env" not in body
|
|
166
171
|
assert "expires_at" not in body
|
|
167
172
|
assert "provider" not in body
|
|
173
|
+
assert "instance_type" not in body
|
|
174
|
+
assert "disk_gb" not in body
|
|
168
175
|
assert "Idempotency-Key" not in route.calls.last.request.headers
|
|
169
176
|
|
|
170
177
|
|
|
178
|
+
@respx.mock
|
|
179
|
+
async def test_create_host_none_expires_at_sends_explicit_null(api: SandboxAPI):
|
|
180
|
+
"""Mirroring the wire contract: an explicit ``expires_at=None`` asks for
|
|
181
|
+
a permanent (never-reaped) host and must serialize as
|
|
182
|
+
``"expires_at":null`` — distinct from omitting it (default lease),
|
|
183
|
+
covered by test_create_host_omits_image_and_env_when_unset."""
|
|
184
|
+
|
|
185
|
+
payload = _host_payload(expires_at=None)
|
|
186
|
+
route = respx.post(f"{BASE_URL}/hosts").mock(
|
|
187
|
+
return_value=httpx.Response(201, json=payload),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
await api.create_host(expires_at=None)
|
|
191
|
+
|
|
192
|
+
assert '"expires_at":null' in route.calls.last.request.content.decode()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@respx.mock
|
|
196
|
+
async def test_create_host_sends_sizing_when_set(api: SandboxAPI):
|
|
197
|
+
payload = _host_payload(instance_type="t3.xlarge", disk_gb=100)
|
|
198
|
+
route = respx.post(f"{BASE_URL}/hosts").mock(
|
|
199
|
+
return_value=httpx.Response(201, json=payload),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
host = await api.create_host(instance_type="t3.xlarge", disk_gb=100)
|
|
203
|
+
|
|
204
|
+
body = route.calls.last.request.content.decode()
|
|
205
|
+
assert '"instance_type":"t3.xlarge"' in body
|
|
206
|
+
assert '"disk_gb":100' in body
|
|
207
|
+
assert host.instance_type == "t3.xlarge"
|
|
208
|
+
assert host.disk_gb == 100
|
|
209
|
+
|
|
210
|
+
|
|
171
211
|
@respx.mock
|
|
172
212
|
async def test_create_host_sends_provider_when_set(api: SandboxAPI):
|
|
173
213
|
payload = _host_payload(provider="hetzner")
|
|
@@ -231,25 +271,55 @@ async def test_delete_host_swallows_204(api: SandboxAPI):
|
|
|
231
271
|
await api.delete_host(host_id)
|
|
232
272
|
|
|
233
273
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
274
|
+
@respx.mock
|
|
275
|
+
async def test_renew_host_posts_expires_at_and_returns_host(api: SandboxAPI):
|
|
276
|
+
expires_at = datetime(2026, 7, 1, 12, 0, tzinfo=UTC)
|
|
277
|
+
payload = _host_payload(status="active", expires_at=expires_at.isoformat())
|
|
278
|
+
route = respx.post(f"{BASE_URL}/hosts/{payload['id']}/renew").mock(
|
|
279
|
+
return_value=httpx.Response(200, json=payload),
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
host = await api.renew_host(payload["id"], expires_at=expires_at)
|
|
283
|
+
|
|
284
|
+
assert f'"expires_at":"{expires_at.isoformat()}"' in route.calls.last.request.content.decode()
|
|
285
|
+
assert host.expires_at == payload["expires_at"]
|
|
237
286
|
|
|
238
287
|
|
|
239
288
|
@respx.mock
|
|
240
|
-
async def
|
|
241
|
-
"""
|
|
242
|
-
|
|
289
|
+
async def test_renew_host_omits_expires_at_when_unset(api: SandboxAPI):
|
|
290
|
+
"""Omitted expires_at means "extend by the default TTL"; the SDK must
|
|
291
|
+
send an empty body, not an explicit null."""
|
|
243
292
|
|
|
244
|
-
payload = _host_payload(
|
|
245
|
-
respx.
|
|
293
|
+
payload = _host_payload(status="active")
|
|
294
|
+
route = respx.post(f"{BASE_URL}/hosts/{payload['id']}/renew").mock(
|
|
246
295
|
return_value=httpx.Response(200, json=payload),
|
|
247
296
|
)
|
|
248
297
|
|
|
249
|
-
|
|
298
|
+
await api.renew_host(payload["id"])
|
|
250
299
|
|
|
251
|
-
assert
|
|
252
|
-
|
|
300
|
+
assert "expires_at" not in route.calls.last.request.content.decode()
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@respx.mock
|
|
304
|
+
async def test_renew_host_404_raises_not_found(api: SandboxAPI):
|
|
305
|
+
host_id = uuid4()
|
|
306
|
+
respx.post(f"{BASE_URL}/hosts/{host_id}/renew").mock(
|
|
307
|
+
return_value=httpx.Response(404, json={"detail": "host not found"}),
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
with pytest.raises(SandboxNotFoundError, match="host not found"):
|
|
311
|
+
await api.renew_host(host_id)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@respx.mock
|
|
315
|
+
async def test_renew_host_409_when_not_renewable(api: SandboxAPI):
|
|
316
|
+
host_id = uuid4()
|
|
317
|
+
respx.post(f"{BASE_URL}/hosts/{host_id}/renew").mock(
|
|
318
|
+
return_value=httpx.Response(409, json={"detail": "cannot renew a host in status error"}),
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
with pytest.raises(SandboxConflictError, match="cannot renew"):
|
|
322
|
+
await api.renew_host(host_id)
|
|
253
323
|
|
|
254
324
|
|
|
255
325
|
@respx.mock
|
|
@@ -333,6 +403,28 @@ async def test_409_raises_sandbox_conflict_error(api: SandboxAPI):
|
|
|
333
403
|
await api.delete_host(host_id)
|
|
334
404
|
|
|
335
405
|
|
|
406
|
+
@respx.mock
|
|
407
|
+
async def test_422_raises_sandbox_validation_error(api: SandboxAPI):
|
|
408
|
+
# FastAPI's 422 detail is a list of error dicts; the SDK flattens it to a
|
|
409
|
+
# readable line and raises the dedicated validation error (don't-retry).
|
|
410
|
+
respx.post(f"{BASE_URL}/hosts").mock(
|
|
411
|
+
return_value=httpx.Response(
|
|
412
|
+
422,
|
|
413
|
+
json={
|
|
414
|
+
"detail": [
|
|
415
|
+
{
|
|
416
|
+
"loc": ["body", "env"],
|
|
417
|
+
"msg": "env keys must be valid environment variable names: FOO BAR",
|
|
418
|
+
}
|
|
419
|
+
]
|
|
420
|
+
},
|
|
421
|
+
),
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
with pytest.raises(SandboxValidationError, match="env keys must be valid"):
|
|
425
|
+
await api.create_host(env={"FOO BAR": "x"})
|
|
426
|
+
|
|
427
|
+
|
|
336
428
|
@respx.mock
|
|
337
429
|
async def test_500_raises_sandbox_response_error(api: SandboxAPI):
|
|
338
430
|
respx.get(f"{BASE_URL}/hosts").mock(
|
|
@@ -560,36 +652,121 @@ async def test_doctor_surfaces_failure_with_hint(api: SandboxAPI):
|
|
|
560
652
|
|
|
561
653
|
|
|
562
654
|
@respx.mock
|
|
563
|
-
async def
|
|
564
|
-
"""
|
|
655
|
+
async def test_doctor_401_raises_sandbox_auth_error(api: SandboxAPI):
|
|
656
|
+
"""Doctor is service-token only; a bad token raises like every other call."""
|
|
565
657
|
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
{
|
|
569
|
-
"name": "db",
|
|
570
|
-
"status": "ok",
|
|
571
|
-
"detail": "select 1 -> 1",
|
|
572
|
-
"latency_ms": 2,
|
|
573
|
-
"hint": None,
|
|
574
|
-
"future_field": "surprise",
|
|
575
|
-
},
|
|
576
|
-
],
|
|
658
|
+
respx.get(f"{BASE_URL}/doctor").mock(
|
|
659
|
+
return_value=httpx.Response(401, json={"detail": "service token required"}),
|
|
577
660
|
)
|
|
578
|
-
respx.get(f"{BASE_URL}/doctor").mock(return_value=httpx.Response(200, json=payload))
|
|
579
661
|
|
|
580
|
-
|
|
662
|
+
with pytest.raises(SandboxAuthError, match="service token required"):
|
|
663
|
+
await api.doctor()
|
|
664
|
+
|
|
581
665
|
|
|
582
|
-
|
|
583
|
-
|
|
666
|
+
# ---------------------------------------------------------------------------
|
|
667
|
+
# HTTP proxies
|
|
668
|
+
# ---------------------------------------------------------------------------
|
|
584
669
|
|
|
585
670
|
|
|
586
671
|
@respx.mock
|
|
587
|
-
async def
|
|
588
|
-
|
|
672
|
+
async def test_create_http_proxy_posts_payload_and_returns_record(api: SandboxAPI):
|
|
673
|
+
route = respx.post(f"{BASE_URL}/http-proxies").mock(
|
|
674
|
+
return_value=httpx.Response(201, json={"name": "gh", "status": "created"}),
|
|
675
|
+
)
|
|
589
676
|
|
|
590
|
-
|
|
591
|
-
|
|
677
|
+
proxy = await api.create_http_proxy(
|
|
678
|
+
name="gh",
|
|
679
|
+
target="https://api.github.com",
|
|
680
|
+
headers={"Authorization": "Bearer x"},
|
|
592
681
|
)
|
|
593
682
|
|
|
594
|
-
|
|
595
|
-
|
|
683
|
+
body = route.calls.last.request.content.decode()
|
|
684
|
+
assert route.calls.last.request.headers["Authorization"] == "Bearer t-test"
|
|
685
|
+
assert '"name":"gh"' in body
|
|
686
|
+
assert '"target":"https://api.github.com"' in body
|
|
687
|
+
assert '"headers":{"Authorization":"Bearer x"}' in body
|
|
688
|
+
assert proxy == HTTPProxy(name="gh", status="created")
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
@respx.mock
|
|
692
|
+
async def test_create_http_proxy_409_raises_conflict(api: SandboxAPI):
|
|
693
|
+
respx.post(f"{BASE_URL}/http-proxies").mock(
|
|
694
|
+
return_value=httpx.Response(409, json={"detail": "http proxy already exists"}),
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
with pytest.raises(SandboxConflictError, match="already exists"):
|
|
698
|
+
await api.create_http_proxy(name="gh", target="https://api.github.com", headers={"X": "y"})
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
@respx.mock
|
|
702
|
+
async def test_create_http_proxy_422_raises_validation(api: SandboxAPI):
|
|
703
|
+
"""A target carrying a path is rejected server-side; the SDK surfaces
|
|
704
|
+
it as the don't-retry validation error."""
|
|
705
|
+
|
|
706
|
+
respx.post(f"{BASE_URL}/http-proxies").mock(
|
|
707
|
+
return_value=httpx.Response(
|
|
708
|
+
422,
|
|
709
|
+
json={"detail": [{"msg": "target URL must not contain a path"}]},
|
|
710
|
+
),
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
with pytest.raises(SandboxValidationError, match="must not contain a path"):
|
|
714
|
+
await api.create_http_proxy(
|
|
715
|
+
name="gh", target="https://api.github.com/v3", headers={"X": "y"}
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
@respx.mock
|
|
720
|
+
async def test_delete_http_proxy_swallows_204(api: SandboxAPI):
|
|
721
|
+
respx.delete(f"{BASE_URL}/http-proxies/gh").mock(return_value=httpx.Response(204))
|
|
722
|
+
|
|
723
|
+
await api.delete_http_proxy("gh")
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
@respx.mock
|
|
727
|
+
async def test_delete_http_proxy_404_raises_not_found(api: SandboxAPI):
|
|
728
|
+
respx.delete(f"{BASE_URL}/http-proxies/gh").mock(
|
|
729
|
+
return_value=httpx.Response(404, json={"detail": "http proxy not found"}),
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
with pytest.raises(SandboxNotFoundError, match="not found"):
|
|
733
|
+
await api.delete_http_proxy("gh")
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
@respx.mock
|
|
737
|
+
async def test_attach_http_proxy_returns_attachment(api: SandboxAPI):
|
|
738
|
+
host_id = uuid4()
|
|
739
|
+
route = respx.post(f"{BASE_URL}/http-proxies/gh/hosts/{host_id}").mock(
|
|
740
|
+
return_value=httpx.Response(
|
|
741
|
+
200, json={"name": "gh", "host_id": str(host_id), "status": "attached"}
|
|
742
|
+
),
|
|
743
|
+
)
|
|
744
|
+
|
|
745
|
+
attachment = await api.attach_http_proxy("gh", host_id)
|
|
746
|
+
|
|
747
|
+
assert route.called
|
|
748
|
+
assert attachment == HTTPProxyAttachment(name="gh", host_id=str(host_id), status="attached")
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
@respx.mock
|
|
752
|
+
async def test_attach_http_proxy_409_when_host_has_no_vm(api: SandboxAPI):
|
|
753
|
+
"""Attaching to a host that isn't bootstrapping/active is a state
|
|
754
|
+
conflict, not a validation error."""
|
|
755
|
+
|
|
756
|
+
host_id = uuid4()
|
|
757
|
+
respx.post(f"{BASE_URL}/http-proxies/gh/hosts/{host_id}").mock(
|
|
758
|
+
return_value=httpx.Response(409, json={"detail": "host does not have a backing VM"}),
|
|
759
|
+
)
|
|
760
|
+
|
|
761
|
+
with pytest.raises(SandboxConflictError, match="backing VM"):
|
|
762
|
+
await api.attach_http_proxy("gh", host_id)
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
@respx.mock
|
|
766
|
+
async def test_detach_http_proxy_swallows_204(api: SandboxAPI):
|
|
767
|
+
host_id = uuid4()
|
|
768
|
+
respx.delete(f"{BASE_URL}/http-proxies/gh/hosts/{host_id}").mock(
|
|
769
|
+
return_value=httpx.Response(204),
|
|
770
|
+
)
|
|
771
|
+
|
|
772
|
+
await api.detach_http_proxy("gh", host_id)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|