From bb1a4a2492855ed610e800329001bac42742b5ea Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Wed, 22 Jul 2026 14:23:54 +0300 Subject: [PATCH 1/2] fix: chmod the owned /etc/hosts world-readable for non-root images mktemp creates the script-owned hosts file 0600, owned by the invoking user. Bind-mounted read-only as /etc/hosts, a service image running as a non-root user cannot read it: glibc skips /etc/hosts, falls through to DNS, and pod-internal name resolution fails with "could not translate host name: Temporary failure in name resolution". Root-user images were unaffected, which is why the 0.4.0 owned-/etc/hosts mechanism passed validation. Emit chmod 644 on the hosts file after writing it and before the first podman run mounts it. The file holds only hostname/extra_hosts lines, so world-readable is correct. Reproduced and fixed against real Podman 5.8.1. --- architecture/supported-subset.md | 6 ++- compose2pod/emit.py | 4 ++ ...2026-07-22.01-world-readable-hosts-file.md | 50 +++++++++++++++++++ tests/test_emit.py | 13 +++++ 4 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 planning/changes/2026-07-22.01-world-readable-hosts-file.md diff --git a/architecture/supported-subset.md b/architecture/supported-subset.md index 523f9aa..5e6a12f 100644 --- a/architecture/supported-subset.md +++ b/architecture/supported-subset.md @@ -595,7 +595,11 @@ flags. compose2pod hoists them onto `podman pod create` instead 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` — + (`rm -f "$hostsfile"`). `mktemp` creates the file `0600`; the script then + `chmod 644`s it so a service image running as a non-root user can read the + bind-mounted `/etc/hosts` — without it, glibc cannot read the file, falls + through to DNS, and resolution fails with "Temporary failure in name + resolution". `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, diff --git a/compose2pod/emit.py b/compose2pod/emit.py index fcc0990..bd6cd83 100644 --- a/compose2pod/emit.py +++ b/compose2pod/emit.py @@ -361,6 +361,10 @@ def _plan(compose: dict[str, Any], options: EmitOptions) -> PlannedScript: lines.append(pod_create) _collect_vars(host_tokens, names) lines.append(f"printf '%s\\n' {_render(host_tokens)} > \"$hostsfile\"") + # mktemp creates the file 0600; make it world-readable so a service image + # running as a non-root user can read the bind-mounted /etc/hosts. Without + # this, glibc falls through to DNS and name resolution fails. + lines.append('chmod 644 "$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() diff --git a/planning/changes/2026-07-22.01-world-readable-hosts-file.md b/planning/changes/2026-07-22.01-world-readable-hosts-file.md new file mode 100644 index 0000000..007dff6 --- /dev/null +++ b/planning/changes/2026-07-22.01-world-readable-hosts-file.md @@ -0,0 +1,50 @@ +--- +summary: Generated scripts chmod the owned /etc/hosts to 644 so non-root service images can read it; without it name resolution fell through to DNS and failed. +--- + +# Change: chmod the owned /etc/hosts world-readable + +**Lane:** lightweight — ≲30 LOC net, ≤2 files, no new file, no public-API +change, a single straightforward test. + +## Goal + +Fix pod-internal name resolution for service images that run as a **non-root** +user. On Podman 5.x/6.x, a 0.4.0 script emits `hostsfile=$(mktemp)` — mode +`0600`, owned by the invoking user — and bind-mounts it read-only as +`/etc/hosts` into every container. A non-root process cannot read a `0600` +root-owned file, so glibc skips `/etc/hosts`, falls through to DNS, and every +lookup fails with `could not translate host name "db": Temporary failure in +name resolution`. Root-user images were unaffected, which is why the 0.4.0 +mechanism passed validation (all probes ran as root). + +## Approach + +Emit one line, `chmod 644 "$hostsfile"`, immediately after the `printf` that +writes the file and before the first `podman run` mounts it. The file holds +only `hostname → 127.0.0.1` and `extra_hosts` (`hostname → ip`) lines — nothing +secret — so world-readable is correct. `umask` cannot substitute: `mktemp` +forces `0600` regardless. Truth home: `architecture/supported-subset.md` +(networking / owned-`/etc/hosts` bullet). + +Reproduced on real Podman 5.8.1 (via `quay.io/podman/stable:v5.8.1`): a +non-root member gets `cat: /etc/hosts: Permission denied` and +`socket.gaierror` without the chmod; resolves `db → 127.0.0.1` with it. + +## Files + +- `compose2pod/emit.py` — append the `chmod 644 "$hostsfile"` line after the + hosts-file write. +- `tests/test_emit.py` — assert the chmod line is emitted, ordered after the + write and before the first `podman run`. + +## Verification + +- [x] Failing test first — `pytest -k test_hosts_file_made_world_readable` + fails: `assert 'chmod 644 "$hostsfile"' in lines`. +- [x] Apply the change. +- [x] Test passes. +- [x] `just test-ci` — 1402 passed, 100% coverage. +- [x] `just lint-ci` — clean. +- [x] End-to-end on Podman 5.8.1 — fixed-generator script runs a non-root app + that resolves `db`. diff --git a/tests/test_emit.py b/tests/test_emit.py index 3aa5ca2..5553719 100644 --- a/tests/test_emit.py +++ b/tests/test_emit.py @@ -661,6 +661,19 @@ def test_hosts_file_written_before_first_run(self, chats_compose: dict) -> None: first_run_index = next(i for i, line in enumerate(lines) if line.startswith("podman run")) assert write_index < first_run_index + def test_hosts_file_made_world_readable(self, chats_compose: dict) -> None: + # `mktemp` creates the file 0600, owned by the invoking user. A service + # image running as a non-root user then cannot read the bind-mounted + # /etc/hosts, so name resolution falls through to DNS and fails with + # "Temporary failure in name resolution". `chmod` makes it readable to + # every container uid. Ordered after the write, before the first mount. + lines = self.make_script(chats_compose).splitlines() + assert 'chmod 644 "$hostsfile"' in lines + write_index = next(i for i, line in enumerate(lines) if line.startswith("printf '%s\\n'")) + chmod_index = next(i for i, line in enumerate(lines) if line == 'chmod 644 "$hostsfile"') + 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_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 ")) From 66c6a65ab24d09ca669bca9124a031f800c890c1 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Wed, 22 Jul 2026 14:32:39 +0300 Subject: [PATCH 2/2] fix: return validated key set from require_string_keys for ty 0.0.62 ty 0.0.62 tightened sorted()'s bound: set(mapping) over an untyped compose mapping infers set[object], which does not satisfy SupportsRichComparison, so the 11 unsupported-key error paths that do sorted(set(mapping) - KNOWN) failed the type gate. require_string_keys already proves every key is str; have it return the keys as set[str] so each check builds on a statically sortable type (require_string_keys(...) - KNOWN) and drops a redundant re-iteration. No runtime behavior change. --- compose2pod/keys.py | 11 +++++++++-- compose2pod/parsing.py | 28 ++++++++++++++-------------- compose2pod/resources.py | 16 ++++++++-------- compose2pod/stores.py | 8 ++++---- 4 files changed, 35 insertions(+), 28 deletions(-) diff --git a/compose2pod/keys.py b/compose2pod/keys.py index bc696e5..22b6078 100644 --- a/compose2pod/keys.py +++ b/compose2pod/keys.py @@ -111,8 +111,8 @@ def is_number(value: Any) -> bool: # noqa: ANN401 - Compose values are untyped return not isinstance(value, bool) and isinstance(value, int | float | str) -def require_string_keys(where: str, mapping: dict[Any, Any]) -> None: - """Check every key of a raw YAML/JSON mapping is a string. +def require_string_keys(where: str, mapping: dict[Any, Any]) -> set[str]: + """Check every key of a raw YAML/JSON mapping is a string; return them. PyYAML routinely produces non-string keys (a bare `3:` is an int; under YAML 1.1, a bare `on:`/`off:` is a bool). Every mapping-key consumer @@ -120,11 +120,18 @@ def require_string_keys(where: str, mapping: dict[Any, Any]) -> None: `str` and crashes raw otherwise. This is the one shared check the gate runs before it reads any of `mapping`'s keys, so a non-string key fails clean regardless of which mapping it turned up in. + + Returns the validated keys as a `set[str]` so an unsupported-key check can + build on it directly (`require_string_keys(...) - KNOWN`) with a statically + sortable type, instead of re-deriving `set(mapping)` as `set[object]`. """ + keys: set[str] = set() for key in mapping: if not isinstance(key, str): msg = f"{where}: key {key!r} must be a string" raise UnsupportedComposeError(msg) + keys.add(key) + return keys def _validate_list_elements(name: str, key: str, value: list[Any]) -> None: diff --git a/compose2pod/parsing.py b/compose2pod/parsing.py index bd3e861..27752ae 100644 --- a/compose2pod/parsing.py +++ b/compose2pod/parsing.py @@ -216,12 +216,12 @@ def _validate_volume_long_form(name: str, entry: dict[str, Any]) -> None: here) because the check itself needs `vtype` to know which sub-map key -- `bind`/`volume`/`tmpfs` -- the entry is allowed to carry alongside it. """ - require_string_keys(f"service {name!r}: volume", entry) + keys = require_string_keys(f"service {name!r}: volume", entry) vtype = entry.get("type") if vtype not in _VOLUME_LONG_TYPES: msg = f"service {name!r}: volume 'type' must be one of {list(_VOLUME_LONG_TYPES)}" raise UnsupportedComposeError(msg) - unknown = set(entry) - _VOLUME_LONG_KEYS - {vtype} + unknown = keys - _VOLUME_LONG_KEYS - {vtype} if unknown: msg = f"service {name!r}: volume: unsupported keys {sorted(unknown)}" raise UnsupportedComposeError(msg) @@ -333,8 +333,8 @@ def _validate_volume_options(name: str, vtype: str, options: Any) -> None: # no if not isinstance(options, dict): msg = f"service {name!r}: {vtype} options must be a mapping" raise UnsupportedComposeError(msg) - require_string_keys(f"service {name!r}: {vtype} options", options) - unknown = set(options) - _VOLUME_OPTION_KEYS[vtype] + keys = require_string_keys(f"service {name!r}: {vtype} options", options) + unknown = keys - _VOLUME_OPTION_KEYS[vtype] if unknown: msg = f"service {name!r}: {vtype} options: unsupported keys {sorted(unknown)}" raise UnsupportedComposeError(msg) @@ -529,8 +529,8 @@ def _validate_build_secret_entry(name: str, entry: Any) -> None: # noqa: ANN401 if not isinstance(entry, dict): msg = f"service {name!r}: build 'secrets' entries must be a string or mapping" raise UnsupportedComposeError(msg) - require_string_keys(f"service {name!r}: build 'secrets' entry", entry) - unknown = set(entry) - _BUILD_SECRET_REF_KEYS + keys = require_string_keys(f"service {name!r}: build 'secrets' entry", entry) + unknown = keys - _BUILD_SECRET_REF_KEYS if unknown: msg = f"service {name!r}: build 'secrets' entry: unsupported keys {sorted(unknown)}" raise UnsupportedComposeError(msg) @@ -683,8 +683,8 @@ def _validate_env_file(name: str, svc: dict[str, Any]) -> None: def _validate_env_file_mapping(name: str, entry: dict[str, Any]) -> None: """Check one long-form env_file mapping against Docker's strict schema (measured, v5.1.2).""" - require_string_keys(f"service {name!r}: 'env_file'", entry) - unknown = set(entry) - _ENV_FILE_ENTRY_KEYS + keys = require_string_keys(f"service {name!r}: 'env_file'", entry) + unknown = keys - _ENV_FILE_ENTRY_KEYS if unknown: msg = f"service {name!r}: 'env_file' entry: unsupported keys {sorted(unknown)}" raise UnsupportedComposeError(msg) @@ -1240,8 +1240,8 @@ def validate(ident: str, key: str, value: Any) -> None: # noqa: ANN401 - untype if values.is_bool_like(value): return if isinstance(value, dict): - require_string_keys(f"{label} {ident!r}: {key!r}", value) - unknown = set(value) - _EXTERNAL_MAP_KEYS + keys = require_string_keys(f"{label} {ident!r}: {key!r}", value) + unknown = keys - _EXTERNAL_MAP_KEYS if unknown: msg = f"{label} {ident!r}: {key!r}: unsupported keys {sorted(unknown)}" raise UnsupportedComposeError(msg) @@ -1278,8 +1278,8 @@ def _validate_ipam_config_entry(label: str, ident: str, entry: Any) -> None: # if not isinstance(entry, dict): msg = f"{label} {ident!r}: ipam 'config' entries must be a mapping" raise UnsupportedComposeError(msg) - require_string_keys(f"{label} {ident!r}: ipam config entry", entry) - unknown = set(entry) - _IPAM_CONFIG_ENTRY_KEYS + keys = require_string_keys(f"{label} {ident!r}: ipam config entry", entry) + unknown = keys - _IPAM_CONFIG_ENTRY_KEYS if unknown: msg = f"{label} {ident!r}: ipam config entry: unsupported keys {sorted(unknown)}" raise UnsupportedComposeError(msg) @@ -1309,8 +1309,8 @@ def validate(ident: str, key: str, value: Any) -> None: # noqa: ANN401 - untype if not isinstance(value, dict): msg = f"{label} {ident!r}: {key!r} must be a mapping" raise UnsupportedComposeError(msg) - require_string_keys(f"{label} {ident!r}: {key!r}", value) - unknown = set(value) - _IPAM_KEYS + keys = require_string_keys(f"{label} {ident!r}: {key!r}", value) + unknown = keys - _IPAM_KEYS if unknown: msg = f"{label} {ident!r}: {key!r}: unsupported keys {sorted(unknown)}" raise UnsupportedComposeError(msg) diff --git a/compose2pod/resources.py b/compose2pod/resources.py index da1b3e5..8558f73 100644 --- a/compose2pod/resources.py +++ b/compose2pod/resources.py @@ -36,8 +36,8 @@ def _validate_limits(name: str, svc: dict[str, Any], limits: Any) -> None: # no if not isinstance(limits, dict): msg = f"service {name!r}: deploy.resources.limits must be a mapping" raise UnsupportedComposeError(msg) - require_string_keys(f"service {name!r}: deploy.resources.limits", limits) - unknown = set(limits) - set(_LIMITS) + keys = require_string_keys(f"service {name!r}: deploy.resources.limits", limits) + unknown = keys - set(_LIMITS) if unknown: msg = f"service {name!r}: deploy.resources.limits: unsupported keys {sorted(unknown)}" raise UnsupportedComposeError(msg) @@ -54,8 +54,8 @@ def _validate_reservations(name: str, svc: dict[str, Any], reservations: Any) -> if not isinstance(reservations, dict): msg = f"service {name!r}: deploy.resources.reservations must be a mapping" raise UnsupportedComposeError(msg) - require_string_keys(f"service {name!r}: deploy.resources.reservations", reservations) - unknown = set(reservations) - {"cpus", "memory", "devices"} + keys = require_string_keys(f"service {name!r}: deploy.resources.reservations", reservations) + unknown = keys - {"cpus", "memory", "devices"} if unknown: msg = f"service {name!r}: deploy.resources.reservations: unsupported keys {sorted(unknown)}" raise UnsupportedComposeError(msg) @@ -91,8 +91,8 @@ def validate_deploy(name: str, svc: dict[str, Any]) -> None: if not isinstance(deploy, dict): msg = f"service {name!r}: 'deploy' must be a mapping" raise UnsupportedComposeError(msg) - require_string_keys(f"service {name!r}: deploy", deploy) - unknown = set(deploy) - {"resources"} + keys = require_string_keys(f"service {name!r}: deploy", deploy) + unknown = keys - {"resources"} if unknown: msg = f"service {name!r}: deploy: only 'resources' is supported (got {sorted(unknown)})" raise UnsupportedComposeError(msg) @@ -105,8 +105,8 @@ def validate_deploy(name: str, svc: dict[str, Any]) -> None: raise UnsupportedComposeError(msg) _reject_null_block(name, "deploy.resources.limits", resources, "limits") _reject_null_block(name, "deploy.resources.reservations", resources, "reservations") - require_string_keys(f"service {name!r}: deploy.resources", resources) - unknown = set(resources) - {"limits", "reservations"} + keys = require_string_keys(f"service {name!r}: deploy.resources", resources) + unknown = keys - {"limits", "reservations"} if unknown: msg = f"service {name!r}: deploy.resources: unsupported keys {sorted(unknown)}" raise UnsupportedComposeError(msg) diff --git a/compose2pod/stores.py b/compose2pod/stores.py index 27bb4db..4f104e7 100644 --- a/compose2pod/stores.py +++ b/compose2pod/stores.py @@ -73,8 +73,8 @@ def _validate_def(name: str, definition: Any, kind: StoreKind) -> None: # noqa: if not isinstance(definition, dict): msg = f"{kind.label} {name!r} must be a mapping" raise UnsupportedComposeError(msg) - require_string_keys(f"{kind.label} {name!r}", definition) - unknown = set(definition) - kind.sources + keys = require_string_keys(f"{kind.label} {name!r}", definition) + unknown = keys - kind.sources if unknown: if "external" in unknown: msg = ( @@ -131,8 +131,8 @@ def _ref_source(name: str, ref: Any, kind: StoreKind) -> str: # noqa: ANN401 - if not isinstance(ref, dict): msg = f"service {name!r}: {kind.label} entry must be a string or mapping" raise UnsupportedComposeError(msg) - require_string_keys(f"service {name!r}: {kind.label} entry", ref) - unknown = set(ref) - _LONG_FORM_KEYS + keys = require_string_keys(f"service {name!r}: {kind.label} entry", ref) + unknown = keys - _LONG_FORM_KEYS if unknown: msg = f"service {name!r}: unsupported {kind.label} keys {sorted(unknown)}" raise UnsupportedComposeError(msg)