copydock 0.1.0__tar.gz

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,12 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .pytest_cache/
7
+ .ruff_cache/
8
+
9
+ # Never commit real settings or secrets
10
+ .env
11
+ *.copydock
12
+ *.tar.gz
copydock-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dominic Quainoo Amuah
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,127 @@
1
+ Metadata-Version: 2.5
2
+ Name: copydock
3
+ Version: 0.1.0
4
+ Summary: Package a Docker Compose app into a portable bundle you can install on any Linux server, online or offline.
5
+ Project-URL: Homepage, https://github.com/Dominic62q/copydock
6
+ Project-URL: Source, https://github.com/Dominic62q/copydock
7
+ Project-URL: Issues, https://github.com/Dominic62q/copydock/issues
8
+ Author: Dominic Quainoo Amuah
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: air-gapped,bundle,deployment,docker,docker-compose,installer,offline
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: System Administrators
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Operating System :: POSIX :: Linux
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Software Development :: Build Tools
23
+ Classifier: Topic :: System :: Installation/Setup
24
+ Classifier: Topic :: System :: Software Distribution
25
+ Requires-Python: >=3.10
26
+ Requires-Dist: pyyaml>=6
27
+ Requires-Dist: rich>=13
28
+ Requires-Dist: typer>=0.12
29
+ Provides-Extra: dev
30
+ Requires-Dist: pytest>=8; extra == 'dev'
31
+ Requires-Dist: ruff>=0.6; extra == 'dev'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # CopyDock
35
+
36
+ Package a Docker Compose app into one bundle, copy it to a Linux server, and install it with a
37
+ single script. The server needs only Docker. It does not need internet, Python or CopyDock.
38
+
39
+ > **Status: alpha (0.1.0).** The full flow is tested on the included sample app (frontend, API,
40
+ > PostgreSQL, Caddy), including an install in a Docker daemon with no network. It has not yet been
41
+ > tested on a wide range of real servers or on a real ARM server. Try it on a test machine first.
42
+
43
+ ## How it works
44
+
45
+ ```
46
+ your laptop transfer the server
47
+ ----------- -------- ----------
48
+ copydock package ./app ───► USB / scp / share ───► tar xzf bundle.tar.gz
49
+ builds + collects images ./install.sh
50
+ rewrites compose for offline verifies files
51
+ seals a checksummed bundle checks Docker, CPU, disk, ports
52
+ asks for passwords (.env, mode 600)
53
+ loads images, starts the app
54
+ ```
55
+
56
+ ## Install
57
+
58
+ ```bash
59
+ pipx install copydock
60
+ ```
61
+
62
+ You also need Docker with the Compose plugin on the machine where you build the bundle.
63
+
64
+ ## Use
65
+
66
+ ```bash
67
+ # 1. See what a project needs, and whether CopyDock can package it
68
+ copydock inspect ./myapp
69
+
70
+ # 2. Build a bundle for the server's CPU type (linux/amd64 or linux/arm64)
71
+ copydock package ./myapp --app-version 1.0 --platform linux/amd64 -o dist
72
+
73
+ # 3. Copy dist/myapp-1.0-linux-amd64.tar.gz to the server, then on the server:
74
+ tar xzf myapp-1.0-linux-amd64.tar.gz
75
+ cd myapp-1.0-linux-amd64
76
+ ./install.sh
77
+ ```
78
+
79
+ `./install.sh --check` only verifies the bundle and checks the server; it changes nothing.
80
+
81
+ ### Commands
82
+
83
+ | Command | What it does |
84
+ | --- | --- |
85
+ | `copydock inspect PROJECT` | Lists images, ports, required settings, and anything that blocks packaging. `--json` for scripts. |
86
+ | `copydock package PROJECT` | Builds the bundle `.tar.gz` and a `.sha256` file. |
87
+
88
+ Options for `package`: `--platform`, `--app-version`, `-f/--compose`, `-o/--output`,
89
+ `--include-secret-files`.
90
+
91
+ ### On the server
92
+
93
+ `./install.sh` verifies checksums, checks Docker, Compose, CPU type, disk space and ports, collects
94
+ settings into `.env` (mode 600, never printed), loads the images from the bundle, and starts the
95
+ app with pulling disabled. It never deletes volumes, so your data survives re-installs.
96
+ Options: `--check`, `--non-interactive`, `--env-file PATH`.
97
+
98
+ ## What it deliberately does not do (yet)
99
+
100
+ - It does not install Docker or Compose on the server; it checks for them.
101
+ - It does not set up DNS or trusted HTTPS. Bundles serve plain HTTP on the server's address.
102
+ Caddy runs as an ordinary service in your Compose file.
103
+ - It does not back up or migrate databases, and it does not sign bundles. Checksums catch damage,
104
+ not tampering: compare the `.sha256` file over a channel you trust.
105
+ - It rejects Compose features it cannot package safely, with a clear message: external volumes or
106
+ networks, `secrets:`, `configs:`, `env_file`, bind mounts outside the project, variable build
107
+ args. Files that look like secrets (`.env`, `.pem`, `.key`) block packaging unless you pass
108
+ `--include-secret-files`.
109
+ - One CPU type per bundle.
110
+
111
+ External services still need internet. Packaging an app does not make an SMS provider, for
112
+ example, work offline.
113
+
114
+ ## Development
115
+
116
+ ```bash
117
+ uv sync --extra dev
118
+ uv run pytest
119
+ uv run ruff check .
120
+ ```
121
+
122
+ `examples/sample-app/` is the test application. `scripts/offline-test.sh` installs a bundle in a
123
+ Docker daemon with no network and checks the result. Scope and decisions are in `V1.md`.
124
+
125
+ ## License
126
+
127
+ MIT. See `LICENSE`.
@@ -0,0 +1,94 @@
1
+ # CopyDock
2
+
3
+ Package a Docker Compose app into one bundle, copy it to a Linux server, and install it with a
4
+ single script. The server needs only Docker. It does not need internet, Python or CopyDock.
5
+
6
+ > **Status: alpha (0.1.0).** The full flow is tested on the included sample app (frontend, API,
7
+ > PostgreSQL, Caddy), including an install in a Docker daemon with no network. It has not yet been
8
+ > tested on a wide range of real servers or on a real ARM server. Try it on a test machine first.
9
+
10
+ ## How it works
11
+
12
+ ```
13
+ your laptop transfer the server
14
+ ----------- -------- ----------
15
+ copydock package ./app ───► USB / scp / share ───► tar xzf bundle.tar.gz
16
+ builds + collects images ./install.sh
17
+ rewrites compose for offline verifies files
18
+ seals a checksummed bundle checks Docker, CPU, disk, ports
19
+ asks for passwords (.env, mode 600)
20
+ loads images, starts the app
21
+ ```
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ pipx install copydock
27
+ ```
28
+
29
+ You also need Docker with the Compose plugin on the machine where you build the bundle.
30
+
31
+ ## Use
32
+
33
+ ```bash
34
+ # 1. See what a project needs, and whether CopyDock can package it
35
+ copydock inspect ./myapp
36
+
37
+ # 2. Build a bundle for the server's CPU type (linux/amd64 or linux/arm64)
38
+ copydock package ./myapp --app-version 1.0 --platform linux/amd64 -o dist
39
+
40
+ # 3. Copy dist/myapp-1.0-linux-amd64.tar.gz to the server, then on the server:
41
+ tar xzf myapp-1.0-linux-amd64.tar.gz
42
+ cd myapp-1.0-linux-amd64
43
+ ./install.sh
44
+ ```
45
+
46
+ `./install.sh --check` only verifies the bundle and checks the server; it changes nothing.
47
+
48
+ ### Commands
49
+
50
+ | Command | What it does |
51
+ | --- | --- |
52
+ | `copydock inspect PROJECT` | Lists images, ports, required settings, and anything that blocks packaging. `--json` for scripts. |
53
+ | `copydock package PROJECT` | Builds the bundle `.tar.gz` and a `.sha256` file. |
54
+
55
+ Options for `package`: `--platform`, `--app-version`, `-f/--compose`, `-o/--output`,
56
+ `--include-secret-files`.
57
+
58
+ ### On the server
59
+
60
+ `./install.sh` verifies checksums, checks Docker, Compose, CPU type, disk space and ports, collects
61
+ settings into `.env` (mode 600, never printed), loads the images from the bundle, and starts the
62
+ app with pulling disabled. It never deletes volumes, so your data survives re-installs.
63
+ Options: `--check`, `--non-interactive`, `--env-file PATH`.
64
+
65
+ ## What it deliberately does not do (yet)
66
+
67
+ - It does not install Docker or Compose on the server; it checks for them.
68
+ - It does not set up DNS or trusted HTTPS. Bundles serve plain HTTP on the server's address.
69
+ Caddy runs as an ordinary service in your Compose file.
70
+ - It does not back up or migrate databases, and it does not sign bundles. Checksums catch damage,
71
+ not tampering: compare the `.sha256` file over a channel you trust.
72
+ - It rejects Compose features it cannot package safely, with a clear message: external volumes or
73
+ networks, `secrets:`, `configs:`, `env_file`, bind mounts outside the project, variable build
74
+ args. Files that look like secrets (`.env`, `.pem`, `.key`) block packaging unless you pass
75
+ `--include-secret-files`.
76
+ - One CPU type per bundle.
77
+
78
+ External services still need internet. Packaging an app does not make an SMS provider, for
79
+ example, work offline.
80
+
81
+ ## Development
82
+
83
+ ```bash
84
+ uv sync --extra dev
85
+ uv run pytest
86
+ uv run ruff check .
87
+ ```
88
+
89
+ `examples/sample-app/` is the test application. `scripts/offline-test.sh` installs a bundle in a
90
+ Docker daemon with no network and checks the result. Scope and decisions are in `V1.md`.
91
+
92
+ ## License
93
+
94
+ MIT. See `LICENSE`.
@@ -0,0 +1,66 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "copydock"
7
+ version = "0.1.0"
8
+ description = "Package a Docker Compose app into a portable bundle you can install on any Linux server, online or offline."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Dominic Quainoo Amuah" }]
14
+ keywords = ["docker", "docker-compose", "deployment", "offline", "air-gapped", "bundle", "installer"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Environment :: Console",
18
+ "Intended Audience :: Developers",
19
+ "Intended Audience :: System Administrators",
20
+ "Operating System :: MacOS",
21
+ "Operating System :: POSIX :: Linux",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Topic :: Software Development :: Build Tools",
27
+ "Topic :: System :: Installation/Setup",
28
+ "Topic :: System :: Software Distribution",
29
+ ]
30
+ dependencies = [
31
+ "typer>=0.12",
32
+ "rich>=13",
33
+ "pyyaml>=6",
34
+ ]
35
+
36
+ [project.optional-dependencies]
37
+ dev = [
38
+ "pytest>=8",
39
+ "ruff>=0.6",
40
+ ]
41
+
42
+ [project.urls]
43
+ Homepage = "https://github.com/Dominic62q/copydock"
44
+ Source = "https://github.com/Dominic62q/copydock"
45
+ Issues = "https://github.com/Dominic62q/copydock/issues"
46
+
47
+ [project.scripts]
48
+ copydock = "copydock.cli:app"
49
+
50
+ [tool.hatch.build.targets.wheel]
51
+ packages = ["src/copydock"]
52
+
53
+ # Keep the source download small: code, README and license only.
54
+ [tool.hatch.build.targets.sdist]
55
+ include = ["src/copydock", "README.md", "LICENSE"]
56
+
57
+ [tool.pytest.ini_options]
58
+ testpaths = ["tests"]
59
+
60
+ [tool.ruff]
61
+ line-length = 100
62
+ target-version = "py310"
63
+
64
+ [tool.ruff.lint]
65
+ # Typer's idiom is typer.Option(...) in argument defaults.
66
+ extend-ignore = ["B008"]
@@ -0,0 +1,3 @@
1
+ """CopyDock: package a Docker Compose app into a portable deployment bundle."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from copydock.cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
@@ -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
@@ -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()