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
13 changes: 9 additions & 4 deletions architecture/supported-subset.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion compose2pod/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
1 change: 1 addition & 0 deletions compose2pod/parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
103 changes: 103 additions & 0 deletions planning/changes/2026-07-08.01-service-hostname.md
Original file line number Diff line number Diff line change
@@ -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 <hostname>: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 <name>: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 <hostname>: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: <h>`
round-trips through `validate` → `emit_script` and the rendered script
contains `--add-host <h>: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.
21 changes: 21 additions & 0 deletions tests/test_emit.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
image_for,
run_flags,
)
from compose2pod.parsing import validate


_EXPECTED_RUN_LINES: int = 4
Expand Down Expand Up @@ -140,6 +141,26 @@ 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=[],
)
assert validate(compose) == []
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",
Expand Down
8 changes: 8 additions & 0 deletions tests/test_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ 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"]

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:
Expand Down
4 changes: 4 additions & 0 deletions tests/test_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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) == []