compose2pod 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.
- compose2pod-0.1.0/PKG-INFO +62 -0
- compose2pod-0.1.0/README.md +36 -0
- compose2pod-0.1.0/compose2pod/__init__.py +13 -0
- compose2pod-0.1.0/compose2pod/__main__.py +9 -0
- compose2pod-0.1.0/compose2pod/cli.py +110 -0
- compose2pod-0.1.0/compose2pod/emit.py +171 -0
- compose2pod-0.1.0/compose2pod/exceptions.py +5 -0
- compose2pod-0.1.0/compose2pod/graph.py +52 -0
- compose2pod-0.1.0/compose2pod/healthcheck.py +54 -0
- compose2pod-0.1.0/compose2pod/parsing.py +86 -0
- compose2pod-0.1.0/compose2pod/py.typed +0 -0
- compose2pod-0.1.0/pyproject.toml +86 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: compose2pod
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Convert a Docker Compose file into a script that runs its services as a single Podman pod
|
|
5
|
+
Keywords: podman,docker-compose,compose,pod,ci,testing,containers
|
|
6
|
+
Author: Artur Shiriev
|
|
7
|
+
Author-email: Artur Shiriev <me@shiriev.ru>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
18
|
+
Classifier: Topic :: Software Development :: Testing
|
|
19
|
+
Requires-Dist: pyyaml>=6 ; extra == 'yaml'
|
|
20
|
+
Requires-Python: >=3.10, <4
|
|
21
|
+
Project-URL: Repository, https://github.com/modern-python/compose2pod
|
|
22
|
+
Project-URL: Issues, https://github.com/modern-python/compose2pod/issues
|
|
23
|
+
Project-URL: Changelog, https://github.com/modern-python/compose2pod/releases
|
|
24
|
+
Provides-Extra: yaml
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# compose2pod
|
|
28
|
+
|
|
29
|
+
Convert a Docker Compose file into a POSIX `sh` script that runs its services as a **single Podman pod**.
|
|
30
|
+
|
|
31
|
+
Built for CI and test environments where you can't use `docker compose` or `podman kube play`:
|
|
32
|
+
|
|
33
|
+
- **No bridge networking / netavark.** Unprivileged CI containers often have a read-only `/proc/sys`, so netavark fails to create bridge networks. A single pod shares one network namespace with no bridge: services talk over `127.0.0.1`, and names resolve via `--add-host`.
|
|
34
|
+
- **No systemd.** Podman healthchecks are normally scheduled by systemd timers. compose2pod gates startup by polling `podman healthcheck run` directly, so `depends_on: service_healthy` works without systemd.
|
|
35
|
+
- **No heavy runtime.** The core is stdlib-only — no dependencies, no compiled wheels — so it installs and runs in minimal Python images.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install compose2pod # core: reads compose as JSON
|
|
41
|
+
pip install compose2pod[yaml] # optional: read YAML directly (adds PyYAML)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
# YAML directly (needs the [yaml] extra)
|
|
48
|
+
compose2pod docker-compose.yml --target app --image myimage:ci > run.sh
|
|
49
|
+
|
|
50
|
+
# Or stay dependency-free by piping JSON (e.g. via yq)
|
|
51
|
+
yq -o=json '.' docker-compose.yml | compose2pod --target app --image myimage:ci > run.sh
|
|
52
|
+
|
|
53
|
+
sh ./run.sh
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Supported compose subset
|
|
57
|
+
|
|
58
|
+
compose2pod supports an honest subset and errors clearly on anything outside it. See the design spec for the full matrix: `image`/`build`, `command`, `environment`/`env_file`, short-form bind `volumes`, `healthcheck` (CMD/CMD-SHELL), `depends_on` (all conditions), and network `aliases`.
|
|
59
|
+
|
|
60
|
+
## Status
|
|
61
|
+
|
|
62
|
+
Beta. Part of the [modern-python](https://github.com/modern-python) family. MIT licensed.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# compose2pod
|
|
2
|
+
|
|
3
|
+
Convert a Docker Compose file into a POSIX `sh` script that runs its services as a **single Podman pod**.
|
|
4
|
+
|
|
5
|
+
Built for CI and test environments where you can't use `docker compose` or `podman kube play`:
|
|
6
|
+
|
|
7
|
+
- **No bridge networking / netavark.** Unprivileged CI containers often have a read-only `/proc/sys`, so netavark fails to create bridge networks. A single pod shares one network namespace with no bridge: services talk over `127.0.0.1`, and names resolve via `--add-host`.
|
|
8
|
+
- **No systemd.** Podman healthchecks are normally scheduled by systemd timers. compose2pod gates startup by polling `podman healthcheck run` directly, so `depends_on: service_healthy` works without systemd.
|
|
9
|
+
- **No heavy runtime.** The core is stdlib-only — no dependencies, no compiled wheels — so it installs and runs in minimal Python images.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install compose2pod # core: reads compose as JSON
|
|
15
|
+
pip install compose2pod[yaml] # optional: read YAML directly (adds PyYAML)
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
# YAML directly (needs the [yaml] extra)
|
|
22
|
+
compose2pod docker-compose.yml --target app --image myimage:ci > run.sh
|
|
23
|
+
|
|
24
|
+
# Or stay dependency-free by piping JSON (e.g. via yq)
|
|
25
|
+
yq -o=json '.' docker-compose.yml | compose2pod --target app --image myimage:ci > run.sh
|
|
26
|
+
|
|
27
|
+
sh ./run.sh
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Supported compose subset
|
|
31
|
+
|
|
32
|
+
compose2pod supports an honest subset and errors clearly on anything outside it. See the design spec for the full matrix: `image`/`build`, `command`, `environment`/`env_file`, short-form bind `volumes`, `healthcheck` (CMD/CMD-SHELL), `depends_on` (all conditions), and network `aliases`.
|
|
33
|
+
|
|
34
|
+
## Status
|
|
35
|
+
|
|
36
|
+
Beta. Part of the [modern-python](https://github.com/modern-python) family. MIT licensed.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""compose2pod: convert a Docker Compose file into a single-Podman-pod run script."""
|
|
2
|
+
|
|
3
|
+
from compose2pod.emit import EmitOptions, emit_script
|
|
4
|
+
from compose2pod.exceptions import UnsupportedComposeError
|
|
5
|
+
from compose2pod.parsing import validate
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"EmitOptions",
|
|
10
|
+
"UnsupportedComposeError",
|
|
11
|
+
"emit_script",
|
|
12
|
+
"validate",
|
|
13
|
+
]
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Command-line interface: read a compose document and emit the pod script."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import contextlib
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from types import ModuleType
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from compose2pod.emit import EmitOptions, emit_script
|
|
13
|
+
from compose2pod.exceptions import UnsupportedComposeError
|
|
14
|
+
from compose2pod.parsing import validate
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_yaml: ModuleType | None = None
|
|
18
|
+
with contextlib.suppress(ImportError): # the optional [yaml] extra is not installed
|
|
19
|
+
import yaml as _yaml
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
POD_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _load_yaml(text: str) -> Any: # noqa: ANN401 - returns arbitrary parsed compose data
|
|
26
|
+
if _yaml is None:
|
|
27
|
+
msg = "YAML input requires the 'yaml' extra: pip install compose2pod[yaml] (or pipe JSON via yq)"
|
|
28
|
+
raise UnsupportedComposeError(msg)
|
|
29
|
+
try:
|
|
30
|
+
return _yaml.safe_load(text)
|
|
31
|
+
except _yaml.YAMLError as error:
|
|
32
|
+
msg = f"invalid YAML: {error}"
|
|
33
|
+
raise UnsupportedComposeError(msg) from error
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _read_compose(text: str, fmt: str) -> Any: # noqa: ANN401 - returns arbitrary parsed compose data
|
|
37
|
+
if fmt == "json":
|
|
38
|
+
return json.loads(text)
|
|
39
|
+
if fmt == "yaml":
|
|
40
|
+
return _load_yaml(text)
|
|
41
|
+
try:
|
|
42
|
+
return json.loads(text)
|
|
43
|
+
except json.JSONDecodeError:
|
|
44
|
+
return _load_yaml(text)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def main(argv: list[str] | None = None) -> int:
|
|
48
|
+
parser = argparse.ArgumentParser(
|
|
49
|
+
prog="compose2pod",
|
|
50
|
+
description="Convert a Docker Compose document to a podman-pod run script (stdout).",
|
|
51
|
+
)
|
|
52
|
+
parser.add_argument("file", nargs="?", help="compose file to read (default: stdin)")
|
|
53
|
+
parser.add_argument("--target", required=True, help="service to run in the foreground with --command")
|
|
54
|
+
parser.add_argument("--image", required=True, help="CI image replacing services that have a build section")
|
|
55
|
+
parser.add_argument("--project-dir", default=".", help="host path relative volume/env_file sources resolve to")
|
|
56
|
+
parser.add_argument("--command", default="", help="shell command overriding the target service command")
|
|
57
|
+
parser.add_argument("--pod-name", default="test-pod")
|
|
58
|
+
parser.add_argument("--format", choices=("auto", "json", "yaml"), default="auto")
|
|
59
|
+
parser.add_argument(
|
|
60
|
+
"--artifact",
|
|
61
|
+
action="append",
|
|
62
|
+
default=[],
|
|
63
|
+
metavar="SRC:DST",
|
|
64
|
+
help="file to podman-cp out of the target container after it exits",
|
|
65
|
+
)
|
|
66
|
+
parser.add_argument(
|
|
67
|
+
"--allow-exit-code",
|
|
68
|
+
type=int,
|
|
69
|
+
action="append",
|
|
70
|
+
default=[],
|
|
71
|
+
help="target exit code treated as success in addition to 0",
|
|
72
|
+
)
|
|
73
|
+
args = parser.parse_args(argv)
|
|
74
|
+
if not POD_NAME_PATTERN.match(args.pod_name):
|
|
75
|
+
sys.stderr.write(f"compose2pod: error: invalid pod name {args.pod_name!r}\n")
|
|
76
|
+
return 2
|
|
77
|
+
if args.file:
|
|
78
|
+
try:
|
|
79
|
+
text = Path(args.file).read_text()
|
|
80
|
+
except OSError as error:
|
|
81
|
+
sys.stderr.write(f"compose2pod: error: could not read file: {error}\n")
|
|
82
|
+
return 2
|
|
83
|
+
else:
|
|
84
|
+
text = sys.stdin.read()
|
|
85
|
+
try:
|
|
86
|
+
compose = _read_compose(text, args.format)
|
|
87
|
+
except (json.JSONDecodeError, UnsupportedComposeError) as error:
|
|
88
|
+
sys.stderr.write(f"compose2pod: error: could not parse compose input: {error}\n")
|
|
89
|
+
return 2
|
|
90
|
+
try:
|
|
91
|
+
warnings = validate(compose)
|
|
92
|
+
script = emit_script(
|
|
93
|
+
compose=compose,
|
|
94
|
+
options=EmitOptions(
|
|
95
|
+
target=args.target,
|
|
96
|
+
ci_image=args.image,
|
|
97
|
+
command=args.command,
|
|
98
|
+
pod=args.pod_name,
|
|
99
|
+
project_dir=args.project_dir,
|
|
100
|
+
artifacts=args.artifact,
|
|
101
|
+
allow_exit_codes=args.allow_exit_code,
|
|
102
|
+
),
|
|
103
|
+
)
|
|
104
|
+
except UnsupportedComposeError as error:
|
|
105
|
+
sys.stderr.write(f"compose2pod: error: {error}\n")
|
|
106
|
+
return 2
|
|
107
|
+
for warning in warnings:
|
|
108
|
+
sys.stderr.write(f"compose2pod: {warning}\n")
|
|
109
|
+
sys.stdout.write(script)
|
|
110
|
+
return 0
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Render the podman-pod test script for a target service and its dependencies."""
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import shlex
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from compose2pod.graph import depends_on, hostnames, startup_order
|
|
9
|
+
from compose2pod.healthcheck import health_cmd, interval_seconds
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
HEALTHY_WAIT_BUDGET_SECONDS = 120
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def image_for(svc: dict[str, Any], ci_image: str) -> str:
|
|
16
|
+
"""Services with a build section run the freshly built CI image."""
|
|
17
|
+
if "build" in svc:
|
|
18
|
+
return ci_image
|
|
19
|
+
return svc["image"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def command_tokens(svc: dict[str, Any]) -> list[str]:
|
|
23
|
+
"""Service command as argv tokens; compose string form means shell form."""
|
|
24
|
+
command = svc.get("command")
|
|
25
|
+
if command is None:
|
|
26
|
+
return []
|
|
27
|
+
if isinstance(command, str):
|
|
28
|
+
return ["/bin/sh", "-c", command]
|
|
29
|
+
return list(command)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _add_health_flags(flags: list[str], healthcheck: dict[str, Any]) -> None:
|
|
33
|
+
"""Add healthcheck flags to the flags list."""
|
|
34
|
+
cmd = health_cmd(healthcheck.get("test"))
|
|
35
|
+
if cmd is not None:
|
|
36
|
+
flags += ["--health-cmd", cmd]
|
|
37
|
+
if "timeout" in healthcheck:
|
|
38
|
+
flags += ["--health-timeout", str(healthcheck["timeout"])]
|
|
39
|
+
if "start_period" in healthcheck:
|
|
40
|
+
flags += ["--health-start-period", str(healthcheck["start_period"])]
|
|
41
|
+
if "retries" in healthcheck:
|
|
42
|
+
flags += ["--health-retries", str(healthcheck["retries"])]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def run_flags(name: str, svc: dict[str, Any], pod: str, hosts: list[str], project_dir: str) -> list[str]:
|
|
46
|
+
"""Flag tokens (unquoted) for `podman run` of one service."""
|
|
47
|
+
flags = ["--pod", pod, "--name", f"{pod}-{name}"]
|
|
48
|
+
for host in hosts:
|
|
49
|
+
flags += ["--add-host", f"{host}:127.0.0.1"]
|
|
50
|
+
environment = svc.get("environment") or {}
|
|
51
|
+
pairs = environment if isinstance(environment, list) else [f"{k}={v}" for k, v in environment.items()]
|
|
52
|
+
for pair in pairs:
|
|
53
|
+
flags += ["-e", pair]
|
|
54
|
+
env_files = svc.get("env_file") or []
|
|
55
|
+
if isinstance(env_files, str):
|
|
56
|
+
env_files = [env_files]
|
|
57
|
+
for env_file in env_files:
|
|
58
|
+
flags += ["--env-file", str(Path(project_dir, env_file))]
|
|
59
|
+
for volume in svc.get("volumes") or []:
|
|
60
|
+
source, destination = volume.split(":", 1)
|
|
61
|
+
if not source.startswith("/"):
|
|
62
|
+
source = str(Path(project_dir, source))
|
|
63
|
+
flags += ["-v", f"{source}:{destination}"]
|
|
64
|
+
healthcheck = svc.get("healthcheck") or {}
|
|
65
|
+
_add_health_flags(flags, healthcheck)
|
|
66
|
+
return flags
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
_SCRIPT_HEADER = """\
|
|
70
|
+
#!/bin/sh
|
|
71
|
+
# Generated by compose2pod -- do not edit, regenerate instead.
|
|
72
|
+
set -eu
|
|
73
|
+
|
|
74
|
+
wait_healthy() {
|
|
75
|
+
ctr=$1
|
|
76
|
+
attempts=$2
|
|
77
|
+
interval=$3
|
|
78
|
+
i=0
|
|
79
|
+
while [ "$i" -lt "$attempts" ]; do
|
|
80
|
+
if podman healthcheck run "$ctr"; then
|
|
81
|
+
return 0
|
|
82
|
+
fi
|
|
83
|
+
i=$((i + 1))
|
|
84
|
+
sleep "$interval"
|
|
85
|
+
done
|
|
86
|
+
echo "wait_healthy: $ctr did not become healthy after $attempts attempts" >&2
|
|
87
|
+
podman logs "$ctr" >&2 || true
|
|
88
|
+
return 1
|
|
89
|
+
}
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclasses.dataclass(frozen=True)
|
|
94
|
+
class EmitOptions:
|
|
95
|
+
"""Options for emit_script rendering."""
|
|
96
|
+
|
|
97
|
+
target: str
|
|
98
|
+
ci_image: str
|
|
99
|
+
command: str
|
|
100
|
+
pod: str
|
|
101
|
+
project_dir: str
|
|
102
|
+
artifacts: list[str]
|
|
103
|
+
allow_exit_codes: list[int]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _render(tokens: list[str]) -> str:
|
|
107
|
+
return " ".join(shlex.quote(token) for token in tokens)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _run_tokens(name: str, services: dict[str, Any], options: EmitOptions, hosts: list[str]) -> list[str]:
|
|
111
|
+
svc = services[name]
|
|
112
|
+
tokens = run_flags(name, svc, options.pod, hosts, options.project_dir)
|
|
113
|
+
tokens.append(image_for(svc, options.ci_image))
|
|
114
|
+
if name == options.target and options.command:
|
|
115
|
+
tokens.extend(shlex.split(options.command))
|
|
116
|
+
else:
|
|
117
|
+
tokens.extend(command_tokens(svc))
|
|
118
|
+
return tokens
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _emit_target(lines: list[str], run_tokens: list[str], options: EmitOptions) -> None:
|
|
122
|
+
target_ctr = shlex.quote(f"{options.pod}-{options.target}")
|
|
123
|
+
lines.append("rc=0")
|
|
124
|
+
lines.append(f"podman run {_render(run_tokens)} || rc=$?")
|
|
125
|
+
for artifact in options.artifacts:
|
|
126
|
+
source, destination = artifact.split(":", 1)
|
|
127
|
+
lines.append(f"podman cp {target_ctr}:{shlex.quote(source)} {shlex.quote(destination)} || true")
|
|
128
|
+
allowed = "|".join(str(code) for code in [0, *options.allow_exit_codes])
|
|
129
|
+
lines.append('case "$rc" in')
|
|
130
|
+
lines.append(f" {allowed}) ;;")
|
|
131
|
+
lines.append(' *) echo "target service failed with exit code $rc" >&2')
|
|
132
|
+
lines.append(
|
|
133
|
+
" podman inspect --format "
|
|
134
|
+
"'OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}}' " + target_ctr + " >&2 || true"
|
|
135
|
+
)
|
|
136
|
+
lines.append(" podman ps -a --format '{{.Names}} {{.Status}}' >&2 || true")
|
|
137
|
+
lines.append(' exit "$rc" ;;')
|
|
138
|
+
lines.append("esac")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def emit_script(compose: dict[str, Any], options: EmitOptions) -> str:
|
|
142
|
+
"""Render the full pod test script for `target` and its dependency closure."""
|
|
143
|
+
services = compose["services"]
|
|
144
|
+
hosts = hostnames(services)
|
|
145
|
+
order = startup_order(services, options.target)
|
|
146
|
+
completion_gated = {
|
|
147
|
+
dep
|
|
148
|
+
for svc in services.values()
|
|
149
|
+
for dep, condition in depends_on(svc).items()
|
|
150
|
+
if condition == "service_completed_successfully"
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
lines = [_SCRIPT_HEADER]
|
|
154
|
+
lines.append(f"trap 'podman pod rm -f {shlex.quote(options.pod)} >/dev/null 2>&1 || true' EXIT")
|
|
155
|
+
lines.append(f"podman pod create --name {shlex.quote(options.pod)}")
|
|
156
|
+
waited: set[str] = set()
|
|
157
|
+
for name in order:
|
|
158
|
+
for dep, condition in depends_on(services[name]).items():
|
|
159
|
+
if condition == "service_healthy" and dep not in waited:
|
|
160
|
+
interval = interval_seconds((services[dep].get("healthcheck") or {}).get("interval"))
|
|
161
|
+
attempts = max(HEALTHY_WAIT_BUDGET_SECONDS // interval, 1)
|
|
162
|
+
lines.append(f"wait_healthy {shlex.quote(f'{options.pod}-{dep}')} {attempts} {interval}")
|
|
163
|
+
waited.add(dep)
|
|
164
|
+
run_tokens = _run_tokens(name, services, options, hosts)
|
|
165
|
+
if name == options.target:
|
|
166
|
+
_emit_target(lines, run_tokens, options)
|
|
167
|
+
elif name in completion_gated:
|
|
168
|
+
lines.append(f"podman run --rm {_render(run_tokens)}")
|
|
169
|
+
else:
|
|
170
|
+
lines.append(f"podman run -d {_render(run_tokens)}")
|
|
171
|
+
return "\n".join(lines) + "\n"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Dependency graph: normalize depends_on, collect hostnames, compute startup order."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, cast
|
|
4
|
+
|
|
5
|
+
from compose2pod.exceptions import UnsupportedComposeError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def depends_on(svc: dict[str, Any]) -> dict[str, str]:
|
|
9
|
+
"""Normalize dependencies of a service to a name -> condition mapping."""
|
|
10
|
+
deps = svc.get("depends_on") or {}
|
|
11
|
+
if isinstance(deps, list):
|
|
12
|
+
return cast(dict[str, str], dict.fromkeys(deps, "service_started"))
|
|
13
|
+
return {name: spec.get("condition", "service_started") for name, spec in deps.items()}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def hostnames(services: dict[str, Any]) -> list[str]:
|
|
17
|
+
"""All names other services may use to reach a service: names, then aliases."""
|
|
18
|
+
names = list(services)
|
|
19
|
+
for svc in services.values():
|
|
20
|
+
networks = svc.get("networks")
|
|
21
|
+
if isinstance(networks, dict):
|
|
22
|
+
for network in networks.values():
|
|
23
|
+
if isinstance(network, dict):
|
|
24
|
+
names.extend(network.get("aliases") or [])
|
|
25
|
+
return names
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def startup_order(services: dict[str, Any], target: str) -> list[str]:
|
|
29
|
+
"""Dependency closure of target in start order (dependencies first, target last)."""
|
|
30
|
+
if target not in services:
|
|
31
|
+
msg = f"target service '{target}' not found"
|
|
32
|
+
raise UnsupportedComposeError(msg)
|
|
33
|
+
order: list[str] = []
|
|
34
|
+
state: dict[str, str] = {}
|
|
35
|
+
|
|
36
|
+
def visit(name: str) -> None:
|
|
37
|
+
if state.get(name) == "visiting":
|
|
38
|
+
msg = f"dependency cycle involving '{name}'"
|
|
39
|
+
raise UnsupportedComposeError(msg)
|
|
40
|
+
if state.get(name) == "done":
|
|
41
|
+
return
|
|
42
|
+
if name not in services:
|
|
43
|
+
msg = f"unknown dependency '{name}'"
|
|
44
|
+
raise UnsupportedComposeError(msg)
|
|
45
|
+
state[name] = "visiting"
|
|
46
|
+
for dep in depends_on(services[name]):
|
|
47
|
+
visit(dep)
|
|
48
|
+
state[name] = "done"
|
|
49
|
+
order.append(name)
|
|
50
|
+
|
|
51
|
+
visit(target)
|
|
52
|
+
return order
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Healthcheck translation: compose healthcheck -> podman --health-* values."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from compose2pod.exceptions import UnsupportedComposeError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
_CMD_MIN_LENGTH = 2
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def has_healthcheck(svc: dict[str, Any]) -> bool:
|
|
13
|
+
"""Report whether the service defines a healthcheck with a non-disabled test."""
|
|
14
|
+
test = (svc.get("healthcheck") or {}).get("test")
|
|
15
|
+
return test is not None and test not in ("NONE", ["NONE"])
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def health_cmd(test: object) -> str | None:
|
|
19
|
+
"""Compose healthcheck `test` value to a podman --health-cmd value."""
|
|
20
|
+
if test is None or test in ("NONE", ["NONE"]):
|
|
21
|
+
return None
|
|
22
|
+
if isinstance(test, str):
|
|
23
|
+
return test
|
|
24
|
+
if not isinstance(test, list) or not test:
|
|
25
|
+
msg = f"unsupported healthcheck test: {test!r}"
|
|
26
|
+
raise UnsupportedComposeError(msg)
|
|
27
|
+
kind = test[0]
|
|
28
|
+
if kind == "CMD-SHELL":
|
|
29
|
+
if len(test) < _CMD_MIN_LENGTH:
|
|
30
|
+
msg = f"unsupported healthcheck test: {test!r}"
|
|
31
|
+
raise UnsupportedComposeError(msg)
|
|
32
|
+
return test[1] # ty: ignore
|
|
33
|
+
if kind == "CMD":
|
|
34
|
+
if len(test) < _CMD_MIN_LENGTH:
|
|
35
|
+
msg = f"unsupported healthcheck test: {test!r}"
|
|
36
|
+
raise UnsupportedComposeError(msg)
|
|
37
|
+
return json.dumps(test[1:])
|
|
38
|
+
msg = f"unsupported healthcheck test kind: {kind!r}"
|
|
39
|
+
raise UnsupportedComposeError(msg)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def interval_seconds(duration: object) -> int:
|
|
43
|
+
"""Compose duration ('1s', '2m', '500ms', int) to whole seconds, minimum 1."""
|
|
44
|
+
if duration is None:
|
|
45
|
+
return 1
|
|
46
|
+
if isinstance(duration, (int, float)):
|
|
47
|
+
return max(int(duration), 1)
|
|
48
|
+
text = str(duration).strip()
|
|
49
|
+
if text.endswith("ms"):
|
|
50
|
+
return max(int(float(text[:-2]) / 1000), 1)
|
|
51
|
+
if text.endswith("m"):
|
|
52
|
+
return max(int(float(text[:-1])) * 60, 1)
|
|
53
|
+
text = text.removesuffix("s")
|
|
54
|
+
return max(int(float(text)), 1)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Validate a compose document against the supported subset."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from compose2pod.exceptions import UnsupportedComposeError
|
|
6
|
+
from compose2pod.graph import depends_on
|
|
7
|
+
from compose2pod.healthcheck import has_healthcheck
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
SUPPORTED_SERVICE_KEYS = {
|
|
11
|
+
"image",
|
|
12
|
+
"build",
|
|
13
|
+
"command",
|
|
14
|
+
"environment",
|
|
15
|
+
"env_file",
|
|
16
|
+
"volumes",
|
|
17
|
+
"healthcheck",
|
|
18
|
+
"depends_on",
|
|
19
|
+
"networks",
|
|
20
|
+
}
|
|
21
|
+
IGNORED_SERVICE_KEYS = {"ports", "restart", "stdin_open", "tty"}
|
|
22
|
+
SUPPORTED_HEALTHCHECK_KEYS = {"test", "interval", "timeout", "retries", "start_period"}
|
|
23
|
+
SUPPORTED_TOP_LEVEL_KEYS = {"services", "version", "name", "networks"}
|
|
24
|
+
DEPENDS_ON_CONDITIONS = {"service_started", "service_healthy", "service_completed_successfully"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _validate_service(name: str, svc: dict[str, Any]) -> list[str]:
|
|
28
|
+
"""Validate one service; returns warnings, raises UnsupportedComposeError."""
|
|
29
|
+
warnings: list[str] = []
|
|
30
|
+
for key in sorted(svc):
|
|
31
|
+
if key in IGNORED_SERVICE_KEYS:
|
|
32
|
+
warnings.append(f"service {name!r}: ignoring '{key}'")
|
|
33
|
+
elif key not in SUPPORTED_SERVICE_KEYS:
|
|
34
|
+
msg = f"service {name!r}: unsupported key '{key}'"
|
|
35
|
+
raise UnsupportedComposeError(msg)
|
|
36
|
+
for key in sorted(svc.get("healthcheck") or {}):
|
|
37
|
+
if key not in SUPPORTED_HEALTHCHECK_KEYS:
|
|
38
|
+
msg = f"service {name!r}: unsupported healthcheck key '{key}'"
|
|
39
|
+
raise UnsupportedComposeError(msg)
|
|
40
|
+
for volume in svc.get("volumes") or []:
|
|
41
|
+
if not isinstance(volume, str):
|
|
42
|
+
msg = f"service {name!r}: only short volume syntax is supported"
|
|
43
|
+
raise UnsupportedComposeError(msg)
|
|
44
|
+
source = volume.split(":", 1)[0]
|
|
45
|
+
if not source.startswith((".", "/")):
|
|
46
|
+
msg = f"service {name!r}: named volume '{source}' is not supported (bind mounts only)"
|
|
47
|
+
raise UnsupportedComposeError(msg)
|
|
48
|
+
return warnings
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _validate_depends_on(services: dict[str, Any]) -> None:
|
|
52
|
+
"""Cross-service depends_on checks: known conditions, service_healthy needs a healthcheck."""
|
|
53
|
+
for name, svc in services.items():
|
|
54
|
+
for dep, condition in depends_on(svc).items():
|
|
55
|
+
if condition not in DEPENDS_ON_CONDITIONS:
|
|
56
|
+
msg = f"service {name!r}: depends_on {dep!r} has unsupported condition {condition!r}"
|
|
57
|
+
raise UnsupportedComposeError(msg)
|
|
58
|
+
if condition == "service_healthy" and dep in services and not has_healthcheck(services[dep]):
|
|
59
|
+
msg = f"service {name!r}: depends on {dep!r} (service_healthy) but {dep!r} has no healthcheck"
|
|
60
|
+
raise UnsupportedComposeError(msg)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def validate(compose: dict[str, Any]) -> list[str]:
|
|
64
|
+
"""Check the compose document against the supported subset.
|
|
65
|
+
|
|
66
|
+
Returns human-readable warnings for ignored constructs.
|
|
67
|
+
Raises UnsupportedComposeError for anything that would change behavior silently.
|
|
68
|
+
"""
|
|
69
|
+
if not isinstance(compose, dict):
|
|
70
|
+
msg = f"compose document must be a mapping, got {type(compose).__name__}"
|
|
71
|
+
raise UnsupportedComposeError(msg)
|
|
72
|
+
warnings: list[str] = []
|
|
73
|
+
unknown_top = set(compose) - SUPPORTED_TOP_LEVEL_KEYS
|
|
74
|
+
if unknown_top:
|
|
75
|
+
msg = f"unsupported top-level keys: {sorted(unknown_top)}"
|
|
76
|
+
raise UnsupportedComposeError(msg)
|
|
77
|
+
if "networks" in compose:
|
|
78
|
+
warnings.append("ignoring top-level 'networks' (all services share the pod namespace)")
|
|
79
|
+
services = compose.get("services") or {}
|
|
80
|
+
if not services:
|
|
81
|
+
msg = "no services defined"
|
|
82
|
+
raise UnsupportedComposeError(msg)
|
|
83
|
+
for name, svc in services.items():
|
|
84
|
+
warnings.extend(_validate_service(name, svc))
|
|
85
|
+
_validate_depends_on(services)
|
|
86
|
+
return warnings
|
|
File without changes
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "compose2pod"
|
|
3
|
+
description = "Convert a Docker Compose file into a script that runs its services as a single Podman pod"
|
|
4
|
+
authors = [{ name = "Artur Shiriev", email = "me@shiriev.ru" }]
|
|
5
|
+
requires-python = ">=3.10,<4"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
keywords = ["podman", "docker-compose", "compose", "pod", "ci", "testing", "containers"]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 4 - Beta",
|
|
11
|
+
"Intended Audience :: Developers",
|
|
12
|
+
"Programming Language :: Python :: 3.10",
|
|
13
|
+
"Programming Language :: Python :: 3.11",
|
|
14
|
+
"Programming Language :: Python :: 3.12",
|
|
15
|
+
"Programming Language :: Python :: 3.13",
|
|
16
|
+
"Programming Language :: Python :: 3.14",
|
|
17
|
+
"Typing :: Typed",
|
|
18
|
+
"Topic :: Software Development :: Libraries",
|
|
19
|
+
"Topic :: Software Development :: Testing",
|
|
20
|
+
]
|
|
21
|
+
version = "0.1.0"
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
yaml = ["PyYAML>=6"]
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
compose2pod = "compose2pod.cli:main"
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Repository = "https://github.com/modern-python/compose2pod"
|
|
31
|
+
Issues = "https://github.com/modern-python/compose2pod/issues"
|
|
32
|
+
Changelog = "https://github.com/modern-python/compose2pod/releases"
|
|
33
|
+
|
|
34
|
+
[build-system]
|
|
35
|
+
requires = ["uv_build>=0.11,<1.0"]
|
|
36
|
+
build-backend = "uv_build"
|
|
37
|
+
|
|
38
|
+
[tool.uv.build-backend]
|
|
39
|
+
module-name = "compose2pod"
|
|
40
|
+
module-root = ""
|
|
41
|
+
|
|
42
|
+
[dependency-groups]
|
|
43
|
+
dev = [
|
|
44
|
+
"pytest",
|
|
45
|
+
"pytest-cov",
|
|
46
|
+
]
|
|
47
|
+
lint = [
|
|
48
|
+
"ruff",
|
|
49
|
+
"ty",
|
|
50
|
+
"eof-fixer",
|
|
51
|
+
"typing-extensions",
|
|
52
|
+
"types-PyYAML",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
[tool.ruff]
|
|
56
|
+
fix = true
|
|
57
|
+
unsafe-fixes = true
|
|
58
|
+
line-length = 120
|
|
59
|
+
target-version = "py310"
|
|
60
|
+
|
|
61
|
+
[tool.ruff.lint]
|
|
62
|
+
select = ["ALL"]
|
|
63
|
+
ignore = [
|
|
64
|
+
"D1", # allow missing docstrings
|
|
65
|
+
"S101", # allow asserts
|
|
66
|
+
"TCH", # ignore flake8-type-checking
|
|
67
|
+
"FBT", # allow boolean args
|
|
68
|
+
"D203", # "one-blank-line-before-class" conflicting with D211
|
|
69
|
+
"D213", # "multi-line-summary-second-line" conflicting with D212
|
|
70
|
+
"COM812", # flake8-commas "Trailing comma missing"
|
|
71
|
+
"ISC001", # flake8-implicit-str-concat
|
|
72
|
+
]
|
|
73
|
+
isort.lines-after-imports = 2
|
|
74
|
+
isort.no-lines-before = ["standard-library", "local-folder"]
|
|
75
|
+
|
|
76
|
+
[tool.pytest.ini_options]
|
|
77
|
+
addopts = ""
|
|
78
|
+
testpaths = ["tests"]
|
|
79
|
+
pythonpath = ["."]
|
|
80
|
+
|
|
81
|
+
[tool.coverage]
|
|
82
|
+
report.exclude_also = ["if typing.TYPE_CHECKING:"]
|
|
83
|
+
# The __main__.py shim is a pure `sys.exit(main())` entrypoint exercised only via
|
|
84
|
+
# subprocess (tests/test_cli.py::test_python_m_runs), which the parent coverage process
|
|
85
|
+
# can't measure; omit it rather than enable subprocess/branch coverage machinery.
|
|
86
|
+
run.omit = ["compose2pod/__main__.py"]
|