isb-sdk 0.2.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.
- isb_sdk-0.2.0/.gitignore +8 -0
- isb_sdk-0.2.0/LICENSE +21 -0
- isb_sdk-0.2.0/PKG-INFO +321 -0
- isb_sdk-0.2.0/README.md +294 -0
- isb_sdk-0.2.0/hatch_build.py +50 -0
- isb_sdk-0.2.0/pyproject.toml +65 -0
- isb_sdk-0.2.0/scripts/gen_types.py +230 -0
- isb_sdk-0.2.0/src/isb/__init__.py +99 -0
- isb_sdk-0.2.0/src/isb/_builders.py +113 -0
- isb_sdk-0.2.0/src/isb/_client.py +429 -0
- isb_sdk-0.2.0/src/isb/_errors.py +95 -0
- isb_sdk-0.2.0/src/isb/_project.py +149 -0
- isb_sdk-0.2.0/src/isb/_sandbox.py +550 -0
- isb_sdk-0.2.0/src/isb/_spec.py +261 -0
- isb_sdk-0.2.0/src/isb/_types.py +163 -0
- isb_sdk-0.2.0/src/isb/_util.py +33 -0
- isb_sdk-0.2.0/src/isb/py.typed +0 -0
- isb_sdk-0.2.0/src/isb/volumes.py +73 -0
- isb_sdk-0.2.0/tests/test_integration.py +374 -0
- isb_sdk-0.2.0/tests/test_unit.py +365 -0
isb_sdk-0.2.0/.gitignore
ADDED
isb_sdk-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Execution Associates
|
|
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.
|
isb_sdk-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: isb-sdk
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Python SDK for isb: declarative incus sandboxes (containers and VMs)
|
|
5
|
+
Project-URL: Homepage, https://github.com/execution-associates/isb
|
|
6
|
+
Project-URL: Repository, https://github.com/execution-associates/isb
|
|
7
|
+
Project-URL: Documentation, https://github.com/execution-associates/isb/tree/main/sdk/python
|
|
8
|
+
Author-email: Stephan Fitzpatrick <stephan@orangecountyai.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: containers,incus,lxc,sandbox,virtual-machines
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Framework :: AsyncIO
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
23
|
+
Classifier: Topic :: System :: Systems Administration
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# isb for Python
|
|
29
|
+
|
|
30
|
+
An asyncio SDK for [isb](https://github.com/execution-associates/isb):
|
|
31
|
+
declarative incus sandboxes (containers and VMs). It is a thin client of
|
|
32
|
+
`isb rpc`, a line-delimited JSON protocol over the isb binary's stdin and stdout
|
|
33
|
+
([docs/rpc.md](https://github.com/execution-associates/isb/blob/main/docs/rpc.md)).
|
|
34
|
+
All the work (planning, reconciling, readiness, exec) happens in isb; this
|
|
35
|
+
package starts it, sends requests and maps the answers to Python types.
|
|
36
|
+
|
|
37
|
+
- Python 3.10 or later, Linux.
|
|
38
|
+
- No runtime dependencies (standard library only).
|
|
39
|
+
- Typed (`py.typed`), with TypedDicts for the spec generated from `isb schema`.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
pip install isb-sdk
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Platform wheels (x86_64 and aarch64 Linux) bundle a static isb binary at
|
|
48
|
+
`isb/_bin/isb`, so nothing else is needed. The pure wheel and the sdist do not;
|
|
49
|
+
they use an isb binary from elsewhere.
|
|
50
|
+
|
|
51
|
+
The binary is looked up in this order:
|
|
52
|
+
|
|
53
|
+
1. `Client(isb_bin="/path/to/isb")`
|
|
54
|
+
2. the `ISB_BIN` environment variable
|
|
55
|
+
3. the bundled binary
|
|
56
|
+
4. `isb` on `PATH`
|
|
57
|
+
|
|
58
|
+
It must be a version with the `rpc` command (protocol 1). The client refuses a
|
|
59
|
+
server that announces any other protocol version.
|
|
60
|
+
|
|
61
|
+
isb needs access to the incus socket (`$INCUS_SOCKET`, else
|
|
62
|
+
`$INCUS_DIR/unix.socket`, else `/var/lib/incus/unix.socket`), which usually
|
|
63
|
+
means membership in `incus-admin`. That access is root-equivalent on the host.
|
|
64
|
+
|
|
65
|
+
## Quickstart
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
import asyncio
|
|
69
|
+
import isb
|
|
70
|
+
from isb import PortBinding, Sandbox, Volume
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def main() -> None:
|
|
74
|
+
async with isb.Client() as client:
|
|
75
|
+
sb = await Sandbox.connect_or_create(
|
|
76
|
+
"dev-web",
|
|
77
|
+
image="dev-base",
|
|
78
|
+
client=client,
|
|
79
|
+
cpus=4,
|
|
80
|
+
memory="4GiB",
|
|
81
|
+
idmap="auto",
|
|
82
|
+
labels={"app": "web"},
|
|
83
|
+
volumes={
|
|
84
|
+
"/home/dev/src": Volume.bind("./src", device="src"),
|
|
85
|
+
"/home/dev/.cache": Volume.named("dev-cache", owner="dev"),
|
|
86
|
+
},
|
|
87
|
+
ports=[
|
|
88
|
+
PortBinding.host(
|
|
89
|
+
"tcp:127.0.0.1:5173",
|
|
90
|
+
"tcp:127.0.0.1:5173",
|
|
91
|
+
name="vite",
|
|
92
|
+
search=20,
|
|
93
|
+
),
|
|
94
|
+
],
|
|
95
|
+
ready=["running", "default_route", {"user_exists": "dev"}],
|
|
96
|
+
exec={"user": "dev", "cwd": "/home/dev/src"},
|
|
97
|
+
on_progress=print,
|
|
98
|
+
)
|
|
99
|
+
print(sb.last_report)
|
|
100
|
+
|
|
101
|
+
# Captured output. argv is never joined into a shell string.
|
|
102
|
+
out = await sb.exec("printf", ["[%s]", "a b", "$HOME"])
|
|
103
|
+
assert out.stdout_text == "[a b][$HOME]"
|
|
104
|
+
|
|
105
|
+
# Streaming output, as it is produced.
|
|
106
|
+
script = "for i in 1 2 3; do echo $i; sleep 1; done"
|
|
107
|
+
proc = await sb.exec_stream(["sh", "-c", script])
|
|
108
|
+
async with proc:
|
|
109
|
+
async for event in proc:
|
|
110
|
+
print(event.kind, event.text, end="")
|
|
111
|
+
print("exit code:", await proc.wait())
|
|
112
|
+
|
|
113
|
+
await sb.remove(force=True)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
asyncio.run(main())
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Compose files
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
project = await isb.Project.load("isb.yaml", vars={"WORKTREE": "/srv/wt"})
|
|
123
|
+
|
|
124
|
+
for plan in await project.plan():
|
|
125
|
+
print(plan.name, plan.status, plan.actions)
|
|
126
|
+
|
|
127
|
+
for service, report in await project.up(on_progress=print):
|
|
128
|
+
print(service, report.created, report.ports)
|
|
129
|
+
|
|
130
|
+
# A sandbox from the project carries its service's exec defaults.
|
|
131
|
+
web = project.sandbox("web")
|
|
132
|
+
await web.exec(["bun", "install"])
|
|
133
|
+
|
|
134
|
+
await project.down(volumes=True)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## API overview
|
|
138
|
+
|
|
139
|
+
### Client
|
|
140
|
+
|
|
141
|
+
`Client(isb_bin=None, socket=None, project=None, create_timeout=None)` owns one
|
|
142
|
+
`isb rpc` subprocess, started on first use (or `await client.start()`) and
|
|
143
|
+
stopped by `await client.close()` or `async with`. `socket`, `project` and
|
|
144
|
+
`create_timeout` are passed as the global flags `--socket`, `--project` and
|
|
145
|
+
`--create-timeout`. Requests run concurrently over the one process.
|
|
146
|
+
|
|
147
|
+
Every function takes an optional `client=`. Without one it uses
|
|
148
|
+
`isb.default_client()`, created lazily with default settings (one per event
|
|
149
|
+
loop). `client.call(method, params)` sends any protocol method directly.
|
|
150
|
+
|
|
151
|
+
A client belongs to the event loop it started on. If the subprocess exits,
|
|
152
|
+
every pending and later request fails with `ProcessError`, whose message
|
|
153
|
+
includes the tail of isb's stderr.
|
|
154
|
+
|
|
155
|
+
The server's working directory is fixed when it starts, so the SDK resolves
|
|
156
|
+
relative paths itself: `base_dir` for bind mounts defaults to the current
|
|
157
|
+
directory at the time of the call, and compose file paths are made absolute.
|
|
158
|
+
|
|
159
|
+
### Sandbox
|
|
160
|
+
|
|
161
|
+
| | |
|
|
162
|
+
|---|---|
|
|
163
|
+
| `await Sandbox.create(name, *, image, **spec)` | Create. `AlreadyExistsError` if the name is taken. |
|
|
164
|
+
| `await Sandbox.connect_or_create(name, *, image, prune_devices=False, **spec)` | Create or reconcile (only what differs changes). The report is on `sb.last_report`. Also `Sandbox.ensure`. |
|
|
165
|
+
| `await Sandbox.plan(name, *, image, **spec)` | What `connect_or_create` would do, as a `Plan`. |
|
|
166
|
+
| `await Sandbox.get(name)` | Handle on an existing sandbox. `NotFoundError` if missing. |
|
|
167
|
+
| `await Sandbox.list_with(labels)` / `Sandbox.list()` | `list[SandboxInfo]`. `labels` is `{"k": "v", "k2": None}` or `["k=v", "k2"]`. |
|
|
168
|
+
| `await Sandbox.remove(name, force=False)` | Delete. A running sandbox needs `force`. |
|
|
169
|
+
|
|
170
|
+
`**spec` are the `SandboxSpec` fields of the compose format
|
|
171
|
+
([docs/spec.md](https://github.com/execution-associates/isb/blob/main/docs/spec.md)):
|
|
172
|
+
`cpus`, `memory`, `storage`, `type` (`"container"`, `"virtual-machine"` or
|
|
173
|
+
`"vm"`), `privileged`, `idmap`, `profiles`, `labels`, `env`, `volumes`, `ports`,
|
|
174
|
+
`ready`, `ready_timeout`, `exec`, `raw_config`, `raw_devices`. A full spec dict
|
|
175
|
+
can be passed as `spec=` instead. The server rejects unknown fields
|
|
176
|
+
(`InvalidError`). The create-style methods also take `base_dir`, `wait_ready`
|
|
177
|
+
(default true), `named_volumes` (top-level named volume definitions, as in a
|
|
178
|
+
compose file's `volumes:`) and `on_progress` (called with each progress line).
|
|
179
|
+
|
|
180
|
+
On an instance: `info()`, `labels()`, `start()`, `stop(force=False, timeout="30s")`,
|
|
181
|
+
`restart()`, `wait_ready(ready=None, ready_timeout=None)`, `remove(force=False)`,
|
|
182
|
+
`add_port(port)` (returns the listen address in use), `remove_device(name)`
|
|
183
|
+
(returns whether it existed).
|
|
184
|
+
|
|
185
|
+
A handle from `create`, `connect_or_create` or `Project.sandbox` carries the
|
|
186
|
+
spec's `exec` defaults (user, cwd, env, login) and sends them with every exec;
|
|
187
|
+
per-call arguments override them.
|
|
188
|
+
|
|
189
|
+
### Exec
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
out = await sb.exec(
|
|
193
|
+
cmd,
|
|
194
|
+
args=None,
|
|
195
|
+
*,
|
|
196
|
+
cwd=None,
|
|
197
|
+
user=None,
|
|
198
|
+
env=None,
|
|
199
|
+
login=None,
|
|
200
|
+
timeout=None,
|
|
201
|
+
stdin=None,
|
|
202
|
+
tty=False,
|
|
203
|
+
)
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
`cmd` is a program plus `args`, or a full argv list. Returns
|
|
207
|
+
`ExecOutput(exit_code, stdout: bytes, stderr: bytes)` with `stdout_text`,
|
|
208
|
+
`stderr_text` and `success`. A non-zero exit is not an exception. `stdin` is
|
|
209
|
+
bytes or str, sent followed by EOF. `timeout` is seconds or a duration string
|
|
210
|
+
(`"90s"`); when it passes, the command is killed and `IsbTimeoutError` is
|
|
211
|
+
raised. With `tty=True`, all output arrives as stdout.
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
p = await sb.exec_stream(cmd, args=None, *, ..., stdin=None | "piped" | bytes)
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
returns an `ExecProcess`: iterate it for `ExecEvent(kind, data)` chunks in
|
|
218
|
+
order, and drive it with `await p.write(b)`, `await p.close_stdin()`,
|
|
219
|
+
`await p.signal(15)`, `await p.resize(w, h)`. `await p.wait()` returns the exit
|
|
220
|
+
code, `await p.collect()` gathers the rest into an `ExecOutput`. Used as
|
|
221
|
+
`async with`, it kills the command on exit if it is still running.
|
|
222
|
+
|
|
223
|
+
### Builders
|
|
224
|
+
|
|
225
|
+
These return plain dicts for the spec:
|
|
226
|
+
|
|
227
|
+
- `Volume.bind(host_path, *, readonly=False, device=None, options=None)`
|
|
228
|
+
- `Volume.named(name, *, mode=NamedVolumeMode.ENSURE_EXISTS, owner=None, readonly=False, pool=None, device=None)`;
|
|
229
|
+
`NamedVolumeMode.EXISTING` means the volume must exist (`external: true`).
|
|
230
|
+
- `PortBinding.host(listen, connect, *, name=None, search=None)`: listen on the
|
|
231
|
+
host, connect in the guest.
|
|
232
|
+
- `PortBinding.guest(listen, connect, *, name=None)`: listen in the guest,
|
|
233
|
+
connect on the host.
|
|
234
|
+
|
|
235
|
+
### Project
|
|
236
|
+
|
|
237
|
+
`await Project.load(files=None, *, env_files=None, project_name=None, vars=None)`
|
|
238
|
+
loads and resolves compose files (default `./isb.yaml`, else `./isb.yml`).
|
|
239
|
+
`vars` win over the environment for `${VAR}`. The result has `name`,
|
|
240
|
+
`base_dir`, `files`, `file` (the resolved file, every sandbox named) and
|
|
241
|
+
`services`. Then `up(services=None, *, prune_devices=False, wait_ready=True)`
|
|
242
|
+
returns `(service, ApplyReport)` pairs, `plan(...)` returns `list[Plan]`,
|
|
243
|
+
`down(services=None, *, volumes=False)` deletes, and `sandbox("web")` returns a
|
|
244
|
+
handle with that service's exec defaults.
|
|
245
|
+
|
|
246
|
+
### Volumes and prune
|
|
247
|
+
|
|
248
|
+
`isb.volumes.list(pool=None)`, `get(name, pool=None)`,
|
|
249
|
+
`create(name, pool=None, *, config=None)` (returns `{"created", "pool"}`, a
|
|
250
|
+
no-op if it exists), `remove(name, pool=None)`. `isb.Volumes(client)` has the
|
|
251
|
+
same methods bound to one client. `await isb.prune(label, dry_run=True)` lists
|
|
252
|
+
(or, with `dry_run=False`, deletes) sandboxes whose `label` value is a host path
|
|
253
|
+
that no longer exists.
|
|
254
|
+
|
|
255
|
+
### Types
|
|
256
|
+
|
|
257
|
+
`SandboxInfo`, `ApplyReport`, `Plan`, `VolumeInfo`, `PruneResult`, `ExecOutput`
|
|
258
|
+
and `ExecEvent` are dataclasses. Plan actions are dicts tagged by `action`.
|
|
259
|
+
The spec types (`SandboxSpec`, `VolumeSpec`, `PortSpec`, `ReadyCheck`,
|
|
260
|
+
`ExecDefaults`, `IdmapSpec`, `NamedVolumeSpec`, `ComposeFile`) are TypedDicts in
|
|
261
|
+
`isb._spec`, generated from the JSON Schema by `scripts/gen_types.py`:
|
|
262
|
+
|
|
263
|
+
```sh
|
|
264
|
+
ISB_BIN=/path/to/isb python3 scripts/gen_types.py # regenerate
|
|
265
|
+
ISB_BIN=/path/to/isb python3 scripts/gen_types.py --check # fail if stale
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
## Errors
|
|
269
|
+
|
|
270
|
+
Every error is an `IsbError` with `code` (the protocol's stable code),
|
|
271
|
+
`message` and `data`:
|
|
272
|
+
|
|
273
|
+
| class | codes |
|
|
274
|
+
|---|---|
|
|
275
|
+
| `NotFoundError` | `not_found` |
|
|
276
|
+
| `AlreadyExistsError` | `already_exists` |
|
|
277
|
+
| `NotReadyError` | `not_ready` |
|
|
278
|
+
| `IsbTimeoutError` | `request_timeout`, `operation_timeout`, `exec_timeout` |
|
|
279
|
+
| `InvalidError` | `invalid`, `interpolation`, `parse` |
|
|
280
|
+
| `ConnectError` | `connect` (isb cannot reach incusd) |
|
|
281
|
+
| `ApiError` | `api`, `operation_failed` |
|
|
282
|
+
| `ProtocolError` | `protocol` (also unknown method), `bad_request`, a bad hello |
|
|
283
|
+
| `ProcessError` | `process`: the subprocess could not start or exited |
|
|
284
|
+
| `BinaryNotFoundError` | `binary_not_found` (a `ProcessError`) |
|
|
285
|
+
|
|
286
|
+
Other codes (`websocket`, `io`, `json`) raise `IsbError` itself.
|
|
287
|
+
`IsbTimeoutError` does not derive from the built-in `TimeoutError`.
|
|
288
|
+
|
|
289
|
+
## Development
|
|
290
|
+
|
|
291
|
+
Unit tests need only an isb binary with `rpc` (they use a socket that does not
|
|
292
|
+
exist, and fake servers for failure paths); integration tests need incusd and a
|
|
293
|
+
local image with a `dev` user at uid 1000 and python3 (`ISB_TEST_IMAGE`,
|
|
294
|
+
default `dev-base`). Both use the standard library's `unittest`, so no install
|
|
295
|
+
is needed:
|
|
296
|
+
|
|
297
|
+
```sh
|
|
298
|
+
# from the repository root
|
|
299
|
+
cargo build
|
|
300
|
+
ISB_BIN=target/debug/isb PYTHONPATH=sdk/python/src python3 -m unittest discover -s sdk/python/tests -v
|
|
301
|
+
ISB_INTEGRATION=1 ISB_BIN=target/debug/isb PYTHONPATH=sdk/python/src python3 -m unittest discover -s sdk/python/tests -v
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
Integration tests name everything `isb-test-py-<pid>-...`, label it
|
|
305
|
+
`isb-test=py`, and remove it afterwards.
|
|
306
|
+
|
|
307
|
+
Lint, type check and build (in `sdk/python`):
|
|
308
|
+
|
|
309
|
+
```sh
|
|
310
|
+
uv sync --group dev
|
|
311
|
+
uv run ruff check . && uv run ruff format --check .
|
|
312
|
+
uv run mypy
|
|
313
|
+
uv build # pure wheel and sdist
|
|
314
|
+
ISB_WHEEL_BINARY=../../target/x86_64-unknown-linux-musl/release/isb \
|
|
315
|
+
ISB_WHEEL_PLAT=manylinux_2_17_x86_64.musllinux_1_2_x86_64 \
|
|
316
|
+
uv build --wheel # platform wheel with the binary
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
## License
|
|
320
|
+
|
|
321
|
+
MIT
|
isb_sdk-0.2.0/README.md
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
# isb for Python
|
|
2
|
+
|
|
3
|
+
An asyncio SDK for [isb](https://github.com/execution-associates/isb):
|
|
4
|
+
declarative incus sandboxes (containers and VMs). It is a thin client of
|
|
5
|
+
`isb rpc`, a line-delimited JSON protocol over the isb binary's stdin and stdout
|
|
6
|
+
([docs/rpc.md](https://github.com/execution-associates/isb/blob/main/docs/rpc.md)).
|
|
7
|
+
All the work (planning, reconciling, readiness, exec) happens in isb; this
|
|
8
|
+
package starts it, sends requests and maps the answers to Python types.
|
|
9
|
+
|
|
10
|
+
- Python 3.10 or later, Linux.
|
|
11
|
+
- No runtime dependencies (standard library only).
|
|
12
|
+
- Typed (`py.typed`), with TypedDicts for the spec generated from `isb schema`.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
pip install isb-sdk
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Platform wheels (x86_64 and aarch64 Linux) bundle a static isb binary at
|
|
21
|
+
`isb/_bin/isb`, so nothing else is needed. The pure wheel and the sdist do not;
|
|
22
|
+
they use an isb binary from elsewhere.
|
|
23
|
+
|
|
24
|
+
The binary is looked up in this order:
|
|
25
|
+
|
|
26
|
+
1. `Client(isb_bin="/path/to/isb")`
|
|
27
|
+
2. the `ISB_BIN` environment variable
|
|
28
|
+
3. the bundled binary
|
|
29
|
+
4. `isb` on `PATH`
|
|
30
|
+
|
|
31
|
+
It must be a version with the `rpc` command (protocol 1). The client refuses a
|
|
32
|
+
server that announces any other protocol version.
|
|
33
|
+
|
|
34
|
+
isb needs access to the incus socket (`$INCUS_SOCKET`, else
|
|
35
|
+
`$INCUS_DIR/unix.socket`, else `/var/lib/incus/unix.socket`), which usually
|
|
36
|
+
means membership in `incus-admin`. That access is root-equivalent on the host.
|
|
37
|
+
|
|
38
|
+
## Quickstart
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
import asyncio
|
|
42
|
+
import isb
|
|
43
|
+
from isb import PortBinding, Sandbox, Volume
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def main() -> None:
|
|
47
|
+
async with isb.Client() as client:
|
|
48
|
+
sb = await Sandbox.connect_or_create(
|
|
49
|
+
"dev-web",
|
|
50
|
+
image="dev-base",
|
|
51
|
+
client=client,
|
|
52
|
+
cpus=4,
|
|
53
|
+
memory="4GiB",
|
|
54
|
+
idmap="auto",
|
|
55
|
+
labels={"app": "web"},
|
|
56
|
+
volumes={
|
|
57
|
+
"/home/dev/src": Volume.bind("./src", device="src"),
|
|
58
|
+
"/home/dev/.cache": Volume.named("dev-cache", owner="dev"),
|
|
59
|
+
},
|
|
60
|
+
ports=[
|
|
61
|
+
PortBinding.host(
|
|
62
|
+
"tcp:127.0.0.1:5173",
|
|
63
|
+
"tcp:127.0.0.1:5173",
|
|
64
|
+
name="vite",
|
|
65
|
+
search=20,
|
|
66
|
+
),
|
|
67
|
+
],
|
|
68
|
+
ready=["running", "default_route", {"user_exists": "dev"}],
|
|
69
|
+
exec={"user": "dev", "cwd": "/home/dev/src"},
|
|
70
|
+
on_progress=print,
|
|
71
|
+
)
|
|
72
|
+
print(sb.last_report)
|
|
73
|
+
|
|
74
|
+
# Captured output. argv is never joined into a shell string.
|
|
75
|
+
out = await sb.exec("printf", ["[%s]", "a b", "$HOME"])
|
|
76
|
+
assert out.stdout_text == "[a b][$HOME]"
|
|
77
|
+
|
|
78
|
+
# Streaming output, as it is produced.
|
|
79
|
+
script = "for i in 1 2 3; do echo $i; sleep 1; done"
|
|
80
|
+
proc = await sb.exec_stream(["sh", "-c", script])
|
|
81
|
+
async with proc:
|
|
82
|
+
async for event in proc:
|
|
83
|
+
print(event.kind, event.text, end="")
|
|
84
|
+
print("exit code:", await proc.wait())
|
|
85
|
+
|
|
86
|
+
await sb.remove(force=True)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
asyncio.run(main())
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Compose files
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
project = await isb.Project.load("isb.yaml", vars={"WORKTREE": "/srv/wt"})
|
|
96
|
+
|
|
97
|
+
for plan in await project.plan():
|
|
98
|
+
print(plan.name, plan.status, plan.actions)
|
|
99
|
+
|
|
100
|
+
for service, report in await project.up(on_progress=print):
|
|
101
|
+
print(service, report.created, report.ports)
|
|
102
|
+
|
|
103
|
+
# A sandbox from the project carries its service's exec defaults.
|
|
104
|
+
web = project.sandbox("web")
|
|
105
|
+
await web.exec(["bun", "install"])
|
|
106
|
+
|
|
107
|
+
await project.down(volumes=True)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## API overview
|
|
111
|
+
|
|
112
|
+
### Client
|
|
113
|
+
|
|
114
|
+
`Client(isb_bin=None, socket=None, project=None, create_timeout=None)` owns one
|
|
115
|
+
`isb rpc` subprocess, started on first use (or `await client.start()`) and
|
|
116
|
+
stopped by `await client.close()` or `async with`. `socket`, `project` and
|
|
117
|
+
`create_timeout` are passed as the global flags `--socket`, `--project` and
|
|
118
|
+
`--create-timeout`. Requests run concurrently over the one process.
|
|
119
|
+
|
|
120
|
+
Every function takes an optional `client=`. Without one it uses
|
|
121
|
+
`isb.default_client()`, created lazily with default settings (one per event
|
|
122
|
+
loop). `client.call(method, params)` sends any protocol method directly.
|
|
123
|
+
|
|
124
|
+
A client belongs to the event loop it started on. If the subprocess exits,
|
|
125
|
+
every pending and later request fails with `ProcessError`, whose message
|
|
126
|
+
includes the tail of isb's stderr.
|
|
127
|
+
|
|
128
|
+
The server's working directory is fixed when it starts, so the SDK resolves
|
|
129
|
+
relative paths itself: `base_dir` for bind mounts defaults to the current
|
|
130
|
+
directory at the time of the call, and compose file paths are made absolute.
|
|
131
|
+
|
|
132
|
+
### Sandbox
|
|
133
|
+
|
|
134
|
+
| | |
|
|
135
|
+
|---|---|
|
|
136
|
+
| `await Sandbox.create(name, *, image, **spec)` | Create. `AlreadyExistsError` if the name is taken. |
|
|
137
|
+
| `await Sandbox.connect_or_create(name, *, image, prune_devices=False, **spec)` | Create or reconcile (only what differs changes). The report is on `sb.last_report`. Also `Sandbox.ensure`. |
|
|
138
|
+
| `await Sandbox.plan(name, *, image, **spec)` | What `connect_or_create` would do, as a `Plan`. |
|
|
139
|
+
| `await Sandbox.get(name)` | Handle on an existing sandbox. `NotFoundError` if missing. |
|
|
140
|
+
| `await Sandbox.list_with(labels)` / `Sandbox.list()` | `list[SandboxInfo]`. `labels` is `{"k": "v", "k2": None}` or `["k=v", "k2"]`. |
|
|
141
|
+
| `await Sandbox.remove(name, force=False)` | Delete. A running sandbox needs `force`. |
|
|
142
|
+
|
|
143
|
+
`**spec` are the `SandboxSpec` fields of the compose format
|
|
144
|
+
([docs/spec.md](https://github.com/execution-associates/isb/blob/main/docs/spec.md)):
|
|
145
|
+
`cpus`, `memory`, `storage`, `type` (`"container"`, `"virtual-machine"` or
|
|
146
|
+
`"vm"`), `privileged`, `idmap`, `profiles`, `labels`, `env`, `volumes`, `ports`,
|
|
147
|
+
`ready`, `ready_timeout`, `exec`, `raw_config`, `raw_devices`. A full spec dict
|
|
148
|
+
can be passed as `spec=` instead. The server rejects unknown fields
|
|
149
|
+
(`InvalidError`). The create-style methods also take `base_dir`, `wait_ready`
|
|
150
|
+
(default true), `named_volumes` (top-level named volume definitions, as in a
|
|
151
|
+
compose file's `volumes:`) and `on_progress` (called with each progress line).
|
|
152
|
+
|
|
153
|
+
On an instance: `info()`, `labels()`, `start()`, `stop(force=False, timeout="30s")`,
|
|
154
|
+
`restart()`, `wait_ready(ready=None, ready_timeout=None)`, `remove(force=False)`,
|
|
155
|
+
`add_port(port)` (returns the listen address in use), `remove_device(name)`
|
|
156
|
+
(returns whether it existed).
|
|
157
|
+
|
|
158
|
+
A handle from `create`, `connect_or_create` or `Project.sandbox` carries the
|
|
159
|
+
spec's `exec` defaults (user, cwd, env, login) and sends them with every exec;
|
|
160
|
+
per-call arguments override them.
|
|
161
|
+
|
|
162
|
+
### Exec
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
out = await sb.exec(
|
|
166
|
+
cmd,
|
|
167
|
+
args=None,
|
|
168
|
+
*,
|
|
169
|
+
cwd=None,
|
|
170
|
+
user=None,
|
|
171
|
+
env=None,
|
|
172
|
+
login=None,
|
|
173
|
+
timeout=None,
|
|
174
|
+
stdin=None,
|
|
175
|
+
tty=False,
|
|
176
|
+
)
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`cmd` is a program plus `args`, or a full argv list. Returns
|
|
180
|
+
`ExecOutput(exit_code, stdout: bytes, stderr: bytes)` with `stdout_text`,
|
|
181
|
+
`stderr_text` and `success`. A non-zero exit is not an exception. `stdin` is
|
|
182
|
+
bytes or str, sent followed by EOF. `timeout` is seconds or a duration string
|
|
183
|
+
(`"90s"`); when it passes, the command is killed and `IsbTimeoutError` is
|
|
184
|
+
raised. With `tty=True`, all output arrives as stdout.
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
p = await sb.exec_stream(cmd, args=None, *, ..., stdin=None | "piped" | bytes)
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
returns an `ExecProcess`: iterate it for `ExecEvent(kind, data)` chunks in
|
|
191
|
+
order, and drive it with `await p.write(b)`, `await p.close_stdin()`,
|
|
192
|
+
`await p.signal(15)`, `await p.resize(w, h)`. `await p.wait()` returns the exit
|
|
193
|
+
code, `await p.collect()` gathers the rest into an `ExecOutput`. Used as
|
|
194
|
+
`async with`, it kills the command on exit if it is still running.
|
|
195
|
+
|
|
196
|
+
### Builders
|
|
197
|
+
|
|
198
|
+
These return plain dicts for the spec:
|
|
199
|
+
|
|
200
|
+
- `Volume.bind(host_path, *, readonly=False, device=None, options=None)`
|
|
201
|
+
- `Volume.named(name, *, mode=NamedVolumeMode.ENSURE_EXISTS, owner=None, readonly=False, pool=None, device=None)`;
|
|
202
|
+
`NamedVolumeMode.EXISTING` means the volume must exist (`external: true`).
|
|
203
|
+
- `PortBinding.host(listen, connect, *, name=None, search=None)`: listen on the
|
|
204
|
+
host, connect in the guest.
|
|
205
|
+
- `PortBinding.guest(listen, connect, *, name=None)`: listen in the guest,
|
|
206
|
+
connect on the host.
|
|
207
|
+
|
|
208
|
+
### Project
|
|
209
|
+
|
|
210
|
+
`await Project.load(files=None, *, env_files=None, project_name=None, vars=None)`
|
|
211
|
+
loads and resolves compose files (default `./isb.yaml`, else `./isb.yml`).
|
|
212
|
+
`vars` win over the environment for `${VAR}`. The result has `name`,
|
|
213
|
+
`base_dir`, `files`, `file` (the resolved file, every sandbox named) and
|
|
214
|
+
`services`. Then `up(services=None, *, prune_devices=False, wait_ready=True)`
|
|
215
|
+
returns `(service, ApplyReport)` pairs, `plan(...)` returns `list[Plan]`,
|
|
216
|
+
`down(services=None, *, volumes=False)` deletes, and `sandbox("web")` returns a
|
|
217
|
+
handle with that service's exec defaults.
|
|
218
|
+
|
|
219
|
+
### Volumes and prune
|
|
220
|
+
|
|
221
|
+
`isb.volumes.list(pool=None)`, `get(name, pool=None)`,
|
|
222
|
+
`create(name, pool=None, *, config=None)` (returns `{"created", "pool"}`, a
|
|
223
|
+
no-op if it exists), `remove(name, pool=None)`. `isb.Volumes(client)` has the
|
|
224
|
+
same methods bound to one client. `await isb.prune(label, dry_run=True)` lists
|
|
225
|
+
(or, with `dry_run=False`, deletes) sandboxes whose `label` value is a host path
|
|
226
|
+
that no longer exists.
|
|
227
|
+
|
|
228
|
+
### Types
|
|
229
|
+
|
|
230
|
+
`SandboxInfo`, `ApplyReport`, `Plan`, `VolumeInfo`, `PruneResult`, `ExecOutput`
|
|
231
|
+
and `ExecEvent` are dataclasses. Plan actions are dicts tagged by `action`.
|
|
232
|
+
The spec types (`SandboxSpec`, `VolumeSpec`, `PortSpec`, `ReadyCheck`,
|
|
233
|
+
`ExecDefaults`, `IdmapSpec`, `NamedVolumeSpec`, `ComposeFile`) are TypedDicts in
|
|
234
|
+
`isb._spec`, generated from the JSON Schema by `scripts/gen_types.py`:
|
|
235
|
+
|
|
236
|
+
```sh
|
|
237
|
+
ISB_BIN=/path/to/isb python3 scripts/gen_types.py # regenerate
|
|
238
|
+
ISB_BIN=/path/to/isb python3 scripts/gen_types.py --check # fail if stale
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
## Errors
|
|
242
|
+
|
|
243
|
+
Every error is an `IsbError` with `code` (the protocol's stable code),
|
|
244
|
+
`message` and `data`:
|
|
245
|
+
|
|
246
|
+
| class | codes |
|
|
247
|
+
|---|---|
|
|
248
|
+
| `NotFoundError` | `not_found` |
|
|
249
|
+
| `AlreadyExistsError` | `already_exists` |
|
|
250
|
+
| `NotReadyError` | `not_ready` |
|
|
251
|
+
| `IsbTimeoutError` | `request_timeout`, `operation_timeout`, `exec_timeout` |
|
|
252
|
+
| `InvalidError` | `invalid`, `interpolation`, `parse` |
|
|
253
|
+
| `ConnectError` | `connect` (isb cannot reach incusd) |
|
|
254
|
+
| `ApiError` | `api`, `operation_failed` |
|
|
255
|
+
| `ProtocolError` | `protocol` (also unknown method), `bad_request`, a bad hello |
|
|
256
|
+
| `ProcessError` | `process`: the subprocess could not start or exited |
|
|
257
|
+
| `BinaryNotFoundError` | `binary_not_found` (a `ProcessError`) |
|
|
258
|
+
|
|
259
|
+
Other codes (`websocket`, `io`, `json`) raise `IsbError` itself.
|
|
260
|
+
`IsbTimeoutError` does not derive from the built-in `TimeoutError`.
|
|
261
|
+
|
|
262
|
+
## Development
|
|
263
|
+
|
|
264
|
+
Unit tests need only an isb binary with `rpc` (they use a socket that does not
|
|
265
|
+
exist, and fake servers for failure paths); integration tests need incusd and a
|
|
266
|
+
local image with a `dev` user at uid 1000 and python3 (`ISB_TEST_IMAGE`,
|
|
267
|
+
default `dev-base`). Both use the standard library's `unittest`, so no install
|
|
268
|
+
is needed:
|
|
269
|
+
|
|
270
|
+
```sh
|
|
271
|
+
# from the repository root
|
|
272
|
+
cargo build
|
|
273
|
+
ISB_BIN=target/debug/isb PYTHONPATH=sdk/python/src python3 -m unittest discover -s sdk/python/tests -v
|
|
274
|
+
ISB_INTEGRATION=1 ISB_BIN=target/debug/isb PYTHONPATH=sdk/python/src python3 -m unittest discover -s sdk/python/tests -v
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Integration tests name everything `isb-test-py-<pid>-...`, label it
|
|
278
|
+
`isb-test=py`, and remove it afterwards.
|
|
279
|
+
|
|
280
|
+
Lint, type check and build (in `sdk/python`):
|
|
281
|
+
|
|
282
|
+
```sh
|
|
283
|
+
uv sync --group dev
|
|
284
|
+
uv run ruff check . && uv run ruff format --check .
|
|
285
|
+
uv run mypy
|
|
286
|
+
uv build # pure wheel and sdist
|
|
287
|
+
ISB_WHEEL_BINARY=../../target/x86_64-unknown-linux-musl/release/isb \
|
|
288
|
+
ISB_WHEEL_PLAT=manylinux_2_17_x86_64.musllinux_1_2_x86_64 \
|
|
289
|
+
uv build --wheel # platform wheel with the binary
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
## License
|
|
293
|
+
|
|
294
|
+
MIT
|