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.
Files changed (42) hide show
  1. {boxd-0.2.8.dev448/src/boxd.egg-info → boxd-0.2.9}/PKG-INFO +49 -4
  2. {boxd-0.2.8.dev448 → boxd-0.2.9}/README.md +48 -3
  3. {boxd-0.2.8.dev448 → boxd-0.2.9}/pyproject.toml +1 -1
  4. {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd/_credentials.py +53 -20
  5. boxd-0.2.9/src/boxd/_generated/api_pb2.py +434 -0
  6. {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd/_generated/api_pb2_grpc.py +96 -2
  7. {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd/_mappers.py +3 -1
  8. {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd/_requests.py +13 -2
  9. {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd/_transport.py +16 -2
  10. {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd/models.py +13 -0
  11. {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd/resources/machines.py +64 -9
  12. {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd/resources/vars.py +38 -6
  13. {boxd-0.2.8.dev448 → boxd-0.2.9/src/boxd.egg-info}/PKG-INFO +49 -4
  14. {boxd-0.2.8.dev448 → boxd-0.2.9}/tests/test_credentials.py +39 -2
  15. {boxd-0.2.8.dev448 → boxd-0.2.9}/tests/test_mappers.py +26 -0
  16. {boxd-0.2.8.dev448 → boxd-0.2.9}/tests/test_namespaces.py +30 -1
  17. {boxd-0.2.8.dev448 → boxd-0.2.9}/tests/test_requests.py +16 -0
  18. boxd-0.2.8.dev448/src/boxd/_generated/api_pb2.py +0 -428
  19. {boxd-0.2.8.dev448 → boxd-0.2.9}/LICENSE +0 -0
  20. {boxd-0.2.8.dev448 → boxd-0.2.9}/setup.cfg +0 -0
  21. {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd/__init__.py +0 -0
  22. {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd/_client.py +0 -0
  23. {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd/_generated/__init__.py +0 -0
  24. {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd/_streaming.py +0 -0
  25. {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd/_urls.py +0 -0
  26. {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd/_version_check.py +0 -0
  27. {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd/errors.py +0 -0
  28. {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd/resources/__init__.py +0 -0
  29. {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd/resources/account.py +0 -0
  30. {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd/resources/credentials.py +0 -0
  31. {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd/resources/disks.py +0 -0
  32. {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd/resources/domains.py +0 -0
  33. {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd/resources/orgs.py +0 -0
  34. {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd/resources/snapshots.py +0 -0
  35. {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd.egg-info/SOURCES.txt +0 -0
  36. {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd.egg-info/dependency_links.txt +0 -0
  37. {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd.egg-info/requires.txt +0 -0
  38. {boxd-0.2.8.dev448 → boxd-0.2.9}/src/boxd.egg-info/top_level.txt +0 -0
  39. {boxd-0.2.8.dev448 → boxd-0.2.9}/tests/test_streaming.py +0 -0
  40. {boxd-0.2.8.dev448 → boxd-0.2.9}/tests/test_transport.py +0 -0
  41. {boxd-0.2.8.dev448 → boxd-0.2.9}/tests/test_urls.py +0 -0
  42. {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.8.dev448
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.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "boxd"
3
- version = "0.2.8.dev448"
3
+ version = "0.2.9"
4
4
  description = "Python SDK for the boxd cloud VM platform"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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 AuthenticationError("rate limit exceeded — try again later")
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
- try:
212
- with httpx.Client(timeout=_HTTP_TIMEOUT_SECS) as client:
213
- resp = client.post(self._exchange_url, json={"api_key": self._api_key})
214
- except httpx.HTTPError as e:
215
- raise AuthenticationError(f"could not reach {self._exchange_url}: {e}") from e
216
- self._cached, self._expires_at = parse_exchange(
217
- resp.status_code, resp.text, _json_or_none(resp)
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
- try:
264
- async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_SECS) as client:
265
- resp = await client.post(
266
- self._exchange_url, json={"api_key": self._api_key}
267
- )
268
- except httpx.HTTPError as e:
269
- raise AuthenticationError(f"could not reach {self._exchange_url}: {e}") from e
270
- self._cached, self._expires_at = parse_exchange(
271
- resp.status_code, resp.text, _json_or_none(resp)
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()