bladekit 4.5.0__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.
- bladekit/__init__.py +219 -0
- bladekit/_app.py +3241 -0
- bladekit/_banner.py +640 -0
- bladekit/_batch.py +150 -0
- bladekit/_bucket.py +211 -0
- bladekit/_build.py +337 -0
- bladekit/_bundle.py +459 -0
- bladekit/_client.py +1152 -0
- bladekit/_cls.py +574 -0
- bladekit/_constants.py +121 -0
- bladekit/_cron.py +88 -0
- bladekit/_dict.py +174 -0
- bladekit/_env.py +98 -0
- bladekit/_image.py +577 -0
- bladekit/_loader.py +79 -0
- bladekit/_map.py +555 -0
- bladekit/_preempt.py +94 -0
- bladekit/_queue.py +137 -0
- bladekit/_render.py +683 -0
- bladekit/_runner_api.py +148 -0
- bladekit/_sandbox.py +1051 -0
- bladekit/_secret.py +52 -0
- bladekit/_serialize.py +88 -0
- bladekit/_serve.py +188 -0
- bladekit/_volume.py +232 -0
- bladekit/_webhook.py +90 -0
- bladekit/cli.py +2282 -0
- bladekit/config.py +181 -0
- bladekit/exceptions.py +544 -0
- bladekit-4.5.0.dist-info/METADATA +373 -0
- bladekit-4.5.0.dist-info/RECORD +33 -0
- bladekit-4.5.0.dist-info/WHEEL +4 -0
- bladekit-4.5.0.dist-info/entry_points.txt +3 -0
bladekit/_client.py
ADDED
|
@@ -0,0 +1,1152 @@
|
|
|
1
|
+
"""Thin HTTP client over the control plane ``/v1/*`` surface.
|
|
2
|
+
|
|
3
|
+
Responsibilities:
|
|
4
|
+
- attach the bearer token,
|
|
5
|
+
- decode the standard error envelope (conventions.md §2) into typed exceptions,
|
|
6
|
+
- expose the handful of endpoints the SDK/CLI use.
|
|
7
|
+
|
|
8
|
+
``httpx`` is imported lazily so ``import bladekit`` stays cheap.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from typing import Any, Mapping, Optional
|
|
16
|
+
from urllib.parse import quote
|
|
17
|
+
|
|
18
|
+
from . import exceptions as exc
|
|
19
|
+
from ._constants import DEFAULT_PAGE_LIMIT
|
|
20
|
+
|
|
21
|
+
# HTTP status -> envelope-aware exception class. The ``code`` in the envelope is
|
|
22
|
+
# authoritative; status only picks the broad family for unknown codes.
|
|
23
|
+
_STATUS_EXC = {
|
|
24
|
+
400: exc.InvalidArgumentError,
|
|
25
|
+
401: exc.AuthenticationError,
|
|
26
|
+
403: exc.PermissionDeniedError,
|
|
27
|
+
404: exc.NotFoundError,
|
|
28
|
+
409: exc.ConflictError,
|
|
29
|
+
413: exc.PayloadTooLargeError,
|
|
30
|
+
503: exc.UnavailableError,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
_CODE_EXC = {
|
|
34
|
+
"UNAUTHENTICATED": exc.AuthenticationError,
|
|
35
|
+
"RUN_TOKEN_EXPIRED": exc.AuthenticationError,
|
|
36
|
+
"PERMISSION_DENIED": exc.PermissionDeniedError,
|
|
37
|
+
"NOT_FOUND": exc.NotFoundError,
|
|
38
|
+
"RUN_NOT_FOUND": exc.NotFoundError,
|
|
39
|
+
"CALL_NOT_FOUND": exc.NotFoundError,
|
|
40
|
+
"BUNDLE_NOT_FOUND": exc.NotFoundError,
|
|
41
|
+
"NODE_NOT_FOUND": exc.NotFoundError,
|
|
42
|
+
"INVALID_ARGUMENT": exc.InvalidArgumentError,
|
|
43
|
+
"INVALID_CURSOR": exc.InvalidArgumentError,
|
|
44
|
+
"IMAGE_ALIAS_UNKNOWN": exc.InvalidArgumentError,
|
|
45
|
+
"BUNDLE_NOT_READY": exc.ConflictError,
|
|
46
|
+
"BUNDLE_CHECKSUM_MISMATCH": exc.ConflictError,
|
|
47
|
+
"RUN_NOT_CANCELLABLE": exc.ConflictError,
|
|
48
|
+
"RUN_NOT_ACCEPTING_CALLS": exc.ConflictError,
|
|
49
|
+
"CALL_ALREADY_FINISHED": exc.ConflictError,
|
|
50
|
+
"BUNDLE_TOO_LARGE": exc.PayloadTooLargeError,
|
|
51
|
+
"PAYLOAD_TOO_LARGE": exc.PayloadTooLargeError,
|
|
52
|
+
"UNAVAILABLE": exc.UnavailableError,
|
|
53
|
+
"IMAGE_BUILD_NOT_FOUND": exc.NotFoundError,
|
|
54
|
+
"VOLUME_NOT_FOUND": exc.NotFoundError,
|
|
55
|
+
"SECRET_NOT_FOUND": exc.NotFoundError,
|
|
56
|
+
"VOLUME_COMMIT_NOT_FOUND": exc.NotFoundError,
|
|
57
|
+
"VOLUME_FILE_NOT_FOUND": exc.NotFoundError,
|
|
58
|
+
"IMAGE_DEFINITION_INVALID": exc.InvalidArgumentError,
|
|
59
|
+
# Submit-time refusal of a run whose pickle would cross Python minors. The
|
|
60
|
+
# same verdict the runner would reach at boot (VersionMismatchError, run
|
|
61
|
+
# level), raised here as an API error because nothing was ever created.
|
|
62
|
+
"PYTHON_VERSION_INCOMPATIBLE": exc.InvalidArgumentError,
|
|
63
|
+
"MOUNT_PATH_INVALID": exc.InvalidArgumentError,
|
|
64
|
+
"SECRET_KEY_RESERVED": exc.InvalidArgumentError,
|
|
65
|
+
"IMAGE_BUILD_NOT_READY": exc.ConflictError,
|
|
66
|
+
"IMAGE_BUILD_FAILED": exc.ConflictError,
|
|
67
|
+
"VOLUME_IN_USE": exc.ConflictError,
|
|
68
|
+
"VOLUME_COMMIT_OBJECTS_MISSING": exc.ConflictError,
|
|
69
|
+
"VOLUME_COMMIT_NOT_PENDING": exc.ConflictError,
|
|
70
|
+
"RUN_NOT_UPDATABLE": exc.ConflictError,
|
|
71
|
+
"RUN_NOT_SERVING": exc.ConflictError,
|
|
72
|
+
"APP_NAME_INVALID": exc.InvalidArgumentError,
|
|
73
|
+
"APP_NOT_FOUND": exc.NotFoundError,
|
|
74
|
+
"DEPLOYMENT_NOT_FOUND": exc.NotFoundError,
|
|
75
|
+
"AUTOSCALE_LIMIT_INVALID": exc.InvalidArgumentError,
|
|
76
|
+
"ENDPOINT_ROUTE_CONFLICT": exc.ConflictError,
|
|
77
|
+
"DEPLOYMENT_NOT_READY": exc.ConflictError,
|
|
78
|
+
"DEPLOYMENT_NOT_ROLLBACKABLE": exc.ConflictError,
|
|
79
|
+
"APP_STOPPED": exc.UnavailableError,
|
|
80
|
+
"MAP_NOT_FOUND": exc.NotFoundError,
|
|
81
|
+
"MAP_INPUT_NOT_FOUND": exc.NotFoundError,
|
|
82
|
+
"CRON_NOT_FOUND": exc.NotFoundError,
|
|
83
|
+
"CRON_EXECUTION_NOT_FOUND": exc.NotFoundError,
|
|
84
|
+
"QUEUE_NOT_FOUND": exc.NotFoundError,
|
|
85
|
+
"DICT_NOT_FOUND": exc.NotFoundError,
|
|
86
|
+
"NAMESPACE_NOT_FOUND": exc.NotFoundError,
|
|
87
|
+
"MAP_NOT_ACCEPTING_INPUTS": exc.ConflictError,
|
|
88
|
+
"MAP_NOT_CANCELLABLE": exc.ConflictError,
|
|
89
|
+
"MAP_INPUTS_NOT_FINALIZED": exc.ConflictError,
|
|
90
|
+
"CRON_EXPRESSION_INVALID": exc.InvalidArgumentError,
|
|
91
|
+
"CRON_NOT_TRIGGERABLE": exc.ConflictError,
|
|
92
|
+
"QUEUE_NAME_INVALID": exc.InvalidArgumentError,
|
|
93
|
+
"DICT_NAME_INVALID": exc.InvalidArgumentError,
|
|
94
|
+
"QUEUE_ITEM_TOO_LARGE": exc.PayloadTooLargeError,
|
|
95
|
+
"DICT_VALUE_TOO_LARGE": exc.PayloadTooLargeError,
|
|
96
|
+
"DICT_KEY_TOO_LARGE": exc.InvalidArgumentError,
|
|
97
|
+
"PRIORITY_INVALID": exc.InvalidArgumentError,
|
|
98
|
+
"RETRY_POLICY_INVALID": exc.InvalidArgumentError,
|
|
99
|
+
"GPU_QUOTA_EXCEEDED": exc.GPUQuotaExceededError,
|
|
100
|
+
"ORG_GPU_QUOTA_EXCEEDED": exc.OrgGPUQuotaExceededError,
|
|
101
|
+
# --- sandbox errors ---
|
|
102
|
+
"SANDBOX_NOT_FOUND": exc.NotFoundError,
|
|
103
|
+
"SANDBOX_EXEC_NOT_FOUND": exc.NotFoundError,
|
|
104
|
+
"SANDBOX_TUNNEL_NOT_FOUND": exc.NotFoundError,
|
|
105
|
+
"SANDBOX_NOT_RUNNING": exc.SandboxNotRunningError,
|
|
106
|
+
"SANDBOX_EXEC_LIMIT": exc.SandboxExecLimitError,
|
|
107
|
+
"SANDBOX_FILE_NOT_FOUND": exc.SandboxFileNotFoundError,
|
|
108
|
+
"SANDBOX_FILE_TOO_LARGE": exc.SandboxFileTooLargeError,
|
|
109
|
+
"SANDBOX_PATH_INVALID": exc.SandboxPathInvalidError,
|
|
110
|
+
"SANDBOX_DISK_QUOTA_EXCEEDED": exc.SandboxDiskQuotaExceededError,
|
|
111
|
+
"SANDBOX_PORT_IN_USE": exc.SandboxPortInUseError,
|
|
112
|
+
"RUNTIME_INVALID": exc.InvalidArgumentError,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _version_tuple(v: str) -> tuple:
|
|
117
|
+
"""Best-effort numeric semver key: leading ``major.minor.patch`` ints.
|
|
118
|
+
|
|
119
|
+
Stops at the first non-numeric component (pre-release / build metadata), so
|
|
120
|
+
``0.1.0+g1a2b3c4`` -> ``(0, 1, 0)``. Unparseable -> ``(0,)``.
|
|
121
|
+
"""
|
|
122
|
+
head = v.strip().lstrip("vV").split("+", 1)[0].split("-", 1)[0]
|
|
123
|
+
parts: list[int] = []
|
|
124
|
+
for chunk in head.split("."):
|
|
125
|
+
if chunk.isdigit():
|
|
126
|
+
parts.append(int(chunk))
|
|
127
|
+
else:
|
|
128
|
+
break
|
|
129
|
+
return tuple(parts) if parts else (0,)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _version_lt(current: str, minimum: str) -> bool:
|
|
133
|
+
"""True iff ``current`` is strictly below ``minimum`` (numeric semver)."""
|
|
134
|
+
return _version_tuple(current) < _version_tuple(minimum)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _raise_for_envelope(status: int, body: Any) -> None:
|
|
138
|
+
code = "INTERNAL"
|
|
139
|
+
message = f"HTTP {status}"
|
|
140
|
+
details: Mapping[str, Any] = {}
|
|
141
|
+
if isinstance(body, Mapping):
|
|
142
|
+
err = body.get("error")
|
|
143
|
+
if isinstance(err, Mapping):
|
|
144
|
+
code = str(err.get("code", code))
|
|
145
|
+
message = str(err.get("message", message))
|
|
146
|
+
details = err.get("details") or {}
|
|
147
|
+
|
|
148
|
+
# Prefer the machine code for the class; fall back to status family; then
|
|
149
|
+
# a generic APIError so unknown codes are still surfaced (conventions §2).
|
|
150
|
+
cls = _CODE_EXC.get(code) or _STATUS_EXC.get(status) or exc.APIError
|
|
151
|
+
raise cls(code, message, status_code=status, details=details)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _could_not_connect(err: BaseException) -> bool:
|
|
155
|
+
"""True when the request never reached a server.
|
|
156
|
+
|
|
157
|
+
`_request` collapses every httpx error into ConnectionFailed, read timeouts
|
|
158
|
+
included. Only a failed CONNECT may be retried against a different address: a read
|
|
159
|
+
timeout means the host accepted the request, and re-sending it elsewhere would run
|
|
160
|
+
the caller's work twice.
|
|
161
|
+
"""
|
|
162
|
+
import httpx
|
|
163
|
+
|
|
164
|
+
return isinstance(err.__cause__, (httpx.ConnectError, httpx.ConnectTimeout))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class Client:
|
|
168
|
+
"""Synchronous control-plane client."""
|
|
169
|
+
|
|
170
|
+
def __init__(
|
|
171
|
+
self,
|
|
172
|
+
base_url: str,
|
|
173
|
+
token: Optional[str],
|
|
174
|
+
*,
|
|
175
|
+
timeout: float = 30.0,
|
|
176
|
+
transport: Any = None,
|
|
177
|
+
) -> None:
|
|
178
|
+
import httpx
|
|
179
|
+
|
|
180
|
+
self.base_url = base_url.rstrip("/")
|
|
181
|
+
self.token = token
|
|
182
|
+
headers = {"User-Agent": "forge-sdk/0.3.0"}
|
|
183
|
+
if token:
|
|
184
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
185
|
+
# ``transport`` lets tests inject an in-process ASGI/WSGI transport.
|
|
186
|
+
kwargs: dict[str, Any] = {
|
|
187
|
+
"base_url": self.base_url,
|
|
188
|
+
"headers": headers,
|
|
189
|
+
"timeout": timeout,
|
|
190
|
+
}
|
|
191
|
+
if transport is not None:
|
|
192
|
+
kwargs["transport"] = transport
|
|
193
|
+
self._http = httpx.Client(**kwargs)
|
|
194
|
+
# Per-instance guard so the version handshake fires at most once per client
|
|
195
|
+
# *after a successful contact*. Per-instance (not process-global) so a 2nd
|
|
196
|
+
# Client — e.g. the App lifecycle creating its own — still checks; the lock
|
|
197
|
+
# makes the at-most-once promise safe if a worker thread also touches it.
|
|
198
|
+
self._version_checked = False
|
|
199
|
+
self._version_lock = threading.Lock()
|
|
200
|
+
|
|
201
|
+
# -- low level ----------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
204
|
+
import httpx
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
resp = self._http.request(method, path, **kwargs)
|
|
208
|
+
except httpx.HTTPError as e: # connect/read/timeout
|
|
209
|
+
raise exc.ConnectionFailed(
|
|
210
|
+
f"could not reach control plane at {self.base_url}: {e}"
|
|
211
|
+
) from e
|
|
212
|
+
|
|
213
|
+
if resp.status_code >= 400:
|
|
214
|
+
try:
|
|
215
|
+
body = resp.json()
|
|
216
|
+
except ValueError:
|
|
217
|
+
body = None
|
|
218
|
+
_raise_for_envelope(resp.status_code, body)
|
|
219
|
+
|
|
220
|
+
if resp.content:
|
|
221
|
+
return resp.json()
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
def close(self) -> None:
|
|
225
|
+
self._http.close()
|
|
226
|
+
|
|
227
|
+
def __enter__(self) -> "Client":
|
|
228
|
+
return self
|
|
229
|
+
|
|
230
|
+
def __exit__(self, *exc_info: Any) -> None:
|
|
231
|
+
self.close()
|
|
232
|
+
|
|
233
|
+
# -- meta ---------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
def get_version(self) -> dict:
|
|
236
|
+
return self._request("GET", "/v1/version")
|
|
237
|
+
|
|
238
|
+
def warn_if_outdated(self) -> None:
|
|
239
|
+
"""Call ``/v1/version`` once and warn if the SDK is below ``min_sdk_version``.
|
|
240
|
+
|
|
241
|
+
Per openapi ``/v1/version``: the SDK calls this on first contact and shows
|
|
242
|
+
a warning if the server announces a ``min_sdk_version`` greater than the
|
|
243
|
+
SDK's own version. Best-effort: any network/parse failure is swallowed so a
|
|
244
|
+
version smoke-test never breaks a real command.
|
|
245
|
+
|
|
246
|
+
The HTTP call is made **outside** the lock so that a slow or stalled
|
|
247
|
+
control plane does not block a second thread that also calls this method
|
|
248
|
+
(e.g. the log-pump thread in blade serve). The lock only protects the
|
|
249
|
+
read/write of ``_version_checked``.
|
|
250
|
+
"""
|
|
251
|
+
with self._version_lock:
|
|
252
|
+
if self._version_checked:
|
|
253
|
+
return
|
|
254
|
+
# Network call outside the lock.
|
|
255
|
+
try:
|
|
256
|
+
info = self.get_version()
|
|
257
|
+
except exc.FabricError:
|
|
258
|
+
# Transient failure (server not yet reachable): leave the guard
|
|
259
|
+
# unset so a later command re-attempts the smoke-test instead of
|
|
260
|
+
# silently disarming the compat warning for the process lifetime.
|
|
261
|
+
return
|
|
262
|
+
# Write back under the lock — another thread may have raced us.
|
|
263
|
+
with self._version_lock:
|
|
264
|
+
if self._version_checked:
|
|
265
|
+
# Other thread beat us; they already printed any warning.
|
|
266
|
+
return
|
|
267
|
+
self._version_checked = True
|
|
268
|
+
if not isinstance(info, Mapping):
|
|
269
|
+
return
|
|
270
|
+
min_sdk = info.get("min_sdk_version")
|
|
271
|
+
if not min_sdk:
|
|
272
|
+
return
|
|
273
|
+
from . import __version__
|
|
274
|
+
|
|
275
|
+
if _version_lt(__version__, str(min_sdk)):
|
|
276
|
+
from ._render import err_console
|
|
277
|
+
|
|
278
|
+
err_console.print(
|
|
279
|
+
f"[yellow]warning[/yellow]: bladekit {__version__} is older than the "
|
|
280
|
+
f"server's minimum ({min_sdk}). Upgrade with "
|
|
281
|
+
f"`pip install --upgrade bladekit`."
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
def healthz(self) -> dict:
|
|
285
|
+
return self._request("GET", "/healthz")
|
|
286
|
+
|
|
287
|
+
# -- bundles ------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
def create_bundle(self, size_bytes: int, sha256: str) -> dict:
|
|
290
|
+
return self._request(
|
|
291
|
+
"POST",
|
|
292
|
+
"/v1/bundles",
|
|
293
|
+
json={"size_bytes": size_bytes, "sha256": sha256},
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
def complete_bundle(self, bundle_id: str) -> dict:
|
|
297
|
+
return self._request("POST", f"/v1/bundles/{bundle_id}/complete")
|
|
298
|
+
|
|
299
|
+
def upload_to_presigned(self, url: str, data: bytes, content_type: str) -> None:
|
|
300
|
+
"""PUT raw bytes to a presigned URL (bundle or payload)."""
|
|
301
|
+
import httpx
|
|
302
|
+
|
|
303
|
+
try:
|
|
304
|
+
resp = httpx.put(
|
|
305
|
+
url, content=data, headers={"Content-Type": content_type}, timeout=300.0
|
|
306
|
+
)
|
|
307
|
+
except httpx.HTTPError as e:
|
|
308
|
+
raise exc.ConnectionFailed(f"upload to object store failed: {e}") from e
|
|
309
|
+
if resp.status_code >= 400:
|
|
310
|
+
raise exc.FabricError(
|
|
311
|
+
f"object store rejected upload (HTTP {resp.status_code})"
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
def download_presigned(self, url: str) -> bytes:
|
|
315
|
+
import httpx
|
|
316
|
+
|
|
317
|
+
try:
|
|
318
|
+
resp = httpx.get(url, timeout=300.0)
|
|
319
|
+
except httpx.HTTPError as e:
|
|
320
|
+
raise exc.ConnectionFailed(f"download from object store failed: {e}") from e
|
|
321
|
+
if resp.status_code >= 400:
|
|
322
|
+
raise exc.FabricError(
|
|
323
|
+
f"object store rejected download (HTTP {resp.status_code})"
|
|
324
|
+
)
|
|
325
|
+
return resp.content
|
|
326
|
+
|
|
327
|
+
# -- payloads -----------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
def create_payload(self, size_bytes: int, sha256: str) -> dict:
|
|
330
|
+
return self._request(
|
|
331
|
+
"POST",
|
|
332
|
+
"/v1/payloads",
|
|
333
|
+
json={"size_bytes": size_bytes, "sha256": sha256},
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
# -- runs ---------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
def create_run(self, body: dict) -> dict:
|
|
339
|
+
return self._request("POST", "/v1/runs", json=body)
|
|
340
|
+
|
|
341
|
+
def get_run(self, run_id: str) -> dict:
|
|
342
|
+
return self._request("GET", f"/v1/runs/{run_id}")
|
|
343
|
+
|
|
344
|
+
def list_runs(
|
|
345
|
+
self,
|
|
346
|
+
*,
|
|
347
|
+
state: Optional[list] = None,
|
|
348
|
+
limit: int = DEFAULT_PAGE_LIMIT,
|
|
349
|
+
cursor: Optional[str] = None,
|
|
350
|
+
) -> dict:
|
|
351
|
+
params: dict[str, Any] = {"limit": limit}
|
|
352
|
+
if state:
|
|
353
|
+
params["state"] = state
|
|
354
|
+
if cursor:
|
|
355
|
+
params["cursor"] = cursor
|
|
356
|
+
return self._request("GET", "/v1/runs", params=params)
|
|
357
|
+
|
|
358
|
+
def cancel_run(self, run_id: str) -> dict:
|
|
359
|
+
return self._request("POST", f"/v1/runs/{run_id}/cancel")
|
|
360
|
+
|
|
361
|
+
# ---- admin (role=admin tokens only; PAAS-24 containment) ----
|
|
362
|
+
|
|
363
|
+
def admin_contain_user(self, user_id: str, reason: str = "") -> dict:
|
|
364
|
+
"""Stop every workload of an account (graceful then hard). Idempotent.
|
|
365
|
+
The suspension itself is iam's (the backoffice)."""
|
|
366
|
+
return self._request(
|
|
367
|
+
"POST", f"/v1/admin/users/{user_id}/contain", json={"reason": reason}
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
def admin_force_stop(self, kind: str, ref: str) -> dict:
|
|
371
|
+
"""Force-stop ANY tenant's workload (audited). kind is one of
|
|
372
|
+
app, deployment, run, sandbox, map."""
|
|
373
|
+
path = {
|
|
374
|
+
"app": f"/v1/admin/apps/{ref}/stop",
|
|
375
|
+
"deployment": f"/v1/admin/deployments/{ref}/stop",
|
|
376
|
+
"run": f"/v1/admin/runs/{ref}/cancel",
|
|
377
|
+
"sandbox": f"/v1/admin/sandboxes/{ref}/terminate",
|
|
378
|
+
"map": f"/v1/admin/maps/{ref}/cancel",
|
|
379
|
+
}[kind]
|
|
380
|
+
return self._request("POST", path)
|
|
381
|
+
|
|
382
|
+
def complete_run(self, run_id: str) -> dict:
|
|
383
|
+
return self._request("POST", f"/v1/runs/{run_id}/complete")
|
|
384
|
+
|
|
385
|
+
# -- calls --------------------------------------------------------------
|
|
386
|
+
|
|
387
|
+
def create_call(self, run_id: str, args_payload: dict) -> dict:
|
|
388
|
+
return self._request(
|
|
389
|
+
"POST", f"/v1/runs/{run_id}/calls", json={"args": args_payload}
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
def get_call(self, call_id: str) -> dict:
|
|
393
|
+
return self._request("GET", f"/v1/calls/{call_id}")
|
|
394
|
+
|
|
395
|
+
def spawn_async_endpoint(
|
|
396
|
+
self, app_name: str, path: str, *, input: Any, webhook: Optional[dict] = None
|
|
397
|
+
) -> dict:
|
|
398
|
+
"""POST /v1/serve-async/<app>/<path> — enqueue an async job on a deployed
|
|
399
|
+
endpoint; the control plane answers 202 with a call_id without holding the
|
|
400
|
+
connection (async-jobs-study.md)."""
|
|
401
|
+
body: dict[str, Any] = {"input": input}
|
|
402
|
+
if webhook:
|
|
403
|
+
body["webhook"] = webhook
|
|
404
|
+
app_seg = quote(app_name, safe="")
|
|
405
|
+
path_seg = quote(path.lstrip("/"), safe="/")
|
|
406
|
+
return self._request("POST", f"/v1/serve-async/{app_seg}/{path_seg}", json=body)
|
|
407
|
+
|
|
408
|
+
def cancel_call(self, call_id: str) -> dict:
|
|
409
|
+
return self._request("POST", f"/v1/calls/{call_id}/cancel")
|
|
410
|
+
|
|
411
|
+
# -- logs ---------------------------------------------------------------
|
|
412
|
+
|
|
413
|
+
def get_logs(
|
|
414
|
+
self,
|
|
415
|
+
run_id: str,
|
|
416
|
+
*,
|
|
417
|
+
cursor: Optional[int] = None,
|
|
418
|
+
limit: int = DEFAULT_PAGE_LIMIT,
|
|
419
|
+
stream: Optional[str] = None,
|
|
420
|
+
) -> dict:
|
|
421
|
+
params: dict[str, Any] = {"limit": limit}
|
|
422
|
+
if cursor is not None:
|
|
423
|
+
params["cursor"] = cursor
|
|
424
|
+
if stream:
|
|
425
|
+
params["stream"] = stream
|
|
426
|
+
return self._request("GET", f"/v1/runs/{run_id}/logs", params=params)
|
|
427
|
+
|
|
428
|
+
def stream_logs(self, run_id: str, *, cursor: Optional[int] = None):
|
|
429
|
+
"""Yield SSE events as ``(event, data_str, event_id)`` tuples.
|
|
430
|
+
|
|
431
|
+
Used by ``blade run`` / ``blade logs --follow``. Reconnection with a
|
|
432
|
+
resume cursor is handled by the caller (it re-invokes with the last seq).
|
|
433
|
+
"""
|
|
434
|
+
import httpx
|
|
435
|
+
|
|
436
|
+
params: dict[str, Any] = {}
|
|
437
|
+
headers: dict[str, str] = {}
|
|
438
|
+
if cursor is not None:
|
|
439
|
+
# Resume via the standard SSE header only; the contract says the header
|
|
440
|
+
# wins over ?cursor= when both are present, so sending both is just
|
|
441
|
+
# redundancy that could mask a divergence bug later (Last-Event-ID is
|
|
442
|
+
# also what an EventSource-style reconnect would send natively).
|
|
443
|
+
headers["Last-Event-ID"] = str(cursor)
|
|
444
|
+
|
|
445
|
+
try:
|
|
446
|
+
with self._http.stream(
|
|
447
|
+
"GET",
|
|
448
|
+
f"/v1/runs/{run_id}/logs/stream",
|
|
449
|
+
params=params,
|
|
450
|
+
headers=headers,
|
|
451
|
+
# Finite read timeout: the server sends `event: ping` every 15 s
|
|
452
|
+
# (openapi /logs/stream). 60 s (4× ping) tolerates jitter but still
|
|
453
|
+
# bounds a silently-wedged socket so iter_lines() can't block
|
|
454
|
+
# forever — a ReadTimeout surfaces as ConnectionFailed and the
|
|
455
|
+
# caller reconnects from the last seq.
|
|
456
|
+
timeout=httpx.Timeout(connect=5.0, read=60.0, write=5.0, pool=5.0),
|
|
457
|
+
) as resp:
|
|
458
|
+
if resp.status_code >= 400:
|
|
459
|
+
body = resp.read()
|
|
460
|
+
try:
|
|
461
|
+
import json
|
|
462
|
+
|
|
463
|
+
parsed = json.loads(body)
|
|
464
|
+
except ValueError:
|
|
465
|
+
parsed = None
|
|
466
|
+
_raise_for_envelope(resp.status_code, parsed)
|
|
467
|
+
yield from _warn_on_gap(_parse_sse(resp.iter_lines()))
|
|
468
|
+
except httpx.HTTPError as e:
|
|
469
|
+
raise exc.ConnectionFailed(f"log stream failed: {e}") from e
|
|
470
|
+
|
|
471
|
+
# -- image builds -------------------------------------------------------
|
|
472
|
+
|
|
473
|
+
def create_image_build(
|
|
474
|
+
self,
|
|
475
|
+
definition: dict,
|
|
476
|
+
definition_hash: Optional[str] = None,
|
|
477
|
+
*,
|
|
478
|
+
force: bool = False,
|
|
479
|
+
force_compression: bool = False,
|
|
480
|
+
context_bundle_id: Optional[str] = None,
|
|
481
|
+
) -> dict:
|
|
482
|
+
body: dict[str, Any] = {"definition": definition}
|
|
483
|
+
if definition_hash:
|
|
484
|
+
body["definition_hash"] = definition_hash
|
|
485
|
+
if force:
|
|
486
|
+
body["force"] = True
|
|
487
|
+
if context_bundle_id:
|
|
488
|
+
# from_dockerfile build context (docs/specs/image-from-dockerfile.md):
|
|
489
|
+
# required iff definition.base.context_sha256 is set.
|
|
490
|
+
body["context_bundle_id"] = context_bundle_id
|
|
491
|
+
if force_compression:
|
|
492
|
+
# Admin-only operator knob (phase2-dogfood-catalog-build.md §4b/§4f.5):
|
|
493
|
+
# omit the field entirely by default so ordinary callers see no
|
|
494
|
+
# behavior change — only `--force-compression` sends it.
|
|
495
|
+
body["force_compression"] = True
|
|
496
|
+
return self._request("POST", "/v1/images/builds", json=body)
|
|
497
|
+
|
|
498
|
+
def get_image_build(self, build_id: str) -> dict:
|
|
499
|
+
return self._request("GET", f"/v1/images/builds/{build_id}")
|
|
500
|
+
|
|
501
|
+
def list_image_builds(
|
|
502
|
+
self,
|
|
503
|
+
*,
|
|
504
|
+
definition_hash: Optional[str] = None,
|
|
505
|
+
state: Optional[list] = None,
|
|
506
|
+
limit: int = DEFAULT_PAGE_LIMIT,
|
|
507
|
+
cursor: Optional[str] = None,
|
|
508
|
+
) -> dict:
|
|
509
|
+
params: dict[str, Any] = {"limit": limit}
|
|
510
|
+
if definition_hash:
|
|
511
|
+
params["definition_hash"] = definition_hash
|
|
512
|
+
if state:
|
|
513
|
+
params["state"] = state
|
|
514
|
+
if cursor:
|
|
515
|
+
params["cursor"] = cursor
|
|
516
|
+
return self._request("GET", "/v1/images/builds", params=params)
|
|
517
|
+
|
|
518
|
+
def list_images(self, *, limit: int = DEFAULT_PAGE_LIMIT, cursor: Optional[str] = None) -> dict:
|
|
519
|
+
params: dict[str, Any] = {"limit": limit}
|
|
520
|
+
if cursor:
|
|
521
|
+
params["cursor"] = cursor
|
|
522
|
+
return self._request("GET", "/v1/images", params=params)
|
|
523
|
+
|
|
524
|
+
def stream_build_logs(self, build_id: str, *, cursor: Optional[int] = None):
|
|
525
|
+
"""Yield SSE ``(event, data, id)`` tuples for a build's logs (same as runs)."""
|
|
526
|
+
import httpx
|
|
527
|
+
|
|
528
|
+
headers: dict[str, str] = {}
|
|
529
|
+
if cursor is not None:
|
|
530
|
+
headers["Last-Event-ID"] = str(cursor)
|
|
531
|
+
try:
|
|
532
|
+
with self._http.stream(
|
|
533
|
+
"GET",
|
|
534
|
+
f"/v1/images/builds/{build_id}/logs/stream",
|
|
535
|
+
headers=headers,
|
|
536
|
+
timeout=httpx.Timeout(connect=5.0, read=60.0, write=5.0, pool=5.0),
|
|
537
|
+
) as resp:
|
|
538
|
+
if resp.status_code >= 400:
|
|
539
|
+
body = resp.read()
|
|
540
|
+
try:
|
|
541
|
+
import json
|
|
542
|
+
|
|
543
|
+
parsed = json.loads(body)
|
|
544
|
+
except ValueError:
|
|
545
|
+
parsed = None
|
|
546
|
+
_raise_for_envelope(resp.status_code, parsed)
|
|
547
|
+
yield from _parse_sse(resp.iter_lines())
|
|
548
|
+
except httpx.HTTPError as e:
|
|
549
|
+
raise exc.ConnectionFailed(f"build log stream failed: {e}") from e
|
|
550
|
+
|
|
551
|
+
def get_build_logs(
|
|
552
|
+
self, build_id: str, *, cursor: Optional[int] = None, limit: int = DEFAULT_PAGE_LIMIT
|
|
553
|
+
) -> dict:
|
|
554
|
+
params: dict[str, Any] = {"limit": limit}
|
|
555
|
+
if cursor is not None:
|
|
556
|
+
params["cursor"] = cursor
|
|
557
|
+
return self._request("GET", f"/v1/images/builds/{build_id}/logs", params=params)
|
|
558
|
+
|
|
559
|
+
# -- volumes ------------------------------------------------------------
|
|
560
|
+
|
|
561
|
+
def create_volume(self, name: str) -> dict:
|
|
562
|
+
return self._request("POST", "/v1/volumes", json={"name": name})
|
|
563
|
+
|
|
564
|
+
def list_volumes(
|
|
565
|
+
self,
|
|
566
|
+
*,
|
|
567
|
+
name: Optional[str] = None,
|
|
568
|
+
limit: int = DEFAULT_PAGE_LIMIT,
|
|
569
|
+
cursor: Optional[str] = None,
|
|
570
|
+
) -> dict:
|
|
571
|
+
params: dict[str, Any] = {"limit": limit}
|
|
572
|
+
if name:
|
|
573
|
+
params["name"] = name
|
|
574
|
+
if cursor:
|
|
575
|
+
params["cursor"] = cursor
|
|
576
|
+
return self._request("GET", "/v1/volumes", params=params)
|
|
577
|
+
|
|
578
|
+
def get_volume(self, volume_id: str) -> dict:
|
|
579
|
+
return self._request("GET", f"/v1/volumes/{volume_id}")
|
|
580
|
+
|
|
581
|
+
def delete_volume(self, volume_id: str) -> None:
|
|
582
|
+
self._request("DELETE", f"/v1/volumes/{volume_id}")
|
|
583
|
+
|
|
584
|
+
def list_volume_files(
|
|
585
|
+
self,
|
|
586
|
+
volume_id: str,
|
|
587
|
+
*,
|
|
588
|
+
path: str = "/",
|
|
589
|
+
generation: Optional[int] = None,
|
|
590
|
+
limit: int = DEFAULT_PAGE_LIMIT,
|
|
591
|
+
cursor: Optional[str] = None,
|
|
592
|
+
) -> dict:
|
|
593
|
+
params: dict[str, Any] = {"path": path, "limit": limit}
|
|
594
|
+
if generation is not None:
|
|
595
|
+
params["generation"] = generation
|
|
596
|
+
if cursor:
|
|
597
|
+
params["cursor"] = cursor
|
|
598
|
+
return self._request("GET", f"/v1/volumes/{volume_id}/files", params=params)
|
|
599
|
+
|
|
600
|
+
def get_volume_file_content(
|
|
601
|
+
self, volume_id: str, path: str, *, generation: Optional[int] = None
|
|
602
|
+
) -> dict:
|
|
603
|
+
params: dict[str, Any] = {"path": path}
|
|
604
|
+
if generation is not None:
|
|
605
|
+
params["generation"] = generation
|
|
606
|
+
return self._request(
|
|
607
|
+
"GET", f"/v1/volumes/{volume_id}/files/content", params=params
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
def create_volume_commit(self, volume_id: str, body: dict) -> dict:
|
|
611
|
+
return self._request("POST", f"/v1/volumes/{volume_id}/commits", json=body)
|
|
612
|
+
|
|
613
|
+
def finalize_volume_commit(self, volume_id: str, commit_id: str) -> dict:
|
|
614
|
+
return self._request(
|
|
615
|
+
"POST", f"/v1/volumes/{volume_id}/commits/{commit_id}/finalize"
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
def list_volume_commits(
|
|
619
|
+
self, volume_id: str, *, limit: int = DEFAULT_PAGE_LIMIT, cursor: Optional[str] = None
|
|
620
|
+
) -> dict:
|
|
621
|
+
params: dict[str, Any] = {"limit": limit}
|
|
622
|
+
if cursor:
|
|
623
|
+
params["cursor"] = cursor
|
|
624
|
+
return self._request("GET", f"/v1/volumes/{volume_id}/commits", params=params)
|
|
625
|
+
|
|
626
|
+
# -- secrets ------------------------------------------------------------
|
|
627
|
+
|
|
628
|
+
def set_secret(self, name: str, values: dict) -> dict:
|
|
629
|
+
return self._request(
|
|
630
|
+
"POST", "/v1/secrets", json={"name": name, "values": values}
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
def list_secrets(
|
|
634
|
+
self,
|
|
635
|
+
*,
|
|
636
|
+
name: Optional[str] = None,
|
|
637
|
+
limit: int = DEFAULT_PAGE_LIMIT,
|
|
638
|
+
cursor: Optional[str] = None,
|
|
639
|
+
) -> dict:
|
|
640
|
+
params: dict[str, Any] = {"limit": limit}
|
|
641
|
+
if name:
|
|
642
|
+
params["name"] = name
|
|
643
|
+
if cursor:
|
|
644
|
+
params["cursor"] = cursor
|
|
645
|
+
return self._request("GET", "/v1/secrets", params=params)
|
|
646
|
+
|
|
647
|
+
def delete_secret(self, secret_id: str) -> None:
|
|
648
|
+
self._request("DELETE", f"/v1/secrets/{secret_id}")
|
|
649
|
+
|
|
650
|
+
# -- gpu types ----------------------------------------------------------
|
|
651
|
+
|
|
652
|
+
def list_gpu_types(self) -> dict:
|
|
653
|
+
return self._request("GET", "/v1/gpu-types")
|
|
654
|
+
|
|
655
|
+
# -- hot bundle reload (blade serve) ------------------------------------
|
|
656
|
+
|
|
657
|
+
def update_run_bundle(self, run_id: str, bundle_id: str) -> dict:
|
|
658
|
+
return self._request(
|
|
659
|
+
"POST", f"/v1/runs/{run_id}/bundle", json={"bundle_id": bundle_id}
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
# -- apps / deployments -------------------------------------------------
|
|
663
|
+
|
|
664
|
+
def create_app(self, name: str) -> dict:
|
|
665
|
+
return self._request("POST", "/v1/apps", json={"name": name})
|
|
666
|
+
|
|
667
|
+
def list_apps(
|
|
668
|
+
self,
|
|
669
|
+
*,
|
|
670
|
+
name: Optional[str] = None,
|
|
671
|
+
state: Optional[str] = None,
|
|
672
|
+
limit: int = DEFAULT_PAGE_LIMIT,
|
|
673
|
+
cursor: Optional[str] = None,
|
|
674
|
+
) -> dict:
|
|
675
|
+
params: dict[str, Any] = {"limit": limit}
|
|
676
|
+
if name:
|
|
677
|
+
params["name"] = name
|
|
678
|
+
if state:
|
|
679
|
+
params["state"] = state
|
|
680
|
+
if cursor:
|
|
681
|
+
params["cursor"] = cursor
|
|
682
|
+
return self._request("GET", "/v1/apps", params=params)
|
|
683
|
+
|
|
684
|
+
def get_app(self, app_id: str) -> dict:
|
|
685
|
+
return self._request("GET", f"/v1/apps/{app_id}")
|
|
686
|
+
|
|
687
|
+
def get_app_coldstarts(self, app_id: str, *, limit: Optional[int] = None) -> dict:
|
|
688
|
+
"""Recent cold-start breakdowns for an app (newest first).
|
|
689
|
+
|
|
690
|
+
Recomputed on the fly from ready replicas' phase columns; returns
|
|
691
|
+
``{"items": []}`` when no replica has gone ready yet (the common case
|
|
692
|
+
right after a ``min_replicas=0`` deploy), so callers must treat the
|
|
693
|
+
result as best-effort.
|
|
694
|
+
"""
|
|
695
|
+
params: dict[str, Any] = {}
|
|
696
|
+
if limit is not None:
|
|
697
|
+
params["limit"] = limit
|
|
698
|
+
return self._request(
|
|
699
|
+
"GET", f"/v1/apps/{app_id}/coldstarts", params=params or None
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
def get_app_by_name(self, name: str) -> dict:
|
|
703
|
+
page = self.list_apps(name=name, limit=1)
|
|
704
|
+
for app in page.get("items", []):
|
|
705
|
+
if app.get("name") == name:
|
|
706
|
+
return app
|
|
707
|
+
raise exc.NotFoundError("APP_NOT_FOUND", f"app {name!r} not found")
|
|
708
|
+
|
|
709
|
+
def stop_app(self, app_id: str) -> dict:
|
|
710
|
+
return self._request("POST", f"/v1/apps/{app_id}/stop")
|
|
711
|
+
|
|
712
|
+
def scale_endpoint(
|
|
713
|
+
self,
|
|
714
|
+
app_id: str,
|
|
715
|
+
endpoint_name: str,
|
|
716
|
+
*,
|
|
717
|
+
min_replicas: Optional[int] = None,
|
|
718
|
+
max_replicas: Optional[int] = None,
|
|
719
|
+
max_concurrent_inputs: Optional[int] = None,
|
|
720
|
+
scale_down_idle_seconds: Optional[int] = None,
|
|
721
|
+
reserved_replicas: Optional[int] = None,
|
|
722
|
+
scaling_policy: Optional[dict] = None,
|
|
723
|
+
clear_scaling_policy: bool = False,
|
|
724
|
+
) -> dict:
|
|
725
|
+
"""Update a deployed endpoint's autoscaling bounds live (no redeploy).
|
|
726
|
+
|
|
727
|
+
Only the provided fields change; the autoscaler picks the new bounds up
|
|
728
|
+
on its next tick. Returns the updated app.
|
|
729
|
+
|
|
730
|
+
``scaling_policy`` is a FULL REPLACEMENT of the endpoint's v2 policy
|
|
731
|
+
(the wire ``{"v": 1, ...}`` document) — the server never merges it
|
|
732
|
+
field by field. Pass ``clear_scaling_policy=True`` to send an explicit
|
|
733
|
+
``null`` (back to default concurrency autoscaling).
|
|
734
|
+
"""
|
|
735
|
+
body: dict[str, Any] = {}
|
|
736
|
+
if min_replicas is not None:
|
|
737
|
+
body["min_replicas"] = min_replicas
|
|
738
|
+
if max_replicas is not None:
|
|
739
|
+
body["max_replicas"] = max_replicas
|
|
740
|
+
if max_concurrent_inputs is not None:
|
|
741
|
+
body["max_concurrent_inputs"] = max_concurrent_inputs
|
|
742
|
+
if scale_down_idle_seconds is not None:
|
|
743
|
+
body["scale_down_idle_seconds"] = scale_down_idle_seconds
|
|
744
|
+
if reserved_replicas is not None:
|
|
745
|
+
body["reserved_replicas"] = reserved_replicas
|
|
746
|
+
if clear_scaling_policy:
|
|
747
|
+
body["scaling_policy"] = None
|
|
748
|
+
elif scaling_policy is not None:
|
|
749
|
+
body["scaling_policy"] = scaling_policy
|
|
750
|
+
return self._request(
|
|
751
|
+
"POST", f"/v1/apps/{app_id}/endpoints/{endpoint_name}/scale", json=body
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
def create_deployment(self, app_id: str, body: dict) -> dict:
|
|
755
|
+
return self._request("POST", f"/v1/apps/{app_id}/deployments", json=body)
|
|
756
|
+
|
|
757
|
+
def list_deployments(
|
|
758
|
+
self,
|
|
759
|
+
app_id: str,
|
|
760
|
+
*,
|
|
761
|
+
state: Optional[str] = None,
|
|
762
|
+
limit: int = DEFAULT_PAGE_LIMIT,
|
|
763
|
+
cursor: Optional[str] = None,
|
|
764
|
+
) -> dict:
|
|
765
|
+
params: dict[str, Any] = {"limit": limit}
|
|
766
|
+
if state:
|
|
767
|
+
params["state"] = state
|
|
768
|
+
if cursor:
|
|
769
|
+
params["cursor"] = cursor
|
|
770
|
+
return self._request("GET", f"/v1/apps/{app_id}/deployments", params=params)
|
|
771
|
+
|
|
772
|
+
def get_deployment(self, deployment_id: str) -> dict:
|
|
773
|
+
return self._request("GET", f"/v1/deployments/{deployment_id}")
|
|
774
|
+
|
|
775
|
+
def get_active_deployment_for_app(self, app: dict) -> dict:
|
|
776
|
+
app_id = app.get("app_id")
|
|
777
|
+
active_id = app.get("active_deployment_id")
|
|
778
|
+
if active_id:
|
|
779
|
+
return self.get_deployment(str(active_id))
|
|
780
|
+
if not app_id:
|
|
781
|
+
raise exc.NotFoundError("DEPLOYMENT_NOT_FOUND", "app has no app_id")
|
|
782
|
+
page = self.list_deployments(str(app_id), state="active", limit=1)
|
|
783
|
+
items = page.get("items", [])
|
|
784
|
+
if items:
|
|
785
|
+
return items[0]
|
|
786
|
+
raise exc.NotFoundError(
|
|
787
|
+
"DEPLOYMENT_NOT_FOUND",
|
|
788
|
+
f"app {app.get('name')!r} has no active deployment",
|
|
789
|
+
)
|
|
790
|
+
|
|
791
|
+
def rollback_app(
|
|
792
|
+
self,
|
|
793
|
+
app_id: str,
|
|
794
|
+
*,
|
|
795
|
+
deployment_id: Optional[str] = None,
|
|
796
|
+
version: Optional[int] = None,
|
|
797
|
+
) -> dict:
|
|
798
|
+
body: dict[str, Any] = {}
|
|
799
|
+
if deployment_id is not None:
|
|
800
|
+
body["deployment_id"] = deployment_id
|
|
801
|
+
if version is not None:
|
|
802
|
+
body["version"] = version
|
|
803
|
+
return self._request("POST", f"/v1/apps/{app_id}/rollback", json=body)
|
|
804
|
+
|
|
805
|
+
def invoke_web_endpoint(
|
|
806
|
+
self,
|
|
807
|
+
app_name: str,
|
|
808
|
+
endpoint_path: str,
|
|
809
|
+
*,
|
|
810
|
+
method: str = "POST",
|
|
811
|
+
json: Any = None,
|
|
812
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
813
|
+
params: Optional[Mapping[str, Any]] = None,
|
|
814
|
+
url: Optional[str] = None,
|
|
815
|
+
) -> Any:
|
|
816
|
+
import httpx
|
|
817
|
+
|
|
818
|
+
if not endpoint_path.startswith("/"):
|
|
819
|
+
endpoint_path = f"/{endpoint_path}"
|
|
820
|
+
app_segment = quote(app_name, safe="")
|
|
821
|
+
path_segment = quote(endpoint_path.lstrip("/"), safe="/")
|
|
822
|
+
path_url = f"/serve/{app_segment}/{path_segment}"
|
|
823
|
+
# The address the SERVER reported first, the path ingress behind it. A host
|
|
824
|
+
# route that cannot be resolved — a cell with no wildcard DNS, a stale record —
|
|
825
|
+
# must not be a dead end for a client that can still reach the control plane.
|
|
826
|
+
# Only a failure to CONNECT falls through: once the host has answered anything,
|
|
827
|
+
# including a 401 or a 503, that answer is the answer.
|
|
828
|
+
targets = [url, path_url] if url and url != path_url else [path_url]
|
|
829
|
+
# The ingress layer may block up to IngressWakeTimeout (server default: 60 s)
|
|
830
|
+
# while waiting for a scale-to-zero replica to start. The default client
|
|
831
|
+
# timeout of 30 s would fire before that window closes, surfacing a
|
|
832
|
+
# misleading ConnectionFailed instead of a successful cold-start response.
|
|
833
|
+
# Use a dedicated read timeout of 90 s (1.5× the server maximum) so a
|
|
834
|
+
# legitimate wake-from-zero always completes. The connect timeout remains
|
|
835
|
+
# short (5 s) to catch genuine network unreachability quickly.
|
|
836
|
+
# On UNAVAILABLE (503) the ingress polled past its wake window without
|
|
837
|
+
# getting a replica; retry up to 2 times with a short backoff
|
|
838
|
+
# (conventions.md §2: "le client peut retenter").
|
|
839
|
+
_INGRESS_TIMEOUT = httpx.Timeout(connect=5.0, read=90.0, write=10.0, pool=5.0)
|
|
840
|
+
_MAX_RETRIES = 2
|
|
841
|
+
_RETRY_INITIAL = 1.0
|
|
842
|
+
_RETRY_BACKOFF = 2.0
|
|
843
|
+
|
|
844
|
+
for index, target in enumerate(targets):
|
|
845
|
+
delay = _RETRY_INITIAL
|
|
846
|
+
for attempt in range(_MAX_RETRIES + 1):
|
|
847
|
+
try:
|
|
848
|
+
return self._request(
|
|
849
|
+
method.upper(),
|
|
850
|
+
target,
|
|
851
|
+
json=json,
|
|
852
|
+
headers=dict(headers or {}),
|
|
853
|
+
params=dict(params or {}),
|
|
854
|
+
timeout=_INGRESS_TIMEOUT,
|
|
855
|
+
)
|
|
856
|
+
except exc.UnavailableError:
|
|
857
|
+
if attempt >= _MAX_RETRIES:
|
|
858
|
+
raise
|
|
859
|
+
time.sleep(delay)
|
|
860
|
+
delay *= _RETRY_BACKOFF
|
|
861
|
+
except exc.ConnectionFailed as e:
|
|
862
|
+
# Never reached it at all, and something else to try: fall through
|
|
863
|
+
# WITHOUT consuming this target's remaining 503 retries.
|
|
864
|
+
if index + 1 < len(targets) and _could_not_connect(e):
|
|
865
|
+
break
|
|
866
|
+
raise
|
|
867
|
+
# Unreachable; satisfy type checkers.
|
|
868
|
+
raise exc.UnavailableError("UNAVAILABLE", "service unavailable after retries")
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
# -- maps (batch fan-out) -----------------------------------------------
|
|
872
|
+
|
|
873
|
+
def create_map(self, body: dict) -> dict:
|
|
874
|
+
return self._request("POST", "/v1/maps", json=body)
|
|
875
|
+
|
|
876
|
+
def get_map(self, map_id: str) -> dict:
|
|
877
|
+
return self._request("GET", f"/v1/maps/{map_id}")
|
|
878
|
+
|
|
879
|
+
def push_map_inputs(self, map_id: str, inputs: list) -> dict:
|
|
880
|
+
return self._request(
|
|
881
|
+
"POST", f"/v1/maps/{map_id}/inputs", json={"inputs": inputs}
|
|
882
|
+
)
|
|
883
|
+
|
|
884
|
+
def finalize_map(self, map_id: str) -> dict:
|
|
885
|
+
return self._request("POST", f"/v1/maps/{map_id}/finalize")
|
|
886
|
+
|
|
887
|
+
def cancel_map(self, map_id: str) -> dict:
|
|
888
|
+
return self._request("POST", f"/v1/maps/{map_id}/cancel")
|
|
889
|
+
|
|
890
|
+
def get_map_results(
|
|
891
|
+
self,
|
|
892
|
+
map_id: str,
|
|
893
|
+
*,
|
|
894
|
+
order: str = "index",
|
|
895
|
+
return_exceptions: bool = False,
|
|
896
|
+
cursor: Optional[str] = None,
|
|
897
|
+
limit: int = 200,
|
|
898
|
+
) -> dict:
|
|
899
|
+
params: dict[str, Any] = {
|
|
900
|
+
"order": order,
|
|
901
|
+
"return_exceptions": "true" if return_exceptions else "false",
|
|
902
|
+
"limit": limit,
|
|
903
|
+
}
|
|
904
|
+
if cursor:
|
|
905
|
+
params["cursor"] = cursor
|
|
906
|
+
return self._request("GET", f"/v1/maps/{map_id}/results", params=params)
|
|
907
|
+
|
|
908
|
+
def list_maps(
|
|
909
|
+
self,
|
|
910
|
+
*,
|
|
911
|
+
limit: int = DEFAULT_PAGE_LIMIT,
|
|
912
|
+
cursor: Optional[str] = None,
|
|
913
|
+
) -> dict:
|
|
914
|
+
params: dict[str, Any] = {"limit": limit}
|
|
915
|
+
if cursor:
|
|
916
|
+
params["cursor"] = cursor
|
|
917
|
+
return self._request("GET", "/v1/maps", params=params)
|
|
918
|
+
|
|
919
|
+
# -- crons --------------------------------------------------------------
|
|
920
|
+
|
|
921
|
+
def list_crons(self, app_id: str) -> dict:
|
|
922
|
+
return self._request("GET", f"/v1/apps/{app_id}/crons")
|
|
923
|
+
|
|
924
|
+
def get_cron(self, cron_id: str) -> dict:
|
|
925
|
+
return self._request("GET", f"/v1/crons/{cron_id}")
|
|
926
|
+
|
|
927
|
+
def list_cron_executions(
|
|
928
|
+
self,
|
|
929
|
+
cron_id: str,
|
|
930
|
+
*,
|
|
931
|
+
limit: int = DEFAULT_PAGE_LIMIT,
|
|
932
|
+
cursor: Optional[str] = None,
|
|
933
|
+
) -> dict:
|
|
934
|
+
params: dict[str, Any] = {"limit": limit}
|
|
935
|
+
if cursor:
|
|
936
|
+
params["cursor"] = cursor
|
|
937
|
+
return self._request(
|
|
938
|
+
"GET", f"/v1/crons/{cron_id}/executions", params=params
|
|
939
|
+
)
|
|
940
|
+
|
|
941
|
+
def trigger_cron(self, cron_id: str) -> dict:
|
|
942
|
+
return self._request("POST", f"/v1/crons/{cron_id}/trigger")
|
|
943
|
+
|
|
944
|
+
def pause_cron(self, cron_id: str) -> dict:
|
|
945
|
+
return self._request("POST", f"/v1/crons/{cron_id}/pause")
|
|
946
|
+
|
|
947
|
+
def resume_cron(self, cron_id: str) -> dict:
|
|
948
|
+
return self._request("POST", f"/v1/crons/{cron_id}/resume")
|
|
949
|
+
|
|
950
|
+
# -- queues (name-keyed, get-or-create on use — openapi /v1/queues) -----
|
|
951
|
+
|
|
952
|
+
def get_queue(self, name: str) -> dict:
|
|
953
|
+
"""GET /v1/queues/{name} — returns {queue_id, name, len, created_at}."""
|
|
954
|
+
return self._request("GET", f"/v1/queues/{_quote_key(name)}")
|
|
955
|
+
|
|
956
|
+
def delete_queue(self, name: str) -> None:
|
|
957
|
+
self._request("DELETE", f"/v1/queues/{_quote_key(name)}")
|
|
958
|
+
|
|
959
|
+
def queue_put(self, name: str, payload: dict, *, block: bool = False,
|
|
960
|
+
wait_seconds: int = 0) -> dict:
|
|
961
|
+
body: dict[str, Any] = {"value": payload}
|
|
962
|
+
if block:
|
|
963
|
+
body["block"] = True
|
|
964
|
+
body["wait_seconds"] = wait_seconds
|
|
965
|
+
return self._request("POST", f"/v1/queues/{_quote_key(name)}/put", json=body)
|
|
966
|
+
|
|
967
|
+
def queue_get(self, name: str, *, timeout: int = 0) -> dict:
|
|
968
|
+
"""POST /v1/queues/{name}/get — claim-and-delete the head item.
|
|
969
|
+
Returns {item, item_id}; item is null when empty (never 404)."""
|
|
970
|
+
body: dict[str, Any] = {}
|
|
971
|
+
if timeout > 0:
|
|
972
|
+
body = {"block": True, "wait_seconds": timeout}
|
|
973
|
+
return self._request("POST", f"/v1/queues/{_quote_key(name)}/get", json=body)
|
|
974
|
+
|
|
975
|
+
# -- dicts (name-keyed, get-or-create on use — openapi /v1/dicts) -------
|
|
976
|
+
|
|
977
|
+
def get_dict(self, name: str) -> dict:
|
|
978
|
+
"""GET /v1/dicts/{name} — returns {dict_id, name, created_at}."""
|
|
979
|
+
return self._request("GET", f"/v1/dicts/{_quote_key(name)}")
|
|
980
|
+
|
|
981
|
+
def delete_dict(self, name: str) -> None:
|
|
982
|
+
self._request("DELETE", f"/v1/dicts/{_quote_key(name)}")
|
|
983
|
+
|
|
984
|
+
def dict_get(self, name: str, key: str) -> dict:
|
|
985
|
+
"""GET /v1/dicts/{name}/items/{key} — returns {found, value}."""
|
|
986
|
+
return self._request(
|
|
987
|
+
"GET", f"/v1/dicts/{_quote_key(name)}/items/{_quote_key(key)}"
|
|
988
|
+
)
|
|
989
|
+
|
|
990
|
+
def dict_put(self, name: str, key: str, payload: dict) -> None:
|
|
991
|
+
self._request(
|
|
992
|
+
"PUT", f"/v1/dicts/{_quote_key(name)}/items/{_quote_key(key)}",
|
|
993
|
+
json={"value": payload},
|
|
994
|
+
)
|
|
995
|
+
|
|
996
|
+
def dict_delete(self, name: str, key: str) -> None:
|
|
997
|
+
self._request(
|
|
998
|
+
"DELETE", f"/v1/dicts/{_quote_key(name)}/items/{_quote_key(key)}"
|
|
999
|
+
)
|
|
1000
|
+
|
|
1001
|
+
# -- namespaces ---------------------------------------------------------
|
|
1002
|
+
|
|
1003
|
+
def list_namespaces(
|
|
1004
|
+
self,
|
|
1005
|
+
*,
|
|
1006
|
+
limit: int = DEFAULT_PAGE_LIMIT,
|
|
1007
|
+
cursor: Optional[str] = None,
|
|
1008
|
+
) -> dict:
|
|
1009
|
+
params: dict[str, Any] = {"limit": limit}
|
|
1010
|
+
if cursor:
|
|
1011
|
+
params["cursor"] = cursor
|
|
1012
|
+
return self._request("GET", "/v1/namespaces", params=params)
|
|
1013
|
+
|
|
1014
|
+
|
|
1015
|
+
# -- sandboxes ----------------------------------------------------------
|
|
1016
|
+
|
|
1017
|
+
def create_sandbox(self, body: dict) -> dict:
|
|
1018
|
+
return self._request("POST", "/v1/sandboxes", json=body)
|
|
1019
|
+
|
|
1020
|
+
def get_sandbox(self, sandbox_id: str) -> dict:
|
|
1021
|
+
return self._request("GET", f"/v1/sandboxes/{sandbox_id}")
|
|
1022
|
+
|
|
1023
|
+
def list_sandboxes(
|
|
1024
|
+
self,
|
|
1025
|
+
*,
|
|
1026
|
+
state: Optional[list] = None,
|
|
1027
|
+
limit: int = DEFAULT_PAGE_LIMIT,
|
|
1028
|
+
cursor: Optional[str] = None,
|
|
1029
|
+
) -> dict:
|
|
1030
|
+
params: dict[str, Any] = {"limit": limit}
|
|
1031
|
+
if state:
|
|
1032
|
+
params["state"] = state
|
|
1033
|
+
if cursor:
|
|
1034
|
+
params["cursor"] = cursor
|
|
1035
|
+
return self._request("GET", "/v1/sandboxes", params=params)
|
|
1036
|
+
|
|
1037
|
+
def sandbox_terminate(self, sandbox_id: str) -> dict:
|
|
1038
|
+
return self._request("POST", f"/v1/sandboxes/{sandbox_id}/terminate")
|
|
1039
|
+
|
|
1040
|
+
def sandbox_exec(self, sandbox_id: str, body: dict) -> dict:
|
|
1041
|
+
return self._request("POST", f"/v1/sandboxes/{sandbox_id}/exec", json=body)
|
|
1042
|
+
|
|
1043
|
+
def get_sandbox_exec(self, sandbox_id: str, exec_id: str) -> dict:
|
|
1044
|
+
return self._request("GET", f"/v1/sandboxes/{sandbox_id}/execs/{exec_id}")
|
|
1045
|
+
|
|
1046
|
+
def sandbox_put_file(self, sandbox_id: str, body: dict) -> None:
|
|
1047
|
+
self._request("PUT", f"/v1/sandboxes/{sandbox_id}/files", json=body)
|
|
1048
|
+
|
|
1049
|
+
def sandbox_get_file(self, sandbox_id: str, path: str) -> dict:
|
|
1050
|
+
return self._request(
|
|
1051
|
+
"GET",
|
|
1052
|
+
f"/v1/sandboxes/{sandbox_id}/files/content",
|
|
1053
|
+
params={"path": path},
|
|
1054
|
+
)
|
|
1055
|
+
|
|
1056
|
+
def sandbox_ls(
|
|
1057
|
+
self,
|
|
1058
|
+
sandbox_id: str,
|
|
1059
|
+
path: str,
|
|
1060
|
+
*,
|
|
1061
|
+
limit: int = 200,
|
|
1062
|
+
cursor: Optional[str] = None,
|
|
1063
|
+
) -> dict:
|
|
1064
|
+
params: dict[str, Any] = {"path": path, "limit": limit}
|
|
1065
|
+
if cursor:
|
|
1066
|
+
params["cursor"] = cursor
|
|
1067
|
+
return self._request("GET", f"/v1/sandboxes/{sandbox_id}/ls", params=params)
|
|
1068
|
+
|
|
1069
|
+
def sandbox_open_tunnel(self, sandbox_id: str, container_port: int) -> dict:
|
|
1070
|
+
return self._request(
|
|
1071
|
+
"POST",
|
|
1072
|
+
f"/v1/sandboxes/{sandbox_id}/tunnels",
|
|
1073
|
+
json={"container_port": container_port},
|
|
1074
|
+
)
|
|
1075
|
+
|
|
1076
|
+
def sandbox_close_tunnel(self, sandbox_id: str, tunnel_id: str) -> None:
|
|
1077
|
+
self._request("DELETE", f"/v1/sandboxes/{sandbox_id}/tunnels/{tunnel_id}")
|
|
1078
|
+
|
|
1079
|
+
def sandbox_list_tunnels(self, sandbox_id: str) -> dict:
|
|
1080
|
+
return self._request("GET", f"/v1/sandboxes/{sandbox_id}/tunnels")
|
|
1081
|
+
|
|
1082
|
+
def stream_sandbox_logs(self, sandbox_id: str, *, cursor: Optional[int] = None):
|
|
1083
|
+
"""Yield SSE log events for a sandbox (same protocol as run logs)."""
|
|
1084
|
+
import httpx
|
|
1085
|
+
|
|
1086
|
+
headers: dict[str, str] = {}
|
|
1087
|
+
if cursor is not None:
|
|
1088
|
+
headers["Last-Event-ID"] = str(cursor)
|
|
1089
|
+
try:
|
|
1090
|
+
with self._http.stream(
|
|
1091
|
+
"GET",
|
|
1092
|
+
f"/v1/sandboxes/{sandbox_id}/logs/stream",
|
|
1093
|
+
headers=headers,
|
|
1094
|
+
timeout=httpx.Timeout(connect=5.0, read=60.0, write=5.0, pool=5.0),
|
|
1095
|
+
) as resp:
|
|
1096
|
+
if resp.status_code >= 400:
|
|
1097
|
+
body = resp.read()
|
|
1098
|
+
try:
|
|
1099
|
+
import json as _json
|
|
1100
|
+
parsed = _json.loads(body)
|
|
1101
|
+
except ValueError:
|
|
1102
|
+
parsed = None
|
|
1103
|
+
_raise_for_envelope(resp.status_code, parsed)
|
|
1104
|
+
yield from _warn_on_gap(_parse_sse(resp.iter_lines()))
|
|
1105
|
+
except httpx.HTTPError as e:
|
|
1106
|
+
raise exc.ConnectionFailed(f"sandbox log stream failed: {e}") from e
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
def _quote_key(key: str) -> str:
|
|
1110
|
+
"""URL-encode a Dict key for use in a path segment."""
|
|
1111
|
+
return quote(key, safe="")
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
def _warn_on_gap(events):
|
|
1115
|
+
"""Pass SSE events through, warning on stderr at each ``gap`` (the stream stays open)."""
|
|
1116
|
+
for event in events:
|
|
1117
|
+
if event[0] == "gap":
|
|
1118
|
+
from ._render import warn_logs_incomplete
|
|
1119
|
+
|
|
1120
|
+
warn_logs_incomplete()
|
|
1121
|
+
yield event
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
def _parse_sse(lines):
|
|
1125
|
+
"""Parse an SSE byte/line iterator into ``(event, data, id)`` tuples."""
|
|
1126
|
+
event = "message"
|
|
1127
|
+
data_lines: list[str] = []
|
|
1128
|
+
last_id: Optional[str] = None
|
|
1129
|
+
for raw in lines:
|
|
1130
|
+
line = raw if isinstance(raw, str) else raw.decode("utf-8", "replace")
|
|
1131
|
+
if line == "":
|
|
1132
|
+
if data_lines:
|
|
1133
|
+
yield event, "\n".join(data_lines), last_id
|
|
1134
|
+
event = "message"
|
|
1135
|
+
data_lines = []
|
|
1136
|
+
continue
|
|
1137
|
+
if line.startswith(":"):
|
|
1138
|
+
continue # comment
|
|
1139
|
+
if line.startswith("event:"):
|
|
1140
|
+
event = line[len("event:"):].strip()
|
|
1141
|
+
elif line.startswith("data:"):
|
|
1142
|
+
# SSE spec: strip exactly one leading space after the colon, not all
|
|
1143
|
+
# whitespace — so a payload that begins with significant spaces/tabs
|
|
1144
|
+
# survives intact.
|
|
1145
|
+
value = line[len("data:"):]
|
|
1146
|
+
if value.startswith(" "):
|
|
1147
|
+
value = value[1:]
|
|
1148
|
+
data_lines.append(value)
|
|
1149
|
+
elif line.startswith("id:"):
|
|
1150
|
+
last_id = line[len("id:"):].strip()
|
|
1151
|
+
if data_lines:
|
|
1152
|
+
yield event, "\n".join(data_lines), last_id
|