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.
@@ -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()
@@ -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
@@ -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__}")