compose2pod 0.1.7__tar.gz → 0.2.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.7 → compose2pod-0.2.0}/PKG-INFO +11 -4
- {compose2pod-0.1.7 → compose2pod-0.2.0}/README.md +10 -3
- {compose2pod-0.1.7 → compose2pod-0.2.0}/compose2pod/__init__.py +4 -2
- {compose2pod-0.1.7 → compose2pod-0.2.0}/compose2pod/cli.py +18 -22
- compose2pod-0.2.0/compose2pod/emit.py +274 -0
- compose2pod-0.2.0/compose2pod/extends.py +127 -0
- {compose2pod-0.1.7 → compose2pod-0.2.0}/compose2pod/graph.py +33 -13
- {compose2pod-0.1.7 → compose2pod-0.2.0}/compose2pod/healthcheck.py +12 -7
- compose2pod-0.2.0/compose2pod/keys.py +229 -0
- {compose2pod-0.1.7 → compose2pod-0.2.0}/compose2pod/parsing.py +56 -23
- compose2pod-0.2.0/compose2pod/pod.py +128 -0
- compose2pod-0.2.0/compose2pod/resources.py +97 -0
- compose2pod-0.2.0/compose2pod/shell.py +69 -0
- compose2pod-0.2.0/compose2pod/stores.py +259 -0
- {compose2pod-0.1.7 → compose2pod-0.2.0}/pyproject.toml +7 -3
- compose2pod-0.1.7/compose2pod/emit.py +0 -183
- compose2pod-0.1.7/compose2pod/interpolate.py +0 -72
- {compose2pod-0.1.7 → compose2pod-0.2.0}/compose2pod/__main__.py +0 -0
- {compose2pod-0.1.7 → compose2pod-0.2.0}/compose2pod/exceptions.py +0 -0
- {compose2pod-0.1.7 → compose2pod-0.2.0}/compose2pod/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: compose2pod
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Convert a Docker Compose file into a script that runs its services as a single Podman pod
|
|
5
5
|
Keywords: podman,docker-compose,compose,pod,ci,testing,containers
|
|
6
6
|
Author: Artur Shiriev
|
|
@@ -24,7 +24,13 @@ Project-URL: Changelog, https://github.com/modern-python/compose2pod/releases
|
|
|
24
24
|
Provides-Extra: yaml
|
|
25
25
|
Description-Content-Type: text/markdown
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
<p align="center">
|
|
28
|
+
<picture>
|
|
29
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/modern-python/.github/main/brand/projects/compose2pod/lockup-dark.svg">
|
|
30
|
+
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/modern-python/.github/main/brand/projects/compose2pod/lockup-light.svg">
|
|
31
|
+
<img alt="compose2pod" src="https://raw.githubusercontent.com/modern-python/.github/main/brand/projects/compose2pod/lockup.png" width="420">
|
|
32
|
+
</picture>
|
|
33
|
+
</p>
|
|
28
34
|
|
|
29
35
|
[](https://pypi.org/project/compose2pod/)
|
|
30
36
|
[](https://pypi.org/project/compose2pod/)
|
|
@@ -72,8 +78,9 @@ it: `image`/`build`, `command`, `environment`/`env_file`, short-form bind
|
|
|
72
78
|
`volumes`, `healthcheck` (CMD/CMD-SHELL), `depends_on` (all conditions), and
|
|
73
79
|
network `aliases`. Compose extension fields (any `x-`-prefixed key) and YAML
|
|
74
80
|
anchors are accepted as-is, so a top-level `x-*` anchor block for shared
|
|
75
|
-
config just works. `${VAR}`-style variable interpolation is
|
|
76
|
-
|
|
81
|
+
config just works. `${VAR}`-style variable interpolation is left live in the
|
|
82
|
+
generated script, resolved by its shell against the environment present
|
|
83
|
+
when the script runs (no `.env` file support). See
|
|
77
84
|
`architecture/supported-subset.md` for the full accept/ignore/reject matrix.
|
|
78
85
|
|
|
79
86
|
## Status
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/modern-python/.github/main/brand/projects/compose2pod/lockup-dark.svg">
|
|
4
|
+
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/modern-python/.github/main/brand/projects/compose2pod/lockup-light.svg">
|
|
5
|
+
<img alt="compose2pod" src="https://raw.githubusercontent.com/modern-python/.github/main/brand/projects/compose2pod/lockup.png" width="420">
|
|
6
|
+
</picture>
|
|
7
|
+
</p>
|
|
2
8
|
|
|
3
9
|
[](https://pypi.org/project/compose2pod/)
|
|
4
10
|
[](https://pypi.org/project/compose2pod/)
|
|
@@ -46,8 +52,9 @@ it: `image`/`build`, `command`, `environment`/`env_file`, short-form bind
|
|
|
46
52
|
`volumes`, `healthcheck` (CMD/CMD-SHELL), `depends_on` (all conditions), and
|
|
47
53
|
network `aliases`. Compose extension fields (any `x-`-prefixed key) and YAML
|
|
48
54
|
anchors are accepted as-is, so a top-level `x-*` anchor block for shared
|
|
49
|
-
config just works. `${VAR}`-style variable interpolation is
|
|
50
|
-
|
|
55
|
+
config just works. `${VAR}`-style variable interpolation is left live in the
|
|
56
|
+
generated script, resolved by its shell against the environment present
|
|
57
|
+
when the script runs (no `.env` file support). See
|
|
51
58
|
`architecture/supported-subset.md` for the full accept/ignore/reject matrix.
|
|
52
59
|
|
|
53
60
|
## Status
|
|
@@ -2,14 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
from compose2pod.emit import EmitOptions, emit_script
|
|
4
4
|
from compose2pod.exceptions import UnsupportedComposeError
|
|
5
|
-
from compose2pod.
|
|
5
|
+
from compose2pod.extends import resolve_extends
|
|
6
6
|
from compose2pod.parsing import validate
|
|
7
|
+
from compose2pod.shell import to_shell
|
|
7
8
|
|
|
8
9
|
|
|
9
10
|
__all__ = [
|
|
10
11
|
"EmitOptions",
|
|
11
12
|
"UnsupportedComposeError",
|
|
12
13
|
"emit_script",
|
|
13
|
-
"
|
|
14
|
+
"resolve_extends",
|
|
15
|
+
"to_shell",
|
|
14
16
|
"validate",
|
|
15
17
|
]
|
|
@@ -3,16 +3,14 @@
|
|
|
3
3
|
import argparse
|
|
4
4
|
import contextlib
|
|
5
5
|
import json
|
|
6
|
-
import os
|
|
7
|
-
import re
|
|
8
6
|
import sys
|
|
9
7
|
from pathlib import Path
|
|
10
8
|
from types import ModuleType
|
|
11
9
|
from typing import Any
|
|
12
10
|
|
|
13
|
-
from compose2pod.emit import EmitOptions, emit_script
|
|
11
|
+
from compose2pod.emit import POD_NAME_PATTERN, EmitOptions, emit_script, referenced_variables
|
|
14
12
|
from compose2pod.exceptions import UnsupportedComposeError
|
|
15
|
-
from compose2pod.
|
|
13
|
+
from compose2pod.extends import resolve_extends
|
|
16
14
|
from compose2pod.parsing import validate
|
|
17
15
|
|
|
18
16
|
|
|
@@ -21,9 +19,6 @@ with contextlib.suppress(ImportError): # the optional [yaml] extra is not insta
|
|
|
21
19
|
import yaml as _yaml
|
|
22
20
|
|
|
23
21
|
|
|
24
|
-
POD_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$")
|
|
25
|
-
|
|
26
|
-
|
|
27
22
|
def _load_yaml(text: str) -> Any: # noqa: ANN401 - returns arbitrary parsed compose data
|
|
28
23
|
if _yaml is None:
|
|
29
24
|
msg = "YAML input requires the 'yaml' extra: pip install compose2pod[yaml] (or pipe JSON via yq)"
|
|
@@ -73,7 +68,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
73
68
|
help="target exit code treated as success in addition to 0",
|
|
74
69
|
)
|
|
75
70
|
args = parser.parse_args(argv)
|
|
76
|
-
if not POD_NAME_PATTERN.
|
|
71
|
+
if not POD_NAME_PATTERN.fullmatch(args.pod_name):
|
|
77
72
|
sys.stderr.write(f"compose2pod: error: invalid pod name {args.pod_name!r}\n")
|
|
78
73
|
return 2
|
|
79
74
|
if args.file:
|
|
@@ -89,24 +84,25 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
89
84
|
except (json.JSONDecodeError, UnsupportedComposeError) as error:
|
|
90
85
|
sys.stderr.write(f"compose2pod: error: could not parse compose input: {error}\n")
|
|
91
86
|
return 2
|
|
87
|
+
options = EmitOptions(
|
|
88
|
+
target=args.target,
|
|
89
|
+
ci_image=args.image,
|
|
90
|
+
command=args.command,
|
|
91
|
+
pod=args.pod_name,
|
|
92
|
+
project_dir=args.project_dir,
|
|
93
|
+
artifacts=args.artifact,
|
|
94
|
+
allow_exit_codes=args.allow_exit_code,
|
|
95
|
+
)
|
|
92
96
|
try:
|
|
93
|
-
compose
|
|
94
|
-
warnings =
|
|
95
|
-
script = emit_script(
|
|
96
|
-
compose=compose,
|
|
97
|
-
options=EmitOptions(
|
|
98
|
-
target=args.target,
|
|
99
|
-
ci_image=args.image,
|
|
100
|
-
command=args.command,
|
|
101
|
-
pod=args.pod_name,
|
|
102
|
-
project_dir=args.project_dir,
|
|
103
|
-
artifacts=args.artifact,
|
|
104
|
-
allow_exit_codes=args.allow_exit_code,
|
|
105
|
-
),
|
|
106
|
-
)
|
|
97
|
+
compose = resolve_extends(compose)
|
|
98
|
+
warnings = validate(compose)
|
|
99
|
+
script = emit_script(compose=compose, options=options)
|
|
107
100
|
except UnsupportedComposeError as error:
|
|
108
101
|
sys.stderr.write(f"compose2pod: error: {error}\n")
|
|
109
102
|
return 2
|
|
103
|
+
referenced = referenced_variables(compose, options)
|
|
104
|
+
if referenced:
|
|
105
|
+
sys.stderr.write("compose2pod: note: script references variables at run time: " + ", ".join(referenced) + "\n")
|
|
110
106
|
for warning in warnings:
|
|
111
107
|
sys.stderr.write(f"compose2pod: {warning}\n")
|
|
112
108
|
sys.stdout.write(script)
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Render the podman-pod test script for a target service and its dependencies."""
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import re
|
|
5
|
+
import shlex
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from compose2pod import stores
|
|
10
|
+
from compose2pod.exceptions import UnsupportedComposeError
|
|
11
|
+
from compose2pod.graph import depends_on, hostnames, startup_order
|
|
12
|
+
from compose2pod.healthcheck import health_cmd, interval_seconds
|
|
13
|
+
from compose2pod.keys import SERVICE_KEYS, Token, _Expand, _key_value_pairs
|
|
14
|
+
from compose2pod.pod import pod_create_flags
|
|
15
|
+
from compose2pod.resources import deploy_resource_flags
|
|
16
|
+
from compose2pod.shell import to_shell, variable_names
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
HEALTHY_WAIT_BUDGET_SECONDS = 120
|
|
20
|
+
POD_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def image_for(svc: dict[str, Any], ci_image: str) -> Token:
|
|
24
|
+
"""Services with a build section run the freshly built CI image."""
|
|
25
|
+
if "build" in svc:
|
|
26
|
+
return ci_image
|
|
27
|
+
return _Expand(value=svc["image"])
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def command_tokens(svc: dict[str, Any]) -> list[Token]:
|
|
31
|
+
"""Service command as argv tokens; compose string form means shell form."""
|
|
32
|
+
command = svc.get("command")
|
|
33
|
+
if command is None:
|
|
34
|
+
return []
|
|
35
|
+
if isinstance(command, str):
|
|
36
|
+
return ["/bin/sh", "-c", _Expand(value=command)]
|
|
37
|
+
return [_Expand(value=str(token)) for token in command]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def entrypoint_tokens(svc: dict[str, Any]) -> list[Token]:
|
|
41
|
+
"""Service entrypoint as argv tokens; compose string form means shell form."""
|
|
42
|
+
entrypoint = svc.get("entrypoint")
|
|
43
|
+
if entrypoint is None:
|
|
44
|
+
return []
|
|
45
|
+
if isinstance(entrypoint, str):
|
|
46
|
+
return ["/bin/sh", "-c", _Expand(value=entrypoint)]
|
|
47
|
+
return [_Expand(value=str(token)) for token in entrypoint]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _add_health_flags(flags: list[Token], healthcheck: dict[str, Any]) -> None:
|
|
51
|
+
"""Add healthcheck flags to the flags list."""
|
|
52
|
+
cmd = health_cmd(healthcheck.get("test"))
|
|
53
|
+
if cmd is not None:
|
|
54
|
+
flags += ["--health-cmd", _Expand(value=cmd)]
|
|
55
|
+
if "timeout" in healthcheck:
|
|
56
|
+
flags += ["--health-timeout", str(healthcheck["timeout"])]
|
|
57
|
+
if "start_period" in healthcheck:
|
|
58
|
+
flags += ["--health-start-period", str(healthcheck["start_period"])]
|
|
59
|
+
if "retries" in healthcheck:
|
|
60
|
+
flags += ["--health-retries", str(healthcheck["retries"])]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _add_env_flags(flags: list[Token], svc: dict[str, Any], project_dir: str) -> None:
|
|
64
|
+
"""Add -e and --env-file flags to the flags list."""
|
|
65
|
+
# A null environment value means "pass KEY through from the host" (bare `-e KEY`).
|
|
66
|
+
for pair in _key_value_pairs(svc.get("environment") or {}):
|
|
67
|
+
flags += ["-e", _Expand(value=str(pair))]
|
|
68
|
+
env_files = svc.get("env_file") or []
|
|
69
|
+
if isinstance(env_files, str):
|
|
70
|
+
env_files = [env_files]
|
|
71
|
+
for env_file in env_files:
|
|
72
|
+
flags += ["--env-file", _Expand(value=str(Path(project_dir, env_file)))]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _add_volume_flags(flags: list[Token], svc: dict[str, Any], project_dir: str) -> None:
|
|
76
|
+
"""Add -v and --tmpfs flags to the flags list."""
|
|
77
|
+
for volume in svc.get("volumes") or []:
|
|
78
|
+
if ":" not in volume:
|
|
79
|
+
# Anonymous volume: a bare container path, no host source to translate.
|
|
80
|
+
flags += ["-v", _Expand(value=volume)]
|
|
81
|
+
continue
|
|
82
|
+
source, destination = volume.split(":", 1)
|
|
83
|
+
if source.startswith("."):
|
|
84
|
+
# Relative bind mount: resolve against project_dir.
|
|
85
|
+
source = str(Path(project_dir, source))
|
|
86
|
+
# Absolute bind mount (starts with "/") and named volume (bare
|
|
87
|
+
# identifier) are both kept as-is — neither is a path to translate.
|
|
88
|
+
flags += ["-v", _Expand(value=f"{source}:{destination}")]
|
|
89
|
+
tmpfs = svc.get("tmpfs") or []
|
|
90
|
+
if isinstance(tmpfs, str):
|
|
91
|
+
tmpfs = [tmpfs]
|
|
92
|
+
for mount in tmpfs:
|
|
93
|
+
flags += ["--tmpfs", _Expand(value=mount)]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def run_flags(name: str, svc: dict[str, Any], pod: str, project_dir: str) -> list[Token]:
|
|
97
|
+
"""Flag tokens (unquoted) for `podman run` of one service."""
|
|
98
|
+
flags: list[Token] = ["--pod", pod, "--name", f"{pod}-{name}"]
|
|
99
|
+
_add_env_flags(flags, svc, project_dir)
|
|
100
|
+
_add_volume_flags(flags, svc, project_dir)
|
|
101
|
+
_add_health_flags(flags, svc.get("healthcheck") or {})
|
|
102
|
+
for key, spec in SERVICE_KEYS.items():
|
|
103
|
+
if key in svc:
|
|
104
|
+
flags += spec.emit(svc[key])
|
|
105
|
+
flags += stores.flags(svc, pod)
|
|
106
|
+
flags += deploy_resource_flags(svc)
|
|
107
|
+
return flags
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
_SCRIPT_HEADER = """\
|
|
111
|
+
#!/bin/sh
|
|
112
|
+
# Generated by compose2pod -- do not edit, regenerate instead.
|
|
113
|
+
set -eu
|
|
114
|
+
|
|
115
|
+
wait_healthy() {
|
|
116
|
+
ctr=$1
|
|
117
|
+
attempts=$2
|
|
118
|
+
interval=$3
|
|
119
|
+
i=0
|
|
120
|
+
while [ "$i" -lt "$attempts" ]; do
|
|
121
|
+
if podman healthcheck run "$ctr"; then
|
|
122
|
+
return 0
|
|
123
|
+
fi
|
|
124
|
+
i=$((i + 1))
|
|
125
|
+
sleep "$interval"
|
|
126
|
+
done
|
|
127
|
+
echo "wait_healthy: $ctr did not become healthy after $attempts attempts" >&2
|
|
128
|
+
podman logs "$ctr" >&2 || true
|
|
129
|
+
return 1
|
|
130
|
+
}
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
|
|
135
|
+
class EmitOptions:
|
|
136
|
+
"""Options for emit_script rendering."""
|
|
137
|
+
|
|
138
|
+
target: str
|
|
139
|
+
ci_image: str
|
|
140
|
+
command: str
|
|
141
|
+
pod: str
|
|
142
|
+
project_dir: str
|
|
143
|
+
artifacts: list[str]
|
|
144
|
+
allow_exit_codes: list[int]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
|
|
148
|
+
class PlannedScript:
|
|
149
|
+
"""The rendered pod script and the run-time variables it expands.
|
|
150
|
+
|
|
151
|
+
Both are products of a single `_plan` traversal, so they cannot drift.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
script: str
|
|
155
|
+
variables: list[str]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _render(tokens: list[Token]) -> str:
|
|
159
|
+
return " ".join(to_shell(token.value) if isinstance(token, _Expand) else shlex.quote(token) for token in tokens)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _collect_vars(tokens: list[Token], names: set[str]) -> None:
|
|
163
|
+
"""Add the run-time variables any `_Expand` tokens expand to `names`."""
|
|
164
|
+
for token in tokens:
|
|
165
|
+
if isinstance(token, _Expand):
|
|
166
|
+
names.update(variable_names(token.value))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _run_tokens(name: str, services: dict[str, Any], options: EmitOptions) -> list[Token]:
|
|
170
|
+
svc = services[name]
|
|
171
|
+
tokens = run_flags(name, svc, options.pod, options.project_dir)
|
|
172
|
+
entrypoint = entrypoint_tokens(svc)
|
|
173
|
+
if entrypoint:
|
|
174
|
+
tokens += ["--entrypoint", entrypoint[0]]
|
|
175
|
+
tokens.append(image_for(svc, options.ci_image))
|
|
176
|
+
tokens += entrypoint[1:]
|
|
177
|
+
if name == options.target and options.command:
|
|
178
|
+
tokens.extend(shlex.split(options.command))
|
|
179
|
+
elif not isinstance(svc.get("entrypoint"), str):
|
|
180
|
+
# A string (shell-form) entrypoint runs via `sh -c`; the service command
|
|
181
|
+
# is ignored, matching Docker. List/exec form appends the command.
|
|
182
|
+
tokens.extend(command_tokens(svc))
|
|
183
|
+
return tokens
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _emit_target(lines: list[str], run_tokens: list[Token], options: EmitOptions) -> None:
|
|
187
|
+
target_ctr = shlex.quote(f"{options.pod}-{options.target}")
|
|
188
|
+
lines.append("rc=0")
|
|
189
|
+
lines.append(f"podman run {_render(run_tokens)} || rc=$?")
|
|
190
|
+
for artifact in options.artifacts:
|
|
191
|
+
source, destination = artifact.split(":", 1)
|
|
192
|
+
lines.append(f"podman cp {target_ctr}:{shlex.quote(source)} {shlex.quote(destination)} || true")
|
|
193
|
+
allowed = "|".join(str(code) for code in [0, *options.allow_exit_codes])
|
|
194
|
+
lines.append('case "$rc" in')
|
|
195
|
+
lines.append(f" {allowed}) ;;")
|
|
196
|
+
lines.append(' *) echo "target service failed with exit code $rc" >&2')
|
|
197
|
+
lines.append(
|
|
198
|
+
" podman inspect --format "
|
|
199
|
+
"'OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}}' " + target_ctr + " >&2 || true"
|
|
200
|
+
)
|
|
201
|
+
lines.append(" podman ps -a --format '{{.Names}} {{.Status}}' >&2 || true")
|
|
202
|
+
lines.append(' exit "$rc" ;;')
|
|
203
|
+
lines.append("esac")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _plan(compose: dict[str, Any], options: EmitOptions) -> PlannedScript:
|
|
207
|
+
"""Walk the target's dependency closure once, building the script and its variables.
|
|
208
|
+
|
|
209
|
+
Every token source is visited a single time and feeds both outputs: each
|
|
210
|
+
service's `_run_tokens` and the `pod_create_flags` render into lines *and*
|
|
211
|
+
have their `_Expand` variables collected, so the script and the variable
|
|
212
|
+
list cannot disagree about what the script expands at run time.
|
|
213
|
+
"""
|
|
214
|
+
services = compose["services"]
|
|
215
|
+
hosts = hostnames(services)
|
|
216
|
+
order = startup_order(services, options.target)
|
|
217
|
+
completion_gated = {
|
|
218
|
+
dep
|
|
219
|
+
for svc in services.values()
|
|
220
|
+
for dep, condition in depends_on(svc).items()
|
|
221
|
+
if condition == "service_completed_successfully"
|
|
222
|
+
}
|
|
223
|
+
names: set[str] = set()
|
|
224
|
+
|
|
225
|
+
lines = [_SCRIPT_HEADER]
|
|
226
|
+
teardown = f"podman pod rm -f {shlex.quote(options.pod)} >/dev/null 2>&1 || true"
|
|
227
|
+
store_teardown = stores.teardown_line(compose, order, options.pod)
|
|
228
|
+
if store_teardown:
|
|
229
|
+
teardown += f"; {store_teardown}"
|
|
230
|
+
lines.append(f"trap '{teardown}' EXIT")
|
|
231
|
+
pod_flags = pod_create_flags(services, order, hosts)
|
|
232
|
+
_collect_vars(pod_flags, names)
|
|
233
|
+
pod_create = f"podman pod create --name {shlex.quote(options.pod)}"
|
|
234
|
+
if pod_flags:
|
|
235
|
+
pod_create += " " + _render(pod_flags)
|
|
236
|
+
lines.append(pod_create)
|
|
237
|
+
lines.extend(stores.create_lines(compose, order, options.pod, options.project_dir))
|
|
238
|
+
names |= stores.referenced_variables(compose, order, options.project_dir)
|
|
239
|
+
waited: set[str] = set()
|
|
240
|
+
for name in order:
|
|
241
|
+
for dep, condition in depends_on(services[name]).items():
|
|
242
|
+
if condition == "service_healthy" and dep not in waited:
|
|
243
|
+
interval = interval_seconds((services[dep].get("healthcheck") or {}).get("interval"))
|
|
244
|
+
attempts = max(HEALTHY_WAIT_BUDGET_SECONDS // interval, 1)
|
|
245
|
+
lines.append(f"wait_healthy {shlex.quote(f'{options.pod}-{dep}')} {attempts} {interval}")
|
|
246
|
+
waited.add(dep)
|
|
247
|
+
run_tokens = _run_tokens(name, services, options)
|
|
248
|
+
_collect_vars(run_tokens, names)
|
|
249
|
+
if name == options.target:
|
|
250
|
+
_emit_target(lines, run_tokens, options)
|
|
251
|
+
elif name in completion_gated:
|
|
252
|
+
lines.append(f"podman run --rm {_render(run_tokens)}")
|
|
253
|
+
else:
|
|
254
|
+
lines.append(f"podman run -d {_render(run_tokens)}")
|
|
255
|
+
return PlannedScript(script="\n".join(lines) + "\n", variables=sorted(names))
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def emit_script(compose: dict[str, Any], options: EmitOptions) -> str:
|
|
259
|
+
"""Render the full pod test script for `target` and its dependency closure."""
|
|
260
|
+
if not POD_NAME_PATTERN.fullmatch(options.pod):
|
|
261
|
+
msg = f"invalid pod name {options.pod!r}"
|
|
262
|
+
raise UnsupportedComposeError(msg)
|
|
263
|
+
return _plan(compose, options).script
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def referenced_variables(compose: dict[str, Any], options: EmitOptions) -> list[str]:
|
|
267
|
+
"""Variable names the emitted script expands at run time (sorted, unique).
|
|
268
|
+
|
|
269
|
+
Projects the same `_plan` traversal `emit_script` renders from, so it counts
|
|
270
|
+
exactly the values that pass through `to_shell` -- never a `${VAR}` in a
|
|
271
|
+
field emitted literally, and never the `--command` override (a literal CLI
|
|
272
|
+
value).
|
|
273
|
+
"""
|
|
274
|
+
return _plan(compose, options).variables
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Resolve same-file compose `extends`: flatten each service's inheritance."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from compose2pod.exceptions import UnsupportedComposeError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
_MERGE_KEYS = {"environment", "labels", "annotations", "extra_hosts", "ulimits", "healthcheck", "depends_on"}
|
|
9
|
+
_CONCAT_KEYS = {
|
|
10
|
+
"cap_add",
|
|
11
|
+
"cap_drop",
|
|
12
|
+
"security_opt",
|
|
13
|
+
"devices",
|
|
14
|
+
"group_add",
|
|
15
|
+
"secrets",
|
|
16
|
+
"configs",
|
|
17
|
+
"volumes",
|
|
18
|
+
"tmpfs",
|
|
19
|
+
"env_file",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _extends_target(name: str, ext: Any) -> str: # noqa: ANN401 - Compose values are untyped
|
|
24
|
+
"""Return the referenced service name, after refusing cross-file and malformed forms."""
|
|
25
|
+
if not isinstance(ext, dict):
|
|
26
|
+
msg = f"service {name!r}: 'extends' must be a mapping with a 'service' key"
|
|
27
|
+
raise UnsupportedComposeError(msg)
|
|
28
|
+
if "file" in ext:
|
|
29
|
+
msg = f"service {name!r}: extends with 'file:' (cross-file) is not supported"
|
|
30
|
+
raise UnsupportedComposeError(msg)
|
|
31
|
+
unknown = set(ext) - {"service"}
|
|
32
|
+
if unknown:
|
|
33
|
+
msg = f"service {name!r}: unsupported 'extends' keys {sorted(unknown)}"
|
|
34
|
+
raise UnsupportedComposeError(msg)
|
|
35
|
+
service = ext.get("service")
|
|
36
|
+
if not isinstance(service, str):
|
|
37
|
+
msg = f"service {name!r}: extends 'service' must be a string"
|
|
38
|
+
raise UnsupportedComposeError(msg)
|
|
39
|
+
return service
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _env_list_to_map(items: list[Any]) -> dict[str, Any]:
|
|
43
|
+
"""Normalize a `[KEY=value, BARE]` environment list to a mapping (bare -> None)."""
|
|
44
|
+
result: dict[str, Any] = {}
|
|
45
|
+
for item in items:
|
|
46
|
+
key, sep, value = str(item).partition("=")
|
|
47
|
+
result[key] = value if sep else None
|
|
48
|
+
return result
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _as_mapping(key: str, name: str, value: Any) -> dict[str, Any]: # noqa: ANN401 - Compose values are untyped
|
|
52
|
+
if isinstance(value, dict):
|
|
53
|
+
return value
|
|
54
|
+
if isinstance(value, list):
|
|
55
|
+
if key == "environment":
|
|
56
|
+
return _env_list_to_map(value)
|
|
57
|
+
if key == "depends_on":
|
|
58
|
+
return {dep: {} for dep in value}
|
|
59
|
+
msg = f"service {name!r}: cannot merge {key!r} across incompatible forms"
|
|
60
|
+
raise UnsupportedComposeError(msg)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _as_list(key: str, name: str, value: Any) -> list[Any]: # noqa: ANN401 - Compose values are untyped
|
|
64
|
+
if isinstance(value, list):
|
|
65
|
+
return list(value)
|
|
66
|
+
if isinstance(value, str):
|
|
67
|
+
return [value]
|
|
68
|
+
msg = f"service {name!r}: cannot merge {key!r} across incompatible forms"
|
|
69
|
+
raise UnsupportedComposeError(msg)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _merge(base: dict[str, Any], local: dict[str, Any], name: str) -> dict[str, Any]:
|
|
73
|
+
"""Merge `local` onto `base` per key category: mapping-merge, sequence-concat, else override."""
|
|
74
|
+
merged: dict[str, Any] = dict(base)
|
|
75
|
+
for key, local_val in local.items():
|
|
76
|
+
if key in base and key in _MERGE_KEYS:
|
|
77
|
+
merged[key] = {**_as_mapping(key, name, base[key]), **_as_mapping(key, name, local_val)}
|
|
78
|
+
elif key in base and key in _CONCAT_KEYS:
|
|
79
|
+
merged[key] = _as_list(key, name, base[key]) + _as_list(key, name, local_val)
|
|
80
|
+
else:
|
|
81
|
+
merged[key] = local_val
|
|
82
|
+
return merged
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def resolve_extends(compose: Any) -> Any: # noqa: ANN401 - Compose values are untyped
|
|
86
|
+
"""Return a new document with every service's same-file `extends` flattened.
|
|
87
|
+
|
|
88
|
+
Transitive and cycle-checked; cross-file (`file:`) extends is refused. A
|
|
89
|
+
non-dict document or non-dict `services` is returned unchanged for
|
|
90
|
+
`validate()` to reject.
|
|
91
|
+
"""
|
|
92
|
+
if not isinstance(compose, dict):
|
|
93
|
+
return compose
|
|
94
|
+
services = compose.get("services")
|
|
95
|
+
if not isinstance(services, dict):
|
|
96
|
+
return compose
|
|
97
|
+
resolved: dict[str, Any] = {}
|
|
98
|
+
resolving: set[str] = set()
|
|
99
|
+
|
|
100
|
+
def resolve(name: str) -> Any: # noqa: ANN401 - Compose values are untyped
|
|
101
|
+
if name in resolved:
|
|
102
|
+
return resolved[name]
|
|
103
|
+
svc = services[name]
|
|
104
|
+
ext = svc.get("extends") if isinstance(svc, dict) else None
|
|
105
|
+
if ext is None:
|
|
106
|
+
resolved[name] = svc
|
|
107
|
+
return svc
|
|
108
|
+
if name in resolving:
|
|
109
|
+
msg = f"extends cycle involving {name!r}"
|
|
110
|
+
raise UnsupportedComposeError(msg)
|
|
111
|
+
resolving.add(name)
|
|
112
|
+
base_name = _extends_target(name, ext)
|
|
113
|
+
if base_name not in services:
|
|
114
|
+
msg = f"service {name!r}: extends unknown service {base_name!r}"
|
|
115
|
+
raise UnsupportedComposeError(msg)
|
|
116
|
+
base = resolve(base_name)
|
|
117
|
+
local = {key: value for key, value in svc.items() if key != "extends"}
|
|
118
|
+
resolving.discard(name)
|
|
119
|
+
if not isinstance(base, dict):
|
|
120
|
+
resolved[name] = local
|
|
121
|
+
return local
|
|
122
|
+
merged = _merge(base, local, name)
|
|
123
|
+
resolved[name] = merged
|
|
124
|
+
return merged
|
|
125
|
+
|
|
126
|
+
new_services = {name: resolve(name) for name in services}
|
|
127
|
+
return {**compose, "services": new_services}
|
|
@@ -10,24 +10,44 @@ def depends_on(svc: dict[str, Any]) -> dict[str, str]:
|
|
|
10
10
|
deps = svc.get("depends_on") or {}
|
|
11
11
|
if isinstance(deps, list):
|
|
12
12
|
return cast(dict[str, str], dict.fromkeys(deps, "service_started"))
|
|
13
|
-
|
|
13
|
+
if not isinstance(deps, dict):
|
|
14
|
+
msg = "'depends_on' must be a list or mapping"
|
|
15
|
+
raise UnsupportedComposeError(msg)
|
|
16
|
+
result: dict[str, str] = {}
|
|
17
|
+
for dep, spec in deps.items():
|
|
18
|
+
if not isinstance(spec, dict):
|
|
19
|
+
msg = f"depends_on entry {dep!r} must be a mapping"
|
|
20
|
+
raise UnsupportedComposeError(msg)
|
|
21
|
+
result[dep] = spec.get("condition", "service_started")
|
|
22
|
+
return result
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _host_names(name: str, svc: dict[str, Any]) -> list[str]:
|
|
26
|
+
"""Names one service is reachable by: hostname, container_name, and network aliases."""
|
|
27
|
+
result: list[str] = []
|
|
28
|
+
for key in ("hostname", "container_name"):
|
|
29
|
+
value = svc.get(key)
|
|
30
|
+
if value is not None and not isinstance(value, str):
|
|
31
|
+
msg = f"service {name!r}: {key} must be a string"
|
|
32
|
+
raise UnsupportedComposeError(msg)
|
|
33
|
+
if value:
|
|
34
|
+
result.append(value)
|
|
35
|
+
networks = svc.get("networks")
|
|
36
|
+
if networks is not None and not isinstance(networks, list | dict):
|
|
37
|
+
msg = f"service {name!r}: networks must be a list or mapping"
|
|
38
|
+
raise UnsupportedComposeError(msg)
|
|
39
|
+
if isinstance(networks, dict):
|
|
40
|
+
for network in networks.values():
|
|
41
|
+
if isinstance(network, dict):
|
|
42
|
+
result.extend(network.get("aliases") or [])
|
|
43
|
+
return result
|
|
14
44
|
|
|
15
45
|
|
|
16
46
|
def hostnames(services: dict[str, Any]) -> list[str]:
|
|
17
47
|
"""All names other services may use to reach a service: names, hostnames/container names, then aliases."""
|
|
18
48
|
names = list(services)
|
|
19
|
-
for svc in services.
|
|
20
|
-
|
|
21
|
-
if hostname:
|
|
22
|
-
names.append(hostname)
|
|
23
|
-
container_name = svc.get("container_name")
|
|
24
|
-
if container_name:
|
|
25
|
-
names.append(container_name)
|
|
26
|
-
networks = svc.get("networks")
|
|
27
|
-
if isinstance(networks, dict):
|
|
28
|
-
for network in networks.values():
|
|
29
|
-
if isinstance(network, dict):
|
|
30
|
-
names.extend(network.get("aliases") or [])
|
|
49
|
+
for name, svc in services.items():
|
|
50
|
+
names.extend(_host_names(name, svc))
|
|
31
51
|
return names
|
|
32
52
|
|
|
33
53
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Healthcheck translation: compose healthcheck -> podman --health-* values."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import math
|
|
4
5
|
from typing import Any
|
|
5
6
|
|
|
6
7
|
from compose2pod.exceptions import UnsupportedComposeError
|
|
@@ -43,12 +44,16 @@ def interval_seconds(duration: object) -> int:
|
|
|
43
44
|
"""Compose duration ('1s', '2m', '500ms', int) to whole seconds, minimum 1."""
|
|
44
45
|
if duration is None:
|
|
45
46
|
return 1
|
|
46
|
-
if isinstance(duration, (int, float)):
|
|
47
|
+
if isinstance(duration, (int, float)) and math.isfinite(duration):
|
|
47
48
|
return max(int(duration), 1)
|
|
49
|
+
# Non-finite floats (inf/nan) fall through to the guarded parse below and are refused.
|
|
48
50
|
text = str(duration).strip()
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
51
|
+
try:
|
|
52
|
+
if text.endswith("ms"):
|
|
53
|
+
return max(int(float(text[:-2]) / 1000), 1)
|
|
54
|
+
if text.endswith("m"):
|
|
55
|
+
return max(int(float(text[:-1])) * 60, 1)
|
|
56
|
+
return max(int(float(text.removesuffix("s"))), 1)
|
|
57
|
+
except (ValueError, OverflowError):
|
|
58
|
+
msg = f"unsupported healthcheck interval {duration!r} (use forms like '30s', '2m', '500ms')"
|
|
59
|
+
raise UnsupportedComposeError(msg) from None
|