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 +18 -0
- boxd/_credentials.py +110 -29
- boxd/_generated/api_pb2.py +600 -558
- boxd/_generated/api_pb2_grpc.py +428 -1
- boxd/_mappers.py +40 -0
- boxd/_requests.py +104 -9
- boxd/_transport.py +24 -3
- boxd/errors.py +228 -5
- boxd/models.py +67 -4
- boxd/py.typed +0 -0
- boxd/resources/fs.py +434 -0
- boxd/resources/machines.py +125 -28
- boxd/spec.py +4 -2
- {boxd-0.2.19.dev910.dist-info → boxd-0.2.20.dev929.dist-info}/METADATA +74 -6
- boxd-0.2.20.dev929.dist-info/RECORD +32 -0
- boxd-0.2.19.dev910.dist-info/RECORD +0 -30
- {boxd-0.2.19.dev910.dist-info → boxd-0.2.20.dev929.dist-info}/WHEEL +0 -0
- {boxd-0.2.19.dev910.dist-info → boxd-0.2.20.dev929.dist-info}/licenses/LICENSE +0 -0
- {boxd-0.2.19.dev910.dist-info → boxd-0.2.20.dev929.dist-info}/top_level.txt +0 -0
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
|
|
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
|
)
|