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
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""ContainerSpec — fluent, content-hashed image builder for Docker, Firecracker, and OCI.
|
|
2
|
+
|
|
3
|
+
Mirrors Modal's Image API surface but targets any Docker daemon, BuildKit instance,
|
|
4
|
+
or Buildah (daemonless) for producing Docker images, Firecracker rootfs ext4 images,
|
|
5
|
+
and OCI tarballs. Every method returns a new frozen ``ImageSpec``.
|
|
6
|
+
|
|
7
|
+
Convenience methods (``nvm_install``, ``npm_install``, ``brew_install``, ``rust_install``,
|
|
8
|
+
``cargo_install``, ``uvx_install``) generate the correct bootstrap + install + PATH setup.
|
|
9
|
+
For custom tooling, use ``run_commands()`` directly.
|
|
10
|
+
|
|
11
|
+
``docker`` is a lazy import inside ``build()`` — ``to_dockerfile()``,
|
|
12
|
+
``content_hash()``, and ``tag()`` work without Docker installed.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from containerspec.backends import (
|
|
18
|
+
BuildahBackend,
|
|
19
|
+
BuildBackend,
|
|
20
|
+
BuildError,
|
|
21
|
+
BuildKitBackend,
|
|
22
|
+
DockerBackend,
|
|
23
|
+
)
|
|
24
|
+
from containerspec.layers import (
|
|
25
|
+
AddPython,
|
|
26
|
+
ApkInstall,
|
|
27
|
+
AptInstall,
|
|
28
|
+
BrewInstall,
|
|
29
|
+
CargoInstall,
|
|
30
|
+
Chown,
|
|
31
|
+
Cmd,
|
|
32
|
+
Copy,
|
|
33
|
+
CopyFromStage,
|
|
34
|
+
DnfInstall,
|
|
35
|
+
Entrypoint,
|
|
36
|
+
Env,
|
|
37
|
+
Expose,
|
|
38
|
+
GemInstall,
|
|
39
|
+
GoInstall,
|
|
40
|
+
Layer,
|
|
41
|
+
NpmInstall,
|
|
42
|
+
NvmInstall,
|
|
43
|
+
PacmanInstall,
|
|
44
|
+
PipInstall,
|
|
45
|
+
PnpmInstall,
|
|
46
|
+
RunCommands,
|
|
47
|
+
RustInstall,
|
|
48
|
+
User,
|
|
49
|
+
UvPipInstall,
|
|
50
|
+
UvxInstall,
|
|
51
|
+
Volume,
|
|
52
|
+
Workdir,
|
|
53
|
+
YarnInstall,
|
|
54
|
+
ZypperInstall,
|
|
55
|
+
)
|
|
56
|
+
from containerspec.rootfs import MissingToolError
|
|
57
|
+
from containerspec.spec import ImageSpec, StageSpec
|
|
58
|
+
from containerspec.targets import (
|
|
59
|
+
BuildTarget,
|
|
60
|
+
BuiltImage,
|
|
61
|
+
DockerTarget,
|
|
62
|
+
FirecrackerRootfs,
|
|
63
|
+
FirecrackerRootfsTarget,
|
|
64
|
+
OciArtifact,
|
|
65
|
+
OciTarget,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
__all__ = [
|
|
69
|
+
"AddPython",
|
|
70
|
+
"ApkInstall",
|
|
71
|
+
"AptInstall",
|
|
72
|
+
"BrewInstall",
|
|
73
|
+
"BuildBackend",
|
|
74
|
+
"BuildError",
|
|
75
|
+
"BuildKitBackend",
|
|
76
|
+
"BuildTarget",
|
|
77
|
+
"BuildahBackend",
|
|
78
|
+
"BuiltImage",
|
|
79
|
+
"CargoInstall",
|
|
80
|
+
"Chown",
|
|
81
|
+
"Cmd",
|
|
82
|
+
"Copy",
|
|
83
|
+
"CopyFromStage",
|
|
84
|
+
"DnfInstall",
|
|
85
|
+
"DockerBackend",
|
|
86
|
+
"DockerTarget",
|
|
87
|
+
"Entrypoint",
|
|
88
|
+
"Env",
|
|
89
|
+
"Expose",
|
|
90
|
+
"FirecrackerRootfs",
|
|
91
|
+
"FirecrackerRootfsTarget",
|
|
92
|
+
"GemInstall",
|
|
93
|
+
"GoInstall",
|
|
94
|
+
"ImageSpec",
|
|
95
|
+
"Layer",
|
|
96
|
+
"MissingToolError",
|
|
97
|
+
"NpmInstall",
|
|
98
|
+
"NvmInstall",
|
|
99
|
+
"OciArtifact",
|
|
100
|
+
"OciTarget",
|
|
101
|
+
"PacmanInstall",
|
|
102
|
+
"PipInstall",
|
|
103
|
+
"PnpmInstall",
|
|
104
|
+
"RunCommands",
|
|
105
|
+
"RustInstall",
|
|
106
|
+
"StageSpec",
|
|
107
|
+
"User",
|
|
108
|
+
"UvPipInstall",
|
|
109
|
+
"UvxInstall",
|
|
110
|
+
"Volume",
|
|
111
|
+
"Workdir",
|
|
112
|
+
"YarnInstall",
|
|
113
|
+
"ZypperInstall",
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Build backends — BuildKitBackend (docker buildx), BuildahBackend (daemonless), DockerBackend (docker-py)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import shutil
|
|
8
|
+
import tempfile
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Protocol, runtime_checkable
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("containerspec")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BuildError(RuntimeError):
|
|
17
|
+
"""Raised when a build subprocess fails. Includes stderr and command for diagnostics."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self, message: str, *, cmd: list[str] | None = None, stderr: str | None = None
|
|
21
|
+
) -> None:
|
|
22
|
+
self.cmd = cmd
|
|
23
|
+
self.stderr = stderr
|
|
24
|
+
parts = [message]
|
|
25
|
+
if cmd:
|
|
26
|
+
parts.append(f"Command: {' '.join(cmd)}")
|
|
27
|
+
if stderr:
|
|
28
|
+
parts.append(f"stderr:\n{stderr}")
|
|
29
|
+
super().__init__("\n".join(parts))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@runtime_checkable
|
|
33
|
+
class BuildBackend(Protocol):
|
|
34
|
+
"""A build execution backend."""
|
|
35
|
+
|
|
36
|
+
async def solve_and_export(
|
|
37
|
+
self,
|
|
38
|
+
*,
|
|
39
|
+
dockerfile: str,
|
|
40
|
+
tag: str,
|
|
41
|
+
output_type: str,
|
|
42
|
+
output_path: str | None,
|
|
43
|
+
labels: dict[str, str],
|
|
44
|
+
pull: bool,
|
|
45
|
+
) -> None: ...
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _write_dockerfile_temp(dockerfile: str) -> str:
|
|
49
|
+
"""Write Dockerfile to a temp file. Returns the path. Caller must clean up."""
|
|
50
|
+
fd, path = tempfile.mkstemp(suffix="Dockerfile", prefix="containerspec-")
|
|
51
|
+
with open(fd, "w") as f:
|
|
52
|
+
f.write(dockerfile)
|
|
53
|
+
logger.debug("containerspec.dockerfile.temp", extra={"path": path})
|
|
54
|
+
return path
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def _run_command(cmd: list[str], *, label: str) -> tuple[int, bytes, bytes]:
|
|
58
|
+
"""Run a command, log it, return (rc, stdout, stderr)."""
|
|
59
|
+
logger.info(f"containerspec.{label}.run", extra={"cmd": cmd})
|
|
60
|
+
proc = await asyncio.create_subprocess_exec(
|
|
61
|
+
*cmd,
|
|
62
|
+
stdout=asyncio.subprocess.PIPE,
|
|
63
|
+
stderr=asyncio.subprocess.PIPE,
|
|
64
|
+
)
|
|
65
|
+
stdout, stderr = await proc.communicate()
|
|
66
|
+
if proc.returncode != 0:
|
|
67
|
+
err = stderr.decode() if stderr else "(no stderr)"
|
|
68
|
+
logger.error(f"containerspec.{label}.failed", extra={"rc": proc.returncode, "stderr": err})
|
|
69
|
+
raise BuildError(
|
|
70
|
+
f"{label} failed with exit code {proc.returncode}",
|
|
71
|
+
cmd=cmd,
|
|
72
|
+
stderr=err,
|
|
73
|
+
)
|
|
74
|
+
logger.info(f"containerspec.{label}.complete")
|
|
75
|
+
return proc.returncode, stdout, stderr
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class BuildKitBackend:
|
|
80
|
+
"""Build via ``docker buildx build`` CLI. Supports all output types.
|
|
81
|
+
|
|
82
|
+
Dockerfile is written to a temp file and passed via ``-f`` — more robust
|
|
83
|
+
than stdin piping, and the temp file is preserved on failure for debugging.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
url: str | None = None
|
|
87
|
+
builder: str | None = None
|
|
88
|
+
|
|
89
|
+
async def solve_and_export(
|
|
90
|
+
self,
|
|
91
|
+
*,
|
|
92
|
+
dockerfile: str,
|
|
93
|
+
tag: str,
|
|
94
|
+
output_type: str,
|
|
95
|
+
output_path: str | None,
|
|
96
|
+
labels: dict[str, str],
|
|
97
|
+
pull: bool,
|
|
98
|
+
) -> None:
|
|
99
|
+
dockerfile_path = _write_dockerfile_temp(dockerfile)
|
|
100
|
+
try:
|
|
101
|
+
cmd: list[str] = ["docker", "buildx", "build"]
|
|
102
|
+
if self.builder:
|
|
103
|
+
cmd.extend(["--builder", self.builder])
|
|
104
|
+
if output_path:
|
|
105
|
+
output_val = f"type={output_type},dest={output_path}"
|
|
106
|
+
else:
|
|
107
|
+
output_val = f"type={output_type}"
|
|
108
|
+
cmd.extend(["--output", output_val, "--tag", tag, "-f", dockerfile_path])
|
|
109
|
+
if pull:
|
|
110
|
+
cmd.append("--pull")
|
|
111
|
+
for k, v in labels.items():
|
|
112
|
+
cmd.extend(["--label", f"{k}={v}"])
|
|
113
|
+
cmd.append(".")
|
|
114
|
+
await _run_command(cmd, label="buildx")
|
|
115
|
+
finally:
|
|
116
|
+
Path(dockerfile_path).unlink(missing_ok=True)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True)
|
|
120
|
+
class BuildahBackend:
|
|
121
|
+
"""Build via ``buildah bud`` CLI. Daemonless — no Docker daemon needed.
|
|
122
|
+
|
|
123
|
+
Default for FirecrackerRootfsTarget and OciTarget. Does NOT support
|
|
124
|
+
``output_type="docker"`` (use BuildKitBackend or DockerBackend for that).
|
|
125
|
+
Linux-only — ``MissingToolError`` if buildah is not on PATH.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
async def solve_and_export(
|
|
129
|
+
self,
|
|
130
|
+
*,
|
|
131
|
+
dockerfile: str,
|
|
132
|
+
tag: str,
|
|
133
|
+
output_type: str,
|
|
134
|
+
output_path: str | None,
|
|
135
|
+
labels: dict[str, str],
|
|
136
|
+
pull: bool,
|
|
137
|
+
) -> None:
|
|
138
|
+
from containerspec.rootfs import check_buildah
|
|
139
|
+
|
|
140
|
+
check_buildah()
|
|
141
|
+
|
|
142
|
+
if output_type == "docker":
|
|
143
|
+
raise BuildError(
|
|
144
|
+
"BuildahBackend does not support output_type='docker'. "
|
|
145
|
+
"Use BuildKitBackend or DockerBackend for Docker targets."
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
dockerfile_path = _write_dockerfile_temp(dockerfile)
|
|
149
|
+
try:
|
|
150
|
+
build_cmd: list[str] = ["buildah", "bud", "-f", dockerfile_path, "-t", tag]
|
|
151
|
+
if pull:
|
|
152
|
+
build_cmd.append("--pull")
|
|
153
|
+
for k, v in labels.items():
|
|
154
|
+
build_cmd.extend(["--label", f"{k}={v}"])
|
|
155
|
+
build_cmd.append(".")
|
|
156
|
+
await _run_command(build_cmd, label="buildah.bud")
|
|
157
|
+
|
|
158
|
+
if output_type == "oci" and output_path:
|
|
159
|
+
push_cmd = ["buildah", "push", "-f", "oci-archive", tag, output_path]
|
|
160
|
+
await _run_command(push_cmd, label="buildah.push.oci")
|
|
161
|
+
elif output_type == "local" and output_path:
|
|
162
|
+
await self._export_filesystem(tag=tag, dest=output_path)
|
|
163
|
+
finally:
|
|
164
|
+
Path(dockerfile_path).unlink(missing_ok=True)
|
|
165
|
+
|
|
166
|
+
async def _export_filesystem(self, *, tag: str, dest: str) -> None:
|
|
167
|
+
"""Export the working container's filesystem to dest using buildah mount."""
|
|
168
|
+
from_cmd = ["buildah", "from", tag]
|
|
169
|
+
_, stdout, _ = await _run_command(from_cmd, label="buildah.from")
|
|
170
|
+
container_name = stdout.decode().strip()
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
mount_cmd = ["buildah", "mount", container_name]
|
|
174
|
+
_, stdout, _ = await _run_command(mount_cmd, label="buildah.mount")
|
|
175
|
+
mountpoint = stdout.decode().strip()
|
|
176
|
+
|
|
177
|
+
copy_cmd = ["cp", "-a", f"{mountpoint}/.", dest + "/"]
|
|
178
|
+
await _run_command(copy_cmd, label="buildah.copy")
|
|
179
|
+
|
|
180
|
+
umount_cmd = ["buildah", "umount", container_name]
|
|
181
|
+
await _run_command(umount_cmd, label="buildah.umount")
|
|
182
|
+
finally:
|
|
183
|
+
rm_cmd = ["buildah", "rm", container_name]
|
|
184
|
+
await _run_command(rm_cmd, label="buildah.rm")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass
|
|
188
|
+
class DockerBackend:
|
|
189
|
+
"""Build via docker-py. Docker target only (fallback when buildx unavailable)."""
|
|
190
|
+
|
|
191
|
+
client: Any = None
|
|
192
|
+
|
|
193
|
+
async def solve_and_export(
|
|
194
|
+
self,
|
|
195
|
+
*,
|
|
196
|
+
dockerfile: str,
|
|
197
|
+
tag: str,
|
|
198
|
+
output_type: str,
|
|
199
|
+
output_path: str | None,
|
|
200
|
+
labels: dict[str, str],
|
|
201
|
+
pull: bool,
|
|
202
|
+
) -> None:
|
|
203
|
+
if output_type != "docker":
|
|
204
|
+
raise BuildError(
|
|
205
|
+
f"DockerBackend does not support output_type='{output_type}'. "
|
|
206
|
+
"Use BuildKitBackend or BuildahBackend for non-Docker targets."
|
|
207
|
+
)
|
|
208
|
+
import io
|
|
209
|
+
|
|
210
|
+
if self.client is None:
|
|
211
|
+
import docker
|
|
212
|
+
|
|
213
|
+
self.client = docker.from_env()
|
|
214
|
+
logger.info("containerspec.docker.build", extra={"tag": tag})
|
|
215
|
+
await asyncio.to_thread(
|
|
216
|
+
self.client.images.build,
|
|
217
|
+
fileobj=io.BytesIO(dockerfile.encode()),
|
|
218
|
+
tag=tag,
|
|
219
|
+
pull=pull,
|
|
220
|
+
rm=True,
|
|
221
|
+
labels=labels,
|
|
222
|
+
)
|
|
223
|
+
logger.info("containerspec.docker.complete", extra={"tag": tag})
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def auto_detect_backend(*, target: Any = None) -> BuildBackend:
|
|
227
|
+
"""Pick the best backend for the given target.
|
|
228
|
+
|
|
229
|
+
- DockerTarget: BuildKitBackend if buildx is available, else DockerBackend.
|
|
230
|
+
- FirecrackerRootfsTarget / OciTarget: BuildahBackend if available (daemonless),
|
|
231
|
+
else BuildKitBackend if buildx is available, else DockerBackend (will error
|
|
232
|
+
for non-Docker output types).
|
|
233
|
+
"""
|
|
234
|
+
from containerspec.targets import DockerTarget
|
|
235
|
+
|
|
236
|
+
is_docker_target = isinstance(target, DockerTarget) if target else True
|
|
237
|
+
|
|
238
|
+
if is_docker_target:
|
|
239
|
+
if shutil.which("docker") is not None:
|
|
240
|
+
return BuildKitBackend()
|
|
241
|
+
return DockerBackend()
|
|
242
|
+
|
|
243
|
+
if shutil.which("buildah") is not None:
|
|
244
|
+
return BuildahBackend()
|
|
245
|
+
if shutil.which("docker") is not None:
|
|
246
|
+
return BuildKitBackend()
|
|
247
|
+
return DockerBackend()
|
containerspec/distros.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Distro profiles — data-driven distro-specific command configuration.
|
|
2
|
+
|
|
3
|
+
Instead of branching in renderer functions, each distro declares its
|
|
4
|
+
user-creation commands, package-update commands, and non-interactive env.
|
|
5
|
+
Adding a new distro is a data entry, not code changes across renderers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class DistroProfile:
|
|
15
|
+
"""Distro-specific command templates for rendering."""
|
|
16
|
+
|
|
17
|
+
group_add: str
|
|
18
|
+
user_add: str
|
|
19
|
+
package_update: str
|
|
20
|
+
noninteractive_prefix: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
DISTRO_PROFILES: dict[str, DistroProfile] = {
|
|
24
|
+
"debian": DistroProfile(
|
|
25
|
+
group_add="groupadd -g {gid} {name}",
|
|
26
|
+
user_add="useradd -u {uid} -g {gid} -m -d /home/{name} {name}",
|
|
27
|
+
package_update=(
|
|
28
|
+
"DEBIAN_FRONTEND=noninteractive apt-get update && "
|
|
29
|
+
"apt-get dist-upgrade -y --no-install-recommends && "
|
|
30
|
+
"apt-get clean && rm -rf /var/lib/apt/lists/*"
|
|
31
|
+
),
|
|
32
|
+
noninteractive_prefix="DEBIAN_FRONTEND=noninteractive",
|
|
33
|
+
),
|
|
34
|
+
"alpine": DistroProfile(
|
|
35
|
+
group_add="addgroup -g {gid} {name}",
|
|
36
|
+
user_add="adduser -u {uid} -G {name} -D -h /home/{name} {name}",
|
|
37
|
+
package_update="apk update && apk upgrade --no-cache",
|
|
38
|
+
noninteractive_prefix="",
|
|
39
|
+
),
|
|
40
|
+
"rhel": DistroProfile(
|
|
41
|
+
group_add="groupadd -g {gid} {name}",
|
|
42
|
+
user_add="useradd -u {uid} -g {gid} -m -d /home/{name} {name}",
|
|
43
|
+
package_update="dnf upgrade -y && dnf clean all",
|
|
44
|
+
noninteractive_prefix="",
|
|
45
|
+
),
|
|
46
|
+
"fedora": DistroProfile(
|
|
47
|
+
group_add="groupadd -g {gid} {name}",
|
|
48
|
+
user_add="useradd -u {uid} -g {gid} -m -d /home/{name} {name}",
|
|
49
|
+
package_update="dnf upgrade -y && dnf clean all",
|
|
50
|
+
noninteractive_prefix="",
|
|
51
|
+
),
|
|
52
|
+
"arch": DistroProfile(
|
|
53
|
+
group_add="groupadd -g {gid} {name}",
|
|
54
|
+
user_add="useradd -u {uid} -g {gid} -m -d /home/{name} {name}",
|
|
55
|
+
package_update="pacman -Syu --noconfirm",
|
|
56
|
+
noninteractive_prefix="",
|
|
57
|
+
),
|
|
58
|
+
"opensuse": DistroProfile(
|
|
59
|
+
group_add="groupadd -g {gid} {name}",
|
|
60
|
+
user_add="useradd -u {uid} -g {gid} -m -d /home/{name} {name}",
|
|
61
|
+
package_update="zypper update -y",
|
|
62
|
+
noninteractive_prefix="",
|
|
63
|
+
),
|
|
64
|
+
"busybox": DistroProfile(
|
|
65
|
+
group_add="addgroup -g {gid} {name}",
|
|
66
|
+
user_add="adduser -u {uid} -G {name} -D -h /home/{name} {name}",
|
|
67
|
+
package_update="apk update && apk upgrade --no-cache",
|
|
68
|
+
noninteractive_prefix="",
|
|
69
|
+
),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get_profile(distro: str | None) -> DistroProfile:
|
|
74
|
+
"""Get the distro profile, falling back to debian (the most common base)."""
|
|
75
|
+
if distro and distro in DISTRO_PROFILES:
|
|
76
|
+
return DISTRO_PROFILES[distro]
|
|
77
|
+
return DISTRO_PROFILES["debian"]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def distro_from_pm(pm: str | None) -> str | None:
|
|
81
|
+
"""Infer distro from package manager used."""
|
|
82
|
+
pm_to_distro = {
|
|
83
|
+
"apt": "debian",
|
|
84
|
+
"apk": "alpine",
|
|
85
|
+
"dnf": "rhel",
|
|
86
|
+
"pacman": "arch",
|
|
87
|
+
"zypper": "opensuse",
|
|
88
|
+
}
|
|
89
|
+
return pm_to_distro.get(pm) if pm else None
|
containerspec/layers.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Layer types for ImageSpec — frozen dataclasses, discriminated union."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from types import MappingProxyType
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Layer:
|
|
13
|
+
"""Base type for all image layers."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class AddPython(Layer):
|
|
18
|
+
version: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class AptInstall(Layer):
|
|
23
|
+
packages: tuple[str, ...]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class ApkInstall(Layer):
|
|
28
|
+
packages: tuple[str, ...]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class DnfInstall(Layer):
|
|
33
|
+
packages: tuple[str, ...]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class PacmanInstall(Layer):
|
|
38
|
+
packages: tuple[str, ...]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class ZypperInstall(Layer):
|
|
43
|
+
packages: tuple[str, ...]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class UvPipInstall(Layer):
|
|
48
|
+
packages: tuple[str, ...]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class PipInstall(Layer):
|
|
53
|
+
packages: tuple[str, ...]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class Env(Layer):
|
|
58
|
+
vars: Mapping[str, str]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class RunCommands(Layer):
|
|
63
|
+
commands: tuple[str, ...]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class Workdir(Layer):
|
|
68
|
+
path: str
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class Chown(Layer):
|
|
73
|
+
path: str
|
|
74
|
+
uid: int | None = None
|
|
75
|
+
gid: int | None = None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class User(Layer):
|
|
80
|
+
uid: int
|
|
81
|
+
gid: int
|
|
82
|
+
name: str
|
|
83
|
+
alpine: bool = False
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class Entrypoint(Layer):
|
|
88
|
+
commands: tuple[str, ...] | None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class Expose(Layer):
|
|
93
|
+
ports: tuple[int, ...]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class Cmd(Layer):
|
|
98
|
+
commands: tuple[str, ...] | None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class Volume(Layer):
|
|
103
|
+
paths: tuple[str, ...]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True)
|
|
107
|
+
class Copy(Layer):
|
|
108
|
+
src: str
|
|
109
|
+
dest: str
|
|
110
|
+
content_hash: str = ""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True)
|
|
114
|
+
class NvmInstall(Layer):
|
|
115
|
+
version: str
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True)
|
|
119
|
+
class NpmInstall(Layer):
|
|
120
|
+
packages: tuple[str, ...]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass(frozen=True)
|
|
124
|
+
class PnpmInstall(Layer):
|
|
125
|
+
packages: tuple[str, ...]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class BrewInstall(Layer):
|
|
130
|
+
packages: tuple[str, ...]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(frozen=True)
|
|
134
|
+
class RustInstall(Layer):
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True)
|
|
139
|
+
class CargoInstall(Layer):
|
|
140
|
+
packages: tuple[str, ...]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass(frozen=True)
|
|
144
|
+
class UvxInstall(Layer):
|
|
145
|
+
packages: tuple[str, ...]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass(frozen=True)
|
|
149
|
+
class YarnInstall(Layer):
|
|
150
|
+
packages: tuple[str, ...]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass(frozen=True)
|
|
154
|
+
class GemInstall(Layer):
|
|
155
|
+
packages: tuple[str, ...]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass(frozen=True)
|
|
159
|
+
class GoInstall(Layer):
|
|
160
|
+
packages: tuple[str, ...]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass(frozen=True)
|
|
164
|
+
class CopyFromStage(Layer):
|
|
165
|
+
stage_name: str
|
|
166
|
+
stage_hash: str
|
|
167
|
+
src: str
|
|
168
|
+
dest: str
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def frozen_mapping(vars: Mapping[str, str]) -> Mapping[str, str]:
|
|
172
|
+
"""Wrap a dict in MappingProxyType for immutability."""
|
|
173
|
+
return MappingProxyType(dict(vars))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def layer_payload(layer: Layer) -> dict[str, Any]:
|
|
177
|
+
"""Serialize a layer to its canonical dict for hashing."""
|
|
178
|
+
if isinstance(layer, AddPython):
|
|
179
|
+
return {"type": "add_python", "version": layer.version}
|
|
180
|
+
if isinstance(layer, AptInstall):
|
|
181
|
+
return {"type": "apt_install", "packages": list(layer.packages)}
|
|
182
|
+
if isinstance(layer, ApkInstall):
|
|
183
|
+
return {"type": "apk_install", "packages": list(layer.packages)}
|
|
184
|
+
if isinstance(layer, DnfInstall):
|
|
185
|
+
return {"type": "dnf_install", "packages": list(layer.packages)}
|
|
186
|
+
if isinstance(layer, PacmanInstall):
|
|
187
|
+
return {"type": "pacman_install", "packages": list(layer.packages)}
|
|
188
|
+
if isinstance(layer, ZypperInstall):
|
|
189
|
+
return {"type": "zypper_install", "packages": list(layer.packages)}
|
|
190
|
+
if isinstance(layer, UvPipInstall):
|
|
191
|
+
return {"type": "uv_pip_install", "packages": list(layer.packages)}
|
|
192
|
+
if isinstance(layer, PipInstall):
|
|
193
|
+
return {"type": "pip_install", "packages": list(layer.packages)}
|
|
194
|
+
if isinstance(layer, Env):
|
|
195
|
+
return {"type": "env", "vars": dict(layer.vars)}
|
|
196
|
+
if isinstance(layer, RunCommands):
|
|
197
|
+
return {"type": "run_commands", "commands": list(layer.commands)}
|
|
198
|
+
if isinstance(layer, Workdir):
|
|
199
|
+
return {"type": "workdir", "path": layer.path}
|
|
200
|
+
if isinstance(layer, Chown):
|
|
201
|
+
return {"type": "chown", "path": layer.path, "uid": layer.uid, "gid": layer.gid}
|
|
202
|
+
if isinstance(layer, User):
|
|
203
|
+
return {
|
|
204
|
+
"type": "user",
|
|
205
|
+
"uid": layer.uid,
|
|
206
|
+
"gid": layer.gid,
|
|
207
|
+
"name": layer.name,
|
|
208
|
+
"alpine": layer.alpine,
|
|
209
|
+
}
|
|
210
|
+
if isinstance(layer, Entrypoint):
|
|
211
|
+
return {
|
|
212
|
+
"type": "entrypoint",
|
|
213
|
+
"commands": list(layer.commands) if layer.commands is not None else None,
|
|
214
|
+
}
|
|
215
|
+
if isinstance(layer, Expose):
|
|
216
|
+
return {"type": "expose", "ports": list(layer.ports)}
|
|
217
|
+
if isinstance(layer, Cmd):
|
|
218
|
+
return {
|
|
219
|
+
"type": "cmd",
|
|
220
|
+
"commands": list(layer.commands) if layer.commands is not None else None,
|
|
221
|
+
}
|
|
222
|
+
if isinstance(layer, Volume):
|
|
223
|
+
return {"type": "volume", "paths": list(layer.paths)}
|
|
224
|
+
if isinstance(layer, Copy):
|
|
225
|
+
return {
|
|
226
|
+
"type": "copy",
|
|
227
|
+
"src": layer.src,
|
|
228
|
+
"dest": layer.dest,
|
|
229
|
+
"content_hash": layer.content_hash,
|
|
230
|
+
}
|
|
231
|
+
if isinstance(layer, NvmInstall):
|
|
232
|
+
return {"type": "nvm_install", "version": layer.version}
|
|
233
|
+
if isinstance(layer, NpmInstall):
|
|
234
|
+
return {"type": "npm_install", "packages": list(layer.packages)}
|
|
235
|
+
if isinstance(layer, PnpmInstall):
|
|
236
|
+
return {"type": "pnpm_install", "packages": list(layer.packages)}
|
|
237
|
+
if isinstance(layer, BrewInstall):
|
|
238
|
+
return {"type": "brew_install", "packages": list(layer.packages)}
|
|
239
|
+
if isinstance(layer, RustInstall):
|
|
240
|
+
return {"type": "rust_install"}
|
|
241
|
+
if isinstance(layer, CargoInstall):
|
|
242
|
+
return {"type": "cargo_install", "packages": list(layer.packages)}
|
|
243
|
+
if isinstance(layer, UvxInstall):
|
|
244
|
+
return {"type": "uvx_install", "packages": list(layer.packages)}
|
|
245
|
+
if isinstance(layer, YarnInstall):
|
|
246
|
+
return {"type": "yarn_install", "packages": list(layer.packages)}
|
|
247
|
+
if isinstance(layer, GemInstall):
|
|
248
|
+
return {"type": "gem_install", "packages": list(layer.packages)}
|
|
249
|
+
if isinstance(layer, GoInstall):
|
|
250
|
+
return {"type": "go_install", "packages": list(layer.packages)}
|
|
251
|
+
if isinstance(layer, CopyFromStage):
|
|
252
|
+
return {
|
|
253
|
+
"type": "copy_from_stage",
|
|
254
|
+
"stage_name": layer.stage_name,
|
|
255
|
+
"stage_hash": layer.stage_hash,
|
|
256
|
+
"src": layer.src,
|
|
257
|
+
"dest": layer.dest,
|
|
258
|
+
}
|
|
259
|
+
raise TypeError(f"Unknown layer type: {type(layer).__name__}")
|