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.
- copydock-0.1.0/.gitignore +12 -0
- copydock-0.1.0/LICENSE +21 -0
- copydock-0.1.0/PKG-INFO +127 -0
- copydock-0.1.0/README.md +94 -0
- copydock-0.1.0/pyproject.toml +66 -0
- copydock-0.1.0/src/copydock/__init__.py +3 -0
- copydock-0.1.0/src/copydock/__main__.py +4 -0
- copydock-0.1.0/src/copydock/analysis.py +223 -0
- copydock-0.1.0/src/copydock/cli.py +117 -0
- copydock-0.1.0/src/copydock/docker.py +76 -0
- copydock-0.1.0/src/copydock/errors.py +6 -0
- copydock-0.1.0/src/copydock/manifest.py +48 -0
- copydock-0.1.0/src/copydock/packager.py +344 -0
- copydock-0.1.0/src/copydock/templates/install.sh +326 -0
- copydock-0.1.0/src/copydock/ui.py +183 -0
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.
|
copydock-0.1.0/PKG-INFO
ADDED
|
@@ -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`.
|
copydock-0.1.0/README.md
ADDED
|
@@ -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,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()
|