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/_image.py
ADDED
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
"""``bladekit.Image`` — chained, immutable image-definition DSL.
|
|
2
|
+
|
|
3
|
+
Each builder method returns a NEW ``Image`` with one extra layer appended; an
|
|
4
|
+
``Image`` is never mutated in place, so the same base can branch safely::
|
|
5
|
+
|
|
6
|
+
base = bladekit.Image.debian_slim(python_version="3.12").apt_install("git")
|
|
7
|
+
a = base.pip_install("torch")
|
|
8
|
+
b = base.pip_install("jax") # `base` unchanged
|
|
9
|
+
|
|
10
|
+
The canonical JSON serialization and the ``definition_hash`` are STRICTLY the
|
|
11
|
+
ones specified in contracts/image-dsl.md §2/§3:
|
|
12
|
+
|
|
13
|
+
- canonical bytes = ``json.dumps(d, sort_keys=True, separators=(",", ":"),
|
|
14
|
+
ensure_ascii=False)`` encoded UTF-8;
|
|
15
|
+
- absent/empty optional fields are dropped before hashing (one representation
|
|
16
|
+
per definition);
|
|
17
|
+
- array order is preserved (layers, packages) — never sorted;
|
|
18
|
+
- ``definition_hash`` = lowercase-hex sha256 of those bytes.
|
|
19
|
+
|
|
20
|
+
The hash is computed locally only for display / dedup hints; the server
|
|
21
|
+
recomputes it and is authoritative (image-dsl.md §3). ``hashlib`` / ``json`` are
|
|
22
|
+
stdlib, so importing this module stays cheap (no heavy top-level import) — it is
|
|
23
|
+
imported lazily from ``bladekit.__init__`` only when first referenced is not
|
|
24
|
+
required, but it is light enough to import eagerly.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import hashlib
|
|
30
|
+
import ipaddress
|
|
31
|
+
import json
|
|
32
|
+
import re
|
|
33
|
+
from typing import Any, Dict, List, Mapping, Optional, Sequence
|
|
34
|
+
from urllib.parse import urlsplit
|
|
35
|
+
|
|
36
|
+
from . import exceptions as exc
|
|
37
|
+
|
|
38
|
+
# Limits — contracts/conventions.md §6 and image-dsl.md §2.
|
|
39
|
+
_DEFINITION_VERSION = 1
|
|
40
|
+
_MAX_LAYERS = 64
|
|
41
|
+
_MAX_PACKAGES_PER_LAYER = 256
|
|
42
|
+
_MAX_COMMANDS_PER_LAYER = 64
|
|
43
|
+
_MAX_ENV_VARS_PER_LAYER = 64
|
|
44
|
+
_CANONICAL_MAX_BYTES = 64 * 1024 # 64 KiB
|
|
45
|
+
# from_dockerfile limits (docs/specs/image-from-dockerfile.md §3).
|
|
46
|
+
_MAX_DOCKERFILE_BYTES = 32 * 1024
|
|
47
|
+
_MAX_BUILD_ARGS = 32
|
|
48
|
+
_MAX_BUILD_ARGS_BYTES = 4 * 1024
|
|
49
|
+
|
|
50
|
+
_PYTHON_VERSION_RE = re.compile(r"^3\.[0-9]+$")
|
|
51
|
+
# env keys: ^[A-Z_][A-Z0-9_]*$, never FABRIC_/FORGE_-prefixed (reserved for runtime).
|
|
52
|
+
_ENV_KEY_RE = re.compile(r"^[A-Z_][A-Z0-9_]*$")
|
|
53
|
+
# catalog alias (from_catalog): a catalogue name like "fabric/torch" — not a
|
|
54
|
+
# registry URL. No tag/digest: the server resolves the alias to the real ref.
|
|
55
|
+
_CATALOG_ALIAS_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._/-]*$")
|
|
56
|
+
# Env prefixes the runtime owns (bladekit._env.RESERVED_PREFIXES, inlined so
|
|
57
|
+
# this module keeps importing nothing but exceptions).
|
|
58
|
+
RESERVED_ENV_PREFIXES = ("FABRIC_", "FORGE_")
|
|
59
|
+
|
|
60
|
+
# Catalog aliases are written ``fabric/<name>`` (PAAS-771). The control plane only
|
|
61
|
+
# knows them as ``forge/<name>`` today, so the SDK rewrites the prefix on the wire
|
|
62
|
+
# and the server never sees ``fabric/``. Remove the rewrite once the server
|
|
63
|
+
# accepts both spellings (PAAS-771, server half). ``forge/<name>`` keeps working
|
|
64
|
+
# unchanged.
|
|
65
|
+
CATALOG_PREFIX = "fabric/"
|
|
66
|
+
WIRE_CATALOG_PREFIX = "forge/"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def to_wire_image(ref: Any) -> Any:
|
|
70
|
+
"""Map a ``fabric/<name>`` catalog alias to the ``forge/<name>`` the server knows.
|
|
71
|
+
|
|
72
|
+
Anything else — ``forge/<name>``, a registry ref with a tag or digest, a
|
|
73
|
+
non-string — is returned unchanged.
|
|
74
|
+
"""
|
|
75
|
+
if (
|
|
76
|
+
isinstance(ref, str)
|
|
77
|
+
and ref.startswith(CATALOG_PREFIX)
|
|
78
|
+
and _CATALOG_ALIAS_RE.match(ref)
|
|
79
|
+
):
|
|
80
|
+
return WIRE_CATALOG_PREFIX + ref[len(CATALOG_PREFIX):]
|
|
81
|
+
return ref
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def to_display_image(ref: Any) -> Any:
|
|
85
|
+
"""Inverse of :func:`to_wire_image`, for showing a server alias to the user."""
|
|
86
|
+
if (
|
|
87
|
+
isinstance(ref, str)
|
|
88
|
+
and ref.startswith(WIRE_CATALOG_PREFIX)
|
|
89
|
+
and _CATALOG_ALIAS_RE.match(ref)
|
|
90
|
+
):
|
|
91
|
+
return CATALOG_PREFIX + ref[len(WIRE_CATALOG_PREFIX):]
|
|
92
|
+
return ref
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# Dockerfile ARG names (same charset docker accepts).
|
|
96
|
+
_BUILD_ARG_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# pip index URL policy (PAAS-265) — client-side mirror of the server's
|
|
100
|
+
# authoritative check (control-plane/internal/imagedsl checkIndexURL). The
|
|
101
|
+
# server validates too, but client-side rejection gives an immediate, actionable
|
|
102
|
+
# error rather than a build failure minutes later.
|
|
103
|
+
#
|
|
104
|
+
# * https:// is the only scheme accepted for a public host: a plaintext index
|
|
105
|
+
# lets a network attacker substitute packages during the BuildKit build.
|
|
106
|
+
# * http:// is tolerated ONLY for a loopback / RFC 1918 / link-local host or a
|
|
107
|
+
# bare "localhost" (a dev mirror reachable from the builder alone). Names
|
|
108
|
+
# other than "localhost" are never resolved and count as public.
|
|
109
|
+
# * Everything else (file:, ftp://, ssh://, git+, scheme-less, ...) is
|
|
110
|
+
# rejected — a positive allowlist, not a blocklist.
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _is_private_index_host(host: str) -> bool:
|
|
114
|
+
if host.lower() == "localhost":
|
|
115
|
+
return True
|
|
116
|
+
try:
|
|
117
|
+
ip = ipaddress.ip_address(host.strip("[]"))
|
|
118
|
+
except ValueError:
|
|
119
|
+
return False
|
|
120
|
+
return ip.is_loopback or ip.is_private or ip.is_link_local
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _check_index_url(op: str, param: str, url: str) -> None:
|
|
124
|
+
"""Raise unless *url* is https://, or http:// to a loopback/private host."""
|
|
125
|
+
parsed = urlsplit(url)
|
|
126
|
+
scheme = parsed.scheme.lower()
|
|
127
|
+
host = parsed.hostname or ""
|
|
128
|
+
if scheme not in ("https", "http") or not host:
|
|
129
|
+
raise _invalid(
|
|
130
|
+
f"{op}: {param} uses a disallowed scheme; only https:// is "
|
|
131
|
+
f"accepted (got {url!r})"
|
|
132
|
+
)
|
|
133
|
+
if scheme == "http" and not _is_private_index_host(host):
|
|
134
|
+
raise _invalid(
|
|
135
|
+
f"{op}: {param} uses plaintext http://; only https:// is allowed "
|
|
136
|
+
f"for non-local hosts — a plaintext index lets a network attacker "
|
|
137
|
+
f"substitute packages during the build (got {url!r})"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _dockerfile_reads_context(text: str) -> bool:
|
|
142
|
+
"""Heuristic: does the Dockerfile COPY/ADD from the local build context?
|
|
143
|
+
|
|
144
|
+
Client-side mirror of the server's authoritative check (which uses the
|
|
145
|
+
real BuildKit parser): merges backslash line continuations, skips
|
|
146
|
+
comments, then looks for a ``COPY`` without ``--from=`` or an ``ADD``
|
|
147
|
+
whose source is not a remote URL. A false positive is harmless (pass
|
|
148
|
+
context_dir); the server re-validates either way.
|
|
149
|
+
"""
|
|
150
|
+
logical: List[str] = []
|
|
151
|
+
pending = ""
|
|
152
|
+
for raw in text.splitlines():
|
|
153
|
+
line = raw.rstrip()
|
|
154
|
+
stripped = line.strip()
|
|
155
|
+
if not pending and (not stripped or stripped.startswith("#")):
|
|
156
|
+
continue
|
|
157
|
+
if line.endswith("\\") and not line.endswith("\\\\"):
|
|
158
|
+
pending += line[:-1] + " "
|
|
159
|
+
continue
|
|
160
|
+
logical.append((pending + line).strip())
|
|
161
|
+
pending = ""
|
|
162
|
+
if pending.strip():
|
|
163
|
+
logical.append(pending.strip())
|
|
164
|
+
for inst in logical:
|
|
165
|
+
parts = inst.split()
|
|
166
|
+
if not parts:
|
|
167
|
+
continue
|
|
168
|
+
op = parts[0].upper()
|
|
169
|
+
if op == "COPY":
|
|
170
|
+
if not any(a.lower().startswith("--from=") for a in parts[1:]):
|
|
171
|
+
return True
|
|
172
|
+
elif op == "ADD":
|
|
173
|
+
args = [a for a in parts[1:] if not a.startswith("--")]
|
|
174
|
+
for a in args[:-1]: # last arg is the destination
|
|
175
|
+
low = a.lower()
|
|
176
|
+
if not low.startswith(("http://", "https://", "git@")):
|
|
177
|
+
return True
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _invalid(message: str) -> exc.InvalidArgumentError:
|
|
182
|
+
"""Local validation error — mirrors the server's 400 IMAGE_DEFINITION_INVALID.
|
|
183
|
+
|
|
184
|
+
Raised eagerly at DSL-construction time so the user gets a clear, immediate
|
|
185
|
+
message at the offending ``.pip_install(...)`` call instead of a late 400.
|
|
186
|
+
"""
|
|
187
|
+
return exc.InvalidArgumentError("IMAGE_DEFINITION_INVALID", message)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class Image:
|
|
191
|
+
"""An immutable image definition: a base plus an ordered list of layers.
|
|
192
|
+
|
|
193
|
+
Construct with one of the base factories
|
|
194
|
+
(:meth:`debian_slim` / :meth:`from_registry`) then chain layer methods.
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
__slots__ = ("_base", "_layers", "_context_data")
|
|
198
|
+
|
|
199
|
+
def __init__(
|
|
200
|
+
self,
|
|
201
|
+
base: Dict[str, Any],
|
|
202
|
+
layers: Sequence[Dict[str, Any]],
|
|
203
|
+
context_data: Optional[bytes] = None,
|
|
204
|
+
) -> None:
|
|
205
|
+
# Private: use the factories. ``layers`` is copied to keep immutability.
|
|
206
|
+
self._base = base
|
|
207
|
+
self._layers: List[Dict[str, Any]] = list(layers)
|
|
208
|
+
# from_dockerfile build context (tar.gz bytes) — uploaded as a bundle
|
|
209
|
+
# when the build is created; its sha256 is in the base and thus in the
|
|
210
|
+
# hash, the bytes themselves never are (content-addressed identity).
|
|
211
|
+
self._context_data = context_data
|
|
212
|
+
|
|
213
|
+
# -- base factories -----------------------------------------------------
|
|
214
|
+
|
|
215
|
+
@classmethod
|
|
216
|
+
def debian_slim(cls, python_version: str = "3.12") -> "Image":
|
|
217
|
+
"""Platform-maintained Debian slim base with the given Python.
|
|
218
|
+
|
|
219
|
+
``python_version`` is ``major.minor`` (e.g. ``"3.12"``). The exact base
|
|
220
|
+
image digest is a server config, NOT part of the hash (image-dsl.md §5).
|
|
221
|
+
"""
|
|
222
|
+
if not isinstance(python_version, str) or not _PYTHON_VERSION_RE.match(
|
|
223
|
+
python_version
|
|
224
|
+
):
|
|
225
|
+
raise _invalid(
|
|
226
|
+
f"python_version must match '3.<minor>' (e.g. '3.12'), got "
|
|
227
|
+
f"{python_version!r}"
|
|
228
|
+
)
|
|
229
|
+
return cls({"kind": "debian_slim", "python_version": python_version}, [])
|
|
230
|
+
|
|
231
|
+
@classmethod
|
|
232
|
+
def mac_native(cls, python_version: str = "3.13") -> "Image":
|
|
233
|
+
"""Native macOS (Apple Silicon) environment base — mac-nodes.md §4.2.
|
|
234
|
+
|
|
235
|
+
The "image" is a relocatable Python venv assembled with ``uv`` ON a
|
|
236
|
+
darwin/arm64 Mac node (no Docker involved) and distributed through the
|
|
237
|
+
registry as a darwin/arm64 artifact. Functions using it schedule on
|
|
238
|
+
Mac nodes only (e.g. ``gpu="M4"``).
|
|
239
|
+
|
|
240
|
+
Only :meth:`pip_install` layers can follow: there is no ``apt`` on
|
|
241
|
+
macOS (common system packages — ffmpeg, git, cmake — are preinstalled
|
|
242
|
+
on Mac nodes), no shell layer, and no baked env vars in v1.
|
|
243
|
+
"""
|
|
244
|
+
if not isinstance(python_version, str) or not _PYTHON_VERSION_RE.match(
|
|
245
|
+
python_version
|
|
246
|
+
):
|
|
247
|
+
raise _invalid(
|
|
248
|
+
f"python_version must match '3.<minor>' (e.g. '3.13'), got "
|
|
249
|
+
f"{python_version!r}"
|
|
250
|
+
)
|
|
251
|
+
return cls({"kind": "mac_native", "python_version": python_version}, [])
|
|
252
|
+
|
|
253
|
+
@classmethod
|
|
254
|
+
def from_registry(cls, ref: str) -> "Image":
|
|
255
|
+
"""Base from an existing registry image (must carry an explicit tag/digest).
|
|
256
|
+
|
|
257
|
+
Same rule as run refs: no implicit ``latest``. The image must be
|
|
258
|
+
Linux and ship a Python if pip layers follow (else the build fails — a
|
|
259
|
+
user error, image-dsl.md §2).
|
|
260
|
+
"""
|
|
261
|
+
if not isinstance(ref, str) or not ref:
|
|
262
|
+
raise _invalid("from_registry(ref): ref must be a non-empty string")
|
|
263
|
+
if ":" not in ref and "@" not in ref:
|
|
264
|
+
raise _invalid(
|
|
265
|
+
f"from_registry({ref!r}): ref needs an explicit tag (':') or "
|
|
266
|
+
"digest ('@') — no implicit 'latest'."
|
|
267
|
+
)
|
|
268
|
+
return cls({"kind": "registry", "ref": ref}, [])
|
|
269
|
+
|
|
270
|
+
@classmethod
|
|
271
|
+
def from_catalog(cls, alias: str) -> "Image":
|
|
272
|
+
"""Base from an AI Fabric catalog image, by its alias (e.g. ``"fabric/torch"``).
|
|
273
|
+
|
|
274
|
+
Unlike :meth:`from_registry`, you pass the *catalog alias* — the same
|
|
275
|
+
string you'd use in ``image="fabric/torch"`` — NOT a registry URL. The
|
|
276
|
+
control plane resolves the alias to the real registry ref at build time,
|
|
277
|
+
so the underlying registry (and its host) never leaks into your code and
|
|
278
|
+
can move (e.g. to an external registry) without changing tutorials.
|
|
279
|
+
|
|
280
|
+
The alias is resolved server-side against the enabled catalogue; an
|
|
281
|
+
unknown or disabled alias fails the build with a clear error.
|
|
282
|
+
"""
|
|
283
|
+
if not isinstance(alias, str) or not alias:
|
|
284
|
+
raise _invalid("from_catalog(alias): alias must be a non-empty string")
|
|
285
|
+
if not _CATALOG_ALIAS_RE.match(alias):
|
|
286
|
+
raise _invalid(
|
|
287
|
+
f"from_catalog({alias!r}): alias must look like a catalog name "
|
|
288
|
+
"(e.g. 'fabric/torch') — letters, digits, '.', '_', '-', '/'. "
|
|
289
|
+
"Pass a registry URL to from_registry() instead."
|
|
290
|
+
)
|
|
291
|
+
# Stored in its wire form, so fabric/x and forge/x hash (and cache) alike.
|
|
292
|
+
return cls({"kind": "catalog", "ref": to_wire_image(alias)}, [])
|
|
293
|
+
|
|
294
|
+
@classmethod
|
|
295
|
+
def from_dockerfile(
|
|
296
|
+
cls,
|
|
297
|
+
path: Any,
|
|
298
|
+
*,
|
|
299
|
+
context_dir: Any = None,
|
|
300
|
+
build_args: Optional[Mapping[str, str]] = None,
|
|
301
|
+
) -> "Image":
|
|
302
|
+
"""Base built from a local Dockerfile (docs/specs/image-from-dockerfile.md).
|
|
303
|
+
|
|
304
|
+
The Dockerfile is read EAGERLY (the returned ``Image`` stays immutable
|
|
305
|
+
and hashable; later edits to the file do not affect it). Layer methods
|
|
306
|
+
(:meth:`pip_install`, :meth:`env`, …) chain after it and are applied on
|
|
307
|
+
top of the Dockerfile's final stage.
|
|
308
|
+
|
|
309
|
+
``context_dir`` is required iff the Dockerfile ``COPY``/``ADD``s local
|
|
310
|
+
files: the directory is packed (honoring a ``.dockerignore`` at its
|
|
311
|
+
root) and uploaded when the build is created. ``build_args`` are the
|
|
312
|
+
Dockerfile ``ARG`` values; they are part of the image identity.
|
|
313
|
+
|
|
314
|
+
Platform notes (same rules as :meth:`from_registry`): linux/amd64
|
|
315
|
+
only; the final stage must ship Python + pip (AI Fabric appends its
|
|
316
|
+
runtime layer there); your ``ENTRYPOINT``/``CMD`` is overridden by the
|
|
317
|
+
AI Fabric runtime entrypoint; the container runs as root.
|
|
318
|
+
|
|
319
|
+
Never put a secret in the Dockerfile or ``build_args`` — the
|
|
320
|
+
definition is stored in clear and hashed. Use :class:`bladekit.Secret`.
|
|
321
|
+
"""
|
|
322
|
+
from pathlib import Path
|
|
323
|
+
|
|
324
|
+
p = Path(path)
|
|
325
|
+
try:
|
|
326
|
+
text = p.read_text(encoding="utf-8")
|
|
327
|
+
except OSError as e:
|
|
328
|
+
raise _invalid(f"from_dockerfile({str(path)!r}): cannot read Dockerfile: {e}")
|
|
329
|
+
if not text.strip():
|
|
330
|
+
raise _invalid(f"from_dockerfile({str(path)!r}): the Dockerfile is empty")
|
|
331
|
+
if len(text.encode("utf-8")) > _MAX_DOCKERFILE_BYTES:
|
|
332
|
+
raise _invalid(
|
|
333
|
+
f"from_dockerfile({str(path)!r}): Dockerfile exceeds the "
|
|
334
|
+
f"{_MAX_DOCKERFILE_BYTES}-byte (32 KiB) limit"
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
base: Dict[str, Any] = {"kind": "dockerfile", "dockerfile": text}
|
|
338
|
+
|
|
339
|
+
if build_args:
|
|
340
|
+
if not isinstance(build_args, Mapping):
|
|
341
|
+
raise _invalid("from_dockerfile: build_args must be a mapping of strings")
|
|
342
|
+
if len(build_args) > _MAX_BUILD_ARGS:
|
|
343
|
+
raise _invalid(
|
|
344
|
+
f"from_dockerfile: too many build_args ({len(build_args)} > {_MAX_BUILD_ARGS})"
|
|
345
|
+
)
|
|
346
|
+
clean: Dict[str, str] = {}
|
|
347
|
+
total = 0
|
|
348
|
+
for k, v in build_args.items():
|
|
349
|
+
if not isinstance(k, str) or not _BUILD_ARG_KEY_RE.match(k):
|
|
350
|
+
raise _invalid(
|
|
351
|
+
f"from_dockerfile: invalid build arg name {k!r}; must match "
|
|
352
|
+
"'^[A-Za-z_][A-Za-z0-9_]*$'"
|
|
353
|
+
)
|
|
354
|
+
if not isinstance(v, str):
|
|
355
|
+
raise _invalid(f"from_dockerfile: build arg {k!r} value must be a string")
|
|
356
|
+
total += len(k.encode("utf-8")) + len(v.encode("utf-8"))
|
|
357
|
+
clean[k] = v
|
|
358
|
+
if total > _MAX_BUILD_ARGS_BYTES:
|
|
359
|
+
raise _invalid(
|
|
360
|
+
f"from_dockerfile: build_args exceed {_MAX_BUILD_ARGS_BYTES} total bytes"
|
|
361
|
+
)
|
|
362
|
+
base["build_args"] = clean
|
|
363
|
+
|
|
364
|
+
context_data: Optional[bytes] = None
|
|
365
|
+
if context_dir is not None:
|
|
366
|
+
ctx = Path(context_dir)
|
|
367
|
+
if not ctx.is_dir():
|
|
368
|
+
raise _invalid(
|
|
369
|
+
f"from_dockerfile: context_dir {str(context_dir)!r} is not a directory"
|
|
370
|
+
)
|
|
371
|
+
from ._bundle import build_context_bundle
|
|
372
|
+
|
|
373
|
+
context_data, context_sha, _ = build_context_bundle(ctx)
|
|
374
|
+
base["context_sha256"] = context_sha
|
|
375
|
+
elif _dockerfile_reads_context(text):
|
|
376
|
+
# Client-side mirror of the server's dockerfile_needs_context 400:
|
|
377
|
+
# an immediate, actionable error at the constructor call site.
|
|
378
|
+
raise _invalid(
|
|
379
|
+
f"from_dockerfile({str(path)!r}): the Dockerfile COPY/ADDs local "
|
|
380
|
+
"files but no build context was given — pass "
|
|
381
|
+
"context_dir=<directory containing those files>"
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
return cls(base, [], context_data)
|
|
385
|
+
|
|
386
|
+
# -- layer builders (return a new Image) --------------------------------
|
|
387
|
+
|
|
388
|
+
def _with_layer(self, layer: Dict[str, Any]) -> "Image":
|
|
389
|
+
if len(self._layers) >= _MAX_LAYERS:
|
|
390
|
+
raise _invalid(
|
|
391
|
+
f"too many layers (max {_MAX_LAYERS}); collapse steps or use "
|
|
392
|
+
"run_commands."
|
|
393
|
+
)
|
|
394
|
+
return Image(self._base, [*self._layers, layer], self._context_data)
|
|
395
|
+
|
|
396
|
+
def _reject_on_mac_native(self, op: str, hint: str) -> None:
|
|
397
|
+
"""Mirror the server-side mac_native restriction (mac-nodes.md §4.2).
|
|
398
|
+
|
|
399
|
+
A native macOS env build understands pip layers only; rejecting the
|
|
400
|
+
other verbs at the offending call gives an immediate, actionable error
|
|
401
|
+
instead of a 400 at build submission.
|
|
402
|
+
"""
|
|
403
|
+
if self._base.get("kind") == "mac_native":
|
|
404
|
+
raise _invalid(f"{op}: not supported on a mac_native base — {hint}")
|
|
405
|
+
|
|
406
|
+
@staticmethod
|
|
407
|
+
def _check_packages(op: str, packages: Sequence[str]) -> List[str]:
|
|
408
|
+
pkgs = list(packages)
|
|
409
|
+
if not pkgs:
|
|
410
|
+
raise _invalid(f"{op}: at least one package is required")
|
|
411
|
+
if len(pkgs) > _MAX_PACKAGES_PER_LAYER:
|
|
412
|
+
raise _invalid(
|
|
413
|
+
f"{op}: too many packages ({len(pkgs)} > {_MAX_PACKAGES_PER_LAYER})"
|
|
414
|
+
)
|
|
415
|
+
for p in pkgs:
|
|
416
|
+
if not isinstance(p, str) or not p:
|
|
417
|
+
raise _invalid(f"{op}: package names must be non-empty strings")
|
|
418
|
+
return pkgs
|
|
419
|
+
|
|
420
|
+
def apt_install(self, *packages: str) -> "Image":
|
|
421
|
+
"""Append an ``apt_install`` layer (Debian package names, no shell)."""
|
|
422
|
+
self._reject_on_mac_native(
|
|
423
|
+
"apt_install",
|
|
424
|
+
"there is no apt on macOS; common system packages (ffmpeg, git, "
|
|
425
|
+
"cmake) are preinstalled on Mac nodes",
|
|
426
|
+
)
|
|
427
|
+
pkgs = self._check_packages("apt_install", packages)
|
|
428
|
+
return self._with_layer({"op": "apt_install", "packages": pkgs})
|
|
429
|
+
|
|
430
|
+
def pip_install(
|
|
431
|
+
self,
|
|
432
|
+
*packages: str,
|
|
433
|
+
index_url: Optional[str] = None,
|
|
434
|
+
extra_index_urls: Optional[Sequence[str]] = None,
|
|
435
|
+
) -> "Image":
|
|
436
|
+
"""Append a ``pip_install`` layer (PEP 508 specifiers).
|
|
437
|
+
|
|
438
|
+
Pinning versions is recommended: two builds of the same hash can resolve
|
|
439
|
+
differently otherwise (assumed, image-dsl.md §5).
|
|
440
|
+
"""
|
|
441
|
+
pkgs = self._check_packages("pip_install", packages)
|
|
442
|
+
layer: Dict[str, Any] = {"op": "pip_install", "packages": pkgs}
|
|
443
|
+
if index_url is not None:
|
|
444
|
+
if not isinstance(index_url, str) or not index_url:
|
|
445
|
+
raise _invalid("pip_install: index_url must be a non-empty string")
|
|
446
|
+
_check_index_url("pip_install", "index_url", index_url)
|
|
447
|
+
layer["index_url"] = index_url
|
|
448
|
+
if extra_index_urls:
|
|
449
|
+
urls = list(extra_index_urls)
|
|
450
|
+
for u in urls:
|
|
451
|
+
if not isinstance(u, str) or not u:
|
|
452
|
+
raise _invalid(
|
|
453
|
+
"pip_install: extra_index_urls must be non-empty strings"
|
|
454
|
+
)
|
|
455
|
+
_check_index_url("pip_install", "extra_index_urls", u)
|
|
456
|
+
layer["extra_index_urls"] = urls
|
|
457
|
+
return self._with_layer(layer)
|
|
458
|
+
|
|
459
|
+
def env(self, vars: Mapping[str, str]) -> "Image":
|
|
460
|
+
"""Append an ``env`` layer. Keys ``^[A-Z_][A-Z0-9_]*$``, never ``FABRIC_*``/``FORGE_*``.
|
|
461
|
+
|
|
462
|
+
Never put a secret here: the definition is stored in clear and hashed —
|
|
463
|
+
use :class:`bladekit.Secret` (image-dsl.md §2).
|
|
464
|
+
"""
|
|
465
|
+
self._reject_on_mac_native(
|
|
466
|
+
"env",
|
|
467
|
+
"baked env vars are not supported in v1; set environment "
|
|
468
|
+
"variables via bladekit.Secret or the function definition instead",
|
|
469
|
+
)
|
|
470
|
+
if not isinstance(vars, Mapping) or not vars:
|
|
471
|
+
raise _invalid("env(vars): a non-empty mapping is required")
|
|
472
|
+
if len(vars) > _MAX_ENV_VARS_PER_LAYER:
|
|
473
|
+
raise _invalid(
|
|
474
|
+
f"env: too many vars ({len(vars)} > {_MAX_ENV_VARS_PER_LAYER})"
|
|
475
|
+
)
|
|
476
|
+
clean: Dict[str, str] = {}
|
|
477
|
+
for k, v in vars.items():
|
|
478
|
+
if not isinstance(k, str) or not _ENV_KEY_RE.match(k):
|
|
479
|
+
raise _invalid(
|
|
480
|
+
f"env: invalid key {k!r}; must match '^[A-Z_][A-Z0-9_]*$'"
|
|
481
|
+
)
|
|
482
|
+
# FABRIC_ too, client-side: the SDK reads FABRIC_* before FORGE_*, so a
|
|
483
|
+
# baked FABRIC_API_URL would shadow what the control plane injects.
|
|
484
|
+
reserved = next((p for p in RESERVED_ENV_PREFIXES if k.startswith(p)), None)
|
|
485
|
+
if reserved:
|
|
486
|
+
raise _invalid(
|
|
487
|
+
f"env: key {k!r} is reserved ({reserved} prefix is runtime-only)"
|
|
488
|
+
)
|
|
489
|
+
if not isinstance(v, str):
|
|
490
|
+
raise _invalid(f"env: value for {k!r} must be a string")
|
|
491
|
+
clean[k] = v
|
|
492
|
+
return self._with_layer({"op": "env", "vars": clean})
|
|
493
|
+
|
|
494
|
+
def run_commands(self, *commands: str) -> "Image":
|
|
495
|
+
"""Append a ``run_commands`` layer (each command is one ``RUN`` / cache step)."""
|
|
496
|
+
self._reject_on_mac_native(
|
|
497
|
+
"run_commands",
|
|
498
|
+
"a mac env build runs uv commands only, not arbitrary shell",
|
|
499
|
+
)
|
|
500
|
+
cmds = list(commands)
|
|
501
|
+
if not cmds:
|
|
502
|
+
raise _invalid("run_commands: at least one command is required")
|
|
503
|
+
if len(cmds) > _MAX_COMMANDS_PER_LAYER:
|
|
504
|
+
raise _invalid(
|
|
505
|
+
f"run_commands: too many commands ({len(cmds)} > "
|
|
506
|
+
f"{_MAX_COMMANDS_PER_LAYER})"
|
|
507
|
+
)
|
|
508
|
+
for c in cmds:
|
|
509
|
+
if not isinstance(c, str) or not c:
|
|
510
|
+
raise _invalid("run_commands: commands must be non-empty strings")
|
|
511
|
+
if "\n" in c or "\r" in c:
|
|
512
|
+
raise _invalid(
|
|
513
|
+
"run_commands: commands must not contain newline characters "
|
|
514
|
+
"(\\n or \\r); each command is a single shell line"
|
|
515
|
+
)
|
|
516
|
+
return self._with_layer({"op": "run_commands", "commands": cmds})
|
|
517
|
+
|
|
518
|
+
# -- serialization & hash (NORMATIVE — image-dsl.md §2/§3) --------------
|
|
519
|
+
|
|
520
|
+
def to_definition(self, *, sandbox: bool = False) -> Dict[str, Any]:
|
|
521
|
+
"""Return the definition object (version, base, layers).
|
|
522
|
+
|
|
523
|
+
Optional/empty fields are already absent (the builders never add empties),
|
|
524
|
+
so this is the pre-hash representation per §3 rule 5.
|
|
525
|
+
|
|
526
|
+
``sandbox=True`` marks the build as a SANDBOX image: the server injects
|
|
527
|
+
forge-sandboxd as PID 1 / entrypoint instead of forge-runner
|
|
528
|
+
(sandbox-protocol.md §3). It is part of the canonical JSON (and thus the
|
|
529
|
+
hash), so a sandbox build never collides with the function build of the
|
|
530
|
+
same base/layers.
|
|
531
|
+
"""
|
|
532
|
+
if sandbox and self._base.get("kind") == "mac_native":
|
|
533
|
+
# Mirrors the server rule: sandboxes are Linux containers
|
|
534
|
+
# (runc/runsc) — a native mac env can never back one.
|
|
535
|
+
raise _invalid(
|
|
536
|
+
"sandbox images are not supported on a mac_native base "
|
|
537
|
+
"(sandboxes are Linux containers)"
|
|
538
|
+
)
|
|
539
|
+
d: Dict[str, Any] = {
|
|
540
|
+
"version": _DEFINITION_VERSION,
|
|
541
|
+
"base": dict(self._base),
|
|
542
|
+
"layers": [dict(layer) for layer in self._layers],
|
|
543
|
+
}
|
|
544
|
+
if sandbox:
|
|
545
|
+
d["sandbox"] = True
|
|
546
|
+
return d
|
|
547
|
+
|
|
548
|
+
def canonical_json(self, *, sandbox: bool = False) -> str:
|
|
549
|
+
"""Canonical JSON string (image-dsl.md §3): sorted keys, no spaces, UTF-8."""
|
|
550
|
+
return json.dumps(
|
|
551
|
+
self.to_definition(sandbox=sandbox),
|
|
552
|
+
sort_keys=True,
|
|
553
|
+
separators=(",", ":"),
|
|
554
|
+
ensure_ascii=False,
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
def definition_hash(self, *, sandbox: bool = False) -> str:
|
|
558
|
+
"""Lowercase-hex sha256 of the canonical JSON bytes (image-dsl.md §3)."""
|
|
559
|
+
data = self.canonical_json(sandbox=sandbox).encode("utf-8")
|
|
560
|
+
if len(data) > _CANONICAL_MAX_BYTES:
|
|
561
|
+
raise _invalid(
|
|
562
|
+
f"image definition is {len(data)} bytes, over the "
|
|
563
|
+
f"{_CANONICAL_MAX_BYTES}-byte (64 KiB) limit (conventions.md §6)."
|
|
564
|
+
)
|
|
565
|
+
return hashlib.sha256(data).hexdigest()
|
|
566
|
+
|
|
567
|
+
def __repr__(self) -> str:
|
|
568
|
+
kind = self._base.get("kind")
|
|
569
|
+
return f"<bladekit.Image base={kind!r} layers={len(self._layers)} hash={self.definition_hash()[:12]}…>"
|
|
570
|
+
|
|
571
|
+
def __eq__(self, other: object) -> bool:
|
|
572
|
+
if not isinstance(other, Image):
|
|
573
|
+
return NotImplemented
|
|
574
|
+
return self.canonical_json() == other.canonical_json()
|
|
575
|
+
|
|
576
|
+
def __hash__(self) -> int:
|
|
577
|
+
return hash(self.canonical_json())
|
bladekit/_loader.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Load a user script as a module so its ``bladekit.App`` and decorators register.
|
|
2
|
+
|
|
3
|
+
``blade run script.py`` must:
|
|
4
|
+
- set the bundle root to the script's directory,
|
|
5
|
+
- import the script under its file-stem module name (so the remote runner can
|
|
6
|
+
import the same module from the bundle root — see App._module_for),
|
|
7
|
+
- discover the App and its @app.local_entrypoint.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import importlib.util
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from types import ModuleType
|
|
16
|
+
from typing import List, Tuple
|
|
17
|
+
|
|
18
|
+
from . import exceptions as exc
|
|
19
|
+
from ._app import App
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_script(script_path: Path) -> Tuple[ModuleType, str, Path]:
|
|
23
|
+
"""Import ``script_path`` and return ``(module, module_name, bundle_root)``.
|
|
24
|
+
|
|
25
|
+
``module_name`` is the file stem (the name the bundle root will expose), and
|
|
26
|
+
``bundle_root`` is the script's parent directory.
|
|
27
|
+
"""
|
|
28
|
+
script_path = script_path.resolve()
|
|
29
|
+
if not script_path.exists():
|
|
30
|
+
raise exc.FabricError(f"script not found: {script_path}")
|
|
31
|
+
|
|
32
|
+
bundle_root = script_path.parent
|
|
33
|
+
module_name = script_path.stem
|
|
34
|
+
|
|
35
|
+
# Make sibling modules importable both locally and consistently with the
|
|
36
|
+
# remote runner (which inserts the bundle root on sys.path).
|
|
37
|
+
if str(bundle_root) not in sys.path:
|
|
38
|
+
sys.path.insert(0, str(bundle_root))
|
|
39
|
+
|
|
40
|
+
# Remove any stale cached module under this name (e.g. from a prior
|
|
41
|
+
# load_script() call in the same process — common in tests). Leaving the
|
|
42
|
+
# old module in sys.modules would cause importlib to skip re-execution and
|
|
43
|
+
# return the stale object instead of the freshly-loaded one.
|
|
44
|
+
sys.modules.pop(module_name, None)
|
|
45
|
+
|
|
46
|
+
spec = importlib.util.spec_from_file_location(module_name, script_path)
|
|
47
|
+
if spec is None or spec.loader is None:
|
|
48
|
+
raise exc.FabricError(f"cannot import {script_path}")
|
|
49
|
+
module = importlib.util.module_from_spec(spec)
|
|
50
|
+
sys.modules[module_name] = module
|
|
51
|
+
spec.loader.exec_module(module)
|
|
52
|
+
return module, module_name, bundle_root
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def find_apps(module: ModuleType) -> List[App]:
|
|
56
|
+
return [v for v in vars(module).values() if isinstance(v, App)]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def select_app_and_entrypoint(module: ModuleType):
|
|
60
|
+
"""Return ``(app, entrypoint)`` from a loaded module.
|
|
61
|
+
|
|
62
|
+
Requires exactly one App with at least one @app.local_entrypoint for
|
|
63
|
+
``blade run``.
|
|
64
|
+
"""
|
|
65
|
+
apps = find_apps(module)
|
|
66
|
+
if not apps:
|
|
67
|
+
raise exc.FabricError("no bladekit.App found in script")
|
|
68
|
+
if len(apps) > 1:
|
|
69
|
+
raise exc.FabricError(
|
|
70
|
+
f"multiple bladekit.App objects found ({len(apps)}); only one is supported per script"
|
|
71
|
+
)
|
|
72
|
+
app = apps[0]
|
|
73
|
+
if not app.local_entrypoints:
|
|
74
|
+
raise exc.FabricError(
|
|
75
|
+
"no @app.local_entrypoint found; `blade run` needs one to invoke"
|
|
76
|
+
)
|
|
77
|
+
if len(app.local_entrypoints) > 1:
|
|
78
|
+
raise exc.FabricError("multiple @app.local_entrypoint found; expected one")
|
|
79
|
+
return app, app.local_entrypoints[0]
|