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
48 changes: 48 additions & 0 deletions architecture/supported-subset.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,54 @@ warns (ignored, behavior-neutral inside a single pod) or raises
silently.
- Everything else raises.

## Extends

- **Resolution timing:** `extends` is resolved by `resolve_extends`
(`compose2pod/extends.py`) as a pre-validation flattening step, called from
`cli.py` immediately before `validate()` — the rest of the pipeline
(`validate()`, `emit_script()`) never sees an `extends` key.
- **Supported form:** only the mapping form `extends: {service: <name>}`;
`service` must name another service in the same document. Resolution is
transitive (a chain of `extends` is fully flattened) and cycle-checked — an
`extends` cycle raises `UnsupportedComposeError`.
- **Merge rules**, applied per key when both the base service and the local
(extending) service define it:
- **Mapping-merge, local wins:** `environment`, `labels`, `annotations`,
`extra_hosts`, `ulimits`, `healthcheck`, `depends_on` — base's and local's
keys are combined into one mapping, with local's value winning on
collision.
- **Sequence-concatenate, base then local:** `cap_add`, `cap_drop`,
`security_opt`, `devices`, `group_add`, `secrets`, `configs`, `volumes`,
`tmpfs`, `env_file` — base's list is followed by local's list, unchanged.
- **Override, local replaces:** every other key, including `command` and
`entrypoint` (argv replaced wholesale, never concatenated) — this also
covers unknown keys, which `validate()` then rejects downstream exactly
as it would without `extends`.
- **Normalization before merge:** list-form `environment`
(`- KEY=value` / `- KEY`) and list-form `depends_on` (a bare
service-name list) are normalized to mappings before the mapping-merge;
scalar-form `tmpfs`/`env_file` (a single string) are normalized to a
one-element list before the concatenation.
- **Refused loudly** (`UnsupportedComposeError`), rather than guessed at:
cross-file `extends: {file: ..., service: ...}`; a bare-string `extends`
(or any other non-mapping value); an unrecognized key under `extends`
other than `service`; a non-string `service`; an `extends` `service`
naming a service that doesn't exist in the document; and a merge across
incompatible forms — a mapping-merge key that is neither a mapping nor a
list-form `environment`/`depends_on`, or a sequence-concatenate key that is
neither a list nor a scalar string.
- **Divergences from Compose:**
- Only `environment` and `depends_on` accept list form for the
mapping-merge; the other mapping-merge keys (`labels`, `annotations`,
`extra_hosts`, `ulimits`, `healthcheck`) in list form on a merged side are
refused as an incompatible form rather than silently coerced.
- Short-form `volumes` are concatenated rather than merged by target path;
podman resolves duplicate mounts at run time rather than compose2pod
deduplicating them at generation time.
- Referenced resources (top-level `volumes`, `networks`, `secrets`,
`configs`) are not auto-imported by `extends` — as in Compose, the
extending service must declare what it needs.

## Healthcheck keys

- **Supported:** `test`, `interval`, `timeout`, `retries`, `start_period`.
Expand Down
2 changes: 2 additions & 0 deletions compose2pod/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from compose2pod.emit import EmitOptions, emit_script
from compose2pod.exceptions import UnsupportedComposeError
from compose2pod.extends import resolve_extends
from compose2pod.parsing import validate
from compose2pod.shell import to_shell

Expand All @@ -10,6 +11,7 @@
"EmitOptions",
"UnsupportedComposeError",
"emit_script",
"resolve_extends",
"to_shell",
"validate",
]
2 changes: 2 additions & 0 deletions compose2pod/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from compose2pod.emit import EmitOptions, emit_script, referenced_variables
from compose2pod.exceptions import UnsupportedComposeError
from compose2pod.extends import resolve_extends
from compose2pod.parsing import validate


Expand Down Expand Up @@ -97,6 +98,7 @@ def main(argv: list[str] | None = None) -> int:
allow_exit_codes=args.allow_exit_code,
)
try:
compose = resolve_extends(compose)
warnings = validate(compose)
script = emit_script(compose=compose, options=options)
except UnsupportedComposeError as error:
Expand Down
127 changes: 127 additions & 0 deletions compose2pod/extends.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
"""Resolve same-file compose `extends`: flatten each service's inheritance."""

from typing import Any

from compose2pod.exceptions import UnsupportedComposeError


_MERGE_KEYS = {"environment", "labels", "annotations", "extra_hosts", "ulimits", "healthcheck", "depends_on"}
_CONCAT_KEYS = {
"cap_add",
"cap_drop",
"security_opt",
"devices",
"group_add",
"secrets",
"configs",
"volumes",
"tmpfs",
"env_file",
}


def _extends_target(name: str, ext: Any) -> str: # noqa: ANN401 - Compose values are untyped
"""Return the referenced service name, after refusing cross-file and malformed forms."""
if not isinstance(ext, dict):
msg = f"service {name!r}: 'extends' must be a mapping with a 'service' key"
raise UnsupportedComposeError(msg)
if "file" in ext:
msg = f"service {name!r}: extends with 'file:' (cross-file) is not supported"
raise UnsupportedComposeError(msg)
unknown = set(ext) - {"service"}
if unknown:
msg = f"service {name!r}: unsupported 'extends' keys {sorted(unknown)}"
raise UnsupportedComposeError(msg)
service = ext.get("service")
if not isinstance(service, str):
msg = f"service {name!r}: extends 'service' must be a string"
raise UnsupportedComposeError(msg)
return service


def _env_list_to_map(items: list[Any]) -> dict[str, Any]:
"""Normalize a `[KEY=value, BARE]` environment list to a mapping (bare -> None)."""
result: dict[str, Any] = {}
for item in items:
key, sep, value = str(item).partition("=")
result[key] = value if sep else None
return result


def _as_mapping(key: str, name: str, value: Any) -> dict[str, Any]: # noqa: ANN401 - Compose values are untyped
if isinstance(value, dict):
return value
if isinstance(value, list):
if key == "environment":
return _env_list_to_map(value)
if key == "depends_on":
return {dep: {} for dep in value}
msg = f"service {name!r}: cannot merge {key!r} across incompatible forms"
raise UnsupportedComposeError(msg)


def _as_list(key: str, name: str, value: Any) -> list[Any]: # noqa: ANN401 - Compose values are untyped
if isinstance(value, list):
return list(value)
if isinstance(value, str):
return [value]
msg = f"service {name!r}: cannot merge {key!r} across incompatible forms"
raise UnsupportedComposeError(msg)


def _merge(base: dict[str, Any], local: dict[str, Any], name: str) -> dict[str, Any]:
"""Merge `local` onto `base` per key category: mapping-merge, sequence-concat, else override."""
merged: dict[str, Any] = dict(base)
for key, local_val in local.items():
if key in base and key in _MERGE_KEYS:
merged[key] = {**_as_mapping(key, name, base[key]), **_as_mapping(key, name, local_val)}
elif key in base and key in _CONCAT_KEYS:
merged[key] = _as_list(key, name, base[key]) + _as_list(key, name, local_val)
else:
merged[key] = local_val
return merged


def resolve_extends(compose: Any) -> Any: # noqa: ANN401 - Compose values are untyped
"""Return a new document with every service's same-file `extends` flattened.

Transitive and cycle-checked; cross-file (`file:`) extends is refused. A
non-dict document or non-dict `services` is returned unchanged for
`validate()` to reject.
"""
if not isinstance(compose, dict):
return compose
services = compose.get("services")
if not isinstance(services, dict):
return compose
resolved: dict[str, Any] = {}
resolving: set[str] = set()

def resolve(name: str) -> Any: # noqa: ANN401 - Compose values are untyped
if name in resolved:
return resolved[name]
svc = services[name]
ext = svc.get("extends") if isinstance(svc, dict) else None
if ext is None:
resolved[name] = svc
return svc
if name in resolving:
msg = f"extends cycle involving {name!r}"
raise UnsupportedComposeError(msg)
resolving.add(name)
base_name = _extends_target(name, ext)
if base_name not in services:
msg = f"service {name!r}: extends unknown service {base_name!r}"
raise UnsupportedComposeError(msg)
base = resolve(base_name)
local = {key: value for key, value in svc.items() if key != "extends"}
resolving.discard(name)
if not isinstance(base, dict):
resolved[name] = local
return local
merged = _merge(base, local, name)
resolved[name] = merged
return merged

new_services = {name: resolve(name) for name in services}
return {**compose, "services": new_services}
5 changes: 4 additions & 1 deletion compose2pod/parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,11 @@ def _validate_tmpfs(name: str, svc: dict[str, Any]) -> None:
raise UnsupportedComposeError(msg)


def _validate_service(name: str, svc: dict[str, Any]) -> list[str]:
def _validate_service(name: str, svc: Any) -> list[str]: # noqa: ANN401 - Compose values are untyped
"""Validate one service; returns warnings, raises UnsupportedComposeError."""
if not isinstance(svc, dict):
msg = f"service {name!r} must be a mapping"
raise UnsupportedComposeError(msg)
warnings: list[str] = []
for key in sorted(svc):
if key.startswith("x-"):
Expand Down
112 changes: 112 additions & 0 deletions planning/changes/2026-07-11.01-extends.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
---
summary: Resolve same-file compose `extends` in a new pure `extends.py` module that flattens each service's inheritance (transitive, cycle-checked, per-key merge: override/merge/concat) before validation; cross-file `file:` extends is refused loudly to keep the single-document input model.
---

# Design: compose extends

## Summary

Support the common form of compose `extends` — a service inheriting another
service's config within the same document. A new pure module
`compose2pod/extends.py` exposes `resolve_extends(compose) -> compose` that
flattens every service's `extends` away (transitive, cycle-checked, per-key
merge) before `validate()` runs, so the rest of the pipeline never sees
`extends`. A cross-file `file:` extends is refused loudly, keeping compose2pod's
single-document input model intact.

## Motivation

`extends` is a widely used authoring convenience: a base service holds shared
config and derived services override or add a few keys. Today `extends` is an
unsupported service key, so any document using it is rejected outright. The
prior coverage audit (`audits/2026-07-09-compose-spec-coverage.md`) places
`extends` in Bucket B (build) and flags its one hard part — the multi-file
`file:` model — as "the largest design here." Resolving `extends` natively
(rather than telling users to pre-flatten with `docker compose config`) keeps
compose2pod self-contained and preserves its deferred `${VAR}` model, which
`docker compose config` would otherwise resolve at config time.

## Design

**Module.** `compose2pod/extends.py`, a pure function
`resolve_extends(compose: dict[str, Any]) -> dict[str, Any]` returning a new
document with every service's `extends` flattened. Same-file only, so no
filesystem access and no `--project-dir` dependency. Import direction: `cli` ->
`extends`; `extends` -> `exceptions` only.

**Pipeline.** `cli.py` calls it between reading and validating:

```python
compose = _read_compose(text, args.format)
compose = resolve_extends(compose) # flatten inheritance
warnings = validate(compose) # never sees `extends`
script = emit_script(compose=compose, options=options)
```

A library caller that skips `resolve_extends` still gets a clean rejection:
`validate()` treats a leftover `extends` as an unsupported service key.

**Resolution.** For each service carrying `extends`:

- The value must be the mapping form `{service: <name>, file?: ...}`. A `file:`
key is refused (`extends with 'file:' (cross-file) is not supported`); a
bare-string form is refused (not valid in the current spec); unknown keys
under `extends` are refused.
- `service:` must name a service in the same document; unknown -> refuse.
- Resolution is **transitive** (resolve the base first), and a **cycle** raises
`UnsupportedComposeError` (`extends cycle: a -> b -> a`).
- The flattened service is `merge(resolved_base, local)` with `extends`
stripped.

**Merge semantics** (per-key category, matching the spec for compose2pod's
supported subset):

- **CONCAT** (base then local): `cap_add`, `cap_drop`, `security_opt`,
`devices`, `group_add`, `secrets`, `configs`, `volumes`, `tmpfs`, `env_file`.
A scalar form (e.g. `tmpfs: /run`) is normalized to a one-element list first.
- **MERGE** (key-by-key, local wins): `environment`, `labels`, `annotations`,
`extra_hosts`, `ulimits`, `healthcheck`, and mapping-form `depends_on`.
List-form `environment`/`depends_on` is normalized to a mapping for the merge.
- **OVERRIDE** (local replaces wholesale) — the default, covering scalars
(`image`, `build`, `user`, `working_dir`, `platform`, `pull_policy`,
`hostname`, `container_name`, `networks`), bools (`init`, `read_only`,
`privileged`), and crucially the argv lists `command`/`entrypoint`, which must
not concatenate.
- **Unknown keys default to OVERRIDE** — safe, because `validate()` rejects any
unsupported key afterward regardless of how it merged.

**The honesty boundary.** Where a faithful merge is not possible — a
MERGE-category key that is neither a mapping nor a normalizable list on one
side, or a CONCAT-category key that is neither a scalar nor a list — resolution
refuses loudly (`cannot merge 'environment' for service 'web': incompatible
forms`) instead of guessing.

## Non-goals

- `file:` / cross-file extends — refused; a later follow-up if demand appears.
- Bare-string `extends` — not valid in the current compose spec.
- Spec-exact volume merge-by-target-path — short-form `volumes` concatenate and
podman resolves duplicate mount points; a documented divergence.
- Auto-importing referenced resources (top-level volumes/networks/secrets) — as
in Compose, the extending service must declare what it needs.

## Testing

`just test-ci` at 100%: single-level and transitive resolution; each merge
category (concat / merge / override) including `command` override and
`environment` map-and-list normalization; cycle detection; `file:` refusal;
unknown `service:` refusal; incompatible-form refusal; a resolved document then
passing `validate()` and `emit_script()` end to end; and a document with no
`extends` passing through byte-identical. `just lint-ci` clean and
`just check-planning`.

## Risk

- **Merge misclassification** (med x med): a key merged with the wrong category
yields wrong output. Mitigated by the explicit category tables, the OVERRIDE
default backstopped by `validate()`, and per-category tests.
- **`C901` on the merge function** (low x low): split by category (a small
helper per CONCAT/MERGE/OVERRIDE) if it crosses the limit, as prior bundles
did.
- **Ambiguous-form input** (low x med): refused loudly rather than guessed, so a
surprising merge never ships silently.
34 changes: 34 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ def test_exports(self) -> None:
"EmitOptions",
"UnsupportedComposeError",
"emit_script",
"resolve_extends",
"to_shell",
"validate",
}
Expand Down Expand Up @@ -187,6 +188,39 @@ def test_env_file_path_variable_is_live_and_noted(
assert "${ENV_DIR-}" in out.out
assert "ENV_DIR" in out.err

def test_extends_is_resolved_before_emit(
self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
) -> None:
doc = {
"services": {
"base": {"image": "app", "environment": {"LOG": "info"}},
"web": {"extends": {"service": "base"}, "environment": {"PORT": "8080"}},
}
}
rc = run_main(json.dumps(doc), ["--target", "web", "--image", "i"], monkeypatch)
out = capsys.readouterr().out
assert rc == 0
# merged web runs the base image with both env vars
assert "app" in out
assert "LOG=info" in out
assert "PORT=8080" in out

def test_cross_file_extends_is_rejected_by_cli(
self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
) -> None:
doc = {"services": {"web": {"extends": {"service": "base", "file": "other.yml"}}}}
rc = run_main(json.dumps(doc), ["--target", "web", "--image", "i"], monkeypatch)
assert rc == EXIT_USAGE_ERROR
assert "cross-file" in capsys.readouterr().err

def test_extends_non_dict_base_is_clean_error(
self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
) -> None:
doc = {"services": {"base": None, "web": {"extends": {"service": "base"}, "image": "x"}}}
rc = run_main(json.dumps(doc), ["--target", "web", "--image", "i"], monkeypatch)
assert rc == EXIT_USAGE_ERROR
assert "must be a mapping" in capsys.readouterr().err

def test_yaml_anchor_extension_fields_convert(
self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
) -> None:
Expand Down
Loading