From 892f5b3a920173932cc5cffe800dd6a778b3eae6 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 20 Jul 2026 22:58:54 +0300 Subject: [PATCH 01/10] docs: design owning /etc/hosts via --no-hosts for podman 5.x --- ...026-07-20.01-own-etc-hosts-via-no-hosts.md | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 planning/changes/2026-07-20.01-own-etc-hosts-via-no-hosts.md diff --git a/planning/changes/2026-07-20.01-own-etc-hosts-via-no-hosts.md b/planning/changes/2026-07-20.01-own-etc-hosts-via-no-hosts.md new file mode 100644 index 0000000..4b5e7b7 --- /dev/null +++ b/planning/changes/2026-07-20.01-own-etc-hosts-via-no-hosts.md @@ -0,0 +1,106 @@ +--- +summary: Generated scripts own /etc/hosts (--no-hosts + a bind-mounted, generated hosts file) instead of pod-level --add-host, making pod-internal name resolution correct on every Podman including 5.x, and removing the Podman-6 requirement. +--- + +# Design: Own /etc/hosts via --no-hosts instead of --add-host + +## Summary + +The generated script stops using pod-level `--add-host` for pod-internal name +resolution. Instead it writes a complete `/etc/hosts` at run time, passes +`--no-hosts` to `podman pod create` and every `podman run`, and bind-mounts the +generated file read-only into each container (`-v "$hostsfile":/etc/hosts:ro,z`). +With `--no-hosts`, Podman never manages the hosts file, so the stop-path that +regenerates it never runs — which makes the behavior identical on every Podman +version and removes compose2pod's dependency on Podman >= 6.0.0. + +## Motivation + +compose2pod requires Podman >= 6.0.0 solely to dodge a bug: before 6.0.0, a +container *stopping* inside a multi-container pod made Podman rewrite the pod's +shared `/etc/hosts` and delete every entry whose line matched the stopping +container on IP `127.0.0.1` — wiping the whole `--add-host` set that is the +pod's only source of name resolution (see `2026-07-13.01-pod-level-add-host.md`, +`2026-07-13.06-podman-version-guard.md`). A `service_completed_successfully` +dependency (a migration that runs and exits) triggers it. + +Confirmed (this session, upstream research): the fix is Podman PR #28494 +(`libnetwork/etchosts` removal keys on IP + name match; commit `24130e2`), first +shipped in **6.0.0** and **not backported to any 5.x** — verified against the +`v5.8` release notes and tag-containment for 5.8.0–5.8.5. The bug is present in +every 5.x including the latest 5.8.5. Users pinned to a Fedora-based Podman 5.x +image (5.8.1 here; 6.x not on quay until ~Sep 2026) therefore cannot get a fixed +Podman and, today, silently lose name resolution. + +## Design + +`--no-hosts` **conflicts with** `--add-host` (verified against podman 5.8/6.0 +docs), so the two moves are one change: + +- **Pod:** `pod_create_flags` no longer emits the `--add-host` tokens; + `podman pod create` gains `--no-hosts`. dns/sysctl flags are unaffected. +- **Every service run** (target, `--rm` completion deps, `-d` long-running) + uniformly gains `--no-hosts` and `-v "$hostsfile":/etc/hosts:ro,z`. +- **Hosts file:** the script writes it once, before the first container, to a + `mktemp` path held in a shell var; the `trap ... EXIT` teardown appends + `rm -f "$hostsfile"`. Content: + + ``` + 127.0.0.1 localhost + ::1 localhost + 127.0.0.1 + + ``` + +The merge/conflict logic in `pod.py::_add_host_flags` (alias/hostname fixed at +`127.0.0.1`, `extra_hosts` order-scoped, a name on two addresses refused) is +kept verbatim but re-targeted from `--add-host NAME:IP` tokens to `IP NAME` +hosts-file lines. `extra_hosts` addresses may carry `${VAR}`, so the file is +written by emitted shell (the existing `Token`/`Expand` → `to_shell` machinery +renders into a `printf`/heredoc), not pre-rendered in Python. + +**`:z`** is mandatory: on SELinux-enforcing Fedora (the target), a bind-mounted +`/etc/hosts` is unreadable without a relabel — validated end-to-end in a Fedora +CoreOS VM, where the un-relabeled mount gave `Permission denied` and every +lookup failed. `z` (shared, the file is shared read-only across the pod) is a +no-op where SELinux is not enforcing, so it is safe everywhere. + +Validated on Podman 6.0.1 (Fedora CoreOS): db up, a completion container +resolves `db` then exits, and db's `/etc/hosts` is **byte-identical** across that +stop (podman never touches it) — the version-independent property this relies on. + +The Podman-6 apparatus is deleted, not lowered: the `_SCRIPT_HEADER` version +warning, `README.md`'s Requirements section, and the `architecture/ +supported-subset.md` "Requires Podman >= 6.0.0" note all go. This supersedes +`2026-07-13.06-podman-version-guard.md`. + +## Non-goals + +- **`host.containers.internal` / `host.docker.internal`.** Dropped: `--no-hosts` + means we cannot get Podman's computed gateway IP. Documented; a user needing + it adds an explicit `extra_hosts` entry. (Docker Compose on Linux does not add + them by default either.) +- **No version gating / probing.** One unconditional code path on every Podman, + matching the taste in `2026-07-13.06`. +- **A2 (surgical `--no-hosts` on completion containers only).** Rejected: rests + on unverified pod hosts-file sharing semantics and covers only the one known + stop trigger. + +## Testing + +- `just test-ci` at 100%. `tests/test_emit.py`: pod create carries `--no-hosts` + and no `--add-host`; every run form carries `--no-hosts` and the + `:/etc/hosts:ro,z` mount; the hosts-file write and its teardown `rm` are + present; alias, `hostname`, and `extra_hosts` (incl. a `${VAR}` address) land + as the right `IP NAME` lines; conflict cases still raise. +- `tests/test_pod.py`: the relocated merge/conflict logic (line output). +- Integration harness: a `service_completed_successfully` closure resolves a + peer by name after the completion container exits. + +## Risk + +- **Regression for existing 6.x users relying on `host.containers.internal`** + (low × low): documented, explicit `extra_hosts` workaround. +- **An image with no writable `/etc` layer** rejecting the `/etc/hosts` bind + mount (very low): a bind mount over a single path does not need a writable + layer; not observed in validation. From 09d977ad8715ba4afd488cef55e5e663b4996255 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 20 Jul 2026 23:13:30 +0300 Subject: [PATCH 02/10] refactor: produce /etc/hosts line tokens instead of --add-host flags --- compose2pod/pod.py | 40 ++++++++------ tests/test_pod.py | 127 +++++++++++++++++++++------------------------ 2 files changed, 81 insertions(+), 86 deletions(-) diff --git a/compose2pod/pod.py b/compose2pod/pod.py index 197a9e4..b4b61d5 100644 --- a/compose2pod/pod.py +++ b/compose2pod/pod.py @@ -131,14 +131,19 @@ def _sysctl_flags(services: dict[str, Any], order: list[str]) -> list[Token]: return tokens -def _add_host_flags(services: dict[str, Any], order: list[str], hosts: list[str]) -> list[Token]: - """Merge alias/hostname hosts (fixed 127.0.0.1) with extra_hosts (order-scoped) into one add-host set. - - A host name landing on two different addresses -- across services' extra_hosts, - or against an alias's fixed 127.0.0.1 -- is refused rather than guessed at, matching - the sysctls conflict rule below. Alias entries render as plain tokens (unquoted, as - before this move); extra_hosts entries render via `Expand` (as before, quoted/interpolated) - -- relocating the flags changes nothing else observable about either source. +_LOCALHOST_LINES: tuple[str, str] = ("127.0.0.1 localhost", "::1 localhost") + + +def hosts_file_tokens(services: dict[str, Any], order: list[str], hosts: list[str]) -> list[Token]: + """Render the pod's /etc/hosts as `IP NAME` line tokens (localhost lines first). + + compose2pod owns /etc/hosts: the generated script bind-mounts these lines + into every container under `--no-hosts`, replacing the former pod-level + `--add-host` set (which conflicts with `--no-hosts`). Merge/conflict rules + are unchanged from the add-host era: alias/hostname names are fixed at + 127.0.0.1, `extra_hosts` is order-scoped, and a name landing on two + addresses is refused. Alias lines are literal tokens; `extra_hosts` lines + render via `Expand` so a `${VAR}` address stays live at run time. """ merged: dict[str, str] = {} from_extra_hosts: set[str] = set() @@ -154,18 +159,19 @@ def _add_host_flags(services: dict[str, Any], order: list[str], hosts: list[str] raise UnsupportedComposeError(msg) merged[host] = addr from_extra_hosts.add(host) - tokens: list[Token] = [] + tokens: list[Token] = [*_LOCALHOST_LINES] for host, addr in merged.items(): - value = Expand(value=f"{host}:{addr}") if host in from_extra_hosts else f"{host}:{addr}" - tokens += ["--add-host", value] + line = f"{addr} {host}" + tokens.append(Expand(value=line) if host in from_extra_hosts else line) return tokens -def pod_create_flags(services: dict[str, Any], order: list[str], hosts: list[str]) -> list[Token]: - """Pod-create flag tokens aggregated across the closure `order`, plus `hosts` for add-host. +def pod_create_flags(services: dict[str, Any], order: list[str]) -> list[Token]: + """Pod-create flag tokens aggregated across the closure `order`. - Add-host is merged from alias/hostname resolution (`hosts`, fixed 127.0.0.1) and each - closure service's `extra_hosts`, conflict-checked (see `_add_host_flags`). dns/dns_search/ - dns_opt are unioned (dedup, first-seen order); sysctls are unioned by key. + dns/dns_search/dns_opt are unioned (dedup, first-seen order); sysctls are + unioned by key. Name resolution no longer rides on `--add-host` (it conflicts + with the `--no-hosts` the script now passes); it lives in the bind-mounted + hosts file -- see `hosts_file_tokens`. """ - return _add_host_flags(services, order, hosts) + _dns_flags(services, order) + _sysctl_flags(services, order) + return _dns_flags(services, order) + _sysctl_flags(services, order) diff --git a/tests/test_pod.py b/tests/test_pod.py index 43222b7..b7f4e58 100644 --- a/tests/test_pod.py +++ b/tests/test_pod.py @@ -3,7 +3,7 @@ from compose2pod.exceptions import UnsupportedComposeError from compose2pod.keys import Expand from compose2pod.parsing import validate -from compose2pod.pod import pod_create_flags, uses_pod_options, validate_pod_options +from compose2pod.pod import hosts_file_tokens, pod_create_flags, uses_pod_options, validate_pod_options class TestValidatePodOptions: @@ -87,11 +87,11 @@ def test_false_when_absent(self) -> None: class TestPodCreateFlags: def test_no_options_no_flags(self) -> None: - assert pod_create_flags({"a": {"image": "x"}}, ["a"], []) == [] + assert pod_create_flags({"a": {"image": "x"}}, ["a"]) == [] def test_dns_union_across_closure_dedup_first_seen(self) -> None: services = {"a": {"dns": ["1.1.1.1", "8.8.8.8"]}, "b": {"dns": "8.8.8.8"}} - assert pod_create_flags(services, ["a", "b"], []) == [ + assert pod_create_flags(services, ["a", "b"]) == [ "--dns", Expand(value="1.1.1.1"), "--dns", @@ -100,7 +100,7 @@ def test_dns_union_across_closure_dedup_first_seen(self) -> None: def test_dns_search_and_opt_flags(self) -> None: services = {"a": {"dns_search": "corp.internal", "dns_opt": ["ndots:2"]}} - assert pod_create_flags(services, ["a"], []) == [ + assert pod_create_flags(services, ["a"]) == [ "--dns-search", Expand(value="corp.internal"), "--dns-option", @@ -109,7 +109,7 @@ def test_dns_search_and_opt_flags(self) -> None: def test_sysctls_mapping_and_list_merge(self) -> None: services = {"a": {"sysctls": {"net.core.somaxconn": 1024}}, "b": {"sysctls": ["net.ipv4.tcp_syncookies=1"]}} - assert pod_create_flags(services, ["a", "b"], []) == [ + assert pod_create_flags(services, ["a", "b"]) == [ "--sysctl", Expand(value="net.core.somaxconn=1024"), "--sysctl", @@ -118,107 +118,96 @@ def test_sysctls_mapping_and_list_merge(self) -> None: def test_sysctls_same_key_same_value_merges(self) -> None: services = {"a": {"sysctls": {"net.x": "1"}}, "b": {"sysctls": {"net.x": 1}}} - assert pod_create_flags(services, ["a", "b"], []) == ["--sysctl", Expand(value="net.x=1")] + assert pod_create_flags(services, ["a", "b"]) == ["--sysctl", Expand(value="net.x=1")] def test_sysctls_same_key_conflict_refused(self) -> None: services = {"a": {"sysctls": {"net.x": "1"}}, "b": {"sysctls": {"net.x": "2"}}} with pytest.raises(UnsupportedComposeError, match=r"conflicting sysctl 'net.x'"): - pod_create_flags(services, ["a", "b"], []) + pod_create_flags(services, ["a", "b"]) def test_non_closure_service_options_ignored(self) -> None: services = {"a": {"image": "x"}, "extra": {"dns": ["9.9.9.9"]}} - assert pod_create_flags(services, ["a"], []) == [] + assert pod_create_flags(services, ["a"]) == [] -class TestAddHostFlags: - def test_alias_only_hosts_produce_add_host(self) -> None: - # Alias entries render as plain tokens (unquoted), matching pre-move `run_flags` behavior. +class TestHostsFileTokens: + def test_localhost_lines_always_first(self) -> None: + assert hosts_file_tokens({"a": {"image": "x"}}, ["a"], []) == [ + "127.0.0.1 localhost", + "::1 localhost", + ] + + def test_alias_hosts_render_as_literal_lines(self) -> None: services = {"a": {"image": "x"}} - assert pod_create_flags(services, ["a"], ["web", "db"]) == [ - "--add-host", - "web:127.0.0.1", - "--add-host", - "db:127.0.0.1", + assert hosts_file_tokens(services, ["a"], ["web", "db"]) == [ + "127.0.0.1 localhost", + "::1 localhost", + "127.0.0.1 web", + "127.0.0.1 db", ] - def test_extra_hosts_list_form(self) -> None: + def test_extra_hosts_list_form_renders_as_expand(self) -> None: services = {"a": {"extra_hosts": ["db:10.0.0.5"]}} - assert pod_create_flags(services, ["a"], []) == ["--add-host", Expand(value="db:10.0.0.5")] - - def test_extra_hosts_mapping_form(self) -> None: - services = {"a": {"extra_hosts": {"db": "10.0.0.5"}}} - assert pod_create_flags(services, ["a"], []) == ["--add-host", Expand(value="db:10.0.0.5")] - - def test_extra_hosts_boolean_value_renders_defensively_when_gate_is_bypassed(self) -> None: - # A boolean extra_hosts value is refused at the gate as of Task 10 - # (`TestValidatePodOptions.test_extra_hosts_map_bool_value_rejected`, - # measured: Docker itself rejects it, unlike environment/labels/ - # annotations) -- a real document carrying one never reaches this - # function. `_render_scalar` still normalizes it when called directly, - # bypassing the gate, as defense-in-depth (`keys.extra_host_entries`). - services = {"a": {"extra_hosts": {"db": True}}} - assert pod_create_flags(services, ["a"], []) == ["--add-host", Expand(value="db:true")] - - def test_extra_hosts_ipv6_value_keeps_colons(self) -> None: - services = {"a": {"extra_hosts": {"myhost": "2001:db8::1"}}} - assert pod_create_flags(services, ["a"], []) == [ - "--add-host", - Expand(value="myhost:2001:db8::1"), + assert hosts_file_tokens(services, ["a"], []) == [ + "127.0.0.1 localhost", + "::1 localhost", + Expand(value="10.0.0.5 db"), ] - def test_alias_and_extra_hosts_on_different_hosts_both_appear(self) -> None: + def test_extra_hosts_mapping_form_renders_as_expand(self) -> None: services = {"a": {"extra_hosts": {"db": "10.0.0.5"}}} - assert pod_create_flags(services, ["a"], ["web"]) == [ - "--add-host", - "web:127.0.0.1", - "--add-host", - Expand(value="db:10.0.0.5"), + assert hosts_file_tokens(services, ["a"], []) == [ + "127.0.0.1 localhost", + "::1 localhost", + Expand(value="10.0.0.5 db"), ] - def test_same_host_same_address_across_sources_dedups_silently(self) -> None: - services = {"a": {"extra_hosts": {"web": "127.0.0.1"}}} - assert pod_create_flags(services, ["a"], ["web"]) == ["--add-host", Expand(value="web:127.0.0.1")] - - def test_conflicting_extra_hosts_across_services_refused(self) -> None: - services = {"a": {"extra_hosts": {"db": "10.0.0.5"}}, "b": {"extra_hosts": {"db": "10.0.0.6"}}} - with pytest.raises(UnsupportedComposeError, match="conflicting host"): - pod_create_flags(services, ["a", "b"], []) + def test_extra_hosts_variable_address_stays_live(self) -> None: + services = {"a": {"extra_hosts": {"db": "${DB_IP}"}}} + assert hosts_file_tokens(services, ["a"], []) == [ + "127.0.0.1 localhost", + "::1 localhost", + Expand(value="${DB_IP} db"), + ] - def test_conflicting_alias_and_extra_hosts_refused(self) -> None: - services = {"a": {"extra_hosts": {"web": "10.0.0.5"}}} - with pytest.raises(UnsupportedComposeError, match="conflicting host"): - pod_create_flags(services, ["a"], ["web"]) + def test_conflicting_address_for_same_host_refused(self) -> None: + services = {"a": {"extra_hosts": ["db:10.0.0.5"]}, "b": {"extra_hosts": ["db:10.0.0.9"]}} + with pytest.raises(UnsupportedComposeError, match=r"conflicting host 'db'"): + hosts_file_tokens(services, ["a", "b"], []) - def test_non_closure_service_extra_hosts_ignored(self) -> None: - services = {"a": {"image": "x"}, "extra": {"extra_hosts": {"db": "10.0.0.5"}}} - assert pod_create_flags(services, ["a"], []) == [] + def test_alias_vs_extra_hosts_address_conflict_refused(self) -> None: + # An alias fixes db at 127.0.0.1; extra_hosts wanting another address is refused. + services = {"a": {"extra_hosts": ["db:10.0.0.5"]}} + with pytest.raises(UnsupportedComposeError, match=r"conflicting host 'db'"): + hosts_file_tokens(services, ["a"], ["db"]) class TestExtraHostsSeparators: """Compose documents 'host=ip'; the legacy 'host:ip' also works. Both must.""" - def _flags(self, entries: object) -> list[str]: + def _lines(self, entries: object) -> list[str]: services = {"a": {"image": "x", "extra_hosts": entries}} # pod.py never emits a GuardedEnvFile (only emit.py's env_file path does), so this # is always str at runtime; the Token union just doesn't narrow that far statically. - return [t.value if isinstance(t, Expand) else t for t in pod_create_flags(services, ["a"], [])] # ty: ignore[invalid-return-type] + tokens = hosts_file_tokens(services, ["a"], [])[2:] # drop the two fixed localhost lines + return [t.value if isinstance(t, Expand) else t for t in tokens] # ty: ignore[invalid-return-type] def test_equals_separator_is_split_correctly(self) -> None: - # Used to emit the whole string as the hostname: 'somehost=1.2.3.4:' - assert self._flags(["somehost=162.242.195.82"]) == ["--add-host", "somehost:162.242.195.82"] + # Used to emit the whole string as the hostname: '1.2.3.4: somehost' + assert self._lines(["somehost=162.242.195.82"]) == ["162.242.195.82 somehost"] def test_colon_separator_still_works(self) -> None: - assert self._flags(["somehost:162.242.195.82"]) == ["--add-host", "somehost:162.242.195.82"] + assert self._lines(["somehost:162.242.195.82"]) == ["162.242.195.82 somehost"] def test_equals_separator_keeps_ipv6(self) -> None: # '=' wins over ':' precisely because an IPv6 address is full of colons. - assert self._flags(["myhostv6=::1"]) == ["--add-host", "myhostv6:::1"] + assert self._lines(["myhostv6=::1"]) == ["::1 myhostv6"] def test_colon_separator_keeps_ipv6(self) -> None: - assert self._flags(["myhost:::1"]) == ["--add-host", "myhost:::1"] + assert self._lines(["myhost:::1"]) == ["::1 myhost"] def test_mapping_form_is_unchanged(self) -> None: - assert self._flags({"somehost": "162.242.195.82"}) == ["--add-host", "somehost:162.242.195.82"] + assert self._lines({"somehost": "162.242.195.82"}) == ["162.242.195.82 somehost"] def test_entry_with_no_separator_is_refused(self) -> None: # Used to be accepted and emit the malformed '--add-host "no-separator:"' @@ -226,9 +215,9 @@ def test_entry_with_no_separator_is_refused(self) -> None: validate_pod_options("a", {"image": "x", "extra_hosts": ["no-separator"]}) def test_mapping_value_containing_an_equals_is_not_re_split(self) -> None: - # The mapping form arrives already divided. Joining it into 'host:address' + # The mapping form arrives already divided. Joining it into 'address host' # and re-splitting would divide at the '=' instead. - assert self._flags({"somehost": "weird=address"}) == ["--add-host", "somehost:weird=address"] + assert self._lines({"somehost": "weird=address"}) == ["weird=address somehost"] def test_dns_opt_must_be_a_list() -> None: From f6e68ab5a57c4c5c655d75eb51c6a416dd7bd2bc Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 20 Jul 2026 23:17:56 +0300 Subject: [PATCH 03/10] test: restore merge-behavior regression coverage in TestHostsFileTokens Review found the task-1 brief's snippet dropped three merge-behavior regressions covered by the old TestAddHostFlags: alias/extra_hosts coexisting on different hosts, the same-host/same-address dedup that flips a host's token from literal to Expand, and non-closure services being ignored. --- tests/test_pod.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/test_pod.py b/tests/test_pod.py index b7f4e58..f2609c7 100644 --- a/tests/test_pod.py +++ b/tests/test_pod.py @@ -170,6 +170,33 @@ def test_extra_hosts_variable_address_stays_live(self) -> None: Expand(value="${DB_IP} db"), ] + def test_alias_and_extra_hosts_on_different_hosts_both_appear(self) -> None: + services = {"a": {"extra_hosts": ["db:10.0.0.5"]}} + assert hosts_file_tokens(services, ["a"], ["web"]) == [ + "127.0.0.1 localhost", + "::1 localhost", + "127.0.0.1 web", + Expand(value="10.0.0.5 db"), + ] + + def test_same_host_same_address_across_sources_dedups_and_renders_as_expand(self) -> None: + # An alias fixes "web" at 127.0.0.1; extra_hosts restating the same + # address doesn't conflict, but it does flip which token form wins -- + # extra_hosts membership renders as Expand, not the alias's literal. + services = {"a": {"extra_hosts": {"web": "127.0.0.1"}}} + assert hosts_file_tokens(services, ["a"], ["web"]) == [ + "127.0.0.1 localhost", + "::1 localhost", + Expand(value="127.0.0.1 web"), + ] + + def test_non_closure_service_extra_hosts_ignored(self) -> None: + services = {"a": {"image": "x"}, "extra": {"extra_hosts": ["db:10.0.0.5"]}} + assert hosts_file_tokens(services, ["a"], []) == [ + "127.0.0.1 localhost", + "::1 localhost", + ] + def test_conflicting_address_for_same_host_refused(self) -> None: services = {"a": {"extra_hosts": ["db:10.0.0.5"]}, "b": {"extra_hosts": ["db:10.0.0.9"]}} with pytest.raises(UnsupportedComposeError, match=r"conflicting host 'db'"): From da55b99184e90a0ac1e23aee409219ff6f2070b7 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 20 Jul 2026 23:22:43 +0300 Subject: [PATCH 04/10] feat: own /etc/hosts via --no-hosts + bind mount instead of --add-host --- compose2pod/emit.py | 22 ++++++++++++++++------ tests/test_emit.py | 45 ++++++++++++++++++++++++++++++--------------- 2 files changed, 46 insertions(+), 21 deletions(-) diff --git a/compose2pod/emit.py b/compose2pod/emit.py index 617ecae..42bb34a 100644 --- a/compose2pod/emit.py +++ b/compose2pod/emit.py @@ -12,7 +12,7 @@ from compose2pod.healthcheck import health_cmd, interval_seconds from compose2pod.keys import SERVICE_KEYS, Expand, GuardedEnvFile, Token from compose2pod.parsing import validate -from compose2pod.pod import pod_create_flags +from compose2pod.pod import hosts_file_tokens, pod_create_flags from compose2pod.resources import deploy_resource_flags from compose2pod.shell import to_shell, variable_names from compose2pod.values import as_bool @@ -161,6 +161,11 @@ def run_flags(name: str, svc: dict[str, Any], pod: str, project_dir: str) -> lis return flags +# Every container mounts the script-owned /etc/hosts read-only. `--no-hosts` +# stops podman managing the file (so the pre-6.0.0 stop-wipe never runs); `z` +# relabels for SELinux (mandatory on Fedora, a no-op elsewhere). +_HOSTS_MOUNT = '--no-hosts -v "$hostsfile":/etc/hosts:ro,z' + _SCRIPT_HEADER = """\ #!/bin/sh # Generated by compose2pod -- do not edit, regenerate instead. @@ -277,7 +282,7 @@ def _emit_target(lines: list[str], tokens: list[Token], options: EmitOptions) -> target_ctr = shlex.quote(f"{options.pod}-{options.target}") lines.append("rc=0") lines.extend(_env_file_preludes(tokens)) - lines.append(f"podman run {_render(tokens)} || rc=$?") + lines.append(f"podman run {_HOSTS_MOUNT} {_render(tokens)} || rc=$?") for artifact in options.artifacts: source, destination = artifact.split(":", 1) lines.append(f"podman cp {target_ctr}:{shlex.quote(source)} {shlex.quote(destination)} || true") @@ -343,6 +348,7 @@ def _plan(compose: dict[str, Any], options: EmitOptions) -> PlannedScript: # pointing at 127.0.0.1 for a service that never runs would turn an honest # resolution failure into a connection-refused. hosts = hostnames({name: services[name] for name in order}) + host_tokens = hosts_file_tokens(services, order, hosts) completion_gated = { dep for svc in services.values() @@ -352,17 +358,21 @@ def _plan(compose: dict[str, Any], options: EmitOptions) -> PlannedScript: names: set[str] = set() lines = [_SCRIPT_HEADER] + lines.append("hostsfile=$(mktemp)") teardown = f"podman pod rm -f {shlex.quote(options.pod)} >/dev/null 2>&1 || true" store_teardown = stores.teardown_line(compose, order, options.pod) if store_teardown: teardown += f"; {store_teardown}" + teardown += '; rm -f "$hostsfile"' lines.append(f"trap '{teardown}' EXIT") - pod_flags = pod_create_flags(services, order, hosts) + pod_flags = pod_create_flags(services, order) _collect_vars(pod_flags, names) - pod_create = f"podman pod create --name {shlex.quote(options.pod)}" + pod_create = f"podman pod create --name {shlex.quote(options.pod)} --no-hosts" if pod_flags: pod_create += " " + _render(pod_flags) lines.append(pod_create) + _collect_vars(host_tokens, names) + lines.append(f"printf '%s\\n' {_render(host_tokens)} > \"$hostsfile\"") lines.extend(stores.create_lines(compose, order, options.pod, options.project_dir)) names |= stores.referenced_variables(compose, order, options.project_dir) waited: set[str] = set() @@ -379,10 +389,10 @@ def _plan(compose: dict[str, Any], options: EmitOptions) -> PlannedScript: _emit_target(lines, tokens, options) elif name in completion_gated: lines.extend(_env_file_preludes(tokens)) - lines.append(f"podman run --rm {_render(tokens)}") + lines.append(f"podman run --rm {_HOSTS_MOUNT} {_render(tokens)}") else: lines.extend(_env_file_preludes(tokens)) - lines.append(f"podman run -d {_render(tokens)}") + lines.append(f"podman run -d {_HOSTS_MOUNT} {_render(tokens)}") return PlannedScript(script="\n".join(lines) + "\n", variables=sorted(names)) diff --git a/tests/test_emit.py b/tests/test_emit.py index 2f6bcf0..839f1ad 100644 --- a/tests/test_emit.py +++ b/tests/test_emit.py @@ -644,13 +644,26 @@ def test_failure_branch_prints_oom_diagnostics(self, chats_compose: dict) -> Non assert "OOMKilled={{.State.OOMKilled}}" in script[gate:] assert "podman ps -a" in script[gate:] - def test_add_host_becomes_pod_level(self, chats_compose: dict) -> None: + def test_pod_create_carries_no_hosts_and_no_add_host(self, chats_compose: dict) -> None: script = self.make_script(chats_compose) pod_create = next(line for line in script.splitlines() if line.startswith("podman pod create")) - assert "--add-host keydb-test-server-0:127.0.0.1" in pod_create - run_lines = [line for line in script.splitlines() if line.startswith("podman run")] - for line in run_lines: - assert "--add-host" not in line + assert "--no-hosts" in pod_create + assert "--add-host" not in script + + def test_hosts_file_written_and_mounted(self, chats_compose: dict) -> None: + script = self.make_script(chats_compose) + assert "hostsfile=$(mktemp)" in script + printf_line = next(line for line in script.splitlines() if line.startswith("printf '%s\\n'")) + assert "127.0.0.1 localhost" in printf_line + assert "127.0.0.1 keydb-test-server-0" in printf_line + assert printf_line.rstrip().endswith('> "$hostsfile"') + for line in [ln for ln in script.splitlines() if ln.startswith("podman run")]: + assert '--no-hosts -v "$hostsfile":/etc/hosts:ro,z' in line + + def test_hostsfile_removed_on_teardown(self, chats_compose: dict) -> None: + script = self.make_script(chats_compose) + trap = next(line for line in script.splitlines() if line.startswith("trap ")) + assert 'rm -f "$hostsfile"' in trap def test_wait_healthy_function_uses_healthcheck_run(self, chats_compose: dict) -> None: script = self.make_script(chats_compose) @@ -684,7 +697,7 @@ def test_hostname_becomes_add_host_entry(self) -> None: ) assert validate(compose) == [] script = emit_script(compose=compose, options=options) - assert "--add-host keydb-test-server-0:127.0.0.1" in script + assert "127.0.0.1 keydb-test-server-0" in script def test_container_name_becomes_add_host_entry(self) -> None: compose = { @@ -703,7 +716,7 @@ def test_container_name_becomes_add_host_entry(self) -> None: ) assert validate(compose) == [] script = emit_script(compose=compose, options=options) - assert "--add-host calutron-ronline:127.0.0.1" in script + assert "127.0.0.1 calutron-ronline" in script def test_named_volume_round_trips_through_validate_and_emit(self) -> None: compose = { @@ -808,7 +821,7 @@ def test_image_host_metadata_keys_compose_on_one_service(self) -> None: '--platform "linux/amd64"', '--device "/dev/fuse"', '--annotation "team=api"', - '--add-host "db.local:10.0.0.5"', + '"10.0.0.5 db.local"', "--pull missing", ): assert fragment in script @@ -822,15 +835,17 @@ def test_ulimits_compose_through_emit_script(self) -> None: def test_pod_create_carries_dns_and_sysctl_flags(self) -> None: svc = {"image": "x", "dns": ["1.1.1.1", "8.8.8.8"], "sysctls": {"net.core.somaxconn": 1024}} script = self._single(svc) - # `app` (the service's own name) is always a self-alias, so it precedes dns/sysctls. - assert 'podman pod create --name p --add-host app:127.0.0.1 --dns "1.1.1.1" --dns "8.8.8.8"' in script + # `--no-hosts` always precedes dns/sysctls now that /etc/hosts is script-owned. + assert 'podman pod create --name p --no-hosts --dns "1.1.1.1" --dns "8.8.8.8"' in script assert '--sysctl "net.core.somaxconn=1024"' in script def test_pod_create_carries_only_self_alias_without_pod_options(self) -> None: # A service's own name is always a resolvable alias (`graph.hostnames`), so even - # with no dns/sysctls/extra_hosts declared, pod create still carries its add-host. + # with no dns/sysctls/extra_hosts declared, the alias still lands -- now in the + # hosts file rather than on pod create. script = self._single({"image": "x"}) - assert "podman pod create --name p --add-host app:127.0.0.1\n" in script + assert "podman pod create --name p --no-hosts\n" in script + assert "127.0.0.1 app" in script def test_env_file_required_false_is_guarded(self) -> None: svc = {"image": "x", "env_file": [{"path": "opt.env", "required": False}]} @@ -1168,7 +1183,7 @@ def test_service_outside_the_closure_is_not_resolvable(self) -> None: # A never-run service pointed its name at 127.0.0.1, where nothing listens. compose = {"services": {"app": {"image": "x"}, "never_run": {"image": "x"}}} script = emit_script(compose=compose, options=self._options("app")) - assert "--add-host app:127.0.0.1" in script + assert "127.0.0.1 app" in script assert "never_run" not in script def test_out_of_closure_hostname_does_not_veto_extra_hosts(self) -> None: @@ -1180,7 +1195,7 @@ def test_out_of_closure_hostname_does_not_veto_extra_hosts(self) -> None: } } script = emit_script(compose=compose, options=self._options("app")) - assert '--add-host "db:1.2.3.4"' in script + assert '"1.2.3.4 db"' in script def test_in_closure_hostname_still_conflicts_with_extra_hosts(self) -> None: # The conflict rule still holds for services that actually run. @@ -1204,7 +1219,7 @@ def test_dependency_hostnames_and_aliases_still_resolve(self) -> None: } script = emit_script(compose=compose, options=self._options("app")) for host in ("app", "db", "db-host", "db-alias"): - assert f"--add-host {host}:127.0.0.1" in script + assert f"127.0.0.1 {host}" in script class TestGuardedEnvFileDependencyWiring: From 5a0360d7b17c9da7980a3c9a674decc406fbf83b Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 20 Jul 2026 23:43:55 +0300 Subject: [PATCH 05/10] feat: drop the podman >= 6.0.0 version-guard warning --- compose2pod/emit.py | 12 ----------- tests/test_emit.py | 49 ++++----------------------------------------- 2 files changed, 4 insertions(+), 57 deletions(-) diff --git a/compose2pod/emit.py b/compose2pod/emit.py index 42bb34a..fcc0990 100644 --- a/compose2pod/emit.py +++ b/compose2pod/emit.py @@ -171,18 +171,6 @@ 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/tests/test_emit.py b/tests/test_emit.py index 839f1ad..036fe46 100644 --- a/tests/test_emit.py +++ b/tests/test_emit.py @@ -670,14 +670,11 @@ 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: + def test_no_podman_version_guard(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 + assert "podman version --format" not in script + assert "requires podman" not in script + assert "podman_major" not in script def test_hostname_becomes_add_host_entry(self) -> None: compose = { @@ -1084,44 +1081,6 @@ def test_valid_int_allow_exit_codes_still_accepted(self, chats_compose: dict) -> assert "in\n 0|1|2|5) ;;" 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 == "" - - class TestPublicEntryPointsValidateWithoutBeingToldTo: """Both public entry points must reject malformed input on their own. From 9f2aeeaeba8d0904d3f54e28adab10968904a94c Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Tue, 21 Jul 2026 09:40:34 +0300 Subject: [PATCH 06/10] test: drop imports orphaned by version-guard removal --- tests/test_emit.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/tests/test_emit.py b/tests/test_emit.py index 036fe46..438e2f6 100644 --- a/tests/test_emit.py +++ b/tests/test_emit.py @@ -1,12 +1,6 @@ -import os -import shutil -import subprocess -from pathlib import Path - import pytest from compose2pod.emit import ( - _SCRIPT_HEADER, EmitOptions, Expand, GuardedEnvFile, @@ -22,9 +16,6 @@ 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") From 96d41043cad06cf9317808dfe1667c1cb029b15d Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Tue, 21 Jul 2026 09:46:34 +0300 Subject: [PATCH 07/10] docs: promote owned-/etc/hosts model to README and architecture --- README.md | 15 +++++---- architecture/supported-subset.md | 54 +++++++++++++++++++------------- 2 files changed, 40 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 57f07db..c59f66b 100644 --- a/README.md +++ b/README.md @@ -22,19 +22,18 @@ Convert a Docker Compose file into a POSIX `sh` script that runs its services as Built for CI and test environments where you can't use `docker compose` or `podman kube play`: -- **No bridge networking / netavark.** Unprivileged CI containers often have a read-only `/proc/sys`, so netavark fails to create bridge networks. A single pod shares one network namespace with no bridge: services talk over `127.0.0.1`, and names resolve via `--add-host`. +- **No bridge networking / netavark.** Unprivileged CI containers often have a read-only `/proc/sys`, so netavark fails to create bridge networks. A single pod shares one network namespace with no bridge: services talk over `127.0.0.1`, and names resolve via a generated `/etc/hosts` the script owns. - **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. +compose2pod's generated scripts own `/etc/hosts`: they write it to a temp +file and bind-mount it read-only into every container under `--no-hosts` +(see `architecture/supported-subset.md`), so pod-internal name resolution +works on any Podman version. `host.containers.internal` / +`host.docker.internal` are not provided — add an explicit `extra_hosts` +entry if you need them. ## Install diff --git a/architecture/supported-subset.md b/architecture/supported-subset.md index c418742..523f9aa 100644 --- a/architecture/supported-subset.md +++ b/architecture/supported-subset.md @@ -398,8 +398,9 @@ slot (`architecture/glossary.md`). own flag. No format validation beyond shape; a malformed option string surfaces as a podman error at run time. - **`hostname` and `container_name`:** each made resolvable to `127.0.0.1` - like a network alias (added to the shared `--add-host` set, see Pod-level - options), so other services can reach the service by either name. The pod + like a network alias (added to the shared, script-owned `/etc/hosts`, see + Pod-level options), so other services can reach the service by either + name. The pod shares the UTS namespace, so a service's own hostname is the pod's; the actual podman container is always named `{pod}-{service}` regardless of `container_name` (used internally for `podman cp`, healthcheck polling, @@ -536,7 +537,8 @@ flags. compose2pod hoists them onto `podman pod create` instead - **Supported:** `dns`, `dns_search`, `dns_opt`, `sysctls`, `extra_hosts` — mapped to `--dns`, `--dns-search`, `--dns-option`, `--sysctl`, and (merged - with the alias/hostname set) `--add-host` respectively. + with the alias/hostname set) lines in the pod's owned `/etc/hosts` + respectively. - **Value shapes:** `dns`/`dns_search` accept a string or list of strings; `dns_opt` accepts a list of strings only — Docker refuses a bare string there (`dns_opt: "ndots:2"`) even though it accepts one for `dns` and @@ -556,7 +558,7 @@ flags. compose2pod hoists them onto `podman pod create` instead emitter, and the `extends` merge — goes through the one shared `keys.split_extra_host`, so they cannot disagree about where an entry divides. An entry with neither separator has no address and raises, rather - than emitting a malformed `--add-host`. A `${VAR}` inside a + than emitting a malformed hosts-file line. A `${VAR}` inside a value stays live at run time (see Variable interpolation, below) and counts toward `referenced_variables`. - **Aggregation is closure-scoped:** computed over the target's dependency @@ -564,12 +566,12 @@ flags. compose2pod hoists them onto `podman pod create` instead `dns_opt` are unioned (deduplicated, first-seen order); `sysctls` are unioned by key, and two closure services setting the same key to different values is refused (`conflicting sysctl ...`) rather than - resolved last-writer-wins. `--add-host` is seeded from the alias/hostname - set of the closure's services, then layered with each closure service's - `extra_hosts`; a host name landing on two different addresses is refused - the same way (`conflicting host ...`). An alias/hostname `--add-host` - entry stays a plain unquoted token; an `extra_hosts` entry is - `${VAR}`-live. + resolved last-writer-wins. The hosts file is seeded from the alias/hostname + set of the closure's services (each mapped to `127.0.0.1`), then layered + with each closure service's `extra_hosts` (each mapped to its address); a + host name landing on two different addresses is refused the same way + (`conflicting host ...`). An alias/hostname line stays a plain unquoted + token; an `extra_hosts` line is `${VAR}`-live. Only the closure joins the pod, so only the closure is resolvable: a service outside it contributes no name and cannot conflict with an @@ -587,16 +589,26 @@ flags. compose2pod hoists them onto `podman pod create` instead that service is in the target's closure; at emit time a declaration on a service outside the closure is silently ignored — no flag for it, since that service never runs. -- **Requires Podman >= 6.0.0.** Before 6.0.0, a container stopping inside a - multi-container pod wiped `/etc/hosts` for every container in the pod. - Because `--add-host` here is the pod's *only* source of `/etc/hosts` - entries, a `service_completed_successfully` dependency (a container that - runs to completion and exits, e.g. a migration step) 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. The generated script checks - `podman version` at startup and warns on stderr (without blocking) below - major version 6, so the requirement is visible at the point of failure, - not only in the docs. See `README.md`'s Requirements section. +- **The script owns `/etc/hosts`, not Podman.** `pod_create_flags` no longer + emits `--add-host`; instead `hosts_file_tokens` (`compose2pod/pod.py`) + renders the merged alias/hostname/`extra_hosts` set as `IP NAME` lines, + prefixed with `127.0.0.1 localhost` and `::1 localhost`. The script writes + those lines to a `mktemp` path (`hostsfile=$(mktemp)`) once, before the + first container starts, and removes it in the `trap ... EXIT` teardown + (`rm -f "$hostsfile"`). `podman pod create` and every `podman run` — + target, `--rm` completion dependency, and `-d` long-running alike — pass + `--no-hosts` and bind-mount the file read-only: + `-v "$hostsfile":/etc/hosts:ro,z`. `--no-hosts` and `--add-host` conflict, + so the two moves are one change. `z` relabels the mount for SELinux (a + no-op where SELinux is not enforcing) — without it, a bind-mounted + `/etc/hosts` is unreadable on an SELinux-enforcing host. Because + `--no-hosts` stops Podman from managing `/etc/hosts` at all, the pre-6.0.0 + bug where a container stopping inside a pod wiped the shared `/etc/hosts` + for every other container cannot fire — compose2pod works identically on + every Podman version, with no version floor. `host.containers.internal` / + `host.docker.internal` are not provided: `--no-hosts` means Podman's + computed gateway IP is unavailable, so a service needing it must add an + explicit `extra_hosts` entry (see `README.md`'s Requirements section). - **Non-goals:** per-service DNS/sysctls — impossible inside a shared-namespace pod, not a compose2pod limitation; last-writer-wins on a sysctl or host conflict — refused instead. @@ -971,7 +983,7 @@ truth if this enumeration ever appears to drift: Everything else is never interpolated: `build`'s own contents (never read), `depends_on`, `networks`, `hostname`, and `container_name` (the -last two are emitted as literal `--add-host host:127.0.0.1` entries, not +last two are emitted as literal `127.0.0.1 host` hosts-file lines, not expanded), and the healthcheck `timeout`/`start_period`/`retries` numbers. Supported forms: `$VAR`, `${VAR}`, `${VAR:-default}`, `${VAR-default}`, From eef45c4a77cf413c58a902a0ed17076edb79d4c3 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Tue, 21 Jul 2026 09:46:34 +0300 Subject: [PATCH 08/10] test: reword add-host references to reflect owned hosts-file mechanism --- tests/integration/test_network_aliases.py | 2 +- tests/integration/test_pod_level_options.py | 8 ++++---- tests/test_emit.py | 10 +++++----- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/integration/test_network_aliases.py b/tests/integration/test_network_aliases.py index fa04fdb..80736ec 100644 --- a/tests/integration/test_network_aliases.py +++ b/tests/integration/test_network_aliases.py @@ -19,5 +19,5 @@ def test_network_alias_lands_on_the_pod(run_pod: Callable[..., PodRun]) -> None: run = run_pod(compose, target="app") # Exit 0 proves the long-form networks.aliases entry (a distinct code path # from hostname/container_name in graph.py's _host_names) reaches the - # pod-level --add-host set, resolving to 127.0.0.1 like every other alias. + # script-owned /etc/hosts file, resolving to 127.0.0.1 like every other alias. assert run.returncode == 0, run.stderr diff --git a/tests/integration/test_pod_level_options.py b/tests/integration/test_pod_level_options.py index 4587866..6d67ff4 100644 --- a/tests/integration/test_pod_level_options.py +++ b/tests/integration/test_pod_level_options.py @@ -25,10 +25,10 @@ def test_pod_level_options_land_on_the_pod(run_pod: Callable[..., PodRun]) -> No }, } run = run_pod(compose, target="app") - # Exit 0 proves all three landed on the pod (podman pod create), not the - # container -- exactly the merge that regressed in the add-host bug. - # /etc/hosts is "iphostname" (podman's --add-host FLAG syntax - # is "host:ip", but the file it writes is the other order, unquoted) -- + # Exit 0 proves all three landed on the pod (podman pod create) or the + # script-owned /etc/hosts, not the container's own state -- exactly the + # merge that regressed in the historical add-host bug. The owned + # /etc/hosts file is written as "iphostname" lines -- # checked as two independent substrings rather than one combined pattern # to stay robust to the exact separator. assert run.returncode == 0, run.stderr diff --git a/tests/test_emit.py b/tests/test_emit.py index 438e2f6..01461d2 100644 --- a/tests/test_emit.py +++ b/tests/test_emit.py @@ -667,7 +667,7 @@ def test_no_podman_version_guard(self, chats_compose: dict) -> None: assert "requires podman" not in script assert "podman_major" not in script - def test_hostname_becomes_add_host_entry(self) -> None: + def test_hostname_becomes_hosts_file_entry(self) -> None: compose = { "services": { "application": {"image": "app", "depends_on": ["keydb"]}, @@ -687,7 +687,7 @@ def test_hostname_becomes_add_host_entry(self) -> None: script = emit_script(compose=compose, options=options) assert "127.0.0.1 keydb-test-server-0" in script - def test_container_name_becomes_add_host_entry(self) -> None: + def test_container_name_becomes_hosts_file_entry(self) -> None: compose = { "services": { "application": {"image": "app", "container_name": "calutron-ronline"}, @@ -1115,8 +1115,8 @@ def test_referenced_variables_is_equally_guarded(self) -> None: referenced_variables({}, self._options()) -class TestAddHostClosureScope: - """--add-host covers the target's closure, like every other emit-path aggregate.""" +class TestHostsFileClosureScope: + """The owned /etc/hosts file covers the target's closure, like every other emit-path aggregate.""" def _options(self, target: str) -> EmitOptions: return EmitOptions( @@ -1159,7 +1159,7 @@ def test_in_closure_hostname_still_conflicts_with_extra_hosts(self) -> None: emit_script(compose=compose, options=self._options("app")) def test_dependency_hostnames_and_aliases_still_resolve(self) -> None: - # Everything inside the closure keeps its add-host entry. + # Everything inside the closure keeps its hosts-file entry. compose = { "services": { "app": {"image": "x", "depends_on": ["db"]}, From a6cd2b9cbd8b03700bb455812d0176e7b68556aa Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Tue, 21 Jul 2026 09:51:36 +0300 Subject: [PATCH 09/10] docs: correct extra_hosts validator docstrings for hosts-file rendering --- compose2pod/pod.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/compose2pod/pod.py b/compose2pod/pod.py index b4b61d5..0f67b1a 100644 --- a/compose2pod/pod.py +++ b/compose2pod/pod.py @@ -47,8 +47,8 @@ def _check_extra_host_separators(name: str, value: Any) -> None: # noqa: ANN401 """Check each list-form entry actually divides into a host and an address. An entry with neither separator has no address to emit, and would render as - a malformed `--add-host "no-separator:"`. The mapping form cannot have this - problem: its key and value are already separate. + a malformed hosts-file line (a name with no address). The mapping form cannot + have this problem: its key and value are already separate. """ if not isinstance(value, list): return @@ -65,7 +65,7 @@ def _check_extra_host_value_types(name: str, value: Any) -> None: # noqa: ANN40 number, boolean, or null -- correct for `labels`/`annotations`' bare-key- means-null semantics, but too loose for `extra_hosts`: an int/bool/null address would otherwise reach `keys.extra_host_entries` and get coerced - into a bogus `--add-host` value. Measured against `docker compose config` + into a bogus hosts-file line. Measured against `docker compose config` v5.1.2: `extra_hosts: {h: 3}` and `extra_hosts: {h: true}` are both refused ("services..extra_hosts.h must be a string"). The list form cannot have this problem: `validate_map` already requires every list From 3ae9d11982467d5704b493ca3d20f9f3f113e79c Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Tue, 21 Jul 2026 10:31:14 +0300 Subject: [PATCH 10/10] test: pin that the hosts file is written before the first podman run --- tests/test_emit.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_emit.py b/tests/test_emit.py index 01461d2..3aa5ca2 100644 --- a/tests/test_emit.py +++ b/tests/test_emit.py @@ -651,6 +651,16 @@ def test_hosts_file_written_and_mounted(self, chats_compose: dict) -> None: for line in [ln for ln in script.splitlines() if ln.startswith("podman run")]: assert '--no-hosts -v "$hostsfile":/etc/hosts:ro,z' in line + def test_hosts_file_written_before_first_run(self, chats_compose: dict) -> None: + # Every container bind-mounts $hostsfile, so the file must be written + # before any `podman run` mounts it -- otherwise the first container + # sees the empty mktemp file. Guaranteed by _plan's linear order; pinned + # here so a reordering refactor fails the unit gate, not just integration. + lines = self.make_script(chats_compose).splitlines() + write_index = next(i for i, line in enumerate(lines) if line.startswith("printf '%s\\n'")) + first_run_index = next(i for i, line in enumerate(lines) if line.startswith("podman run")) + assert write_index < first_run_index + def test_hostsfile_removed_on_teardown(self, chats_compose: dict) -> None: script = self.make_script(chats_compose) trap = next(line for line in script.splitlines() if line.startswith("trap "))