boxd 0.2.8.dev448__tar.gz → 0.2.9__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.8.dev448/src/boxd.egg-info → boxd-0.2.9}/PKG-INFO +49 -4
- {boxd-0.2.8.dev448 → boxd-0.2.9}/README.md +48 -3
- {boxd-0.2.8.dev448 → boxd-0.2.9}/pyproject.toml +1 -1
- {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd/_credentials.py +53 -20
- boxd-0.2.9/src/boxd/_generated/api_pb2.py +434 -0
- {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd/_generated/api_pb2_grpc.py +96 -2
- {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd/_mappers.py +3 -1
- {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd/_requests.py +13 -2
- {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd/_transport.py +16 -2
- {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd/models.py +13 -0
- {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd/resources/machines.py +64 -9
- {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd/resources/vars.py +38 -6
- {boxd-0.2.8.dev448 → boxd-0.2.9/src/boxd.egg-info}/PKG-INFO +49 -4
- {boxd-0.2.8.dev448 → boxd-0.2.9}/tests/test_credentials.py +39 -2
- {boxd-0.2.8.dev448 → boxd-0.2.9}/tests/test_mappers.py +26 -0
- {boxd-0.2.8.dev448 → boxd-0.2.9}/tests/test_namespaces.py +30 -1
- {boxd-0.2.8.dev448 → boxd-0.2.9}/tests/test_requests.py +16 -0
- boxd-0.2.8.dev448/src/boxd/_generated/api_pb2.py +0 -428
- {boxd-0.2.8.dev448 → boxd-0.2.9}/LICENSE +0 -0
- {boxd-0.2.8.dev448 → boxd-0.2.9}/setup.cfg +0 -0
- {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd/__init__.py +0 -0
- {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd/_client.py +0 -0
- {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd/_generated/__init__.py +0 -0
- {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd/_streaming.py +0 -0
- {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd/_urls.py +0 -0
- {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd/_version_check.py +0 -0
- {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd/errors.py +0 -0
- {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd/resources/__init__.py +0 -0
- {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd/resources/account.py +0 -0
- {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd/resources/credentials.py +0 -0
- {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd/resources/disks.py +0 -0
- {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd/resources/domains.py +0 -0
- {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd/resources/orgs.py +0 -0
- {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd/resources/snapshots.py +0 -0
- {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd.egg-info/SOURCES.txt +0 -0
- {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd.egg-info/dependency_links.txt +0 -0
- {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd.egg-info/requires.txt +0 -0
- {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd.egg-info/top_level.txt +0 -0
- {boxd-0.2.8.dev448 → boxd-0.2.9}/tests/test_streaming.py +0 -0
- {boxd-0.2.8.dev448 → boxd-0.2.9}/tests/test_transport.py +0 -0
- {boxd-0.2.8.dev448 → boxd-0.2.9}/tests/test_urls.py +0 -0
- {boxd-0.2.8.dev448 → boxd-0.2.9}/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.9
|
|
4
4
|
Summary: Python SDK for the boxd cloud VM platform
|
|
5
5
|
Author: Azin
|
|
6
6
|
License-Expression: MIT
|
|
@@ -112,6 +112,12 @@ The first of these that is present wins:
|
|
|
112
112
|
If your key is revoked mid-session, the SDK fails fast with
|
|
113
113
|
`AuthenticationError` rather than retrying.
|
|
114
114
|
|
|
115
|
+
The exchange endpoint is **rate-limited per source IP** (30 requests / 60s). A
|
|
116
|
+
session token is valid for one hour, so a fleet of workers behind one NAT
|
|
117
|
+
should **mint one token and share it** (`Boxd(token=...)`) rather than each
|
|
118
|
+
process exchanging its own key. On a 429 the SDK waits out one `Retry-After`
|
|
119
|
+
before raising `RateLimitError`.
|
|
120
|
+
|
|
115
121
|
### Inside a machine
|
|
116
122
|
|
|
117
123
|
Inside a boxd machine, `Boxd()` authenticates automatically — no API key
|
|
@@ -237,6 +243,24 @@ private to you unless you pass `shared=True`.
|
|
|
237
243
|
usable. Call `wait_until_ready` before doing anything that depends on it
|
|
238
244
|
running — especially before forking it again.
|
|
239
245
|
|
|
246
|
+
### Egress allowlist
|
|
247
|
+
|
|
248
|
+
A machine can be limited to what it may reach on the internet:
|
|
249
|
+
|
|
250
|
+
```python
|
|
251
|
+
boxd.machines.set_egress_allow("alpha", ["api.stripe.com", "*.github.com", "203.0.113.0/24"])
|
|
252
|
+
boxd.machines.get("alpha").egress_allow
|
|
253
|
+
boxd.machines.set_egress_allow("alpha", []) # clear: unrestricted again
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Entries are hostnames or wildcards, or public IPv4 addresses and CIDRs. Web
|
|
257
|
+
requests are admitted by hostname, everything else by address, with the
|
|
258
|
+
addresses of allowlisted names learned as the machine resolves them. Nothing
|
|
259
|
+
that is not named is reachable. The hosts of any bound secret the machine
|
|
260
|
+
holds are always admitted. Takes effect within a second and is not visible or
|
|
261
|
+
changeable from inside the machine. A fork inherits its source's allowlist; a
|
|
262
|
+
machine restored from a snapshot starts unrestricted.
|
|
263
|
+
|
|
240
264
|
### Sizing
|
|
241
265
|
|
|
242
266
|
There are three sizes: 1 vCPU/4G, 2/8G, 4/16G. Name either dimension and the
|
|
@@ -400,7 +424,9 @@ data = boxd.machines.files.download(id, "/app/output.json") # bytes
|
|
|
400
424
|
```
|
|
401
425
|
|
|
402
426
|
`upload` takes `str` or `bytes`, streams it in chunks so large files are fine,
|
|
403
|
-
and returns the number of bytes the machine confirmed it wrote.
|
|
427
|
+
and returns the number of bytes the machine confirmed it wrote. `download`
|
|
428
|
+
streams too and returns the whole file as `bytes` — there is no size cap, but
|
|
429
|
+
it is all held in memory.
|
|
404
430
|
|
|
405
431
|
### Ports and proxies
|
|
406
432
|
|
|
@@ -475,13 +501,32 @@ boxd.env.list(org="acme")
|
|
|
475
501
|
boxd.env.delete("MODE", scope="all")
|
|
476
502
|
|
|
477
503
|
boxd.secrets.set("API_TOKEN", "s3cr3t", scope="shared")
|
|
478
|
-
boxd.secrets.list() # Secret(name, scope) — no value
|
|
504
|
+
boxd.secrets.list() # Secret(name, scope, domains) — no value
|
|
479
505
|
boxd.secrets.delete("API_TOKEN", scope="shared")
|
|
480
506
|
```
|
|
481
507
|
|
|
508
|
+
A secret can be bound to the hosts it may be sent to. The machine then only
|
|
509
|
+
ever holds a placeholder, and the real value is substituted into requests to
|
|
510
|
+
those hosts on the way out:
|
|
511
|
+
|
|
512
|
+
```python
|
|
513
|
+
boxd.secrets.set("STRIPE_KEY", "sk_live_...", scope="all", domains=["api.stripe.com"])
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
Inside the machine, `STRIPE_KEY` is an opaque `bxds_…` string. A request to
|
|
517
|
+
`api.stripe.com` carrying it, in a header, the query, the body or Basic auth,
|
|
518
|
+
arrives at Stripe with the real key; a request anywhere else carries the
|
|
519
|
+
useless placeholder. Nothing to configure in the machine, and the placeholder
|
|
520
|
+
does not change when the value is rotated. `domains` is the full desired set:
|
|
521
|
+
setting the secret again without it makes it a plain secret. Wildcards such as
|
|
522
|
+
`*.stripe.com` are accepted; provider-wide ones such as `*.amazonaws.com` are
|
|
523
|
+
refused.
|
|
524
|
+
|
|
482
525
|
`scope` defaults to `"shared"` on `set` and `delete`; `list` takes `org` only
|
|
483
526
|
and reports every scope. Pass `org="acme"` to any of these to work in an
|
|
484
|
-
organization instead of your personal scope.
|
|
527
|
+
organization instead of your personal scope. With an API key minted in an
|
|
528
|
+
organization your personal scope already lives there, so `org` is only needed
|
|
529
|
+
to reach the organization's `shared`/`all` vars.
|
|
485
530
|
|
|
486
531
|
`set`, `delete` and `move` each return the server's human-readable
|
|
487
532
|
confirmation of what it did.
|
|
@@ -80,6 +80,12 @@ The first of these that is present wins:
|
|
|
80
80
|
If your key is revoked mid-session, the SDK fails fast with
|
|
81
81
|
`AuthenticationError` rather than retrying.
|
|
82
82
|
|
|
83
|
+
The exchange endpoint is **rate-limited per source IP** (30 requests / 60s). A
|
|
84
|
+
session token is valid for one hour, so a fleet of workers behind one NAT
|
|
85
|
+
should **mint one token and share it** (`Boxd(token=...)`) rather than each
|
|
86
|
+
process exchanging its own key. On a 429 the SDK waits out one `Retry-After`
|
|
87
|
+
before raising `RateLimitError`.
|
|
88
|
+
|
|
83
89
|
### Inside a machine
|
|
84
90
|
|
|
85
91
|
Inside a boxd machine, `Boxd()` authenticates automatically — no API key
|
|
@@ -205,6 +211,24 @@ private to you unless you pass `shared=True`.
|
|
|
205
211
|
usable. Call `wait_until_ready` before doing anything that depends on it
|
|
206
212
|
running — especially before forking it again.
|
|
207
213
|
|
|
214
|
+
### Egress allowlist
|
|
215
|
+
|
|
216
|
+
A machine can be limited to what it may reach on the internet:
|
|
217
|
+
|
|
218
|
+
```python
|
|
219
|
+
boxd.machines.set_egress_allow("alpha", ["api.stripe.com", "*.github.com", "203.0.113.0/24"])
|
|
220
|
+
boxd.machines.get("alpha").egress_allow
|
|
221
|
+
boxd.machines.set_egress_allow("alpha", []) # clear: unrestricted again
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Entries are hostnames or wildcards, or public IPv4 addresses and CIDRs. Web
|
|
225
|
+
requests are admitted by hostname, everything else by address, with the
|
|
226
|
+
addresses of allowlisted names learned as the machine resolves them. Nothing
|
|
227
|
+
that is not named is reachable. The hosts of any bound secret the machine
|
|
228
|
+
holds are always admitted. Takes effect within a second and is not visible or
|
|
229
|
+
changeable from inside the machine. A fork inherits its source's allowlist; a
|
|
230
|
+
machine restored from a snapshot starts unrestricted.
|
|
231
|
+
|
|
208
232
|
### Sizing
|
|
209
233
|
|
|
210
234
|
There are three sizes: 1 vCPU/4G, 2/8G, 4/16G. Name either dimension and the
|
|
@@ -368,7 +392,9 @@ data = boxd.machines.files.download(id, "/app/output.json") # bytes
|
|
|
368
392
|
```
|
|
369
393
|
|
|
370
394
|
`upload` takes `str` or `bytes`, streams it in chunks so large files are fine,
|
|
371
|
-
and returns the number of bytes the machine confirmed it wrote.
|
|
395
|
+
and returns the number of bytes the machine confirmed it wrote. `download`
|
|
396
|
+
streams too and returns the whole file as `bytes` — there is no size cap, but
|
|
397
|
+
it is all held in memory.
|
|
372
398
|
|
|
373
399
|
### Ports and proxies
|
|
374
400
|
|
|
@@ -443,13 +469,32 @@ boxd.env.list(org="acme")
|
|
|
443
469
|
boxd.env.delete("MODE", scope="all")
|
|
444
470
|
|
|
445
471
|
boxd.secrets.set("API_TOKEN", "s3cr3t", scope="shared")
|
|
446
|
-
boxd.secrets.list() # Secret(name, scope) — no value
|
|
472
|
+
boxd.secrets.list() # Secret(name, scope, domains) — no value
|
|
447
473
|
boxd.secrets.delete("API_TOKEN", scope="shared")
|
|
448
474
|
```
|
|
449
475
|
|
|
476
|
+
A secret can be bound to the hosts it may be sent to. The machine then only
|
|
477
|
+
ever holds a placeholder, and the real value is substituted into requests to
|
|
478
|
+
those hosts on the way out:
|
|
479
|
+
|
|
480
|
+
```python
|
|
481
|
+
boxd.secrets.set("STRIPE_KEY", "sk_live_...", scope="all", domains=["api.stripe.com"])
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
Inside the machine, `STRIPE_KEY` is an opaque `bxds_…` string. A request to
|
|
485
|
+
`api.stripe.com` carrying it, in a header, the query, the body or Basic auth,
|
|
486
|
+
arrives at Stripe with the real key; a request anywhere else carries the
|
|
487
|
+
useless placeholder. Nothing to configure in the machine, and the placeholder
|
|
488
|
+
does not change when the value is rotated. `domains` is the full desired set:
|
|
489
|
+
setting the secret again without it makes it a plain secret. Wildcards such as
|
|
490
|
+
`*.stripe.com` are accepted; provider-wide ones such as `*.amazonaws.com` are
|
|
491
|
+
refused.
|
|
492
|
+
|
|
450
493
|
`scope` defaults to `"shared"` on `set` and `delete`; `list` takes `org` only
|
|
451
494
|
and reports every scope. Pass `org="acme"` to any of these to work in an
|
|
452
|
-
organization instead of your personal scope.
|
|
495
|
+
organization instead of your personal scope. With an API key minted in an
|
|
496
|
+
organization your personal scope already lives there, so `org` is only needed
|
|
497
|
+
to reach the organization's `shared`/`all` vars.
|
|
453
498
|
|
|
454
499
|
`set`, `delete` and `move` each return the server's human-readable
|
|
455
500
|
confirmation of what it did.
|
|
@@ -26,7 +26,7 @@ from typing import Any
|
|
|
26
26
|
|
|
27
27
|
import httpx
|
|
28
28
|
|
|
29
|
-
from .errors import AuthenticationError
|
|
29
|
+
from .errors import AuthenticationError, RateLimitError
|
|
30
30
|
|
|
31
31
|
# The in-VM metadata adapter (`crates/boxd-worker/src/metadata.rs`) listens on
|
|
32
32
|
# the VM's default gateway.
|
|
@@ -125,7 +125,11 @@ def parse_exchange(status_code: int, body: str, json: Any) -> tuple[str, float]:
|
|
|
125
125
|
if status_code == 401:
|
|
126
126
|
raise AuthenticationError("invalid or expired API key")
|
|
127
127
|
if status_code == 429:
|
|
128
|
-
raise
|
|
128
|
+
raise RateLimitError(
|
|
129
|
+
"token exchange rate limit exceeded"
|
|
130
|
+
+ (f": {body}" if body else "")
|
|
131
|
+
+ " — a token is valid for 1 hour; reuse one across your workers"
|
|
132
|
+
)
|
|
129
133
|
if status_code != 200:
|
|
130
134
|
raise AuthenticationError(f"exchange failed: {status_code} {body}")
|
|
131
135
|
try:
|
|
@@ -155,6 +159,15 @@ def _expired(expires_at: float, skew: float) -> bool:
|
|
|
155
159
|
return time.time() > expires_at - skew
|
|
156
160
|
|
|
157
161
|
|
|
162
|
+
def _retry_after_secs(resp: httpx.Response) -> float:
|
|
163
|
+
"""Seconds to wait per the ``Retry-After`` header — capped at 60, default 5."""
|
|
164
|
+
try:
|
|
165
|
+
secs = float(resp.headers.get("retry-after", ""))
|
|
166
|
+
except ValueError:
|
|
167
|
+
return 5.0
|
|
168
|
+
return min(max(secs, 0.0), 60.0) or 5.0
|
|
169
|
+
|
|
170
|
+
|
|
158
171
|
# ── Credential stores ────────────────────────────────────────────────
|
|
159
172
|
|
|
160
173
|
|
|
@@ -208,14 +221,25 @@ class SyncCredentials(_CredentialsBase):
|
|
|
208
221
|
return self._cached
|
|
209
222
|
|
|
210
223
|
def _exchange(self) -> None:
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
224
|
+
for attempt in range(2):
|
|
225
|
+
try:
|
|
226
|
+
with httpx.Client(timeout=_HTTP_TIMEOUT_SECS) as client:
|
|
227
|
+
resp = client.post(
|
|
228
|
+
self._exchange_url, json={"api_key": self._api_key}
|
|
229
|
+
)
|
|
230
|
+
except httpx.HTTPError as e:
|
|
231
|
+
raise AuthenticationError(
|
|
232
|
+
f"could not reach {self._exchange_url}: {e}"
|
|
233
|
+
) from e
|
|
234
|
+
# The exchange endpoint is rate-limited per source IP; a NATed fleet
|
|
235
|
+
# can trip it legitimately. Wait out one Retry-After before failing.
|
|
236
|
+
if resp.status_code == 429 and attempt == 0:
|
|
237
|
+
time.sleep(_retry_after_secs(resp))
|
|
238
|
+
continue
|
|
239
|
+
self._cached, self._expires_at = parse_exchange(
|
|
240
|
+
resp.status_code, resp.text, _json_or_none(resp)
|
|
241
|
+
)
|
|
242
|
+
return
|
|
219
243
|
|
|
220
244
|
def _mint_from_vm(self) -> None:
|
|
221
245
|
base = self._metadata_base()
|
|
@@ -260,16 +284,25 @@ class AsyncCredentials(_CredentialsBase):
|
|
|
260
284
|
return self._cached
|
|
261
285
|
|
|
262
286
|
async def _exchange(self) -> None:
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
287
|
+
for attempt in range(2):
|
|
288
|
+
try:
|
|
289
|
+
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_SECS) as client:
|
|
290
|
+
resp = await client.post(
|
|
291
|
+
self._exchange_url, json={"api_key": self._api_key}
|
|
292
|
+
)
|
|
293
|
+
except httpx.HTTPError as e:
|
|
294
|
+
raise AuthenticationError(
|
|
295
|
+
f"could not reach {self._exchange_url}: {e}"
|
|
296
|
+
) from e
|
|
297
|
+
# The exchange endpoint is rate-limited per source IP; a NATed fleet
|
|
298
|
+
# can trip it legitimately. Wait out one Retry-After before failing.
|
|
299
|
+
if resp.status_code == 429 and attempt == 0:
|
|
300
|
+
await asyncio.sleep(_retry_after_secs(resp))
|
|
301
|
+
continue
|
|
302
|
+
self._cached, self._expires_at = parse_exchange(
|
|
303
|
+
resp.status_code, resp.text, _json_or_none(resp)
|
|
304
|
+
)
|
|
305
|
+
return
|
|
273
306
|
|
|
274
307
|
async def _mint_from_vm(self) -> None:
|
|
275
308
|
base = await self._metadata_base()
|