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
6 changes: 5 additions & 1 deletion architecture/supported-subset.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions compose2pod/emit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
11 changes: 9 additions & 2 deletions compose2pod/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,20 +111,27 @@ 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
downstream (`sorted()`, `str.startswith`, a compiled regex) assumes
`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:
Expand Down
28 changes: 14 additions & 14 deletions compose2pod/parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
16 changes: 8 additions & 8 deletions compose2pod/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
8 changes: 4 additions & 4 deletions compose2pod/stores.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down Expand Up @@ -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)
Expand Down
50 changes: 50 additions & 0 deletions planning/changes/2026-07-22.01-world-readable-hosts-file.md
Original file line number Diff line number Diff line change
@@ -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`.
13 changes: 13 additions & 0 deletions tests/test_emit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "))
Expand Down