diff --git a/architecture/supported-subset.md b/architecture/supported-subset.md index 99002b0..ad25dba 100644 --- a/architecture/supported-subset.md +++ b/architecture/supported-subset.md @@ -281,12 +281,20 @@ flags. compose2pod hoists them onto `podman pod create` instead unioned by key, and two closure services setting the same key to different values is refused (`conflicting sysctl ...`) rather than resolved last-writer-wins. `--add-host` is seeded from the alias/hostname - set (document-wide, not closure-scoped — see `hostname`/`container_name`/ - `networks`, above), then layered with each closure service's + set of the closure's services, then layered with each closure service's `extra_hosts`; a host name landing on two different addresses is refused the same way (`conflicting host ...`). An alias/hostname `--add-host` entry stays a plain unquoted token; an `extra_hosts` entry is `${VAR}`-live. + + Only the closure joins the pod, so only the closure is resolvable: a + service outside it contributes no name and cannot conflict with an + `extra_hosts` entry. Resolving a never-run service's name to `127.0.0.1` + would point it at a port where nothing listens, turning an honest + resolution failure into a connection-refused. Shape validation of + `hostname`/`container_name`/`networks` stays document-wide at the gate + (`hostnames` in `parsing.py`), so a malformed value is still rejected on a + service the target never reaches. - **Pod-wide divergence:** unlike every other service key, these apply to every container in the pod once emitted — including services that never declared them — because the pod shares one `/etc/resolv.conf`, sysctl diff --git a/compose2pod/emit.py b/compose2pod/emit.py index 7e2a479..9d07166 100644 --- a/compose2pod/emit.py +++ b/compose2pod/emit.py @@ -272,8 +272,11 @@ def _plan(compose: dict[str, Any], options: EmitOptions) -> PlannedScript: msg = f"invalid pod name {options.pod!r}" raise UnsupportedComposeError(msg) services = compose["services"] - hosts = hostnames(services) order = startup_order(services, options.target) + # 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}) completion_gated = { dep for svc in services.values() diff --git a/planning/changes/2026-07-14.02-add-host-closure-scope.md b/planning/changes/2026-07-14.02-add-host-closure-scope.md new file mode 100644 index 0000000..7745a09 --- /dev/null +++ b/planning/changes/2026-07-14.02-add-host-closure-scope.md @@ -0,0 +1,69 @@ +--- +summary: The emit-side --add-host set is scoped to the target's dependency closure, so a service that never runs no longer resolves to 127.0.0.1 and can no longer veto another service's extra_hosts. +--- + +# Change: Scope --add-host to the target's closure + +**Lane:** lightweight — one-line change in `emit._plan`, plus tests. + +## Goal + +`--add-host` is the one aggregate in the emit path that is built document-wide +instead of closure-scoped, and the seam shows. `emit._plan` seeds hosts from +`graph.hostnames(services)` — every service in the *document* — while +`extra_hosts` is layered per service in `order`, the target's dependency +closure. `pod._add_host_flags` then conflict-checks the two against each other, +so a service that **never runs** can veto a valid configuration: + +```yaml +services: + app: {image: i, extra_hosts: ["db:1.2.3.4"]} + other: {image: i, hostname: db} # not in app's closure; never started +``` +→ `UnsupportedComposeError: service 'app': conflicting host 'db' +('127.0.0.1' vs '1.2.3.4')` + +The second symptom is quieter: a never-run service still gets an `--add-host` +entry pointing its name at `127.0.0.1`, where nothing is listening. That turns +an honest name-resolution failure into a connection-refused. + +Every other aggregate in the emit path — `dns`, `dns_search`, `dns_opt`, +`sysctls`, secrets, configs — is closure-scoped. This makes `--add-host` agree. + +## Approach + +`emit._plan` passes only the closure's services to `hostnames()`: + +```python +hosts = hostnames({name: services[name] for name in order}) +``` + +`hostnames()` has exactly two callers, and only this one changes: + +- `parsing.py` calls it document-wide to shape-check every service's + `hostname`/`container_name`/`networks` at the gate. That stays — validation + is target-agnostic, and scoping it would stop rejecting a malformed + `hostname` on a service outside the closure. +- `emit.py` calls it to build the `--add-host` set. That is the one that must + match the pod's actual contents. + +Truth home: `architecture/supported-subset.md`'s Pod-level options section, +which currently documents the document-wide behavior as "pre-existing, +orthogonal". + +## Files + +- `compose2pod/emit.py` — scope the `hostnames()` argument to `order` +- `architecture/supported-subset.md` — Pod-level options: `--add-host` is + closure-scoped like the rest +- `tests/test_emit.py` — tests added + +## Verification + +- [ ] Failing test first: an out-of-closure `hostname` colliding with an + in-closure `extra_hosts` must NOT raise; a never-run service must NOT + appear in `--add-host`. +- [ ] Apply the change. +- [ ] Tests pass. +- [ ] `just test-ci` — full suite green at 100%. +- [ ] `just lint-ci`, `just check-planning` — clean. diff --git a/tests/test_emit.py b/tests/test_emit.py index 4bc639e..70d8559 100644 --- a/tests/test_emit.py +++ b/tests/test_emit.py @@ -995,3 +995,59 @@ def test_referenced_variables_is_equally_guarded(self) -> None: # and must reject malformed input identically. with pytest.raises(UnsupportedComposeError): referenced_variables({}, self._options()) + + +class TestAddHostClosureScope: + """--add-host covers the target's closure, like every other emit-path aggregate.""" + + def _options(self, target: str) -> EmitOptions: + return EmitOptions( + target=target, + ci_image="ci:latest", + command="", + pod="test-pod", + project_dir=".", + artifacts=[], + allow_exit_codes=[], + ) + + def test_service_outside_the_closure_is_not_resolvable(self) -> None: + # A never-run service pointed its name at 127.0.0.1, where nothing listens. + compose = {"services": {"app": {"image": "x"}, "never_run": {"image": "x"}}} + script = emit_script(compose=compose, options=self._options("app")) + assert "--add-host app:127.0.0.1" in script + assert "never_run" not in script + + def test_out_of_closure_hostname_does_not_veto_extra_hosts(self) -> None: + # 'other' is not in app's closure, so its hostname cannot conflict with app's extra_hosts. + compose = { + "services": { + "app": {"image": "x", "extra_hosts": ["db:1.2.3.4"]}, + "other": {"image": "x", "hostname": "db"}, + } + } + script = emit_script(compose=compose, options=self._options("app")) + assert '--add-host "db:1.2.3.4"' in script + + def test_in_closure_hostname_still_conflicts_with_extra_hosts(self) -> None: + # The conflict rule still holds for services that actually run. + compose = { + "services": { + "app": {"image": "x", "extra_hosts": ["db:1.2.3.4"], "depends_on": ["db_svc"]}, + "db_svc": {"image": "x", "hostname": "db"}, + } + } + with pytest.raises(UnsupportedComposeError, match="conflicting host"): + emit_script(compose=compose, options=self._options("app")) + + def test_dependency_hostnames_and_aliases_still_resolve(self) -> None: + # Everything inside the closure keeps its add-host entry. + compose = { + "services": { + "app": {"image": "x", "depends_on": ["db"]}, + "db": {"image": "x", "hostname": "db-host", "networks": {"default": {"aliases": ["db-alias"]}}}, + } + } + script = emit_script(compose=compose, options=self._options("app")) + for host in ("app", "db", "db-host", "db-alias"): + assert f"--add-host {host}:127.0.0.1" in script