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/py.typed ADDED
File without changes
@@ -0,0 +1,527 @@
1
+ """Dockerfile rendering with build context state tracking.
2
+
3
+ Dockerfile is a stateful, sequential format — USER, WORKDIR, ENV persist
4
+ for all subsequent layers. The RenderContext tracks this accumulated state
5
+ so each layer renders with correct paths, cache mounts, and user switches.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+ from dataclasses import dataclass
13
+ from typing import TYPE_CHECKING
14
+
15
+ from containerspec.distros import distro_from_pm, get_profile
16
+ from containerspec.layers import (
17
+ AddPython,
18
+ ApkInstall,
19
+ AptInstall,
20
+ BrewInstall,
21
+ CargoInstall,
22
+ Chown,
23
+ Cmd,
24
+ Copy,
25
+ CopyFromStage,
26
+ DnfInstall,
27
+ Entrypoint,
28
+ Env,
29
+ Expose,
30
+ GemInstall,
31
+ GoInstall,
32
+ Layer,
33
+ NpmInstall,
34
+ NvmInstall,
35
+ PacmanInstall,
36
+ PipInstall,
37
+ PnpmInstall,
38
+ RunCommands,
39
+ RustInstall,
40
+ User,
41
+ UvPipInstall,
42
+ UvxInstall,
43
+ Volume,
44
+ Workdir,
45
+ YarnInstall,
46
+ ZypperInstall,
47
+ )
48
+
49
+ if TYPE_CHECKING:
50
+ from containerspec.spec import ImageSpec
51
+
52
+ NVM_VERSION = "0.40.6"
53
+ NVM_INSTALL_SCRIPT = f"https://raw.githubusercontent.com/nvm-sh/nvm/v{NVM_VERSION}/install.sh"
54
+
55
+ _PKG_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+:=@~/\[\]-]*$")
56
+
57
+
58
+ def validate_package(name: str) -> str:
59
+ """Validate a package name against a safe pattern to prevent shell injection."""
60
+ if not name:
61
+ raise ValueError("Package name cannot be empty")
62
+ if not _PKG_PATTERN.match(name):
63
+ raise ValueError(
64
+ f"Invalid package name: {name!r}. Package names must match {_PKG_PATTERN.pattern}"
65
+ )
66
+ return name
67
+
68
+
69
+ @dataclass
70
+ class RenderContext:
71
+ """Tracks accumulated Dockerfile state from preceding layers."""
72
+
73
+ current_user: User | None = None
74
+ current_workdir: str = "/"
75
+ python_venv: str | None = None
76
+ rust_installed: bool = False
77
+ nvm_installed: bool = False
78
+ brew_installed: bool = False
79
+ package_manager: str | None = None
80
+ distro: str | None = None
81
+
82
+ @property
83
+ def home(self) -> str:
84
+ if self.current_user and self.current_user.uid != 0:
85
+ return f"/home/{self.current_user.name}"
86
+ return "/root"
87
+
88
+ @property
89
+ def is_root(self) -> bool:
90
+ return self.current_user is None or self.current_user.uid == 0
91
+
92
+ @property
93
+ def uid(self) -> int:
94
+ return self.current_user.uid if self.current_user else 0
95
+
96
+ @property
97
+ def gid(self) -> int:
98
+ return self.current_user.gid if self.current_user else 0
99
+
100
+ @property
101
+ def is_alpine(self) -> bool:
102
+ return self.package_manager == "apk"
103
+
104
+ def uv_cache(self) -> str:
105
+ return f"{self.home}/.cache/uv"
106
+
107
+ def pip_cache(self) -> str:
108
+ return f"{self.home}/.cache/pip"
109
+
110
+ def npm_cache(self) -> str:
111
+ return f"{self.home}/.npm"
112
+
113
+ def cargo_registry(self) -> str:
114
+ return f"{self.home}/.cargo/registry"
115
+
116
+ def cargo_git(self) -> str:
117
+ return f"{self.home}/.cargo/git"
118
+
119
+ def pnpm_store(self) -> str:
120
+ return f"{self.home}/.local/share/pnpm/store"
121
+
122
+ def update(self, layer: Layer) -> None:
123
+ """Update context state after a layer has been rendered."""
124
+ if isinstance(layer, User):
125
+ self.current_user = layer
126
+ elif isinstance(layer, Workdir):
127
+ self.current_workdir = layer.path
128
+ elif isinstance(layer, AddPython):
129
+ self.python_venv = "/opt/venv"
130
+ elif isinstance(layer, RustInstall):
131
+ self.rust_installed = True
132
+ elif isinstance(layer, NvmInstall):
133
+ self.nvm_installed = True
134
+ elif isinstance(layer, BrewInstall):
135
+ self.brew_installed = True
136
+ elif isinstance(layer, AptInstall):
137
+ self.package_manager = "apt"
138
+ elif isinstance(layer, ApkInstall):
139
+ self.package_manager = "apk"
140
+ elif isinstance(layer, DnfInstall):
141
+ self.package_manager = "dnf"
142
+ elif isinstance(layer, PacmanInstall):
143
+ self.package_manager = "pacman"
144
+ elif isinstance(layer, ZypperInstall):
145
+ self.package_manager = "zypper"
146
+
147
+
148
+ _DISTRO_TO_PM = {
149
+ "debian": "apt",
150
+ "alpine": "apk",
151
+ "rhel": "dnf",
152
+ "fedora": "dnf",
153
+ "arch": "pacman",
154
+ "opensuse": "zypper",
155
+ "busybox": "apk",
156
+ }
157
+
158
+
159
+ def render_dockerfile(spec: ImageSpec, layer: Layer, index: int) -> list[str]:
160
+ """Render a single layer with context from preceding layers."""
161
+ ctx = RenderContext()
162
+ if spec.distro:
163
+ ctx.distro = spec.distro
164
+ if spec.distro in _DISTRO_TO_PM:
165
+ ctx.package_manager = _DISTRO_TO_PM[spec.distro]
166
+ for preceding in spec.layers[:index]:
167
+ ctx.update(preceding)
168
+ return render_layer(spec, layer, index, ctx)
169
+
170
+
171
+ def render_layer(spec: ImageSpec, layer: Layer, index: int, ctx: RenderContext) -> list[str]:
172
+ """Render a single layer to Dockerfile lines using the current build context."""
173
+ if isinstance(layer, AddPython):
174
+ return _render_add_python(layer)
175
+ if isinstance(layer, AptInstall):
176
+ return _render_apt_install(layer)
177
+ if isinstance(layer, ApkInstall):
178
+ return _render_apk_install(layer)
179
+ if isinstance(layer, DnfInstall):
180
+ return _render_dnf_install(layer)
181
+ if isinstance(layer, PacmanInstall):
182
+ return _render_pacman_install(layer)
183
+ if isinstance(layer, ZypperInstall):
184
+ return _render_zypper_install(layer)
185
+ if isinstance(layer, UvPipInstall):
186
+ return _render_uv_pip_install(layer, ctx)
187
+ if isinstance(layer, PipInstall):
188
+ return _render_pip_install(layer, ctx)
189
+ if isinstance(layer, Env):
190
+ return _render_env(layer)
191
+ if isinstance(layer, RunCommands):
192
+ return _render_run_commands(layer)
193
+ if isinstance(layer, Workdir):
194
+ return _render_workdir(layer)
195
+ if isinstance(layer, Chown):
196
+ return _render_chown(spec, layer, index, ctx)
197
+ if isinstance(layer, User):
198
+ return _render_user(layer, ctx)
199
+ if isinstance(layer, Entrypoint):
200
+ return _render_entrypoint(layer)
201
+ if isinstance(layer, Expose):
202
+ return _render_expose(layer)
203
+ if isinstance(layer, Cmd):
204
+ return _render_cmd(layer)
205
+ if isinstance(layer, Volume):
206
+ return _render_volume(layer)
207
+ if isinstance(layer, Copy):
208
+ return _render_copy(layer)
209
+ if isinstance(layer, CopyFromStage):
210
+ return _render_copy_from_stage(layer)
211
+ if isinstance(layer, NvmInstall):
212
+ return _render_nvm_install(layer, ctx)
213
+ if isinstance(layer, NpmInstall):
214
+ return _render_npm_install(layer, ctx)
215
+ if isinstance(layer, PnpmInstall):
216
+ return _render_pnpm_install(layer, ctx)
217
+ if isinstance(layer, BrewInstall):
218
+ return _render_brew_install(layer, ctx)
219
+ if isinstance(layer, RustInstall):
220
+ return _render_rust_install(ctx)
221
+ if isinstance(layer, CargoInstall):
222
+ return _render_cargo_install(layer, ctx)
223
+ if isinstance(layer, UvxInstall):
224
+ return _render_uvx_install(layer, ctx)
225
+ if isinstance(layer, YarnInstall):
226
+ return _render_yarn_install(layer, ctx)
227
+ if isinstance(layer, GemInstall):
228
+ return _render_gem_install(layer, ctx)
229
+ if isinstance(layer, GoInstall):
230
+ return _render_go_install(layer, ctx)
231
+ raise TypeError(f"Unknown layer type: {type(layer).__name__}")
232
+
233
+
234
+ def _render_add_python(layer: AddPython) -> list[str]:
235
+ return [
236
+ f'# add_python("{layer.version}")',
237
+ "COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/",
238
+ f"RUN uv python install {layer.version} && uv venv --python {layer.version} /opt/venv",
239
+ "ENV PATH=/opt/venv/bin:$PATH",
240
+ "ENV VIRTUAL_ENV=/opt/venv",
241
+ ]
242
+
243
+
244
+ def _render_apt_install(layer: AptInstall) -> list[str]:
245
+ pkgs = " ".join(validate_package(p) for p in layer.packages)
246
+ pkgs_repr = ", ".join(f'"{p}"' for p in layer.packages)
247
+ return [
248
+ f"# apt_install({pkgs_repr})",
249
+ "RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\\n"
250
+ " --mount=type=cache,target=/var/lib/apt,sharing=locked \\\n"
251
+ f" DEBIAN_FRONTEND=noninteractive apt-get update && "
252
+ f"apt-get install -y --no-install-recommends {pkgs}",
253
+ ]
254
+
255
+
256
+ def _render_apk_install(layer: ApkInstall) -> list[str]:
257
+ pkgs = " ".join(validate_package(p) for p in layer.packages)
258
+ pkgs_repr = ", ".join(f'"{p}"' for p in layer.packages)
259
+ return [
260
+ f"# apk_install({pkgs_repr})",
261
+ "RUN --mount=type=cache,target=/var/cache/apk,sharing=locked \\\n"
262
+ f" apk add --no-cache {pkgs}",
263
+ ]
264
+
265
+
266
+ def _render_dnf_install(layer: DnfInstall) -> list[str]:
267
+ pkgs = " ".join(validate_package(p) for p in layer.packages)
268
+ pkgs_repr = ", ".join(f'"{p}"' for p in layer.packages)
269
+ return [
270
+ f"# dnf_install({pkgs_repr})",
271
+ "RUN --mount=type=cache,target=/var/cache/dnf,sharing=locked \\\n"
272
+ f" dnf install -y {pkgs} && dnf clean all",
273
+ ]
274
+
275
+
276
+ def _render_uv_pip_install(layer: UvPipInstall, ctx: RenderContext) -> list[str]:
277
+ pkgs = " ".join(validate_package(p) for p in layer.packages)
278
+ pkgs_repr = ", ".join(f'"{p}"' for p in layer.packages)
279
+ cache = ctx.uv_cache()
280
+ return [
281
+ f"# uv_pip_install({pkgs_repr})",
282
+ f"RUN --mount=type=cache,target={cache},sharing=locked \\\n"
283
+ f" UV_LINK_MODE=copy uv pip install {pkgs}",
284
+ ]
285
+
286
+
287
+ def _render_pip_install(layer: PipInstall, ctx: RenderContext) -> list[str]:
288
+ pkgs = " ".join(validate_package(p) for p in layer.packages)
289
+ pkgs_repr = ", ".join(f'"{p}"' for p in layer.packages)
290
+ cache = ctx.pip_cache()
291
+ return [
292
+ f"# pip_install({pkgs_repr})",
293
+ f"RUN --mount=type=cache,target={cache},sharing=locked \\\n"
294
+ f" pip install --no-cache-dir {pkgs}",
295
+ ]
296
+
297
+
298
+ def _render_env(layer: Env) -> list[str]:
299
+ lines = [f"# env({json.dumps(dict(layer.vars))})"]
300
+ for k, v in layer.vars.items():
301
+ if " " in v or '"' in v:
302
+ escaped = v.replace("\\", "\\\\").replace('"', '\\"')
303
+ lines.append(f'ENV {k}="{escaped}"')
304
+ else:
305
+ lines.append(f"ENV {k}={v}")
306
+ return lines
307
+
308
+
309
+ def _render_run_commands(layer: RunCommands) -> list[str]:
310
+ cmds_repr = ", ".join(f'"{c}"' for c in layer.commands)
311
+ if len(layer.commands) == 1:
312
+ return [f"# run_commands({cmds_repr})", f"RUN {layer.commands[0]}"]
313
+ chained = " && ".join(layer.commands)
314
+ return [f"# run_commands({cmds_repr})", f"RUN {chained}"]
315
+
316
+
317
+ def _render_workdir(layer: Workdir) -> list[str]:
318
+ return [f'# workdir("{layer.path}")', f"WORKDIR {layer.path}"]
319
+
320
+
321
+ def _render_chown(spec: ImageSpec, layer: Chown, index: int, ctx: RenderContext) -> list[str]:
322
+ """Render chown with USER root sandwich when non-root user is active."""
323
+ uid, gid = spec.resolve_chown_uid_gid(layer, index=index)
324
+ path = layer.path
325
+ source = " from preceding .user()" if layer.uid is None and layer.gid is None else ""
326
+ lines: list[str] = [f'# chown("{path}") — resolved to uid={uid}, gid={gid}{source}']
327
+
328
+ if not ctx.is_root:
329
+ lines.append("USER root")
330
+ lines.append(f"RUN mkdir -p {path} && chown -R {uid}:{gid} {path}")
331
+ lines.append(f"USER {ctx.uid}:{ctx.gid}")
332
+ else:
333
+ lines.append(f"RUN mkdir -p {path} && chown -R {uid}:{gid} {path}")
334
+ return lines
335
+
336
+
337
+ def _render_user(layer: User, ctx: RenderContext) -> list[str]:
338
+ """Render user creation using distro-specific commands from DistroProfile."""
339
+ effective_distro = (
340
+ (layer.alpine and "alpine")
341
+ or (ctx.package_manager and distro_from_pm(ctx.package_manager))
342
+ or ctx.distro
343
+ )
344
+ profile = get_profile(effective_distro if effective_distro else "debian")
345
+ group_cmd = profile.group_add.format(gid=layer.gid, name=layer.name)
346
+ user_cmd = profile.user_add.format(uid=layer.uid, gid=layer.gid, name=layer.name)
347
+ return [
348
+ f'# user(uid={layer.uid}, gid={layer.gid}, name="{layer.name}")',
349
+ f"RUN {group_cmd} && {user_cmd}",
350
+ f"USER {layer.uid}:{layer.gid}",
351
+ ]
352
+
353
+
354
+ def _render_entrypoint(layer: Entrypoint) -> list[str]:
355
+ if layer.commands is None:
356
+ return ["# entrypoint(None)"]
357
+ cmds_repr = ", ".join(f'"{c}"' for c in layer.commands)
358
+ return [f"# entrypoint([{cmds_repr}])", f"ENTRYPOINT {json.dumps(list(layer.commands))}"]
359
+
360
+
361
+ def _render_expose(layer: Expose) -> list[str]:
362
+ ports_repr = ", ".join(str(p) for p in layer.ports)
363
+ return [f"# expose({ports_repr})", f"EXPOSE {' '.join(str(p) for p in layer.ports)}"]
364
+
365
+
366
+ def _render_cmd(layer: Cmd) -> list[str]:
367
+ if layer.commands is None:
368
+ return ["# cmd(None)"]
369
+ cmds_repr = ", ".join(f'"{c}"' for c in layer.commands)
370
+ return [f"# cmd([{cmds_repr}])", f"CMD {json.dumps(list(layer.commands))}"]
371
+
372
+
373
+ def _render_volume(layer: Volume) -> list[str]:
374
+ paths_repr = ", ".join(f'"{p}"' for p in layer.paths)
375
+ return [f"# volume({paths_repr})", f"VOLUME {json.dumps(list(layer.paths))}"]
376
+
377
+
378
+ def _render_copy(layer: Copy) -> list[str]:
379
+ return [
380
+ f'# copy("{layer.src}", "{layer.dest}")',
381
+ f"COPY {layer.src} {layer.dest}",
382
+ ]
383
+
384
+
385
+ def _render_copy_from_stage(layer: CopyFromStage) -> list[str]:
386
+ return [
387
+ f'# copy_from_stage("{layer.stage_name}", "{layer.src}", "{layer.dest}")',
388
+ f"COPY --from={layer.stage_name} {layer.src} {layer.dest}",
389
+ ]
390
+
391
+
392
+ def _render_nvm_install(layer: NvmInstall, ctx: RenderContext) -> list[str]:
393
+ version = layer.version
394
+ nvm_dir = f"{ctx.home}/.nvm"
395
+ return [
396
+ f'# nvm_install("{version}")',
397
+ 'SHELL ["/bin/bash", "-o", "pipefail", "-c"]',
398
+ f"RUN curl -o- {NVM_INSTALL_SCRIPT} | bash \\\n"
399
+ f' && export NVM_DIR="{nvm_dir}" && . "$NVM_DIR/nvm.sh" \\\n'
400
+ f" && nvm install {version} \\\n"
401
+ f" && nvm alias default {version} \\\n"
402
+ f" && npm config set prefix /usr/local \\\n"
403
+ f" && NODE_DIR=$(dirname $(which node)) \\\n"
404
+ f" && ln -sf $NODE_DIR/node /usr/local/bin/node \\\n"
405
+ f" && ln -sf $NODE_DIR/npm /usr/local/bin/npm \\\n"
406
+ f" && ln -sf $NODE_DIR/npx /usr/local/bin/npx",
407
+ f"ENV NVM_DIR={nvm_dir}",
408
+ ]
409
+
410
+
411
+ def _render_npm_install(layer: NpmInstall, ctx: RenderContext) -> list[str]:
412
+ pkgs = " ".join(validate_package(p) for p in layer.packages)
413
+ pkgs_repr = ", ".join(f'"{p}"' for p in layer.packages)
414
+ cache = ctx.npm_cache()
415
+ return [
416
+ f"# npm_install({pkgs_repr})",
417
+ f"RUN --mount=type=cache,target={cache},sharing=locked \\\n npm install -g {pkgs}",
418
+ ]
419
+
420
+
421
+ def _render_pnpm_install(layer: PnpmInstall, ctx: RenderContext) -> list[str]:
422
+ pkgs = " ".join(validate_package(p) for p in layer.packages)
423
+ pkgs_repr = ", ".join(f'"{p}"' for p in layer.packages)
424
+ store = ctx.pnpm_store()
425
+ return [
426
+ f"# pnpm_install({pkgs_repr})",
427
+ f"RUN --mount=type=cache,target={store},sharing=locked \\\n"
428
+ f" npm install -g pnpm && pnpm add -g {pkgs}",
429
+ ]
430
+
431
+
432
+ def _render_brew_install(layer: BrewInstall, ctx: RenderContext) -> list[str]:
433
+ pkgs = " ".join(validate_package(p) for p in layer.packages)
434
+ pkgs_repr = ", ".join(f'"{p}"' for p in layer.packages)
435
+ brew_prefix = "/home/linuxbrew/.linuxbrew"
436
+ shellenv = f'eval "$({brew_prefix}/bin/brew shellenv)"'
437
+ return [
438
+ f"# brew_install({pkgs_repr})",
439
+ 'SHELL ["/bin/bash", "-o", "pipefail", "-c"]',
440
+ "RUN if ! command -v brew &>/dev/null; then \\\n"
441
+ ' /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"; \\\n'
442
+ f" echo 'eval \"$({brew_prefix}/bin/brew shellenv)\"' >> {ctx.home}/.bashrc; \\\n"
443
+ "fi && \\\n"
444
+ f" {shellenv} && \\\n"
445
+ f" brew install {pkgs}",
446
+ f"ENV PATH={brew_prefix}/bin:$PATH",
447
+ ]
448
+
449
+
450
+ def _render_rust_install(ctx: RenderContext) -> list[str]:
451
+ cargo_bin = f"{ctx.home}/.cargo/bin"
452
+ return [
453
+ "# rust_install()",
454
+ 'SHELL ["/bin/bash", "-o", "pipefail", "-c"]',
455
+ "RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y",
456
+ f"ENV PATH={cargo_bin}:$PATH",
457
+ ]
458
+
459
+
460
+ def _render_cargo_install(layer: CargoInstall, ctx: RenderContext) -> list[str]:
461
+ pkgs = " ".join(validate_package(p) for p in layer.packages)
462
+ pkgs_repr = ", ".join(f'"{p}"' for p in layer.packages)
463
+ registry = ctx.cargo_registry()
464
+ git = ctx.cargo_git()
465
+ return [
466
+ f"# cargo_install({pkgs_repr})",
467
+ f"RUN --mount=type=cache,target={registry},sharing=locked \\\n"
468
+ f" --mount=type=cache,target={git},sharing=locked \\\n"
469
+ f" cargo install {pkgs}",
470
+ ]
471
+
472
+
473
+ def _render_uvx_install(layer: UvxInstall, ctx: RenderContext) -> list[str]:
474
+ pkgs = " ".join(validate_package(p) for p in layer.packages)
475
+ pkgs_repr = ", ".join(f'"{p}"' for p in layer.packages)
476
+ cache = ctx.uv_cache()
477
+ return [
478
+ f"# uvx_install({pkgs_repr})",
479
+ f"RUN --mount=type=cache,target={cache},sharing=locked \\\n uvx --system {pkgs}",
480
+ ]
481
+
482
+
483
+ def _render_pacman_install(layer: PacmanInstall) -> list[str]:
484
+ pkgs = " ".join(validate_package(p) for p in layer.packages)
485
+ pkgs_repr = ", ".join(f'"{p}"' for p in layer.packages)
486
+ return [
487
+ f"# pacman_install({pkgs_repr})",
488
+ f"RUN pacman -S --noconfirm --needed {pkgs} && pacman -Scc --noconfirm",
489
+ ]
490
+
491
+
492
+ def _render_zypper_install(layer: ZypperInstall) -> list[str]:
493
+ pkgs = " ".join(validate_package(p) for p in layer.packages)
494
+ pkgs_repr = ", ".join(f'"{p}"' for p in layer.packages)
495
+ return [
496
+ f"# zypper_install({pkgs_repr})",
497
+ f"RUN zypper install -y {pkgs} && zypper clean -a",
498
+ ]
499
+
500
+
501
+ def _render_yarn_install(layer: YarnInstall, ctx: RenderContext) -> list[str]:
502
+ pkgs = " ".join(validate_package(p) for p in layer.packages)
503
+ pkgs_repr = ", ".join(f'"{p}"' for p in layer.packages)
504
+ cache = f"{ctx.home}/.cache/yarn"
505
+ return [
506
+ f"# yarn_install({pkgs_repr})",
507
+ f"RUN --mount=type=cache,target={cache},sharing=locked \\\n yarn global add {pkgs}",
508
+ ]
509
+
510
+
511
+ def _render_gem_install(layer: GemInstall, ctx: RenderContext) -> list[str]:
512
+ pkgs = " ".join(validate_package(p) for p in layer.packages)
513
+ pkgs_repr = ", ".join(f'"{p}"' for p in layer.packages)
514
+ return [
515
+ f"# gem_install({pkgs_repr})",
516
+ f"RUN gem install {pkgs}",
517
+ ]
518
+
519
+
520
+ def _render_go_install(layer: GoInstall, ctx: RenderContext) -> list[str]:
521
+ pkgs = " ".join(validate_package(p) for p in layer.packages)
522
+ pkgs_repr = ", ".join(f'"{p}"' for p in layer.packages)
523
+ cache = f"{ctx.home}/.cache/go-build"
524
+ return [
525
+ f"# go_install({pkgs_repr})",
526
+ f"RUN --mount=type=cache,target={cache},sharing=locked \\\n go install {pkgs}",
527
+ ]
@@ -0,0 +1,165 @@
1
+ """Rootfs creation — ext4 via mke2fs -d, sidecar metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import os
9
+ import shutil
10
+ import tempfile
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ logger = logging.getLogger("containerspec")
15
+
16
+
17
+ class MissingToolError(RuntimeError):
18
+ """Raised when a required host tool (e.g. mke2fs) is not found."""
19
+
20
+
21
+ def check_mke2fs() -> None:
22
+ """Raise MissingToolError if mke2fs is not on PATH."""
23
+ path = shutil.which("mke2fs")
24
+ if path is None:
25
+ raise MissingToolError(
26
+ "mke2fs not found — install e2fsprogs to build Firecracker rootfs images"
27
+ )
28
+ logger.debug("containerspec.tool_check.mke2fs", extra={"path": path})
29
+
30
+
31
+ def check_buildah() -> None:
32
+ """Raise MissingToolError if buildah is not on PATH."""
33
+ path = shutil.which("buildah")
34
+ if path is None:
35
+ raise MissingToolError("buildah not found — install buildah to use the BuildahBackend")
36
+ logger.debug("containerspec.tool_check.buildah", extra={"path": path})
37
+
38
+
39
+ async def create_ext4(*, rootfs_dir: str, dest: str, size_mb: int) -> None:
40
+ """Create an ext4 image at dest, populated from rootfs_dir. No root needed."""
41
+ check_mke2fs()
42
+ size_arg = f"{size_mb}M"
43
+ logger.info(
44
+ "containerspec.rootfs.create",
45
+ extra={"dest": dest, "size_mb": size_mb, "rootfs_dir": rootfs_dir},
46
+ )
47
+ proc = await asyncio.create_subprocess_exec(
48
+ "mke2fs",
49
+ "-t",
50
+ "ext4",
51
+ "-d",
52
+ rootfs_dir,
53
+ dest,
54
+ size_arg,
55
+ stdout=asyncio.subprocess.PIPE,
56
+ stderr=asyncio.subprocess.PIPE,
57
+ )
58
+ _, stderr = await proc.communicate()
59
+ if proc.returncode != 0:
60
+ raise RuntimeError(
61
+ f"mke2fs failed with exit code {proc.returncode}:\n"
62
+ f"{stderr.decode() if stderr else '(no stderr)'}"
63
+ )
64
+ logger.info("containerspec.rootfs.complete", extra={"dest": dest})
65
+
66
+
67
+ def write_sidecar(path: str, *, hash_16: str, spec_json: str) -> None:
68
+ """Write sidecar metadata atomically (unique temp file + rename)."""
69
+ data = {"hash": hash_16, "spec": json.loads(spec_json)}
70
+ fd, tmp_path = tempfile.mkstemp(
71
+ dir=os.path.dirname(path) or ".", prefix=".containerspec-", suffix=".tmp"
72
+ )
73
+ try:
74
+ with os.fdopen(fd, "w") as f:
75
+ json.dump(data, f, sort_keys=True)
76
+ os.replace(tmp_path, path)
77
+ except Exception:
78
+ os.unlink(tmp_path)
79
+ raise
80
+ logger.debug("containerspec.sidecar.written", extra={"path": path, "hash": hash_16})
81
+
82
+
83
+ def read_sidecar(path: str) -> dict[str, Any] | None:
84
+ """Read sidecar metadata. Returns None if missing or invalid."""
85
+ p = Path(path)
86
+ if not p.exists():
87
+ return None
88
+ try:
89
+ return json.loads(p.read_text())
90
+ except (json.JSONDecodeError, OSError):
91
+ return None
92
+
93
+
94
+ async def convert_oci_to_rootfs(
95
+ *,
96
+ oci_tar_path: str,
97
+ dest: str,
98
+ size_mb: int,
99
+ converter_image: str = "oci2rootfs:latest",
100
+ ) -> None:
101
+ """Convert an OCI tarball to an ext4 rootfs using oci2rootfs in a container.
102
+
103
+ Isolates the conversion — no host e2fsprogs dependency. Requires Docker.
104
+ The converter_image must have oci2rootfs CLI installed. Build one from
105
+ https://github.com/arcboxlabs/oci2rootfs or provide your own.
106
+ """
107
+ import tempfile
108
+
109
+ size_str = f"{size_mb}M"
110
+ work_dir = tempfile.mkdtemp(prefix="containerspec-oci2rootfs-")
111
+ try:
112
+ oci_dir = f"{work_dir}/oci"
113
+ output_dir = f"{work_dir}/output"
114
+ Path(oci_dir).mkdir()
115
+ Path(output_dir).mkdir()
116
+
117
+ extract_cmd = ["tar", "xf", oci_tar_path, "-C", oci_dir]
118
+ proc = await asyncio.create_subprocess_exec(
119
+ *extract_cmd,
120
+ stdout=asyncio.subprocess.PIPE,
121
+ stderr=asyncio.subprocess.PIPE,
122
+ )
123
+ _, stderr = await proc.communicate()
124
+ if proc.returncode != 0:
125
+ raise RuntimeError(
126
+ f"tar extract failed with exit code {proc.returncode}:\n"
127
+ f"{stderr.decode() if stderr else '(no stderr)'}"
128
+ )
129
+
130
+ convert_cmd = [
131
+ "docker",
132
+ "run",
133
+ "--rm",
134
+ "-v",
135
+ f"{oci_dir}:/oci:ro",
136
+ "-v",
137
+ f"{output_dir}:/output",
138
+ converter_image,
139
+ "/oci",
140
+ "--output",
141
+ "/output/rootfs.ext4",
142
+ "--size",
143
+ size_str,
144
+ ]
145
+ logger.info("containerspec.oci2rootfs.run", extra={"converter_image": converter_image})
146
+ proc = await asyncio.create_subprocess_exec(
147
+ *convert_cmd,
148
+ stdout=asyncio.subprocess.PIPE,
149
+ stderr=asyncio.subprocess.PIPE,
150
+ )
151
+ _, stderr = await proc.communicate()
152
+ if proc.returncode != 0:
153
+ raise RuntimeError(
154
+ f"oci2rootfs failed with exit code {proc.returncode}:\n"
155
+ f"{stderr.decode() if stderr else '(no stderr)'}"
156
+ )
157
+
158
+ import shutil as _shutil
159
+
160
+ _shutil.move(f"{output_dir}/rootfs.ext4", dest)
161
+ logger.info("containerspec.oci2rootfs.complete", extra={"dest": dest})
162
+ finally:
163
+ import shutil as _shutil
164
+
165
+ _shutil.rmtree(work_dir, ignore_errors=True)