drukbox-python-sdk 0.0.1__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.1/.github/workflows/on-main-merge.yml +47 -0
- drukbox_python_sdk-0.0.1/.github/workflows/on-pull-request.yml +45 -0
- drukbox_python_sdk-0.0.1/.github/workflows/release.yml +49 -0
- drukbox_python_sdk-0.0.1/.gitignore +16 -0
- drukbox_python_sdk-0.0.1/AGENTS.md +59 -0
- drukbox_python_sdk-0.0.1/PKG-INFO +95 -0
- drukbox_python_sdk-0.0.1/README.md +87 -0
- drukbox_python_sdk-0.0.1/pyproject.toml +49 -0
- drukbox_python_sdk-0.0.1/src/drukbox_sdk/__init__.py +31 -0
- drukbox_python_sdk-0.0.1/src/drukbox_sdk/api.py +313 -0
- drukbox_python_sdk-0.0.1/src/drukbox_sdk/exceptions.py +54 -0
- drukbox_python_sdk-0.0.1/tests/__init__.py +0 -0
- drukbox_python_sdk-0.0.1/tests/test_api.py +419 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
name: Validate On Main Merge
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- main
|
|
7
|
+
|
|
8
|
+
concurrency:
|
|
9
|
+
group: main-merge-${{ github.ref }}
|
|
10
|
+
cancel-in-progress: true
|
|
11
|
+
|
|
12
|
+
permissions:
|
|
13
|
+
contents: read
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
checks:
|
|
17
|
+
name: Run Checks
|
|
18
|
+
runs-on: ubuntu-latest
|
|
19
|
+
|
|
20
|
+
steps:
|
|
21
|
+
- name: Check out repository
|
|
22
|
+
uses: actions/checkout@v4
|
|
23
|
+
|
|
24
|
+
- name: Set up Python
|
|
25
|
+
uses: actions/setup-python@v5
|
|
26
|
+
with:
|
|
27
|
+
python-version: "3.11"
|
|
28
|
+
|
|
29
|
+
- name: Set up uv
|
|
30
|
+
uses: astral-sh/setup-uv@v6
|
|
31
|
+
with:
|
|
32
|
+
enable-cache: true
|
|
33
|
+
version: "0.10.9"
|
|
34
|
+
|
|
35
|
+
- name: Sync dependencies
|
|
36
|
+
run: uv sync --dev
|
|
37
|
+
|
|
38
|
+
- name: Run Ruff
|
|
39
|
+
run: |
|
|
40
|
+
uv run ruff check
|
|
41
|
+
uv run ruff format --check
|
|
42
|
+
|
|
43
|
+
- name: Run Pyright
|
|
44
|
+
run: uv run pyright
|
|
45
|
+
|
|
46
|
+
- name: Run Tests
|
|
47
|
+
run: uv run pytest -v
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
name: Validate On Pull Request
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
|
|
6
|
+
concurrency:
|
|
7
|
+
group: pull-request-${{ github.event.pull_request.number }}
|
|
8
|
+
cancel-in-progress: true
|
|
9
|
+
|
|
10
|
+
permissions:
|
|
11
|
+
contents: read
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
checks:
|
|
15
|
+
name: Run Checks
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
|
|
18
|
+
steps:
|
|
19
|
+
- name: Check out repository
|
|
20
|
+
uses: actions/checkout@v4
|
|
21
|
+
|
|
22
|
+
- name: Set up Python
|
|
23
|
+
uses: actions/setup-python@v5
|
|
24
|
+
with:
|
|
25
|
+
python-version: "3.11"
|
|
26
|
+
|
|
27
|
+
- name: Set up uv
|
|
28
|
+
uses: astral-sh/setup-uv@v6
|
|
29
|
+
with:
|
|
30
|
+
enable-cache: true
|
|
31
|
+
version: "0.10.9"
|
|
32
|
+
|
|
33
|
+
- name: Sync dependencies
|
|
34
|
+
run: uv sync --dev
|
|
35
|
+
|
|
36
|
+
- name: Run Ruff
|
|
37
|
+
run: |
|
|
38
|
+
uv run ruff check
|
|
39
|
+
uv run ruff format --check
|
|
40
|
+
|
|
41
|
+
- name: Run Pyright
|
|
42
|
+
run: uv run pyright
|
|
43
|
+
|
|
44
|
+
- name: Run Tests
|
|
45
|
+
run: uv run pytest -v
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types:
|
|
6
|
+
- published
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
|
|
9
|
+
concurrency:
|
|
10
|
+
group: release-${{ github.workflow }}-${{ github.ref }}
|
|
11
|
+
cancel-in-progress: false
|
|
12
|
+
|
|
13
|
+
permissions:
|
|
14
|
+
contents: read
|
|
15
|
+
|
|
16
|
+
jobs:
|
|
17
|
+
pypi:
|
|
18
|
+
name: Publish to PyPI
|
|
19
|
+
runs-on: ubuntu-latest
|
|
20
|
+
environment:
|
|
21
|
+
name: pypi
|
|
22
|
+
url: https://pypi.org/project/drukbox-python-sdk/
|
|
23
|
+
permissions:
|
|
24
|
+
contents: read
|
|
25
|
+
id-token: write # required for PyPI Trusted Publisher OIDC exchange
|
|
26
|
+
|
|
27
|
+
steps:
|
|
28
|
+
- name: Check out repository
|
|
29
|
+
uses: actions/checkout@v4
|
|
30
|
+
|
|
31
|
+
- name: Set up Python
|
|
32
|
+
uses: actions/setup-python@v5
|
|
33
|
+
with:
|
|
34
|
+
python-version: "3.11"
|
|
35
|
+
|
|
36
|
+
- name: Set up uv
|
|
37
|
+
uses: astral-sh/setup-uv@v6
|
|
38
|
+
with:
|
|
39
|
+
enable-cache: true
|
|
40
|
+
version: "0.10.9"
|
|
41
|
+
|
|
42
|
+
- name: Build wheel + sdist
|
|
43
|
+
run: uv build --out-dir dist
|
|
44
|
+
|
|
45
|
+
- name: Validate distribution metadata
|
|
46
|
+
run: uv run --with twine twine check --strict dist/*
|
|
47
|
+
|
|
48
|
+
- name: Publish to PyPI
|
|
49
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# AGENTS.md
|
|
2
|
+
|
|
3
|
+
Guide for AI agents working in `drukbox-python-sdk`.
|
|
4
|
+
|
|
5
|
+
## What This Project Is
|
|
6
|
+
|
|
7
|
+
`drukbox-python-sdk` is the async Python SDK for Drukbox's HTTP host API:
|
|
8
|
+
a small client library that speaks HTTP, parses typed records, and raises
|
|
9
|
+
typed errors.
|
|
10
|
+
|
|
11
|
+
The package name is `drukbox-python-sdk`; the import package is
|
|
12
|
+
`drukbox_sdk`. Public API names use `Sandbox*` because the Drukbox domain
|
|
13
|
+
object is a sandbox host.
|
|
14
|
+
|
|
15
|
+
## Boundaries
|
|
16
|
+
|
|
17
|
+
- Keep this repo focused on HTTP client behavior, typed records, and typed
|
|
18
|
+
exceptions.
|
|
19
|
+
- Do not add SSH session management, file transfer, command execution,
|
|
20
|
+
Tailscale management, VM provider logic, or service-side lifecycle behavior.
|
|
21
|
+
- Prefer explicit Drukbox wire contracts over broad defensive parsing.
|
|
22
|
+
- Keep dependencies light; justify any new runtime dependency before adding it.
|
|
23
|
+
|
|
24
|
+
## Layout
|
|
25
|
+
|
|
26
|
+
```text
|
|
27
|
+
src/drukbox_sdk/
|
|
28
|
+
api.py # SandboxAPI, records, parsers, HTTP request handling
|
|
29
|
+
exceptions.py # SDK exception hierarchy
|
|
30
|
+
__init__.py # Public exports
|
|
31
|
+
tests/
|
|
32
|
+
test_api.py # respx-backed SDK contract tests
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Development Commands
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
uv sync
|
|
39
|
+
uv run ruff check
|
|
40
|
+
uv run ruff format --check
|
|
41
|
+
uv run pyright
|
|
42
|
+
uv run pytest
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Run the full set when changing Python behavior. For documentation-only edits,
|
|
46
|
+
grep for any names you changed and state in the summary that tests were not
|
|
47
|
+
run.
|
|
48
|
+
|
|
49
|
+
## Working Rules
|
|
50
|
+
|
|
51
|
+
- Read this file, `pyproject.toml`, and the relevant source/tests before
|
|
52
|
+
editing.
|
|
53
|
+
- Follow the repo's Python 3.11+ typing style and keep public APIs typed.
|
|
54
|
+
- Match the existing async `httpx` style and strict typing.
|
|
55
|
+
- Add focused tests for behavior changes.
|
|
56
|
+
- Keep docs concise and repo-specific. Avoid references to downstream
|
|
57
|
+
consumers unless the user asks for them.
|
|
58
|
+
- Preserve user changes in the worktree; do not clean or rewrite unrelated
|
|
59
|
+
files.
|
|
@@ -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,87 @@
|
|
|
1
|
+
# drukbox-python-sdk
|
|
2
|
+
|
|
3
|
+
Async Python client for the [Drukbox] host API.
|
|
4
|
+
|
|
5
|
+
The SDK provisions sandbox VMs, reads host state, deletes hosts, and
|
|
6
|
+
returns the SSH connection details a caller needs. It speaks HTTP only:
|
|
7
|
+
SSH sessions, file transfer, command execution, and retry orchestration
|
|
8
|
+
belong in the caller.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install drukbox-python-sdk
|
|
14
|
+
uv add drukbox-python-sdk
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from drukbox_sdk import SandboxAPI
|
|
21
|
+
|
|
22
|
+
sandbox = SandboxAPI(
|
|
23
|
+
base_url="https://sandbox.internal.ts.net",
|
|
24
|
+
token="...",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
host = await sandbox.create_host(
|
|
29
|
+
image="ghcr.io/drukbox/sandbox:abc123",
|
|
30
|
+
env={"FOO": "bar"},
|
|
31
|
+
idempotency_key="agent-run-42",
|
|
32
|
+
)
|
|
33
|
+
# Use host.external_ssh_host (or host.internal_ssh_host when the
|
|
34
|
+
# service runs with Tailscale enabled), host.external_ssh_port,
|
|
35
|
+
# and host.known_hosts with asyncssh or another SSH client.
|
|
36
|
+
finally:
|
|
37
|
+
await sandbox.delete_host(host.id)
|
|
38
|
+
await sandbox.aclose()
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`create_host` blocks until the host is `active` — typically ~10–30s, up to a
|
|
42
|
+
few minutes worst case. The SDK's default `timeout` (300s) covers this. Pass
|
|
43
|
+
an `idempotency_key` for retry safety: a retry with the same key after a
|
|
44
|
+
successful provision returns the original host instead of creating a duplicate.
|
|
45
|
+
|
|
46
|
+
`SandboxAPI.from_env(prefix="SANDBOX_")` reads
|
|
47
|
+
`SANDBOX_SERVICE_URL`, `SANDBOX_SERVICE_TOKEN`, and optional
|
|
48
|
+
`SANDBOX_SERVICE_TIMEOUT`.
|
|
49
|
+
|
|
50
|
+
## Contract
|
|
51
|
+
|
|
52
|
+
Public exports live in `drukbox_sdk`:
|
|
53
|
+
|
|
54
|
+
- `SandboxAPI`
|
|
55
|
+
- `SandboxHost`
|
|
56
|
+
- `SandboxAPIError` and typed subclasses for auth, not found, conflict,
|
|
57
|
+
unavailable, and unclassified response errors
|
|
58
|
+
|
|
59
|
+
Supported host operations:
|
|
60
|
+
|
|
61
|
+
- `create_host`
|
|
62
|
+
- `get_host`
|
|
63
|
+
- `attach`
|
|
64
|
+
- `list_hosts`
|
|
65
|
+
- `delete_host`
|
|
66
|
+
- `aclose`
|
|
67
|
+
|
|
68
|
+
`create_host` supports the service's optional `image`, `env`, `expires_at`,
|
|
69
|
+
and `Idempotency-Key` inputs.
|
|
70
|
+
|
|
71
|
+
The SDK does not mint Tailscale auth keys, manage ACLs, establish SSH,
|
|
72
|
+
provision Linux users, transfer files, or run remote commands.
|
|
73
|
+
|
|
74
|
+
## Development
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
uv sync
|
|
78
|
+
uv run ruff check
|
|
79
|
+
uv run ruff format --check
|
|
80
|
+
uv run pyright
|
|
81
|
+
uv run pytest
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Tests use `respx` to fake the Drukbox HTTP API. They do not need a
|
|
85
|
+
real network, VM provider, or Drukbox service.
|
|
86
|
+
|
|
87
|
+
[Drukbox]: https://github.com/clawhaven/drukbox
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "drukbox-python-sdk"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "Async Python client for Drukbox."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
# Tracks the lowest Python any current consumer supports. Bump only
|
|
11
|
+
# when a real consumer needs a 3.12+ feature.
|
|
12
|
+
requires-python = ">=3.11"
|
|
13
|
+
dependencies = [
|
|
14
|
+
"httpx>=0.28",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[tool.hatch.build.targets.wheel]
|
|
18
|
+
packages = ["src/drukbox_sdk"]
|
|
19
|
+
|
|
20
|
+
[dependency-groups]
|
|
21
|
+
dev = [
|
|
22
|
+
"pyright>=1.1",
|
|
23
|
+
"pytest>=8.0",
|
|
24
|
+
"pytest-asyncio>=0.25",
|
|
25
|
+
"respx>=0.23",
|
|
26
|
+
"ruff>=0.9",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[tool.ruff]
|
|
30
|
+
line-length = 100
|
|
31
|
+
target-version = "py311"
|
|
32
|
+
src = ["src", "tests"]
|
|
33
|
+
|
|
34
|
+
[tool.ruff.format]
|
|
35
|
+
line-ending = "lf"
|
|
36
|
+
quote-style = "double"
|
|
37
|
+
|
|
38
|
+
[tool.ruff.lint]
|
|
39
|
+
select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
|
|
40
|
+
|
|
41
|
+
[tool.pytest.ini_options]
|
|
42
|
+
asyncio_mode = "auto"
|
|
43
|
+
testpaths = ["tests"]
|
|
44
|
+
|
|
45
|
+
[tool.pyright]
|
|
46
|
+
include = ["src", "tests"]
|
|
47
|
+
pythonVersion = "3.11"
|
|
48
|
+
typeCheckingMode = "strict"
|
|
49
|
+
reportMissingTypeStubs = false
|
|
@@ -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
|
+
]
|
|
@@ -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."""
|
|
File without changes
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
"""SDK-only tests against a fake httpx server via respx.
|
|
2
|
+
|
|
3
|
+
The SDK's contract is "speak HTTP to the sandbox service correctly,
|
|
4
|
+
parse the responses into typed records, raise typed errors on
|
|
5
|
+
non-success codes." No SSH, no provisioning, no real network. respx
|
|
6
|
+
gives us a controllable transport layer so every test stays in-process.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
# Tests legitimately read the SDK's internal _client / _client_loop to
|
|
10
|
+
# verify lifecycle + loop-binding behaviour. Suppressing here rather
|
|
11
|
+
# than promoting them to public API or adding test-only accessors.
|
|
12
|
+
# pyright: reportPrivateUsage=false
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
from collections.abc import AsyncGenerator
|
|
16
|
+
from datetime import UTC, datetime
|
|
17
|
+
from typing import Any
|
|
18
|
+
from uuid import uuid4
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
import pytest
|
|
22
|
+
import respx
|
|
23
|
+
|
|
24
|
+
from drukbox_sdk import (
|
|
25
|
+
SandboxAPI,
|
|
26
|
+
SandboxAuthError,
|
|
27
|
+
SandboxConflictError,
|
|
28
|
+
SandboxHost,
|
|
29
|
+
SandboxNotFoundError,
|
|
30
|
+
SandboxProvisioningError,
|
|
31
|
+
SandboxResponseError,
|
|
32
|
+
SandboxUnavailableError,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
BASE_URL = "https://sandbox.test"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _host_payload(**overrides: Any) -> dict[str, Any]:
|
|
39
|
+
"""One canonical host payload used across tests; overrides per case."""
|
|
40
|
+
|
|
41
|
+
payload: dict[str, Any] = {
|
|
42
|
+
"id": str(uuid4()),
|
|
43
|
+
"name": "host-abc",
|
|
44
|
+
"status": "provisioning",
|
|
45
|
+
"provider": "exe",
|
|
46
|
+
"image": "ghcr.io/drukbox/sandbox:test",
|
|
47
|
+
"external_ssh_host": "203.0.113.42",
|
|
48
|
+
"external_ssh_port": 22,
|
|
49
|
+
"internal_ssh_host": "host-abc.example.ts.net",
|
|
50
|
+
"known_hosts": "ssh-ed25519 AAAA...\n",
|
|
51
|
+
"tailscale_device_id": None,
|
|
52
|
+
"last_error": "",
|
|
53
|
+
"created_at": "2026-05-28T12:00:00+00:00",
|
|
54
|
+
"updated_at": "2026-05-28T12:00:00+00:00",
|
|
55
|
+
"activated_at": None,
|
|
56
|
+
"expires_at": None,
|
|
57
|
+
}
|
|
58
|
+
payload.update(overrides)
|
|
59
|
+
return payload
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@pytest.fixture
|
|
63
|
+
async def api() -> AsyncGenerator[SandboxAPI, None]:
|
|
64
|
+
"""Fresh SandboxAPI per test. Closed via finalizer."""
|
|
65
|
+
|
|
66
|
+
client = SandboxAPI(base_url=BASE_URL, token="t-test", timeout=5.0)
|
|
67
|
+
yield client
|
|
68
|
+
await client.aclose()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
# Happy paths
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@respx.mock
|
|
77
|
+
async def test_create_host_posts_payload_and_returns_parsed_host(api: SandboxAPI):
|
|
78
|
+
expires_at = datetime(2026, 6, 1, 12, 0, tzinfo=UTC)
|
|
79
|
+
payload = _host_payload(expires_at=expires_at.isoformat())
|
|
80
|
+
route = respx.post(f"{BASE_URL}/hosts").mock(
|
|
81
|
+
return_value=httpx.Response(202, json=payload),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
host = await api.create_host(
|
|
85
|
+
image="ghcr.io/drukbox/sandbox:test",
|
|
86
|
+
env={"FOO": "bar"},
|
|
87
|
+
expires_at=expires_at,
|
|
88
|
+
idempotency_key="agent-run-42",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
assert route.called
|
|
92
|
+
sent = route.calls.last.request
|
|
93
|
+
assert sent.headers["Authorization"] == "Bearer t-test"
|
|
94
|
+
assert sent.headers["Accept"] == "application/json"
|
|
95
|
+
assert sent.headers["Idempotency-Key"] == "agent-run-42"
|
|
96
|
+
body = sent.content.decode()
|
|
97
|
+
assert '"image":"ghcr.io/drukbox/sandbox:test"' in body
|
|
98
|
+
assert '"env":{"FOO":"bar"}' in body
|
|
99
|
+
assert f'"expires_at":"{expires_at.isoformat()}"' in body
|
|
100
|
+
|
|
101
|
+
assert isinstance(host, SandboxHost)
|
|
102
|
+
assert host.id == payload["id"]
|
|
103
|
+
assert host.external_ssh_host == payload["external_ssh_host"]
|
|
104
|
+
assert host.external_ssh_port == payload["external_ssh_port"]
|
|
105
|
+
assert host.internal_ssh_host == payload["internal_ssh_host"]
|
|
106
|
+
assert host.tailscale_device_id is None
|
|
107
|
+
assert host.activated_at is None
|
|
108
|
+
assert host.expires_at == payload["expires_at"]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@respx.mock
|
|
112
|
+
async def test_create_host_omits_image_and_env_when_unset(api: SandboxAPI):
|
|
113
|
+
"""The service treats absence as "use defaults"; we must not send
|
|
114
|
+
explicit nulls or empty dicts because the service distinguishes
|
|
115
|
+
those from "key not present"."""
|
|
116
|
+
|
|
117
|
+
payload = _host_payload()
|
|
118
|
+
route = respx.post(f"{BASE_URL}/hosts").mock(
|
|
119
|
+
return_value=httpx.Response(201, json=payload),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
await api.create_host()
|
|
123
|
+
|
|
124
|
+
body = route.calls.last.request.content.decode()
|
|
125
|
+
assert "image" not in body
|
|
126
|
+
assert "env" not in body
|
|
127
|
+
assert "expires_at" not in body
|
|
128
|
+
assert "Idempotency-Key" not in route.calls.last.request.headers
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@respx.mock
|
|
132
|
+
async def test_get_host_returns_parsed_host(api: SandboxAPI):
|
|
133
|
+
payload = _host_payload(status="active")
|
|
134
|
+
respx.get(f"{BASE_URL}/hosts/{payload['id']}").mock(
|
|
135
|
+
return_value=httpx.Response(200, json=payload),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
host = await api.get_host(payload["id"])
|
|
139
|
+
|
|
140
|
+
assert host.status == "active"
|
|
141
|
+
assert host.provider == "exe"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@respx.mock
|
|
145
|
+
async def test_attach_is_alias_for_get_host(api: SandboxAPI):
|
|
146
|
+
payload = _host_payload()
|
|
147
|
+
route = respx.get(f"{BASE_URL}/hosts/{payload['id']}").mock(
|
|
148
|
+
return_value=httpx.Response(200, json=payload),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
via_attach = await api.attach(payload["id"])
|
|
152
|
+
via_get = await api.get_host(payload["id"])
|
|
153
|
+
|
|
154
|
+
assert via_attach == via_get
|
|
155
|
+
# Two HTTP roundtrips; attach is not memoized.
|
|
156
|
+
assert route.call_count == 2
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@respx.mock
|
|
160
|
+
async def test_list_hosts_parses_each_record(api: SandboxAPI):
|
|
161
|
+
payloads = [_host_payload(), _host_payload()]
|
|
162
|
+
respx.get(f"{BASE_URL}/hosts").mock(
|
|
163
|
+
return_value=httpx.Response(200, json=payloads),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
hosts = await api.list_hosts()
|
|
167
|
+
|
|
168
|
+
assert len(hosts) == 2
|
|
169
|
+
assert {h.id for h in hosts} == {p["id"] for p in payloads}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@respx.mock
|
|
173
|
+
async def test_delete_host_swallows_204(api: SandboxAPI):
|
|
174
|
+
host_id = uuid4()
|
|
175
|
+
respx.delete(f"{BASE_URL}/hosts/{host_id}").mock(return_value=httpx.Response(204))
|
|
176
|
+
|
|
177
|
+
# Should not raise and should not need a body.
|
|
178
|
+
await api.delete_host(host_id)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ---------------------------------------------------------------------------
|
|
182
|
+
# Forwards compatibility — service adds fields
|
|
183
|
+
# ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@respx.mock
|
|
187
|
+
async def test_unknown_fields_in_host_payload_are_ignored(api: SandboxAPI):
|
|
188
|
+
"""If the service ships a new field tomorrow, today's SDK must not
|
|
189
|
+
break. The host parser picks known fields only."""
|
|
190
|
+
|
|
191
|
+
payload = _host_payload(future_field="surprise", another_one=42)
|
|
192
|
+
respx.get(f"{BASE_URL}/hosts/{payload['id']}").mock(
|
|
193
|
+
return_value=httpx.Response(200, json=payload),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
host = await api.get_host(payload["id"])
|
|
197
|
+
|
|
198
|
+
assert host.id == payload["id"] # Old fields still parsed.
|
|
199
|
+
assert not hasattr(host, "future_field") # New field silently dropped.
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
# Error classification
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@respx.mock
|
|
208
|
+
async def test_401_raises_sandbox_auth_error(api: SandboxAPI):
|
|
209
|
+
respx.get(f"{BASE_URL}/hosts").mock(
|
|
210
|
+
return_value=httpx.Response(401, json={"detail": "bad token"}),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
with pytest.raises(SandboxAuthError, match="bad token"):
|
|
214
|
+
await api.list_hosts()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@respx.mock
|
|
218
|
+
async def test_403_raises_sandbox_auth_error(api: SandboxAPI):
|
|
219
|
+
"""403 and 401 are both classified as auth; callers shouldn't have
|
|
220
|
+
to care about the distinction — both mean "fix your credentials"."""
|
|
221
|
+
|
|
222
|
+
respx.get(f"{BASE_URL}/hosts").mock(
|
|
223
|
+
return_value=httpx.Response(403, json={"detail": "forbidden"}),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
with pytest.raises(SandboxAuthError):
|
|
227
|
+
await api.list_hosts()
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@respx.mock
|
|
231
|
+
async def test_404_raises_sandbox_not_found_error(api: SandboxAPI):
|
|
232
|
+
host_id = uuid4()
|
|
233
|
+
respx.get(f"{BASE_URL}/hosts/{host_id}").mock(
|
|
234
|
+
return_value=httpx.Response(404, json={"detail": "host gone"}),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
with pytest.raises(SandboxNotFoundError, match="host gone"):
|
|
238
|
+
await api.get_host(host_id)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@respx.mock
|
|
242
|
+
async def test_409_raises_sandbox_conflict_error(api: SandboxAPI):
|
|
243
|
+
host_id = uuid4()
|
|
244
|
+
respx.delete(f"{BASE_URL}/hosts/{host_id}").mock(
|
|
245
|
+
return_value=httpx.Response(409, json={"detail": "host is still provisioning"}),
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
with pytest.raises(SandboxConflictError, match="host is still provisioning"):
|
|
249
|
+
await api.delete_host(host_id)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@respx.mock
|
|
253
|
+
async def test_500_raises_sandbox_response_error(api: SandboxAPI):
|
|
254
|
+
respx.get(f"{BASE_URL}/hosts").mock(
|
|
255
|
+
return_value=httpx.Response(500, json={"detail": "boom"}),
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
with pytest.raises(SandboxResponseError, match="boom"):
|
|
259
|
+
await api.list_hosts()
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@respx.mock
|
|
263
|
+
async def test_502_raises_sandbox_provisioning_error(api: SandboxAPI):
|
|
264
|
+
"""The service maps inline provisioning failures to 502; the SDK
|
|
265
|
+
surfaces them as a dedicated error so callers can distinguish
|
|
266
|
+
"provisioning broke" from generic server faults."""
|
|
267
|
+
|
|
268
|
+
respx.post(f"{BASE_URL}/hosts").mock(
|
|
269
|
+
return_value=httpx.Response(
|
|
270
|
+
502,
|
|
271
|
+
json={"detail": "ssh-keyscan never returned host keys"},
|
|
272
|
+
),
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
with pytest.raises(SandboxProvisioningError, match="ssh-keyscan"):
|
|
276
|
+
await api.create_host()
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@respx.mock
|
|
280
|
+
async def test_transport_error_raises_sandbox_unavailable_error(api: SandboxAPI):
|
|
281
|
+
"""Network-level failure (DNS, connection refused, TLS) — wrapped as
|
|
282
|
+
SandboxUnavailableError so callers can distinguish "service can't be
|
|
283
|
+
reached, retry with backoff" from "service responded with a refusal"."""
|
|
284
|
+
|
|
285
|
+
respx.get(f"{BASE_URL}/hosts").mock(side_effect=httpx.ConnectError("nope"))
|
|
286
|
+
|
|
287
|
+
with pytest.raises(SandboxUnavailableError, match="transport failed"):
|
|
288
|
+
await api.list_hosts()
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@respx.mock
|
|
292
|
+
async def test_503_raises_sandbox_unavailable_error(api: SandboxAPI):
|
|
293
|
+
"""A 503 from the service (host teardown failed, dependency outage)
|
|
294
|
+
is the service's own "I'm broken, try again later" signal — same
|
|
295
|
+
semantics as a transport failure on the caller's side."""
|
|
296
|
+
|
|
297
|
+
respx.get(f"{BASE_URL}/hosts").mock(
|
|
298
|
+
return_value=httpx.Response(503, json={"detail": "host teardown could not be completed"}),
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
with pytest.raises(SandboxUnavailableError, match="host teardown"):
|
|
302
|
+
await api.list_hosts()
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@respx.mock
|
|
306
|
+
async def test_non_json_body_raises_sandbox_response_error(api: SandboxAPI):
|
|
307
|
+
respx.get(f"{BASE_URL}/hosts").mock(
|
|
308
|
+
return_value=httpx.Response(200, text="<html>oops</html>"),
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
with pytest.raises(SandboxResponseError, match="non-JSON"):
|
|
312
|
+
await api.list_hosts()
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@respx.mock
|
|
316
|
+
async def test_error_detail_absent_uses_fallback_message(api: SandboxAPI):
|
|
317
|
+
"""The service contract returns ``{"detail": "..."}`` on errors but
|
|
318
|
+
we shouldn't crash if a different shape arrives — fall back to a
|
|
319
|
+
generic message rather than KeyError-ing on the caller."""
|
|
320
|
+
|
|
321
|
+
respx.get(f"{BASE_URL}/hosts").mock(
|
|
322
|
+
return_value=httpx.Response(500, json={"unexpected": "shape"}),
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
with pytest.raises(SandboxResponseError):
|
|
326
|
+
await api.list_hosts()
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# ---------------------------------------------------------------------------
|
|
330
|
+
# Lifecycle + loop affinity
|
|
331
|
+
# ---------------------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
@respx.mock
|
|
335
|
+
async def test_aclose_drops_client_and_is_idempotent(api: SandboxAPI):
|
|
336
|
+
respx.get(f"{BASE_URL}/hosts").mock(return_value=httpx.Response(200, json=[]))
|
|
337
|
+
await api.list_hosts()
|
|
338
|
+
assert api._client is not None
|
|
339
|
+
|
|
340
|
+
await api.aclose()
|
|
341
|
+
assert api._client is None
|
|
342
|
+
|
|
343
|
+
# Second close is a no-op (no AttributeError, no double-close).
|
|
344
|
+
await api.aclose()
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def test_cross_loop_reuse_rebinds_client_without_crashing():
|
|
348
|
+
"""The httpx ``AsyncClient`` is loop-bound. Reusing the same SDK
|
|
349
|
+
instance across two ``asyncio.run`` invocations (the closest
|
|
350
|
+
stand-in for two distinct event-loop lifetimes against one module-
|
|
351
|
+
level SDK instance) should rebind instead of erroring.
|
|
352
|
+
|
|
353
|
+
The pre-fix behaviour was a ``RuntimeError("Event loop is closed")``
|
|
354
|
+
or ``Loop attached to a different loop`` on the second call — that
|
|
355
|
+
not raising is the whole point. As a secondary check we capture
|
|
356
|
+
the bound loop reference and assert they differ across the two
|
|
357
|
+
runs.
|
|
358
|
+
"""
|
|
359
|
+
|
|
360
|
+
sandbox = SandboxAPI(base_url=BASE_URL, token="t", timeout=5.0)
|
|
361
|
+
bound_loops: list[asyncio.AbstractEventLoop] = []
|
|
362
|
+
|
|
363
|
+
async def use_it() -> None:
|
|
364
|
+
with respx.mock() as mock:
|
|
365
|
+
mock.get(f"{BASE_URL}/hosts").mock(
|
|
366
|
+
return_value=httpx.Response(200, json=[]),
|
|
367
|
+
)
|
|
368
|
+
await sandbox.list_hosts()
|
|
369
|
+
assert sandbox._client_loop is not None
|
|
370
|
+
bound_loops.append(sandbox._client_loop)
|
|
371
|
+
|
|
372
|
+
asyncio.run(use_it())
|
|
373
|
+
asyncio.run(use_it())
|
|
374
|
+
|
|
375
|
+
# Two distinct loop objects — the SDK rebound on the second
|
|
376
|
+
# invocation rather than reusing a stale (closed) loop.
|
|
377
|
+
assert bound_loops[0] is not bound_loops[1]
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
# ---------------------------------------------------------------------------
|
|
381
|
+
# from_env
|
|
382
|
+
# ---------------------------------------------------------------------------
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def test_from_env_reads_prefixed_vars(monkeypatch: pytest.MonkeyPatch):
|
|
386
|
+
monkeypatch.setenv("SANDBOX_SERVICE_URL", "https://from-env.test")
|
|
387
|
+
monkeypatch.setenv("SANDBOX_SERVICE_TOKEN", "env-token")
|
|
388
|
+
monkeypatch.setenv("SANDBOX_SERVICE_TIMEOUT", "60")
|
|
389
|
+
|
|
390
|
+
sandbox = SandboxAPI.from_env()
|
|
391
|
+
|
|
392
|
+
assert sandbox.base_url == "https://from-env.test"
|
|
393
|
+
assert sandbox.token == "env-token"
|
|
394
|
+
assert sandbox.timeout == 60.0
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def test_from_env_supports_custom_prefix(monkeypatch: pytest.MonkeyPatch):
|
|
398
|
+
monkeypatch.setenv("CUSTOM_SERVICE_URL", "https://custom.test")
|
|
399
|
+
monkeypatch.setenv("CUSTOM_SERVICE_TOKEN", "x")
|
|
400
|
+
|
|
401
|
+
sandbox = SandboxAPI.from_env(prefix="CUSTOM_")
|
|
402
|
+
|
|
403
|
+
assert sandbox.base_url == "https://custom.test"
|
|
404
|
+
assert sandbox.timeout == 300.0 # default when unset
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def test_from_env_strips_trailing_slash_via_constructor(
|
|
408
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
409
|
+
):
|
|
410
|
+
"""``base_url`` should be normalized so concatenation with ``/hosts``
|
|
411
|
+
doesn't yield ``//hosts``. The constructor strips, not the env
|
|
412
|
+
reader — covered here because that's the most common entry point."""
|
|
413
|
+
|
|
414
|
+
monkeypatch.setenv("SANDBOX_SERVICE_URL", "https://trailing.test/")
|
|
415
|
+
monkeypatch.setenv("SANDBOX_SERVICE_TOKEN", "x")
|
|
416
|
+
|
|
417
|
+
sandbox = SandboxAPI.from_env()
|
|
418
|
+
|
|
419
|
+
assert sandbox.base_url == "https://trailing.test"
|