From e9bd3e9e62d260ed3a2dd8f8dc4d668dd72a43f7 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Thu, 9 Jul 2026 15:58:17 +0300 Subject: [PATCH 1/7] docs: design image, host, device, and metadata service keys --- ...image-host-device-metadata-service-keys.md | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 planning/changes/2026-07-09.06-image-host-device-metadata-service-keys.md diff --git a/planning/changes/2026-07-09.06-image-host-device-metadata-service-keys.md b/planning/changes/2026-07-09.06-image-host-device-metadata-service-keys.md new file mode 100644 index 0000000..b7b28b3 --- /dev/null +++ b/planning/changes/2026-07-09.06-image-host-device-metadata-service-keys.md @@ -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 + `. 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. From 160c9b21cace1afdc9f0de6ea9d6d7a38252fb55 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Thu, 9 Jul 2026 16:10:07 +0300 Subject: [PATCH 2/7] feat: support platform, devices, annotations service keys --- compose2pod/emit.py | 10 +++++++--- compose2pod/parsing.py | 14 +++++++++----- tests/test_emit.py | 20 ++++++++++++++++++++ tests/test_parsing.py | 16 ++++++++++++++++ 4 files changed, 52 insertions(+), 8 deletions(-) diff --git a/compose2pod/emit.py b/compose2pod/emit.py index c301d07..0416102 100644 --- a/compose2pod/emit.py +++ b/compose2pod/emit.py @@ -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"} @@ -21,8 +21,11 @@ "cap_add": "--cap-add", "cap_drop": "--cap-drop", "security_opt": "--security-opt", + "devices": "--device", } +_MAP_FLAGS: dict[str, str] = {"labels": "--label", "annotations": "--annotation"} + @dataclasses.dataclass(frozen=True) class _Expand: @@ -129,8 +132,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]: diff --git a/compose2pod/parsing.py b/compose2pod/parsing.py index d1c6b54..780eeb0 100644 --- a/compose2pod/parsing.py +++ b/compose2pod/parsing.py @@ -31,6 +31,9 @@ "cap_add", "cap_drop", "security_opt", + "platform", + "devices", + "annotations", } IGNORED_SERVICE_KEYS = {"ports", "restart", "stdin_open", "tty", "stop_signal", "stop_grace_period"} SUPPORTED_HEALTHCHECK_KEYS = {"test", "interval", "timeout", "retries", "start_period"} @@ -67,17 +70,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"): + 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) diff --git a/tests/test_emit.py b/tests/test_emit.py index 53e2b81..4cb4721 100644 --- a/tests/test_emit.py +++ b/tests/test_emit.py @@ -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"] diff --git a/tests/test_parsing.py b/tests/test_parsing.py index da84614..453af2d 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -172,6 +172,22 @@ 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_entrypoint_list_accepted(self) -> None: assert validate({"services": {"app": {"image": "x", "entrypoint": ["run"]}}}) == [] From 8937f508cd9288ef87fe10f76506798caeb03f39 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Thu, 9 Jul 2026 16:24:55 +0300 Subject: [PATCH 3/7] feat: support extra_hosts service key --- compose2pod/emit.py | 14 ++++++++++++++ compose2pod/parsing.py | 3 ++- tests/test_emit.py | 16 ++++++++++++++++ tests/test_parsing.py | 7 +++++++ 4 files changed, 39 insertions(+), 1 deletion(-) diff --git a/compose2pod/emit.py b/compose2pod/emit.py index 0416102..69aa3a2 100644 --- a/compose2pod/emit.py +++ b/compose2pod/emit.py @@ -88,6 +88,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`). @@ -121,6 +128,12 @@ 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_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(): @@ -146,6 +159,7 @@ 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) return flags diff --git a/compose2pod/parsing.py b/compose2pod/parsing.py index 780eeb0..cec05a1 100644 --- a/compose2pod/parsing.py +++ b/compose2pod/parsing.py @@ -34,6 +34,7 @@ "platform", "devices", "annotations", + "extra_hosts", } IGNORED_SERVICE_KEYS = {"ports", "restart", "stdin_open", "tty", "stop_signal", "stop_grace_period"} SUPPORTED_HEALTHCHECK_KEYS = {"test", "interval", "timeout", "retries", "start_period"} @@ -78,7 +79,7 @@ def _validate_service_forms(name: str, svc: dict[str, Any]) -> None: if key in svc and not isinstance(svc[key], list): msg = f"service {name!r}: '{key}' must be a list" raise UnsupportedComposeError(msg) - for key in ("labels", "annotations"): + 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) diff --git a/tests/test_emit.py b/tests/test_emit.py index 4cb4721..cd00dc5 100644 --- a/tests/test_emit.py +++ b/tests/test_emit.py @@ -163,6 +163,18 @@ 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")] + class TestImageAndCommand: def test_build_service_uses_ci_image(self, chats_compose: dict) -> None: @@ -475,3 +487,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"] diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 453af2d..58575ae 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -188,6 +188,13 @@ 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"]}}}) == [] From 1c55676cb8612498b1d94530d1a03d80af7c44fe Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Thu, 9 Jul 2026 16:29:27 +0300 Subject: [PATCH 4/7] feat: support pull_policy service key --- compose2pod/emit.py | 15 +++++++++++++++ compose2pod/parsing.py | 7 +++++++ tests/test_emit.py | 9 +++++++++ tests/test_parsing.py | 11 +++++++++++ 4 files changed, 42 insertions(+) diff --git a/compose2pod/emit.py b/compose2pod/emit.py index 69aa3a2..1c2d269 100644 --- a/compose2pod/emit.py +++ b/compose2pod/emit.py @@ -26,6 +26,13 @@ _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", +} + @dataclasses.dataclass(frozen=True) class _Expand: @@ -134,6 +141,13 @@ def _add_extra_host_flags(flags: list[Token], svc: dict[str, Any]) -> None: 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(): @@ -160,6 +174,7 @@ def run_flags(name: str, svc: dict[str, Any], pod: str, hosts: list[str], projec _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 diff --git a/compose2pod/parsing.py b/compose2pod/parsing.py index cec05a1..73c1909 100644 --- a/compose2pod/parsing.py +++ b/compose2pod/parsing.py @@ -2,6 +2,7 @@ from typing import Any +from compose2pod.emit import PULL_POLICY_MAP from compose2pod.exceptions import UnsupportedComposeError from compose2pod.graph import depends_on from compose2pod.healthcheck import has_healthcheck @@ -35,6 +36,7 @@ "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"} @@ -90,6 +92,11 @@ def _validate_service_forms(name: str, svc: dict[str, Any]) -> None: if key in svc and not isinstance(svc[key], bool): msg = f"service {name!r}: '{key}' must be a boolean" raise UnsupportedComposeError(msg) + 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]: diff --git a/tests/test_emit.py b/tests/test_emit.py index cd00dc5..114af49 100644 --- a/tests/test_emit.py +++ b/tests/test_emit.py @@ -175,6 +175,15 @@ 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: diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 58575ae..3e132ff 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -213,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"}}}) From ad11fe491e955e05f380739c45f7229a25b30c11 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Thu, 9 Jul 2026 16:33:21 +0300 Subject: [PATCH 5/7] refactor: extract pull_policy validation to keep _validate_service_forms under complexity limit --- compose2pod/parsing.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/compose2pod/parsing.py b/compose2pod/parsing.py index 73c1909..beda0e2 100644 --- a/compose2pod/parsing.py +++ b/compose2pod/parsing.py @@ -92,6 +92,10 @@ def _validate_service_forms(name: str, svc: dict[str, Any]) -> None: if key in svc and not isinstance(svc[key], bool): msg = f"service {name!r}: '{key}' must be a boolean" 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) @@ -115,6 +119,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 From a322f61352e65b57898686ee23130704dcba3946 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Thu, 9 Jul 2026 16:37:31 +0300 Subject: [PATCH 6/7] docs: promote image, host, device, and metadata keys to supported subset --- architecture/supported-subset.md | 15 ++++++++++++++- tests/test_emit.py | 19 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/architecture/supported-subset.md b/architecture/supported-subset.md index 75ed00d..18b7bc6 100644 --- a/architecture/supported-subset.md +++ b/architecture/supported-subset.md @@ -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. @@ -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 diff --git a/tests/test_emit.py b/tests/test_emit.py index 114af49..70e03df 100644 --- a/tests/test_emit.py +++ b/tests/test_emit.py @@ -440,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: From 1368eec9f41a1a2cab134f265481eaec4f2afc1e Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Thu, 9 Jul 2026 16:43:50 +0300 Subject: [PATCH 7/7] docs: note the deliberate one-way parsing->emit import --- compose2pod/parsing.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/compose2pod/parsing.py b/compose2pod/parsing.py index beda0e2..8256d63 100644 --- a/compose2pod/parsing.py +++ b/compose2pod/parsing.py @@ -2,6 +2,8 @@ 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