From fb6f6a7f9cddd52d872024002b8b32dced0c6774 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Wed, 22 Jul 2026 15:25:24 +0300 Subject: [PATCH] fix: give the pod a resolvable hostname so a container can resolve its own hostname 0.4.0 moved name resolution into a script-owned /etc/hosts under --no-hosts to kill the pre-6.0.0 stop-wipe bug. But --no-hosts also stops Podman from adding each container's own hostname line, so the hostname is the random container ID, absent from the owned file: hostname -f then fails with 'Temporary failure in name resolution' and an image whose startup resolves its own hostname (e.g. a KeyDB-sentinel image) crash-loops and never goes healthy -- a regression from 0.1.8, which used no --no-hosts and let Podman add the line. Give the pod a private UTS namespace and the pod name as hostname (podman pod create --uts private --hostname ; --uts private is required, Podman refuses --hostname in the host UTS namespace), and add 127.0.0.1 to the owned /etc/hosts. A pod shares one UTS namespace, so the hostname is pod-wide; per-service hostname: keys are unchanged. Verified end-to-end on Podman 5.8.1 and 6.0.1. --- architecture/supported-subset.md | 10 +++- compose2pod/emit.py | 14 ++++- ...26-07-22.02-pod-hostname-own-resolution.md | 55 +++++++++++++++++++ tests/test_emit.py | 22 +++++++- 4 files changed, 95 insertions(+), 6 deletions(-) create mode 100644 planning/changes/2026-07-22.02-pod-hostname-own-resolution.md diff --git a/architecture/supported-subset.md b/architecture/supported-subset.md index 5e6a12f..43d06d8 100644 --- a/architecture/supported-subset.md +++ b/architecture/supported-subset.md @@ -609,7 +609,15 @@ flags. compose2pod hoists them onto `podman pod create` instead `--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` / + every Podman version, with no version floor. `--no-hosts` also stops Podman + from adding each container's own hostname line, so `podman pod create` gets + `--uts private --hostname ` and the pod name is added to the hosts + file (`127.0.0.1 `): a container resolving its own hostname + (`hostname -f`) — which Podman handled before `--no-hosts` and which images + with a self-referential startup do — resolves again. A pod shares one UTS + namespace, so this hostname is pod-wide; per-service `hostname:` values stay + resolvable by peers via the hosts file but do not set a container's own name. + `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). diff --git a/compose2pod/emit.py b/compose2pod/emit.py index bd6cd83..b04b6ad 100644 --- a/compose2pod/emit.py +++ b/compose2pod/emit.py @@ -335,7 +335,13 @@ def _plan(compose: dict[str, Any], options: EmitOptions) -> PlannedScript: # Only the closure joins the pod, so only the closure is resolvable. A name # 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}) + # The pod name is added as a resolvable name so every container's own + # hostname (set to the pod name below) resolves. A pod shares one UTS + # namespace, so without this the hostname is the random container ID, + # absent from the owned /etc/hosts -- `hostname -f` then fails and images + # that resolve their own hostname at startup crash. Podman used to add this + # line itself; `--no-hosts` stops it, so the script owns it too. + hosts = [*hostnames({name: services[name] for name in order}), options.pod] host_tokens = hosts_file_tokens(services, order, hosts) completion_gated = { dep @@ -355,7 +361,11 @@ def _plan(compose: dict[str, Any], options: EmitOptions) -> PlannedScript: lines.append(f"trap '{teardown}' EXIT") pod_flags = pod_create_flags(services, order) _collect_vars(pod_flags, names) - pod_create = f"podman pod create --name {shlex.quote(options.pod)} --no-hosts" + # `--uts private` gives the pod its own UTS namespace so `--hostname` is + # settable (in the host UTS namespace Podman refuses it); the pod name is + # the shared hostname, resolvable via the line added to the hosts file above. + pod_name = shlex.quote(options.pod) + pod_create = f"podman pod create --name {pod_name} --no-hosts --uts private --hostname {pod_name}" if pod_flags: pod_create += " " + _render(pod_flags) lines.append(pod_create) diff --git a/planning/changes/2026-07-22.02-pod-hostname-own-resolution.md b/planning/changes/2026-07-22.02-pod-hostname-own-resolution.md new file mode 100644 index 0000000..3777721 --- /dev/null +++ b/planning/changes/2026-07-22.02-pod-hostname-own-resolution.md @@ -0,0 +1,55 @@ +--- +summary: The pod gets --uts private --hostname and that name is added to the owned /etc/hosts, so a container resolving its own hostname works again (regressed by 0.4.0's --no-hosts). +--- + +# Change: give the pod a resolvable hostname + +**Lane:** lightweight — one code file (`emit.py`), one straightforward test, +no new file, no public-API change. + +## Goal + +Restore own-hostname resolution inside the pod. 0.4.0 switched to a +script-owned `/etc/hosts` under `--no-hosts` to kill the pre-6.0.0 stop-wipe +bug — but `--no-hosts` also stopped Podman from adding each container's own +hostname line. A container's hostname is then the random container ID, absent +from the owned file, so `hostname -f` fails with `Temporary failure in name +resolution`. Images with a self-referential startup (e.g. a KeyDB-sentinel +image whose supervisor resolves its own hostname) crash-loop and never go +healthy — a regression from 0.1.8, which used no `--no-hosts` and let Podman +add the line. + +## Approach + +- `podman pod create` gains `--uts private --hostname `. `--uts + private` is required: in the host UTS namespace Podman refuses `--hostname` + (`cannot set hostname ... host UTS namespace`). A pod shares one UTS + namespace, so per-container hostnames are impossible on 5.x/6.x (verified); + the pod name is the single shared hostname. +- Add the pod name to the owned `/etc/hosts` (`127.0.0.1 `) so that + hostname resolves. Reuses `hosts_file_tokens`' existing merge/conflict path. + +Per-service `hostname:` keys are unchanged: still resolvable by peers via the +hosts file, still not the container's own name (0.1.8 didn't honor them as +real hostnames either, and the value is irrelevant to resolution). Truth home: +`architecture/supported-subset.md` (owned-`/etc/hosts` bullet). + +Verified end-to-end on real Podman **5.8.1** (`quay.io/podman/stable:v5.8.1`) +and local **6.0.1**: a service whose healthcheck is `hostname -f` goes healthy, +and a peer resolves its own hostname (`OWN-HOSTNAME-OK`). + +## Files + +- `compose2pod/emit.py` — add the pod name to the hosts list; emit `--uts + private --hostname ` on pod create. +- `tests/test_emit.py` — new `test_pod_gets_private_uts_and_hostname`; update + the two existing pod-create string assertions. + +## Verification + +- [x] Failing test first — `pytest -k test_pod_gets_private_uts_and_hostname`. +- [x] Apply the change. +- [x] Test passes. +- [x] `just test-ci` — 1403 passed, 100% coverage. +- [x] `just lint-ci` — clean. +- [x] End-to-end on Podman 5.8.1 and 6.0.1 — own hostname resolves. diff --git a/tests/test_emit.py b/tests/test_emit.py index 5553719..b158e76 100644 --- a/tests/test_emit.py +++ b/tests/test_emit.py @@ -674,6 +674,20 @@ def test_hosts_file_made_world_readable(self, chats_compose: dict) -> None: first_run_index = next(i for i, line in enumerate(lines) if line.startswith("podman run")) assert write_index < chmod_index < first_run_index + def test_pod_gets_private_uts_and_hostname(self, chats_compose: dict) -> None: + # A pod shares one UTS namespace; with no pod hostname, every container's + # own hostname is the random container ID -- absent from the owned + # /etc/hosts -- so `hostname -f` fails ("Temporary failure in name + # resolution") and an image that resolves its own hostname at startup + # crashes. Give the pod a private UTS namespace and the pod name as its + # hostname, and put that name in the hosts file so it resolves. + script = self.make_script(chats_compose) + pod_create = next(line for line in script.splitlines() if line.startswith("podman pod create")) + assert "--uts private" in pod_create + assert "--hostname test-pod" in pod_create + printf_line = next(line for line in script.splitlines() if line.startswith("printf '%s\\n'")) + assert "127.0.0.1 test-pod" in printf_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 ")) @@ -846,8 +860,10 @@ 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) - # `--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 + # `--no-hosts` and the pod hostname always precede dns/sysctls now that /etc/hosts is script-owned. + assert ( + 'podman pod create --name p --no-hosts --uts private --hostname p --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: @@ -855,7 +871,7 @@ def test_pod_create_carries_only_self_alias_without_pod_options(self) -> None: # 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 --no-hosts\n" in script + assert "podman pod create --name p --no-hosts --uts private --hostname p\n" in script assert "127.0.0.1 app" in script def test_env_file_required_false_is_guarded(self) -> None: