boxd 0.2.18.dev896__tar.gz → 0.2.19.dev901__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.
- {boxd-0.2.18.dev896/src/boxd.egg-info → boxd-0.2.19.dev901}/PKG-INFO +241 -3
- {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/README.md +240 -2
- {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/pyproject.toml +1 -1
- {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/__init__.py +51 -0
- boxd-0.2.19.dev901/src/boxd/_generated/api_pb2.py +626 -0
- {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/_generated/api_pb2_grpc.py +47 -0
- {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/_mappers.py +68 -1
- {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/_requests.py +11 -0
- {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/models.py +149 -3
- {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/resources/machines.py +179 -4
- {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/resources/vars.py +2 -0
- boxd-0.2.19.dev901/src/boxd/spec.py +475 -0
- {boxd-0.2.18.dev896 → boxd-0.2.19.dev901/src/boxd.egg-info}/PKG-INFO +241 -3
- {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd.egg-info/SOURCES.txt +2 -0
- {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/tests/test_mappers.py +8 -0
- {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/tests/test_namespaces.py +25 -0
- boxd-0.2.19.dev901/tests/test_spec.py +471 -0
- boxd-0.2.18.dev896/src/boxd/_generated/api_pb2.py +0 -600
- {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/LICENSE +0 -0
- {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/setup.cfg +0 -0
- {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/_client.py +0 -0
- {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/_credentials.py +0 -0
- {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/_generated/__init__.py +0 -0
- {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/_streaming.py +0 -0
- {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/_transport.py +0 -0
- {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/_urls.py +0 -0
- {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/_version_check.py +0 -0
- {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/errors.py +0 -0
- {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/resources/__init__.py +0 -0
- {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/resources/account.py +0 -0
- {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/resources/credentials.py +0 -0
- {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/resources/disks.py +0 -0
- {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/resources/domains.py +0 -0
- {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/resources/orgs.py +0 -0
- {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/resources/snapshots.py +0 -0
- {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/resources/teams.py +0 -0
- {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd.egg-info/dependency_links.txt +0 -0
- {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd.egg-info/requires.txt +0 -0
- {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd.egg-info/top_level.txt +0 -0
- {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/tests/test_credentials.py +0 -0
- {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/tests/test_delete_self.py +0 -0
- {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/tests/test_requests.py +0 -0
- {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/tests/test_streaming.py +0 -0
- {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/tests/test_transport.py +0 -0
- {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/tests/test_urls.py +0 -0
- {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/tests/test_version_check.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: boxd
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.19.dev901
|
|
4
4
|
Summary: Python SDK for the boxd cloud VM platform
|
|
5
5
|
Author: Azin
|
|
6
6
|
License-Expression: MIT
|
|
@@ -250,6 +250,228 @@ private to you unless you pass `shared=True`.
|
|
|
250
250
|
usable. Call `wait_until_ready` before doing anything that depends on it
|
|
251
251
|
running — especially before forking it again.
|
|
252
252
|
|
|
253
|
+
### Machine specs
|
|
254
|
+
|
|
255
|
+
A machine spec describes a machine in one value: what it boots from, its size,
|
|
256
|
+
labels, networks, isolation, egress, env, the secrets it gets, the
|
|
257
|
+
integrations and agent logins it needs, and the packages it installs on first boot. Build one from reusable pieces and create from it:
|
|
258
|
+
|
|
259
|
+
```python
|
|
260
|
+
from boxd import Machine, team, snapshot, github, slack, jira, claude, secrets
|
|
261
|
+
|
|
262
|
+
engineering = (
|
|
263
|
+
Machine.spec()
|
|
264
|
+
.from_(team("eng")) # a team's default snapshot; or snapshot("eng-base")
|
|
265
|
+
.network("eng")
|
|
266
|
+
.label("team", "eng")
|
|
267
|
+
.env("NODE_ENV", "development")
|
|
268
|
+
.egress("github.com", "*.atlassian.net", "*.ubuntu.com")
|
|
269
|
+
.packages("apt-get update", "apt-get install -y nginx")
|
|
270
|
+
.with_(github(shared="eng-bot"), claude())
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
mike = engineering.size("2x8").with_(slack(), jira(shared="jira"), secrets("NPM_TOKEN"))
|
|
274
|
+
m = boxd.machines.create("mike-dev", org="acme", spec=mike)
|
|
275
|
+
m.spec # the spec as it stands (update_spec, and label, network, egress and size edits, update it)
|
|
276
|
+
m.created_spec # the spec it was created with, never changed
|
|
277
|
+
mike.resolve() # the same spec as a dict, without creating anything
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
| Builder | Sets |
|
|
281
|
+
|---|---|
|
|
282
|
+
| `.from_(team(name))` / `.from_(snapshot(name))` | `base`. Omit for the default image. |
|
|
283
|
+
| `.size("1x4" \| "2x8" \| "4x16" \| …)` | `size`, `<vcpu>x<memory GiB>` |
|
|
284
|
+
| `.assign_to(user_id)` | `assignTo`: create the machine for another member of the org (below) |
|
|
285
|
+
| `.label(key, value)` / `.label({...})` | `labels` |
|
|
286
|
+
| `.network(*names)` | `networks` |
|
|
287
|
+
| `.isolated()` | `isolated` |
|
|
288
|
+
| `.egress(*hosts)` | `egress`: the machine's egress allowlist from birth |
|
|
289
|
+
| `.env(key, value)` / `.env({...})` | `env`: wins over org and personal vars of the same name |
|
|
290
|
+
| `.packages(*commands)` | `packages`: shell commands run once after the first boot (below) |
|
|
291
|
+
| `.with_(*pieces)` | merges pieces or other builders |
|
|
292
|
+
|
|
293
|
+
Pieces: `slack()`, `jira()`, `github()`, `asana()` (pass `shared="tag"` to use
|
|
294
|
+
the org connection shared under that tag instead of the owner's own),
|
|
295
|
+
`claude()`, `codex()`, and `secrets(*names)` (below). Integrations and agent logins are recorded on the
|
|
296
|
+
machine; a missing one never blocks the create (see "Integrations the machine
|
|
297
|
+
needs"). A piece is a plain dict (`{"labels": {...}}`, or
|
|
298
|
+
`{"connections": {"notion": "personal"}}` for any toolkit), so you can write your own.
|
|
299
|
+
|
|
300
|
+
Pieces merge in order: maps (`labels`, `env`, `connections`) merge with the
|
|
301
|
+
later piece winning, lists (`networks`, `egress`, `secrets`, `agents`,
|
|
302
|
+
`packages`) combine in order without duplicates, `isolated` only turns on, and `base`, `size` or `assignTo`
|
|
303
|
+
set twice to different values raises `MachineSpecError` (a `ValueError`). So
|
|
304
|
+
does a key a spec doesn't have, and a name in both `env` and `secrets`. The server checks every field and names it in its error
|
|
305
|
+
(`spec.size: ...`).
|
|
306
|
+
|
|
307
|
+
A spec carries its machine's base, size, labels, networks, isolation and env,
|
|
308
|
+
so `create(spec=...)` takes none of `image`, `env`, `labels`, `networks`,
|
|
309
|
+
`isolated`, `vcpu` or `memory` beside it. A spec whose base is a snapshot (or a
|
|
310
|
+
team with a default snapshot) restores it. A snapshot keeps its machine's spec:
|
|
311
|
+
restoring it gives the new machine that spec, and
|
|
312
|
+
`create(from_snapshot=..., spec=...)` merges a spec on top by the same rules;
|
|
313
|
+
its `size`, if set, must be the snapshot's. A fork keeps its source's spec.
|
|
314
|
+
|
|
315
|
+
#### Secrets
|
|
316
|
+
|
|
317
|
+
A spec names the secrets its machine gets; their values stay in
|
|
318
|
+
`boxd.secrets`. Usually they are `unattached` secrets, which reach no machine
|
|
319
|
+
until a spec lists them:
|
|
320
|
+
|
|
321
|
+
```python
|
|
322
|
+
boxd.secrets.set("NPM_TOKEN", "npm_…", scope="unattached", org="acme")
|
|
323
|
+
|
|
324
|
+
m = boxd.machines.create("mike-dev", org="acme", spec=engineering.with_(secrets("NPM_TOKEN")))
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
A name resolves, on your own machine, to your own secret first
|
|
328
|
+
(`private-unattached`, then `private`), else the org's (`unattached`, `all`,
|
|
329
|
+
`shared`). On a machine created for someone else, or a shared machine, only
|
|
330
|
+
the org's secrets count. Only org owners and admins may list an org
|
|
331
|
+
`unattached` secret in a spec, and an org `shared` secret only reaches
|
|
332
|
+
shared machines, so a private machine can't list one. A name that resolves to
|
|
333
|
+
nothing, or that the create may not list, fails the create, naming it.
|
|
334
|
+
|
|
335
|
+
On the machine, the spec's `env` and its `secrets` win over your `private`
|
|
336
|
+
vars, which win over the org's `shared` vars (shared machines only), which win
|
|
337
|
+
over the org's `all` vars. A name can't be in both `env` and `secrets`.
|
|
338
|
+
|
|
339
|
+
The machine keeps a reference, not a copy: rotating the secret reaches it, and
|
|
340
|
+
deleting the secret takes it away
|
|
341
|
+
(setting it again with the same name brings it back). Forks, snapshots and
|
|
342
|
+
restores resolve the
|
|
343
|
+
names again, and fail naming any that no longer resolve. A secret's
|
|
344
|
+
`bound_machines` says how many machines list it.
|
|
345
|
+
|
|
346
|
+
#### Creating a machine for someone else
|
|
347
|
+
|
|
348
|
+
An org owner or admin can create a machine for another member of the org with
|
|
349
|
+
`.assign_to(user_id)` (the member's user id, as `orgs.get_members()` lists it):
|
|
350
|
+
|
|
351
|
+
```python
|
|
352
|
+
m = boxd.machines.create(
|
|
353
|
+
"mike-dev",
|
|
354
|
+
org="acme", # required: the org the machine is for
|
|
355
|
+
spec=engineering.size("2x8").assign_to("usr_e393f70e…"),
|
|
356
|
+
)
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
The machine is the member's from the start: private to them, billed to the
|
|
360
|
+
org, and set up with their own integrations and agent logins. You can't see or
|
|
361
|
+
reach it afterwards, so `create()` answers with its id and name only, and the
|
|
362
|
+
create can't also set `delete_after`. The member sees `created_by` (your user
|
|
363
|
+
id) and `created_by_name` on it. Refused, naming `spec.assignTo`: without
|
|
364
|
+
`org`, with `shared=True`, for a caller who isn't an owner or admin of the org,
|
|
365
|
+
with an org API key or from inside a machine, for someone who isn't a member,
|
|
366
|
+
and with a snapshot or disk the member couldn't use. Your own id is a plain
|
|
367
|
+
create. `assignTo` is never inherited: forks, snapshots and restores drop it.
|
|
368
|
+
|
|
369
|
+
#### Integrations the machine needs
|
|
370
|
+
|
|
371
|
+
A spec's `connections` and `agents` are the machine's requirements. Reading the
|
|
372
|
+
machine (`get`, `list`, the machine `create` returns) tells you, for each one,
|
|
373
|
+
whether it is connected right now:
|
|
374
|
+
|
|
375
|
+
```python
|
|
376
|
+
m = boxd.machines.get("mike-dev")
|
|
377
|
+
for r in m.requirements:
|
|
378
|
+
# kind="connection" name="jira" scope="shared" tag="jira" met=False can_fix=True reason=None
|
|
379
|
+
print(r.name, r.scope, r.tag, "connected" if r.met else "connect it" if r.can_fix else r.reason or "ask an org admin")
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
A `personal` connection is the machine owner's own connection for that toolkit
|
|
383
|
+
in the machine's org (on a machine created with `assign_to`, the member's), a
|
|
384
|
+
`shared` one the org's connection shared under `tag`, and an agent the owner's
|
|
385
|
+
Claude Code or Codex sign-in. `can_fix` says whether you could connect it: the
|
|
386
|
+
owner for personal ones and agents, an org owner or admin for shared ones.
|
|
387
|
+
Connect from the console: the machine's page lists them with a Connect button.
|
|
388
|
+
|
|
389
|
+
An unmet requirement is only unavailable on the machine; nothing blocks
|
|
390
|
+
creating, booting or using it. Some can't be met on the machine as it is, and
|
|
391
|
+
`reason` says why: an isolated machine gets no integrations or agent logins,
|
|
392
|
+
and a shared machine never gets anyone's personal connection or agent login
|
|
393
|
+
(require `shared:<tag>` instead). The create refuses a toolkit this cluster
|
|
394
|
+
can't connect (`spec.connections.<toolkit>: ...`), and the GitHub App
|
|
395
|
+
(`github-app`) only as `"shared:org"`.
|
|
396
|
+
|
|
397
|
+
#### Packages
|
|
398
|
+
|
|
399
|
+
`packages` are shell commands the machine runs once, in order, after its first
|
|
400
|
+
boot: as root, with bash (sh on an image without it),
|
|
401
|
+
`DEBIAN_FRONTEND=noninteractive`, no stdin and a 30-minute timeout each. Their
|
|
402
|
+
output is appended to `/var/log/boxd/packages.log` in the machine. The first
|
|
403
|
+
failure (a non-zero exit, a timeout) stops the rest; the machine stays usable.
|
|
404
|
+
Reading the machine tells you how each one went:
|
|
405
|
+
|
|
406
|
+
```python
|
|
407
|
+
m = boxd.machines.get("mike-dev")
|
|
408
|
+
for p in m.packages:
|
|
409
|
+
# command="apt-get install -y nginx" state="failed" exit_code=100 output_tail="E: Unable to locate…" reason=None …
|
|
410
|
+
print(p.state, p.exit_code, p.command)
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
`state` is `"pending"`, `"running"`, `"done"` or `"failed"`; the commands
|
|
414
|
+
after a failure stay `"pending"`. `output_tail` is the last 4 KiB of the
|
|
415
|
+
command's output, and `reason` says why a failed one has no exit code (it
|
|
416
|
+
timed out, or the run was interrupted). Nothing runs again: not on a reboot, a
|
|
417
|
+
resume, a wake or a move. Restoring a snapshot whose spec lists packages marks
|
|
418
|
+
those done and runs only the ones the restore's spec adds; a fork keeps its
|
|
419
|
+
source's states and runs nothing. The machine doesn't auto-suspend or
|
|
420
|
+
hibernate while a command runs. At most 50 commands (a restore counts the
|
|
421
|
+
snapshot's too), each at most 4 KiB; the server names the one it refuses
|
|
422
|
+
(`spec.packages[2]: ...`). Commands that download something need the hosts in
|
|
423
|
+
the spec's `egress`, if it has one.
|
|
424
|
+
|
|
425
|
+
#### Updating a machine's spec
|
|
426
|
+
|
|
427
|
+
`update_spec` changes the spec of a machine created from one and applies what
|
|
428
|
+
changes. Pass a JSON Merge Patch, or a function that adds to the current spec:
|
|
429
|
+
|
|
430
|
+
```python
|
|
431
|
+
# Patch: a field left out is untouched, a scalar replaces, maps merge key by
|
|
432
|
+
# key (None removes a key), a list replaces the whole list, None removes a field.
|
|
433
|
+
boxd.machines.update_spec("mike-dev", {
|
|
434
|
+
"labels": {"owner": "mike", "team": None},
|
|
435
|
+
"networks": ["eng", "ops"],
|
|
436
|
+
"packages": ["apt-get install -y nginx", "npm i -g pnpm"],
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
# Builder: reads the spec, adds with the composition rules, sends the difference.
|
|
440
|
+
boxd.machines.update_spec("mike-dev", lambda s: s.network("ops").env("LOG_LEVEL", "debug"))
|
|
441
|
+
|
|
442
|
+
# Dry run: the diff, nothing changed.
|
|
443
|
+
diff = boxd.machines.update_spec("mike-dev", {"size": "4x16"}, dry_run=True)
|
|
444
|
+
for c in diff.changes:
|
|
445
|
+
print(c.field, c.apply, c.note, c.warnings)
|
|
446
|
+
if diff.reboot_required:
|
|
447
|
+
boxd.machines.update_spec("mike-dev", {"size": "4x16"}, reboot=True)
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
Each change in `changes` says how it applies (`apply`):
|
|
451
|
+
|
|
452
|
+
| Field | `apply` |
|
|
453
|
+
|---|---|
|
|
454
|
+
| `labels`, `networks`, `egress` | `"live"` |
|
|
455
|
+
| `connections`, `agents` | `"live"`: what the machine needs (`requirements`) changes |
|
|
456
|
+
| `packages` | `"live"`: added commands run now, after the ones before them. Removing one undoes nothing (a warning says so); a command that already ran isn't run again; commands after a failed one don't run (a warning says so) |
|
|
457
|
+
| `env`, `secrets` | `"reboot_recommended"`: new shells and exec sessions get them now; processes already running keep the old values until they restart (`reboot_recommended`) |
|
|
458
|
+
| `size` | `"reboot"` on a running machine, which reboots to apply it (as `resize`), so it needs `reboot=True`; `"live"` on a stopped one |
|
|
459
|
+
| `base`, `isolated`, `assignTo` | `"refused"`: fixed when the machine is created |
|
|
460
|
+
|
|
461
|
+
A refused change (also: new packages on a fork, a size change in standby or
|
|
462
|
+
hibernated) refuses the whole patch, and so does a reboot without
|
|
463
|
+
`reboot=True`; nothing is applied. Otherwise the spec and the labels,
|
|
464
|
+
networks, egress and secrets it drives change together, then the size, then
|
|
465
|
+
the new packages run. The patched spec is checked like a create's (an
|
|
466
|
+
unknown toolkit, a secret you may not list, more than 50 commands in all are
|
|
467
|
+
refused, naming the field). `machine.created_spec` never changes, so the
|
|
468
|
+
console shows the spec badge as "updated". Allowed to whoever may edit the
|
|
469
|
+
machine's labels: its owner, or an org owner or admin on a shared machine.
|
|
470
|
+
The builder form only adds: a `size` that differs from the spec's raises, so
|
|
471
|
+
use a patch to replace or remove. `spec_patch(from_spec, to_spec)` computes the
|
|
472
|
+
patch the builder form sends. `AsyncBoxd` has the same `await
|
|
473
|
+
boxd.machines.update_spec(...)`.
|
|
474
|
+
|
|
253
475
|
### Egress allowlist
|
|
254
476
|
|
|
255
477
|
A machine can be limited to what it may reach on the internet:
|
|
@@ -266,7 +488,8 @@ addresses of allowlisted names learned as the machine resolves them. Nothing
|
|
|
266
488
|
that is not named is reachable. The hosts of any bound secret the machine
|
|
267
489
|
holds are always admitted. Takes effect within a second and is not visible or
|
|
268
490
|
changeable from inside the machine. A fork inherits its source's allowlist; a
|
|
269
|
-
machine restored from a snapshot starts unrestricted
|
|
491
|
+
machine restored from a snapshot starts unrestricted, unless its machine spec
|
|
492
|
+
lists `egress`.
|
|
270
493
|
|
|
271
494
|
### Labels
|
|
272
495
|
|
|
@@ -345,6 +568,14 @@ machine.source.id # str | None — provenance; may not resolve
|
|
|
345
568
|
machine.hibernated_at # datetime | None — None = not hibernated
|
|
346
569
|
machine.last_connected_at # datetime | None — None = never connected
|
|
347
570
|
machine.boot_time_ms # int | None — last boot; None = never booted
|
|
571
|
+
machine.spec # dict | None — the machine spec it runs under
|
|
572
|
+
machine.created_spec # dict | None — the spec it was created with
|
|
573
|
+
machine.created_by # str | None — who created it for you (spec assignTo); None = you did
|
|
574
|
+
machine.created_by_name # str | None — a name to show for created_by
|
|
575
|
+
machine.requirements # list[MachineRequirement] — the spec's connections and agents:
|
|
576
|
+
# kind, name, scope ("personal"/"shared"), tag, met, can_fix, reason
|
|
577
|
+
machine.packages # list[MachinePackage] — the spec's packages and how each went:
|
|
578
|
+
# command, state, exit_code, output_tail, reason, started_at, finished_at
|
|
348
579
|
```
|
|
349
580
|
|
|
350
581
|
`None` always means "not set": a port that was never allocated, a boot that
|
|
@@ -558,7 +789,7 @@ boxd.env.list(org="acme")
|
|
|
558
789
|
boxd.env.delete("MODE", scope="all")
|
|
559
790
|
|
|
560
791
|
boxd.secrets.set("API_TOKEN", "s3cr3t", scope="shared")
|
|
561
|
-
boxd.secrets.list() # Secret(name, scope, domains) — no value
|
|
792
|
+
boxd.secrets.list() # Secret(name, scope, domains, bound_machines) — no value
|
|
562
793
|
boxd.secrets.delete("API_TOKEN", scope="shared")
|
|
563
794
|
```
|
|
564
795
|
|
|
@@ -595,6 +826,13 @@ Scope decides which machines a name applies to:
|
|
|
595
826
|
| `private` | only your machines in that organization |
|
|
596
827
|
| `shared` | the organization's shared machines |
|
|
597
828
|
| `all` | every machine in the organization |
|
|
829
|
+
| `unattached` | secrets only: just the machines whose spec lists it ([Secrets](#secrets)); the organization's with `org`, else your own |
|
|
830
|
+
| `private-unattached` | secrets only: your own unattached secret in the organization named by `org` |
|
|
831
|
+
|
|
832
|
+
`list` reports the organization's unattached secrets as `unattached` and your
|
|
833
|
+
own as `private-unattached`, each with `bound_machines`, the number of machines
|
|
834
|
+
whose spec lists it (0 for every other scope). `unattached` is for secrets
|
|
835
|
+
only: a plain value a machine needs belongs in its spec's `env`.
|
|
598
836
|
|
|
599
837
|
Scope is part of a name's identity — the same name can exist in several scopes
|
|
600
838
|
at once — so changing it is a `move` between two addresses, and both ends are
|
|
@@ -216,6 +216,228 @@ private to you unless you pass `shared=True`.
|
|
|
216
216
|
usable. Call `wait_until_ready` before doing anything that depends on it
|
|
217
217
|
running — especially before forking it again.
|
|
218
218
|
|
|
219
|
+
### Machine specs
|
|
220
|
+
|
|
221
|
+
A machine spec describes a machine in one value: what it boots from, its size,
|
|
222
|
+
labels, networks, isolation, egress, env, the secrets it gets, the
|
|
223
|
+
integrations and agent logins it needs, and the packages it installs on first boot. Build one from reusable pieces and create from it:
|
|
224
|
+
|
|
225
|
+
```python
|
|
226
|
+
from boxd import Machine, team, snapshot, github, slack, jira, claude, secrets
|
|
227
|
+
|
|
228
|
+
engineering = (
|
|
229
|
+
Machine.spec()
|
|
230
|
+
.from_(team("eng")) # a team's default snapshot; or snapshot("eng-base")
|
|
231
|
+
.network("eng")
|
|
232
|
+
.label("team", "eng")
|
|
233
|
+
.env("NODE_ENV", "development")
|
|
234
|
+
.egress("github.com", "*.atlassian.net", "*.ubuntu.com")
|
|
235
|
+
.packages("apt-get update", "apt-get install -y nginx")
|
|
236
|
+
.with_(github(shared="eng-bot"), claude())
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
mike = engineering.size("2x8").with_(slack(), jira(shared="jira"), secrets("NPM_TOKEN"))
|
|
240
|
+
m = boxd.machines.create("mike-dev", org="acme", spec=mike)
|
|
241
|
+
m.spec # the spec as it stands (update_spec, and label, network, egress and size edits, update it)
|
|
242
|
+
m.created_spec # the spec it was created with, never changed
|
|
243
|
+
mike.resolve() # the same spec as a dict, without creating anything
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
| Builder | Sets |
|
|
247
|
+
|---|---|
|
|
248
|
+
| `.from_(team(name))` / `.from_(snapshot(name))` | `base`. Omit for the default image. |
|
|
249
|
+
| `.size("1x4" \| "2x8" \| "4x16" \| …)` | `size`, `<vcpu>x<memory GiB>` |
|
|
250
|
+
| `.assign_to(user_id)` | `assignTo`: create the machine for another member of the org (below) |
|
|
251
|
+
| `.label(key, value)` / `.label({...})` | `labels` |
|
|
252
|
+
| `.network(*names)` | `networks` |
|
|
253
|
+
| `.isolated()` | `isolated` |
|
|
254
|
+
| `.egress(*hosts)` | `egress`: the machine's egress allowlist from birth |
|
|
255
|
+
| `.env(key, value)` / `.env({...})` | `env`: wins over org and personal vars of the same name |
|
|
256
|
+
| `.packages(*commands)` | `packages`: shell commands run once after the first boot (below) |
|
|
257
|
+
| `.with_(*pieces)` | merges pieces or other builders |
|
|
258
|
+
|
|
259
|
+
Pieces: `slack()`, `jira()`, `github()`, `asana()` (pass `shared="tag"` to use
|
|
260
|
+
the org connection shared under that tag instead of the owner's own),
|
|
261
|
+
`claude()`, `codex()`, and `secrets(*names)` (below). Integrations and agent logins are recorded on the
|
|
262
|
+
machine; a missing one never blocks the create (see "Integrations the machine
|
|
263
|
+
needs"). A piece is a plain dict (`{"labels": {...}}`, or
|
|
264
|
+
`{"connections": {"notion": "personal"}}` for any toolkit), so you can write your own.
|
|
265
|
+
|
|
266
|
+
Pieces merge in order: maps (`labels`, `env`, `connections`) merge with the
|
|
267
|
+
later piece winning, lists (`networks`, `egress`, `secrets`, `agents`,
|
|
268
|
+
`packages`) combine in order without duplicates, `isolated` only turns on, and `base`, `size` or `assignTo`
|
|
269
|
+
set twice to different values raises `MachineSpecError` (a `ValueError`). So
|
|
270
|
+
does a key a spec doesn't have, and a name in both `env` and `secrets`. The server checks every field and names it in its error
|
|
271
|
+
(`spec.size: ...`).
|
|
272
|
+
|
|
273
|
+
A spec carries its machine's base, size, labels, networks, isolation and env,
|
|
274
|
+
so `create(spec=...)` takes none of `image`, `env`, `labels`, `networks`,
|
|
275
|
+
`isolated`, `vcpu` or `memory` beside it. A spec whose base is a snapshot (or a
|
|
276
|
+
team with a default snapshot) restores it. A snapshot keeps its machine's spec:
|
|
277
|
+
restoring it gives the new machine that spec, and
|
|
278
|
+
`create(from_snapshot=..., spec=...)` merges a spec on top by the same rules;
|
|
279
|
+
its `size`, if set, must be the snapshot's. A fork keeps its source's spec.
|
|
280
|
+
|
|
281
|
+
#### Secrets
|
|
282
|
+
|
|
283
|
+
A spec names the secrets its machine gets; their values stay in
|
|
284
|
+
`boxd.secrets`. Usually they are `unattached` secrets, which reach no machine
|
|
285
|
+
until a spec lists them:
|
|
286
|
+
|
|
287
|
+
```python
|
|
288
|
+
boxd.secrets.set("NPM_TOKEN", "npm_…", scope="unattached", org="acme")
|
|
289
|
+
|
|
290
|
+
m = boxd.machines.create("mike-dev", org="acme", spec=engineering.with_(secrets("NPM_TOKEN")))
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
A name resolves, on your own machine, to your own secret first
|
|
294
|
+
(`private-unattached`, then `private`), else the org's (`unattached`, `all`,
|
|
295
|
+
`shared`). On a machine created for someone else, or a shared machine, only
|
|
296
|
+
the org's secrets count. Only org owners and admins may list an org
|
|
297
|
+
`unattached` secret in a spec, and an org `shared` secret only reaches
|
|
298
|
+
shared machines, so a private machine can't list one. A name that resolves to
|
|
299
|
+
nothing, or that the create may not list, fails the create, naming it.
|
|
300
|
+
|
|
301
|
+
On the machine, the spec's `env` and its `secrets` win over your `private`
|
|
302
|
+
vars, which win over the org's `shared` vars (shared machines only), which win
|
|
303
|
+
over the org's `all` vars. A name can't be in both `env` and `secrets`.
|
|
304
|
+
|
|
305
|
+
The machine keeps a reference, not a copy: rotating the secret reaches it, and
|
|
306
|
+
deleting the secret takes it away
|
|
307
|
+
(setting it again with the same name brings it back). Forks, snapshots and
|
|
308
|
+
restores resolve the
|
|
309
|
+
names again, and fail naming any that no longer resolve. A secret's
|
|
310
|
+
`bound_machines` says how many machines list it.
|
|
311
|
+
|
|
312
|
+
#### Creating a machine for someone else
|
|
313
|
+
|
|
314
|
+
An org owner or admin can create a machine for another member of the org with
|
|
315
|
+
`.assign_to(user_id)` (the member's user id, as `orgs.get_members()` lists it):
|
|
316
|
+
|
|
317
|
+
```python
|
|
318
|
+
m = boxd.machines.create(
|
|
319
|
+
"mike-dev",
|
|
320
|
+
org="acme", # required: the org the machine is for
|
|
321
|
+
spec=engineering.size("2x8").assign_to("usr_e393f70e…"),
|
|
322
|
+
)
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
The machine is the member's from the start: private to them, billed to the
|
|
326
|
+
org, and set up with their own integrations and agent logins. You can't see or
|
|
327
|
+
reach it afterwards, so `create()` answers with its id and name only, and the
|
|
328
|
+
create can't also set `delete_after`. The member sees `created_by` (your user
|
|
329
|
+
id) and `created_by_name` on it. Refused, naming `spec.assignTo`: without
|
|
330
|
+
`org`, with `shared=True`, for a caller who isn't an owner or admin of the org,
|
|
331
|
+
with an org API key or from inside a machine, for someone who isn't a member,
|
|
332
|
+
and with a snapshot or disk the member couldn't use. Your own id is a plain
|
|
333
|
+
create. `assignTo` is never inherited: forks, snapshots and restores drop it.
|
|
334
|
+
|
|
335
|
+
#### Integrations the machine needs
|
|
336
|
+
|
|
337
|
+
A spec's `connections` and `agents` are the machine's requirements. Reading the
|
|
338
|
+
machine (`get`, `list`, the machine `create` returns) tells you, for each one,
|
|
339
|
+
whether it is connected right now:
|
|
340
|
+
|
|
341
|
+
```python
|
|
342
|
+
m = boxd.machines.get("mike-dev")
|
|
343
|
+
for r in m.requirements:
|
|
344
|
+
# kind="connection" name="jira" scope="shared" tag="jira" met=False can_fix=True reason=None
|
|
345
|
+
print(r.name, r.scope, r.tag, "connected" if r.met else "connect it" if r.can_fix else r.reason or "ask an org admin")
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
A `personal` connection is the machine owner's own connection for that toolkit
|
|
349
|
+
in the machine's org (on a machine created with `assign_to`, the member's), a
|
|
350
|
+
`shared` one the org's connection shared under `tag`, and an agent the owner's
|
|
351
|
+
Claude Code or Codex sign-in. `can_fix` says whether you could connect it: the
|
|
352
|
+
owner for personal ones and agents, an org owner or admin for shared ones.
|
|
353
|
+
Connect from the console: the machine's page lists them with a Connect button.
|
|
354
|
+
|
|
355
|
+
An unmet requirement is only unavailable on the machine; nothing blocks
|
|
356
|
+
creating, booting or using it. Some can't be met on the machine as it is, and
|
|
357
|
+
`reason` says why: an isolated machine gets no integrations or agent logins,
|
|
358
|
+
and a shared machine never gets anyone's personal connection or agent login
|
|
359
|
+
(require `shared:<tag>` instead). The create refuses a toolkit this cluster
|
|
360
|
+
can't connect (`spec.connections.<toolkit>: ...`), and the GitHub App
|
|
361
|
+
(`github-app`) only as `"shared:org"`.
|
|
362
|
+
|
|
363
|
+
#### Packages
|
|
364
|
+
|
|
365
|
+
`packages` are shell commands the machine runs once, in order, after its first
|
|
366
|
+
boot: as root, with bash (sh on an image without it),
|
|
367
|
+
`DEBIAN_FRONTEND=noninteractive`, no stdin and a 30-minute timeout each. Their
|
|
368
|
+
output is appended to `/var/log/boxd/packages.log` in the machine. The first
|
|
369
|
+
failure (a non-zero exit, a timeout) stops the rest; the machine stays usable.
|
|
370
|
+
Reading the machine tells you how each one went:
|
|
371
|
+
|
|
372
|
+
```python
|
|
373
|
+
m = boxd.machines.get("mike-dev")
|
|
374
|
+
for p in m.packages:
|
|
375
|
+
# command="apt-get install -y nginx" state="failed" exit_code=100 output_tail="E: Unable to locate…" reason=None …
|
|
376
|
+
print(p.state, p.exit_code, p.command)
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
`state` is `"pending"`, `"running"`, `"done"` or `"failed"`; the commands
|
|
380
|
+
after a failure stay `"pending"`. `output_tail` is the last 4 KiB of the
|
|
381
|
+
command's output, and `reason` says why a failed one has no exit code (it
|
|
382
|
+
timed out, or the run was interrupted). Nothing runs again: not on a reboot, a
|
|
383
|
+
resume, a wake or a move. Restoring a snapshot whose spec lists packages marks
|
|
384
|
+
those done and runs only the ones the restore's spec adds; a fork keeps its
|
|
385
|
+
source's states and runs nothing. The machine doesn't auto-suspend or
|
|
386
|
+
hibernate while a command runs. At most 50 commands (a restore counts the
|
|
387
|
+
snapshot's too), each at most 4 KiB; the server names the one it refuses
|
|
388
|
+
(`spec.packages[2]: ...`). Commands that download something need the hosts in
|
|
389
|
+
the spec's `egress`, if it has one.
|
|
390
|
+
|
|
391
|
+
#### Updating a machine's spec
|
|
392
|
+
|
|
393
|
+
`update_spec` changes the spec of a machine created from one and applies what
|
|
394
|
+
changes. Pass a JSON Merge Patch, or a function that adds to the current spec:
|
|
395
|
+
|
|
396
|
+
```python
|
|
397
|
+
# Patch: a field left out is untouched, a scalar replaces, maps merge key by
|
|
398
|
+
# key (None removes a key), a list replaces the whole list, None removes a field.
|
|
399
|
+
boxd.machines.update_spec("mike-dev", {
|
|
400
|
+
"labels": {"owner": "mike", "team": None},
|
|
401
|
+
"networks": ["eng", "ops"],
|
|
402
|
+
"packages": ["apt-get install -y nginx", "npm i -g pnpm"],
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
# Builder: reads the spec, adds with the composition rules, sends the difference.
|
|
406
|
+
boxd.machines.update_spec("mike-dev", lambda s: s.network("ops").env("LOG_LEVEL", "debug"))
|
|
407
|
+
|
|
408
|
+
# Dry run: the diff, nothing changed.
|
|
409
|
+
diff = boxd.machines.update_spec("mike-dev", {"size": "4x16"}, dry_run=True)
|
|
410
|
+
for c in diff.changes:
|
|
411
|
+
print(c.field, c.apply, c.note, c.warnings)
|
|
412
|
+
if diff.reboot_required:
|
|
413
|
+
boxd.machines.update_spec("mike-dev", {"size": "4x16"}, reboot=True)
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
Each change in `changes` says how it applies (`apply`):
|
|
417
|
+
|
|
418
|
+
| Field | `apply` |
|
|
419
|
+
|---|---|
|
|
420
|
+
| `labels`, `networks`, `egress` | `"live"` |
|
|
421
|
+
| `connections`, `agents` | `"live"`: what the machine needs (`requirements`) changes |
|
|
422
|
+
| `packages` | `"live"`: added commands run now, after the ones before them. Removing one undoes nothing (a warning says so); a command that already ran isn't run again; commands after a failed one don't run (a warning says so) |
|
|
423
|
+
| `env`, `secrets` | `"reboot_recommended"`: new shells and exec sessions get them now; processes already running keep the old values until they restart (`reboot_recommended`) |
|
|
424
|
+
| `size` | `"reboot"` on a running machine, which reboots to apply it (as `resize`), so it needs `reboot=True`; `"live"` on a stopped one |
|
|
425
|
+
| `base`, `isolated`, `assignTo` | `"refused"`: fixed when the machine is created |
|
|
426
|
+
|
|
427
|
+
A refused change (also: new packages on a fork, a size change in standby or
|
|
428
|
+
hibernated) refuses the whole patch, and so does a reboot without
|
|
429
|
+
`reboot=True`; nothing is applied. Otherwise the spec and the labels,
|
|
430
|
+
networks, egress and secrets it drives change together, then the size, then
|
|
431
|
+
the new packages run. The patched spec is checked like a create's (an
|
|
432
|
+
unknown toolkit, a secret you may not list, more than 50 commands in all are
|
|
433
|
+
refused, naming the field). `machine.created_spec` never changes, so the
|
|
434
|
+
console shows the spec badge as "updated". Allowed to whoever may edit the
|
|
435
|
+
machine's labels: its owner, or an org owner or admin on a shared machine.
|
|
436
|
+
The builder form only adds: a `size` that differs from the spec's raises, so
|
|
437
|
+
use a patch to replace or remove. `spec_patch(from_spec, to_spec)` computes the
|
|
438
|
+
patch the builder form sends. `AsyncBoxd` has the same `await
|
|
439
|
+
boxd.machines.update_spec(...)`.
|
|
440
|
+
|
|
219
441
|
### Egress allowlist
|
|
220
442
|
|
|
221
443
|
A machine can be limited to what it may reach on the internet:
|
|
@@ -232,7 +454,8 @@ addresses of allowlisted names learned as the machine resolves them. Nothing
|
|
|
232
454
|
that is not named is reachable. The hosts of any bound secret the machine
|
|
233
455
|
holds are always admitted. Takes effect within a second and is not visible or
|
|
234
456
|
changeable from inside the machine. A fork inherits its source's allowlist; a
|
|
235
|
-
machine restored from a snapshot starts unrestricted
|
|
457
|
+
machine restored from a snapshot starts unrestricted, unless its machine spec
|
|
458
|
+
lists `egress`.
|
|
236
459
|
|
|
237
460
|
### Labels
|
|
238
461
|
|
|
@@ -311,6 +534,14 @@ machine.source.id # str | None — provenance; may not resolve
|
|
|
311
534
|
machine.hibernated_at # datetime | None — None = not hibernated
|
|
312
535
|
machine.last_connected_at # datetime | None — None = never connected
|
|
313
536
|
machine.boot_time_ms # int | None — last boot; None = never booted
|
|
537
|
+
machine.spec # dict | None — the machine spec it runs under
|
|
538
|
+
machine.created_spec # dict | None — the spec it was created with
|
|
539
|
+
machine.created_by # str | None — who created it for you (spec assignTo); None = you did
|
|
540
|
+
machine.created_by_name # str | None — a name to show for created_by
|
|
541
|
+
machine.requirements # list[MachineRequirement] — the spec's connections and agents:
|
|
542
|
+
# kind, name, scope ("personal"/"shared"), tag, met, can_fix, reason
|
|
543
|
+
machine.packages # list[MachinePackage] — the spec's packages and how each went:
|
|
544
|
+
# command, state, exit_code, output_tail, reason, started_at, finished_at
|
|
314
545
|
```
|
|
315
546
|
|
|
316
547
|
`None` always means "not set": a port that was never allocated, a boot that
|
|
@@ -524,7 +755,7 @@ boxd.env.list(org="acme")
|
|
|
524
755
|
boxd.env.delete("MODE", scope="all")
|
|
525
756
|
|
|
526
757
|
boxd.secrets.set("API_TOKEN", "s3cr3t", scope="shared")
|
|
527
|
-
boxd.secrets.list() # Secret(name, scope, domains) — no value
|
|
758
|
+
boxd.secrets.list() # Secret(name, scope, domains, bound_machines) — no value
|
|
528
759
|
boxd.secrets.delete("API_TOKEN", scope="shared")
|
|
529
760
|
```
|
|
530
761
|
|
|
@@ -561,6 +792,13 @@ Scope decides which machines a name applies to:
|
|
|
561
792
|
| `private` | only your machines in that organization |
|
|
562
793
|
| `shared` | the organization's shared machines |
|
|
563
794
|
| `all` | every machine in the organization |
|
|
795
|
+
| `unattached` | secrets only: just the machines whose spec lists it ([Secrets](#secrets)); the organization's with `org`, else your own |
|
|
796
|
+
| `private-unattached` | secrets only: your own unattached secret in the organization named by `org` |
|
|
797
|
+
|
|
798
|
+
`list` reports the organization's unattached secrets as `unattached` and your
|
|
799
|
+
own as `private-unattached`, each with `bound_machines`, the number of machines
|
|
800
|
+
whose spec lists it (0 for every other scope). `unattached` is for secrets
|
|
801
|
+
only: a plain value a machine needs belongs in its spec's `env`.
|
|
564
802
|
|
|
565
803
|
Scope is part of a name's identity — the same name can exist in several scopes
|
|
566
804
|
at once — so changing it is a `move` between two addresses, and both ends are
|
|
@@ -61,6 +61,11 @@ from .models import (
|
|
|
61
61
|
MachineAccess,
|
|
62
62
|
MachineIdle,
|
|
63
63
|
MachineNetworking,
|
|
64
|
+
MachinePackage,
|
|
65
|
+
MachineSpecApply,
|
|
66
|
+
MachineSpecChange,
|
|
67
|
+
MachineSpecUpdate,
|
|
68
|
+
MachineRequirement,
|
|
64
69
|
MachineResources,
|
|
65
70
|
MachineSource,
|
|
66
71
|
MachineSourceKind,
|
|
@@ -96,6 +101,27 @@ from .models import (
|
|
|
96
101
|
VarScope,
|
|
97
102
|
VolumeMount,
|
|
98
103
|
)
|
|
104
|
+
from .spec import (
|
|
105
|
+
MACHINE_SPEC_VERSION,
|
|
106
|
+
MachineSize,
|
|
107
|
+
MachineSpec,
|
|
108
|
+
MachineSpecBase,
|
|
109
|
+
MachineSpecBuilder,
|
|
110
|
+
MachineSpecError,
|
|
111
|
+
MachineSpecPatch,
|
|
112
|
+
MachineSpecPiece,
|
|
113
|
+
asana,
|
|
114
|
+
claude,
|
|
115
|
+
codex,
|
|
116
|
+
github,
|
|
117
|
+
jira,
|
|
118
|
+
merge_specs,
|
|
119
|
+
secrets,
|
|
120
|
+
slack,
|
|
121
|
+
snapshot,
|
|
122
|
+
spec_patch,
|
|
123
|
+
team,
|
|
124
|
+
)
|
|
99
125
|
|
|
100
126
|
try:
|
|
101
127
|
from importlib.metadata import version as _pkg_version
|
|
@@ -143,6 +169,11 @@ __all__ = [
|
|
|
143
169
|
"MachineAccess",
|
|
144
170
|
"MachineIdle",
|
|
145
171
|
"MachineNetworking",
|
|
172
|
+
"MachinePackage",
|
|
173
|
+
"MachineSpecApply",
|
|
174
|
+
"MachineSpecChange",
|
|
175
|
+
"MachineSpecUpdate",
|
|
176
|
+
"MachineRequirement",
|
|
146
177
|
"MachineResources",
|
|
147
178
|
"MachineSource",
|
|
148
179
|
"MachineSourceKind",
|
|
@@ -178,6 +209,26 @@ __all__ = [
|
|
|
178
209
|
"UsageRates",
|
|
179
210
|
"VarScope",
|
|
180
211
|
"VolumeMount",
|
|
212
|
+
# Machine specs (`Machine.spec()` starts one)
|
|
213
|
+
"MACHINE_SPEC_VERSION",
|
|
214
|
+
"MachineSize",
|
|
215
|
+
"MachineSpec",
|
|
216
|
+
"MachineSpecBase",
|
|
217
|
+
"MachineSpecBuilder",
|
|
218
|
+
"MachineSpecError",
|
|
219
|
+
"MachineSpecPatch",
|
|
220
|
+
"MachineSpecPiece",
|
|
221
|
+
"asana",
|
|
222
|
+
"claude",
|
|
223
|
+
"codex",
|
|
224
|
+
"github",
|
|
225
|
+
"jira",
|
|
226
|
+
"merge_specs",
|
|
227
|
+
"spec_patch",
|
|
228
|
+
"secrets",
|
|
229
|
+
"slack",
|
|
230
|
+
"snapshot",
|
|
231
|
+
"team",
|
|
181
232
|
# Errors
|
|
182
233
|
"APIConnectionError",
|
|
183
234
|
"APIStatusError",
|