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/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
|
+
]
|
containerspec/rootfs.py
ADDED
|
@@ -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)
|