Skip to content
Merged
15 changes: 14 additions & 1 deletion architecture/supported-subset.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ warns (ignored, behavior-neutral inside a single pod) or raises
- **Supported:** `image`, `build`, `command`, `entrypoint`, `environment`,
`env_file`, `volumes`, `healthcheck`, `depends_on`, `networks`, `hostname`,
`container_name`, `tmpfs`, `user`, `working_dir`, `group_add`, `labels`,
`read_only`, `init`, `privileged`, `cap_add`, `cap_drop`, `security_opt`.
`read_only`, `init`, `privileged`, `cap_add`, `cap_drop`, `security_opt`,
`platform`, `devices`, `annotations`, `extra_hosts`, `pull_policy`.
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.
Expand Down Expand Up @@ -50,6 +51,18 @@ warns (ignored, behavior-neutral inside a single pod) or raises
- **`cap_add` / `cap_drop` / `security_opt`:** lists, emitted as repeated
`--cap-add` / `--cap-drop` / `--security-opt`. Item contents pass through
verbatim (no content validation), like `tmpfs` and named volumes.
- **`platform`:** a string, emitted verbatim as `--platform`.
- **`devices`:** a list, emitted as repeated `--device` (contents verbatim).
- **`annotations`:** list or mapping, emitted as repeated `--annotation`
(`KEY=value`, or bare `KEY` for a null value), sharing the `_MAP_FLAGS`
machinery with `labels`.
- **`extra_hosts`:** list (`- host:ip`) or mapping (`host: ip`), emitted as
per-service `--add-host host:ip`. Distinct from the alias/hostname entries
(which resolve to `127.0.0.1`); IPv6 values keep their colons.
- **`pull_policy`:** a validated enum mapped to podman's `--pull`
(`if_not_present` → `missing`; `always`/`never`/`missing` pass through),
emitted literally. `build` and unknown values are rejected — compose2pod
never builds, so a `build` pull policy cannot be honored.
- **`labels`:** list (`- KEY=value` / `- KEY`) or mapping (`KEY: value` / `KEY:`),
emitted as repeated `--label`. A null value means an empty label
(`--label KEY`) -- the same emitted shape as `environment`'s null but a
Expand Down
39 changes: 36 additions & 3 deletions compose2pod/emit.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

HEALTHY_WAIT_BUDGET_SECONDS = 120

_SCALAR_FLAGS: dict[str, str] = {"user": "--user", "working_dir": "--workdir"}
_SCALAR_FLAGS: dict[str, str] = {"user": "--user", "working_dir": "--workdir", "platform": "--platform"}

_BOOL_FLAGS: dict[str, str] = {"init": "--init", "read_only": "--read-only", "privileged": "--privileged"}

Expand All @@ -21,6 +21,16 @@
"cap_add": "--cap-add",
"cap_drop": "--cap-drop",
"security_opt": "--security-opt",
"devices": "--device",
}

_MAP_FLAGS: dict[str, str] = {"labels": "--label", "annotations": "--annotation"}

PULL_POLICY_MAP: dict[str, str] = {
"always": "always",
"never": "never",
"missing": "missing",
"if_not_present": "missing",
}


Expand Down Expand Up @@ -85,6 +95,13 @@ def _key_value_pairs(value: list[Any] | dict[str, Any]) -> list[Any]:
return [key if val is None else f"{key}={val}" for key, val in value.items()]


def _extra_host_pairs(value: list[Any] | dict[str, Any]) -> list[Any]:
"""Compose extra_hosts as 'host:ip' entries; map values keep their colons (IPv6-safe)."""
if isinstance(value, list):
return value
return [f"{host}:{ip}" for host, ip in value.items()]


def _add_env_flags(flags: list[Token], svc: dict[str, Any], project_dir: str) -> None:
"""Add -e and --env-file flags to the flags list."""
# A null environment value means "pass KEY through from the host" (bare `-e KEY`).
Expand Down Expand Up @@ -118,6 +135,19 @@ def _add_volume_flags(flags: list[Token], svc: dict[str, Any], project_dir: str)
flags += ["--tmpfs", _Expand(mount)]


def _add_extra_host_flags(flags: list[Token], svc: dict[str, Any]) -> None:
"""Add per-service --add-host flags from extra_hosts (explicit host:ip)."""
for entry in _extra_host_pairs(svc.get("extra_hosts") or []):
flags += ["--add-host", _Expand(str(entry))]


def _add_pull_policy_flag(flags: list[Token], svc: dict[str, Any]) -> None:
"""Add --pull from pull_policy (a validated enum, mapped to podman's vocabulary)."""
policy = svc.get("pull_policy")
if policy is not None:
flags += ["--pull", PULL_POLICY_MAP[policy]]


def _add_declarative_flags(flags: list[Token], svc: dict[str, Any]) -> None:
"""Add the scalar-, boolean-, list-, and label-driven flags to the flags list."""
for key, flag in _SCALAR_FLAGS.items():
Expand All @@ -129,8 +159,9 @@ def _add_declarative_flags(flags: list[Token], svc: dict[str, Any]) -> None:
for key, flag in _LIST_FLAGS.items():
for item in svc.get(key) or []:
flags += [flag, _Expand(str(item))]
for pair in _key_value_pairs(svc.get("labels") or {}):
flags += ["--label", _Expand(str(pair))]
for key, flag in _MAP_FLAGS.items():
for pair in _key_value_pairs(svc.get(key) or {}):
flags += [flag, _Expand(str(pair))]


def run_flags(name: str, svc: dict[str, Any], pod: str, hosts: list[str], project_dir: str) -> list[Token]:
Expand All @@ -142,6 +173,8 @@ def run_flags(name: str, svc: dict[str, Any], pod: str, hosts: list[str], projec
_add_volume_flags(flags, svc, project_dir)
_add_health_flags(flags, svc.get("healthcheck") or {})
_add_declarative_flags(flags, svc)
_add_extra_host_flags(flags, svc)
_add_pull_policy_flag(flags, svc)
return flags


Expand Down
29 changes: 24 additions & 5 deletions compose2pod/parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

from typing import Any

# PULL_POLICY_MAP is shared vocabulary: validation checks membership, emission maps values.
# Deliberate one-way import (emit.py never imports parsing.py), so no cycle.
from compose2pod.emit import PULL_POLICY_MAP
from compose2pod.exceptions import UnsupportedComposeError
from compose2pod.graph import depends_on
from compose2pod.healthcheck import has_healthcheck
Expand Down Expand Up @@ -31,6 +34,11 @@
"cap_add",
"cap_drop",
"security_opt",
"platform",
"devices",
"annotations",
"extra_hosts",
"pull_policy",
}
IGNORED_SERVICE_KEYS = {"ports", "restart", "stdin_open", "tty", "stop_signal", "stop_grace_period"}
SUPPORTED_HEALTHCHECK_KEYS = {"test", "interval", "timeout", "retries", "start_period"}
Expand Down Expand Up @@ -67,17 +75,18 @@ def _validate_service_volumes(name: str, svc: dict[str, Any]) -> None:

def _validate_service_forms(name: str, svc: dict[str, Any]) -> None:
"""Check the process/identity keys use their allowed Compose forms."""
for key in ("user", "working_dir"):
for key in ("user", "working_dir", "platform"):
if key in svc and not isinstance(svc[key], str):
msg = f"service {name!r}: '{key}' must be a string"
raise UnsupportedComposeError(msg)
for key in ("group_add", "cap_add", "cap_drop", "security_opt"):
for key in ("group_add", "cap_add", "cap_drop", "security_opt", "devices"):
if key in svc and not isinstance(svc[key], list):
msg = f"service {name!r}: '{key}' must be a list"
raise UnsupportedComposeError(msg)
if "labels" in svc and not isinstance(svc["labels"], list | dict):
msg = f"service {name!r}: 'labels' must be a list or mapping"
raise UnsupportedComposeError(msg)
for key in ("labels", "annotations", "extra_hosts"):
if key in svc and not isinstance(svc[key], list | dict):
msg = f"service {name!r}: '{key}' must be a list or mapping"
raise UnsupportedComposeError(msg)
if "entrypoint" in svc and not isinstance(svc["entrypoint"], str | list):
msg = f"service {name!r}: 'entrypoint' must be a string or list"
raise UnsupportedComposeError(msg)
Expand All @@ -87,6 +96,15 @@ def _validate_service_forms(name: str, svc: dict[str, Any]) -> None:
raise UnsupportedComposeError(msg)


def _validate_pull_policy(name: str, svc: dict[str, Any]) -> None:
"""Check pull_policy is a supported enum value (mapped to podman's --pull)."""
policy = svc.get("pull_policy")
if policy is not None and (not isinstance(policy, str) or policy not in PULL_POLICY_MAP):
allowed = "/".join(PULL_POLICY_MAP)
msg = f"service {name!r}: unsupported pull_policy {policy!r} (use {allowed})"
raise UnsupportedComposeError(msg)


def _validate_service(name: str, svc: dict[str, Any]) -> list[str]:
"""Validate one service; returns warnings, raises UnsupportedComposeError."""
warnings: list[str] = []
Expand All @@ -103,6 +121,7 @@ def _validate_service(name: str, svc: dict[str, Any]) -> list[str]:
_validate_service_healthcheck(name, svc)
_validate_service_volumes(name, svc)
_validate_service_forms(name, svc)
_validate_pull_policy(name, svc)
return warnings


Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
---
summary: Support platform, devices, annotations, extra_hosts, and pull_policy service keys; add a _MAP_FLAGS table (DRYing labels + annotations), a per-service extra_hosts add-host loop, and a validated pull_policy value map.
---

# Design: image, host, device, and metadata service keys

## Summary

Add five more Bucket A service keys: `platform` (scalar), `devices` (list),
`annotations` (key-value map), `extra_hosts` (per-service `--add-host`), and
`pull_policy` (validated enum). Four reuse the established table/`_Expand`
patterns; `extra_hosts` and `pull_policy` each get a small dedicated piece.
This is the third Bucket A bundle, after
`changes/2026-07-09.04-container-confinement-service-keys.md`.

## Motivation

These are the remaining low-friction Bucket A keys a real compose file reaches
for: pinning `platform`, mapping `devices`, tagging `annotations`, adding host
entries, or controlling image pull. All are per-container `podman run` flags
with no pod-level conflict. (The two Bucket A keys with real design nuance --
`ulimits` nested-map translation and `sysctls` net-namespace caveat -- are left
for a final bundle.)

## Design

**Emission (`emit.py`).**

- `platform` -> add to `_SCALAR_FLAGS` (`--platform`, value `_Expand`).
- `devices` -> add to `_LIST_FLAGS` (`--device`, repeated, items `_Expand`).
- `annotations` -> introduce `_MAP_FLAGS = {"labels": "--label", "annotations":
"--annotation"}`, refactoring the existing hardcoded `labels` loop in
`_add_declarative_flags` into a loop over this table (DRYs the two key-value
maps). Both go through `_key_value_pairs`, so a null map value yields a bare
`KEY` (empty label / empty annotation), consistent with today's `labels`.
- `extra_hosts` -> a small dedicated helper + loop emitting `--add-host
<host:ip>`. List form passes items through verbatim (already `host:ip`); map
form `{host: ip}` joins with `:`. IPv6 values keep their colons (no splitting
on `:`). Entries are `_Expand`. This is per-service and distinct from the
global aliases->`127.0.0.1` loop already in `run_flags`.
- `pull_policy` -> dedicated: map the Compose value to podman's `--pull`
(`if_not_present` -> `missing`; `always`/`never`/`missing` pass through) and
emit it as a **literal** token -- `pull_policy` is a control enum validated at
generation time, not an interpolated runtime string.

```python
_PULL_POLICY = {"always": "always", "never": "never", "missing": "missing", "if_not_present": "missing"}
```

**Validation (`parsing.py`, `_validate_service_forms`).** `platform` must be a
string; `devices` joins the list-keys loop; `annotations`/`extra_hosts` must be
a list or mapping; `pull_policy` must be a string whose value is a key of
`_PULL_POLICY`. `pull_policy: build` and any unknown value raise -- compose2pod
never builds, so a `build` pull policy cannot be honored (honest-subset;
`decisions/` not needed, the rejection message states the reason).

**Promote** the five keys into `architecture/supported-subset.md`.

## Non-goals

- `ulimits` (nested-map -> `name=soft:hard` translation) and `sysctls`
(net-namespace `net.*` are pod-level, like `dns`) -- final Bucket A bundle.
- `pull_policy: build` -- rejected, not reinterpreted.
- Interpolating `pull_policy` -- it is a validated literal enum.

## Testing

`just test-ci` at 100% line coverage. Per key: happy-path flag emission
(`platform`/`devices` verbatim; `annotations` key=value incl. null->bare key;
`extra_hosts` list passthrough + map `host:ip` join incl. an IPv6 value;
`pull_policy` value mapping incl. `if_not_present`->`missing`); form rejection
(non-string `platform`, non-list `devices`, non-list/map `annotations`, invalid
+ `build` `pull_policy`); `${VAR}` interpolation on a `devices`/`extra_hosts`
item reaching `referenced_variables()`; and that the `_MAP_FLAGS` refactor keeps
existing `labels` tests green. `just lint-ci` clean.

## Risk

- **`_MAP_FLAGS` labels refactor** (low x low): behavior-preserving; guarded by
the existing `labels` tests (map/list/null forms) staying green.
- **`extra_hosts` IPv6** (low x med): splitting on `:` would corrupt IPv6 IPs;
mitigated by joining (not splitting) and an explicit IPv6 test.
- **`pull_policy` value drift** (low x low): the podman `--pull` vocabulary
could change; mitigated by the explicit map + rejection of unknowns.
64 changes: 64 additions & 0 deletions tests/test_emit.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,26 @@ def test_labels_null_value_is_empty_label(self) -> None:
flags = run_flags("app", {"image": "x", "labels": {"empty": None}}, "p", [], "/b")
assert flags[4:6] == ["--label", _Expand("empty")]

def test_platform_flag(self) -> None:
flags = run_flags("app", {"image": "x", "platform": "linux/amd64"}, "p", [], "/b")
assert flags[4:6] == ["--platform", _Expand("linux/amd64")]

def test_devices_flag(self) -> None:
flags = run_flags("app", {"image": "x", "devices": ["/dev/fuse", "/dev/net/tun"]}, "p", [], "/b")
assert flags[4:8] == ["--device", _Expand("/dev/fuse"), "--device", _Expand("/dev/net/tun")]

def test_annotations_map_form(self) -> None:
flags = run_flags("app", {"image": "x", "annotations": {"com.example/team": "api"}}, "p", [], "/b")
assert flags[4:6] == ["--annotation", _Expand("com.example/team=api")]

def test_annotations_null_value_is_bare_key(self) -> None:
flags = run_flags("app", {"image": "x", "annotations": {"marker": None}}, "p", [], "/b")
assert flags[4:6] == ["--annotation", _Expand("marker")]

def test_labels_still_emit_after_map_flags_refactor(self) -> None:
flags = run_flags("app", {"image": "x", "labels": {"team": "api"}}, "p", [], "/b")
assert flags[4:6] == ["--label", _Expand("team=api")]

def test_read_only_flag(self) -> None:
flags = run_flags("app", {"image": "x", "read_only": True}, "p", [], "/b")
assert flags[4:5] == ["--read-only"]
Expand All @@ -143,6 +163,27 @@ def test_boolean_flag_false_or_absent_is_omitted(self) -> None:
assert "--read-only" not in flags
assert flags == ["--pod", "p", "--name", "p-app"]

def test_extra_hosts_list_form(self) -> None:
flags = run_flags("app", {"image": "x", "extra_hosts": ["db.local:10.0.0.5"]}, "p", [], "/b")
assert flags[4:6] == ["--add-host", _Expand("db.local:10.0.0.5")]

def test_extra_hosts_map_form(self) -> None:
flags = run_flags("app", {"image": "x", "extra_hosts": {"db.local": "10.0.0.5"}}, "p", [], "/b")
assert flags[4:6] == ["--add-host", _Expand("db.local:10.0.0.5")]

def test_extra_hosts_ipv6_value_keeps_colons(self) -> None:
flags = run_flags("app", {"image": "x", "extra_hosts": {"myhost": "2001:db8::1"}}, "p", [], "/b")
assert flags[4:6] == ["--add-host", _Expand("myhost:2001:db8::1")]

def test_pull_policy_maps_if_not_present_to_missing(self) -> None:
flags = run_flags("app", {"image": "x", "pull_policy": "if_not_present"}, "p", [], "/b")
assert flags[4:6] == ["--pull", "missing"]

def test_pull_policy_passthrough_values(self) -> None:
for value in ("always", "never", "missing"):
flags = run_flags("app", {"image": "x", "pull_policy": value}, "p", [], "/b")
assert flags[4:6] == ["--pull", value]


class TestImageAndCommand:
def test_build_service_uses_ci_image(self, chats_compose: dict) -> None:
Expand Down Expand Up @@ -399,6 +440,25 @@ def test_confinement_keys_compose_on_one_service(self) -> None:
):
assert fragment in script

def test_image_host_metadata_keys_compose_on_one_service(self) -> None:
svc = {
"image": "x",
"platform": "linux/amd64",
"devices": ["/dev/fuse"],
"annotations": {"team": "api"},
"extra_hosts": {"db.local": "10.0.0.5"},
"pull_policy": "if_not_present",
}
script = self._single(svc)
for fragment in (
'--platform "linux/amd64"',
'--device "/dev/fuse"',
'--annotation "team=api"',
'--add-host "db.local:10.0.0.5"',
"--pull missing",
):
assert fragment in script


class TestReferencedVariables:
def _options(self, command: str = "") -> EmitOptions:
Expand Down Expand Up @@ -455,3 +515,7 @@ def test_collects_from_entrypoint(self) -> None:
def test_collects_from_capability_and_security_lists(self) -> None:
compose = {"services": {"app": {"image": "x", "cap_add": ["${CAP}"], "security_opt": ["${OPT}"]}}}
assert referenced_variables(compose, self._options()) == ["CAP", "OPT"]

def test_collects_from_extra_hosts(self) -> None:
compose = {"services": {"app": {"image": "x", "extra_hosts": ["h:${HOST_IP}"]}}}
assert referenced_variables(compose, self._options()) == ["HOST_IP"]
34 changes: 34 additions & 0 deletions tests/test_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,29 @@ def test_labels_non_list_or_map_raises(self) -> None:
with pytest.raises(UnsupportedComposeError, match=r"'labels' must be a list or mapping"):
validate({"services": {"app": {"image": "x", "labels": "team=api"}}})

def test_platform_devices_annotations_accepted(self) -> None:
svc = {"image": "x", "platform": "linux/arm64", "devices": ["/dev/fuse"], "annotations": {"k": "v"}}
assert validate({"services": {"app": svc}}) == []

def test_platform_non_string_raises(self) -> None:
with pytest.raises(UnsupportedComposeError, match=r"'platform' must be a string"):
validate({"services": {"app": {"image": "x", "platform": ["linux/amd64"]}}})

def test_devices_non_list_raises(self) -> None:
with pytest.raises(UnsupportedComposeError, match=r"'devices' must be a list"):
validate({"services": {"app": {"image": "x", "devices": "/dev/fuse"}}})

def test_annotations_non_list_or_map_raises(self) -> None:
with pytest.raises(UnsupportedComposeError, match=r"'annotations' must be a list or mapping"):
validate({"services": {"app": {"image": "x", "annotations": "k=v"}}})

def test_extra_hosts_accepted(self) -> None:
assert validate({"services": {"app": {"image": "x", "extra_hosts": {"db.local": "10.0.0.5"}}}}) == []

def test_extra_hosts_non_list_or_map_raises(self) -> None:
with pytest.raises(UnsupportedComposeError, match=r"'extra_hosts' must be a list or mapping"):
validate({"services": {"app": {"image": "x", "extra_hosts": "db.local:10.0.0.5"}}})

def test_entrypoint_list_accepted(self) -> None:
assert validate({"services": {"app": {"image": "x", "entrypoint": ["run"]}}}) == []

Expand All @@ -190,3 +213,14 @@ def test_boolean_confinement_keys_accepted(self) -> None:
def test_read_only_non_bool_raises(self) -> None:
with pytest.raises(UnsupportedComposeError, match=r"'read_only' must be a boolean"):
validate({"services": {"app": {"image": "x", "read_only": "yes"}}})

def test_pull_policy_accepted(self) -> None:
assert validate({"services": {"app": {"image": "x", "pull_policy": "always"}}}) == []

def test_pull_policy_build_raises(self) -> None:
with pytest.raises(UnsupportedComposeError, match="pull_policy 'build'"):
validate({"services": {"app": {"image": "x", "pull_policy": "build"}}})

def test_pull_policy_unknown_raises(self) -> None:
with pytest.raises(UnsupportedComposeError, match="pull_policy 'sometimes'"):
validate({"services": {"app": {"image": "x", "pull_policy": "sometimes"}}})