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/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)})"