From 826e12f1413e7698810008a12e3200d5bee73ae2 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 13 Jul 2026 17:19:59 +0300 Subject: [PATCH] feat: warn in generated scripts when podman is below the known /etc/hosts pod-wipe fix version Podman before 6.0.0 wipes /etc/hosts for every container in a pod when any one container stops, breaking name resolution for containers started after a completion-gated dependency (e.g. a migration step) exits. compose2pod's pod-level --add-host design makes every generated script vulnerable to this on affected podman versions. The script now checks `podman version` at startup and warns on stderr, without blocking, when it detects a major version below 6. --- README.md | 10 ++ architecture/supported-subset.md | 14 +++ compose2pod/emit.py | 12 ++ .../2026-07-13.06-podman-version-guard.md | 109 ++++++++++++++++++ tests/test_emit.py | 56 +++++++++ 5 files changed, 201 insertions(+) create mode 100644 planning/changes/2026-07-13.06-podman-version-guard.md diff --git a/README.md b/README.md index 0b19a1f..3a4fffe 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,16 @@ Built for CI and test environments where you can't use `docker compose` or `podm - **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. - **No heavy runtime.** The core is stdlib-only — no dependencies, no compiled wheels — so it installs and runs in minimal Python images. +## Requirements + +**Podman >= 6.0.0.** Earlier releases have a bug where a container stopping +inside a multi-container pod wipes `/etc/hosts` for every container in that +pod, not just the one that stopped — fixed in 6.0.0. compose2pod's generated +scripts rely on one shared `--add-host`-populated `/etc/hosts` for the whole +pod (see `architecture/supported-subset.md`), so a `service_completed_successfully` +dependency (a container that runs and exits, e.g. a migration step) can wipe +name resolution for everything started after it on a pre-6.0.0 Podman. + ## Install ```bash diff --git a/architecture/supported-subset.md b/architecture/supported-subset.md index d876ac6..1c1f640 100644 --- a/architecture/supported-subset.md +++ b/architecture/supported-subset.md @@ -227,6 +227,20 @@ compose2pod hoists them onto `podman pod create` instead time a `dns` / `sysctls` / `extra_hosts` declaration on a service outside the target's closure is silently ignored by `pod_create_flags` — no flag is emitted for it, since that service is never run. +- **Requires Podman >= 6.0.0.** Before 6.0.0, Podman had a bug where a + container stopping inside a multi-container pod wiped `/etc/hosts` for + every container in the pod, not just the one that stopped. Because + `--add-host` here is the pod's *only* source of `/etc/hosts` entries (moved + off per-service `podman run` for exactly this pod-wide reason), a + `service_completed_successfully` dependency — a container that runs to + completion and exits, e.g. a migration step run via `podman run --rm` — + triggers the bug and erases name resolution for every service started + after it. Confirmed present on 5.8.1, fixed on 6.0.0/6.0.1 (verified by + reproducing the exact failure end-to-end against real Podman). See + `README.md`'s Requirements section. The generated script itself checks + `podman version` at startup and warns on stderr (without blocking) when + it detects a major version below 6, so the requirement is visible at the + point of failure, not only in the docs. - **Non-goals:** per-service DNS/sysctls — impossible inside a shared-namespace pod, not a compose2pod limitation; last-writer-wins on a sysctl key conflict — refused instead, matching the refuse-on-conflict diff --git a/compose2pod/emit.py b/compose2pod/emit.py index 118e4d5..5697715 100644 --- a/compose2pod/emit.py +++ b/compose2pod/emit.py @@ -112,6 +112,18 @@ def run_flags(name: str, svc: dict[str, Any], pod: str, project_dir: str) -> lis # Generated by compose2pod -- do not edit, regenerate instead. set -eu +podman_version=$(podman version --format '{{.Client.Version}}' 2>/dev/null) || podman_version="unknown" +podman_major=$(echo "$podman_version" | cut -d. -f1) +case "$podman_major" in + ''|*[!0-9]*) ;; # unparseable -- skip rather than false-positive + *) if [ "$podman_major" -lt 6 ]; then + echo "warning: podman $podman_version detected; compose2pod requires podman >= 6.0.0" >&2 + echo "warning: podman < 6.0.0 has a bug where a container stopping in a multi-container" >&2 + echo "pod wipes /etc/hosts for the whole pod (fixed in 6.0.0) -- name resolution between" >&2 + echo "services may fail unpredictably" >&2 + fi ;; +esac + wait_healthy() { ctr=$1 attempts=$2 diff --git a/planning/changes/2026-07-13.06-podman-version-guard.md b/planning/changes/2026-07-13.06-podman-version-guard.md new file mode 100644 index 0000000..2b10004 --- /dev/null +++ b/planning/changes/2026-07-13.06-podman-version-guard.md @@ -0,0 +1,109 @@ +--- +summary: Generated scripts now warn on stderr (non-blocking) when run under podman < 6.0.0, naming the required version and the /etc/hosts pod-wipe bug it fixes. +--- + +# Design: podman version guard in the generated script + +## Summary + +Every script `emit_script` produces gets a small unconditional check, +inlined into `_SCRIPT_HEADER` right after `set -eu`: it reads the podman +client's major version and, if it is below 6, prints a two-line warning to +stderr naming the actual version, the required version, and the underlying +`/etc/hosts` bug — then continues running the script unchanged. It is a +diagnostic aid, not a gate. + +## Motivation + +`README.md` and `architecture/supported-subset.md` already document +"Requires Podman >= 6.0.0" (this session, debugging a real failure): podman +before 6.0.0 has a bug where a container stopping inside a multi-container +pod wipes `/etc/hosts` for every container in the pod, not just the one that +stopped. compose2pod's pod-level `--add-host` design (see +`2026-07-13.01-pod-level-add-host.md`) makes every generated script's +`service_completed_successfully` dependency (e.g. a migration step that runs +via `podman run --rm` and exits) a trigger for it. The failure this produces +— `could not translate host name "db" to address` deep inside an unrelated +application, minutes after the script appeared to succeed — gives no hint +that podman's version is the cause. A stated README requirement does nothing +for someone who hits the bug at 2am and has never read the README. The +script itself is where that information is actually useful. + +## Design + +`compose2pod/emit.py`'s `_SCRIPT_HEADER` constant gains this block, +immediately after `set -eu` and before the `wait_healthy()` function +definition, so it is the first thing every generated script does: + +```sh +podman_version=$(podman version --format '{{.Client.Version}}' 2>/dev/null) || podman_version="unknown" +podman_major=$(echo "$podman_version" | cut -d. -f1) +case "$podman_major" in + ''|*[!0-9]*) ;; # unparseable -- skip rather than false-positive + *) if [ "$podman_major" -lt 6 ]; then + echo "warning: podman $podman_version detected; compose2pod requires podman >= 6.0.0" >&2 + echo "warning: podman < 6.0.0 has a bug where a container stopping in a multi-container pod wipes /etc/hosts for the whole pod (fixed in 6.0.0) -- name resolution between services may fail unpredictably" >&2 + fi ;; +esac +``` + +Only the major version is compared: 6.0.0 is an exact upstream fix +boundary, not a range, so there is no minor/patch granularity to reason +about. The check is unconditional — it runs for every script regardless of +how many services are in the target's dependency closure — trading a +theoretically-avoidable warning on a single-service pod (which cannot hit +the underlying bug) for one code path with nothing to get wrong. It is +inlined rather than wrapped in a named function like `wait_healthy()`: +`wait_healthy` has multiple call sites (one per healthcheck-gated +dependency), this check has exactly one, so a function would add indirection +with no reuse to justify it. + +The check degrades safely on anything unexpected: if `podman version +--format` fails or is unsupported, `podman_version` becomes the literal +string `"unknown"`, `cut -d. -f1` yields `"unknown"`, and the `case` pattern +`*[!0-9]*` matches it — the check is silently skipped rather than raising +under `set -eu` or false-warning on a version it could not parse. + +It is a warning, not a gate: the script proceeds regardless of the podman +version detected. A user who has confirmed their specific compose shape +cannot hit the bug (or is mid-upgrade and accepts the risk) is not blocked. + +## Non-goals + +- **No escape hatch / suppression env var.** It is a non-blocking warning; + there is nothing to bypass. Revisit only if this becomes reported as + noisy in practice. +- **No closure-shape detection** (e.g. skipping the check for + single-service targets that can't hit the bug). Rejected for the same + reason as the escape hatch: one unconditional code path is simpler than a + correct-in-all-cases shape analysis, and the cost of an unnecessary + warning is low. +- **No minor/patch-level version comparison.** The fix boundary is a single + major-version line (6.0.0); finer comparison buys nothing here. + +## Testing + +`just test-ci` at 100%: +- `tests/test_emit.py`: `emit_script(...)` output contains the version-check + block for a representative compose shape (asserts the `podman version + --format` line and the `requires podman >= 6.0.0` message are present) — + one assertion suffices since the block is unconditional and identical + across every shape. +- `tests/test_emit.py`: a subprocess-level test extracts just the version + guard block from `_SCRIPT_HEADER` and runs it under `sh` with a stub + `podman` script on `PATH` (via a `tmp_path` bin dir prepended to `PATH`) + that prints a chosen version string. Cases: `5.8.1` → warning lines on + stderr; `6.0.1` → silent, exit 0; garbage/empty output → silent, exit 0 + (proves the `set -eu` script doesn't abort on an unparseable version). + +`just lint-ci` and `just check-planning` clean. + +## Risk + +- **False negative on a podman fork/vendor build with a non-numeric or + differently-shaped `--format` output** (low x low): the `case` guard + skips the check silently rather than warning wrong or crashing. Accepted: + matches the "degrade safely" principle above. +- **Warning noise on CI logs for users already on podman >= 6** (none): the + check is silent whenever `podman_major -ge 6`, so this only affects users + actually on the affected versions. diff --git a/tests/test_emit.py b/tests/test_emit.py index aec3dd2..21435f7 100644 --- a/tests/test_emit.py +++ b/tests/test_emit.py @@ -1,6 +1,12 @@ +import os +import shutil +import subprocess +from pathlib import Path + import pytest from compose2pod.emit import ( + _SCRIPT_HEADER, EmitOptions, _Expand, command_tokens, @@ -14,6 +20,9 @@ from compose2pod.parsing import validate +_SH = shutil.which("sh") + + class TestRunFlags: def test_db_flags(self, chats_compose: dict) -> None: flags = run_flags("db", chats_compose["services"]["db"], "test-pod", "/builds/chats") @@ -406,6 +415,15 @@ def test_wait_healthy_function_uses_healthcheck_run(self, chats_compose: dict) - assert "podman healthcheck run" in script assert "wait_healthy()" in script + def test_podman_version_guard_present_in_header(self, chats_compose: dict) -> None: + script = self.make_script(chats_compose) + assert "podman version --format '{{.Client.Version}}'" in script + assert "requires podman >= 6.0.0" in script + version_guard_index = script.index("podman_version=") + wait_healthy_index = script.index("wait_healthy()") + set_eu_index = script.index("set -eu") + assert set_eu_index < version_guard_index < wait_healthy_index + def test_hostname_becomes_add_host_entry(self) -> None: compose = { "services": { @@ -733,3 +751,41 @@ def test_emit_script_accepts_a_valid_pod_name(self) -> None: doc = {"services": {"app": {"image": "x"}}} script = emit_script(compose=doc, options=self._options("test-pod.1")) assert "podman pod create --name test-pod.1" in script + + +class TestPodmanVersionGuard: + def _run_header(self, tmp_path: Path, podman_stub_body: str) -> "subprocess.CompletedProcess[str]": + assert _SH is not None # sh is a POSIX baseline binary, always present + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + podman_stub = bin_dir / "podman" + podman_stub.write_text(f"#!/bin/sh\n{podman_stub_body}\n") + podman_stub.chmod(0o755) + header_path = tmp_path / "header.sh" + header_path.write_text(_SCRIPT_HEADER) + env = dict(os.environ) + env["PATH"] = f"{bin_dir}:{env['PATH']}" + return subprocess.run( # noqa: S603 - _SH is an absolute path from shutil.which, not untrusted input + [_SH, str(header_path)], + capture_output=True, + text=True, + env=env, + check=False, + timeout=10, + ) + + def test_warns_when_podman_major_below_six(self, tmp_path: Path) -> None: + result = self._run_header(tmp_path, 'echo "5.8.1"') + assert result.returncode == 0 + assert "podman 5.8.1 detected; compose2pod requires podman >= 6.0.0" in result.stderr + assert "/etc/hosts" in result.stderr + + def test_silent_when_podman_major_six_or_above(self, tmp_path: Path) -> None: + result = self._run_header(tmp_path, 'echo "6.0.1"') + assert result.returncode == 0 + assert result.stderr == "" + + def test_silent_when_podman_version_unparseable(self, tmp_path: Path) -> None: + result = self._run_header(tmp_path, "exit 1") + assert result.returncode == 0 + assert result.stderr == ""