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.
Files changed (26) hide show
  1. {compose2pod-0.2.0 → compose2pod-0.3.0}/PKG-INFO +11 -1
  2. {compose2pod-0.2.0 → compose2pod-0.3.0}/README.md +10 -0
  3. {compose2pod-0.2.0 → compose2pod-0.3.0}/compose2pod/cli.py +3 -33
  4. compose2pod-0.3.0/compose2pod/emit.py +402 -0
  5. compose2pod-0.3.0/compose2pod/extends.py +213 -0
  6. compose2pod-0.3.0/compose2pod/graph.py +181 -0
  7. compose2pod-0.3.0/compose2pod/healthcheck.py +90 -0
  8. compose2pod-0.3.0/compose2pod/keys.py +455 -0
  9. compose2pod-0.3.0/compose2pod/parsing.py +1514 -0
  10. {compose2pod-0.2.0 → compose2pod-0.3.0}/compose2pod/pod.py +51 -8
  11. compose2pod-0.3.0/compose2pod/read.py +99 -0
  12. {compose2pod-0.2.0 → compose2pod-0.3.0}/compose2pod/resources.py +50 -17
  13. {compose2pod-0.2.0 → compose2pod-0.3.0}/compose2pod/stores.py +34 -7
  14. compose2pod-0.3.0/compose2pod/values.py +495 -0
  15. {compose2pod-0.2.0 → compose2pod-0.3.0}/pyproject.toml +4 -3
  16. compose2pod-0.2.0/compose2pod/emit.py +0 -274
  17. compose2pod-0.2.0/compose2pod/extends.py +0 -127
  18. compose2pod-0.2.0/compose2pod/graph.py +0 -78
  19. compose2pod-0.2.0/compose2pod/healthcheck.py +0 -59
  20. compose2pod-0.2.0/compose2pod/keys.py +0 -229
  21. compose2pod-0.2.0/compose2pod/parsing.py +0 -143
  22. {compose2pod-0.2.0 → compose2pod-0.3.0}/compose2pod/__init__.py +0 -0
  23. {compose2pod-0.2.0 → compose2pod-0.3.0}/compose2pod/__main__.py +0 -0
  24. {compose2pod-0.2.0 → compose2pod-0.3.0}/compose2pod/exceptions.py +0 -0
  25. {compose2pod-0.2.0 → compose2pod-0.3.0}/compose2pod/py.typed +0 -0
  26. {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.2.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 = _read_compose(text, args.format)
84
- except (json.JSONDecodeError, UnsupportedComposeError) as error:
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