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/_build.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""Image-build resolution: create-or-find a build, stream build logs, wait.
|
|
2
|
+
|
|
3
|
+
Used when ``@app.function(image=bladekit.Image(...))`` is first invoked. The build
|
|
4
|
+
is idempotent by definition hash (image-dsl.md §7), so an unchanged image
|
|
5
|
+
triggers zero rebuild. While a build is ``queued``/``building`` the SDK streams
|
|
6
|
+
the build's SSE logs to the terminal (same mechanism as run logs), then reports
|
|
7
|
+
success or raises an actionable :class:`bladekit.ImageBuildError`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
from . import exceptions as exc
|
|
16
|
+
|
|
17
|
+
_TERMINAL = frozenset({"succeeded", "failed"})
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def ensure_context_bundle(client: Any, data: bytes) -> str:
|
|
21
|
+
"""Upload a from_dockerfile build context as a bundle; return its id.
|
|
22
|
+
|
|
23
|
+
Same flow as code bundles (create → presigned PUT → complete), so an
|
|
24
|
+
unchanged context deduplicates server-side by sha256 and uploads nothing.
|
|
25
|
+
The complete response is authoritative for the id: a concurrent identical
|
|
26
|
+
upload may adopt the winning ready bundle.
|
|
27
|
+
"""
|
|
28
|
+
import hashlib
|
|
29
|
+
|
|
30
|
+
from ._constants import BUNDLE_MAX_BYTES
|
|
31
|
+
|
|
32
|
+
if len(data) > BUNDLE_MAX_BYTES:
|
|
33
|
+
raise exc.PayloadTooLargeError(
|
|
34
|
+
"BUNDLE_TOO_LARGE",
|
|
35
|
+
f"build context is {len(data) / 1e6:.1f} MB compressed, over the "
|
|
36
|
+
f"{BUNDLE_MAX_BYTES // (1024 * 1024)} MiB limit — add a .dockerignore "
|
|
37
|
+
"(large data belongs in a bladekit.Volume, not the image)",
|
|
38
|
+
)
|
|
39
|
+
sha = hashlib.sha256(data).hexdigest()
|
|
40
|
+
resp = client.create_bundle(len(data), sha)
|
|
41
|
+
bundle_id = resp["bundle"]["bundle_id"]
|
|
42
|
+
if resp.get("deduplicated"):
|
|
43
|
+
return bundle_id
|
|
44
|
+
client.upload_to_presigned(resp["upload_url"], data, "application/gzip")
|
|
45
|
+
completed = client.complete_bundle(bundle_id)
|
|
46
|
+
return completed.get("bundle_id", bundle_id)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def resolve_image_build(
|
|
50
|
+
client: Any,
|
|
51
|
+
definition: dict,
|
|
52
|
+
definition_hash: str,
|
|
53
|
+
*,
|
|
54
|
+
context_data: Optional[bytes] = None,
|
|
55
|
+
) -> str:
|
|
56
|
+
"""Return the build_id of a ``succeeded`` build for this definition.
|
|
57
|
+
|
|
58
|
+
Creates the build if needed (idempotent), streams logs while it runs, and
|
|
59
|
+
raises ImageBuildError on failure.
|
|
60
|
+
|
|
61
|
+
Clean mode (default) collapses the whole build into a single ``building``
|
|
62
|
+
phase line — a spinner while it runs, then ``✓ building <short-ref> (dur)``
|
|
63
|
+
— and SUPPRESSES the raw build-step log chunks (that noise is only useful
|
|
64
|
+
when debugging). ``--verbose`` keeps the old behaviour: a start line, the
|
|
65
|
+
streamed build steps, per-state transitions, and an ``image built`` line.
|
|
66
|
+
"""
|
|
67
|
+
from ._render import console, is_verbose, phase
|
|
68
|
+
|
|
69
|
+
context_bundle_id = (
|
|
70
|
+
ensure_context_bundle(client, context_data) if context_data else None
|
|
71
|
+
)
|
|
72
|
+
resp = client.create_image_build(
|
|
73
|
+
definition, definition_hash, context_bundle_id=context_bundle_id
|
|
74
|
+
)
|
|
75
|
+
build = resp["build"]
|
|
76
|
+
build_id = build["build_id"]
|
|
77
|
+
state = build.get("state")
|
|
78
|
+
|
|
79
|
+
if state == "succeeded":
|
|
80
|
+
# Already built (deduplicated): nothing to wait for, common fast path.
|
|
81
|
+
return build_id
|
|
82
|
+
|
|
83
|
+
if state == "failed":
|
|
84
|
+
# Per image-dsl.md §7, a POST whose last build is `failed` yields a NEW
|
|
85
|
+
# queued build; so seeing `failed` here would be a freshly-adopted
|
|
86
|
+
# terminal failure — surface it.
|
|
87
|
+
_raise_build_failed(build, build_id, definition_hash)
|
|
88
|
+
|
|
89
|
+
# queued / building (or an unknown non-terminal state): stream logs + wait.
|
|
90
|
+
if is_verbose():
|
|
91
|
+
console.print(
|
|
92
|
+
f"[cyan]building image[/cyan] [dim]{definition_hash[:12]}…[/dim] "
|
|
93
|
+
f"(build {build_id})"
|
|
94
|
+
)
|
|
95
|
+
final = _stream_build_logs(client, build_id)
|
|
96
|
+
final_state = final.get("state")
|
|
97
|
+
if final_state == "succeeded":
|
|
98
|
+
console.print(
|
|
99
|
+
f"[green]image built[/green] [dim]{definition_hash[:12]}…[/dim]"
|
|
100
|
+
)
|
|
101
|
+
return build_id
|
|
102
|
+
if final_state == "failed":
|
|
103
|
+
_raise_build_failed(final, build_id, definition_hash)
|
|
104
|
+
return _confirm_terminal(client, build_id, definition_hash)
|
|
105
|
+
|
|
106
|
+
# Clean mode: one collapsed phase line, build-step chunks suppressed.
|
|
107
|
+
with phase("building", f"{definition_hash[:8]}…") as ph:
|
|
108
|
+
final = _stream_build_logs(client, build_id, quiet=True)
|
|
109
|
+
final_state = final.get("state")
|
|
110
|
+
if final_state == "failed":
|
|
111
|
+
_raise_build_failed(final, build_id, definition_hash)
|
|
112
|
+
# Fetch the finished build once for the real short ref + timestamps
|
|
113
|
+
# (the SSE eof path drops both), so the resolved line shows the built
|
|
114
|
+
# ref and the true build duration rather than a wall-clock estimate.
|
|
115
|
+
if final_state != "succeeded":
|
|
116
|
+
build_id = _confirm_terminal(client, build_id, definition_hash)
|
|
117
|
+
fb = _safe_get_build(client, build_id)
|
|
118
|
+
if fb is not None:
|
|
119
|
+
from ._render import duration_str
|
|
120
|
+
|
|
121
|
+
ph.update(
|
|
122
|
+
meta=_short_image_ref(fb.get("image_ref"), definition_hash),
|
|
123
|
+
duration=duration_str(
|
|
124
|
+
fb.get("started_at") or fb.get("created_at"),
|
|
125
|
+
fb.get("finished_at"),
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
else:
|
|
129
|
+
ph.update(meta=_short_image_ref(None, definition_hash))
|
|
130
|
+
return build_id
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def run_standalone_build(
|
|
134
|
+
client: Any,
|
|
135
|
+
definition: dict,
|
|
136
|
+
definition_hash: str,
|
|
137
|
+
*,
|
|
138
|
+
force: bool = False,
|
|
139
|
+
force_compression: bool = False,
|
|
140
|
+
context_data: Optional[bytes] = None,
|
|
141
|
+
) -> dict:
|
|
142
|
+
"""Drive a definition through ``POST /v1/images/builds`` to a terminal
|
|
143
|
+
state, returning the FULL final build record (``state``, ``image_ref``, …).
|
|
144
|
+
|
|
145
|
+
Used by the standalone ``blade image build`` CLI command
|
|
146
|
+
(docs/specs/phase2-dogfood-catalog-build.md §4b). Unlike
|
|
147
|
+
:func:`resolve_image_build` — the IMPLICIT path triggered by ``run``/
|
|
148
|
+
``deploy``, which only ever returns a ``build_id`` — this always surfaces
|
|
149
|
+
the full record so the caller can print the digest-pinned ``image_ref`` or
|
|
150
|
+
emit ``--json``, and it threads through the explicit ``--force``/
|
|
151
|
+
``--force-compression`` flags that the implicit path never sets.
|
|
152
|
+
|
|
153
|
+
Always streams quietly (build-step chunks suppressed), regardless of
|
|
154
|
+
``--verbose``: build-log chunks are unconditionally tagged ``stdout``
|
|
155
|
+
server-side (``imagebuilder.go:492``), so surfacing them raw would leak
|
|
156
|
+
onto stdout and break ``blade image build --json``'s stdout-purity
|
|
157
|
+
contract (only the final JSON object may land on stdout). Use
|
|
158
|
+
``blade image logs -f <build_id>`` for the raw step-by-step stream. The
|
|
159
|
+
collapsed ``building`` phase line (and any raised error) is rendered on
|
|
160
|
+
``err_console`` instead of the default stdout ``console``, for the same
|
|
161
|
+
reason.
|
|
162
|
+
|
|
163
|
+
Raises :class:`bladekit.ImageBuildError` on a failed or unexpectedly
|
|
164
|
+
terminated build; the caller decides the process exit code.
|
|
165
|
+
"""
|
|
166
|
+
from ._render import err_console, is_verbose, phase, duration_str
|
|
167
|
+
|
|
168
|
+
context_bundle_id = (
|
|
169
|
+
ensure_context_bundle(client, context_data) if context_data else None
|
|
170
|
+
)
|
|
171
|
+
resp = client.create_image_build(
|
|
172
|
+
definition,
|
|
173
|
+
definition_hash,
|
|
174
|
+
force=force,
|
|
175
|
+
force_compression=force_compression,
|
|
176
|
+
context_bundle_id=context_bundle_id,
|
|
177
|
+
)
|
|
178
|
+
build = resp["build"]
|
|
179
|
+
build_id = build["build_id"]
|
|
180
|
+
state = build.get("state")
|
|
181
|
+
|
|
182
|
+
if state == "succeeded":
|
|
183
|
+
# Already built (deduplicated): nothing to wait for, common fast path.
|
|
184
|
+
return build
|
|
185
|
+
|
|
186
|
+
if state == "failed":
|
|
187
|
+
# Per image-dsl.md §7, a POST whose last build is `failed` yields a NEW
|
|
188
|
+
# queued build; so seeing `failed` here would be a freshly-adopted
|
|
189
|
+
# terminal failure — surface it.
|
|
190
|
+
_raise_build_failed(build, build_id, definition_hash)
|
|
191
|
+
|
|
192
|
+
if is_verbose():
|
|
193
|
+
err_console.print(
|
|
194
|
+
f"[cyan]building image[/cyan] [dim]{definition_hash[:12]}…[/dim] "
|
|
195
|
+
f"(build {build_id})"
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
with phase("building", f"{definition_hash[:8]}…", stream=err_console) as ph:
|
|
199
|
+
final = _stream_build_logs(client, build_id, quiet=True)
|
|
200
|
+
if final.get("state") == "failed":
|
|
201
|
+
_raise_build_failed(final, build_id, definition_hash)
|
|
202
|
+
fb = _confirm_terminal_build(client, build_id, definition_hash)
|
|
203
|
+
ph.update(
|
|
204
|
+
meta=_short_image_ref(fb.get("image_ref"), definition_hash),
|
|
205
|
+
duration=duration_str(
|
|
206
|
+
fb.get("started_at") or fb.get("created_at"), fb.get("finished_at")
|
|
207
|
+
),
|
|
208
|
+
)
|
|
209
|
+
return fb
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _confirm_terminal_build(client: Any, build_id: str, definition_hash: str) -> dict:
|
|
213
|
+
"""Final GET after the log stream ends: return the FULL build record on
|
|
214
|
+
success, raise ImageBuildError on ``failed``/any other non-terminal state.
|
|
215
|
+
|
|
216
|
+
Distinct from :func:`_confirm_terminal` (which only returns the
|
|
217
|
+
``build_id``) because :func:`run_standalone_build` needs the full record
|
|
218
|
+
(``image_ref``, timestamps) for its own output.
|
|
219
|
+
"""
|
|
220
|
+
build = client.get_image_build(build_id)
|
|
221
|
+
if build.get("state") == "succeeded":
|
|
222
|
+
return build
|
|
223
|
+
if build.get("state") == "failed":
|
|
224
|
+
_raise_build_failed(build, build_id, definition_hash)
|
|
225
|
+
raise exc.ImageBuildError(
|
|
226
|
+
build_id,
|
|
227
|
+
"INTERNAL",
|
|
228
|
+
f"build ended in unexpected state {build.get('state')!r}",
|
|
229
|
+
definition_hash=definition_hash,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _confirm_terminal(client: Any, build_id: str, definition_hash: str) -> str:
|
|
234
|
+
"""Unknown terminal/aborted stream: a final GET decides success/failure."""
|
|
235
|
+
build = client.get_image_build(build_id)
|
|
236
|
+
if build.get("state") == "succeeded":
|
|
237
|
+
return build_id
|
|
238
|
+
if build.get("state") == "failed":
|
|
239
|
+
_raise_build_failed(build, build_id, definition_hash)
|
|
240
|
+
raise exc.ImageBuildError(
|
|
241
|
+
build_id,
|
|
242
|
+
"INTERNAL",
|
|
243
|
+
f"build ended in unexpected state {build.get('state')!r}",
|
|
244
|
+
definition_hash=definition_hash,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _safe_get_build(client: Any, build_id: str) -> Optional[dict]:
|
|
249
|
+
try:
|
|
250
|
+
return client.get_image_build(build_id)
|
|
251
|
+
except Exception: # noqa: BLE001 — best-effort metadata for the phase line
|
|
252
|
+
return None
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _short_image_ref(image_ref: Optional[str], definition_hash: str) -> str:
|
|
256
|
+
"""Short human ref for a built image — ``built:7d448073…`` — never the full
|
|
257
|
+
sha256. Falls back to the definition hash when no image_ref is available."""
|
|
258
|
+
h: Optional[str] = None
|
|
259
|
+
if image_ref and "built:" in image_ref:
|
|
260
|
+
h = image_ref.rsplit("built:", 1)[-1]
|
|
261
|
+
elif image_ref and ":" in image_ref:
|
|
262
|
+
h = image_ref.rsplit(":", 1)[-1]
|
|
263
|
+
elif image_ref:
|
|
264
|
+
h = image_ref
|
|
265
|
+
h = h or definition_hash
|
|
266
|
+
return f"built:{h[:8]}…" if len(h) > 8 else f"built:{h}"
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _raise_build_failed(build: dict, build_id: str, definition_hash: str) -> None:
|
|
270
|
+
err = build.get("error") or {}
|
|
271
|
+
raise exc.ImageBuildError(
|
|
272
|
+
build.get("build_id", build_id),
|
|
273
|
+
str(err.get("code", "IMAGE_BUILD_FAILED")),
|
|
274
|
+
str(err.get("message", "image build failed")),
|
|
275
|
+
definition_hash=definition_hash,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _stream_build_logs(client: Any, build_id: str, quiet: bool = False) -> dict:
|
|
280
|
+
"""Stream a build's SSE logs to the terminal until eof; return final state.
|
|
281
|
+
|
|
282
|
+
Mirrors the run-log streaming loop: resilient to a dropped connection
|
|
283
|
+
(resume from the last seq), tolerant of unparseable events.
|
|
284
|
+
|
|
285
|
+
``quiet=True`` consumes the stream WITHOUT printing anything (build-step
|
|
286
|
+
chunks and state transitions are suppressed) — used by the clean-mode
|
|
287
|
+
``building`` phase, which shows a single collapsed line instead.
|
|
288
|
+
"""
|
|
289
|
+
from ._render import print_log_chunk, print_state_transition, err_console
|
|
290
|
+
|
|
291
|
+
cursor: Optional[int] = None
|
|
292
|
+
final_state: Optional[str] = None
|
|
293
|
+
while True:
|
|
294
|
+
try:
|
|
295
|
+
for event, data, event_id in client.stream_build_logs(
|
|
296
|
+
build_id, cursor=cursor
|
|
297
|
+
):
|
|
298
|
+
if event == "log":
|
|
299
|
+
try:
|
|
300
|
+
chunk = json.loads(data)
|
|
301
|
+
except ValueError:
|
|
302
|
+
continue
|
|
303
|
+
if not quiet:
|
|
304
|
+
print_log_chunk(chunk)
|
|
305
|
+
if isinstance(chunk, dict) and "seq" in chunk:
|
|
306
|
+
cursor = chunk["seq"]
|
|
307
|
+
elif event_id is not None:
|
|
308
|
+
try:
|
|
309
|
+
cursor = int(event_id)
|
|
310
|
+
except ValueError:
|
|
311
|
+
pass
|
|
312
|
+
elif event == "state":
|
|
313
|
+
try:
|
|
314
|
+
payload = json.loads(data)
|
|
315
|
+
except ValueError:
|
|
316
|
+
continue
|
|
317
|
+
final_state = payload.get("state")
|
|
318
|
+
if not quiet:
|
|
319
|
+
print_state_transition(final_state, payload.get("ts"))
|
|
320
|
+
elif event == "eof":
|
|
321
|
+
return {"state": final_state}
|
|
322
|
+
elif event == "ping":
|
|
323
|
+
continue
|
|
324
|
+
# Generator ended without eof: check whether the build is terminal.
|
|
325
|
+
build = client.get_image_build(build_id)
|
|
326
|
+
if build.get("state") in _TERMINAL:
|
|
327
|
+
return build
|
|
328
|
+
return {"state": final_state}
|
|
329
|
+
except exc.ConnectionFailed:
|
|
330
|
+
build = client.get_image_build(build_id)
|
|
331
|
+
if build.get("state") in _TERMINAL:
|
|
332
|
+
return build
|
|
333
|
+
err_console.print("[dim]build log stream dropped, reconnecting…[/dim]")
|
|
334
|
+
import time
|
|
335
|
+
|
|
336
|
+
time.sleep(0.5)
|
|
337
|
+
continue
|