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.
- {boxd-0.2.19.dev910/src/boxd.egg-info → boxd-0.2.20.dev929}/PKG-INFO +74 -6
- {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/README.md +73 -5
- {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/pyproject.toml +5 -1
- {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd/__init__.py +18 -0
- {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd/_credentials.py +110 -29
- boxd-0.2.20.dev929/src/boxd/_generated/api_pb2.py +668 -0
- {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd/_generated/api_pb2_grpc.py +428 -1
- {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd/_mappers.py +40 -0
- {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd/_requests.py +104 -9
- {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd/_transport.py +24 -3
- boxd-0.2.20.dev929/src/boxd/errors.py +324 -0
- {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd/models.py +67 -4
- boxd-0.2.20.dev929/src/boxd/py.typed +0 -0
- boxd-0.2.20.dev929/src/boxd/resources/fs.py +434 -0
- {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd/resources/machines.py +125 -28
- {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd/spec.py +4 -2
- {boxd-0.2.19.dev910 → boxd-0.2.20.dev929/src/boxd.egg-info}/PKG-INFO +74 -6
- {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd.egg-info/SOURCES.txt +3 -0
- {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/tests/test_credentials.py +236 -15
- {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/tests/test_delete_self.py +12 -2
- boxd-0.2.20.dev929/tests/test_fs.py +551 -0
- {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/tests/test_mappers.py +12 -0
- {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/tests/test_namespaces.py +153 -1
- {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/tests/test_requests.py +25 -4
- {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/tests/test_transport.py +97 -0
- boxd-0.2.19.dev910/src/boxd/_generated/api_pb2.py +0 -626
- boxd-0.2.19.dev910/src/boxd/errors.py +0 -101
- {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/LICENSE +0 -0
- {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/setup.cfg +0 -0
- {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd/_client.py +0 -0
- {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd/_generated/__init__.py +0 -0
- {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd/_streaming.py +0 -0
- {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd/_urls.py +0 -0
- {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd/_version_check.py +0 -0
- {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd/resources/__init__.py +0 -0
- {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd/resources/account.py +0 -0
- {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd/resources/credentials.py +0 -0
- {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd/resources/disks.py +0 -0
- {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd/resources/domains.py +0 -0
- {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd/resources/orgs.py +0 -0
- {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd/resources/snapshots.py +0 -0
- {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd/resources/teams.py +0 -0
- {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd/resources/vars.py +0 -0
- {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd.egg-info/dependency_links.txt +0 -0
- {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd.egg-info/requires.txt +0 -0
- {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/src/boxd.egg-info/top_level.txt +0 -0
- {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/tests/test_python_versions.py +0 -0
- {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/tests/test_spec.py +0 -0
- {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/tests/test_streaming.py +0 -0
- {boxd-0.2.19.dev910 → boxd-0.2.20.dev929}/tests/test_urls.py +0 -0
- {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.
|
|
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, #
|
|
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.
|
|
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
|
-
|
|
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, #
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
152
|
+
raise _missing(not_inside)
|
|
126
153
|
gateway = detect_gateway()
|
|
127
|
-
if not gateway
|
|
128
|
-
raise
|
|
129
|
-
|
|
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(
|
|
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
|
|
167
|
+
raise _missing(not_inside)
|
|
136
168
|
gateway = detect_gateway()
|
|
137
|
-
if not gateway
|
|
138
|
-
raise
|
|
139
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
)
|