Skip to content

Commit 8dcfd50

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 8dcfd50

9 files changed

Lines changed: 335 additions & 140 deletions

File tree

architecture/supported-subset.md

Lines changed: 50 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,12 @@ warns (ignored, behavior-neutral inside a single pod) or raises
3131
- **Service-key registry:** the declarative, uniformly-shaped flag keys —
3232
`user`, `working_dir`, `platform`, `init`, `read_only`, `privileged`,
3333
`group_add`, `cap_add`, `cap_drop`, `security_opt`, `devices`, `labels`,
34-
`annotations`, `extra_hosts`, `pull_policy`, `ulimits` — are defined once,
35-
each as a `(validate, emit)` pair, in the **service-key registry**
36-
(`SERVICE_KEYS` in `compose2pod/keys.py`); see `architecture/glossary.md`
37-
for the service-key spec / service-key registry / structural key terms.
34+
`annotations`, `pull_policy`, `ulimits` — are defined once, each as a
35+
`(validate, emit)` pair, in the **service-key registry** (`SERVICE_KEYS` in
36+
`compose2pod/keys.py`); see `architecture/glossary.md` for the service-key
37+
spec / service-key registry / structural key terms. `extra_hosts` is a
38+
supported key but not a registry entry — it is pod-level, see Pod-level
39+
options below.
3840
The remaining keys documented below are **structural keys**, handled
3941
outside the registry because their `emit` needs `project_dir`, spans
4042
multiple keys, or occupies the image/command slot.
@@ -67,9 +69,11 @@ warns (ignored, behavior-neutral inside a single pod) or raises
6769
- **`annotations`:** list or mapping, emitted as repeated `--annotation`
6870
(`KEY=value`, or bare `KEY` for a null value), sharing the `_MAP_FLAGS`
6971
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.
72+
- **`extra_hosts`:** list (`- host:ip`) or mapping (`host: ip`), pod-level like
73+
`dns`/`sysctls` — see the Pod-level options section below. Distinct from
74+
the alias/hostname set in one respect: alias/hostname resolution is
75+
always fixed at `127.0.0.1`, while `extra_hosts` carries a user-specified
76+
address; IPv6 values keep their colons.
7377
- **`pull_policy`:** a validated enum mapped to podman's `--pull`
7478
(`if_not_present``missing`; `always`/`never`/`missing` pass through),
7579
emitted literally. `build` and unknown values are rejected — compose2pod
@@ -181,36 +185,48 @@ it, so a handful of Compose keys cannot be per-container `podman run` flags.
181185
compose2pod hoists them onto `podman pod create` instead
182186
(`compose2pod/pod.py`) — the tool's only pod-create flags.
183187

184-
- **Supported:** `dns`, `dns_search`, `dns_opt`, `sysctls` — mapped to
185-
`--dns`, `--dns-search`, `--dns-option`, `--sysctl` respectively (`_DNS_KEYS`,
188+
- **Supported:** `dns`, `dns_search`, `dns_opt`, `sysctls`, `extra_hosts`
189+
mapped to `--dns`, `--dns-search`, `--dns-option`, `--sysctl`, and (merged
190+
with the alias/hostname set) `--add-host` respectively (`_DNS_KEYS`,
186191
`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.
192+
- **Aggregation is closure-scoped:** `pod_create_flags(services, order,
193+
hosts)` is called with `order` — the target's dependency closure
194+
(`startup_order`) — exactly like other closure-scoped constructs (secrets,
195+
configs). `dns` / `dns_search` / `dns_opt` are unioned across the closure
196+
(deduplicated, first-seen order); `sysctls` are unioned by key, and two
197+
services in the closure setting the same key to different values is
198+
refused (`UnsupportedComposeError: conflicting sysctl ...`) rather than
199+
resolved by last-writer-wins. `--add-host` is seeded from the
200+
alias/hostname set (`hosts`, computed document-wide by `graph.hostnames`,
201+
not closure-scoped — pre-existing, orthogonal behavior), then layered with
202+
each closure service's `extra_hosts` (closure-scoped like `dns`/`sysctls`);
203+
a host name landing on two different addresses — across two services'
204+
`extra_hosts`, or between an `extra_hosts` entry and an alias's fixed
205+
`127.0.0.1` — is refused (`UnsupportedComposeError: conflicting host ...`),
206+
the same refuse-rather-than-guess rule as the `sysctls` conflict.
195207
- **Value shapes:** `dns` / `dns_search` / `dns_opt` accept a string or a list
196208
of strings; `sysctls` accepts a mapping (`key: value`) or a list of
197209
`"key=value"` strings, each value a string or number. A `${VAR}` inside a
198210
value is wrapped in `_Expand` like other interpolated fields, so it stays
199211
live at run time and counts toward `referenced_variables` — the generated
200212
script's own shell expands it when it runs, not compose2pod at generation
201-
time.
213+
time. `--add-host` entries render differently by source: an alias/hostname
214+
entry stays a plain unquoted token (pre-existing behavior, unchanged by
215+
this move), while an `extra_hosts` entry goes through `_Expand` (quoted,
216+
`${VAR}`-live) — same as before it was per-service.
202217
- **Pod-wide divergence:** unlike every other service key, these apply to
203218
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
219+
declared them — because the pod shares one `/etc/resolv.conf`, one sysctl
220+
set, and one `/etc/hosts`. `validate()` (`compose2pod/parsing.py`) is
221+
target-agnostic shape validation over the whole document: whenever any
222+
service anywhere declares `dns` / `dns_search` / `dns_opt` / `sysctls` /
223+
`extra_hosts` (`uses_pod_options`), it emits the warning "dns/sysctls/
224+
extra_hosts apply pod-wide -- all containers in the pod share one
225+
/etc/resolv.conf, sysctl set, and /etc/hosts", regardless of whether that
210226
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.
227+
time a `dns` / `sysctls` / `extra_hosts` declaration on a service outside
228+
the target's closure is silently ignored by `pod_create_flags` — no flag
229+
is emitted for it, since that service is never run.
214230
- **Non-goals:** per-service DNS/sysctls — impossible inside a
215231
shared-namespace pod, not a compose2pod limitation; last-writer-wins on a
216232
sysctl key conflict — refused instead, matching the refuse-on-conflict
@@ -444,13 +460,13 @@ enumeration ever appears to drift:
444460
the healthcheck `test` command.
445461
- **Service-key registry fields** whose spec wraps its value in `_Expand`
446462
e.g. `user`, `working_dir`, `platform`, `group_add`, `cap_add`, `cap_drop`,
447-
`security_opt`, `devices`, `labels`, `annotations`, `extra_hosts`,
448-
`ulimits`, and every numeric resource-limit key (`mem_limit`, `cpus`,
449-
`pids_limit`, ...). The rule, not the list, is authoritative: this is every
450-
`SERVICE_KEYS` entry whose `emit` wraps its value (the
451-
`_scalar`/`_number_scalar`/`_list`/`_map` factories, plus the custom
452-
`extra_hosts`/`ulimits` emitters) except `pull_policy` (a validated enum
453-
emitted verbatim from `PULL_POLICY_MAP`) and the four boolean flags
463+
`security_opt`, `devices`, `labels`, `annotations`, `ulimits`, and every
464+
numeric resource-limit key (`mem_limit`, `cpus`, `pids_limit`, ...). The
465+
rule, not the list, is authoritative: this is every `SERVICE_KEYS` entry
466+
whose `emit` wraps its value (the `_scalar`/`_number_scalar`/`_list`/`_map`
467+
factories, plus the custom `ulimits` emitter) except `pull_policy` (a
468+
validated enum emitted verbatim from `PULL_POLICY_MAP`) and the four
469+
boolean flags
454470
`init`/`read_only`/`privileged`/`oom_kill_disable` (each emits a bare flag
455471
with no value to interpolate).
456472

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)

0 commit comments

Comments
 (0)