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 ADDED
@@ -0,0 +1,3 @@
1
+ """CopyDock: package a Docker Compose app into a portable deployment bundle."""
2
+
3
+ __version__ = "0.1.0"
copydock/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from copydock.cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
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
@@ -0,0 +1,6 @@
1
+ class CopyDockError(Exception):
2
+ """An expected, user-facing failure. The message must say what to do next."""
3
+
4
+
5
+ class UnsupportedComposeFeature(CopyDockError):
6
+ """The Compose file uses something v1 deliberately rejects (see V1.md, Limits)."""
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)