boxd 0.2.19.dev910__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.dev910/src/boxd.egg-info → boxd-0.2.20.dev929}/PKG-INFO +74 -6
  2. {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/README.md +73 -5
  3. {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/pyproject.toml +5 -1
  4. {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd/__init__.py +18 -0
  5. {boxd-0.2.19.dev910 → 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.dev910 → boxd-0.2.20.dev929}/src/boxd/_generated/api_pb2_grpc.py +428 -1
  8. {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd/_mappers.py +40 -0
  9. {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd/_requests.py +104 -9
  10. {boxd-0.2.19.dev910 → 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.dev910 → boxd-0.2.20.dev929}/src/boxd/models.py +67 -4
  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.dev910 → boxd-0.2.20.dev929}/src/boxd/resources/machines.py +125 -28
  16. {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd/spec.py +4 -2
  17. {boxd-0.2.19.dev910 → boxd-0.2.20.dev929/src/boxd.egg-info}/PKG-INFO +74 -6
  18. {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd.egg-info/SOURCES.txt +3 -0
  19. {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/tests/test_credentials.py +236 -15
  20. {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/tests/test_delete_self.py +12 -2
  21. boxd-0.2.20.dev929/tests/test_fs.py +551 -0
  22. {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/tests/test_mappers.py +12 -0
  23. {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/tests/test_namespaces.py +153 -1
  24. {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/tests/test_requests.py +25 -4
  25. {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/tests/test_transport.py +97 -0
  26. boxd-0.2.19.dev910/src/boxd/_generated/api_pb2.py +0 -626
  27. boxd-0.2.19.dev910/src/boxd/errors.py +0 -101
  28. {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/LICENSE +0 -0
  29. {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/setup.cfg +0 -0
  30. {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd/_client.py +0 -0
  31. {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd/_generated/__init__.py +0 -0
  32. {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd/_streaming.py +0 -0
  33. {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd/_urls.py +0 -0
  34. {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd/_version_check.py +0 -0
  35. {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd/resources/__init__.py +0 -0
  36. {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd/resources/account.py +0 -0
  37. {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd/resources/credentials.py +0 -0
  38. {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd/resources/disks.py +0 -0
  39. {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd/resources/domains.py +0 -0
  40. {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd/resources/orgs.py +0 -0
  41. {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd/resources/snapshots.py +0 -0
  42. {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd/resources/teams.py +0 -0
  43. {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd/resources/vars.py +0 -0
  44. {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd.egg-info/dependency_links.txt +0 -0
  45. {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd.egg-info/requires.txt +0 -0
  46. {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd.egg-info/top_level.txt +0 -0
  47. {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/tests/test_python_versions.py +0 -0
  48. {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/tests/test_spec.py +0 -0
  49. {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/tests/test_streaming.py +0 -0
  50. {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/tests/test_urls.py +0 -0
  51. {boxd-0.2.19.dev910 → 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.dev910
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
@@ -119,6 +119,12 @@ The first of these that is present wins:
119
119
  If your key is revoked mid-session, the SDK fails fast with
120
120
  `AuthenticationError` rather than retrying.
121
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
+
122
128
  The exchange endpoint is **rate-limited per source IP** (30 requests / 60s). A
123
129
  session token is valid for one hour, so a fleet of workers behind one NAT
124
130
  should **mint one token and share it** (`Boxd(token=...)`) rather than each
@@ -142,6 +148,9 @@ One limit: inside a **shared** machine the automatic credential can manage the
142
148
  organization's shared machines, but cannot read environment variables or
143
149
  secrets, and cannot reach private machines. Pass an API key for those.
144
150
 
151
+ An **isolated** machine has no automatic credential: `Boxd()` raises
152
+ `AuthenticationError` there, so pass an API key.
153
+
145
154
  ## Sync and async
146
155
 
147
156
  `Boxd` and `AsyncBoxd` are the same surface — same namespaces, same method
@@ -205,7 +214,7 @@ boxd.machines.create(
205
214
  disk="100G",
206
215
  auto_suspend_timeout=300, # seconds; 0 disables
207
216
  auto_destroy_timeout=0,
208
- ssh=True, # give the machine an SSH port
217
+ ssh=True, # False: SSH off, nothing answers on its port
209
218
  proxies=[ProxyEntry(name="api", port=3000)],
210
219
  volumes=[VolumeMount(disk_id="d_...", mount_path="/data", read_only=False)],
211
220
  )
@@ -239,6 +248,7 @@ boxd.machines.unshare(id)
239
248
  boxd.machines.set_auto_suspend_timeout(id, 300) # seconds idle; 0 disables
240
249
  boxd.machines.set_auto_hibernate_timeout(id, 0)
241
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
242
252
  boxd.machines.wait_until_ready(id)
243
253
  boxd.machines.wait_until_ready(id, timeout=180.0, poll_interval=1.0) # seconds
244
254
  boxd.machines.suggest_name()
@@ -247,6 +257,14 @@ boxd.machines.suggest_name()
247
257
  A fork inherits the source's sizing for anything you leave unset, and is
248
258
  private to you unless you pass `shared=True`.
249
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
+
250
268
  `create` and `fork` return once the machine is scheduled, not once it is
251
269
  usable. Call `wait_until_ready` before doing anything that depends on it
252
270
  running — especially before forking it again.
@@ -536,6 +554,7 @@ A machine can be limited to what it may reach on the internet:
536
554
  ```python
537
555
  boxd.machines.set_egress_allow("alpha", ["api.stripe.com", "*.github.com", "203.0.113.0/24"])
538
556
  boxd.machines.get("alpha").egress_allow
557
+ boxd.machines.set_egress_allow("alpha", ["none"]) # nothing is reachable
539
558
  boxd.machines.set_egress_allow("alpha", []) # clear: unrestricted again
540
559
  ```
541
560
 
@@ -545,7 +564,9 @@ becomes `xn--bcher-kva.de`), which is what the machine looks up. Web
545
564
  requests are admitted by hostname, everything else by address, with the
546
565
  addresses of allowlisted names learned as the machine resolves them. Nothing
547
566
  that is not named is reachable. The hosts of any bound secret the machine
548
- holds are always admitted. Takes effect within a second. From inside the
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
549
570
  machine it can be read (`boxd machine egress`) but not changed. A fork inherits its source's allowlist; a
550
571
  machine restored from a snapshot starts unrestricted, unless its machine spec
551
572
  lists `egress`.
@@ -613,6 +634,7 @@ machine.shared # shared with that org, or private to you
613
634
  machine.access.ssh_port # int | None — None until allocated
614
635
  machine.access.domain
615
636
  machine.access.url # https://<name>.<domain>
637
+ machine.ssh # bool — False: SSH is off, nothing answers on ssh_port
616
638
 
617
639
  machine.idle.suspend_after # seconds; 0 = that timer is disabled
618
640
  machine.idle.hibernate_after
@@ -763,6 +785,47 @@ relative to the VM user's home directory (`/home/boxd`) — a bare filename
763
785
  like `"notes.txt"` resolves to `/home/boxd/notes.txt`, same as `"./notes.txt"`
764
786
  or `"sub/notes.txt"` would.
765
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
+
766
829
  ### Ports and proxies
767
830
 
768
831
  ```python
@@ -1182,9 +1245,14 @@ except NotFoundError:
1182
1245
 
1183
1246
  Every error carries `.message`, `.code` (the canonical status name, e.g.
1184
1247
  `"not_found"`) and `.grpc_code`, the numeric
1185
- [status code](https://grpc.github.io/grpc/core/md_doc_statuscodes.html).
1186
-
1187
- 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.
1188
1256
  Timeouts are never retried — the server may already have applied the request —
1189
1257
  and neither is `AuthenticationError`.
1190
1258
 
@@ -84,6 +84,12 @@ The first of these that is present wins:
84
84
  If your key is revoked mid-session, the SDK fails fast with
85
85
  `AuthenticationError` rather than retrying.
86
86
 
87
+ `AuthenticationError` means only that no credential was found or boxd refused
88
+ it; its `.reason` says which (`"missing_credentials"`, `"invalid_api_key"`,
89
+ `"invalid_token"`). Failing to reach boxd is an `APIConnectionError`, an
90
+ account that can't use the API a `PermissionDeniedError`, and an error answer
91
+ from the key exchange an `APIStatusError` with its `.http_status`.
92
+
87
93
  The exchange endpoint is **rate-limited per source IP** (30 requests / 60s). A
88
94
  session token is valid for one hour, so a fleet of workers behind one NAT
89
95
  should **mint one token and share it** (`Boxd(token=...)`) rather than each
@@ -107,6 +113,9 @@ One limit: inside a **shared** machine the automatic credential can manage the
107
113
  organization's shared machines, but cannot read environment variables or
108
114
  secrets, and cannot reach private machines. Pass an API key for those.
109
115
 
116
+ An **isolated** machine has no automatic credential: `Boxd()` raises
117
+ `AuthenticationError` there, so pass an API key.
118
+
110
119
  ## Sync and async
111
120
 
112
121
  `Boxd` and `AsyncBoxd` are the same surface — same namespaces, same method
@@ -170,7 +179,7 @@ boxd.machines.create(
170
179
  disk="100G",
171
180
  auto_suspend_timeout=300, # seconds; 0 disables
172
181
  auto_destroy_timeout=0,
173
- ssh=True, # give the machine an SSH port
182
+ ssh=True, # False: SSH off, nothing answers on its port
174
183
  proxies=[ProxyEntry(name="api", port=3000)],
175
184
  volumes=[VolumeMount(disk_id="d_...", mount_path="/data", read_only=False)],
176
185
  )
@@ -204,6 +213,7 @@ boxd.machines.unshare(id)
204
213
  boxd.machines.set_auto_suspend_timeout(id, 300) # seconds idle; 0 disables
205
214
  boxd.machines.set_auto_hibernate_timeout(id, 0)
206
215
  boxd.machines.set_idle_ignore_outbound(id, True) # only inbound connections keep it awake
216
+ boxd.machines.set_ssh(id, False) # nothing answers SSH; exec still works
207
217
  boxd.machines.wait_until_ready(id)
208
218
  boxd.machines.wait_until_ready(id, timeout=180.0, poll_interval=1.0) # seconds
209
219
  boxd.machines.suggest_name()
@@ -212,6 +222,14 @@ boxd.machines.suggest_name()
212
222
  A fork inherits the source's sizing for anything you leave unset, and is
213
223
  private to you unless you pass `shared=True`.
214
224
 
225
+ With SSH off (`ssh=False` on create, or `set_ssh(id, False)`), nothing
226
+ answers SSH on the machine's port, on the public address or the org's
227
+ network, and SSH sessions already open to it are closed. The port stays
228
+ allocated, so turning SSH back on keeps the address. `exec`, `boxd connect`
229
+ and the console terminal don't use SSH and keep working. A fork or a restore
230
+ keeps its source's setting unless you pass `ssh`. `set_ssh` can't be called
231
+ from inside the machine itself.
232
+
215
233
  `create` and `fork` return once the machine is scheduled, not once it is
216
234
  usable. Call `wait_until_ready` before doing anything that depends on it
217
235
  running — especially before forking it again.
@@ -501,6 +519,7 @@ A machine can be limited to what it may reach on the internet:
501
519
  ```python
502
520
  boxd.machines.set_egress_allow("alpha", ["api.stripe.com", "*.github.com", "203.0.113.0/24"])
503
521
  boxd.machines.get("alpha").egress_allow
522
+ boxd.machines.set_egress_allow("alpha", ["none"]) # nothing is reachable
504
523
  boxd.machines.set_egress_allow("alpha", []) # clear: unrestricted again
505
524
  ```
506
525
 
@@ -510,7 +529,9 @@ becomes `xn--bcher-kva.de`), which is what the machine looks up. Web
510
529
  requests are admitted by hostname, everything else by address, with the
511
530
  addresses of allowlisted names learned as the machine resolves them. Nothing
512
531
  that is not named is reachable. The hosts of any bound secret the machine
513
- holds are always admitted. Takes effect within a second. From inside the
532
+ holds are always admitted. `"none"` on its own leaves the allowlist on with
533
+ nothing in it, so the machine reaches only those; next to other entries it is
534
+ dropped. Takes effect within a second. From inside the
514
535
  machine it can be read (`boxd machine egress`) but not changed. A fork inherits its source's allowlist; a
515
536
  machine restored from a snapshot starts unrestricted, unless its machine spec
516
537
  lists `egress`.
@@ -578,6 +599,7 @@ machine.shared # shared with that org, or private to you
578
599
  machine.access.ssh_port # int | None — None until allocated
579
600
  machine.access.domain
580
601
  machine.access.url # https://<name>.<domain>
602
+ machine.ssh # bool — False: SSH is off, nothing answers on ssh_port
581
603
 
582
604
  machine.idle.suspend_after # seconds; 0 = that timer is disabled
583
605
  machine.idle.hibernate_after
@@ -728,6 +750,47 @@ relative to the VM user's home directory (`/home/boxd`) — a bare filename
728
750
  like `"notes.txt"` resolves to `/home/boxd/notes.txt`, same as `"./notes.txt"`
729
751
  or `"sub/notes.txt"` would.
730
752
 
753
+ ### Filesystem
754
+
755
+ `machines.fs` works on a machine's files the way code works on local ones: as
756
+ the machine's user, seeing the same files its commands do, with failures
757
+ raised as the builtin `OSError`s.
758
+
759
+ ```python
760
+ fs = boxd.machines.fs
761
+
762
+ fs.write(id, "/app/config.json", '{"debug": true}') # creates missing parents
763
+ fs.write(id, "big.bin", chunks) # or stream an iterable of bytes
764
+ data = fs.read(id, "/app/config.json") # bytes
765
+ for chunk in fs.read_stream(id, "big.bin"): ... # without holding it all
766
+
767
+ entry = fs.stat(id, "/app") # FsEntry: name, path, type, size, mode, mtime, ...
768
+ entry.is_dir, entry.is_symlink
769
+ fs.list(id, "/app") # one level, every entry
770
+ fs.exists(id, "/app/missing") # False
771
+ fs.mkdir(id, "/app/a/b") # mkdir -p
772
+ fs.rename(id, "/app/a", "/app/c")
773
+ fs.remove(id, "/app/c", recursive=True)
774
+ fs.realpath(id, "/app/link")
775
+
776
+ try:
777
+ fs.read(id, "/app/nope")
778
+ except FileNotFoundError:
779
+ ...
780
+ ```
781
+
782
+ A write replaces the file all at once, so readers see the old contents or the
783
+ new, never a mix; only a file that can't be swapped by a rename (several hard
784
+ links, another user's file, a mount point) is overwritten in place. It writes
785
+ through a symlink to its target, and an existing file keeps its mode and owner
786
+ (or takes `mode=`). Only regular files can be read. Paths are `str`, `bytes` or
787
+ `os.PathLike`; a relative path starts in the user's home directory, and names
788
+ that aren't valid UTF-8 round-trip. Errors are
789
+ `MachineFilesystemError` subclasses that are also `FileNotFoundError`,
790
+ `PermissionError`, `FileExistsError`, `IsADirectoryError` or
791
+ `NotADirectoryError`, with `errno` and `filename` set. A machine started before
792
+ `machines.fs` existed raises `MachineAgentOutdatedError` until it is rebooted.
793
+
731
794
  ### Ports and proxies
732
795
 
733
796
  ```python
@@ -1147,9 +1210,14 @@ except NotFoundError:
1147
1210
 
1148
1211
  Every error carries `.message`, `.code` (the canonical status name, e.g.
1149
1212
  `"not_found"`) and `.grpc_code`, the numeric
1150
- [status code](https://grpc.github.io/grpc/core/md_doc_statuscodes.html).
1151
-
1152
- Connection failures are retried with exponential backoff, `max_retries` times.
1213
+ [status code](https://grpc.github.io/grpc/core/md_doc_statuscodes.html). Some
1214
+ also carry `.reason`, a name for the exact failure (`"invalid_api_key"`,
1215
+ `"is_a_directory"`, ...), and errors from the key exchange carry
1216
+ `.http_status`. `machines.fs` errors are also `OSError`s (see
1217
+ [Filesystem](#filesystem)).
1218
+
1219
+ Connection failures are retried with exponential backoff, `max_retries` times,
1220
+ including failing to reach the key exchange or a machine's automatic credential.
1153
1221
  Timeouts are never retried — the server may already have applied the request —
1154
1222
  and neither is `AuthenticationError`.
1155
1223
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "boxd"
3
- version = "0.2.19.dev910"
3
+ version = "0.2.20.dev929"
4
4
  description = "Python SDK for the boxd cloud VM platform"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -69,6 +69,10 @@ build-backend = "setuptools.build_meta"
69
69
  [tool.setuptools.packages.find]
70
70
  where = ["src"]
71
71
 
72
+ # PEP 561: ship the marker so type checkers read the inline annotations.
73
+ [tool.setuptools.package-data]
74
+ boxd = ["py.typed"]
75
+
72
76
  [tool.pytest.ini_options]
73
77
  asyncio_mode = "auto"
74
78
  asyncio_default_fixture_loop_scope = "session"
@@ -26,6 +26,13 @@ from .errors import (
26
26
  AuthenticationError,
27
27
  BoxdError,
28
28
  ConflictError,
29
+ MachineAgentOutdatedError,
30
+ MachineFileExistsError,
31
+ MachineFileNotFoundError,
32
+ MachineFilesystemError,
33
+ MachineIsADirectoryError,
34
+ MachineNotADirectoryError,
35
+ MachinePermissionError,
29
36
  NotFoundError,
30
37
  PermissionDeniedError,
31
38
  RateLimitError,
@@ -51,6 +58,8 @@ from .models import (
51
58
  DiskAttachment,
52
59
  DiskStatus,
53
60
  EnvVar,
61
+ FsEntry,
62
+ FsEntryType,
54
63
  ExecResult,
55
64
  SelfDeleteResult,
56
65
  ExposedPort,
@@ -161,6 +170,8 @@ __all__ = [
161
170
  "DiskStatus",
162
171
  "EnvVar",
163
172
  "ExecResult",
173
+ "FsEntry",
174
+ "FsEntryType",
164
175
  "SelfDeleteResult",
165
176
  "ExposedPort",
166
177
  "BillingActivityEntry",
@@ -235,6 +246,13 @@ __all__ = [
235
246
  "AuthenticationError",
236
247
  "BoxdError",
237
248
  "ConflictError",
249
+ "MachineAgentOutdatedError",
250
+ "MachineFileExistsError",
251
+ "MachineFileNotFoundError",
252
+ "MachineFilesystemError",
253
+ "MachineIsADirectoryError",
254
+ "MachineNotADirectoryError",
255
+ "MachinePermissionError",
238
256
  "NotFoundError",
239
257
  "PermissionDeniedError",
240
258
  "RateLimitError",
@@ -10,7 +10,7 @@ Resolution order, first match wins:
10
10
  2. explicit ``api_key`` — exchanged for a JWT
11
11
  3. ``BOXD_TOKEN``, then ``BOXD_API_KEY`` — same handling
12
12
  4. in-VM metadata adapter — no credentials required
13
- 5. otherwise :class:`AuthenticationError`
13
+ 5. otherwise :class:`AuthenticationError` (reason ``"missing_credentials"``)
14
14
 
15
15
  Steps 4 and 5 need I/O, so the decision lands on the first request rather than
16
16
  in ``__init__``: constructing a client never blocks on a network probe.
@@ -26,7 +26,13 @@ from typing import Any
26
26
 
27
27
  import httpx
28
28
 
29
- from .errors import AuthenticationError, RateLimitError
29
+ from .errors import (
30
+ APIConnectionError,
31
+ APIStatusError,
32
+ AuthenticationError,
33
+ PermissionDeniedError,
34
+ RateLimitError,
35
+ )
30
36
 
31
37
  # The in-VM metadata adapter listens on the VM's default gateway.
32
38
  METADATA_PORT = 9002
@@ -48,6 +54,15 @@ _NO_CREDENTIALS = (
48
54
  "no credentials: pass api_key= or token=, set BOXD_API_KEY / BOXD_TOKEN, "
49
55
  "or run inside a boxd VM"
50
56
  )
57
+ # The environment says "machine" and there is a gateway, but nothing answered
58
+ # on it. An isolated machine looks exactly like this (it keeps the hints but
59
+ # has no metadata adapter), and so does an adapter outage: there is no telling
60
+ # them apart from here, so this stays terminal and says what to do instead.
61
+ _ADAPTER_SILENT = (
62
+ "no credentials: the machine's metadata adapter at {url} did not answer "
63
+ "(isolated machines have none); pass api_key= or token=, or set "
64
+ "BOXD_API_KEY / BOXD_TOKEN"
65
+ )
51
66
 
52
67
 
53
68
  # ── In-VM detection ──────────────────────────────────────────────────
@@ -116,61 +131,123 @@ async def probe_async(host: str, port: int, timeout: float = _PROBE_TIMEOUT_SECS
116
131
  return True
117
132
 
118
133
 
119
- def metadata_base(not_inside: str = _NO_CREDENTIALS) -> str:
120
- """The in-VM metadata adapter's base URL, or ``AuthenticationError(not_inside)``
121
- when this code is not running inside a boxd machine. Detection needs BOTH
122
- signals, the environment hint and a live probe — neither alone is
123
- sufficient."""
134
+ def _missing(message: str) -> AuthenticationError:
135
+ return AuthenticationError(
136
+ message, code="unauthenticated", reason="missing_credentials"
137
+ )
138
+
139
+
140
+ def metadata_base(
141
+ not_inside: str = _NO_CREDENTIALS, silent: str = _ADAPTER_SILENT
142
+ ) -> str:
143
+ """The in-VM metadata adapter's base URL, or ``AuthenticationError``
144
+ (reason ``"missing_credentials"``) when there is none to use. Detection
145
+ needs BOTH signals, the environment hint and a live probe — neither alone
146
+ is sufficient.
147
+
148
+ The message is ``not_inside`` when this doesn't look like a machine, and
149
+ ``silent`` (formatted with ``url``) when it does but the adapter didn't
150
+ answer. Nothing is cached: the next call probes again."""
124
151
  if not looks_like_a_machine():
125
- raise AuthenticationError(not_inside)
152
+ raise _missing(not_inside)
126
153
  gateway = detect_gateway()
127
- if not gateway or not probe(gateway, METADATA_PORT):
128
- raise AuthenticationError(not_inside)
129
- return f"http://{gateway}:{METADATA_PORT}"
154
+ if not gateway:
155
+ raise _missing(not_inside)
156
+ base = f"http://{gateway}:{METADATA_PORT}"
157
+ if not probe(gateway, METADATA_PORT):
158
+ raise _missing(silent.format(url=base))
159
+ return base
130
160
 
131
161
 
132
- async def metadata_base_async(not_inside: str = _NO_CREDENTIALS) -> str:
162
+ async def metadata_base_async(
163
+ not_inside: str = _NO_CREDENTIALS, silent: str = _ADAPTER_SILENT
164
+ ) -> str:
133
165
  """Async twin of :func:`metadata_base`."""
134
166
  if not looks_like_a_machine():
135
- raise AuthenticationError(not_inside)
167
+ raise _missing(not_inside)
136
168
  gateway = detect_gateway()
137
- if not gateway or not await probe_async(gateway, METADATA_PORT):
138
- raise AuthenticationError(not_inside)
139
- return f"http://{gateway}:{METADATA_PORT}"
169
+ if not gateway:
170
+ raise _missing(not_inside)
171
+ base = f"http://{gateway}:{METADATA_PORT}"
172
+ if not await probe_async(gateway, METADATA_PORT):
173
+ raise _missing(silent.format(url=base))
174
+ return base
140
175
 
141
176
 
142
177
  # ── Response parsing (shared by both clients) ────────────────────────
143
178
 
144
179
 
145
180
  def parse_exchange(status_code: int, body: str, json: Any) -> tuple[str, float]:
146
- """Validate an API-key exchange response → ``(token, expires_at)``."""
181
+ """Validate an API-key exchange response → ``(token, expires_at)``.
182
+
183
+ Only a 401 means the key itself was refused. An account that can't use
184
+ the API is a 403; anything else the exchange answers is its own failure,
185
+ not the key's.
186
+ """
147
187
  if status_code == 401:
148
- raise AuthenticationError("invalid or expired API key")
188
+ raise AuthenticationError(
189
+ "invalid or expired API key",
190
+ code="unauthenticated",
191
+ reason="invalid_api_key",
192
+ http_status=status_code,
193
+ )
149
194
  if status_code == 429:
150
195
  raise RateLimitError(
151
196
  "token exchange rate limit exceeded"
152
197
  + (f": {body}" if body else "")
153
- + " — a token is valid for 1 hour; reuse one across your workers"
198
+ + " — a token is valid for 1 hour; reuse one across your workers",
199
+ code="resource_exhausted",
200
+ http_status=status_code,
201
+ )
202
+ if status_code == 403:
203
+ raise PermissionDeniedError(
204
+ f"API key refused: {body}" if body else "API key refused",
205
+ code="permission_denied",
206
+ http_status=status_code,
154
207
  )
155
208
  if status_code != 200:
156
- raise AuthenticationError(f"exchange failed: {status_code} {body}")
209
+ raise APIStatusError(
210
+ f"exchange failed: {status_code} {body}", http_status=status_code
211
+ )
157
212
  try:
158
213
  return str(json["token"]), float(json["expires_at"])
159
214
  except (TypeError, KeyError, ValueError) as e:
160
- raise AuthenticationError(f"malformed exchange response: {body}") from e
215
+ raise APIStatusError(
216
+ f"malformed exchange response: {body}", http_status=status_code
217
+ ) from e
161
218
 
162
219
 
163
220
  def parse_vm_token(status_code: int, body: str, json: Any) -> tuple[str, float, str | None]:
164
- """Validate a metadata-adapter token response → ``(token, expires_at, api_url)``."""
221
+ """Validate a metadata-adapter token response → ``(token, expires_at, api_url)``.
222
+
223
+ The adapter authenticates the machine by where the request comes from, so
224
+ it has no "wrong credential" answer: a 403 is a refusal (e.g. a disabled
225
+ account), anything else is the adapter's own failure.
226
+ """
227
+ if status_code == 403:
228
+ raise PermissionDeniedError(
229
+ f"in-VM token request refused: {body}",
230
+ code="permission_denied",
231
+ http_status=status_code,
232
+ )
233
+ if status_code == 429:
234
+ raise RateLimitError(
235
+ f"in-VM token request rate limited: {body}",
236
+ code="resource_exhausted",
237
+ http_status=status_code,
238
+ )
165
239
  if status_code != 200:
166
- raise AuthenticationError(
167
- f"in-VM token request failed: {status_code} {body}"
240
+ raise APIStatusError(
241
+ f"in-VM token request failed: {status_code} {body}",
242
+ http_status=status_code,
168
243
  )
169
244
  try:
170
245
  token = str(json["token"])
171
246
  expires_at = float(json["expires_at"])
172
247
  except (TypeError, KeyError, ValueError) as e:
173
- raise AuthenticationError(f"malformed in-VM token response: {body}") from e
248
+ raise APIStatusError(
249
+ f"malformed in-VM token response: {body}", http_status=status_code
250
+ ) from e
174
251
  # The VM reports its own cluster, which is what lets an unconfigured
175
252
  # `Boxd()` work identically on dev, staging, metal and prod.
176
253
  api_url = json.get("api_url") or None
@@ -250,7 +327,7 @@ class SyncCredentials(_CredentialsBase):
250
327
  self._exchange_url, json={"api_key": self._api_key}
251
328
  )
252
329
  except httpx.HTTPError as e:
253
- raise AuthenticationError(
330
+ raise APIConnectionError(
254
331
  f"could not reach {self._exchange_url}: {e}"
255
332
  ) from e
256
333
  # The exchange endpoint is rate-limited per source IP; a NATed fleet
@@ -269,7 +346,9 @@ class SyncCredentials(_CredentialsBase):
269
346
  with httpx.Client(timeout=_HTTP_TIMEOUT_SECS) as client:
270
347
  resp = client.get(base + METADATA_TOKEN_PATH)
271
348
  except httpx.HTTPError as e:
272
- raise AuthenticationError(f"in-VM token request failed: {e}") from e
349
+ raise APIConnectionError(
350
+ f"could not reach the in-VM metadata adapter: {e}"
351
+ ) from e
273
352
  token, expires_at, api_url = parse_vm_token(
274
353
  resp.status_code, resp.text, _json_or_none(resp)
275
354
  )
@@ -307,7 +386,7 @@ class AsyncCredentials(_CredentialsBase):
307
386
  self._exchange_url, json={"api_key": self._api_key}
308
387
  )
309
388
  except httpx.HTTPError as e:
310
- raise AuthenticationError(
389
+ raise APIConnectionError(
311
390
  f"could not reach {self._exchange_url}: {e}"
312
391
  ) from e
313
392
  # The exchange endpoint is rate-limited per source IP; a NATed fleet
@@ -328,7 +407,9 @@ class AsyncCredentials(_CredentialsBase):
328
407
  base + METADATA_TOKEN_PATH
329
408
  )
330
409
  except httpx.HTTPError as e:
331
- raise AuthenticationError(f"in-VM token request failed: {e}") from e
410
+ raise APIConnectionError(
411
+ f"could not reach the in-VM metadata adapter: {e}"
412
+ ) from e
332
413
  token, expires_at, api_url = parse_vm_token(
333
414
  resp.status_code, resp.text, _json_or_none(resp)
334
415
  )