containerspec 0.1.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.
- containerspec/__init__.py +116 -0
- containerspec/backends.py +247 -0
- containerspec/distros.py +89 -0
- containerspec/layers.py +259 -0
- containerspec/py.typed +0 -0
- containerspec/renderers.py +527 -0
- containerspec/rootfs.py +165 -0
- containerspec/spec.py +551 -0
- containerspec/targets.py +261 -0
- containerspec-0.1.0.dist-info/METADATA +570 -0
- containerspec-0.1.0.dist-info/RECORD +13 -0
- containerspec-0.1.0.dist-info/WHEEL +4 -0
- containerspec-0.1.0.dist-info/licenses/LICENSE +21 -0
containerspec/spec.py
ADDED
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
"""ImageSpec — fluent, immutable image specification."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from dataclasses import dataclass, replace
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
from containerspec.backends import BuildError
|
|
14
|
+
from containerspec.layers import (
|
|
15
|
+
AddPython,
|
|
16
|
+
ApkInstall,
|
|
17
|
+
AptInstall,
|
|
18
|
+
BrewInstall,
|
|
19
|
+
CargoInstall,
|
|
20
|
+
Chown,
|
|
21
|
+
Cmd,
|
|
22
|
+
Copy,
|
|
23
|
+
CopyFromStage,
|
|
24
|
+
DnfInstall,
|
|
25
|
+
Entrypoint,
|
|
26
|
+
Env,
|
|
27
|
+
Expose,
|
|
28
|
+
GemInstall,
|
|
29
|
+
GoInstall,
|
|
30
|
+
Layer,
|
|
31
|
+
NpmInstall,
|
|
32
|
+
NvmInstall,
|
|
33
|
+
PacmanInstall,
|
|
34
|
+
PipInstall,
|
|
35
|
+
PnpmInstall,
|
|
36
|
+
RunCommands,
|
|
37
|
+
RustInstall,
|
|
38
|
+
User,
|
|
39
|
+
UvPipInstall,
|
|
40
|
+
UvxInstall,
|
|
41
|
+
Volume,
|
|
42
|
+
Workdir,
|
|
43
|
+
YarnInstall,
|
|
44
|
+
ZypperInstall,
|
|
45
|
+
frozen_mapping,
|
|
46
|
+
layer_payload,
|
|
47
|
+
)
|
|
48
|
+
from containerspec.renderers import render_dockerfile
|
|
49
|
+
from containerspec.rootfs import MissingToolError
|
|
50
|
+
|
|
51
|
+
if TYPE_CHECKING:
|
|
52
|
+
from containerspec.targets import BuildTarget
|
|
53
|
+
|
|
54
|
+
logger = logging.getLogger("containerspec")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class StageSpec:
|
|
59
|
+
"""A named build stage for multi-stage Dockerfiles."""
|
|
60
|
+
|
|
61
|
+
name: str
|
|
62
|
+
spec: ImageSpec
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _hash_path(path: str) -> str:
|
|
66
|
+
"""Compute a recursive sha256 of a file or directory for cache busting."""
|
|
67
|
+
p = Path(path)
|
|
68
|
+
if not p.exists():
|
|
69
|
+
raise FileNotFoundError(
|
|
70
|
+
f"copy() source '{path}' does not exist. "
|
|
71
|
+
f"The file must exist at spec construction time for content hashing."
|
|
72
|
+
)
|
|
73
|
+
h = hashlib.sha256()
|
|
74
|
+
if p.is_file():
|
|
75
|
+
h.update(p.read_bytes())
|
|
76
|
+
return h.hexdigest()
|
|
77
|
+
files = sorted(p.rglob("*"))
|
|
78
|
+
for f in files:
|
|
79
|
+
if f.is_file():
|
|
80
|
+
rel = f.relative_to(p).as_posix()
|
|
81
|
+
h.update(rel.encode())
|
|
82
|
+
h.update(f.read_bytes())
|
|
83
|
+
return h.hexdigest()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class ImageSpec:
|
|
88
|
+
"""Fluent, immutable image specification.
|
|
89
|
+
|
|
90
|
+
Build with ``ImageSpec.from_registry(base).apt_install(...).uv_pip_install(...)``
|
|
91
|
+
and call ``.build(target)`` to produce a Docker image, Firecracker rootfs, or OCI tarball.
|
|
92
|
+
|
|
93
|
+
For multi-stage builds, use ``copy_from_stage()`` to compose a build stage
|
|
94
|
+
into a runtime image.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
base: str
|
|
98
|
+
pin_digest: bool = True
|
|
99
|
+
layers: tuple[Layer, ...] = ()
|
|
100
|
+
stages: tuple[StageSpec, ...] = ()
|
|
101
|
+
distro: str | None = None
|
|
102
|
+
|
|
103
|
+
@classmethod
|
|
104
|
+
def from_registry(
|
|
105
|
+
cls, base: str, *, pin_digest: bool = True, distro: str | None = None
|
|
106
|
+
) -> ImageSpec:
|
|
107
|
+
"""Create an ImageSpec from a base image.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
base: Base image reference (e.g. ``"python:3.12-slim"``, ``"alpine:3.20"``).
|
|
111
|
+
pin_digest: When True, resolve and pin the base image manifest digest in the hash.
|
|
112
|
+
distro: Declare the base image's distro family for correct rendering before any
|
|
113
|
+
package install. One of ``"debian"``, ``"alpine"``, ``"rhel"``. When None
|
|
114
|
+
(default), inferred from the first ``.apt_install()``/``.apk_install()``/
|
|
115
|
+
``.dnf_install()`` call. Set explicitly if ``.user()`` precedes any package
|
|
116
|
+
install on a non-Debian base (e.g. Alpine).
|
|
117
|
+
"""
|
|
118
|
+
if not base or not base.strip():
|
|
119
|
+
raise ValueError("from_registry requires a non-empty base image reference")
|
|
120
|
+
if distro is not None and distro not in (
|
|
121
|
+
"debian",
|
|
122
|
+
"alpine",
|
|
123
|
+
"rhel",
|
|
124
|
+
"fedora",
|
|
125
|
+
"arch",
|
|
126
|
+
"opensuse",
|
|
127
|
+
"busybox",
|
|
128
|
+
):
|
|
129
|
+
raise ValueError(
|
|
130
|
+
f"distro must be one of 'debian', 'alpine', 'rhel', 'fedora', "
|
|
131
|
+
f"'arch', 'opensuse', 'busybox' — got {distro!r}"
|
|
132
|
+
)
|
|
133
|
+
return cls(base=base, pin_digest=pin_digest, layers=(), stages=(), distro=distro)
|
|
134
|
+
|
|
135
|
+
def _with(self, layer: Layer) -> ImageSpec:
|
|
136
|
+
return replace(self, layers=(*self.layers, layer))
|
|
137
|
+
|
|
138
|
+
def _with_stage(self, stage: StageSpec) -> ImageSpec:
|
|
139
|
+
return replace(self, stages=(*self.stages, stage))
|
|
140
|
+
|
|
141
|
+
def add_python(self, version: str) -> ImageSpec:
|
|
142
|
+
return self._with(AddPython(version=version))
|
|
143
|
+
|
|
144
|
+
def apt_install(self, *packages: str) -> ImageSpec:
|
|
145
|
+
if not packages:
|
|
146
|
+
raise ValueError("apt_install requires at least one package")
|
|
147
|
+
return self._with(AptInstall(packages=tuple(sorted(packages))))
|
|
148
|
+
|
|
149
|
+
def apt_update(self) -> ImageSpec:
|
|
150
|
+
"""Run apt-get update && apt-get dist-upgrade -y to bring base image to latest."""
|
|
151
|
+
return self._with(
|
|
152
|
+
RunCommands(
|
|
153
|
+
commands=(
|
|
154
|
+
"DEBIAN_FRONTEND=noninteractive apt-get update && "
|
|
155
|
+
"apt-get dist-upgrade -y --no-install-recommends && "
|
|
156
|
+
"apt-get clean && rm -rf /var/lib/apt/lists/*",
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def apk_install(self, *packages: str) -> ImageSpec:
|
|
162
|
+
if not packages:
|
|
163
|
+
raise ValueError("apk_install requires at least one package")
|
|
164
|
+
return self._with(ApkInstall(packages=tuple(sorted(packages))))
|
|
165
|
+
|
|
166
|
+
def dnf_install(self, *packages: str) -> ImageSpec:
|
|
167
|
+
if not packages:
|
|
168
|
+
raise ValueError("dnf_install requires at least one package")
|
|
169
|
+
return self._with(DnfInstall(packages=tuple(sorted(packages))))
|
|
170
|
+
|
|
171
|
+
def dnf_update(self) -> ImageSpec:
|
|
172
|
+
"""Run dnf upgrade to bring base image to latest."""
|
|
173
|
+
return self._with(RunCommands(commands=("dnf upgrade -y && dnf clean all",)))
|
|
174
|
+
|
|
175
|
+
def pacman_install(self, *packages: str) -> ImageSpec:
|
|
176
|
+
"""Install packages via pacman (Arch Linux). Sorted within layer.
|
|
177
|
+
|
|
178
|
+
Supports version pins (``pkg=1.2.3``), git AUR helpers, and exact versions.
|
|
179
|
+
"""
|
|
180
|
+
if not packages:
|
|
181
|
+
raise ValueError("pacman_install requires at least one package")
|
|
182
|
+
return self._with(PacmanInstall(packages=tuple(sorted(packages))))
|
|
183
|
+
|
|
184
|
+
def pacman_update(self) -> ImageSpec:
|
|
185
|
+
"""Run pacman -Syu to bring Arch base to latest."""
|
|
186
|
+
return self._with(RunCommands(commands=("pacman -Syu --noconfirm",)))
|
|
187
|
+
|
|
188
|
+
def aur_install(self, *packages: str) -> ImageSpec:
|
|
189
|
+
"""Install packages from the AUR (Arch User Repository).
|
|
190
|
+
|
|
191
|
+
Uses yay as the AUR helper. Requires yay installed (via run_commands or base image).
|
|
192
|
+
Sorted within layer for hash stability.
|
|
193
|
+
"""
|
|
194
|
+
if not packages:
|
|
195
|
+
raise ValueError("aur_install requires at least one package")
|
|
196
|
+
return self._with(
|
|
197
|
+
RunCommands(
|
|
198
|
+
commands=(
|
|
199
|
+
" ".join(
|
|
200
|
+
f"yay -S --noconfirm {' '.join(sorted(packages))}",
|
|
201
|
+
),
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
def zypper_install(self, *packages: str) -> ImageSpec:
|
|
207
|
+
"""Install packages via zypper (openSUSE). Sorted within layer."""
|
|
208
|
+
if not packages:
|
|
209
|
+
raise ValueError("zypper_install requires at least one package")
|
|
210
|
+
return self._with(ZypperInstall(packages=tuple(sorted(packages))))
|
|
211
|
+
|
|
212
|
+
def zypper_update(self) -> ImageSpec:
|
|
213
|
+
"""Run zypper update to bring openSUSE base to latest."""
|
|
214
|
+
return self._with(RunCommands(commands=("zypper update -y",)))
|
|
215
|
+
|
|
216
|
+
def yarn_install(self, *packages: str) -> ImageSpec:
|
|
217
|
+
"""Install npm packages globally via yarn. Requires nvm_install or base with node."""
|
|
218
|
+
if not packages:
|
|
219
|
+
raise ValueError("yarn_install requires at least one package")
|
|
220
|
+
return self._with(YarnInstall(packages=tuple(sorted(packages))))
|
|
221
|
+
|
|
222
|
+
def gem_install(self, *packages: str) -> ImageSpec:
|
|
223
|
+
"""Install Ruby gems. Requires Ruby in base image or installed via run_commands."""
|
|
224
|
+
if not packages:
|
|
225
|
+
raise ValueError("gem_install requires at least one package")
|
|
226
|
+
return self._with(GemInstall(packages=tuple(sorted(packages))))
|
|
227
|
+
|
|
228
|
+
def go_install(self, *packages: str) -> ImageSpec:
|
|
229
|
+
"""Install Go packages. Requires Go in base image or installed via run_commands.
|
|
230
|
+
|
|
231
|
+
Supports version pins (``pkg@v1.2.3``) and git URLs.
|
|
232
|
+
"""
|
|
233
|
+
if not packages:
|
|
234
|
+
raise ValueError("go_install requires at least one package")
|
|
235
|
+
return self._with(GoInstall(packages=tuple(sorted(packages))))
|
|
236
|
+
|
|
237
|
+
def uv_pip_install(self, *packages: str) -> ImageSpec:
|
|
238
|
+
if not packages:
|
|
239
|
+
raise ValueError("uv_pip_install requires at least one package")
|
|
240
|
+
return self._with(UvPipInstall(packages=tuple(sorted(packages))))
|
|
241
|
+
|
|
242
|
+
def pip_install(self, *packages: str) -> ImageSpec:
|
|
243
|
+
if not packages:
|
|
244
|
+
raise ValueError("pip_install requires at least one package")
|
|
245
|
+
return self._with(PipInstall(packages=tuple(sorted(packages))))
|
|
246
|
+
|
|
247
|
+
def env(self, vars: Mapping[str, str]) -> ImageSpec:
|
|
248
|
+
sorted_vars = {k: vars[k] for k in sorted(vars)}
|
|
249
|
+
return self._with(Env(vars=frozen_mapping(sorted_vars)))
|
|
250
|
+
|
|
251
|
+
def run_commands(self, *commands: str) -> ImageSpec:
|
|
252
|
+
return self._with(RunCommands(commands=tuple(commands)))
|
|
253
|
+
|
|
254
|
+
def workdir(self, path: str) -> ImageSpec:
|
|
255
|
+
return self._with(Workdir(path=path))
|
|
256
|
+
|
|
257
|
+
def chown(self, path: str, *, uid: int | None = None, gid: int | None = None) -> ImageSpec:
|
|
258
|
+
return self._with(Chown(path=path, uid=uid, gid=gid))
|
|
259
|
+
|
|
260
|
+
def user(self, *, uid: int, gid: int, name: str, alpine: bool = False) -> ImageSpec:
|
|
261
|
+
"""Create a non-root user.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
uid: User ID.
|
|
265
|
+
gid: Group ID.
|
|
266
|
+
name: Username (also used for group name and home directory).
|
|
267
|
+
alpine: Set True for Alpine/BusyBox-based images (uses addgroup/adduser -D
|
|
268
|
+
instead of groupadd/useradd). Inferred automatically if .apk_install()
|
|
269
|
+
precedes this layer, but set explicitly if .user() comes before any
|
|
270
|
+
package install on an Alpine base.
|
|
271
|
+
"""
|
|
272
|
+
return self._with(User(uid=uid, gid=gid, name=name, alpine=alpine))
|
|
273
|
+
|
|
274
|
+
def entrypoint(self, commands: Sequence[str] | None) -> ImageSpec:
|
|
275
|
+
normalized: tuple[str, ...] | None = tuple(commands) if commands is not None else None
|
|
276
|
+
return self._with(Entrypoint(commands=normalized))
|
|
277
|
+
|
|
278
|
+
def expose(self, *ports: int) -> ImageSpec:
|
|
279
|
+
return self._with(Expose(ports=tuple(sorted(ports))))
|
|
280
|
+
|
|
281
|
+
def cmd(self, commands: Sequence[str] | None) -> ImageSpec:
|
|
282
|
+
normalized: tuple[str, ...] | None = tuple(commands) if commands is not None else None
|
|
283
|
+
return self._with(Cmd(commands=normalized))
|
|
284
|
+
|
|
285
|
+
def volume(self, *paths: str) -> ImageSpec:
|
|
286
|
+
return self._with(Volume(paths=tuple(sorted(paths))))
|
|
287
|
+
|
|
288
|
+
def copy(self, src: str, dest: str, *, content_hash: str | None = None) -> ImageSpec:
|
|
289
|
+
"""Copy a file or directory into the image (COPY).
|
|
290
|
+
|
|
291
|
+
For cache busting, the source content is hashed and included in the
|
|
292
|
+
canonical payload. Two modes:
|
|
293
|
+
|
|
294
|
+
1. Local file exists: ``copy("./app.py", "/app/app.py")`` — hashes
|
|
295
|
+
the file/directory content automatically.
|
|
296
|
+
2. User provides hash: ``copy("remote://file", "/app/file", content_hash="sha256:...")``
|
|
297
|
+
— for CI pipelines where the file isn't local but its hash is known.
|
|
298
|
+
|
|
299
|
+
If the file doesn't exist AND no ``content_hash`` is provided, raises
|
|
300
|
+
``FileNotFoundError`` with a clear message.
|
|
301
|
+
"""
|
|
302
|
+
if content_hash is not None:
|
|
303
|
+
return self._with(Copy(src=src, dest=dest, content_hash=content_hash))
|
|
304
|
+
try:
|
|
305
|
+
computed = _hash_path(src)
|
|
306
|
+
except FileNotFoundError:
|
|
307
|
+
raise FileNotFoundError(
|
|
308
|
+
f"copy() source '{src}' does not exist. "
|
|
309
|
+
f"Either provide a local path that exists, or pass content_hash= "
|
|
310
|
+
f"explicitly: copy('{src}', '{dest}', content_hash='sha256:...')"
|
|
311
|
+
) from None
|
|
312
|
+
return self._with(Copy(src=src, dest=dest, content_hash=computed))
|
|
313
|
+
|
|
314
|
+
def copy_from_stage(self, stage: StageSpec, src: str, dest: str) -> ImageSpec:
|
|
315
|
+
"""Copy from a named build stage into this image (COPY --from=<stage>).
|
|
316
|
+
|
|
317
|
+
The stage's content hash is included in the canonical payload, so changes
|
|
318
|
+
to the stage's spec bust the cache. The stage is registered on first use;
|
|
319
|
+
duplicate stage names are ignored so the Dockerfile emits a single
|
|
320
|
+
``FROM ... AS <name>`` per stage.
|
|
321
|
+
"""
|
|
322
|
+
with_layer = self._with(
|
|
323
|
+
CopyFromStage(
|
|
324
|
+
stage_name=stage.name,
|
|
325
|
+
stage_hash=stage.spec.content_hash(client=None),
|
|
326
|
+
src=src,
|
|
327
|
+
dest=dest,
|
|
328
|
+
)
|
|
329
|
+
)
|
|
330
|
+
if any(s.name == stage.name for s in with_layer.stages):
|
|
331
|
+
return with_layer
|
|
332
|
+
return with_layer._with_stage(stage)
|
|
333
|
+
|
|
334
|
+
def with_stage(self, name: str) -> StageSpec:
|
|
335
|
+
"""Create a named build stage from this spec for use in multi-stage builds."""
|
|
336
|
+
return StageSpec(name=name, spec=self)
|
|
337
|
+
|
|
338
|
+
def nvm_install(self, version: str) -> ImageSpec:
|
|
339
|
+
"""Install Node.js via nvm. Symlinks node/npm/npx to /usr/local/bin."""
|
|
340
|
+
return self._with(NvmInstall(version=version))
|
|
341
|
+
|
|
342
|
+
def npm_install(self, *packages: str) -> ImageSpec:
|
|
343
|
+
if not packages:
|
|
344
|
+
raise ValueError("npm_install requires at least one package")
|
|
345
|
+
return self._with(NpmInstall(packages=tuple(sorted(packages))))
|
|
346
|
+
|
|
347
|
+
def pnpm_install(self, *packages: str) -> ImageSpec:
|
|
348
|
+
if not packages:
|
|
349
|
+
raise ValueError("pnpm_install requires at least one package")
|
|
350
|
+
return self._with(PnpmInstall(packages=tuple(sorted(packages))))
|
|
351
|
+
|
|
352
|
+
def brew_install(self, *packages: str) -> ImageSpec:
|
|
353
|
+
if not packages:
|
|
354
|
+
raise ValueError("brew_install requires at least one package")
|
|
355
|
+
return self._with(BrewInstall(packages=tuple(sorted(packages))))
|
|
356
|
+
|
|
357
|
+
def rust_install(self) -> ImageSpec:
|
|
358
|
+
"""Install Rust via rustup. Sets PATH for cargo/rustc."""
|
|
359
|
+
return self._with(RustInstall())
|
|
360
|
+
|
|
361
|
+
def cargo_install(self, *packages: str) -> ImageSpec:
|
|
362
|
+
if not packages:
|
|
363
|
+
raise ValueError("cargo_install requires at least one package")
|
|
364
|
+
return self._with(CargoInstall(packages=tuple(sorted(packages))))
|
|
365
|
+
|
|
366
|
+
def uvx_install(self, *packages: str) -> ImageSpec:
|
|
367
|
+
if not packages:
|
|
368
|
+
raise ValueError("uvx_install requires at least one package")
|
|
369
|
+
return self._with(UvxInstall(packages=tuple(sorted(packages))))
|
|
370
|
+
|
|
371
|
+
def _canonical_payload(self, *, client: Any) -> dict[str, Any]:
|
|
372
|
+
if self.pin_digest and client is not None:
|
|
373
|
+
digest = client.images.get_registry_data(self.base).id
|
|
374
|
+
base_entry: dict[str, Any] = {"ref": self.base, "digest": digest}
|
|
375
|
+
elif self.pin_digest and client is None:
|
|
376
|
+
logger.warning(
|
|
377
|
+
"containerspec.hash.pin_digest_skipped",
|
|
378
|
+
extra={"reason": "no client available for daemonless target, using tag string"},
|
|
379
|
+
)
|
|
380
|
+
base_entry: dict[str, Any] = {"ref": self.base}
|
|
381
|
+
else:
|
|
382
|
+
base_entry = {"ref": self.base}
|
|
383
|
+
payload: dict[str, Any] = {
|
|
384
|
+
"base": base_entry,
|
|
385
|
+
"pin_digest": self.pin_digest,
|
|
386
|
+
"layers": [layer_payload(layer) for layer in self.layers],
|
|
387
|
+
}
|
|
388
|
+
if self.stages:
|
|
389
|
+
payload["stages"] = [
|
|
390
|
+
{
|
|
391
|
+
"name": s.name,
|
|
392
|
+
"hash": s.spec.content_hash(client=client),
|
|
393
|
+
}
|
|
394
|
+
for s in self.stages
|
|
395
|
+
]
|
|
396
|
+
return payload
|
|
397
|
+
|
|
398
|
+
def _canonical_json(self, *, client: Any) -> str:
|
|
399
|
+
import json
|
|
400
|
+
|
|
401
|
+
return json.dumps(self._canonical_payload(client=client), sort_keys=True)
|
|
402
|
+
|
|
403
|
+
def content_hash(self, *, client: Any = None) -> str:
|
|
404
|
+
import hashlib
|
|
405
|
+
|
|
406
|
+
return hashlib.sha256(self._canonical_json(client=client).encode()).hexdigest()
|
|
407
|
+
|
|
408
|
+
def tag(self, name: str, *, client: Any = None) -> str:
|
|
409
|
+
return f"{name}:sha-{self.content_hash(client=client)[:16]}"
|
|
410
|
+
|
|
411
|
+
def _resolve_digest(self, *, client: Any) -> str | None:
|
|
412
|
+
if not self.pin_digest:
|
|
413
|
+
return None
|
|
414
|
+
return client.images.get_registry_data(self.base).id
|
|
415
|
+
|
|
416
|
+
def to_dockerfile(self) -> str:
|
|
417
|
+
"""Generate a Dockerfile from the layer sequence. Pure — no Docker needed."""
|
|
418
|
+
return self._render_dockerfile(from_ref=self.base)
|
|
419
|
+
|
|
420
|
+
def _to_build_dockerfile(self, *, client: Any) -> str:
|
|
421
|
+
from_ref = self.base
|
|
422
|
+
if self.pin_digest:
|
|
423
|
+
digest = self._resolve_digest(client=client)
|
|
424
|
+
if digest:
|
|
425
|
+
from_ref = f"{self.base}@{digest}"
|
|
426
|
+
return self._render_dockerfile(from_ref=from_ref)
|
|
427
|
+
|
|
428
|
+
def _render_dockerfile(self, *, from_ref: str) -> str:
|
|
429
|
+
lines: list[str] = ["# syntax=docker/dockerfile:1.7"]
|
|
430
|
+
|
|
431
|
+
for stage in self.stages:
|
|
432
|
+
stage_df = stage.spec._render_dockerfile(from_ref=stage.spec.base)
|
|
433
|
+
stage_lines = stage_df.split("\n")
|
|
434
|
+
# Drop the embedded stage's syntax directive: it must appear exactly
|
|
435
|
+
# once, as the first line of the combined Dockerfile (Docker rejects
|
|
436
|
+
# "only one syntax parser directive can be used").
|
|
437
|
+
stage_lines = stage_lines[1:]
|
|
438
|
+
if stage_lines and stage_lines[0].startswith("FROM "):
|
|
439
|
+
stage_lines[0] = f"FROM {stage.spec.base} AS {stage.name}"
|
|
440
|
+
lines.extend(stage_lines)
|
|
441
|
+
lines.append("")
|
|
442
|
+
|
|
443
|
+
lines.append(f"FROM {from_ref}")
|
|
444
|
+
for i, layer in enumerate(self.layers):
|
|
445
|
+
lines.append("")
|
|
446
|
+
lines.extend(render_dockerfile(self, layer, i))
|
|
447
|
+
return "\n".join(lines) + "\n"
|
|
448
|
+
|
|
449
|
+
def resolve_chown_uid_gid(self, chown: Chown, *, index: int) -> tuple[int, int]:
|
|
450
|
+
if chown.uid is not None and chown.gid is not None:
|
|
451
|
+
return chown.uid, chown.gid
|
|
452
|
+
if chown.uid is not None or chown.gid is not None:
|
|
453
|
+
raise ValueError(
|
|
454
|
+
f'chown("{chown.path}"): uid and gid must both be specified or both omitted'
|
|
455
|
+
)
|
|
456
|
+
for layer in reversed(self.layers[:index]):
|
|
457
|
+
if isinstance(layer, User):
|
|
458
|
+
return layer.uid, layer.gid
|
|
459
|
+
raise ValueError(
|
|
460
|
+
f'chown("{chown.path}"): no preceding .user() layer. '
|
|
461
|
+
f"Add .user() before .chown(), or specify uid/gid explicitly."
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
async def build(
|
|
465
|
+
self,
|
|
466
|
+
target: str | BuildTarget,
|
|
467
|
+
*,
|
|
468
|
+
client: Any = None,
|
|
469
|
+
backend: Any = None,
|
|
470
|
+
) -> Any:
|
|
471
|
+
"""Build an artifact. Uses TaskGroup for concurrent builds when called in parallel."""
|
|
472
|
+
from containerspec.backends import auto_detect_backend
|
|
473
|
+
from containerspec.targets import DockerTarget
|
|
474
|
+
|
|
475
|
+
if isinstance(target, str):
|
|
476
|
+
target = DockerTarget(name=target)
|
|
477
|
+
if client is None and target.needs_client:
|
|
478
|
+
import docker
|
|
479
|
+
|
|
480
|
+
client = docker.from_env()
|
|
481
|
+
if backend is None:
|
|
482
|
+
backend = auto_detect_backend(target=target)
|
|
483
|
+
|
|
484
|
+
hash_str = self.content_hash(client=client)
|
|
485
|
+
hash_16 = hash_str[:16]
|
|
486
|
+
|
|
487
|
+
logger.info(
|
|
488
|
+
"containerspec.build.start",
|
|
489
|
+
extra={"target": type(target).__name__, "hash": hash_16, "base": self.base},
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
if target.exists(hash=hash_16, client=client):
|
|
493
|
+
logger.info("containerspec.build.cache_hit", extra={"hash": hash_16})
|
|
494
|
+
return target.result_from_cache(hash=hash_16, client=client)
|
|
495
|
+
|
|
496
|
+
logger.info("containerspec.build.cache_miss", extra={"hash": hash_16})
|
|
497
|
+
|
|
498
|
+
tag = f"{target.name}:sha-{hash_16}"
|
|
499
|
+
canonical = self._canonical_json(client=client)
|
|
500
|
+
dockerfile = self._to_build_dockerfile(client=client)
|
|
501
|
+
pull = not self.pin_digest
|
|
502
|
+
|
|
503
|
+
try:
|
|
504
|
+
result = await target.export(
|
|
505
|
+
dockerfile=dockerfile,
|
|
506
|
+
tag=tag,
|
|
507
|
+
canonical_json=canonical,
|
|
508
|
+
client=client,
|
|
509
|
+
backend=backend,
|
|
510
|
+
pull=pull,
|
|
511
|
+
)
|
|
512
|
+
except (MissingToolError, BuildError):
|
|
513
|
+
raise
|
|
514
|
+
except Exception as e:
|
|
515
|
+
import tempfile
|
|
516
|
+
|
|
517
|
+
fd, dockerfile_debug_path = tempfile.mkstemp(
|
|
518
|
+
suffix=".Dockerfile", prefix=f"containerspec-failed-{hash_16}-"
|
|
519
|
+
)
|
|
520
|
+
try:
|
|
521
|
+
with os.fdopen(fd, "w") as f:
|
|
522
|
+
f.write(dockerfile)
|
|
523
|
+
except OSError:
|
|
524
|
+
pass
|
|
525
|
+
logger.error(
|
|
526
|
+
"containerspec.build.failed",
|
|
527
|
+
extra={"hash": hash_16, "dockerfile": dockerfile_debug_path},
|
|
528
|
+
)
|
|
529
|
+
raise BuildError(
|
|
530
|
+
f"Build failed for {target.name}:sha-{hash_16}. "
|
|
531
|
+
f"Failed Dockerfile saved to {dockerfile_debug_path}",
|
|
532
|
+
) from e
|
|
533
|
+
logger.info("containerspec.build.complete", extra={"hash": hash_16, "tag": tag})
|
|
534
|
+
return result
|
|
535
|
+
|
|
536
|
+
def _resolve_hf_home(self) -> str:
|
|
537
|
+
"""Resolve HF_HOME from env layers. Used by consumers that need it."""
|
|
538
|
+
for layer in self.layers:
|
|
539
|
+
if isinstance(layer, Env) and "HF_HOME" in layer.vars:
|
|
540
|
+
return layer.vars["HF_HOME"]
|
|
541
|
+
return "/root/.cache/huggingface"
|
|
542
|
+
|
|
543
|
+
def _resolve_uid(self) -> int:
|
|
544
|
+
"""Resolve uid from user layers. Used by consumers that need it."""
|
|
545
|
+
for layer in reversed(self.layers):
|
|
546
|
+
if isinstance(layer, User):
|
|
547
|
+
return layer.uid
|
|
548
|
+
return 0
|
|
549
|
+
|
|
550
|
+
def __repr__(self) -> str:
|
|
551
|
+
return f"ImageSpec(base={self.base!r}, layers={len(self.layers)})"
|