Skip to content

Commit 826e12f

Browse files
committed
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.
1 parent 1fcb527 commit 826e12f

5 files changed

Lines changed: 201 additions & 0 deletions

File tree

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,16 @@ Built for CI and test environments where you can't use `docker compose` or `podm
2626
- **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.
2727
- **No heavy runtime.** The core is stdlib-only — no dependencies, no compiled wheels — so it installs and runs in minimal Python images.
2828

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+
2939
## Install
3040

3141
```bash

architecture/supported-subset.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,20 @@ compose2pod hoists them onto `podman pod create` instead
227227
time a `dns` / `sysctls` / `extra_hosts` declaration on a service outside
228228
the target's closure is silently ignored by `pod_create_flags` — no flag
229229
is emitted for it, since that service is never run.
230+
- **Requires Podman >= 6.0.0.** Before 6.0.0, Podman had a bug where a
231+
container stopping inside a multi-container pod wiped `/etc/hosts` for
232+
every container in the pod, not just the one that stopped. Because
233+
`--add-host` here is the pod's *only* source of `/etc/hosts` entries (moved
234+
off per-service `podman run` for exactly this pod-wide reason), a
235+
`service_completed_successfully` dependency — a container that runs to
236+
completion and exits, e.g. a migration step run via `podman run --rm`
237+
triggers the bug and erases name resolution for every service started
238+
after it. Confirmed present on 5.8.1, fixed on 6.0.0/6.0.1 (verified by
239+
reproducing the exact failure end-to-end against real Podman). See
240+
`README.md`'s Requirements section. The generated script itself checks
241+
`podman version` at startup and warns on stderr (without blocking) when
242+
it detects a major version below 6, so the requirement is visible at the
243+
point of failure, not only in the docs.
230244
- **Non-goals:** per-service DNS/sysctls — impossible inside a
231245
shared-namespace pod, not a compose2pod limitation; last-writer-wins on a
232246
sysctl key conflict — refused instead, matching the refuse-on-conflict

compose2pod/emit.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,18 @@ def run_flags(name: str, svc: dict[str, Any], pod: str, project_dir: str) -> lis
112112
# Generated by compose2pod -- do not edit, regenerate instead.
113113
set -eu
114114
115+
podman_version=$(podman version --format '{{.Client.Version}}' 2>/dev/null) || podman_version="unknown"
116+
podman_major=$(echo "$podman_version" | cut -d. -f1)
117+
case "$podman_major" in
118+
''|*[!0-9]*) ;; # unparseable -- skip rather than false-positive
119+
*) if [ "$podman_major" -lt 6 ]; then
120+
echo "warning: podman $podman_version detected; compose2pod requires podman >= 6.0.0" >&2
121+
echo "warning: podman < 6.0.0 has a bug where a container stopping in a multi-container" >&2
122+
echo "pod wipes /etc/hosts for the whole pod (fixed in 6.0.0) -- name resolution between" >&2
123+
echo "services may fail unpredictably" >&2
124+
fi ;;
125+
esac
126+
115127
wait_healthy() {
116128
ctr=$1
117129
attempts=$2
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
---
2+
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.
3+
---
4+
5+
# Design: podman version guard in the generated script
6+
7+
## Summary
8+
9+
Every script `emit_script` produces gets a small unconditional check,
10+
inlined into `_SCRIPT_HEADER` right after `set -eu`: it reads the podman
11+
client's major version and, if it is below 6, prints a two-line warning to
12+
stderr naming the actual version, the required version, and the underlying
13+
`/etc/hosts` bug — then continues running the script unchanged. It is a
14+
diagnostic aid, not a gate.
15+
16+
## Motivation
17+
18+
`README.md` and `architecture/supported-subset.md` already document
19+
"Requires Podman >= 6.0.0" (this session, debugging a real failure): podman
20+
before 6.0.0 has a bug where a container stopping inside a multi-container
21+
pod wipes `/etc/hosts` for every container in the pod, not just the one that
22+
stopped. compose2pod's pod-level `--add-host` design (see
23+
`2026-07-13.01-pod-level-add-host.md`) makes every generated script's
24+
`service_completed_successfully` dependency (e.g. a migration step that runs
25+
via `podman run --rm` and exits) a trigger for it. The failure this produces
26+
`could not translate host name "db" to address` deep inside an unrelated
27+
application, minutes after the script appeared to succeed — gives no hint
28+
that podman's version is the cause. A stated README requirement does nothing
29+
for someone who hits the bug at 2am and has never read the README. The
30+
script itself is where that information is actually useful.
31+
32+
## Design
33+
34+
`compose2pod/emit.py`'s `_SCRIPT_HEADER` constant gains this block,
35+
immediately after `set -eu` and before the `wait_healthy()` function
36+
definition, so it is the first thing every generated script does:
37+
38+
```sh
39+
podman_version=$(podman version --format '{{.Client.Version}}' 2>/dev/null) || podman_version="unknown"
40+
podman_major=$(echo "$podman_version" | cut -d. -f1)
41+
case "$podman_major" in
42+
''|*[!0-9]*) ;; # unparseable -- skip rather than false-positive
43+
*) if [ "$podman_major" -lt 6 ]; then
44+
echo "warning: podman $podman_version detected; compose2pod requires podman >= 6.0.0" >&2
45+
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
46+
fi ;;
47+
esac
48+
```
49+
50+
Only the major version is compared: 6.0.0 is an exact upstream fix
51+
boundary, not a range, so there is no minor/patch granularity to reason
52+
about. The check is unconditional — it runs for every script regardless of
53+
how many services are in the target's dependency closure — trading a
54+
theoretically-avoidable warning on a single-service pod (which cannot hit
55+
the underlying bug) for one code path with nothing to get wrong. It is
56+
inlined rather than wrapped in a named function like `wait_healthy()`:
57+
`wait_healthy` has multiple call sites (one per healthcheck-gated
58+
dependency), this check has exactly one, so a function would add indirection
59+
with no reuse to justify it.
60+
61+
The check degrades safely on anything unexpected: if `podman version
62+
--format` fails or is unsupported, `podman_version` becomes the literal
63+
string `"unknown"`, `cut -d. -f1` yields `"unknown"`, and the `case` pattern
64+
`*[!0-9]*` matches it — the check is silently skipped rather than raising
65+
under `set -eu` or false-warning on a version it could not parse.
66+
67+
It is a warning, not a gate: the script proceeds regardless of the podman
68+
version detected. A user who has confirmed their specific compose shape
69+
cannot hit the bug (or is mid-upgrade and accepts the risk) is not blocked.
70+
71+
## Non-goals
72+
73+
- **No escape hatch / suppression env var.** It is a non-blocking warning;
74+
there is nothing to bypass. Revisit only if this becomes reported as
75+
noisy in practice.
76+
- **No closure-shape detection** (e.g. skipping the check for
77+
single-service targets that can't hit the bug). Rejected for the same
78+
reason as the escape hatch: one unconditional code path is simpler than a
79+
correct-in-all-cases shape analysis, and the cost of an unnecessary
80+
warning is low.
81+
- **No minor/patch-level version comparison.** The fix boundary is a single
82+
major-version line (6.0.0); finer comparison buys nothing here.
83+
84+
## Testing
85+
86+
`just test-ci` at 100%:
87+
- `tests/test_emit.py`: `emit_script(...)` output contains the version-check
88+
block for a representative compose shape (asserts the `podman version
89+
--format` line and the `requires podman >= 6.0.0` message are present) —
90+
one assertion suffices since the block is unconditional and identical
91+
across every shape.
92+
- `tests/test_emit.py`: a subprocess-level test extracts just the version
93+
guard block from `_SCRIPT_HEADER` and runs it under `sh` with a stub
94+
`podman` script on `PATH` (via a `tmp_path` bin dir prepended to `PATH`)
95+
that prints a chosen version string. Cases: `5.8.1` → warning lines on
96+
stderr; `6.0.1` → silent, exit 0; garbage/empty output → silent, exit 0
97+
(proves the `set -eu` script doesn't abort on an unparseable version).
98+
99+
`just lint-ci` and `just check-planning` clean.
100+
101+
## Risk
102+
103+
- **False negative on a podman fork/vendor build with a non-numeric or
104+
differently-shaped `--format` output** (low x low): the `case` guard
105+
skips the check silently rather than warning wrong or crashing. Accepted:
106+
matches the "degrade safely" principle above.
107+
- **Warning noise on CI logs for users already on podman >= 6** (none): the
108+
check is silent whenever `podman_major -ge 6`, so this only affects users
109+
actually on the affected versions.

tests/test_emit.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1+
import os
2+
import shutil
3+
import subprocess
4+
from pathlib import Path
5+
16
import pytest
27

38
from compose2pod.emit import (
9+
_SCRIPT_HEADER,
410
EmitOptions,
511
_Expand,
612
command_tokens,
@@ -14,6 +20,9 @@
1420
from compose2pod.parsing import validate
1521

1622

23+
_SH = shutil.which("sh")
24+
25+
1726
class TestRunFlags:
1827
def test_db_flags(self, chats_compose: dict) -> None:
1928
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) -
406415
assert "podman healthcheck run" in script
407416
assert "wait_healthy()" in script
408417

418+
def test_podman_version_guard_present_in_header(self, chats_compose: dict) -> None:
419+
script = self.make_script(chats_compose)
420+
assert "podman version --format '{{.Client.Version}}'" in script
421+
assert "requires podman >= 6.0.0" in script
422+
version_guard_index = script.index("podman_version=")
423+
wait_healthy_index = script.index("wait_healthy()")
424+
set_eu_index = script.index("set -eu")
425+
assert set_eu_index < version_guard_index < wait_healthy_index
426+
409427
def test_hostname_becomes_add_host_entry(self) -> None:
410428
compose = {
411429
"services": {
@@ -733,3 +751,41 @@ def test_emit_script_accepts_a_valid_pod_name(self) -> None:
733751
doc = {"services": {"app": {"image": "x"}}}
734752
script = emit_script(compose=doc, options=self._options("test-pod.1"))
735753
assert "podman pod create --name test-pod.1" in script
754+
755+
756+
class TestPodmanVersionGuard:
757+
def _run_header(self, tmp_path: Path, podman_stub_body: str) -> "subprocess.CompletedProcess[str]":
758+
assert _SH is not None # sh is a POSIX baseline binary, always present
759+
bin_dir = tmp_path / "bin"
760+
bin_dir.mkdir()
761+
podman_stub = bin_dir / "podman"
762+
podman_stub.write_text(f"#!/bin/sh\n{podman_stub_body}\n")
763+
podman_stub.chmod(0o755)
764+
header_path = tmp_path / "header.sh"
765+
header_path.write_text(_SCRIPT_HEADER)
766+
env = dict(os.environ)
767+
env["PATH"] = f"{bin_dir}:{env['PATH']}"
768+
return subprocess.run( # noqa: S603 - _SH is an absolute path from shutil.which, not untrusted input
769+
[_SH, str(header_path)],
770+
capture_output=True,
771+
text=True,
772+
env=env,
773+
check=False,
774+
timeout=10,
775+
)
776+
777+
def test_warns_when_podman_major_below_six(self, tmp_path: Path) -> None:
778+
result = self._run_header(tmp_path, 'echo "5.8.1"')
779+
assert result.returncode == 0
780+
assert "podman 5.8.1 detected; compose2pod requires podman >= 6.0.0" in result.stderr
781+
assert "/etc/hosts" in result.stderr
782+
783+
def test_silent_when_podman_major_six_or_above(self, tmp_path: Path) -> None:
784+
result = self._run_header(tmp_path, 'echo "6.0.1"')
785+
assert result.returncode == 0
786+
assert result.stderr == ""
787+
788+
def test_silent_when_podman_version_unparseable(self, tmp_path: Path) -> None:
789+
result = self._run_header(tmp_path, "exit 1")
790+
assert result.returncode == 0
791+
assert result.stderr == ""

0 commit comments

Comments
 (0)