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
12 changes: 10 additions & 2 deletions architecture/supported-subset.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion compose2pod/emit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
69 changes: 69 additions & 0 deletions planning/changes/2026-07-14.02-add-host-closure-scope.md
Original file line number Diff line number Diff line change
@@ -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.
56 changes: 56 additions & 0 deletions tests/test_emit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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