compose2pod 0.2.0__tar.gz → 0.3.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.2.0 → compose2pod-0.3.0}/PKG-INFO +11 -1
- {compose2pod-0.2.0 → compose2pod-0.3.0}/README.md +10 -0
- {compose2pod-0.2.0 → compose2pod-0.3.0}/compose2pod/cli.py +3 -33
- compose2pod-0.3.0/compose2pod/emit.py +402 -0
- compose2pod-0.3.0/compose2pod/extends.py +213 -0
- compose2pod-0.3.0/compose2pod/graph.py +181 -0
- compose2pod-0.3.0/compose2pod/healthcheck.py +90 -0
- compose2pod-0.3.0/compose2pod/keys.py +455 -0
- compose2pod-0.3.0/compose2pod/parsing.py +1514 -0
- {compose2pod-0.2.0 → compose2pod-0.3.0}/compose2pod/pod.py +51 -8
- compose2pod-0.3.0/compose2pod/read.py +99 -0
- {compose2pod-0.2.0 → compose2pod-0.3.0}/compose2pod/resources.py +50 -17
- {compose2pod-0.2.0 → compose2pod-0.3.0}/compose2pod/stores.py +34 -7
- compose2pod-0.3.0/compose2pod/values.py +495 -0
- {compose2pod-0.2.0 → compose2pod-0.3.0}/pyproject.toml +4 -3
- compose2pod-0.2.0/compose2pod/emit.py +0 -274
- compose2pod-0.2.0/compose2pod/extends.py +0 -127
- compose2pod-0.2.0/compose2pod/graph.py +0 -78
- compose2pod-0.2.0/compose2pod/healthcheck.py +0 -59
- compose2pod-0.2.0/compose2pod/keys.py +0 -229
- compose2pod-0.2.0/compose2pod/parsing.py +0 -143
- {compose2pod-0.2.0 → compose2pod-0.3.0}/compose2pod/__init__.py +0 -0
- {compose2pod-0.2.0 → compose2pod-0.3.0}/compose2pod/__main__.py +0 -0
- {compose2pod-0.2.0 → compose2pod-0.3.0}/compose2pod/exceptions.py +0 -0
- {compose2pod-0.2.0 → compose2pod-0.3.0}/compose2pod/py.typed +0 -0
- {compose2pod-0.2.0 → compose2pod-0.3.0}/compose2pod/shell.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: compose2pod
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.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
|
|
@@ -52,6 +52,16 @@ Built for CI and test environments where you can't use `docker compose` or `podm
|
|
|
52
52
|
- **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.
|
|
53
53
|
- **No heavy runtime.** The core is stdlib-only — no dependencies, no compiled wheels — so it installs and runs in minimal Python images.
|
|
54
54
|
|
|
55
|
+
## Requirements
|
|
56
|
+
|
|
57
|
+
**Podman >= 6.0.0.** Earlier releases have a bug where a container stopping
|
|
58
|
+
inside a multi-container pod wipes `/etc/hosts` for every container in that
|
|
59
|
+
pod, not just the one that stopped — fixed in 6.0.0. compose2pod's generated
|
|
60
|
+
scripts rely on one shared `--add-host`-populated `/etc/hosts` for the whole
|
|
61
|
+
pod (see `architecture/supported-subset.md`), so a `service_completed_successfully`
|
|
62
|
+
dependency (a container that runs and exits, e.g. a migration step) can wipe
|
|
63
|
+
name resolution for everything started after it on a pre-6.0.0 Podman.
|
|
64
|
+
|
|
55
65
|
## Install
|
|
56
66
|
|
|
57
67
|
```bash
|
|
@@ -26,6 +26,16 @@ Built for CI and test environments where you can't use `docker compose` or `podm
|
|
|
26
26
|
- **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.
|
|
27
27
|
- **No heavy runtime.** The core is stdlib-only — no dependencies, no compiled wheels — so it installs and runs in minimal Python images.
|
|
28
28
|
|
|
29
|
+
## Requirements
|
|
30
|
+
|
|
31
|
+
**Podman >= 6.0.0.** Earlier releases have a bug where a container stopping
|
|
32
|
+
inside a multi-container pod wipes `/etc/hosts` for every container in that
|
|
33
|
+
pod, not just the one that stopped — fixed in 6.0.0. compose2pod's generated
|
|
34
|
+
scripts rely on one shared `--add-host`-populated `/etc/hosts` for the whole
|
|
35
|
+
pod (see `architecture/supported-subset.md`), so a `service_completed_successfully`
|
|
36
|
+
dependency (a container that runs and exits, e.g. a migration step) can wipe
|
|
37
|
+
name resolution for everything started after it on a pre-6.0.0 Podman.
|
|
38
|
+
|
|
29
39
|
## Install
|
|
30
40
|
|
|
31
41
|
```bash
|
|
@@ -1,44 +1,14 @@
|
|
|
1
1
|
"""Command-line interface: read a compose document and emit the pod script."""
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
|
-
import contextlib
|
|
5
|
-
import json
|
|
6
4
|
import sys
|
|
7
5
|
from pathlib import Path
|
|
8
|
-
from types import ModuleType
|
|
9
|
-
from typing import Any
|
|
10
6
|
|
|
11
7
|
from compose2pod.emit import POD_NAME_PATTERN, EmitOptions, emit_script, referenced_variables
|
|
12
8
|
from compose2pod.exceptions import UnsupportedComposeError
|
|
13
9
|
from compose2pod.extends import resolve_extends
|
|
14
10
|
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
|
-
def _load_yaml(text: str) -> Any: # noqa: ANN401 - returns arbitrary parsed compose data
|
|
23
|
-
if _yaml is None:
|
|
24
|
-
msg = "YAML input requires the 'yaml' extra: pip install compose2pod[yaml] (or pipe JSON via yq)"
|
|
25
|
-
raise UnsupportedComposeError(msg)
|
|
26
|
-
try:
|
|
27
|
-
return _yaml.safe_load(text)
|
|
28
|
-
except _yaml.YAMLError as error:
|
|
29
|
-
msg = f"invalid YAML: {error}"
|
|
30
|
-
raise UnsupportedComposeError(msg) from error
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def _read_compose(text: str, fmt: str) -> Any: # noqa: ANN401 - returns arbitrary parsed compose data
|
|
34
|
-
if fmt == "json":
|
|
35
|
-
return json.loads(text)
|
|
36
|
-
if fmt == "yaml":
|
|
37
|
-
return _load_yaml(text)
|
|
38
|
-
try:
|
|
39
|
-
return json.loads(text)
|
|
40
|
-
except json.JSONDecodeError:
|
|
41
|
-
return _load_yaml(text)
|
|
11
|
+
from compose2pod.read import read
|
|
42
12
|
|
|
43
13
|
|
|
44
14
|
def main(argv: list[str] | None = None) -> int:
|
|
@@ -80,8 +50,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
80
50
|
else:
|
|
81
51
|
text = sys.stdin.read()
|
|
82
52
|
try:
|
|
83
|
-
compose =
|
|
84
|
-
except
|
|
53
|
+
compose = read(text, args.format)
|
|
54
|
+
except UnsupportedComposeError as error:
|
|
85
55
|
sys.stderr.write(f"compose2pod: error: could not parse compose input: {error}\n")
|
|
86
56
|
return 2
|
|
87
57
|
options = EmitOptions(
|
|
@@ -0,0 +1,402 @@
|
|
|
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, Expand, GuardedEnvFile, Token
|
|
14
|
+
from compose2pod.parsing import validate
|
|
15
|
+
from compose2pod.pod import pod_create_flags
|
|
16
|
+
from compose2pod.resources import deploy_resource_flags
|
|
17
|
+
from compose2pod.shell import to_shell, variable_names
|
|
18
|
+
from compose2pod.values import as_bool
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
HEALTHY_WAIT_BUDGET_SECONDS = 120
|
|
22
|
+
POD_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def image_for(svc: dict[str, Any], ci_image: str) -> Token:
|
|
26
|
+
"""Services with a build section run the freshly built CI image."""
|
|
27
|
+
if "build" in svc:
|
|
28
|
+
return ci_image
|
|
29
|
+
return Expand(value=svc["image"])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def command_tokens(svc: dict[str, Any]) -> list[Token]:
|
|
33
|
+
"""Service command as argv tokens; compose string form means shell form."""
|
|
34
|
+
command = svc.get("command")
|
|
35
|
+
if command is None:
|
|
36
|
+
return []
|
|
37
|
+
if isinstance(command, str):
|
|
38
|
+
return ["/bin/sh", "-c", Expand(value=command)]
|
|
39
|
+
return [Expand(value=str(token)) for token in command]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def entrypoint_tokens(svc: dict[str, Any]) -> list[Token]:
|
|
43
|
+
"""Service entrypoint as argv tokens; compose string form means shell form."""
|
|
44
|
+
entrypoint = svc.get("entrypoint")
|
|
45
|
+
if entrypoint is None:
|
|
46
|
+
return []
|
|
47
|
+
if isinstance(entrypoint, str):
|
|
48
|
+
return ["/bin/sh", "-c", Expand(value=entrypoint)]
|
|
49
|
+
return [Expand(value=str(token)) for token in entrypoint]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _health_flags(healthcheck: dict[str, Any]) -> list[Token]:
|
|
53
|
+
"""Healthcheck flag tokens."""
|
|
54
|
+
flags: list[Token] = []
|
|
55
|
+
cmd = health_cmd(healthcheck.get("test"))
|
|
56
|
+
if cmd is not None:
|
|
57
|
+
flags += ["--health-cmd", Expand(value=cmd)]
|
|
58
|
+
# An explicit `null` scalar means unset, not "emit the literal string
|
|
59
|
+
# 'None'" -- the same treatment `environment`/`volumes`/`command`
|
|
60
|
+
# give a null value elsewhere in this package, and matching `docker
|
|
61
|
+
# compose config`, which treats an explicitly-null key as absent.
|
|
62
|
+
# Keyed off value, not presence, so `timeout: null` and an omitted
|
|
63
|
+
# `timeout` behave identically.
|
|
64
|
+
if healthcheck.get("timeout") is not None:
|
|
65
|
+
flags += ["--health-timeout", str(healthcheck["timeout"])]
|
|
66
|
+
if healthcheck.get("start_period") is not None:
|
|
67
|
+
flags += ["--health-start-period", str(healthcheck["start_period"])]
|
|
68
|
+
if healthcheck.get("retries") is not None:
|
|
69
|
+
flags += ["--health-retries", str(healthcheck["retries"])]
|
|
70
|
+
return flags
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _env_file_flags(svc: dict[str, Any], project_dir: str) -> list[Token]:
|
|
74
|
+
"""--env-file flag tokens.
|
|
75
|
+
|
|
76
|
+
`environment` is a SERVICE_KEYS registry key (_map("-e")). A `required: false`
|
|
77
|
+
long-form entry becomes a run-time-guarded token (see `GuardedEnvFile`) instead
|
|
78
|
+
of an unconditional flag, so a missing optional file doesn't fail the run.
|
|
79
|
+
"""
|
|
80
|
+
flags: list[Token] = []
|
|
81
|
+
env_files = svc.get("env_file") or []
|
|
82
|
+
if isinstance(env_files, str):
|
|
83
|
+
env_files = [env_files]
|
|
84
|
+
for i, entry in enumerate(env_files):
|
|
85
|
+
if isinstance(entry, str):
|
|
86
|
+
path, required = entry, True
|
|
87
|
+
else:
|
|
88
|
+
path, required = entry["path"], as_bool(entry.get("required", True))
|
|
89
|
+
resolved = str(Path(project_dir, path))
|
|
90
|
+
if required:
|
|
91
|
+
flags += ["--env-file", Expand(value=resolved)]
|
|
92
|
+
else:
|
|
93
|
+
flags.append(GuardedEnvFile(var=f"c2p_envfile_{i}", value=resolved))
|
|
94
|
+
return flags
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _nested_mount_parts(vtype: str, options: dict[str, Any]) -> list[str]:
|
|
98
|
+
"""Map a long-form volume entry's validated `bind`/`volume`/`tmpfs` sub-map to `--mount` options."""
|
|
99
|
+
if vtype == "bind":
|
|
100
|
+
parts = [f"bind-propagation={options['propagation']}"] if "propagation" in options else []
|
|
101
|
+
if "selinux" in options:
|
|
102
|
+
parts.append(f"relabel={'shared' if options['selinux'] == 'z' else 'private'}")
|
|
103
|
+
return parts
|
|
104
|
+
if vtype == "volume":
|
|
105
|
+
return [f"subpath={options['subpath']}"] if "subpath" in options else []
|
|
106
|
+
# vtype == "tmpfs": the gate validates `type` to one of bind/volume/tmpfs, so no other branch is reachable.
|
|
107
|
+
parts = [f"tmpfs-size={options['size']}"] if "size" in options else []
|
|
108
|
+
if "mode" in options:
|
|
109
|
+
parts.append(f"tmpfs-mode={options['mode']}")
|
|
110
|
+
return parts
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _mount_flag(entry: dict[str, Any], project_dir: str) -> list[Token]:
|
|
114
|
+
"""Render one long-form volume mapping as a `--mount` value."""
|
|
115
|
+
parts = [f"type={entry['type']}"]
|
|
116
|
+
source = entry.get("source")
|
|
117
|
+
if source is not None:
|
|
118
|
+
if entry["type"] == "bind" and source.startswith("."):
|
|
119
|
+
source = str(Path(project_dir, source))
|
|
120
|
+
parts.append(f"source={source}")
|
|
121
|
+
parts.append(f"target={entry['target']}")
|
|
122
|
+
if as_bool(entry.get("read_only", False)):
|
|
123
|
+
parts.append("ro")
|
|
124
|
+
vtype = entry["type"]
|
|
125
|
+
parts += _nested_mount_parts(vtype, entry.get(vtype, {}))
|
|
126
|
+
return ["--mount", Expand(value=",".join(parts))]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _volume_flags(svc: dict[str, Any], project_dir: str) -> list[Token]:
|
|
130
|
+
"""-v and --mount flag tokens."""
|
|
131
|
+
flags: list[Token] = []
|
|
132
|
+
for volume in svc.get("volumes") or []:
|
|
133
|
+
if isinstance(volume, dict):
|
|
134
|
+
flags += _mount_flag(volume, project_dir)
|
|
135
|
+
continue
|
|
136
|
+
if ":" not in volume:
|
|
137
|
+
# Anonymous volume: a bare container path, no host source to translate.
|
|
138
|
+
flags += ["-v", Expand(value=volume)]
|
|
139
|
+
continue
|
|
140
|
+
source, destination = volume.split(":", 1)
|
|
141
|
+
if source.startswith("."):
|
|
142
|
+
# Relative bind mount: resolve against project_dir.
|
|
143
|
+
source = str(Path(project_dir, source))
|
|
144
|
+
# Absolute bind mount (starts with "/") and named volume (bare
|
|
145
|
+
# identifier) are both kept as-is — neither is a path to translate.
|
|
146
|
+
flags += ["-v", Expand(value=f"{source}:{destination}")]
|
|
147
|
+
return flags
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def run_flags(name: str, svc: dict[str, Any], pod: str, project_dir: str) -> list[Token]:
|
|
151
|
+
"""Flag tokens (unquoted) for `podman run` of one service."""
|
|
152
|
+
flags: list[Token] = ["--pod", pod, "--name", f"{pod}-{name}"]
|
|
153
|
+
flags += _env_file_flags(svc, project_dir)
|
|
154
|
+
flags += _volume_flags(svc, project_dir)
|
|
155
|
+
flags += _health_flags(svc.get("healthcheck") or {})
|
|
156
|
+
for key, spec in SERVICE_KEYS.items():
|
|
157
|
+
if key in svc:
|
|
158
|
+
flags += spec.emit(svc[key])
|
|
159
|
+
flags += stores.flags(svc, pod)
|
|
160
|
+
flags += deploy_resource_flags(svc)
|
|
161
|
+
return flags
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
_SCRIPT_HEADER = """\
|
|
165
|
+
#!/bin/sh
|
|
166
|
+
# Generated by compose2pod -- do not edit, regenerate instead.
|
|
167
|
+
set -eu
|
|
168
|
+
|
|
169
|
+
podman_version=$(podman version --format '{{.Client.Version}}' 2>/dev/null) || podman_version="unknown"
|
|
170
|
+
podman_major=$(echo "$podman_version" | cut -d. -f1)
|
|
171
|
+
case "$podman_major" in
|
|
172
|
+
''|*[!0-9]*) ;; # unparseable -- skip rather than false-positive
|
|
173
|
+
*) if [ "$podman_major" -lt 6 ]; then
|
|
174
|
+
echo "warning: podman $podman_version detected; compose2pod requires podman >= 6.0.0" >&2
|
|
175
|
+
echo "warning: podman < 6.0.0 has a bug where a container stopping in a multi-container" >&2
|
|
176
|
+
echo "pod wipes /etc/hosts for the whole pod (fixed in 6.0.0) -- name resolution between" >&2
|
|
177
|
+
echo "services may fail unpredictably" >&2
|
|
178
|
+
fi ;;
|
|
179
|
+
esac
|
|
180
|
+
|
|
181
|
+
wait_healthy() {
|
|
182
|
+
ctr=$1
|
|
183
|
+
attempts=$2
|
|
184
|
+
interval=$3
|
|
185
|
+
i=0
|
|
186
|
+
while [ "$i" -lt "$attempts" ]; do
|
|
187
|
+
if podman healthcheck run "$ctr"; then
|
|
188
|
+
return 0
|
|
189
|
+
fi
|
|
190
|
+
i=$((i + 1))
|
|
191
|
+
sleep "$interval"
|
|
192
|
+
done
|
|
193
|
+
echo "wait_healthy: $ctr did not become healthy after $attempts attempts" >&2
|
|
194
|
+
podman logs "$ctr" >&2 || true
|
|
195
|
+
return 1
|
|
196
|
+
}
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
|
|
201
|
+
class EmitOptions:
|
|
202
|
+
"""Options for emit_script rendering."""
|
|
203
|
+
|
|
204
|
+
target: str
|
|
205
|
+
ci_image: str
|
|
206
|
+
command: str
|
|
207
|
+
pod: str
|
|
208
|
+
project_dir: str
|
|
209
|
+
artifacts: list[str]
|
|
210
|
+
allow_exit_codes: list[int]
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
|
|
214
|
+
class PlannedScript:
|
|
215
|
+
"""The rendered pod script and the run-time variables it expands.
|
|
216
|
+
|
|
217
|
+
Both are products of a single `_plan` traversal, so they cannot drift.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
script: str
|
|
221
|
+
variables: list[str]
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _render(tokens: list[Token]) -> str:
|
|
225
|
+
parts: list[str] = []
|
|
226
|
+
for token in tokens:
|
|
227
|
+
if isinstance(token, Expand):
|
|
228
|
+
parts.append(to_shell(token.value))
|
|
229
|
+
elif isinstance(token, GuardedEnvFile):
|
|
230
|
+
parts.append("${" + token.var + ':+"$' + token.var + '"}')
|
|
231
|
+
else:
|
|
232
|
+
parts.append(shlex.quote(token))
|
|
233
|
+
return " ".join(parts)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _collect_vars(tokens: list[Token], names: set[str]) -> None:
|
|
237
|
+
"""Add the run-time variables any `Expand`/`GuardedEnvFile` tokens expand to `names`."""
|
|
238
|
+
for token in tokens:
|
|
239
|
+
if isinstance(token, Expand | GuardedEnvFile):
|
|
240
|
+
names.update(variable_names(token.value))
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _env_file_preludes(tokens: list[Token]) -> list[str]:
|
|
244
|
+
"""Build assignment lines for each guarded env_file, set right before the service's `podman run`.
|
|
245
|
+
|
|
246
|
+
`${var}` is set to the `--env-file=PATH` flag only if the file exists at run time;
|
|
247
|
+
`_render` references it inline as `${var:+"$var"}`. The `[ -f ] && ...` list is
|
|
248
|
+
set -e-safe (the failing test is the left operand of `&&`).
|
|
249
|
+
"""
|
|
250
|
+
lines: list[str] = []
|
|
251
|
+
for token in tokens:
|
|
252
|
+
if isinstance(token, GuardedEnvFile):
|
|
253
|
+
path = to_shell(token.value)
|
|
254
|
+
lines.append(f"{token.var}=")
|
|
255
|
+
lines.append(f"[ -f {path} ] && {token.var}=--env-file={path}")
|
|
256
|
+
return lines
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def run_tokens(name: str, services: dict[str, Any], options: EmitOptions) -> list[Token]:
|
|
260
|
+
svc = services[name]
|
|
261
|
+
tokens = run_flags(name, svc, options.pod, options.project_dir)
|
|
262
|
+
entrypoint = entrypoint_tokens(svc)
|
|
263
|
+
if entrypoint:
|
|
264
|
+
tokens += ["--entrypoint", entrypoint[0]]
|
|
265
|
+
tokens.append(image_for(svc, options.ci_image))
|
|
266
|
+
tokens += entrypoint[1:]
|
|
267
|
+
if name == options.target and options.command:
|
|
268
|
+
tokens.extend(shlex.split(options.command))
|
|
269
|
+
elif not isinstance(svc.get("entrypoint"), str):
|
|
270
|
+
# A string (shell-form) entrypoint runs via `sh -c`; the service command
|
|
271
|
+
# is ignored, matching Docker. List/exec form appends the command.
|
|
272
|
+
tokens.extend(command_tokens(svc))
|
|
273
|
+
return tokens
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _emit_target(lines: list[str], tokens: list[Token], options: EmitOptions) -> None:
|
|
277
|
+
target_ctr = shlex.quote(f"{options.pod}-{options.target}")
|
|
278
|
+
lines.append("rc=0")
|
|
279
|
+
lines.extend(_env_file_preludes(tokens))
|
|
280
|
+
lines.append(f"podman run {_render(tokens)} || rc=$?")
|
|
281
|
+
for artifact in options.artifacts:
|
|
282
|
+
source, destination = artifact.split(":", 1)
|
|
283
|
+
lines.append(f"podman cp {target_ctr}:{shlex.quote(source)} {shlex.quote(destination)} || true")
|
|
284
|
+
allowed = "|".join(str(code) for code in [0, *options.allow_exit_codes])
|
|
285
|
+
lines.append('case "$rc" in')
|
|
286
|
+
lines.append(f" {allowed}) ;;")
|
|
287
|
+
lines.append(' *) echo "target service failed with exit code $rc" >&2')
|
|
288
|
+
lines.append(
|
|
289
|
+
" podman inspect --format "
|
|
290
|
+
"'OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}}' " + target_ctr + " >&2 || true"
|
|
291
|
+
)
|
|
292
|
+
lines.append(" podman ps -a --format '{{.Names}} {{.Status}}' >&2 || true")
|
|
293
|
+
lines.append(' exit "$rc" ;;')
|
|
294
|
+
lines.append("esac")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _validate_options(options: EmitOptions) -> None:
|
|
298
|
+
"""Check option values emit destructures or interpolates unquoted.
|
|
299
|
+
|
|
300
|
+
CLI-unreachable (argparse enforces `artifact` as `str` and
|
|
301
|
+
`allow_exit_codes` as `int`) but a library caller can pass `EmitOptions`
|
|
302
|
+
directly: a non-string `artifact` would crash raw on the ':' membership
|
|
303
|
+
test below, and a non-int `allow_exit_codes` entry is interpolated
|
|
304
|
+
unquoted into the generated `case "$rc" in ...)` pattern -- shell
|
|
305
|
+
injection, not just a crash.
|
|
306
|
+
"""
|
|
307
|
+
for artifact in options.artifacts:
|
|
308
|
+
if not isinstance(artifact, str) or ":" not in artifact:
|
|
309
|
+
msg = f"artifact {artifact!r} must be in SRC:DST form"
|
|
310
|
+
raise UnsupportedComposeError(msg)
|
|
311
|
+
for code in options.allow_exit_codes:
|
|
312
|
+
if isinstance(code, bool) or not isinstance(code, int):
|
|
313
|
+
msg = f"allow_exit_codes entry {code!r} must be an int"
|
|
314
|
+
raise UnsupportedComposeError(msg)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _plan(compose: dict[str, Any], options: EmitOptions) -> PlannedScript:
|
|
318
|
+
"""Walk the target's dependency closure once, building the script and its variables.
|
|
319
|
+
|
|
320
|
+
Every token source is visited a single time and feeds both outputs: each
|
|
321
|
+
service's `run_tokens` and the `pod_create_flags` render into lines *and*
|
|
322
|
+
have their `Expand` variables collected, so the script and the variable
|
|
323
|
+
list cannot disagree about what the script expands at run time.
|
|
324
|
+
|
|
325
|
+
`emit_script` and `referenced_variables` are both public entry points that
|
|
326
|
+
project this traversal, so a library caller can reach either one without
|
|
327
|
+
ever calling `validate()` first. Validating `compose` here -- and nowhere
|
|
328
|
+
else -- makes both safe by construction: this is the one place a caller
|
|
329
|
+
cannot route around. `cli.py` also calls `validate()` itself (to surface
|
|
330
|
+
warnings), so this repeats that pass; `validate()` only reads `compose`
|
|
331
|
+
and returns warnings, so the repeat is side-effect-free. The warnings from
|
|
332
|
+
this pass have no channel to reach a caller here -- `cli.py` surfaces its
|
|
333
|
+
own copy from its own call -- so they are discarded on purpose.
|
|
334
|
+
"""
|
|
335
|
+
_validate_options(options)
|
|
336
|
+
_ = validate(compose) # re-validate; warnings already surfaced by the caller, if any
|
|
337
|
+
if not POD_NAME_PATTERN.fullmatch(options.pod):
|
|
338
|
+
msg = f"invalid pod name {options.pod!r}"
|
|
339
|
+
raise UnsupportedComposeError(msg)
|
|
340
|
+
services = compose["services"]
|
|
341
|
+
order = startup_order(services, options.target)
|
|
342
|
+
# Only the closure joins the pod, so only the closure is resolvable. A name
|
|
343
|
+
# pointing at 127.0.0.1 for a service that never runs would turn an honest
|
|
344
|
+
# resolution failure into a connection-refused.
|
|
345
|
+
hosts = hostnames({name: services[name] for name in order})
|
|
346
|
+
completion_gated = {
|
|
347
|
+
dep
|
|
348
|
+
for svc in services.values()
|
|
349
|
+
for dep, condition in depends_on(svc).items()
|
|
350
|
+
if condition == "service_completed_successfully"
|
|
351
|
+
}
|
|
352
|
+
names: set[str] = set()
|
|
353
|
+
|
|
354
|
+
lines = [_SCRIPT_HEADER]
|
|
355
|
+
teardown = f"podman pod rm -f {shlex.quote(options.pod)} >/dev/null 2>&1 || true"
|
|
356
|
+
store_teardown = stores.teardown_line(compose, order, options.pod)
|
|
357
|
+
if store_teardown:
|
|
358
|
+
teardown += f"; {store_teardown}"
|
|
359
|
+
lines.append(f"trap '{teardown}' EXIT")
|
|
360
|
+
pod_flags = pod_create_flags(services, order, hosts)
|
|
361
|
+
_collect_vars(pod_flags, names)
|
|
362
|
+
pod_create = f"podman pod create --name {shlex.quote(options.pod)}"
|
|
363
|
+
if pod_flags:
|
|
364
|
+
pod_create += " " + _render(pod_flags)
|
|
365
|
+
lines.append(pod_create)
|
|
366
|
+
lines.extend(stores.create_lines(compose, order, options.pod, options.project_dir))
|
|
367
|
+
names |= stores.referenced_variables(compose, order, options.project_dir)
|
|
368
|
+
waited: set[str] = set()
|
|
369
|
+
for name in order:
|
|
370
|
+
for dep, condition in depends_on(services[name]).items():
|
|
371
|
+
if condition == "service_healthy" and dep not in waited:
|
|
372
|
+
interval = interval_seconds((services[dep].get("healthcheck") or {}).get("interval"))
|
|
373
|
+
attempts = max(HEALTHY_WAIT_BUDGET_SECONDS // interval, 1)
|
|
374
|
+
lines.append(f"wait_healthy {shlex.quote(f'{options.pod}-{dep}')} {attempts} {interval}")
|
|
375
|
+
waited.add(dep)
|
|
376
|
+
tokens = run_tokens(name, services, options)
|
|
377
|
+
_collect_vars(tokens, names)
|
|
378
|
+
if name == options.target:
|
|
379
|
+
_emit_target(lines, tokens, options)
|
|
380
|
+
elif name in completion_gated:
|
|
381
|
+
lines.extend(_env_file_preludes(tokens))
|
|
382
|
+
lines.append(f"podman run --rm {_render(tokens)}")
|
|
383
|
+
else:
|
|
384
|
+
lines.extend(_env_file_preludes(tokens))
|
|
385
|
+
lines.append(f"podman run -d {_render(tokens)}")
|
|
386
|
+
return PlannedScript(script="\n".join(lines) + "\n", variables=sorted(names))
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def emit_script(compose: dict[str, Any], options: EmitOptions) -> str:
|
|
390
|
+
"""Render the full pod test script for `target` and its dependency closure."""
|
|
391
|
+
return _plan(compose, options).script
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def referenced_variables(compose: dict[str, Any], options: EmitOptions) -> list[str]:
|
|
395
|
+
"""Variable names the emitted script expands at run time (sorted, unique).
|
|
396
|
+
|
|
397
|
+
Projects the same `_plan` traversal `emit_script` renders from, so it counts
|
|
398
|
+
exactly the values that pass through `to_shell` -- never a `${VAR}` in a
|
|
399
|
+
field emitted literally, and never the `--command` override (a literal CLI
|
|
400
|
+
value).
|
|
401
|
+
"""
|
|
402
|
+
return _plan(compose, options).variables
|