boxd 0.2.19.dev910__py3-none-any.whl → 0.2.20.dev929__py3-none-any.whl

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/__init__.py CHANGED
@@ -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",
boxd/_credentials.py CHANGED
@@ -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
  )