boxd 0.2.19.dev901__tar.gz → 0.2.20.dev929__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 (51) hide show
  1. {boxd-0.2.19.dev901/src/boxd.egg-info → boxd-0.2.20.dev929}/PKG-INFO +173 -35
  2. {boxd-0.2.19.dev901 → boxd-0.2.20.dev929}/README.md +171 -34
  3. {boxd-0.2.19.dev901 → boxd-0.2.20.dev929}/pyproject.toml +9 -1
  4. {boxd-0.2.19.dev901 → boxd-0.2.20.dev929}/src/boxd/__init__.py +18 -0
  5. {boxd-0.2.19.dev901 → boxd-0.2.20.dev929}/src/boxd/_credentials.py +110 -29
  6. boxd-0.2.20.dev929/src/boxd/_generated/api_pb2.py +668 -0
  7. {boxd-0.2.19.dev901 → boxd-0.2.20.dev929}/src/boxd/_generated/api_pb2_grpc.py +428 -1
  8. {boxd-0.2.19.dev901 → boxd-0.2.20.dev929}/src/boxd/_mappers.py +46 -1
  9. {boxd-0.2.19.dev901 → boxd-0.2.20.dev929}/src/boxd/_requests.py +120 -12
  10. {boxd-0.2.19.dev901 → boxd-0.2.20.dev929}/src/boxd/_transport.py +24 -3
  11. boxd-0.2.20.dev929/src/boxd/errors.py +324 -0
  12. {boxd-0.2.19.dev901 → boxd-0.2.20.dev929}/src/boxd/models.py +96 -13
  13. boxd-0.2.20.dev929/src/boxd/py.typed +0 -0
  14. boxd-0.2.20.dev929/src/boxd/resources/fs.py +434 -0
  15. {boxd-0.2.19.dev901 → boxd-0.2.20.dev929}/src/boxd/resources/machines.py +260 -55
  16. {boxd-0.2.19.dev901 → boxd-0.2.20.dev929}/src/boxd/resources/vars.py +4 -3
  17. {boxd-0.2.19.dev901 → boxd-0.2.20.dev929}/src/boxd/spec.py +40 -11
  18. {boxd-0.2.19.dev901 → boxd-0.2.20.dev929/src/boxd.egg-info}/PKG-INFO +173 -35
  19. {boxd-0.2.19.dev901 → boxd-0.2.20.dev929}/src/boxd.egg-info/SOURCES.txt +4 -0
  20. {boxd-0.2.19.dev901 → boxd-0.2.20.dev929}/src/boxd.egg-info/requires.txt +1 -0
  21. {boxd-0.2.19.dev901 → boxd-0.2.20.dev929}/tests/test_credentials.py +236 -15
  22. {boxd-0.2.19.dev901 → boxd-0.2.20.dev929}/tests/test_delete_self.py +12 -2
  23. boxd-0.2.20.dev929/tests/test_fs.py +551 -0
  24. {boxd-0.2.19.dev901 → boxd-0.2.20.dev929}/tests/test_mappers.py +17 -0
  25. {boxd-0.2.19.dev901 → boxd-0.2.20.dev929}/tests/test_namespaces.py +153 -1
  26. boxd-0.2.20.dev929/tests/test_python_versions.py +115 -0
  27. {boxd-0.2.19.dev901 → boxd-0.2.20.dev929}/tests/test_requests.py +25 -4
  28. {boxd-0.2.19.dev901 → boxd-0.2.20.dev929}/tests/test_spec.py +170 -2
  29. {boxd-0.2.19.dev901 → boxd-0.2.20.dev929}/tests/test_transport.py +101 -0
  30. boxd-0.2.19.dev901/src/boxd/_generated/api_pb2.py +0 -626
  31. boxd-0.2.19.dev901/src/boxd/errors.py +0 -97
  32. {boxd-0.2.19.dev901 → boxd-0.2.20.dev929}/LICENSE +0 -0
  33. {boxd-0.2.19.dev901 → boxd-0.2.20.dev929}/setup.cfg +0 -0
  34. {boxd-0.2.19.dev901 → boxd-0.2.20.dev929}/src/boxd/_client.py +0 -0
  35. {boxd-0.2.19.dev901 → boxd-0.2.20.dev929}/src/boxd/_generated/__init__.py +0 -0
  36. {boxd-0.2.19.dev901 → boxd-0.2.20.dev929}/src/boxd/_streaming.py +0 -0
  37. {boxd-0.2.19.dev901 → boxd-0.2.20.dev929}/src/boxd/_urls.py +0 -0
  38. {boxd-0.2.19.dev901 → boxd-0.2.20.dev929}/src/boxd/_version_check.py +0 -0
  39. {boxd-0.2.19.dev901 → boxd-0.2.20.dev929}/src/boxd/resources/__init__.py +0 -0
  40. {boxd-0.2.19.dev901 → boxd-0.2.20.dev929}/src/boxd/resources/account.py +0 -0
  41. {boxd-0.2.19.dev901 → boxd-0.2.20.dev929}/src/boxd/resources/credentials.py +0 -0
  42. {boxd-0.2.19.dev901 → boxd-0.2.20.dev929}/src/boxd/resources/disks.py +0 -0
  43. {boxd-0.2.19.dev901 → boxd-0.2.20.dev929}/src/boxd/resources/domains.py +0 -0
  44. {boxd-0.2.19.dev901 → boxd-0.2.20.dev929}/src/boxd/resources/orgs.py +0 -0
  45. {boxd-0.2.19.dev901 → boxd-0.2.20.dev929}/src/boxd/resources/snapshots.py +0 -0
  46. {boxd-0.2.19.dev901 → boxd-0.2.20.dev929}/src/boxd/resources/teams.py +0 -0
  47. {boxd-0.2.19.dev901 → boxd-0.2.20.dev929}/src/boxd.egg-info/dependency_links.txt +0 -0
  48. {boxd-0.2.19.dev901 → boxd-0.2.20.dev929}/src/boxd.egg-info/top_level.txt +0 -0
  49. {boxd-0.2.19.dev901 → boxd-0.2.20.dev929}/tests/test_streaming.py +0 -0
  50. {boxd-0.2.19.dev901 → boxd-0.2.20.dev929}/tests/test_urls.py +0 -0
  51. {boxd-0.2.19.dev901 → boxd-0.2.20.dev929}/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.19.dev901
3
+ Version: 0.2.20.dev929
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"
@@ -118,6 +119,12 @@ The first of these that is present wins:
118
119
  If your key is revoked mid-session, the SDK fails fast with
119
120
  `AuthenticationError` rather than retrying.
120
121
 
122
+ `AuthenticationError` means only that no credential was found or boxd refused
123
+ it; its `.reason` says which (`"missing_credentials"`, `"invalid_api_key"`,
124
+ `"invalid_token"`). Failing to reach boxd is an `APIConnectionError`, an
125
+ account that can't use the API a `PermissionDeniedError`, and an error answer
126
+ from the key exchange an `APIStatusError` with its `.http_status`.
127
+
121
128
  The exchange endpoint is **rate-limited per source IP** (30 requests / 60s). A
122
129
  session token is valid for one hour, so a fleet of workers behind one NAT
123
130
  should **mint one token and share it** (`Boxd(token=...)`) rather than each
@@ -141,6 +148,9 @@ One limit: inside a **shared** machine the automatic credential can manage the
141
148
  organization's shared machines, but cannot read environment variables or
142
149
  secrets, and cannot reach private machines. Pass an API key for those.
143
150
 
151
+ An **isolated** machine has no automatic credential: `Boxd()` raises
152
+ `AuthenticationError` there, so pass an API key.
153
+
144
154
  ## Sync and async
145
155
 
146
156
  `Boxd` and `AsyncBoxd` are the same surface — same namespaces, same method
@@ -204,7 +214,7 @@ boxd.machines.create(
204
214
  disk="100G",
205
215
  auto_suspend_timeout=300, # seconds; 0 disables
206
216
  auto_destroy_timeout=0,
207
- ssh=True, # give the machine an SSH port
217
+ ssh=True, # False: SSH off, nothing answers on its port
208
218
  proxies=[ProxyEntry(name="api", port=3000)],
209
219
  volumes=[VolumeMount(disk_id="d_...", mount_path="/data", read_only=False)],
210
220
  )
@@ -238,6 +248,7 @@ boxd.machines.unshare(id)
238
248
  boxd.machines.set_auto_suspend_timeout(id, 300) # seconds idle; 0 disables
239
249
  boxd.machines.set_auto_hibernate_timeout(id, 0)
240
250
  boxd.machines.set_idle_ignore_outbound(id, True) # only inbound connections keep it awake
251
+ boxd.machines.set_ssh(id, False) # nothing answers SSH; exec still works
241
252
  boxd.machines.wait_until_ready(id)
242
253
  boxd.machines.wait_until_ready(id, timeout=180.0, poll_interval=1.0) # seconds
243
254
  boxd.machines.suggest_name()
@@ -246,6 +257,14 @@ boxd.machines.suggest_name()
246
257
  A fork inherits the source's sizing for anything you leave unset, and is
247
258
  private to you unless you pass `shared=True`.
248
259
 
260
+ With SSH off (`ssh=False` on create, or `set_ssh(id, False)`), nothing
261
+ answers SSH on the machine's port, on the public address or the org's
262
+ network, and SSH sessions already open to it are closed. The port stays
263
+ allocated, so turning SSH back on keeps the address. `exec`, `boxd connect`
264
+ and the console terminal don't use SSH and keep working. A fork or a restore
265
+ keeps its source's setting unless you pass `ssh`. `set_ssh` can't be called
266
+ from inside the machine itself.
267
+
249
268
  `create` and `fork` return once the machine is scheduled, not once it is
250
269
  usable. Call `wait_until_ready` before doing anything that depends on it
251
270
  running — especially before forking it again.
@@ -265,7 +284,6 @@ engineering = (
265
284
  .network("eng")
266
285
  .label("team", "eng")
267
286
  .env("NODE_ENV", "development")
268
- .egress("github.com", "*.atlassian.net", "*.ubuntu.com")
269
287
  .packages("apt-get update", "apt-get install -y nginx")
270
288
  .with_(github(shared="eng-bot"), claude())
271
289
  )
@@ -332,6 +350,14 @@ the org's secrets count. Only org owners and admins may list an org
332
350
  shared machines, so a private machine can't list one. A name that resolves to
333
351
  nothing, or that the create may not list, fails the create, naming it.
334
352
 
353
+ Only org owners and admins may set, move or delete an org `unattached` secret;
354
+ your own (`private-unattached`) are yours. From inside a machine (with its
355
+ in-VM credentials) a spec can't list an unattached secret, yours or the org's,
356
+ on any machine, and `update_spec` can't change that machine's own `egress` or
357
+ `secrets`; make those changes from outside the machine, with an API key or in
358
+ the console. Names a fork or a restore takes over from its source's spec are
359
+ not affected.
360
+
335
361
  On the machine, the spec's `env` and its `secrets` win over your `private`
336
362
  vars, which win over the org's `shared` vars (shared machines only), which win
337
363
  over the org's `all` vars. A name can't be in both `env` and `secrets`.
@@ -366,6 +392,12 @@ with an org API key or from inside a machine, for someone who isn't a member,
366
392
  and with a snapshot or disk the member couldn't use. Your own id is a plain
367
393
  create. `assignTo` is never inherited: forks, snapshots and restores drop it.
368
394
 
395
+ The machine runs the spec you wrote as the member's own machine: its packages
396
+ as root, its env and its base, alongside the member's GitHub token, agent
397
+ logins and personal connections. Assign a spec only if the member would run it
398
+ themselves. The member sees who created it (`created_by`) and can read the
399
+ spec (`machine.spec`).
400
+
369
401
  #### Integrations the machine needs
370
402
 
371
403
  A spec's `connections` and `agents` are the machine's requirements. Reading the
@@ -398,10 +430,17 @@ can't connect (`spec.connections.<toolkit>: ...`), and the GitHub App
398
430
 
399
431
  `packages` are shell commands the machine runs once, in order, after its first
400
432
  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:
433
+ `DEBIAN_FRONTEND=noninteractive`, no stdin, the env and secrets an exec
434
+ session on the machine gets, and a 30-minute timeout each. Their output is
435
+ appended to `/var/log/boxd/packages.log` in the machine, which only root can
436
+ read. A command is done when its shell exits, so one that leaves a process
437
+ running in the background (`my-server &`) doesn't hold up the next. The first
438
+ failure (a non-zero exit, a timeout) stops the rest; the machine stays usable,
439
+ and `update_spec(id, {}, retry_packages=True)` runs the failed command and the ones after it again (so does
440
+ Retry in the console's Packages section). To give up on a command instead, or
441
+ to replace a wrong one, remove it from the spec: a command that leaves the spec
442
+ before it finished is skipped, and the ones after it run. Reading the machine
443
+ tells you how each one went:
405
444
 
406
445
  ```python
407
446
  m = boxd.machines.get("mike-dev")
@@ -410,18 +449,44 @@ for p in m.packages:
410
449
  print(p.state, p.exit_code, p.command)
411
450
  ```
412
451
 
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
452
+ `state` is `"pending"`, `"running"`, `"done"`, `"failed"` or `"skipped"`; the
453
+ commands after a failure stay `"pending"`. A pending or failed command that an
454
+ `update_spec` removes from the spec becomes `"skipped"`: it never runs and no
455
+ longer stops the ones after it, which run right away (or when the machine next
456
+ runs). A skipped one that failed keeps its `exit_code` and `output_tail`, and
457
+ `reason` says when it was skipped. Listing it in the spec again puts it back to
458
+ `"pending"`, in its place. So replacing a broken command is one patch: drop it
459
+ and add the corrected one, and the corrected one runs, with everything that
460
+ was waiting. A running command can't be removed (the patch is refused until it
461
+ ends), and removing a done one undoes nothing. `output_tail` is the last 4 KiB of the
462
+ command's output, with the value of every secret the machine gets shown as
463
+ `[secret NAME]` (only the value as it is: printed encoded or in pieces, it isn't
464
+ recognised). The log file isn't masked: it is root's, and root on the machine
465
+ can read its env anyway. `output_tail` is empty on the machines `list()` returns,
466
+ which leave it out to keep the list small; `get()` has it. `reason` says why a failed one has no exit code (it
467
+ timed out, or was interrupted too often), or why a pending one is waiting. A
468
+ command the platform interrupts (the machine stops, reboots, hibernates or
469
+ moves, or boxd restarts on the machine's host) goes back to `"pending"` and runs
470
+ again from the start once the machine is running, and the rest follow; after 3
471
+ interruptions it fails. What finished never runs again: not on a reboot, a
472
+ resume, a wake or a move. Restoring a snapshot marks done the commands that had
473
+ run on the machine the snapshot was taken from and runs the snapshot spec's
474
+ others (one that was running when the snapshot was taken runs again from the
475
+ start), then the ones the restore's spec adds; a fork keeps its source's states
476
+ and runs nothing. The machine doesn't auto-suspend or
420
477
  hibernate while a command runs. At most 50 commands (a restore counts the
421
478
  snapshot's too), each at most 4 KiB; the server names the one it refuses
422
479
  (`spec.packages[2]: ...`). Commands that download something need the hosts in
423
480
  the spec's `egress`, if it has one.
424
481
 
482
+ A spec has size limits, checked before anything else and named with what was
483
+ given (`spec.env: at most 128 variables (got 129)`): at most 256 KiB as JSON
484
+ in all; `env` at most 128 variables, each value at most 32 KiB and all values
485
+ together at most 64 KiB; `secrets` at most 64 names; `networks` at most 16, each
486
+ name at most 63 bytes; `egress` at most 256 entries, none empty (leave `egress`
487
+ out for no allowlist); `connections` at most 64; `packages` at most 200 listed
488
+ and 50 once duplicates are dropped. An `update_spec` patch is at most 256 KiB.
489
+
425
490
  #### Updating a machine's spec
426
491
 
427
492
  `update_spec` changes the spec of a machine created from one and applies what
@@ -451,23 +516,33 @@ Each change in `changes` says how it applies (`apply`):
451
516
 
452
517
  | Field | `apply` |
453
518
  |---|---|
454
- | `labels`, `networks`, `egress` | `"live"` |
519
+ | `labels`, `networks`, `egress` | `"live"` (new egress rules are in force within about a second of the call returning) |
455
520
  | `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) |
521
+ | `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 |
457
522
  | `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
523
  | `size` | `"reboot"` on a running machine, which reboots to apply it (as `resize`), so it needs `reboot=True`; `"live"` on a stopped one |
459
524
  | `base`, `isolated`, `assignTo` | `"refused"`: fixed when the machine is created |
460
525
 
461
- A refused change (also: new packages on a fork, a size change in standby or
526
+ A refused change (also: new packages or a retry on a fork, a size change in standby or
462
527
  hibernated) refuses the whole patch, and so does a reboot without
463
- `reboot=True`; nothing is applied. Otherwise the spec and the labels,
528
+ `reboot=True`; nothing is applied. So is an update during which the spec
529
+ changed (another update, a label edit): it raises `ConflictError`, and
530
+ reading the machine again and retrying is safe. The builder form does that
531
+ itself: it sends the spec it read, so a change made in between can't be
532
+ undone by its lists, and on a conflict reads the spec again and reapplies the
533
+ function, up to 3 times. A patch you built from a spec you read can say so
534
+ too, `expected_spec=machine.spec`: if the spec is no longer that one, nothing
535
+ is applied and it raises `ConflictError`. Otherwise the spec and the labels,
464
536
  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
537
+ the new packages run. If the size then fails (the org's quota changed, the
538
+ machine went to standby), the rest stays applied and the call still returns:
539
+ `resize_error` says why, and `spec` has the size the machine has. The patched spec is checked like a create's (an
466
540
  unknown toolkit, a secret you may not list, more than 50 commands in all are
467
541
  refused, naming the field). `machine.created_spec` never changes, so the
468
542
  console shows the spec badge as "updated". Allowed to whoever may edit the
469
543
  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
544
+ The builder form only adds: a `size` that differs from the spec's raises
545
+ (`"2X8"` is the same as `"2x8"`), so
471
546
  use a patch to replace or remove. `spec_patch(from_spec, to_spec)` computes the
472
547
  patch the builder form sends. `AsyncBoxd` has the same `await
473
548
  boxd.machines.update_spec(...)`.
@@ -479,15 +554,20 @@ A machine can be limited to what it may reach on the internet:
479
554
  ```python
480
555
  boxd.machines.set_egress_allow("alpha", ["api.stripe.com", "*.github.com", "203.0.113.0/24"])
481
556
  boxd.machines.get("alpha").egress_allow
557
+ boxd.machines.set_egress_allow("alpha", ["none"]) # nothing is reachable
482
558
  boxd.machines.set_egress_allow("alpha", []) # clear: unrestricted again
483
559
  ```
484
560
 
485
- Entries are hostnames or wildcards, or public IPv4 addresses and CIDRs. Web
561
+ Entries are hostnames or wildcards, or public IPv4 addresses and CIDRs, at
562
+ most 256; an internationalized name is kept in its ASCII form (`bücher.de`
563
+ becomes `xn--bcher-kva.de`), which is what the machine looks up. Web
486
564
  requests are admitted by hostname, everything else by address, with the
487
565
  addresses of allowlisted names learned as the machine resolves them. Nothing
488
566
  that is not named is reachable. The hosts of any bound secret the machine
489
- holds are always admitted. Takes effect within a second and is not visible or
490
- changeable from inside the machine. A fork inherits its source's allowlist; a
567
+ holds are always admitted. `"none"` on its own leaves the allowlist on with
568
+ nothing in it, so the machine reaches only those; next to other entries it is
569
+ dropped. Takes effect within a second. From inside the
570
+ machine it can be read (`boxd machine egress`) but not changed. A fork inherits its source's allowlist; a
491
571
  machine restored from a snapshot starts unrestricted, unless its machine spec
492
572
  lists `egress`.
493
573
 
@@ -554,6 +634,7 @@ machine.shared # shared with that org, or private to you
554
634
  machine.access.ssh_port # int | None — None until allocated
555
635
  machine.access.domain
556
636
  machine.access.url # https://<name>.<domain>
637
+ machine.ssh # bool — False: SSH is off, nothing answers on ssh_port
557
638
 
558
639
  machine.idle.suspend_after # seconds; 0 = that timer is disabled
559
640
  machine.idle.hibernate_after
@@ -576,6 +657,7 @@ machine.requirements # list[MachineRequirement] — the spec's connec
576
657
  # kind, name, scope ("personal"/"shared"), tag, met, can_fix, reason
577
658
  machine.packages # list[MachinePackage] — the spec's packages and how each went:
578
659
  # command, state, exit_code, output_tail, reason, started_at, finished_at
660
+ # (output_tail is empty from list(): get() has it)
579
661
  ```
580
662
 
581
663
  `None` always means "not set": a port that was never allocated, a boot that
@@ -703,6 +785,47 @@ relative to the VM user's home directory (`/home/boxd`) — a bare filename
703
785
  like `"notes.txt"` resolves to `/home/boxd/notes.txt`, same as `"./notes.txt"`
704
786
  or `"sub/notes.txt"` would.
705
787
 
788
+ ### Filesystem
789
+
790
+ `machines.fs` works on a machine's files the way code works on local ones: as
791
+ the machine's user, seeing the same files its commands do, with failures
792
+ raised as the builtin `OSError`s.
793
+
794
+ ```python
795
+ fs = boxd.machines.fs
796
+
797
+ fs.write(id, "/app/config.json", '{"debug": true}') # creates missing parents
798
+ fs.write(id, "big.bin", chunks) # or stream an iterable of bytes
799
+ data = fs.read(id, "/app/config.json") # bytes
800
+ for chunk in fs.read_stream(id, "big.bin"): ... # without holding it all
801
+
802
+ entry = fs.stat(id, "/app") # FsEntry: name, path, type, size, mode, mtime, ...
803
+ entry.is_dir, entry.is_symlink
804
+ fs.list(id, "/app") # one level, every entry
805
+ fs.exists(id, "/app/missing") # False
806
+ fs.mkdir(id, "/app/a/b") # mkdir -p
807
+ fs.rename(id, "/app/a", "/app/c")
808
+ fs.remove(id, "/app/c", recursive=True)
809
+ fs.realpath(id, "/app/link")
810
+
811
+ try:
812
+ fs.read(id, "/app/nope")
813
+ except FileNotFoundError:
814
+ ...
815
+ ```
816
+
817
+ A write replaces the file all at once, so readers see the old contents or the
818
+ new, never a mix; only a file that can't be swapped by a rename (several hard
819
+ links, another user's file, a mount point) is overwritten in place. It writes
820
+ through a symlink to its target, and an existing file keeps its mode and owner
821
+ (or takes `mode=`). Only regular files can be read. Paths are `str`, `bytes` or
822
+ `os.PathLike`; a relative path starts in the user's home directory, and names
823
+ that aren't valid UTF-8 round-trip. Errors are
824
+ `MachineFilesystemError` subclasses that are also `FileNotFoundError`,
825
+ `PermissionError`, `FileExistsError`, `IsADirectoryError` or
826
+ `NotADirectoryError`, with `errno` and `filename` set. A machine started before
827
+ `machines.fs` existed raises `MachineAgentOutdatedError` until it is rebooted.
828
+
706
829
  ### Ports and proxies
707
830
 
708
831
  ```python
@@ -794,18 +917,28 @@ boxd.secrets.delete("API_TOKEN", scope="shared")
794
917
  ```
795
918
 
796
919
  A secret can be bound to the hosts it may be sent to. The machine then only
797
- ever holds a placeholder, and the real value is substituted into requests to
798
- those hosts on the way out:
920
+ ever holds a placeholder, and the real value is substituted into HTTPS
921
+ requests to those hosts on the way out:
799
922
 
800
923
  ```python
801
924
  boxd.secrets.set("STRIPE_KEY", "sk_live_...", scope="all", domains=["api.stripe.com"])
802
925
  ```
803
926
 
804
- Inside the machine, `STRIPE_KEY` is an opaque `bxds_…` string. A request to
805
- `api.stripe.com` carrying it, in a header, the query, the body or Basic auth,
806
- arrives at Stripe with the real key; a request anywhere else carries the
807
- useless placeholder. Nothing to configure in the machine, and the placeholder
808
- does not change when the value is rotated. `domains` is the full desired set:
927
+ Inside the machine, `STRIPE_KEY` is an opaque `bxds_…` string. An HTTPS
928
+ request to `api.stripe.com` carrying it anywhere (the URL, a header, Basic auth
929
+ included, or the body) arrives at Stripe with the real key. It is replaced only
930
+ over HTTPS, and only when both the server name the connection was opened for
931
+ and the request's own `Host` are hosts the secret is bound to: over plain HTTP
932
+ and in a request to any other host it stays the useless placeholder. A body
933
+ over 16 MiB with a `Content-Length` is sent as written, placeholder included.
934
+ Responses on those connections carry the placeholder instead of the value:
935
+ wherever the real value (8 characters or more) appears in a response's headers
936
+ or body exactly as it was sent, it is replaced, so a server that echoes the
937
+ request doesn't hand the value to the machine. A value the server sends back
938
+ re-encoded (base64, URL- or JSON-escaped) isn't caught, and neither is one in
939
+ a body compressed with anything but gzip or deflate (the proxy asks for an
940
+ uncompressed response). Nothing to configure in the machine, and the
941
+ placeholder does not change when the value is rotated. `domains` is the full desired set:
809
942
  setting the secret again without it makes it a plain secret. Wildcards such as
810
943
  `*.stripe.com` are accepted; provider-wide ones such as `*.amazonaws.com` are
811
944
  refused.
@@ -826,7 +959,7 @@ Scope decides which machines a name applies to:
826
959
  | `private` | only your machines in that organization |
827
960
  | `shared` | the organization's shared machines |
828
961
  | `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 |
962
+ | `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 |
830
963
  | `private-unattached` | secrets only: your own unattached secret in the organization named by `org` |
831
964
 
832
965
  `list` reports the organization's unattached secrets as `unattached` and your
@@ -1098,7 +1231,7 @@ from boxd import (
1098
1231
  AuthenticationError, # no usable credential, or it was rejected
1099
1232
  PermissionDeniedError, # authenticated, but not allowed
1100
1233
  NotFoundError,
1101
- ConflictError, # already exists, or fights the current state
1234
+ ConflictError, # already exists, fights the current state, or it changed meanwhile
1102
1235
  RateLimitError, # rate limit or quota
1103
1236
  APIStatusError, # any other error from the server
1104
1237
  APIConnectionError, # could not reach the server
@@ -1112,9 +1245,14 @@ except NotFoundError:
1112
1245
 
1113
1246
  Every error carries `.message`, `.code` (the canonical status name, e.g.
1114
1247
  `"not_found"`) and `.grpc_code`, the numeric
1115
- [status code](https://grpc.github.io/grpc/core/md_doc_statuscodes.html).
1116
-
1117
- Connection failures are retried with exponential backoff, `max_retries` times.
1248
+ [status code](https://grpc.github.io/grpc/core/md_doc_statuscodes.html). Some
1249
+ also carry `.reason`, a name for the exact failure (`"invalid_api_key"`,
1250
+ `"is_a_directory"`, ...), and errors from the key exchange carry
1251
+ `.http_status`. `machines.fs` errors are also `OSError`s (see
1252
+ [Filesystem](#filesystem)).
1253
+
1254
+ Connection failures are retried with exponential backoff, `max_retries` times,
1255
+ including failing to reach the key exchange or a machine's automatic credential.
1118
1256
  Timeouts are never retried — the server may already have applied the request —
1119
1257
  and neither is `AuthenticationError`.
1120
1258