Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions architecture/supported-subset.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions compose2pod/emit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
109 changes: 109 additions & 0 deletions planning/changes/2026-07-13.06-podman-version-guard.md
Original file line number Diff line number Diff line change
@@ -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.
56 changes: 56 additions & 0 deletions tests/test_emit.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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")
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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 == ""