boxd 0.2.18.dev896__tar.gz → 0.2.19__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.
Files changed (47) hide show
  1. {boxd-0.2.18.dev896/src/boxd.egg-info → boxd-0.2.19}/PKG-INFO +322 -14
  2. {boxd-0.2.18.dev896 → boxd-0.2.19}/README.md +320 -13
  3. {boxd-0.2.18.dev896 → boxd-0.2.19}/pyproject.toml +5 -1
  4. {boxd-0.2.18.dev896 → boxd-0.2.19}/src/boxd/__init__.py +51 -0
  5. boxd-0.2.19/src/boxd/_generated/api_pb2.py +626 -0
  6. {boxd-0.2.18.dev896 → boxd-0.2.19}/src/boxd/_generated/api_pb2_grpc.py +47 -0
  7. {boxd-0.2.18.dev896 → boxd-0.2.19}/src/boxd/_mappers.py +73 -1
  8. {boxd-0.2.18.dev896 → boxd-0.2.19}/src/boxd/_requests.py +24 -0
  9. {boxd-0.2.18.dev896 → boxd-0.2.19}/src/boxd/errors.py +5 -1
  10. {boxd-0.2.18.dev896 → boxd-0.2.19}/src/boxd/models.py +169 -3
  11. {boxd-0.2.18.dev896 → boxd-0.2.19}/src/boxd/resources/machines.py +288 -5
  12. {boxd-0.2.18.dev896 → boxd-0.2.19}/src/boxd/resources/vars.py +6 -3
  13. boxd-0.2.19/src/boxd/spec.py +502 -0
  14. {boxd-0.2.18.dev896 → boxd-0.2.19/src/boxd.egg-info}/PKG-INFO +322 -14
  15. {boxd-0.2.18.dev896 → boxd-0.2.19}/src/boxd.egg-info/SOURCES.txt +3 -0
  16. {boxd-0.2.18.dev896 → boxd-0.2.19}/src/boxd.egg-info/requires.txt +1 -0
  17. {boxd-0.2.18.dev896 → boxd-0.2.19}/tests/test_mappers.py +13 -0
  18. {boxd-0.2.18.dev896 → boxd-0.2.19}/tests/test_namespaces.py +25 -0
  19. boxd-0.2.19/tests/test_python_versions.py +115 -0
  20. boxd-0.2.19/tests/test_spec.py +639 -0
  21. {boxd-0.2.18.dev896 → boxd-0.2.19}/tests/test_transport.py +4 -0
  22. boxd-0.2.18.dev896/src/boxd/_generated/api_pb2.py +0 -600
  23. {boxd-0.2.18.dev896 → boxd-0.2.19}/LICENSE +0 -0
  24. {boxd-0.2.18.dev896 → boxd-0.2.19}/setup.cfg +0 -0
  25. {boxd-0.2.18.dev896 → boxd-0.2.19}/src/boxd/_client.py +0 -0
  26. {boxd-0.2.18.dev896 → boxd-0.2.19}/src/boxd/_credentials.py +0 -0
  27. {boxd-0.2.18.dev896 → boxd-0.2.19}/src/boxd/_generated/__init__.py +0 -0
  28. {boxd-0.2.18.dev896 → boxd-0.2.19}/src/boxd/_streaming.py +0 -0
  29. {boxd-0.2.18.dev896 → boxd-0.2.19}/src/boxd/_transport.py +0 -0
  30. {boxd-0.2.18.dev896 → boxd-0.2.19}/src/boxd/_urls.py +0 -0
  31. {boxd-0.2.18.dev896 → boxd-0.2.19}/src/boxd/_version_check.py +0 -0
  32. {boxd-0.2.18.dev896 → boxd-0.2.19}/src/boxd/resources/__init__.py +0 -0
  33. {boxd-0.2.18.dev896 → boxd-0.2.19}/src/boxd/resources/account.py +0 -0
  34. {boxd-0.2.18.dev896 → boxd-0.2.19}/src/boxd/resources/credentials.py +0 -0
  35. {boxd-0.2.18.dev896 → boxd-0.2.19}/src/boxd/resources/disks.py +0 -0
  36. {boxd-0.2.18.dev896 → boxd-0.2.19}/src/boxd/resources/domains.py +0 -0
  37. {boxd-0.2.18.dev896 → boxd-0.2.19}/src/boxd/resources/orgs.py +0 -0
  38. {boxd-0.2.18.dev896 → boxd-0.2.19}/src/boxd/resources/snapshots.py +0 -0
  39. {boxd-0.2.18.dev896 → boxd-0.2.19}/src/boxd/resources/teams.py +0 -0
  40. {boxd-0.2.18.dev896 → boxd-0.2.19}/src/boxd.egg-info/dependency_links.txt +0 -0
  41. {boxd-0.2.18.dev896 → boxd-0.2.19}/src/boxd.egg-info/top_level.txt +0 -0
  42. {boxd-0.2.18.dev896 → boxd-0.2.19}/tests/test_credentials.py +0 -0
  43. {boxd-0.2.18.dev896 → boxd-0.2.19}/tests/test_delete_self.py +0 -0
  44. {boxd-0.2.18.dev896 → boxd-0.2.19}/tests/test_requests.py +0 -0
  45. {boxd-0.2.18.dev896 → boxd-0.2.19}/tests/test_streaming.py +0 -0
  46. {boxd-0.2.18.dev896 → boxd-0.2.19}/tests/test_urls.py +0 -0
  47. {boxd-0.2.18.dev896 → boxd-0.2.19}/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.18.dev896
3
+ Version: 0.2.19
4
4
  Summary: Python SDK for the boxd cloud VM platform
5
5
  Author: Azin
6
6
  License-Expression: MIT
@@ -23,6 +23,7 @@ License-File: LICENSE
23
23
  Requires-Dist: grpcio>=1.80
24
24
  Requires-Dist: protobuf>=6.31.1
25
25
  Requires-Dist: pydantic>=2
26
+ Requires-Dist: typing-extensions>=4.12
26
27
  Requires-Dist: httpx<1,>=0.27
27
28
  Provides-Extra: dev
28
29
  Requires-Dist: grpcio-tools<=1.80,>=1.60; extra == "dev"
@@ -250,6 +251,284 @@ private to you unless you pass `shared=True`.
250
251
  usable. Call `wait_until_ready` before doing anything that depends on it
251
252
  running — especially before forking it again.
252
253
 
254
+ ### Machine specs
255
+
256
+ A machine spec describes a machine in one value: what it boots from, its size,
257
+ labels, networks, isolation, egress, env, the secrets it gets, the
258
+ integrations and agent logins it needs, and the packages it installs on first boot. Build one from reusable pieces and create from it:
259
+
260
+ ```python
261
+ from boxd import Machine, team, snapshot, github, slack, jira, claude, secrets
262
+
263
+ engineering = (
264
+ Machine.spec()
265
+ .from_(team("eng")) # a team's default snapshot; or snapshot("eng-base")
266
+ .network("eng")
267
+ .label("team", "eng")
268
+ .env("NODE_ENV", "development")
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
+ Only org owners and admins may set, move or delete an org `unattached` secret;
336
+ your own (`private-unattached`) are yours. From inside a machine (with its
337
+ in-VM credentials) a spec can't list an unattached secret, yours or the org's,
338
+ on any machine, and `update_spec` can't change that machine's own `egress` or
339
+ `secrets`; make those changes from outside the machine, with an API key or in
340
+ the console. Names a fork or a restore takes over from its source's spec are
341
+ not affected.
342
+
343
+ On the machine, the spec's `env` and its `secrets` win over your `private`
344
+ vars, which win over the org's `shared` vars (shared machines only), which win
345
+ over the org's `all` vars. A name can't be in both `env` and `secrets`.
346
+
347
+ The machine keeps a reference, not a copy: rotating the secret reaches it, and
348
+ deleting the secret takes it away
349
+ (setting it again with the same name brings it back). Forks, snapshots and
350
+ restores resolve the
351
+ names again, and fail naming any that no longer resolve. A secret's
352
+ `bound_machines` says how many machines list it.
353
+
354
+ #### Creating a machine for someone else
355
+
356
+ An org owner or admin can create a machine for another member of the org with
357
+ `.assign_to(user_id)` (the member's user id, as `orgs.get_members()` lists it):
358
+
359
+ ```python
360
+ m = boxd.machines.create(
361
+ "mike-dev",
362
+ org="acme", # required: the org the machine is for
363
+ spec=engineering.size("2x8").assign_to("usr_e393f70e…"),
364
+ )
365
+ ```
366
+
367
+ The machine is the member's from the start: private to them, billed to the
368
+ org, and set up with their own integrations and agent logins. You can't see or
369
+ reach it afterwards, so `create()` answers with its id and name only, and the
370
+ create can't also set `delete_after`. The member sees `created_by` (your user
371
+ id) and `created_by_name` on it. Refused, naming `spec.assignTo`: without
372
+ `org`, with `shared=True`, for a caller who isn't an owner or admin of the org,
373
+ with an org API key or from inside a machine, for someone who isn't a member,
374
+ and with a snapshot or disk the member couldn't use. Your own id is a plain
375
+ create. `assignTo` is never inherited: forks, snapshots and restores drop it.
376
+
377
+ The machine runs the spec you wrote as the member's own machine: its packages
378
+ as root, its env and its base, alongside the member's GitHub token, agent
379
+ logins and personal connections. Assign a spec only if the member would run it
380
+ themselves. The member sees who created it (`created_by`) and can read the
381
+ spec (`machine.spec`).
382
+
383
+ #### Integrations the machine needs
384
+
385
+ A spec's `connections` and `agents` are the machine's requirements. Reading the
386
+ machine (`get`, `list`, the machine `create` returns) tells you, for each one,
387
+ whether it is connected right now:
388
+
389
+ ```python
390
+ m = boxd.machines.get("mike-dev")
391
+ for r in m.requirements:
392
+ # kind="connection" name="jira" scope="shared" tag="jira" met=False can_fix=True reason=None
393
+ 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")
394
+ ```
395
+
396
+ A `personal` connection is the machine owner's own connection for that toolkit
397
+ in the machine's org (on a machine created with `assign_to`, the member's), a
398
+ `shared` one the org's connection shared under `tag`, and an agent the owner's
399
+ Claude Code or Codex sign-in. `can_fix` says whether you could connect it: the
400
+ owner for personal ones and agents, an org owner or admin for shared ones.
401
+ Connect from the console: the machine's page lists them with a Connect button.
402
+
403
+ An unmet requirement is only unavailable on the machine; nothing blocks
404
+ creating, booting or using it. Some can't be met on the machine as it is, and
405
+ `reason` says why: an isolated machine gets no integrations or agent logins,
406
+ and a shared machine never gets anyone's personal connection or agent login
407
+ (require `shared:<tag>` instead). The create refuses a toolkit this cluster
408
+ can't connect (`spec.connections.<toolkit>: ...`), and the GitHub App
409
+ (`github-app`) only as `"shared:org"`.
410
+
411
+ #### Packages
412
+
413
+ `packages` are shell commands the machine runs once, in order, after its first
414
+ boot: as root, with bash (sh on an image without it),
415
+ `DEBIAN_FRONTEND=noninteractive`, no stdin, the env and secrets an exec
416
+ session on the machine gets, and a 30-minute timeout each. Their output is
417
+ appended to `/var/log/boxd/packages.log` in the machine, which only root can
418
+ read. A command is done when its shell exits, so one that leaves a process
419
+ running in the background (`my-server &`) doesn't hold up the next. The first
420
+ failure (a non-zero exit, a timeout) stops the rest; the machine stays usable,
421
+ and `update_spec(id, {}, retry_packages=True)` runs the failed command and the ones after it again (so does
422
+ Retry in the console's Packages section). To give up on a command instead, or
423
+ to replace a wrong one, remove it from the spec: a command that leaves the spec
424
+ before it finished is skipped, and the ones after it run. Reading the machine
425
+ tells you how each one went:
426
+
427
+ ```python
428
+ m = boxd.machines.get("mike-dev")
429
+ for p in m.packages:
430
+ # command="apt-get install -y nginx" state="failed" exit_code=100 output_tail="E: Unable to locate…" reason=None …
431
+ print(p.state, p.exit_code, p.command)
432
+ ```
433
+
434
+ `state` is `"pending"`, `"running"`, `"done"`, `"failed"` or `"skipped"`; the
435
+ commands after a failure stay `"pending"`. A pending or failed command that an
436
+ `update_spec` removes from the spec becomes `"skipped"`: it never runs and no
437
+ longer stops the ones after it, which run right away (or when the machine next
438
+ runs). A skipped one that failed keeps its `exit_code` and `output_tail`, and
439
+ `reason` says when it was skipped. Listing it in the spec again puts it back to
440
+ `"pending"`, in its place. So replacing a broken command is one patch: drop it
441
+ and add the corrected one, and the corrected one runs, with everything that
442
+ was waiting. A running command can't be removed (the patch is refused until it
443
+ ends), and removing a done one undoes nothing. `output_tail` is the last 4 KiB of the
444
+ command's output, with the value of every secret the machine gets shown as
445
+ `[secret NAME]` (only the value as it is: printed encoded or in pieces, it isn't
446
+ recognised). The log file isn't masked: it is root's, and root on the machine
447
+ can read its env anyway. `output_tail` is empty on the machines `list()` returns,
448
+ which leave it out to keep the list small; `get()` has it. `reason` says why a failed one has no exit code (it
449
+ timed out, or was interrupted too often), or why a pending one is waiting. A
450
+ command the platform interrupts (the machine stops, reboots, hibernates or
451
+ moves, or boxd restarts on the machine's host) goes back to `"pending"` and runs
452
+ again from the start once the machine is running, and the rest follow; after 3
453
+ interruptions it fails. What finished never runs again: not on a reboot, a
454
+ resume, a wake or a move. Restoring a snapshot marks done the commands that had
455
+ run on the machine the snapshot was taken from and runs the snapshot spec's
456
+ others (one that was running when the snapshot was taken runs again from the
457
+ start), then the ones the restore's spec adds; a fork keeps its source's states
458
+ and runs nothing. The machine doesn't auto-suspend or
459
+ hibernate while a command runs. At most 50 commands (a restore counts the
460
+ snapshot's too), each at most 4 KiB; the server names the one it refuses
461
+ (`spec.packages[2]: ...`). Commands that download something need the hosts in
462
+ the spec's `egress`, if it has one.
463
+
464
+ A spec has size limits, checked before anything else and named with what was
465
+ given (`spec.env: at most 128 variables (got 129)`): at most 256 KiB as JSON
466
+ in all; `env` at most 128 variables, each value at most 32 KiB and all values
467
+ together at most 64 KiB; `secrets` at most 64 names; `networks` at most 16, each
468
+ name at most 63 bytes; `egress` at most 256 entries, none empty (leave `egress`
469
+ out for no allowlist); `connections` at most 64; `packages` at most 200 listed
470
+ and 50 once duplicates are dropped. An `update_spec` patch is at most 256 KiB.
471
+
472
+ #### Updating a machine's spec
473
+
474
+ `update_spec` changes the spec of a machine created from one and applies what
475
+ changes. Pass a JSON Merge Patch, or a function that adds to the current spec:
476
+
477
+ ```python
478
+ # Patch: a field left out is untouched, a scalar replaces, maps merge key by
479
+ # key (None removes a key), a list replaces the whole list, None removes a field.
480
+ boxd.machines.update_spec("mike-dev", {
481
+ "labels": {"owner": "mike", "team": None},
482
+ "networks": ["eng", "ops"],
483
+ "packages": ["apt-get install -y nginx", "npm i -g pnpm"],
484
+ })
485
+
486
+ # Builder: reads the spec, adds with the composition rules, sends the difference.
487
+ boxd.machines.update_spec("mike-dev", lambda s: s.network("ops").env("LOG_LEVEL", "debug"))
488
+
489
+ # Dry run: the diff, nothing changed.
490
+ diff = boxd.machines.update_spec("mike-dev", {"size": "4x16"}, dry_run=True)
491
+ for c in diff.changes:
492
+ print(c.field, c.apply, c.note, c.warnings)
493
+ if diff.reboot_required:
494
+ boxd.machines.update_spec("mike-dev", {"size": "4x16"}, reboot=True)
495
+ ```
496
+
497
+ Each change in `changes` says how it applies (`apply`):
498
+
499
+ | Field | `apply` |
500
+ |---|---|
501
+ | `labels`, `networks`, `egress` | `"live"` (new egress rules are in force within about a second of the call returning) |
502
+ | `connections`, `agents` | `"live"`: what the machine needs (`requirements`) changes |
503
+ | `packages` | `"live"`: added commands run now, after the ones before them. Removing one undoes nothing (a warning says so); a pending or failed one the patch removes is skipped (listed in `skipped`), so the ones after it run, and a skipped one listed again goes back to pending in its place (`unskipped`); a command that already ran isn't run again; commands after a failed one don't run (a warning says so) unless the update also retries (`retry_packages=True`: the failed command and the rest run again first, listed in `changed`, skipped ones excepted; a failed command the patch removes is skipped, not retried). Removing a running command is `"refused"` until it ends |
504
+ | `env`, `secrets` | `"reboot_recommended"`: new shells and exec sessions get them now; processes already running keep the old values until they restart (`reboot_recommended`) |
505
+ | `size` | `"reboot"` on a running machine, which reboots to apply it (as `resize`), so it needs `reboot=True`; `"live"` on a stopped one |
506
+ | `base`, `isolated`, `assignTo` | `"refused"`: fixed when the machine is created |
507
+
508
+ A refused change (also: new packages or a retry on a fork, a size change in standby or
509
+ hibernated) refuses the whole patch, and so does a reboot without
510
+ `reboot=True`; nothing is applied. So is an update during which the spec
511
+ changed (another update, a label edit): it raises `ConflictError`, and
512
+ reading the machine again and retrying is safe. The builder form does that
513
+ itself: it sends the spec it read, so a change made in between can't be
514
+ undone by its lists, and on a conflict reads the spec again and reapplies the
515
+ function, up to 3 times. A patch you built from a spec you read can say so
516
+ too, `expected_spec=machine.spec`: if the spec is no longer that one, nothing
517
+ is applied and it raises `ConflictError`. Otherwise the spec and the labels,
518
+ networks, egress and secrets it drives change together, then the size, then
519
+ the new packages run. If the size then fails (the org's quota changed, the
520
+ machine went to standby), the rest stays applied and the call still returns:
521
+ `resize_error` says why, and `spec` has the size the machine has. The patched spec is checked like a create's (an
522
+ unknown toolkit, a secret you may not list, more than 50 commands in all are
523
+ refused, naming the field). `machine.created_spec` never changes, so the
524
+ console shows the spec badge as "updated". Allowed to whoever may edit the
525
+ machine's labels: its owner, or an org owner or admin on a shared machine.
526
+ The builder form only adds: a `size` that differs from the spec's raises
527
+ (`"2X8"` is the same as `"2x8"`), so
528
+ use a patch to replace or remove. `spec_patch(from_spec, to_spec)` computes the
529
+ patch the builder form sends. `AsyncBoxd` has the same `await
530
+ boxd.machines.update_spec(...)`.
531
+
253
532
  ### Egress allowlist
254
533
 
255
534
  A machine can be limited to what it may reach on the internet:
@@ -260,13 +539,16 @@ boxd.machines.get("alpha").egress_allow
260
539
  boxd.machines.set_egress_allow("alpha", []) # clear: unrestricted again
261
540
  ```
262
541
 
263
- Entries are hostnames or wildcards, or public IPv4 addresses and CIDRs. Web
542
+ Entries are hostnames or wildcards, or public IPv4 addresses and CIDRs, at
543
+ most 256; an internationalized name is kept in its ASCII form (`bücher.de`
544
+ becomes `xn--bcher-kva.de`), which is what the machine looks up. Web
264
545
  requests are admitted by hostname, everything else by address, with the
265
546
  addresses of allowlisted names learned as the machine resolves them. Nothing
266
547
  that is not named is reachable. The hosts of any bound secret the machine
267
- holds are always admitted. Takes effect within a second and is not visible or
268
- changeable from inside the machine. A fork inherits its source's allowlist; a
269
- machine restored from a snapshot starts unrestricted.
548
+ holds are always admitted. Takes effect within a second. From inside the
549
+ machine it can be read (`boxd machine egress`) but not changed. A fork inherits its source's allowlist; a
550
+ machine restored from a snapshot starts unrestricted, unless its machine spec
551
+ lists `egress`.
270
552
 
271
553
  ### Labels
272
554
 
@@ -345,6 +627,15 @@ machine.source.id # str | None — provenance; may not resolve
345
627
  machine.hibernated_at # datetime | None — None = not hibernated
346
628
  machine.last_connected_at # datetime | None — None = never connected
347
629
  machine.boot_time_ms # int | None — last boot; None = never booted
630
+ machine.spec # dict | None — the machine spec it runs under
631
+ machine.created_spec # dict | None — the spec it was created with
632
+ machine.created_by # str | None — who created it for you (spec assignTo); None = you did
633
+ machine.created_by_name # str | None — a name to show for created_by
634
+ machine.requirements # list[MachineRequirement] — the spec's connections and agents:
635
+ # kind, name, scope ("personal"/"shared"), tag, met, can_fix, reason
636
+ machine.packages # list[MachinePackage] — the spec's packages and how each went:
637
+ # command, state, exit_code, output_tail, reason, started_at, finished_at
638
+ # (output_tail is empty from list(): get() has it)
348
639
  ```
349
640
 
350
641
  `None` always means "not set": a port that was never allocated, a boot that
@@ -558,23 +849,33 @@ boxd.env.list(org="acme")
558
849
  boxd.env.delete("MODE", scope="all")
559
850
 
560
851
  boxd.secrets.set("API_TOKEN", "s3cr3t", scope="shared")
561
- boxd.secrets.list() # Secret(name, scope, domains) — no value
852
+ boxd.secrets.list() # Secret(name, scope, domains, bound_machines) — no value
562
853
  boxd.secrets.delete("API_TOKEN", scope="shared")
563
854
  ```
564
855
 
565
856
  A secret can be bound to the hosts it may be sent to. The machine then only
566
- ever holds a placeholder, and the real value is substituted into requests to
567
- those hosts on the way out:
857
+ ever holds a placeholder, and the real value is substituted into HTTPS
858
+ requests to those hosts on the way out:
568
859
 
569
860
  ```python
570
861
  boxd.secrets.set("STRIPE_KEY", "sk_live_...", scope="all", domains=["api.stripe.com"])
571
862
  ```
572
863
 
573
- Inside the machine, `STRIPE_KEY` is an opaque `bxds_…` string. A request to
574
- `api.stripe.com` carrying it, in a header, the query, the body or Basic auth,
575
- arrives at Stripe with the real key; a request anywhere else carries the
576
- useless placeholder. Nothing to configure in the machine, and the placeholder
577
- does not change when the value is rotated. `domains` is the full desired set:
864
+ Inside the machine, `STRIPE_KEY` is an opaque `bxds_…` string. An HTTPS
865
+ request to `api.stripe.com` carrying it anywhere (the URL, a header, Basic auth
866
+ included, or the body) arrives at Stripe with the real key. It is replaced only
867
+ over HTTPS, and only when both the server name the connection was opened for
868
+ and the request's own `Host` are hosts the secret is bound to: over plain HTTP
869
+ and in a request to any other host it stays the useless placeholder. A body
870
+ over 16 MiB with a `Content-Length` is sent as written, placeholder included.
871
+ Responses on those connections carry the placeholder instead of the value:
872
+ wherever the real value (8 characters or more) appears in a response's headers
873
+ or body exactly as it was sent, it is replaced, so a server that echoes the
874
+ request doesn't hand the value to the machine. A value the server sends back
875
+ re-encoded (base64, URL- or JSON-escaped) isn't caught, and neither is one in
876
+ a body compressed with anything but gzip or deflate (the proxy asks for an
877
+ uncompressed response). Nothing to configure in the machine, and the
878
+ placeholder does not change when the value is rotated. `domains` is the full desired set:
578
879
  setting the secret again without it makes it a plain secret. Wildcards such as
579
880
  `*.stripe.com` are accepted; provider-wide ones such as `*.amazonaws.com` are
580
881
  refused.
@@ -595,6 +896,13 @@ Scope decides which machines a name applies to:
595
896
  | `private` | only your machines in that organization |
596
897
  | `shared` | the organization's shared machines |
597
898
  | `all` | every machine in the organization |
899
+ | `unattached` | secrets only: just the machines whose spec lists it ([Secrets](#secrets)); the organization's with `org` (set, moved and deleted by its owners and admins only), else your own |
900
+ | `private-unattached` | secrets only: your own unattached secret in the organization named by `org` |
901
+
902
+ `list` reports the organization's unattached secrets as `unattached` and your
903
+ own as `private-unattached`, each with `bound_machines`, the number of machines
904
+ whose spec lists it (0 for every other scope). `unattached` is for secrets
905
+ only: a plain value a machine needs belongs in its spec's `env`.
598
906
 
599
907
  Scope is part of a name's identity — the same name can exist in several scopes
600
908
  at once — so changing it is a `move` between two addresses, and both ends are
@@ -860,7 +1168,7 @@ from boxd import (
860
1168
  AuthenticationError, # no usable credential, or it was rejected
861
1169
  PermissionDeniedError, # authenticated, but not allowed
862
1170
  NotFoundError,
863
- ConflictError, # already exists, or fights the current state
1171
+ ConflictError, # already exists, fights the current state, or it changed meanwhile
864
1172
  RateLimitError, # rate limit or quota
865
1173
  APIStatusError, # any other error from the server
866
1174
  APIConnectionError, # could not reach the server