drukbox-python-sdk 0.0.6__tar.gz → 0.1.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/LICENSE +21 -0
- drukbox_python_sdk-0.0.6/README.md → drukbox_python_sdk-0.1.0/PKG-INFO +55 -6
- drukbox_python_sdk-0.0.6/PKG-INFO → drukbox_python_sdk-0.1.0/README.md +42 -14
- {drukbox_python_sdk-0.0.6 → drukbox_python_sdk-0.1.0}/pyproject.toml +8 -1
- {drukbox_python_sdk-0.0.6 → drukbox_python_sdk-0.1.0}/src/drukbox_sdk/__init__.py +6 -0
- {drukbox_python_sdk-0.0.6 → drukbox_python_sdk-0.1.0}/src/drukbox_sdk/api.py +223 -60
- {drukbox_python_sdk-0.0.6 → drukbox_python_sdk-0.1.0}/tests/test_api.py +321 -56
- {drukbox_python_sdk-0.0.6 → drukbox_python_sdk-0.1.0}/.github/workflows/on-main-merge.yml +0 -0
- {drukbox_python_sdk-0.0.6 → drukbox_python_sdk-0.1.0}/.github/workflows/on-pull-request.yml +0 -0
- {drukbox_python_sdk-0.0.6 → drukbox_python_sdk-0.1.0}/.github/workflows/release.yml +0 -0
- {drukbox_python_sdk-0.0.6 → drukbox_python_sdk-0.1.0}/.gitignore +0 -0
- {drukbox_python_sdk-0.0.6 → drukbox_python_sdk-0.1.0}/AGENTS.md +0 -0
- {drukbox_python_sdk-0.0.6 → drukbox_python_sdk-0.1.0}/src/drukbox_sdk/exceptions.py +0 -0
- {drukbox_python_sdk-0.0.6 → drukbox_python_sdk-0.1.0}/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,11 +1,24 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: drukbox-python-sdk
|
|
3
|
+
Version: 0.1.0
|
|
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.
|
|
4
17
|
|
|
5
|
-
The SDK provisions sandbox VMs, reads host
|
|
6
|
-
returns the SSH connection details a
|
|
7
|
-
SSH sessions, file transfer, command
|
|
8
|
-
belong in the caller.
|
|
18
|
+
The SDK builds reusable sandbox templates, provisions sandbox VMs, reads host
|
|
19
|
+
state, deletes hosts and templates, and returns the SSH connection details a
|
|
20
|
+
caller needs. It speaks HTTP only: SSH sessions, file transfer, command
|
|
21
|
+
execution, and retry orchestration belong in the caller.
|
|
9
22
|
|
|
10
23
|
## Install
|
|
11
24
|
|
|
@@ -57,7 +70,9 @@ Public exports live in `drukbox_sdk`:
|
|
|
57
70
|
|
|
58
71
|
- `SandboxAPI`
|
|
59
72
|
- `SandboxHost`
|
|
73
|
+
- `SandboxTemplate`
|
|
60
74
|
- `DoctorReport` and `DoctorCheck`
|
|
75
|
+
- `HTTPProxy` and `HTTPProxyAttachment`
|
|
61
76
|
- `SandboxAPIError` and typed subclasses for auth, not found, conflict,
|
|
62
77
|
unavailable, and unclassified response errors
|
|
63
78
|
|
|
@@ -67,12 +82,46 @@ Supported host operations:
|
|
|
67
82
|
- `get_host`
|
|
68
83
|
- `attach`
|
|
69
84
|
- `list_hosts`
|
|
85
|
+
- `renew_host`
|
|
70
86
|
- `delete_host`
|
|
71
87
|
- `doctor`
|
|
72
88
|
- `aclose`
|
|
73
89
|
|
|
74
90
|
`create_host` supports the service's optional `image`, `env`, `expires_at`,
|
|
75
|
-
`provider`,
|
|
91
|
+
`permanent`, `provider`, `instance_type`, `disk_gb`, `template`, and
|
|
92
|
+
`Idempotency-Key` inputs. `instance_type` (provider-native size, e.g.
|
|
93
|
+
`t3.xlarge` / `cx33`) and `disk_gb` pin the VM shape; omit either for the
|
|
94
|
+
provider default. Omit `expires_at` for the default lease, pass a datetime for
|
|
95
|
+
an explicit expiry, or pass `permanent=True` for a never-reaped host.
|
|
96
|
+
`template` accepts a template ID. An explicit `image` wins over it.
|
|
97
|
+
|
|
98
|
+
`renew_host` extends a host's lease via `POST /hosts/{id}/renew`. Omit
|
|
99
|
+
`expires_at` to extend by the service's default TTL; renewal never makes a host
|
|
100
|
+
permanent.
|
|
101
|
+
|
|
102
|
+
Supported template operations:
|
|
103
|
+
|
|
104
|
+
- `create_template`
|
|
105
|
+
- `get_template`
|
|
106
|
+
- `list_templates`
|
|
107
|
+
- `delete_template`
|
|
108
|
+
|
|
109
|
+
`create_template` returns immediately with a `building` record. Poll
|
|
110
|
+
`get_template` until the status is `available` or `failed`. A failed record
|
|
111
|
+
carries the reason in `last_error`.
|
|
112
|
+
|
|
113
|
+
Supported HTTP-proxy operations:
|
|
114
|
+
|
|
115
|
+
- `create_http_proxy`
|
|
116
|
+
- `delete_http_proxy`
|
|
117
|
+
- `attach_http_proxy`
|
|
118
|
+
- `detach_http_proxy`
|
|
119
|
+
|
|
120
|
+
HTTP proxies are account-bound exe.dev resources, not host state — deleting a
|
|
121
|
+
host does not remove proxies fronting it. `create_http_proxy` takes an
|
|
122
|
+
origin-only `target` (scheme + host, no path/query/fragment/credentials) and at
|
|
123
|
+
least one `headers` entry; `attach_http_proxy` / `detach_http_proxy` point a
|
|
124
|
+
proxy at a host's backing VM (the host must be `bootstrapping` or `active`).
|
|
76
125
|
|
|
77
126
|
`doctor` fetches `GET /doctor` — read-only dependency health. The service
|
|
78
127
|
runs one cheap, non-mutating probe per dependency (database, active VM
|
|
@@ -96,4 +145,4 @@ uv run pytest
|
|
|
96
145
|
Tests use `respx` to fake the Drukbox HTTP API. They do not need a
|
|
97
146
|
real network, VM provider, or Drukbox service.
|
|
98
147
|
|
|
99
|
-
[Drukbox]: https://github.com/
|
|
148
|
+
[Drukbox]: https://github.com/czpython/drukbox
|
|
@@ -1,19 +1,11 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: drukbox-python-sdk
|
|
3
|
-
Version: 0.0.6
|
|
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.
|
|
12
4
|
|
|
13
|
-
The SDK provisions sandbox VMs, reads host
|
|
14
|
-
returns the SSH connection details a
|
|
15
|
-
SSH sessions, file transfer, command
|
|
16
|
-
belong in the caller.
|
|
5
|
+
The SDK builds reusable sandbox templates, provisions sandbox VMs, reads host
|
|
6
|
+
state, deletes hosts and templates, and returns the SSH connection details a
|
|
7
|
+
caller needs. It speaks HTTP only: SSH sessions, file transfer, command
|
|
8
|
+
execution, and retry orchestration belong in the caller.
|
|
17
9
|
|
|
18
10
|
## Install
|
|
19
11
|
|
|
@@ -65,7 +57,9 @@ Public exports live in `drukbox_sdk`:
|
|
|
65
57
|
|
|
66
58
|
- `SandboxAPI`
|
|
67
59
|
- `SandboxHost`
|
|
60
|
+
- `SandboxTemplate`
|
|
68
61
|
- `DoctorReport` and `DoctorCheck`
|
|
62
|
+
- `HTTPProxy` and `HTTPProxyAttachment`
|
|
69
63
|
- `SandboxAPIError` and typed subclasses for auth, not found, conflict,
|
|
70
64
|
unavailable, and unclassified response errors
|
|
71
65
|
|
|
@@ -75,12 +69,46 @@ Supported host operations:
|
|
|
75
69
|
- `get_host`
|
|
76
70
|
- `attach`
|
|
77
71
|
- `list_hosts`
|
|
72
|
+
- `renew_host`
|
|
78
73
|
- `delete_host`
|
|
79
74
|
- `doctor`
|
|
80
75
|
- `aclose`
|
|
81
76
|
|
|
82
77
|
`create_host` supports the service's optional `image`, `env`, `expires_at`,
|
|
83
|
-
`provider`,
|
|
78
|
+
`permanent`, `provider`, `instance_type`, `disk_gb`, `template`, and
|
|
79
|
+
`Idempotency-Key` inputs. `instance_type` (provider-native size, e.g.
|
|
80
|
+
`t3.xlarge` / `cx33`) and `disk_gb` pin the VM shape; omit either for the
|
|
81
|
+
provider default. Omit `expires_at` for the default lease, pass a datetime for
|
|
82
|
+
an explicit expiry, or pass `permanent=True` for a never-reaped host.
|
|
83
|
+
`template` accepts a template ID. An explicit `image` wins over it.
|
|
84
|
+
|
|
85
|
+
`renew_host` extends a host's lease via `POST /hosts/{id}/renew`. Omit
|
|
86
|
+
`expires_at` to extend by the service's default TTL; renewal never makes a host
|
|
87
|
+
permanent.
|
|
88
|
+
|
|
89
|
+
Supported template operations:
|
|
90
|
+
|
|
91
|
+
- `create_template`
|
|
92
|
+
- `get_template`
|
|
93
|
+
- `list_templates`
|
|
94
|
+
- `delete_template`
|
|
95
|
+
|
|
96
|
+
`create_template` returns immediately with a `building` record. Poll
|
|
97
|
+
`get_template` until the status is `available` or `failed`. A failed record
|
|
98
|
+
carries the reason in `last_error`.
|
|
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`).
|
|
84
112
|
|
|
85
113
|
`doctor` fetches `GET /doctor` — read-only dependency health. The service
|
|
86
114
|
runs one cheap, non-mutating probe per dependency (database, active VM
|
|
@@ -104,4 +132,4 @@ uv run pytest
|
|
|
104
132
|
Tests use `respx` to fake the Drukbox HTTP API. They do not need a
|
|
105
133
|
real network, VM provider, or Drukbox service.
|
|
106
134
|
|
|
107
|
-
[Drukbox]: https://github.com/
|
|
135
|
+
[Drukbox]: https://github.com/czpython/drukbox
|
|
@@ -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.1.0"
|
|
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,8 +7,11 @@ 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,
|
|
14
|
+
SandboxTemplate,
|
|
12
15
|
)
|
|
13
16
|
from .exceptions import (
|
|
14
17
|
SandboxAPIError,
|
|
@@ -24,6 +27,8 @@ from .exceptions import (
|
|
|
24
27
|
__all__ = [
|
|
25
28
|
"DoctorCheck",
|
|
26
29
|
"DoctorReport",
|
|
30
|
+
"HTTPProxy",
|
|
31
|
+
"HTTPProxyAttachment",
|
|
27
32
|
"SandboxAPI",
|
|
28
33
|
"SandboxAPIError",
|
|
29
34
|
"SandboxAuthError",
|
|
@@ -32,6 +37,7 @@ __all__ = [
|
|
|
32
37
|
"SandboxNotFoundError",
|
|
33
38
|
"SandboxProvisioningError",
|
|
34
39
|
"SandboxResponseError",
|
|
40
|
+
"SandboxTemplate",
|
|
35
41
|
"SandboxUnavailableError",
|
|
36
42
|
"SandboxValidationError",
|
|
37
43
|
]
|
|
@@ -26,13 +26,20 @@ Usage::
|
|
|
26
26
|
await sandbox.delete_host(host.id)
|
|
27
27
|
await sandbox.aclose()
|
|
28
28
|
|
|
29
|
+
Templates bake a setup script into a reusable image; hosts fork from it::
|
|
30
|
+
|
|
31
|
+
template = await sandbox.create_template(setup_script=script)
|
|
32
|
+
while template.status == "building":
|
|
33
|
+
template = await sandbox.get_template(template.id)
|
|
34
|
+
host = await sandbox.create_host(template=template.id)
|
|
35
|
+
|
|
29
36
|
For env-backed config use :meth:`SandboxAPI.from_env`.
|
|
30
37
|
"""
|
|
31
38
|
|
|
32
39
|
import asyncio
|
|
33
40
|
import os
|
|
34
41
|
import uuid
|
|
35
|
-
from dataclasses import dataclass
|
|
42
|
+
from dataclasses import dataclass
|
|
36
43
|
from datetime import datetime
|
|
37
44
|
from typing import Any, Self
|
|
38
45
|
|
|
@@ -85,6 +92,8 @@ class SandboxHost:
|
|
|
85
92
|
status: str
|
|
86
93
|
provider: str
|
|
87
94
|
image: str
|
|
95
|
+
instance_type: str | None
|
|
96
|
+
disk_gb: int | None
|
|
88
97
|
external_ssh_host: str
|
|
89
98
|
external_ssh_port: int
|
|
90
99
|
ssh_username: str
|
|
@@ -99,18 +108,27 @@ class SandboxHost:
|
|
|
99
108
|
expires_at: str | None
|
|
100
109
|
|
|
101
110
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
def _parse_sandbox_host(data: dict[str, Any]) -> SandboxHost:
|
|
106
|
-
"""Build a :class:`SandboxHost` picking only known fields.
|
|
111
|
+
@dataclass(frozen=True)
|
|
112
|
+
class SandboxTemplate:
|
|
113
|
+
"""Snapshot of a reusable sandbox template as returned by the service.
|
|
107
114
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
115
|
+
``status`` moves from ``"building"`` to ``"available"`` or
|
|
116
|
+
``"failed"``. While the build runs, ``image`` is empty. A failed
|
|
117
|
+
build carries its reason in ``last_error``. The record has no
|
|
118
|
+
``setup_script`` because the service never echoes it back.
|
|
111
119
|
"""
|
|
112
120
|
|
|
113
|
-
|
|
121
|
+
id: str
|
|
122
|
+
provider: str
|
|
123
|
+
base_image: str
|
|
124
|
+
setup_script_hash: str
|
|
125
|
+
label: str
|
|
126
|
+
image: str
|
|
127
|
+
status: str
|
|
128
|
+
last_error: str
|
|
129
|
+
created_at: str
|
|
130
|
+
updated_at: str
|
|
131
|
+
last_used_at: str | None
|
|
114
132
|
|
|
115
133
|
|
|
116
134
|
@dataclass(frozen=True)
|
|
@@ -134,23 +152,30 @@ class DoctorReport:
|
|
|
134
152
|
checks: list[DoctorCheck]
|
|
135
153
|
|
|
136
154
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
status=data["status"],
|
|
141
|
-
detail=data["detail"],
|
|
142
|
-
latency_ms=data["latency_ms"],
|
|
143
|
-
hint=data["hint"],
|
|
144
|
-
)
|
|
155
|
+
@dataclass(frozen=True)
|
|
156
|
+
class HTTPProxy:
|
|
157
|
+
"""An account-bound HTTP proxy as returned by the service.
|
|
145
158
|
|
|
159
|
+
``status`` echoes the lifecycle step the call reached
|
|
160
|
+
(``"created"``). Proxies are account resources, not host state:
|
|
161
|
+
deleting a host they front does not remove them.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
name: str
|
|
165
|
+
status: str
|
|
146
166
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
)
|
|
167
|
+
|
|
168
|
+
@dataclass(frozen=True)
|
|
169
|
+
class HTTPProxyAttachment:
|
|
170
|
+
"""The result of pointing an HTTP proxy at a host's backing VM.
|
|
171
|
+
|
|
172
|
+
``host_id`` is the string UUID of the attached host; ``status``
|
|
173
|
+
echoes the step reached (``"attached"``).
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
name: str
|
|
177
|
+
host_id: str
|
|
178
|
+
status: str
|
|
154
179
|
|
|
155
180
|
|
|
156
181
|
class SandboxAPI:
|
|
@@ -204,18 +229,18 @@ class SandboxAPI:
|
|
|
204
229
|
timeout = float(os.environ.get(f"{prefix}SERVICE_TIMEOUT", "300"))
|
|
205
230
|
return cls(base_url=url, token=token, timeout=timeout)
|
|
206
231
|
|
|
207
|
-
# ------------------------------------------------------------------
|
|
208
|
-
# Host lifecycle
|
|
209
|
-
# ------------------------------------------------------------------
|
|
210
|
-
|
|
211
232
|
async def create_host(
|
|
212
233
|
self,
|
|
213
234
|
*,
|
|
235
|
+
disk_gb: int | None = None,
|
|
214
236
|
env: dict[str, str] | None = None,
|
|
215
237
|
expires_at: datetime | None = None,
|
|
216
238
|
idempotency_key: str | None = None,
|
|
217
239
|
image: str | None = None,
|
|
240
|
+
instance_type: str | None = None,
|
|
241
|
+
permanent: bool = False,
|
|
218
242
|
provider: str | None = None,
|
|
243
|
+
template: uuid.UUID | str | None = None,
|
|
219
244
|
) -> SandboxHost:
|
|
220
245
|
"""Provision a new host.
|
|
221
246
|
|
|
@@ -234,30 +259,55 @@ class SandboxAPI:
|
|
|
234
259
|
``provider`` pins which VM provider serves the request; omit it to
|
|
235
260
|
use the service default. An unknown provider raises
|
|
236
261
|
:class:`SandboxResponseError` (the service rejects it with 400).
|
|
262
|
+
|
|
263
|
+
``template`` names a template by its ID, from
|
|
264
|
+
:meth:`create_template` or :meth:`get_template`. An explicit
|
|
265
|
+
``image`` wins when you pass both. A template that is not
|
|
266
|
+
``available`` raises :class:`SandboxConflictError`. An unknown
|
|
267
|
+
ID raises :class:`SandboxResponseError`.
|
|
268
|
+
|
|
269
|
+
``instance_type`` (provider-native size, e.g. ``t3.xlarge`` /
|
|
270
|
+
``cx33``) and ``disk_gb`` (root disk size) pin the VM shape; omit
|
|
271
|
+
either to use the provider's configured default. A size the active
|
|
272
|
+
provider can't serve raises :class:`SandboxResponseError` (400).
|
|
273
|
+
|
|
274
|
+
Lease: omit ``expires_at`` for the service's default lease, pass a
|
|
275
|
+
``datetime`` for an explicit expiry, or pass ``permanent=True`` for
|
|
276
|
+
a host the janitor never reaps.
|
|
237
277
|
"""
|
|
238
278
|
|
|
279
|
+
if expires_at and permanent:
|
|
280
|
+
raise ValueError("a permanent host cannot carry an expires_at")
|
|
281
|
+
|
|
239
282
|
payload: dict[str, Any] = {}
|
|
283
|
+
if disk_gb is not None:
|
|
284
|
+
payload["disk_gb"] = disk_gb
|
|
240
285
|
if env is not None:
|
|
241
286
|
payload["env"] = env
|
|
242
|
-
if expires_at
|
|
287
|
+
if expires_at:
|
|
243
288
|
payload["expires_at"] = expires_at.isoformat()
|
|
289
|
+
if permanent:
|
|
290
|
+
# An explicit null on the wire is the opt-out from expiry.
|
|
291
|
+
payload["expires_at"] = None
|
|
244
292
|
if image is not None:
|
|
245
293
|
payload["image"] = image
|
|
294
|
+
if instance_type is not None:
|
|
295
|
+
payload["instance_type"] = instance_type
|
|
246
296
|
if provider is not None:
|
|
247
297
|
payload["provider"] = provider
|
|
298
|
+
if template is not None:
|
|
299
|
+
payload["template"] = str(template)
|
|
248
300
|
|
|
249
301
|
headers: dict[str, str] = {}
|
|
250
302
|
if idempotency_key is not None:
|
|
251
303
|
headers["Idempotency-Key"] = idempotency_key
|
|
252
304
|
|
|
253
305
|
data = await self._request("POST", "/hosts", json=payload, headers=headers)
|
|
254
|
-
|
|
255
|
-
return _parse_sandbox_host(data)
|
|
306
|
+
return SandboxHost(**data)
|
|
256
307
|
|
|
257
308
|
async def get_host(self, host_id: uuid.UUID | str) -> SandboxHost:
|
|
258
309
|
data = await self._request("GET", f"/hosts/{host_id}")
|
|
259
|
-
|
|
260
|
-
return _parse_sandbox_host(data)
|
|
310
|
+
return SandboxHost(**data)
|
|
261
311
|
|
|
262
312
|
async def attach(self, host_id: uuid.UUID | str) -> SandboxHost:
|
|
263
313
|
"""Alias for :meth:`get_host` that reads better at call sites
|
|
@@ -273,12 +323,84 @@ class SandboxAPI:
|
|
|
273
323
|
|
|
274
324
|
async def list_hosts(self) -> list[SandboxHost]:
|
|
275
325
|
data = await self._request("GET", "/hosts")
|
|
276
|
-
|
|
277
|
-
|
|
326
|
+
return [SandboxHost(**item) for item in data]
|
|
327
|
+
|
|
328
|
+
async def renew_host(
|
|
329
|
+
self,
|
|
330
|
+
host_id: uuid.UUID | str,
|
|
331
|
+
*,
|
|
332
|
+
expires_at: datetime | None = None,
|
|
333
|
+
) -> SandboxHost:
|
|
334
|
+
"""Extend a host's lease and return the updated record.
|
|
335
|
+
|
|
336
|
+
Omit ``expires_at`` to extend by the service's default TTL from
|
|
337
|
+
now; pass one to set an explicit future expiry. Renewal never
|
|
338
|
+
makes a host permanent — that is a create-time choice.
|
|
339
|
+
|
|
340
|
+
Raises :class:`SandboxNotFoundError` for an unknown host and
|
|
341
|
+
:class:`SandboxConflictError` when the host is in a non-renewable
|
|
342
|
+
state (still provisioning, errored, or an unclaimed pool host).
|
|
343
|
+
"""
|
|
344
|
+
|
|
345
|
+
payload: dict[str, Any] = {}
|
|
346
|
+
if expires_at is not None:
|
|
347
|
+
payload["expires_at"] = expires_at.isoformat()
|
|
348
|
+
data = await self._request("POST", f"/hosts/{host_id}/renew", json=payload)
|
|
349
|
+
return SandboxHost(**data)
|
|
278
350
|
|
|
279
351
|
async def delete_host(self, host_id: uuid.UUID | str) -> None:
|
|
280
352
|
await self._request("DELETE", f"/hosts/{host_id}")
|
|
281
353
|
|
|
354
|
+
async def create_template(
|
|
355
|
+
self,
|
|
356
|
+
*,
|
|
357
|
+
setup_script: str,
|
|
358
|
+
provider: str | None = None,
|
|
359
|
+
base_image: str | None = None,
|
|
360
|
+
label: str | None = None,
|
|
361
|
+
) -> SandboxTemplate:
|
|
362
|
+
"""Start a template build and return immediately.
|
|
363
|
+
|
|
364
|
+
The service responds 202 with ``status="building"``. Poll
|
|
365
|
+
:meth:`get_template` until the status becomes ``"available"`` or
|
|
366
|
+
``"failed"``. A failed record carries the reason in
|
|
367
|
+
``last_error``. A repeated post with the same provider, base
|
|
368
|
+
image, and setup script returns the existing record without a
|
|
369
|
+
rebuild.
|
|
370
|
+
"""
|
|
371
|
+
|
|
372
|
+
payload: dict[str, Any] = {"setup_script": setup_script}
|
|
373
|
+
if provider is not None:
|
|
374
|
+
payload["provider"] = provider
|
|
375
|
+
if base_image is not None:
|
|
376
|
+
payload["base_image"] = base_image
|
|
377
|
+
if label is not None:
|
|
378
|
+
payload["label"] = label
|
|
379
|
+
|
|
380
|
+
data = await self._request("POST", "/templates", json=payload)
|
|
381
|
+
return SandboxTemplate(**data)
|
|
382
|
+
|
|
383
|
+
async def get_template(self, template_id: uuid.UUID | str) -> SandboxTemplate:
|
|
384
|
+
"""Fetch one template. Callers poll this after :meth:`create_template`."""
|
|
385
|
+
|
|
386
|
+
data = await self._request("GET", f"/templates/{template_id}")
|
|
387
|
+
return SandboxTemplate(**data)
|
|
388
|
+
|
|
389
|
+
async def list_templates(self) -> list[SandboxTemplate]:
|
|
390
|
+
"""List templates newest first."""
|
|
391
|
+
|
|
392
|
+
data = await self._request("GET", "/templates")
|
|
393
|
+
return [SandboxTemplate(**item) for item in data]
|
|
394
|
+
|
|
395
|
+
async def delete_template(self, template_id: uuid.UUID | str) -> None:
|
|
396
|
+
"""Delete a template.
|
|
397
|
+
|
|
398
|
+
A template whose status is ``building`` raises
|
|
399
|
+
:class:`SandboxConflictError`.
|
|
400
|
+
"""
|
|
401
|
+
|
|
402
|
+
await self._request("DELETE", f"/templates/{template_id}")
|
|
403
|
+
|
|
282
404
|
async def doctor(self) -> DoctorReport:
|
|
283
405
|
"""Fetch ``GET /doctor`` — read-only dependency health.
|
|
284
406
|
|
|
@@ -287,19 +409,65 @@ class SandboxAPI:
|
|
|
287
409
|
"""
|
|
288
410
|
|
|
289
411
|
data = await self._request("GET", "/doctor")
|
|
290
|
-
|
|
291
|
-
|
|
412
|
+
return DoctorReport(
|
|
413
|
+
ok=data["ok"],
|
|
414
|
+
active_provider=data["active_provider"],
|
|
415
|
+
tailscale_enabled=data["tailscale_enabled"],
|
|
416
|
+
checks=[DoctorCheck(**check) for check in data["checks"]],
|
|
417
|
+
)
|
|
292
418
|
|
|
293
|
-
async def
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
419
|
+
async def create_http_proxy(
|
|
420
|
+
self,
|
|
421
|
+
*,
|
|
422
|
+
name: str,
|
|
423
|
+
target: str,
|
|
424
|
+
headers: dict[str, str],
|
|
425
|
+
) -> HTTPProxy:
|
|
426
|
+
"""Create an account-bound HTTP proxy in front of ``target``.
|
|
427
|
+
|
|
428
|
+
``target`` must be an origin-only URL (scheme + host, no path,
|
|
429
|
+
query, fragment, or credentials); ``headers`` must carry at least
|
|
430
|
+
one entry. The service rejects a malformed target or empty headers
|
|
431
|
+
with :class:`SandboxValidationError`; a name that already exists
|
|
432
|
+
raises :class:`SandboxConflictError`.
|
|
433
|
+
"""
|
|
434
|
+
|
|
435
|
+
payload: dict[str, Any] = {"name": name, "target": target, "headers": headers}
|
|
436
|
+
data = await self._request("POST", "/http-proxies", json=payload)
|
|
437
|
+
return HTTPProxy(**data)
|
|
299
438
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
439
|
+
async def delete_http_proxy(self, name: str) -> None:
|
|
440
|
+
"""Delete an HTTP proxy by name. Unknown name raises
|
|
441
|
+
:class:`SandboxNotFoundError`."""
|
|
442
|
+
|
|
443
|
+
await self._request("DELETE", f"/http-proxies/{name}")
|
|
444
|
+
|
|
445
|
+
async def attach_http_proxy(
|
|
446
|
+
self,
|
|
447
|
+
name: str,
|
|
448
|
+
host_id: uuid.UUID | str,
|
|
449
|
+
) -> HTTPProxyAttachment:
|
|
450
|
+
"""Point proxy ``name`` at ``host_id``'s backing VM.
|
|
451
|
+
|
|
452
|
+
The host must have a backing VM (``bootstrapping`` or ``active``);
|
|
453
|
+
otherwise the service raises :class:`SandboxConflictError`. An
|
|
454
|
+
unknown host or proxy raises :class:`SandboxNotFoundError`.
|
|
455
|
+
"""
|
|
456
|
+
|
|
457
|
+
data = await self._request("POST", f"/http-proxies/{name}/hosts/{host_id}")
|
|
458
|
+
return HTTPProxyAttachment(**data)
|
|
459
|
+
|
|
460
|
+
async def detach_http_proxy(self, name: str, host_id: uuid.UUID | str) -> None:
|
|
461
|
+
"""Detach proxy ``name`` from ``host_id``. Unknown host or proxy
|
|
462
|
+
raises :class:`SandboxNotFoundError`."""
|
|
463
|
+
|
|
464
|
+
await self._request("DELETE", f"/http-proxies/{name}/hosts/{host_id}")
|
|
465
|
+
|
|
466
|
+
async def aclose(self) -> None:
|
|
467
|
+
if self._client:
|
|
468
|
+
await self._client.aclose()
|
|
469
|
+
self._client = None
|
|
470
|
+
self._client_loop = None
|
|
303
471
|
|
|
304
472
|
async def _request(
|
|
305
473
|
self,
|
|
@@ -308,13 +476,13 @@ class SandboxAPI:
|
|
|
308
476
|
*,
|
|
309
477
|
headers: dict[str, str] | None = None,
|
|
310
478
|
json: dict[str, Any] | None = None,
|
|
311
|
-
) ->
|
|
479
|
+
) -> Any:
|
|
312
480
|
client = await self._get_client()
|
|
313
481
|
request_headers = {
|
|
314
482
|
"Authorization": f"Bearer {self.token}",
|
|
315
483
|
"Accept": "application/json",
|
|
316
484
|
}
|
|
317
|
-
if headers
|
|
485
|
+
if headers:
|
|
318
486
|
request_headers.update(headers)
|
|
319
487
|
|
|
320
488
|
try:
|
|
@@ -354,9 +522,8 @@ class SandboxAPI:
|
|
|
354
522
|
if response.status_code == 422:
|
|
355
523
|
# FastAPI's 422 detail is a list of error dicts; join the messages
|
|
356
524
|
# so it reads as a line, not a raw list repr.
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
detail = "; ".join(item.get("msg", "") for item in detail) or "validation error"
|
|
525
|
+
errors = json_response.get("detail") or []
|
|
526
|
+
detail = "; ".join(error.get("msg", "") for error in errors) or "validation error"
|
|
360
527
|
raise SandboxValidationError(detail)
|
|
361
528
|
|
|
362
529
|
if response.status_code >= 400:
|
|
@@ -365,20 +532,16 @@ class SandboxAPI:
|
|
|
365
532
|
|
|
366
533
|
async def _get_client(self) -> httpx.AsyncClient:
|
|
367
534
|
running_loop = asyncio.get_running_loop()
|
|
368
|
-
|
|
369
|
-
if self._client is not None and self._client_loop is running_loop:
|
|
535
|
+
if self._client and self._client_loop is running_loop:
|
|
370
536
|
return self._client
|
|
371
|
-
|
|
372
|
-
# stale loop (e.g. fixture teardown). Serialize through the
|
|
373
|
-
# lock.
|
|
374
|
-
if self._client_lock is None:
|
|
537
|
+
if not self._client_lock:
|
|
375
538
|
self._client_lock = asyncio.Lock()
|
|
376
539
|
async with self._client_lock:
|
|
377
540
|
# Re-check under the lock; another coroutine may have raced
|
|
378
541
|
# ahead.
|
|
379
|
-
if self._client
|
|
542
|
+
if self._client and self._client_loop is running_loop:
|
|
380
543
|
return self._client
|
|
381
|
-
if self._client
|
|
544
|
+
if self._client:
|
|
382
545
|
# Stale-loop client: closing on its own loop is unsafe,
|
|
383
546
|
# so drop the reference and rely on GC. httpx will emit
|
|
384
547
|
# an "unclosed client" warning, which is the correct
|
|
@@ -22,11 +22,11 @@ import pytest
|
|
|
22
22
|
import respx
|
|
23
23
|
|
|
24
24
|
from drukbox_sdk import (
|
|
25
|
-
|
|
25
|
+
HTTPProxy,
|
|
26
|
+
HTTPProxyAttachment,
|
|
26
27
|
SandboxAPI,
|
|
27
28
|
SandboxAuthError,
|
|
28
29
|
SandboxConflictError,
|
|
29
|
-
SandboxHost,
|
|
30
30
|
SandboxNotFoundError,
|
|
31
31
|
SandboxProvisioningError,
|
|
32
32
|
SandboxResponseError,
|
|
@@ -81,6 +81,8 @@ def _host_payload(**overrides: Any) -> dict[str, Any]:
|
|
|
81
81
|
"status": "provisioning",
|
|
82
82
|
"provider": "exe",
|
|
83
83
|
"image": "ghcr.io/drukbox/sandbox:test",
|
|
84
|
+
"instance_type": None,
|
|
85
|
+
"disk_gb": None,
|
|
84
86
|
"external_ssh_host": "203.0.113.42",
|
|
85
87
|
"external_ssh_port": 22,
|
|
86
88
|
"ssh_username": "exedev",
|
|
@@ -98,6 +100,26 @@ def _host_payload(**overrides: Any) -> dict[str, Any]:
|
|
|
98
100
|
return payload
|
|
99
101
|
|
|
100
102
|
|
|
103
|
+
def _template_payload(**overrides: Any) -> dict[str, Any]:
|
|
104
|
+
"""One canonical template payload used across tests. Each case overrides fields."""
|
|
105
|
+
|
|
106
|
+
payload: dict[str, Any] = {
|
|
107
|
+
"id": str(uuid4()),
|
|
108
|
+
"provider": "exe",
|
|
109
|
+
"base_image": "ubuntu-24.04",
|
|
110
|
+
"setup_script_hash": "ab12" * 16,
|
|
111
|
+
"label": "Python tools",
|
|
112
|
+
"image": "",
|
|
113
|
+
"status": "building",
|
|
114
|
+
"last_error": "",
|
|
115
|
+
"created_at": "2026-08-24T12:00:00+00:00",
|
|
116
|
+
"updated_at": "2026-08-24T12:00:00+00:00",
|
|
117
|
+
"last_used_at": None,
|
|
118
|
+
}
|
|
119
|
+
payload.update(overrides)
|
|
120
|
+
return payload
|
|
121
|
+
|
|
122
|
+
|
|
101
123
|
@pytest.fixture
|
|
102
124
|
async def api() -> AsyncGenerator[SandboxAPI, None]:
|
|
103
125
|
"""Fresh SandboxAPI per test. Closed via finalizer."""
|
|
@@ -107,11 +129,6 @@ async def api() -> AsyncGenerator[SandboxAPI, None]:
|
|
|
107
129
|
await client.aclose()
|
|
108
130
|
|
|
109
131
|
|
|
110
|
-
# ---------------------------------------------------------------------------
|
|
111
|
-
# Happy paths
|
|
112
|
-
# ---------------------------------------------------------------------------
|
|
113
|
-
|
|
114
|
-
|
|
115
132
|
@respx.mock
|
|
116
133
|
async def test_create_host_posts_payload_and_returns_parsed_host(api: SandboxAPI):
|
|
117
134
|
expires_at = datetime(2026, 6, 1, 12, 0, tzinfo=UTC)
|
|
@@ -137,7 +154,6 @@ async def test_create_host_posts_payload_and_returns_parsed_host(api: SandboxAPI
|
|
|
137
154
|
assert '"env":{"FOO":"bar"}' in body
|
|
138
155
|
assert f'"expires_at":"{expires_at.isoformat()}"' in body
|
|
139
156
|
|
|
140
|
-
assert isinstance(host, SandboxHost)
|
|
141
157
|
assert host.id == payload["id"]
|
|
142
158
|
assert host.external_ssh_host == payload["external_ssh_host"]
|
|
143
159
|
assert host.external_ssh_port == payload["external_ssh_port"]
|
|
@@ -166,9 +182,52 @@ async def test_create_host_omits_image_and_env_when_unset(api: SandboxAPI):
|
|
|
166
182
|
assert "env" not in body
|
|
167
183
|
assert "expires_at" not in body
|
|
168
184
|
assert "provider" not in body
|
|
185
|
+
assert "instance_type" not in body
|
|
186
|
+
assert "disk_gb" not in body
|
|
187
|
+
assert "template" not in body
|
|
169
188
|
assert "Idempotency-Key" not in route.calls.last.request.headers
|
|
170
189
|
|
|
171
190
|
|
|
191
|
+
@respx.mock
|
|
192
|
+
async def test_create_host_permanent_sends_explicit_null(api: SandboxAPI):
|
|
193
|
+
"""``permanent=True`` opts out of expiry as the wire's explicit
|
|
194
|
+
``"expires_at":null`` — distinct from omitting it (default lease),
|
|
195
|
+
covered by test_create_host_omits_image_and_env_when_unset."""
|
|
196
|
+
|
|
197
|
+
payload = _host_payload(expires_at=None)
|
|
198
|
+
route = respx.post(f"{BASE_URL}/hosts").mock(
|
|
199
|
+
return_value=httpx.Response(201, json=payload),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
await api.create_host(permanent=True)
|
|
203
|
+
|
|
204
|
+
assert '"expires_at":null' in route.calls.last.request.content.decode()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
async def test_create_host_rejects_permanent_with_expires_at(api: SandboxAPI):
|
|
208
|
+
with pytest.raises(ValueError, match="permanent"):
|
|
209
|
+
await api.create_host(
|
|
210
|
+
permanent=True,
|
|
211
|
+
expires_at=datetime(2026, 9, 1, tzinfo=UTC),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@respx.mock
|
|
216
|
+
async def test_create_host_sends_sizing_when_set(api: SandboxAPI):
|
|
217
|
+
payload = _host_payload(instance_type="t3.xlarge", disk_gb=100)
|
|
218
|
+
route = respx.post(f"{BASE_URL}/hosts").mock(
|
|
219
|
+
return_value=httpx.Response(201, json=payload),
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
host = await api.create_host(instance_type="t3.xlarge", disk_gb=100)
|
|
223
|
+
|
|
224
|
+
body = route.calls.last.request.content.decode()
|
|
225
|
+
assert '"instance_type":"t3.xlarge"' in body
|
|
226
|
+
assert '"disk_gb":100' in body
|
|
227
|
+
assert host.instance_type == "t3.xlarge"
|
|
228
|
+
assert host.disk_gb == 100
|
|
229
|
+
|
|
230
|
+
|
|
172
231
|
@respx.mock
|
|
173
232
|
async def test_create_host_sends_provider_when_set(api: SandboxAPI):
|
|
174
233
|
payload = _host_payload(provider="hetzner")
|
|
@@ -182,6 +241,33 @@ async def test_create_host_sends_provider_when_set(api: SandboxAPI):
|
|
|
182
241
|
assert host.provider == "hetzner"
|
|
183
242
|
|
|
184
243
|
|
|
244
|
+
@respx.mock
|
|
245
|
+
async def test_create_host_sends_template_when_set(api: SandboxAPI):
|
|
246
|
+
payload = _host_payload()
|
|
247
|
+
route = respx.post(f"{BASE_URL}/hosts").mock(
|
|
248
|
+
return_value=httpx.Response(201, json=payload),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
template_id = uuid4()
|
|
252
|
+
await api.create_host(template=template_id)
|
|
253
|
+
|
|
254
|
+
assert f'"template":"{template_id}"' in route.calls.last.request.content.decode()
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@respx.mock
|
|
258
|
+
async def test_create_host_409_when_template_is_building(api: SandboxAPI):
|
|
259
|
+
template_id = uuid4()
|
|
260
|
+
respx.post(f"{BASE_URL}/hosts").mock(
|
|
261
|
+
return_value=httpx.Response(
|
|
262
|
+
409,
|
|
263
|
+
json={"detail": f"template {template_id} is building"},
|
|
264
|
+
),
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
with pytest.raises(SandboxConflictError, match="is building"):
|
|
268
|
+
await api.create_host(template=template_id)
|
|
269
|
+
|
|
270
|
+
|
|
185
271
|
@respx.mock
|
|
186
272
|
async def test_get_host_returns_parsed_host(api: SandboxAPI):
|
|
187
273
|
payload = _host_payload(status="active")
|
|
@@ -232,25 +318,55 @@ async def test_delete_host_swallows_204(api: SandboxAPI):
|
|
|
232
318
|
await api.delete_host(host_id)
|
|
233
319
|
|
|
234
320
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
321
|
+
@respx.mock
|
|
322
|
+
async def test_renew_host_posts_expires_at_and_returns_host(api: SandboxAPI):
|
|
323
|
+
expires_at = datetime(2026, 7, 1, 12, 0, tzinfo=UTC)
|
|
324
|
+
payload = _host_payload(status="active", expires_at=expires_at.isoformat())
|
|
325
|
+
route = respx.post(f"{BASE_URL}/hosts/{payload['id']}/renew").mock(
|
|
326
|
+
return_value=httpx.Response(200, json=payload),
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
host = await api.renew_host(payload["id"], expires_at=expires_at)
|
|
330
|
+
|
|
331
|
+
assert f'"expires_at":"{expires_at.isoformat()}"' in route.calls.last.request.content.decode()
|
|
332
|
+
assert host.expires_at == payload["expires_at"]
|
|
238
333
|
|
|
239
334
|
|
|
240
335
|
@respx.mock
|
|
241
|
-
async def
|
|
242
|
-
"""
|
|
243
|
-
|
|
336
|
+
async def test_renew_host_omits_expires_at_when_unset(api: SandboxAPI):
|
|
337
|
+
"""Omitted expires_at means "extend by the default TTL"; the SDK must
|
|
338
|
+
send an empty body, not an explicit null."""
|
|
244
339
|
|
|
245
|
-
payload = _host_payload(
|
|
246
|
-
respx.
|
|
340
|
+
payload = _host_payload(status="active")
|
|
341
|
+
route = respx.post(f"{BASE_URL}/hosts/{payload['id']}/renew").mock(
|
|
247
342
|
return_value=httpx.Response(200, json=payload),
|
|
248
343
|
)
|
|
249
344
|
|
|
250
|
-
|
|
345
|
+
await api.renew_host(payload["id"])
|
|
346
|
+
|
|
347
|
+
assert "expires_at" not in route.calls.last.request.content.decode()
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@respx.mock
|
|
351
|
+
async def test_renew_host_404_raises_not_found(api: SandboxAPI):
|
|
352
|
+
host_id = uuid4()
|
|
353
|
+
respx.post(f"{BASE_URL}/hosts/{host_id}/renew").mock(
|
|
354
|
+
return_value=httpx.Response(404, json={"detail": "host not found"}),
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
with pytest.raises(SandboxNotFoundError, match="host not found"):
|
|
358
|
+
await api.renew_host(host_id)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
@respx.mock
|
|
362
|
+
async def test_renew_host_409_when_not_renewable(api: SandboxAPI):
|
|
363
|
+
host_id = uuid4()
|
|
364
|
+
respx.post(f"{BASE_URL}/hosts/{host_id}/renew").mock(
|
|
365
|
+
return_value=httpx.Response(409, json={"detail": "cannot renew a host in status error"}),
|
|
366
|
+
)
|
|
251
367
|
|
|
252
|
-
|
|
253
|
-
|
|
368
|
+
with pytest.raises(SandboxConflictError, match="cannot renew"):
|
|
369
|
+
await api.renew_host(host_id)
|
|
254
370
|
|
|
255
371
|
|
|
256
372
|
@respx.mock
|
|
@@ -284,9 +400,89 @@ async def test_get_host_returns_none_private_key_after_create(api: SandboxAPI):
|
|
|
284
400
|
assert host.private_key is None
|
|
285
401
|
|
|
286
402
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
403
|
+
@respx.mock
|
|
404
|
+
async def test_create_template_posts_required_payload_and_returns_building_record(
|
|
405
|
+
api: SandboxAPI,
|
|
406
|
+
):
|
|
407
|
+
payload = _template_payload()
|
|
408
|
+
route = respx.post(f"{BASE_URL}/templates").mock(
|
|
409
|
+
return_value=httpx.Response(202, json=payload),
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
template = await api.create_template(setup_script="apt-get install -y ripgrep")
|
|
413
|
+
|
|
414
|
+
sent = route.calls.last.request
|
|
415
|
+
assert sent.headers["Authorization"] == "Bearer t-test"
|
|
416
|
+
assert sent.headers["Accept"] == "application/json"
|
|
417
|
+
body = sent.content.decode()
|
|
418
|
+
assert '"setup_script":"apt-get install -y ripgrep"' in body
|
|
419
|
+
assert "provider" not in body
|
|
420
|
+
assert "base_image" not in body
|
|
421
|
+
assert "label" not in body
|
|
422
|
+
assert template.id == payload["id"]
|
|
423
|
+
assert template.status == "building"
|
|
424
|
+
assert template.image == ""
|
|
425
|
+
assert template.last_used_at is None
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
@respx.mock
|
|
429
|
+
async def test_get_template_returns_parsed_template(api: SandboxAPI):
|
|
430
|
+
payload = _template_payload(
|
|
431
|
+
status="available",
|
|
432
|
+
image="derived:image",
|
|
433
|
+
last_used_at="2026-08-24T12:30:00+00:00",
|
|
434
|
+
)
|
|
435
|
+
respx.get(f"{BASE_URL}/templates/{payload['id']}").mock(
|
|
436
|
+
return_value=httpx.Response(200, json=payload),
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
template = await api.get_template(payload["id"])
|
|
440
|
+
|
|
441
|
+
assert template.status == "available"
|
|
442
|
+
assert template.image == "derived:image"
|
|
443
|
+
assert template.last_used_at == payload["last_used_at"]
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
@respx.mock
|
|
447
|
+
async def test_list_templates_parses_records_in_service_order(api: SandboxAPI):
|
|
448
|
+
payloads = [
|
|
449
|
+
_template_payload(label="newest"),
|
|
450
|
+
_template_payload(label="older"),
|
|
451
|
+
]
|
|
452
|
+
respx.get(f"{BASE_URL}/templates").mock(
|
|
453
|
+
return_value=httpx.Response(200, json=payloads),
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
templates = await api.list_templates()
|
|
457
|
+
|
|
458
|
+
assert [template.id for template in templates] == [payload["id"] for payload in payloads]
|
|
459
|
+
assert [template.label for template in templates] == ["newest", "older"]
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
@respx.mock
|
|
463
|
+
async def test_delete_template_swallows_204(api: SandboxAPI):
|
|
464
|
+
template_id = uuid4()
|
|
465
|
+
route = respx.delete(f"{BASE_URL}/templates/{template_id}").mock(
|
|
466
|
+
return_value=httpx.Response(204),
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
await api.delete_template(template_id)
|
|
470
|
+
|
|
471
|
+
assert route.called
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
@respx.mock
|
|
475
|
+
async def test_delete_template_409_while_building(api: SandboxAPI):
|
|
476
|
+
template_id = uuid4()
|
|
477
|
+
respx.delete(f"{BASE_URL}/templates/{template_id}").mock(
|
|
478
|
+
return_value=httpx.Response(
|
|
479
|
+
409,
|
|
480
|
+
json={"detail": "TEMPLATE_STATE: template is still building"},
|
|
481
|
+
),
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
with pytest.raises(SandboxConflictError, match="TEMPLATE_STATE"):
|
|
485
|
+
await api.delete_template(template_id)
|
|
290
486
|
|
|
291
487
|
|
|
292
488
|
@respx.mock
|
|
@@ -433,11 +629,6 @@ async def test_error_detail_absent_uses_fallback_message(api: SandboxAPI):
|
|
|
433
629
|
await api.list_hosts()
|
|
434
630
|
|
|
435
631
|
|
|
436
|
-
# ---------------------------------------------------------------------------
|
|
437
|
-
# Lifecycle + loop affinity
|
|
438
|
-
# ---------------------------------------------------------------------------
|
|
439
|
-
|
|
440
|
-
|
|
441
632
|
@respx.mock
|
|
442
633
|
async def test_aclose_drops_client_and_is_idempotent(api: SandboxAPI):
|
|
443
634
|
respx.get(f"{BASE_URL}/hosts").mock(return_value=httpx.Response(200, json=[]))
|
|
@@ -484,11 +675,6 @@ def test_cross_loop_reuse_rebinds_client_without_crashing():
|
|
|
484
675
|
assert bound_loops[0] is not bound_loops[1]
|
|
485
676
|
|
|
486
677
|
|
|
487
|
-
# ---------------------------------------------------------------------------
|
|
488
|
-
# from_env
|
|
489
|
-
# ---------------------------------------------------------------------------
|
|
490
|
-
|
|
491
|
-
|
|
492
678
|
def test_from_env_reads_prefixed_vars(monkeypatch: pytest.MonkeyPatch):
|
|
493
679
|
monkeypatch.setenv("SANDBOX_SERVICE_URL", "https://from-env.test")
|
|
494
680
|
monkeypatch.setenv("SANDBOX_SERVICE_TOKEN", "env-token")
|
|
@@ -536,7 +722,6 @@ async def test_doctor_parses_report_and_checks(api: SandboxAPI):
|
|
|
536
722
|
|
|
537
723
|
assert route.called
|
|
538
724
|
assert route.calls.last.request.headers["Authorization"] == "Bearer t-test"
|
|
539
|
-
assert isinstance(report, DoctorReport)
|
|
540
725
|
assert report.ok is True
|
|
541
726
|
assert report.active_provider == "aws"
|
|
542
727
|
assert report.tailscale_enabled is True
|
|
@@ -583,36 +768,116 @@ async def test_doctor_surfaces_failure_with_hint(api: SandboxAPI):
|
|
|
583
768
|
|
|
584
769
|
|
|
585
770
|
@respx.mock
|
|
586
|
-
async def
|
|
587
|
-
"""
|
|
771
|
+
async def test_doctor_401_raises_sandbox_auth_error(api: SandboxAPI):
|
|
772
|
+
"""Doctor is service-token only; a bad token raises like every other call."""
|
|
588
773
|
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
{
|
|
592
|
-
"name": "db",
|
|
593
|
-
"status": "ok",
|
|
594
|
-
"detail": "select 1 -> 1",
|
|
595
|
-
"latency_ms": 2,
|
|
596
|
-
"hint": None,
|
|
597
|
-
"future_field": "surprise",
|
|
598
|
-
},
|
|
599
|
-
],
|
|
774
|
+
respx.get(f"{BASE_URL}/doctor").mock(
|
|
775
|
+
return_value=httpx.Response(401, json={"detail": "service token required"}),
|
|
600
776
|
)
|
|
601
|
-
respx.get(f"{BASE_URL}/doctor").mock(return_value=httpx.Response(200, json=payload))
|
|
602
777
|
|
|
603
|
-
|
|
778
|
+
with pytest.raises(SandboxAuthError, match="service token required"):
|
|
779
|
+
await api.doctor()
|
|
604
780
|
|
|
605
|
-
|
|
606
|
-
|
|
781
|
+
|
|
782
|
+
@respx.mock
|
|
783
|
+
async def test_create_http_proxy_posts_payload_and_returns_record(api: SandboxAPI):
|
|
784
|
+
route = respx.post(f"{BASE_URL}/http-proxies").mock(
|
|
785
|
+
return_value=httpx.Response(201, json={"name": "gh", "status": "created"}),
|
|
786
|
+
)
|
|
787
|
+
|
|
788
|
+
proxy = await api.create_http_proxy(
|
|
789
|
+
name="gh",
|
|
790
|
+
target="https://api.github.com",
|
|
791
|
+
headers={"Authorization": "Bearer x"},
|
|
792
|
+
)
|
|
793
|
+
|
|
794
|
+
body = route.calls.last.request.content.decode()
|
|
795
|
+
assert route.calls.last.request.headers["Authorization"] == "Bearer t-test"
|
|
796
|
+
assert '"name":"gh"' in body
|
|
797
|
+
assert '"target":"https://api.github.com"' in body
|
|
798
|
+
assert '"headers":{"Authorization":"Bearer x"}' in body
|
|
799
|
+
assert proxy == HTTPProxy(name="gh", status="created")
|
|
607
800
|
|
|
608
801
|
|
|
609
802
|
@respx.mock
|
|
610
|
-
async def
|
|
611
|
-
""
|
|
803
|
+
async def test_create_http_proxy_409_raises_conflict(api: SandboxAPI):
|
|
804
|
+
respx.post(f"{BASE_URL}/http-proxies").mock(
|
|
805
|
+
return_value=httpx.Response(409, json={"detail": "http proxy already exists"}),
|
|
806
|
+
)
|
|
612
807
|
|
|
613
|
-
|
|
614
|
-
|
|
808
|
+
with pytest.raises(SandboxConflictError, match="already exists"):
|
|
809
|
+
await api.create_http_proxy(name="gh", target="https://api.github.com", headers={"X": "y"})
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
@respx.mock
|
|
813
|
+
async def test_create_http_proxy_422_raises_validation(api: SandboxAPI):
|
|
814
|
+
"""A target carrying a path is rejected server-side; the SDK surfaces
|
|
815
|
+
it as the don't-retry validation error."""
|
|
816
|
+
|
|
817
|
+
respx.post(f"{BASE_URL}/http-proxies").mock(
|
|
818
|
+
return_value=httpx.Response(
|
|
819
|
+
422,
|
|
820
|
+
json={"detail": [{"msg": "target URL must not contain a path"}]},
|
|
821
|
+
),
|
|
615
822
|
)
|
|
616
823
|
|
|
617
|
-
with pytest.raises(
|
|
618
|
-
await api.
|
|
824
|
+
with pytest.raises(SandboxValidationError, match="must not contain a path"):
|
|
825
|
+
await api.create_http_proxy(
|
|
826
|
+
name="gh", target="https://api.github.com/v3", headers={"X": "y"}
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
@respx.mock
|
|
831
|
+
async def test_delete_http_proxy_swallows_204(api: SandboxAPI):
|
|
832
|
+
respx.delete(f"{BASE_URL}/http-proxies/gh").mock(return_value=httpx.Response(204))
|
|
833
|
+
|
|
834
|
+
await api.delete_http_proxy("gh")
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
@respx.mock
|
|
838
|
+
async def test_delete_http_proxy_404_raises_not_found(api: SandboxAPI):
|
|
839
|
+
respx.delete(f"{BASE_URL}/http-proxies/gh").mock(
|
|
840
|
+
return_value=httpx.Response(404, json={"detail": "http proxy not found"}),
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
with pytest.raises(SandboxNotFoundError, match="not found"):
|
|
844
|
+
await api.delete_http_proxy("gh")
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
@respx.mock
|
|
848
|
+
async def test_attach_http_proxy_returns_attachment(api: SandboxAPI):
|
|
849
|
+
host_id = uuid4()
|
|
850
|
+
route = respx.post(f"{BASE_URL}/http-proxies/gh/hosts/{host_id}").mock(
|
|
851
|
+
return_value=httpx.Response(
|
|
852
|
+
200, json={"name": "gh", "host_id": str(host_id), "status": "attached"}
|
|
853
|
+
),
|
|
854
|
+
)
|
|
855
|
+
|
|
856
|
+
attachment = await api.attach_http_proxy("gh", host_id)
|
|
857
|
+
|
|
858
|
+
assert route.called
|
|
859
|
+
assert attachment == HTTPProxyAttachment(name="gh", host_id=str(host_id), status="attached")
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
@respx.mock
|
|
863
|
+
async def test_attach_http_proxy_409_when_host_has_no_vm(api: SandboxAPI):
|
|
864
|
+
"""Attaching to a host that isn't bootstrapping/active is a state
|
|
865
|
+
conflict, not a validation error."""
|
|
866
|
+
|
|
867
|
+
host_id = uuid4()
|
|
868
|
+
respx.post(f"{BASE_URL}/http-proxies/gh/hosts/{host_id}").mock(
|
|
869
|
+
return_value=httpx.Response(409, json={"detail": "host does not have a backing VM"}),
|
|
870
|
+
)
|
|
871
|
+
|
|
872
|
+
with pytest.raises(SandboxConflictError, match="backing VM"):
|
|
873
|
+
await api.attach_http_proxy("gh", host_id)
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
@respx.mock
|
|
877
|
+
async def test_detach_http_proxy_swallows_204(api: SandboxAPI):
|
|
878
|
+
host_id = uuid4()
|
|
879
|
+
respx.delete(f"{BASE_URL}/http-proxies/gh/hosts/{host_id}").mock(
|
|
880
|
+
return_value=httpx.Response(204),
|
|
881
|
+
)
|
|
882
|
+
|
|
883
|
+
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
|
|
File without changes
|