From 052153c94457f567b9619eacaef49c15043767f9 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Wed, 8 Jul 2026 16:48:52 +0300 Subject: [PATCH 1/5] docs: plan service hostname key support --- .../changes/2026-07-08.01-service-hostname.md | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 planning/changes/2026-07-08.01-service-hostname.md diff --git a/planning/changes/2026-07-08.01-service-hostname.md b/planning/changes/2026-07-08.01-service-hostname.md new file mode 100644 index 0000000..6b8895d --- /dev/null +++ b/planning/changes/2026-07-08.01-service-hostname.md @@ -0,0 +1,103 @@ +--- +summary: Accept the service `hostname` key, resolving it to 127.0.0.1 like a network alias. +--- + +# Design: Support the service `hostname` key + +## Summary + +compose2pod rejects any service key it does not explicitly support, so a +service that sets `hostname:` fails with +`service 'keydb': unsupported key 'hostname'`. This change adds `hostname` to +the supported service keys and folds each service's hostname into +`graph.hostnames()`, so it becomes an `--add-host :127.0.0.1` entry +exactly like a network alias. The change touches `compose2pod/parsing.py` and +`compose2pod/graph.py`, plus the `architecture/supported-subset.md` promotion. + +## Motivation + +A real-world CI compose file sets a hostname on its KeyDB service: + +```yaml +keydb: + image: .../keydb-for-ci:x86_64_v6.3.0 + hostname: keydb-test-server-0 + restart: always +``` + +The application connects to KeyDB at `keydb-test-server-0`, so that name is +**load-bearing** — if it does not resolve to the pod's shared loopback, the +connection fails. `validate()` (`parsing.py:57`) rejects the document outright +because `hostname` is in neither `SUPPORTED_SERVICE_KEYS` nor +`IGNORED_SERVICE_KEYS`. + +This is the same reachability need compose2pod already serves for network +aliases: `hostnames()` collects service names and `networks[*].aliases`, and +`emit.run_flags` emits `--add-host :127.0.0.1` for each so every +container in the shared network namespace resolves those names to loopback. +The `chats_compose` test fixture even models this exact hostname +(`keydb-test-server-0`) as a network alias today; the only gap is the `hostname` +spelling of the same intent. + +## Design + +### 1. Accept `hostname` as a supported service key (`parsing.py`) + +Add `"hostname"` to `SUPPORTED_SERVICE_KEYS`. No dedicated validation: the value +is a plain string consumed by `hostnames()`, handled identically to alias +strings (which carry no type validation either). + +### 2. Collect the hostname in `graph.hostnames()` + +Inside the existing per-service loop, append `svc.get("hostname")` when present, +alongside the alias collection: + +```python +for svc in services.values(): + hostname = svc.get("hostname") + if hostname: + names.append(hostname) + networks = svc.get("networks") + ... +``` + +The hostname joins the flat, pod-global host list, so every container gets an +`--add-host :127.0.0.1` entry — the correct behavior in a single +shared-namespace pod, where all inter-service traffic is loopback. + +### 3. Promote into `architecture/supported-subset.md` + +Add `hostname` to the Supported service keys list, with a note that it is made +resolvable to loopback like a network alias (the pod shares the UTS namespace, +so the container's own hostname is the pod's; only name resolution is +meaningful). + +## Non-goals + +- **No per-container `--hostname` flag.** Pod members share the UTS namespace by + default, so a per-service hostname is not settable/meaningful; only name + resolution matters, and `--add-host` covers it. +- **Not "ignore with a warning"** (the `ports`/`restart` treatment). The + hostname is load-bearing — dropping it silently would emit a script that + fails at connect time, violating the "refuse loudly, never silently drop + behavior" principle. It is genuinely supported, not ignored. +- No change to how aliases or any other service key are handled. + +## Testing + +TDD, and `just test-ci` must stay at 100% line coverage. + +- Unit (`test_parsing.py`): a service with `hostname` validates without raising. +- Unit (`test_graph.py`): `hostnames()` includes a service's `hostname` value in + its output. +- Integration (`test_emit.py`): a compose document with `hostname: ` + round-trips through `validate` → `emit_script` and the rendered script + contains `--add-host :127.0.0.1`. + +## Risk + +Low. The change mirrors an established, tested mechanism (network aliases) and +is confined to two small edits plus a doc promotion. The main risk is a +hostname colliding with an existing service name or alias, producing a duplicate +`--add-host` for the same loopback target — harmless (identical mapping), and no +worse than the existing duplicate-alias possibility. From 0c07087dabbe3aeacd4dd355e9578cf067c1e754 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Wed, 8 Jul 2026 16:54:18 +0300 Subject: [PATCH 2/5] feat: accept service 'hostname' key in validate --- compose2pod/parsing.py | 1 + tests/test_parsing.py | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/compose2pod/parsing.py b/compose2pod/parsing.py index 6d63362..1747940 100644 --- a/compose2pod/parsing.py +++ b/compose2pod/parsing.py @@ -17,6 +17,7 @@ "healthcheck", "depends_on", "networks", + "hostname", } IGNORED_SERVICE_KEYS = {"ports", "restart", "stdin_open", "tty"} SUPPORTED_HEALTHCHECK_KEYS = {"test", "interval", "timeout", "retries", "start_period"} diff --git a/tests/test_parsing.py b/tests/test_parsing.py index e95ba7d..975e103 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -107,3 +107,7 @@ def test_service_extension_key_is_accepted_silently(self) -> None: def test_healthcheck_extension_key_is_accepted(self) -> None: compose = {"services": {"app": {"image": "x", "healthcheck": {"test": "true", "x-note": "n"}}}} assert validate(compose) == [] + + def test_service_hostname_is_accepted(self) -> None: + compose = {"services": {"keydb": {"image": "x", "hostname": "keydb-test-server-0"}}} + assert validate(compose) == [] From 2826af6ebb8449e6f257080aa09cdc2160ec1c3d Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Wed, 8 Jul 2026 16:57:47 +0300 Subject: [PATCH 3/5] feat: collect service hostname as a resolvable pod host --- compose2pod/graph.py | 5 ++++- tests/test_graph.py | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/compose2pod/graph.py b/compose2pod/graph.py index 9a78142..25bed02 100644 --- a/compose2pod/graph.py +++ b/compose2pod/graph.py @@ -14,9 +14,12 @@ def depends_on(svc: dict[str, Any]) -> dict[str, str]: def hostnames(services: dict[str, Any]) -> list[str]: - """All names other services may use to reach a service: names, then aliases.""" + """All names other services may use to reach a service: names, hostnames, then aliases.""" names = list(services) for svc in services.values(): + hostname = svc.get("hostname") + if hostname: + names.append(hostname) networks = svc.get("networks") if isinstance(networks, dict): for network in networks.values(): diff --git a/tests/test_graph.py b/tests/test_graph.py index f9ddd49..15c83ca 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -29,6 +29,10 @@ def test_non_dict_network_entry_is_skipped(self) -> None: services = {"app": {"image": "x", "networks": {"default": None, "other": {"aliases": ["app-alias"]}}}} assert hostnames(services) == ["app", "app-alias"] + def test_collects_service_hostname(self) -> None: + services = {"keydb": {"image": "x", "hostname": "keydb-test-server-0"}} + assert hostnames(services) == ["keydb", "keydb-test-server-0"] + class TestStartupOrder: def test_chats_order(self, chats_compose: dict) -> None: From 5447161f6a7a05e19399525dc94a49570f53618c Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Wed, 8 Jul 2026 17:02:07 +0300 Subject: [PATCH 4/5] feat: document and cover service hostname pod-host resolution --- architecture/supported-subset.md | 13 +++++++++---- tests/test_emit.py | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/architecture/supported-subset.md b/architecture/supported-subset.md index 63dd67c..2088007 100644 --- a/architecture/supported-subset.md +++ b/architecture/supported-subset.md @@ -20,10 +20,15 @@ warns (ignored, behavior-neutral inside a single pod) or raises ## Service keys - **Supported:** `image`, `build`, `command`, `environment`, `env_file`, - `volumes`, `healthcheck`, `depends_on`, `networks`. compose2pod never builds: - a `build` section is accepted but its contents (context, dockerfile, args) are - not read — `image_for` (`compose2pod/emit.py`) runs the CI image supplied via - `--image` for any service that has one. + `volumes`, `healthcheck`, `depends_on`, `networks`, `hostname`. compose2pod + never builds: a `build` section is accepted but its contents (context, + dockerfile, args) are not read — `image_for` (`compose2pod/emit.py`) runs + the CI image supplied via `--image` for any service that has one. +- **`hostname`:** the service's hostname is made resolvable to `127.0.0.1` + like a network alias (added to the shared `--add-host` set), so other + services can reach it by that name. The pod shares the UTS namespace, so a + service's own hostname is the pod's; only name resolution is meaningful, + and no per-container `--hostname` is emitted. - **Ignored (warns):** `ports`, `restart`, `stdin_open`, `tty` — meaningless or irrelevant inside a single shared-namespace pod. - **Extension fields:** any `x-`-prefixed service key is accepted and ignored diff --git a/tests/test_emit.py b/tests/test_emit.py index c0d792d..6dc8dd1 100644 --- a/tests/test_emit.py +++ b/tests/test_emit.py @@ -140,6 +140,25 @@ def test_wait_healthy_function_uses_healthcheck_run(self, chats_compose: dict) - assert "podman healthcheck run" in script assert "wait_healthy()" in script + def test_hostname_becomes_add_host_entry(self) -> None: + compose = { + "services": { + "application": {"image": "app", "depends_on": ["keydb"]}, + "keydb": {"image": "keydb", "hostname": "keydb-test-server-0"}, + } + } + options = EmitOptions( + target="application", + ci_image="ci", + command="", + pod="testpod", + project_dir="/proj", + artifacts=[], + allow_exit_codes=[], + ) + script = emit_script(compose=compose, options=options) + assert "--add-host keydb-test-server-0:127.0.0.1" in script + def test_target_without_command_uses_service_command(self, chats_compose: dict) -> None: options = EmitOptions( target="application", From c8f868aa4f2122d53a60b797dbcbe9591aac26ae Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Wed, 8 Jul 2026 17:08:40 +0300 Subject: [PATCH 5/5] test: round-trip validate in hostname emit test and cover empty hostname --- tests/test_emit.py | 2 ++ tests/test_graph.py | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/tests/test_emit.py b/tests/test_emit.py index 6dc8dd1..b6816ab 100644 --- a/tests/test_emit.py +++ b/tests/test_emit.py @@ -5,6 +5,7 @@ image_for, run_flags, ) +from compose2pod.parsing import validate _EXPECTED_RUN_LINES: int = 4 @@ -156,6 +157,7 @@ def test_hostname_becomes_add_host_entry(self) -> None: artifacts=[], allow_exit_codes=[], ) + assert validate(compose) == [] script = emit_script(compose=compose, options=options) assert "--add-host keydb-test-server-0:127.0.0.1" in script diff --git a/tests/test_graph.py b/tests/test_graph.py index 15c83ca..1e73d7e 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -33,6 +33,10 @@ def test_collects_service_hostname(self) -> None: services = {"keydb": {"image": "x", "hostname": "keydb-test-server-0"}} assert hostnames(services) == ["keydb", "keydb-test-server-0"] + def test_empty_hostname_is_skipped(self) -> None: + services = {"a": {"image": "x", "hostname": ""}} + assert hostnames(services) == ["a"] + class TestStartupOrder: def test_chats_order(self, chats_compose: dict) -> None: