copydock 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.
- copydock/__init__.py +3 -0
- copydock/__main__.py +4 -0
- copydock/analysis.py +223 -0
- copydock/cli.py +117 -0
- copydock/docker.py +76 -0
- copydock/errors.py +6 -0
- copydock/manifest.py +48 -0
- copydock/packager.py +344 -0
- copydock/templates/install.sh +326 -0
- copydock/ui.py +183 -0
- copydock-0.1.0.dist-info/METADATA +127 -0
- copydock-0.1.0.dist-info/RECORD +15 -0
- copydock-0.1.0.dist-info/WHEEL +4 -0
- copydock-0.1.0.dist-info/entry_points.txt +2 -0
- copydock-0.1.0.dist-info/licenses/LICENSE +21 -0
copydock/__init__.py
ADDED
copydock/__main__.py
ADDED
copydock/analysis.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Read a Compose project and report what packaging it would need.
|
|
2
|
+
|
|
3
|
+
`load_config` asks Docker Compose to resolve the file (profiles, extends, merges, paths).
|
|
4
|
+
`analyze` is a pure function over that result, so it can be tested without Docker.
|
|
5
|
+
|
|
6
|
+
Anything v1 does not support is reported as an error rather than silently skipped (see V1.md).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import subprocess
|
|
14
|
+
from dataclasses import asdict, dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from copydock.errors import CopyDockError
|
|
18
|
+
|
|
19
|
+
COMPOSE_FILE_NAMES = ("compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml")
|
|
20
|
+
|
|
21
|
+
# ${VAR:?message} and ${VAR?message}: Compose refuses to run unless VAR is set.
|
|
22
|
+
_REQUIRED_VAR = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*):?\?")
|
|
23
|
+
_SECRET_FILE_HINTS = (".env", ".pem", ".key", ".p12", ".pfx")
|
|
24
|
+
_UNSUPPORTED_BUILD_KEYS = {"additional_contexts", "dockerfile_inline", "secrets", "ssh"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Finding:
|
|
29
|
+
level: str # "error" (v1 cannot package this) or "warning" (works, but check it)
|
|
30
|
+
message: str
|
|
31
|
+
code: str = "" # machine-readable tag, e.g. "secret-file"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class ServiceInfo:
|
|
36
|
+
name: str
|
|
37
|
+
image: str | None
|
|
38
|
+
build_context: str | None
|
|
39
|
+
published_ports: list[int] = field(default_factory=list)
|
|
40
|
+
bind_mounts: list[str] = field(default_factory=list) # relative to the project
|
|
41
|
+
named_volumes: list[str] = field(default_factory=list)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Report:
|
|
46
|
+
project: str
|
|
47
|
+
compose_file: str
|
|
48
|
+
services: list[ServiceInfo] = field(default_factory=list)
|
|
49
|
+
required_env: list[str] = field(default_factory=list)
|
|
50
|
+
findings: list[Finding] = field(default_factory=list)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def published_ports(self) -> list[int]:
|
|
54
|
+
return sorted({p for s in self.services for p in s.published_ports})
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def errors(self) -> list[Finding]:
|
|
58
|
+
return [f for f in self.findings if f.level == "error"]
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def warnings(self) -> list[Finding]:
|
|
62
|
+
return [f for f in self.findings if f.level == "warning"]
|
|
63
|
+
|
|
64
|
+
def to_dict(self) -> dict:
|
|
65
|
+
data = asdict(self)
|
|
66
|
+
data["published_ports"] = self.published_ports
|
|
67
|
+
return data
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def find_compose_file(project: Path, name: str | None) -> Path:
|
|
71
|
+
project = project.resolve()
|
|
72
|
+
if name:
|
|
73
|
+
path = project / name
|
|
74
|
+
if not path.is_file():
|
|
75
|
+
raise CopyDockError(f"Compose file not found: {path}")
|
|
76
|
+
return path
|
|
77
|
+
for candidate in COMPOSE_FILE_NAMES:
|
|
78
|
+
if (project / candidate).is_file():
|
|
79
|
+
return project / candidate
|
|
80
|
+
raise CopyDockError(
|
|
81
|
+
f"No Compose file in {project}. Looked for: {', '.join(COMPOSE_FILE_NAMES)}. "
|
|
82
|
+
"Use --compose to name it."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def load_config(project: Path, compose_file: Path) -> dict:
|
|
87
|
+
"""Resolve the Compose file with `docker compose config`.
|
|
88
|
+
|
|
89
|
+
Interpolation is left off on purpose: values like ${POSTGRES_PASSWORD} are filled in on the
|
|
90
|
+
destination server, never at package time, so inspect must not need them.
|
|
91
|
+
"""
|
|
92
|
+
project = project.resolve() # also used as cwd, so a relative path would be applied twice
|
|
93
|
+
compose_file = compose_file.resolve()
|
|
94
|
+
cmd = [
|
|
95
|
+
"docker", "compose",
|
|
96
|
+
"--project-directory", str(project),
|
|
97
|
+
"-f", str(compose_file),
|
|
98
|
+
"--profile", "*",
|
|
99
|
+
"config", "--no-interpolate", "--format", "json",
|
|
100
|
+
] # fmt: skip
|
|
101
|
+
try:
|
|
102
|
+
result = subprocess.run(cmd, capture_output=True, text=True, cwd=project, check=False)
|
|
103
|
+
except FileNotFoundError:
|
|
104
|
+
raise CopyDockError("Docker was not found. Install Docker with the Compose plugin.") from None
|
|
105
|
+
if result.returncode != 0:
|
|
106
|
+
detail = result.stderr.strip() or "no details from docker"
|
|
107
|
+
raise CopyDockError(f"`docker compose config` failed for {compose_file.name}:\n{detail}")
|
|
108
|
+
try:
|
|
109
|
+
return json.loads(result.stdout)
|
|
110
|
+
except json.JSONDecodeError:
|
|
111
|
+
raise CopyDockError("Could not read the output of `docker compose config`.") from None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _inside(path: Path, root: Path) -> bool:
|
|
115
|
+
try:
|
|
116
|
+
path.resolve().relative_to(root.resolve())
|
|
117
|
+
except ValueError:
|
|
118
|
+
return False
|
|
119
|
+
return True
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _relative(path: Path, root: Path) -> str:
|
|
123
|
+
return str(path.resolve().relative_to(root.resolve()))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def analyze(config: dict, raw_text: str, project: Path, compose_file: str) -> Report:
|
|
127
|
+
report = Report(project=str(project), compose_file=compose_file)
|
|
128
|
+
root = project.resolve()
|
|
129
|
+
project_name = config.get("name") or root.name.lower()
|
|
130
|
+
|
|
131
|
+
# Required variables are found in the raw text: the resolved config does not list them.
|
|
132
|
+
# Limitation: variables used only in `include:`d or `extends:`d files are not seen.
|
|
133
|
+
report.required_env = sorted(set(_REQUIRED_VAR.findall(raw_text)))
|
|
134
|
+
|
|
135
|
+
for kind in ("volumes", "networks"):
|
|
136
|
+
for name, spec in (config.get(kind) or {}).items():
|
|
137
|
+
if spec and spec.get("external"):
|
|
138
|
+
report.findings.append(
|
|
139
|
+
Finding("error", f"{kind[:-1].capitalize()} '{name}' is external. "
|
|
140
|
+
"v1 only packages volumes and networks that Compose creates itself.")
|
|
141
|
+
) # fmt: skip
|
|
142
|
+
for kind in ("secrets", "configs"):
|
|
143
|
+
for name in config.get(kind) or {}:
|
|
144
|
+
report.findings.append(
|
|
145
|
+
Finding("error", f"Top-level {kind[:-1]} '{name}' is not supported in v1. "
|
|
146
|
+
"Pass the value as an environment variable instead.")
|
|
147
|
+
) # fmt: skip
|
|
148
|
+
|
|
149
|
+
for name, svc in (config.get("services") or {}).items():
|
|
150
|
+
# A built service with no `image:` gets the name Compose would give it.
|
|
151
|
+
image = svc.get("image") or (f"{project_name}-{name}" if svc.get("build") else None)
|
|
152
|
+
info = ServiceInfo(name=name, image=image, build_context=None)
|
|
153
|
+
|
|
154
|
+
build = svc.get("build")
|
|
155
|
+
if build:
|
|
156
|
+
context = Path(build["context"] if isinstance(build, dict) else build)
|
|
157
|
+
for key in _UNSUPPORTED_BUILD_KEYS & set(build if isinstance(build, dict) else ()):
|
|
158
|
+
report.findings.append(
|
|
159
|
+
Finding("error", f"{name}: build option '{key}' is not supported in v1.")
|
|
160
|
+
)
|
|
161
|
+
for arg, value in ((build.get("args") or {}) if isinstance(build, dict) else {}).items():
|
|
162
|
+
if value is not None and "$" in str(value):
|
|
163
|
+
report.findings.append(
|
|
164
|
+
Finding("error", f"{name}: build arg {arg} uses a variable. Build args "
|
|
165
|
+
"must be fixed values, because images are built before install.")
|
|
166
|
+
) # fmt: skip
|
|
167
|
+
if _inside(context, root):
|
|
168
|
+
info.build_context = _relative(context, root) or "."
|
|
169
|
+
else:
|
|
170
|
+
info.build_context = str(context)
|
|
171
|
+
report.findings.append(
|
|
172
|
+
Finding("error", f"{name}: build context {context} is outside the project.")
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
for port in svc.get("ports") or []:
|
|
176
|
+
published = port.get("published") if isinstance(port, dict) else None
|
|
177
|
+
if published is None:
|
|
178
|
+
continue # container-only port, nothing opens on the host
|
|
179
|
+
try:
|
|
180
|
+
info.published_ports.append(int(published))
|
|
181
|
+
except ValueError:
|
|
182
|
+
report.findings.append(
|
|
183
|
+
Finding("warning", f"{name}: port '{published}' is a range or uses a variable; "
|
|
184
|
+
"it was not included in the port check.")
|
|
185
|
+
) # fmt: skip
|
|
186
|
+
|
|
187
|
+
for vol in svc.get("volumes") or []:
|
|
188
|
+
if vol.get("type") == "volume":
|
|
189
|
+
info.named_volumes.append(vol.get("source") or "(anonymous)")
|
|
190
|
+
elif vol.get("type") == "bind":
|
|
191
|
+
source = Path(vol["source"])
|
|
192
|
+
if source.name == "docker.sock":
|
|
193
|
+
report.findings.append(
|
|
194
|
+
Finding("error", f"{name}: mounts the Docker socket. That gives the "
|
|
195
|
+
"container control of the host, and v1 does not package it.")
|
|
196
|
+
) # fmt: skip
|
|
197
|
+
elif not _inside(source, root):
|
|
198
|
+
report.findings.append(
|
|
199
|
+
Finding("error", f"{name}: bind mount {source} is outside the project, "
|
|
200
|
+
"so it cannot be bundled.")
|
|
201
|
+
) # fmt: skip
|
|
202
|
+
else:
|
|
203
|
+
rel = _relative(source, root)
|
|
204
|
+
info.bind_mounts.append(rel)
|
|
205
|
+
if source.name.startswith(".env") or source.suffix in _SECRET_FILE_HINTS:
|
|
206
|
+
report.findings.append(
|
|
207
|
+
Finding("warning", f"{name}: bind mount {rel} looks like it may "
|
|
208
|
+
"hold secrets. Check it before bundling.", "secret-file")
|
|
209
|
+
) # fmt: skip
|
|
210
|
+
|
|
211
|
+
if svc.get("env_file"):
|
|
212
|
+
report.findings.append(
|
|
213
|
+
Finding("error", f"{name}: uses env_file, which is not supported in v1. Move the "
|
|
214
|
+
"values into `environment:` as ${VARIABLES}, so the server provides them.")
|
|
215
|
+
) # fmt: skip
|
|
216
|
+
if svc.get("privileged"):
|
|
217
|
+
report.findings.append(Finding("warning", f"{name}: runs privileged."))
|
|
218
|
+
if svc.get("network_mode") == "host":
|
|
219
|
+
report.findings.append(Finding("warning", f"{name}: uses host networking."))
|
|
220
|
+
|
|
221
|
+
report.services.append(info)
|
|
222
|
+
|
|
223
|
+
return report
|
copydock/cli.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from copydock import __version__, ui
|
|
9
|
+
from copydock.analysis import analyze, find_compose_file, load_config
|
|
10
|
+
from copydock.errors import CopyDockError
|
|
11
|
+
from copydock.packager import package_project
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(
|
|
14
|
+
help="Package a Docker Compose app into a portable bundle for any Linux server.",
|
|
15
|
+
no_args_is_help=True,
|
|
16
|
+
add_completion=False,
|
|
17
|
+
rich_markup_mode=None,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _shorten(path: Path) -> str:
|
|
22
|
+
"""Show a path relative to the current folder when it lives below it."""
|
|
23
|
+
try:
|
|
24
|
+
return str(path.relative_to(Path.cwd()))
|
|
25
|
+
except ValueError:
|
|
26
|
+
return str(path)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _version_callback(value: bool) -> None:
|
|
30
|
+
if value:
|
|
31
|
+
ui.console.print(f"copydock {__version__}")
|
|
32
|
+
raise typer.Exit()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@app.callback()
|
|
36
|
+
def main(
|
|
37
|
+
version: bool = typer.Option(
|
|
38
|
+
False, "--version", callback=_version_callback, is_eager=True, help="Show version."
|
|
39
|
+
),
|
|
40
|
+
) -> None:
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.command()
|
|
45
|
+
def inspect(
|
|
46
|
+
project: Path = typer.Argument(..., exists=True, file_okay=False, help="Project directory."),
|
|
47
|
+
compose: str = typer.Option(
|
|
48
|
+
None, "--compose", "-f", help="Compose file name (default: auto-detect)."
|
|
49
|
+
),
|
|
50
|
+
as_json: bool = typer.Option(False, "--json", help="Print the report as JSON."),
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Show what a project needs to be packaged (images, ports, variables, unsupported features)."""
|
|
53
|
+
if not as_json:
|
|
54
|
+
ui.banner(f"inspecting {project.resolve().name}")
|
|
55
|
+
try:
|
|
56
|
+
compose_path = find_compose_file(project, compose)
|
|
57
|
+
config = load_config(project, compose_path)
|
|
58
|
+
report = analyze(config, compose_path.read_text(), project, compose_path.name)
|
|
59
|
+
except CopyDockError as e:
|
|
60
|
+
ui.error("Could not inspect", str(e))
|
|
61
|
+
raise typer.Exit(1) from None
|
|
62
|
+
|
|
63
|
+
if as_json:
|
|
64
|
+
ui.console.print_json(json.dumps(report.to_dict()))
|
|
65
|
+
else:
|
|
66
|
+
ui.print_report(report)
|
|
67
|
+
if report.errors:
|
|
68
|
+
raise typer.Exit(1)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@app.command()
|
|
72
|
+
def package(
|
|
73
|
+
project: Path = typer.Argument(..., exists=True, file_okay=False, help="Project directory."),
|
|
74
|
+
compose: str = typer.Option(
|
|
75
|
+
None, "--compose", "-f", help="Compose file name (default: auto-detect)."
|
|
76
|
+
),
|
|
77
|
+
platform: str = typer.Option(
|
|
78
|
+
None,
|
|
79
|
+
"--platform",
|
|
80
|
+
help="Platform of the SERVER: linux/amd64 or linux/arm64. "
|
|
81
|
+
"Default: the platform of this machine's Docker.",
|
|
82
|
+
),
|
|
83
|
+
app_version: str = typer.Option("latest", "--app-version", help="Version label for the bundle."),
|
|
84
|
+
output: Path = typer.Option(Path("."), "--output", "-o", help="Folder for the bundle."),
|
|
85
|
+
include_secret_files: bool = typer.Option(
|
|
86
|
+
False,
|
|
87
|
+
"--include-secret-files",
|
|
88
|
+
help="Bundle bind-mounted files that look like secrets (.env, .pem, .key).",
|
|
89
|
+
),
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Build a .tar.gz bundle that installs on a Linux server, online or offline."""
|
|
92
|
+
ui.banner(f"packaging {project.resolve().name}")
|
|
93
|
+
try:
|
|
94
|
+
result = package_project(
|
|
95
|
+
project, compose, platform, output, app_version, include_secret_files
|
|
96
|
+
)
|
|
97
|
+
except CopyDockError as e:
|
|
98
|
+
ui.error("Could not package", str(e))
|
|
99
|
+
raise typer.Exit(1) from None
|
|
100
|
+
|
|
101
|
+
name = result.archive.name
|
|
102
|
+
folder = name.removesuffix(".tar.gz")
|
|
103
|
+
ui.print_bundle(
|
|
104
|
+
archive_name=name,
|
|
105
|
+
size=result.size,
|
|
106
|
+
sha256=result.sha256,
|
|
107
|
+
platform=result.manifest.platform,
|
|
108
|
+
images=result.manifest.images,
|
|
109
|
+
sidecar=result.sidecar.name,
|
|
110
|
+
next_steps=[
|
|
111
|
+
f"Copy to the server: scp {_shorten(result.archive)} user@server:~/",
|
|
112
|
+
f"On the server: tar xzf {name} && cd {folder} && ./install.sh",
|
|
113
|
+
],
|
|
114
|
+
)
|
|
115
|
+
if result.findings:
|
|
116
|
+
ui.print_findings(result.findings)
|
|
117
|
+
ui.console.print()
|
copydock/docker.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Thin wrapper around the `docker` command. All Docker work goes through here."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from copydock.errors import CopyDockError
|
|
10
|
+
|
|
11
|
+
SUPPORTED_PLATFORMS = ("linux/amd64", "linux/arm64")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run(args: list[str], what: str) -> str:
|
|
15
|
+
"""Run `docker <args>`; on failure raise an error that shows the last lines of output."""
|
|
16
|
+
try:
|
|
17
|
+
result = subprocess.run(["docker", *args], capture_output=True, text=True, check=False)
|
|
18
|
+
except FileNotFoundError:
|
|
19
|
+
raise CopyDockError("Docker was not found. Install Docker and try again.") from None
|
|
20
|
+
if result.returncode != 0:
|
|
21
|
+
output = (result.stderr or result.stdout).strip().splitlines()
|
|
22
|
+
raise CopyDockError(f"{what} failed:\n" + "\n".join(output[-15:]))
|
|
23
|
+
return result.stdout
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def server_platform() -> str:
|
|
27
|
+
"""Platform of the local Docker engine, e.g. linux/amd64. Also proves the daemon is up."""
|
|
28
|
+
try:
|
|
29
|
+
out = run(["version", "--format", "{{.Server.Os}}/{{.Server.Arch}}"], "Docker")
|
|
30
|
+
except CopyDockError as e:
|
|
31
|
+
if "not found" in str(e):
|
|
32
|
+
raise
|
|
33
|
+
raise CopyDockError(
|
|
34
|
+
"Docker is installed but not running. Start Docker, then try again."
|
|
35
|
+
) from None
|
|
36
|
+
return out.strip()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build(ref: str, context: Path, dockerfile: str, platform: str, args: dict[str, str],
|
|
40
|
+
target: str | None) -> None: # fmt: skip
|
|
41
|
+
cmd = ["build", "--platform", platform, "-t", ref, "-f", str(context / dockerfile)]
|
|
42
|
+
for key, value in args.items():
|
|
43
|
+
cmd += ["--build-arg", f"{key}={value}"]
|
|
44
|
+
if target:
|
|
45
|
+
cmd += ["--target", target]
|
|
46
|
+
cmd.append(str(context))
|
|
47
|
+
run(cmd, f"Building {ref}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def pull(ref: str, platform: str) -> None:
|
|
51
|
+
run(["pull", "--platform", platform, ref], f"Pulling {ref}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _with_platform(prefix: list[str], rest: list[str], platform: str, what: str) -> str:
|
|
55
|
+
"""Run `docker <prefix> --platform P <rest>`.
|
|
56
|
+
|
|
57
|
+
Docker 25+ keeps several platforms under one tag, and without --platform shows or saves the
|
|
58
|
+
variant matching THIS machine, which is wrong when packaging for another CPU type. Older Docker
|
|
59
|
+
keeps one platform per tag and does not know the flag; fall back to the plain command only
|
|
60
|
+
when the flag itself is unknown, never on other errors.
|
|
61
|
+
"""
|
|
62
|
+
try:
|
|
63
|
+
return run([*prefix, "--platform", platform, *rest], what)
|
|
64
|
+
except CopyDockError as e:
|
|
65
|
+
if "unknown flag" not in str(e):
|
|
66
|
+
raise
|
|
67
|
+
return run([*prefix, *rest], what)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def inspect_image(ref: str, platform: str) -> dict:
|
|
71
|
+
out = _with_platform(["image", "inspect"], [ref], platform, f"Inspecting {ref}")
|
|
72
|
+
return json.loads(out)[0]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def save(refs: list[str], dest: Path, platform: str) -> None:
|
|
76
|
+
_with_platform(["save"], ["-o", str(dest), *refs], platform, "Saving images")
|
copydock/errors.py
ADDED
copydock/manifest.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Bundle manifest: the packing list. What is inside a bundle, in a form the installer and
|
|
2
|
+
humans can check."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import asdict, dataclass, field
|
|
8
|
+
|
|
9
|
+
FORMAT_VERSION = 1
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class ImageEntry:
|
|
14
|
+
reference: str # name:tag used in the rewritten compose file
|
|
15
|
+
services: list[str] # compose services that use this image
|
|
16
|
+
image_id: str # sha256 of the image config; identical on every machine that loads it
|
|
17
|
+
repo_digest: str | None # registry digest, for images pulled from a registry
|
|
18
|
+
built: bool # True if built from the project, False if pulled from a registry
|
|
19
|
+
size_bytes: int
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Manifest:
|
|
24
|
+
app_name: str
|
|
25
|
+
app_version: str
|
|
26
|
+
platform: str # e.g. linux/amd64
|
|
27
|
+
created_at: str # UTC, ISO 8601
|
|
28
|
+
copydock_version: str
|
|
29
|
+
images_archive: str = "images/images.tar"
|
|
30
|
+
images: list[ImageEntry] = field(default_factory=list)
|
|
31
|
+
required_ports: list[int] = field(default_factory=list)
|
|
32
|
+
required_env: list[str] = field(default_factory=list) # names only, never values
|
|
33
|
+
checksums: dict[str, str] = field(default_factory=dict) # relative path -> sha256
|
|
34
|
+
format_version: int = FORMAT_VERSION
|
|
35
|
+
|
|
36
|
+
def to_json(self) -> str:
|
|
37
|
+
return json.dumps(asdict(self), indent=2, sort_keys=True)
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def from_json(cls, text: str) -> Manifest:
|
|
41
|
+
data = json.loads(text)
|
|
42
|
+
if data.get("format_version") != FORMAT_VERSION:
|
|
43
|
+
raise ValueError(
|
|
44
|
+
f"Unsupported bundle format {data.get('format_version')!r}; "
|
|
45
|
+
f"this CopyDock reads format {FORMAT_VERSION}."
|
|
46
|
+
)
|
|
47
|
+
images = [ImageEntry(**i) for i in data.pop("images", [])]
|
|
48
|
+
return cls(images=images, **data)
|