Skip to content

Commit 14e46ca

Browse files
committed
fix: emit add-host at podman pod create instead of podman run
Current podman rejects --add-host on a container joining a pod ("network cannot be configured when it is shared with a pod"). Moves alias/hostname add-host emission from run_flags to pod_create_flags, alongside dns/sysctls, and folds extra_hosts in as a pod-level key merged into the same add-host set (conflict refused rather than guessed at).
1 parent d9ea6ef commit 14e46ca

9 files changed

Lines changed: 322 additions & 129 deletions

File tree

architecture/supported-subset.md

Lines changed: 37 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,11 @@ warns (ignored, behavior-neutral inside a single pod) or raises
6767
- **`annotations`:** list or mapping, emitted as repeated `--annotation`
6868
(`KEY=value`, or bare `KEY` for a null value), sharing the `_MAP_FLAGS`
6969
machinery with `labels`.
70-
- **`extra_hosts`:** list (`- host:ip`) or mapping (`host: ip`), emitted as
71-
per-service `--add-host host:ip`. Distinct from the alias/hostname entries
72-
(which resolve to `127.0.0.1`); IPv6 values keep their colons.
70+
- **`extra_hosts`:** list (`- host:ip`) or mapping (`host: ip`), pod-level like
71+
`dns`/`sysctls` — see the Pod-level options section below. Distinct from
72+
the alias/hostname set in one respect: alias/hostname resolution is
73+
always fixed at `127.0.0.1`, while `extra_hosts` carries a user-specified
74+
address; IPv6 values keep their colons.
7375
- **`pull_policy`:** a validated enum mapped to podman's `--pull`
7476
(`if_not_present``missing`; `always`/`never`/`missing` pass through),
7577
emitted literally. `build` and unknown values are rejected — compose2pod
@@ -181,36 +183,48 @@ it, so a handful of Compose keys cannot be per-container `podman run` flags.
181183
compose2pod hoists them onto `podman pod create` instead
182184
(`compose2pod/pod.py`) — the tool's only pod-create flags.
183185

184-
- **Supported:** `dns`, `dns_search`, `dns_opt`, `sysctls` — mapped to
185-
`--dns`, `--dns-search`, `--dns-option`, `--sysctl` respectively (`_DNS_KEYS`,
186+
- **Supported:** `dns`, `dns_search`, `dns_opt`, `sysctls`, `extra_hosts`
187+
mapped to `--dns`, `--dns-search`, `--dns-option`, `--sysctl`, and (merged
188+
with the alias/hostname set) `--add-host` respectively (`_DNS_KEYS`,
186189
`pod.py`).
187-
- **Aggregation is closure-scoped:** `pod_create_flags(services, order)` is
188-
called with `order` — the target's dependency closure (`startup_order`) —
189-
exactly like other closure-scoped constructs (secrets, configs). `dns` /
190-
`dns_search` / `dns_opt` are unioned across the closure (deduplicated,
191-
first-seen order); `sysctls` are unioned by key, and two services in the
192-
closure setting the same key to different values is refused
193-
(`UnsupportedComposeError: conflicting sysctl ...`) rather than resolved by
194-
last-writer-wins.
190+
- **Aggregation is closure-scoped:** `pod_create_flags(services, order,
191+
hosts)` is called with `order` — the target's dependency closure
192+
(`startup_order`) — exactly like other closure-scoped constructs (secrets,
193+
configs). `dns` / `dns_search` / `dns_opt` are unioned across the closure
194+
(deduplicated, first-seen order); `sysctls` are unioned by key, and two
195+
services in the closure setting the same key to different values is
196+
refused (`UnsupportedComposeError: conflicting sysctl ...`) rather than
197+
resolved by last-writer-wins. `--add-host` is seeded from the
198+
alias/hostname set (`hosts`, computed document-wide by `graph.hostnames`,
199+
not closure-scoped — pre-existing, orthogonal behavior), then layered with
200+
each closure service's `extra_hosts` (closure-scoped like `dns`/`sysctls`);
201+
a host name landing on two different addresses — across two services'
202+
`extra_hosts`, or between an `extra_hosts` entry and an alias's fixed
203+
`127.0.0.1` — is refused (`UnsupportedComposeError: conflicting host ...`),
204+
the same refuse-rather-than-guess rule as the `sysctls` conflict.
195205
- **Value shapes:** `dns` / `dns_search` / `dns_opt` accept a string or a list
196206
of strings; `sysctls` accepts a mapping (`key: value`) or a list of
197207
`"key=value"` strings, each value a string or number. A `${VAR}` inside a
198208
value is wrapped in `_Expand` like other interpolated fields, so it stays
199209
live at run time and counts toward `referenced_variables` — the generated
200210
script's own shell expands it when it runs, not compose2pod at generation
201-
time.
211+
time. `--add-host` entries render differently by source: an alias/hostname
212+
entry stays a plain unquoted token (pre-existing behavior, unchanged by
213+
this move), while an `extra_hosts` entry goes through `_Expand` (quoted,
214+
`${VAR}`-live) — same as before it was per-service.
202215
- **Pod-wide divergence:** unlike every other service key, these apply to
203216
every container in the pod once emitted — including services that never
204-
declared them — because the pod shares one `/etc/resolv.conf` and one
205-
sysctl set. `validate()` (`compose2pod/parsing.py`) is target-agnostic
206-
shape validation over the whole document: whenever any service anywhere
207-
declares `dns` / `dns_search` / `dns_opt` / `sysctls` (`uses_pod_options`),
208-
it emits the warning "dns/sysctls apply pod-wide -- all containers in the
209-
pod share one /etc/resolv.conf and sysctl set", regardless of whether that
217+
declared them — because the pod shares one `/etc/resolv.conf`, one sysctl
218+
set, and one `/etc/hosts`. `validate()` (`compose2pod/parsing.py`) is
219+
target-agnostic shape validation over the whole document: whenever any
220+
service anywhere declares `dns` / `dns_search` / `dns_opt` / `sysctls` /
221+
`extra_hosts` (`uses_pod_options`), it emits the warning "dns/sysctls/
222+
extra_hosts apply pod-wide -- all containers in the pod share one
223+
/etc/resolv.conf, sysctl set, and /etc/hosts", regardless of whether that
210224
service turns out to be inside the target's closure. Conversely, at emit
211-
time a `dns` / `sysctls` declaration on a service outside the target's
212-
closure is silently ignored by `pod_create_flags` — no flag is emitted for
213-
it, since that service is never run.
225+
time a `dns` / `sysctls` / `extra_hosts` declaration on a service outside
226+
the target's closure is silently ignored by `pod_create_flags` — no flag
227+
is emitted for it, since that service is never run.
214228
- **Non-goals:** per-service DNS/sysctls — impossible inside a
215229
shared-namespace pod, not a compose2pod limitation; last-writer-wins on a
216230
sysctl key conflict — refused instead, matching the refuse-on-conflict

compose2pod/emit.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -93,11 +93,9 @@ def _add_volume_flags(flags: list[Token], svc: dict[str, Any], project_dir: str)
9393
flags += ["--tmpfs", _Expand(value=mount)]
9494

9595

96-
def run_flags(name: str, svc: dict[str, Any], pod: str, hosts: list[str], project_dir: str) -> list[Token]:
96+
def run_flags(name: str, svc: dict[str, Any], pod: str, project_dir: str) -> list[Token]:
9797
"""Flag tokens (unquoted) for `podman run` of one service."""
9898
flags: list[Token] = ["--pod", pod, "--name", f"{pod}-{name}"]
99-
for host in hosts:
100-
flags += ["--add-host", f"{host}:127.0.0.1"]
10199
_add_env_flags(flags, svc, project_dir)
102100
_add_volume_flags(flags, svc, project_dir)
103101
_add_health_flags(flags, svc.get("healthcheck") or {})
@@ -168,9 +166,9 @@ def _collect_vars(tokens: list[Token], names: set[str]) -> None:
168166
names.update(variable_names(token.value))
169167

170168

171-
def _run_tokens(name: str, services: dict[str, Any], options: EmitOptions, hosts: list[str]) -> list[Token]:
169+
def _run_tokens(name: str, services: dict[str, Any], options: EmitOptions) -> list[Token]:
172170
svc = services[name]
173-
tokens = run_flags(name, svc, options.pod, hosts, options.project_dir)
171+
tokens = run_flags(name, svc, options.pod, options.project_dir)
174172
entrypoint = entrypoint_tokens(svc)
175173
if entrypoint:
176174
tokens += ["--entrypoint", entrypoint[0]]
@@ -230,7 +228,7 @@ def _plan(compose: dict[str, Any], options: EmitOptions) -> PlannedScript:
230228
if store_teardown:
231229
teardown += f"; {store_teardown}"
232230
lines.append(f"trap '{teardown}' EXIT")
233-
pod_flags = pod_create_flags(services, order)
231+
pod_flags = pod_create_flags(services, order, hosts)
234232
_collect_vars(pod_flags, names)
235233
pod_create = f"podman pod create --name {shlex.quote(options.pod)}"
236234
if pod_flags:
@@ -246,7 +244,7 @@ def _plan(compose: dict[str, Any], options: EmitOptions) -> PlannedScript:
246244
attempts = max(HEALTHY_WAIT_BUDGET_SECONDS // interval, 1)
247245
lines.append(f"wait_healthy {shlex.quote(f'{options.pod}-{dep}')} {attempts} {interval}")
248246
waited.add(dep)
249-
run_tokens = _run_tokens(name, services, options, hosts)
247+
run_tokens = _run_tokens(name, services, options)
250248
_collect_vars(run_tokens, names)
251249
if name == options.target:
252250
_emit_target(lines, run_tokens, options)

compose2pod/keys.py

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -126,13 +126,6 @@ def _extra_host_pairs(value: list[Any] | dict[str, Any]) -> list[Any]:
126126
return [f"{host}:{ip}" for host, ip in value.items()]
127127

128128

129-
def _emit_extra_hosts(value: Any) -> list[Token]: # noqa: ANN401 - Compose values are untyped YAML/JSON
130-
tokens: list[Token] = []
131-
for entry in _extra_host_pairs(value):
132-
tokens += ["--add-host", _Expand(value=str(entry))]
133-
return tokens
134-
135-
136129
def _validate_pull_policy(name: str, key: str, value: Any) -> None: # noqa: ANN401 - Compose values are untyped
137130
if value is not None and (not isinstance(value, str) or value not in PULL_POLICY_MAP):
138131
allowed = "/".join(PULL_POLICY_MAP)
@@ -194,7 +187,6 @@ def _emit_ulimits(value: Any) -> list[Token]: # noqa: ANN401 - Compose values a
194187
"devices": _list("--device"),
195188
"labels": _map("--label"),
196189
"annotations": _map("--annotation"),
197-
"extra_hosts": KeySpec(validate=_validate_map, emit=_emit_extra_hosts),
198190
"pull_policy": KeySpec(validate=_validate_pull_policy, emit=_emit_pull_policy),
199191
"ulimits": KeySpec(validate=_validate_ulimits, emit=_emit_ulimits),
200192
"mem_limit": _number_scalar("--memory"),
@@ -233,4 +225,5 @@ def _emit_ulimits(value: Any) -> list[Token]: # noqa: ANN401 - Compose values a
233225
"dns_search",
234226
"dns_opt",
235227
"sysctls",
228+
"extra_hosts",
236229
}

compose2pod/parsing.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ def validate(compose: dict[str, Any]) -> list[str]:
137137
stores.validate(compose)
138138
if uses_pod_options(services):
139139
warnings.append(
140-
"dns/sysctls apply pod-wide -- all containers in the pod share one /etc/resolv.conf and sysctl set"
140+
"dns/sysctls/extra_hosts apply pod-wide -- all containers in the pod share one "
141+
"/etc/resolv.conf, sysctl set, and /etc/hosts"
141142
)
142143
return warnings

compose2pod/pod.py

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@
33
from typing import Any
44

55
from compose2pod.exceptions import UnsupportedComposeError
6-
from compose2pod.keys import Token, _Expand
6+
from compose2pod.keys import Token, _Expand, _extra_host_pairs, _validate_map
77

88

99
_DNS_KEYS = {"dns": "--dns", "dns_search": "--dns-search", "dns_opt": "--dns-option"}
10-
_POD_OPTION_KEYS = (*_DNS_KEYS, "sysctls")
10+
_POD_OPTION_KEYS = (*_DNS_KEYS, "sysctls", "extra_hosts")
1111

1212

1313
def _as_str_list(name: str, key: str, value: Any) -> list[str]: # noqa: ANN401 - Compose values are untyped
@@ -47,6 +47,8 @@ def validate_pod_options(name: str, svc: dict[str, Any]) -> None:
4747
_as_str_list(name, key, svc[key])
4848
if "sysctls" in svc:
4949
_sysctl_pairs(name, svc["sysctls"])
50+
if "extra_hosts" in svc:
51+
_validate_map(name, "extra_hosts", svc["extra_hosts"])
5052

5153

5254
def uses_pod_options(services: dict[str, Any]) -> bool:
@@ -85,10 +87,42 @@ def _sysctl_flags(services: dict[str, Any], order: list[str]) -> list[Token]:
8587
return tokens
8688

8789

88-
def pod_create_flags(services: dict[str, Any], order: list[str]) -> list[Token]:
89-
"""Pod-create flag tokens aggregated across the closure `order`.
90+
def _add_host_flags(services: dict[str, Any], order: list[str], hosts: list[str]) -> list[Token]:
91+
"""Merge alias/hostname hosts (fixed 127.0.0.1) with extra_hosts (order-scoped) into one add-host set.
9092
91-
dns/dns_search/dns_opt are unioned (dedup, first-seen order); sysctls are
92-
unioned by key and a same-key value conflict is refused.
93+
A host name landing on two different addresses -- across services' extra_hosts,
94+
or against an alias's fixed 127.0.0.1 -- is refused rather than guessed at, matching
95+
the sysctls conflict rule below. Alias entries render as plain tokens (unquoted, as
96+
before this move); extra_hosts entries render via `_Expand` (as before, quoted/interpolated)
97+
-- relocating the flags changes nothing else observable about either source.
9398
"""
94-
return _dns_flags(services, order) + _sysctl_flags(services, order)
99+
merged: dict[str, str] = {}
100+
from_extra_hosts: set[str] = set()
101+
for host in hosts:
102+
merged[host] = "127.0.0.1"
103+
for name in order:
104+
svc = services[name]
105+
if "extra_hosts" not in svc:
106+
continue
107+
for entry in _extra_host_pairs(svc["extra_hosts"]):
108+
host, _sep, addr = str(entry).partition(":")
109+
if merged.get(host, addr) != addr:
110+
msg = f"service {name!r}: conflicting host {host!r} ({merged[host]!r} vs {addr!r})"
111+
raise UnsupportedComposeError(msg)
112+
merged[host] = addr
113+
from_extra_hosts.add(host)
114+
tokens: list[Token] = []
115+
for host, addr in merged.items():
116+
value = _Expand(value=f"{host}:{addr}") if host in from_extra_hosts else f"{host}:{addr}"
117+
tokens += ["--add-host", value]
118+
return tokens
119+
120+
121+
def pod_create_flags(services: dict[str, Any], order: list[str], hosts: list[str]) -> list[Token]:
122+
"""Pod-create flag tokens aggregated across the closure `order`, plus `hosts` for add-host.
123+
124+
Add-host is merged from alias/hostname resolution (`hosts`, fixed 127.0.0.1) and each
125+
closure service's `extra_hosts`, conflict-checked (see `_add_host_flags`). dns/dns_search/
126+
dns_opt are unioned (dedup, first-seen order); sysctls are unioned by key.
127+
"""
128+
return _add_host_flags(services, order, hosts) + _dns_flags(services, order) + _sysctl_flags(services, order)
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
---
2+
summary: Move all `--add-host` emission (alias/hostname resolution and `extra_hosts`) from per-service `podman run` to `podman pod create`, since current podman rejects network config on a container joining a pod; `extra_hosts` becomes pod-wide like `dns`/`sysctls`, with a same-host-different-address conflict (against another service's `extra_hosts` or an alias's fixed `127.0.0.1`) refused rather than guessed at.
3+
---
4+
5+
# Design: pod-level `--add-host`
6+
7+
## Summary
8+
9+
Current podman rejects `--add-host` on `podman run` for a container joining a
10+
pod (`invalid config provided: extra host entries must be specified on the
11+
pod: network cannot be configured when it is shared with a pod`), discovered
12+
by the new integration harness (`planning/changes/2026-07-12.04-integration-harness.md`)
13+
on its first real CI run. Every generated script with more than a trivial
14+
single-service pod currently fails at run time. The fix hoists all
15+
`--add-host` emission onto `podman pod create`, alongside `dns`/`sysctls`
16+
(`compose2pod/pod.py`). This forces `extra_hosts` — previously genuinely
17+
per-service — to become pod-wide like `dns`/`sysctls` already are; a same-host
18+
conflict is refused rather than resolved by guessing.
19+
20+
## Motivation
21+
22+
`run_flags` (`compose2pod/emit.py:96-100`) emits `--add-host <name>:127.0.0.1`
23+
for every alias/hostname/container_name in the whole document, on every
24+
service's `podman run` line — and `extra_hosts` (`compose2pod/keys.py:197`)
25+
emits a per-service `--add-host host:ip` the same way. Both are now rejected
26+
outright by podman once a container joins a pod. The integration harness's
27+
first CI run (PR #41) demonstrated this on all three seed scenarios:
28+
`Error: invalid config provided: extra host entries must be specified on the
29+
pod: network cannot be configured when it is shared with a pod`.
30+
31+
## Design
32+
33+
**Alias/hostname hosts move as-is.** They were already effectively pod-wide
34+
(the exact same set was redundantly re-emitted on every service's `run`
35+
line), so relocating them to `podman pod create` changes nothing observable
36+
except where the flag lives.
37+
38+
**`extra_hosts` becomes a pod-level key, mirroring `dns`/`sysctls`:**
39+
- Removed from `SERVICE_KEYS` (`compose2pod/keys.py`) — no longer a
40+
per-container emit. Its `_extra_host_pairs` normalizer (list → items,
41+
mapping → `"host:ip"` strings, IPv6-safe) stays in `keys.py` and is now
42+
imported by `pod.py`. `_emit_extra_hosts` (the old per-service Token
43+
emitter) is deleted — dead once `extra_hosts` leaves `SERVICE_KEYS`.
44+
- Added to `STRUCTURAL_KEYS` (`compose2pod/keys.py`) so it stays a
45+
recognized/supported service key.
46+
- Added to `_POD_OPTION_KEYS` (`compose2pod/pod.py`), so `uses_pod_options`
47+
correctly fires the existing pod-wide-effects warning
48+
(`compose2pod/parsing.py`) whenever any service declares it. The warning
49+
text extends from `"dns/sysctls apply pod-wide ..."` to also name
50+
`extra_hosts`/`/etc/hosts`.
51+
- Shape-checked in `validate_pod_options` (`compose2pod/pod.py`) via the
52+
existing `_validate_map` (list or mapping) — moved out of the generic
53+
`SERVICE_KEYS` validate loop, same relocation pattern as its emit.
54+
55+
**Merge and conflict rule** (new `_add_host_flags` in `pod.py`, alongside
56+
`_dns_flags`/`_sysctl_flags`, folded into `pod_create_flags`, which gains a
57+
third `hosts: list[str]` parameter):
58+
- Seed the merged `{host: address}` map from the alias/hostname set (`hosts`,
59+
computed once by `graph.hostnames`), all fixed at `127.0.0.1` — these can
60+
never conflict with each other (same fixed value).
61+
- Layer each closure service's `extra_hosts` on top, closure-scoped exactly
62+
like `dns`/`sysctls` (`order`, not the whole document).
63+
- A host name landing on two different addresses — from two services'
64+
`extra_hosts`, or from an `extra_hosts` entry against an alias's fixed
65+
`127.0.0.1` — raises `UnsupportedComposeError`, the same
66+
refuse-rather-than-guess rule already used for a `sysctls` key conflict.
67+
One rule for every host-merge source, no source-priority special-casing.
68+
69+
**`run_flags` drops its `hosts` parameter entirely** (the add-host loop is
70+
deleted); `_run_tokens`, and the `_plan` call site, drop the argument too.
71+
`_plan` passes `hosts` (already computed for its own purposes) into
72+
`pod_create_flags` instead.
73+
74+
## Non-goals
75+
76+
- Per-service DNS/sysctls remain impossible inside a shared-namespace pod —
77+
unrelated to this change, already documented.
78+
- No new validation of individual `extra_hosts` entry shape beyond "list or
79+
mapping" (e.g. a colon-less list entry) — matches the pre-existing
80+
shallow validation; podman surfaces a malformed entry at run time, as it
81+
already would have.
82+
- No change to how aliases are collected (`graph.hostnames`) or scoped
83+
(whole document, not closure-limited) — pre-existing, orthogonal behavior.
84+
85+
## Testing
86+
87+
`just test-ci` at 100%: `pod_create_flags` gains add-host cases (alias-only,
88+
extra_hosts-only, both merged, same-value dedup across sources, conflicting
89+
addresses refused — extra_hosts vs extra_hosts, and extra_hosts vs an alias);
90+
`run_flags` no longer emits `--add-host` for any input (extra_hosts or
91+
otherwise) and drops the `hosts` parameter at every call site; the
92+
whole-script tests (`test_add_host_on_every_run`,
93+
`test_hostname_becomes_add_host_entry`,
94+
`test_container_name_becomes_add_host_entry`,
95+
`test_image_host_metadata_keys_compose_on_one_service`) are re-pointed at the
96+
single `podman pod create` line instead of every `podman run` line; the
97+
`extra_hosts` cases inside `test_registry_emission_order_across_shape_groups`
98+
(a `SERVICE_KEYS` order-lock test) are removed, since `extra_hosts` is no
99+
longer a `SERVICE_KEYS` entry. `just lint-ci` and `just check-planning`
100+
clean. Final proof: the integration harness's three scenarios (PR #41) go
101+
green in the dedicated CI job against real podman.
102+
103+
## Risk
104+
105+
- **Silent conflict-detection gap** (low x high): a host conflict slips
106+
through unmerged/undetected. Mitigated by one merge function, one rule,
107+
exercised directly in `pod.py` unit tests plus the end-to-end
108+
`emit_script` tests.
109+
- **Extra_hosts scope-broadening surprise** (med x low): a user relying on
110+
today's genuinely-per-service `extra_hosts` gets a same-host conflict
111+
error instead of silent divergence. Mitigated by the loud refusal (never a
112+
silently wrong resolution) and the existing `uses_pod_options` warning
113+
now also covering `extra_hosts`.

0 commit comments

Comments
 (0)