From 4b73c96006d19e8e3258b36de02c79ce5e8fc181 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 11 Jul 2026 13:26:51 +0300 Subject: [PATCH 1/5] docs: design compose resource limits support --- .../changes/2026-07-11.02-resource-limits.md | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 planning/changes/2026-07-11.02-resource-limits.md diff --git a/planning/changes/2026-07-11.02-resource-limits.md b/planning/changes/2026-07-11.02-resource-limits.md new file mode 100644 index 0000000..ad08a56 --- /dev/null +++ b/planning/changes/2026-07-11.02-resource-limits.md @@ -0,0 +1,111 @@ +--- +summary: Support compose CPU/memory resource limits both ways — ~13 legacy service keys (mem_limit, cpus, pids_limit, ...) as registry entries mapping to podman flags, plus a new resources.py that extracts the modern deploy.resources block (limits.cpus/memory/pids, reservations.memory) and refuses loudly on legacy/deploy conflicts and on knobs with no clean podman flag. +--- + +# Design: compose resource limits + +## Summary + +Convert compose CPU/memory resource limits to podman `run` flags, supporting +both the legacy top-level service keys (`mem_limit`, `cpus`, `pids_limit`, ...) +and the modern `deploy.resources` block. The legacy keys are flat scalar->flag +mappings, so they become registry entries in `keys.py`. The nested +`deploy.resources` block gets a small structural module `compose2pod/resources.py` +that extracts its limits/reservations into the same flags and refuses what has +no clean mapping. When a legacy key and its `deploy.resources` equivalent both +target the same podman flag, resolution refuses loudly rather than guessing a +precedence the compose spec does not define. + +## Motivation + +Resource limits are a common CI need (cap a test service's memory/CPU) and the +last flat-mappable Bucket B item from the coverage audit +(`audits/2026-07-09-compose-spec-coverage.md`), which scoped the design work as +"legacy-vs-`deploy` precedence and which of the ~20 cpu/mem knobs to honor." +Today every one of these keys, and the whole `deploy` block, is rejected as +unsupported. Real compose files split between the two forms — CI/older files +lean legacy, modern v3/compose-spec files use `deploy.resources` — so honoring +both avoids rejecting a whole class of documents. + +## Design + +**Legacy keys -> registry.** All podman flags verified against `podman-run.1`. +A new `keys.py` factory `_number_scalar(flag)` (validate: value is `int`/`float`/ +`str` and not `bool`; emit: `[flag, _Expand(str(value))]`) backs the 12 numeric/ +string keys; `oom_kill_disable` uses the existing `_bool`: + +| key | flag | | key | flag | +|---|---|---|---|---| +| `mem_limit` | `--memory` | | `cpu_period` | `--cpu-period` | +| `memswap_limit` | `--memory-swap` | | `cpuset` | `--cpuset-cpus` | +| `mem_reservation` | `--memory-reservation` | | `pids_limit` | `--pids-limit` | +| `mem_swappiness` | `--memory-swappiness` | | `shm_size` | `--shm-size` | +| `cpus` | `--cpus` | | `oom_score_adj` | `--oom-score-adj` | +| `cpu_shares` | `--cpu-shares` | | `oom_kill_disable` | `--oom-kill-disable` (bool) | +| `cpu_quota` | `--cpu-quota` | | | | + +Values pass through as `_Expand` (a `${VAR}` stays live at run time, like other +scalars); a bare int `mem_limit` is bytes, a `"512m"` string carries a unit — +podman accepts both. + +**`deploy.resources` -> `compose2pod/resources.py`.** `deploy` becomes a +structural key. The module owns: + +- `validate_deploy(name, svc)`: `deploy` must be a mapping whose only key is + `resources` (any other `deploy` subkey — `replicas`, `placement`, + `restart_policy`, ... — is refused, matching the audit's rejection of the + swarm keys). `resources` allows only `limits` / `reservations`. Under + `limits`, only `cpus` / `memory` / `pids`; under `reservations`, only + `memory` is honored — `reservations.cpus` and `reservations.devices` are + refused with a specific message (no clean podman flag; a cpu *reservation* is + a scheduler weight, not a limit). Every honored value is number-or-string. +- `deploy_resource_flags(svc)`: emits `--cpus` / `--memory` / `--pids-limit` + from `limits`, `--memory-reservation` from `reservations.memory`. + +`resources.py` imports only `keys` (for `_Expand`, `Token`) and `exceptions`; +`parsing.validate` calls `validate_deploy`; `emit.run_flags` appends +`deploy_resource_flags(svc)` alongside the registry and store flags. + +**Precedence — refuse on conflict.** The four flags reachable from both forms +(`--memory`, `--cpus`, `--pids-limit`, `--memory-reservation`) are conflict- +checked in `validate_deploy`: if the legacy key and its `deploy.resources` +counterpart are both present (`mem_limit` + `limits.memory`, `cpus` + +`limits.cpus`, `pids_limit` + `limits.pids`, `mem_reservation` + +`reservations.memory`), resolution refuses loudly. This sidesteps the +precedence the compose spec leaves undefined, without silently dropping or +double-emitting a flag. + +## Non-goals + +- `blkio_config` (block IO weights/limits) — complex nested schema, low CI + demand; stays rejected. +- Windows-only `cpu_count` / `cpu_percent` — no meaningful Linux/podman flag; + stay rejected. +- `deploy.resources.reservations.cpus` / `.devices` — no clean podman mapping; + refused loudly. +- Non-`resources` `deploy` subkeys (`replicas`, `placement`, `update_config`, + `restart_policy`, `mode`, ...) — refused (the audit's swarm-key rejection). +- Picking a legacy-vs-`deploy` precedence — refused-on-conflict instead. + +## Testing + +`just test-ci` at 100%: each legacy key emits its flag (int and unit-string +values; a `bool` value refused); `oom_kill_disable` true/false; each +`deploy.resources` field emits its flag; `reservations.cpus`/`.devices` and a +non-`resources` `deploy` key each refused with their message; each of the four +legacy/deploy conflicts refused; a service using only legacy, only deploy, or +non-overlapping halves of both accepted end to end through `validate` + +`emit_script`; the `test_keys` supported-keys snapshot updated for the new +registry + structural keys. `just lint-ci` clean (watch `C901` on +`validate_deploy` — split limits/reservations helpers if needed) and +`just check-planning`. + +## Risk + +- **Wrong flag or value passthrough** (low x med): every flag verified against + `podman-run.1`; values pass through unmodified (podman parses units), + covered by int-and-string tests. +- **Missed conflict pair** (low x med): only four flags overlap; each has an + explicit conflict test. +- **`C901` on `validate_deploy`** (low x low): extract a limits helper and a + reservations helper if it crosses the limit, as prior bundles did. From f1be01ac6774cce4ccf183882247331b07cf3d0f Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 11 Jul 2026 13:51:19 +0300 Subject: [PATCH 2/5] feat: map legacy compose resource-limit keys to podman flags --- compose2pod/keys.py | 26 ++++++++++++++++++++++++++ tests/test_emit.py | 21 +++++++++++++++++++++ tests/test_keys.py | 13 +++++++++++++ tests/test_parsing.py | 12 ++++++++++++ 4 files changed, 72 insertions(+) diff --git a/compose2pod/keys.py b/compose2pod/keys.py index dd07fb4..54175ff 100644 --- a/compose2pod/keys.py +++ b/compose2pod/keys.py @@ -56,6 +56,12 @@ def _validate_bool(name: str, key: str, value: Any) -> None: # noqa: ANN401 - C raise UnsupportedComposeError(msg) +def _validate_number(name: str, key: str, value: Any) -> None: # noqa: ANN401 - Compose values are untyped YAML/JSON + if isinstance(value, bool) or not isinstance(value, int | float | str): + msg = f"service {name!r}: '{key}' must be a number or string" + raise UnsupportedComposeError(msg) + + def _validate_list(name: str, key: str, value: Any) -> None: # noqa: ANN401 - Compose values are untyped YAML/JSON if not isinstance(value, list): msg = f"service {name!r}: '{key}' must be a list" @@ -82,6 +88,13 @@ def emit(value: Any) -> list[Token]: # noqa: ANN401 - Compose values are untype return KeySpec(validate=_validate_bool, emit=emit) +def _number_scalar(flag: str) -> KeySpec: + def emit(value: Any) -> list[Token]: # noqa: ANN401 - Compose values are untyped YAML/JSON + return [flag, _Expand(value=str(value))] + + return KeySpec(validate=_validate_number, emit=emit) + + def _list(flag: str) -> KeySpec: def emit(value: Any) -> list[Token]: # noqa: ANN401 - Compose values are untyped YAML/JSON tokens: list[Token] = [] @@ -180,6 +193,19 @@ def _emit_ulimits(value: Any) -> list[Token]: # noqa: ANN401 - Compose values a "extra_hosts": KeySpec(validate=_validate_map, emit=_emit_extra_hosts), "pull_policy": KeySpec(validate=_validate_pull_policy, emit=_emit_pull_policy), "ulimits": KeySpec(validate=_validate_ulimits, emit=_emit_ulimits), + "mem_limit": _number_scalar("--memory"), + "memswap_limit": _number_scalar("--memory-swap"), + "mem_reservation": _number_scalar("--memory-reservation"), + "mem_swappiness": _number_scalar("--memory-swappiness"), + "cpus": _number_scalar("--cpus"), + "cpu_shares": _number_scalar("--cpu-shares"), + "cpu_quota": _number_scalar("--cpu-quota"), + "cpu_period": _number_scalar("--cpu-period"), + "cpuset": _number_scalar("--cpuset-cpus"), + "pids_limit": _number_scalar("--pids-limit"), + "shm_size": _number_scalar("--shm-size"), + "oom_score_adj": _number_scalar("--oom-score-adj"), + "oom_kill_disable": _bool("--oom-kill-disable"), } STRUCTURAL_KEYS: set[str] = { diff --git a/tests/test_emit.py b/tests/test_emit.py index ac223e8..039e41b 100644 --- a/tests/test_emit.py +++ b/tests/test_emit.py @@ -104,6 +104,27 @@ def test_user_and_working_dir_order(self) -> None: flags = run_flags("app", svc, "p", [], "/b") assert flags[4:8] == ["--user", _Expand(value="root"), "--workdir", _Expand(value="/app")] + def test_mem_limit_flag(self) -> None: + flags = run_flags("app", {"image": "x", "mem_limit": "512m"}, "p", [], "/b") + assert flags[4:6] == ["--memory", _Expand(value="512m")] + + def test_cpus_numeric_value_flag(self) -> None: + flags = run_flags("app", {"image": "x", "cpus": 0.5}, "p", [], "/b") + assert flags[4:6] == ["--cpus", _Expand(value="0.5")] + + def test_pids_limit_int_flag(self) -> None: + flags = run_flags("app", {"image": "x", "pids_limit": 100}, "p", [], "/b") + assert flags[4:6] == ["--pids-limit", _Expand(value="100")] + + def test_cpuset_and_shm_size_flags(self) -> None: + svc = {"image": "x", "cpuset": "0-3", "shm_size": "64m"} + flags = run_flags("app", svc, "p", [], "/b") + assert flags[4:8] == ["--cpuset-cpus", _Expand(value="0-3"), "--shm-size", _Expand(value="64m")] + + def test_oom_kill_disable_bool_flag(self) -> None: + assert run_flags("app", {"image": "x", "oom_kill_disable": True}, "p", [], "/b")[4:5] == ["--oom-kill-disable"] + assert "--oom-kill-disable" not in run_flags("app", {"image": "x", "oom_kill_disable": False}, "p", [], "/b") + def test_group_add_flag(self) -> None: flags = run_flags("app", {"image": "x", "group_add": ["docker", 1000]}, "p", [], "/b") assert flags[4:8] == ["--group-add", _Expand(value="docker"), "--group-add", _Expand(value="1000")] diff --git a/tests/test_keys.py b/tests/test_keys.py index 810a587..540c7b1 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -39,4 +39,17 @@ def test_supported_service_keys_snapshot() -> None: "extra_hosts", "pull_policy", "ulimits", + "mem_limit", + "memswap_limit", + "mem_reservation", + "mem_swappiness", + "cpus", + "cpu_shares", + "cpu_quota", + "cpu_period", + "cpuset", + "pids_limit", + "shm_size", + "oom_score_adj", + "oom_kill_disable", } == SUPPORTED_SERVICE_KEYS diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 9d0871f..5b26e17 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -35,6 +35,18 @@ def test_non_dict_service_value_is_rejected(self) -> None: with pytest.raises(UnsupportedComposeError, match="must be a mapping"): validate({"services": {"web": None}}) + def test_resource_limits_accepted(self) -> None: + svc = {"image": "x", "mem_limit": "512m", "cpus": 0.5, "pids_limit": 100, "oom_kill_disable": True} + assert validate({"services": {"app": svc}}) == [] + + def test_mem_limit_bool_value_rejected(self) -> None: + with pytest.raises(UnsupportedComposeError, match=r"'mem_limit' must be a number or string"): + validate({"services": {"app": {"image": "x", "mem_limit": True}}}) + + def test_cpus_list_value_rejected(self) -> None: + with pytest.raises(UnsupportedComposeError, match=r"'cpus' must be a number or string"): + validate({"services": {"app": {"image": "x", "cpus": [1]}}}) + def test_unsupported_service_key_raises(self) -> None: with pytest.raises(UnsupportedComposeError, match="network_mode"): validate({"services": {"app": {"image": "x", "network_mode": "host"}}}) From a4f6d346faac98eac8c78b9dceb615ce0dc8d731 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 11 Jul 2026 13:57:53 +0300 Subject: [PATCH 3/5] feat: support compose deploy.resources with legacy-conflict refusal --- compose2pod/emit.py | 2 + compose2pod/keys.py | 1 + compose2pod/parsing.py | 2 + compose2pod/resources.py | 93 +++++++++++++++++++++++++++++++++ tests/test_emit.py | 8 +++ tests/test_keys.py | 1 + tests/test_parsing.py | 14 +++++ tests/test_resources.py | 110 +++++++++++++++++++++++++++++++++++++++ 8 files changed, 231 insertions(+) create mode 100644 compose2pod/resources.py create mode 100644 tests/test_resources.py diff --git a/compose2pod/emit.py b/compose2pod/emit.py index ea1775d..bed9aab 100644 --- a/compose2pod/emit.py +++ b/compose2pod/emit.py @@ -8,6 +8,7 @@ from compose2pod.graph import depends_on, hostnames, startup_order from compose2pod.healthcheck import health_cmd, interval_seconds from compose2pod.keys import SERVICE_KEYS, Token, _Expand, _key_value_pairs +from compose2pod.resources import deploy_resource_flags from compose2pod.shell import to_shell, variable_names from compose2pod.store import all_create_lines, all_flags, all_referenced_variables, all_teardown_names from compose2pod.stores import STORE_KINDS @@ -101,6 +102,7 @@ def run_flags(name: str, svc: dict[str, Any], pod: str, hosts: list[str], projec if key in svc: flags += spec.emit(svc[key]) flags += all_flags(svc, pod, STORE_KINDS) + flags += deploy_resource_flags(svc) return flags diff --git a/compose2pod/keys.py b/compose2pod/keys.py index 54175ff..6d656b6 100644 --- a/compose2pod/keys.py +++ b/compose2pod/keys.py @@ -224,4 +224,5 @@ def _emit_ulimits(value: Any) -> list[Token]: # noqa: ANN401 - Compose values a "container_name", "secrets", "configs", + "deploy", } diff --git a/compose2pod/parsing.py b/compose2pod/parsing.py index 50128e8..846bd71 100644 --- a/compose2pod/parsing.py +++ b/compose2pod/parsing.py @@ -6,6 +6,7 @@ from compose2pod.graph import depends_on, hostnames from compose2pod.healthcheck import has_healthcheck, interval_seconds from compose2pod.keys import SERVICE_KEYS, STRUCTURAL_KEYS +from compose2pod.resources import validate_deploy from compose2pod.store import validate_all from compose2pod.stores import STORE_KINDS @@ -87,6 +88,7 @@ def _validate_service(name: str, svc: Any) -> list[str]: # noqa: ANN401 - Compo _validate_service_volumes(name, svc) _validate_entrypoint(name, svc) _validate_tmpfs(name, svc) + validate_deploy(name, svc) for key, spec in SERVICE_KEYS.items(): if key in svc: spec.validate(name, key, svc[key]) diff --git a/compose2pod/resources.py b/compose2pod/resources.py new file mode 100644 index 0000000..ce2c8c4 --- /dev/null +++ b/compose2pod/resources.py @@ -0,0 +1,93 @@ +"""Extract compose deploy.resources limits/reservations into podman run flags.""" + +from typing import Any + +from compose2pod.exceptions import UnsupportedComposeError +from compose2pod.keys import Token, _Expand + + +# deploy.resources.limits. -> (podman flag, conflicting legacy key) +_LIMITS = {"cpus": ("--cpus", "cpus"), "memory": ("--memory", "mem_limit"), "pids": ("--pids-limit", "pids_limit")} + + +def _check_number(name: str, field: str, value: Any) -> None: # noqa: ANN401 - Compose values are untyped + if isinstance(value, bool) or not isinstance(value, int | float | str): + msg = f"service {name!r}: {field} must be a number or string" + raise UnsupportedComposeError(msg) + + +def _validate_limits(name: str, svc: dict[str, Any], limits: Any) -> None: # noqa: ANN401 - Compose values are untyped + if not isinstance(limits, dict): + msg = f"service {name!r}: deploy.resources.limits must be a mapping" + raise UnsupportedComposeError(msg) + unknown = set(limits) - set(_LIMITS) + if unknown: + msg = f"service {name!r}: deploy.resources.limits: unsupported keys {sorted(unknown)}" + raise UnsupportedComposeError(msg) + for field, (_flag, legacy) in _LIMITS.items(): + if field in limits: + _check_number(name, f"deploy.resources.limits.{field}", limits[field]) + if legacy in svc: + msg = f"service {name!r}: {legacy!r} conflicts with deploy.resources.limits.{field}" + raise UnsupportedComposeError(msg) + + +def _validate_reservations(name: str, svc: dict[str, Any], reservations: Any) -> None: # noqa: ANN401 - Compose values are untyped + if not isinstance(reservations, dict): + msg = f"service {name!r}: deploy.resources.reservations must be a mapping" + raise UnsupportedComposeError(msg) + unknown = set(reservations) - {"cpus", "memory", "devices"} + if unknown: + msg = f"service {name!r}: deploy.resources.reservations: unsupported keys {sorted(unknown)}" + raise UnsupportedComposeError(msg) + for field in ("cpus", "devices"): + if field in reservations: + msg = f"service {name!r}: deploy.resources.reservations.{field} is not supported (no podman equivalent)" + raise UnsupportedComposeError(msg) + if "memory" in reservations: + _check_number(name, "deploy.resources.reservations.memory", reservations["memory"]) + if "mem_reservation" in svc: + msg = f"service {name!r}: 'mem_reservation' conflicts with deploy.resources.reservations.memory" + raise UnsupportedComposeError(msg) + + +def validate_deploy(name: str, svc: dict[str, Any]) -> None: + """Validate a service's deploy block: only deploy.resources, only mappable fields, no legacy conflicts.""" + deploy = svc.get("deploy") + if deploy is None: + return + if not isinstance(deploy, dict): + msg = f"service {name!r}: 'deploy' must be a mapping" + raise UnsupportedComposeError(msg) + unknown = set(deploy) - {"resources"} + if unknown: + msg = f"service {name!r}: deploy: only 'resources' is supported (got {sorted(unknown)})" + raise UnsupportedComposeError(msg) + resources = deploy.get("resources") + if resources is None: + return + if not isinstance(resources, dict): + msg = f"service {name!r}: deploy.resources must be a mapping" + raise UnsupportedComposeError(msg) + unknown = set(resources) - {"limits", "reservations"} + if unknown: + msg = f"service {name!r}: deploy.resources: unsupported keys {sorted(unknown)}" + raise UnsupportedComposeError(msg) + if "limits" in resources: + _validate_limits(name, svc, resources["limits"]) + if "reservations" in resources: + _validate_reservations(name, svc, resources["reservations"]) + + +def deploy_resource_flags(svc: dict[str, Any]) -> list[Token]: + """Emit podman resource flags from a service's deploy.resources block.""" + resources = (svc.get("deploy") or {}).get("resources") or {} + limits = resources.get("limits") or {} + reservations = resources.get("reservations") or {} + tokens: list[Token] = [] + for field, (flag, _legacy) in _LIMITS.items(): + if field in limits: + tokens += [flag, _Expand(value=str(limits[field]))] + if "memory" in reservations: + tokens += ["--memory-reservation", _Expand(value=str(reservations["memory"]))] + return tokens diff --git a/tests/test_emit.py b/tests/test_emit.py index 039e41b..5ad6796 100644 --- a/tests/test_emit.py +++ b/tests/test_emit.py @@ -257,6 +257,14 @@ def test_registry_emission_order_across_shape_groups(self) -> None: _Expand(value="nofile=1024"), ] + def test_deploy_resource_flags_emitted(self) -> None: + svc = { + "image": "x", + "deploy": {"resources": {"limits": {"memory": "256m"}, "reservations": {"memory": "128m"}}}, + } + flags = run_flags("app", svc, "p", [], "/b") + assert flags[4:8] == ["--memory", _Expand(value="256m"), "--memory-reservation", _Expand(value="128m")] + class TestImageAndCommand: def test_build_service_uses_ci_image(self, chats_compose: dict) -> None: diff --git a/tests/test_keys.py b/tests/test_keys.py index 540c7b1..30b5f14 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -23,6 +23,7 @@ def test_supported_service_keys_snapshot() -> None: "container_name", "secrets", "configs", + "deploy", "user", "working_dir", "platform", diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 5b26e17..e951ee8 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -333,3 +333,17 @@ def test_relative_config_target_rejected_at_gate(self) -> None: } with pytest.raises(UnsupportedComposeError, match="must be an absolute path"): validate(doc) + + def test_deploy_resources_accepted(self) -> None: + svc = {"image": "x", "deploy": {"resources": {"limits": {"cpus": "0.5", "memory": "256m"}}}} + assert validate({"services": {"app": svc}}) == [] + + def test_deploy_legacy_conflict_rejected_at_gate(self) -> None: + svc = {"image": "x", "mem_limit": "512m", "deploy": {"resources": {"limits": {"memory": "256m"}}}} + with pytest.raises(UnsupportedComposeError, match=r"conflicts with deploy.resources.limits.memory"): + validate({"services": {"app": svc}}) + + def test_deploy_reservations_cpus_rejected_at_gate(self) -> None: + svc = {"image": "x", "deploy": {"resources": {"reservations": {"cpus": "0.5"}}}} + with pytest.raises(UnsupportedComposeError, match=r"reservations.cpus is not supported"): + validate({"services": {"app": svc}}) diff --git a/tests/test_resources.py b/tests/test_resources.py new file mode 100644 index 0000000..e72370d --- /dev/null +++ b/tests/test_resources.py @@ -0,0 +1,110 @@ +from typing import Any + +import pytest + +from compose2pod.exceptions import UnsupportedComposeError +from compose2pod.keys import _Expand +from compose2pod.resources import deploy_resource_flags, validate_deploy + + +def _svc(deploy: Any, **extra: Any) -> dict[str, Any]: # noqa: ANN401 - Compose values are untyped + return {"image": "x", "deploy": deploy, **extra} + + +class TestValidateDeploy: + def test_no_deploy_is_noop(self) -> None: + validate_deploy("app", {"image": "x"}) + + def test_full_resources_accepted(self) -> None: + deploy = { + "resources": {"limits": {"cpus": "0.5", "memory": "256m", "pids": 100}, "reservations": {"memory": "128m"}} + } + validate_deploy("app", _svc(deploy)) + + def test_deploy_not_mapping_rejected(self) -> None: + with pytest.raises(UnsupportedComposeError, match="'deploy' must be a mapping"): + validate_deploy("app", _svc(["resources"])) + + def test_non_resources_deploy_key_rejected(self) -> None: + with pytest.raises(UnsupportedComposeError, match="deploy: only 'resources' is supported"): + validate_deploy("app", _svc({"resources": {}, "replicas": 3})) + + def test_unsupported_resources_section_rejected(self) -> None: + with pytest.raises(UnsupportedComposeError, match=r"deploy.resources: unsupported keys"): + validate_deploy("app", _svc({"resources": {"foo": {}}})) + + def test_resources_absent_is_noop(self) -> None: + validate_deploy("app", _svc({"resources": None})) + + def test_resources_not_mapping_rejected(self) -> None: + with pytest.raises(UnsupportedComposeError, match=r"deploy.resources must be a mapping"): + validate_deploy("app", _svc({"resources": ["limits"]})) + + def test_limits_not_mapping_rejected(self) -> None: + with pytest.raises(UnsupportedComposeError, match=r"deploy.resources.limits must be a mapping"): + validate_deploy("app", _svc({"resources": {"limits": ["cpus"]}})) + + def test_reservations_not_mapping_rejected(self) -> None: + with pytest.raises(UnsupportedComposeError, match=r"deploy.resources.reservations must be a mapping"): + validate_deploy("app", _svc({"resources": {"reservations": ["memory"]}})) + + def test_unsupported_reservation_key_rejected(self) -> None: + with pytest.raises(UnsupportedComposeError, match=r"deploy.resources.reservations: unsupported keys"): + validate_deploy("app", _svc({"resources": {"reservations": {"gpus": 1}}})) + + def test_unsupported_limit_key_rejected(self) -> None: + with pytest.raises(UnsupportedComposeError, match=r"deploy.resources.limits: unsupported keys"): + validate_deploy("app", _svc({"resources": {"limits": {"gpus": 1}}})) + + def test_reservations_cpus_rejected(self) -> None: + with pytest.raises(UnsupportedComposeError, match=r"reservations.cpus is not supported"): + validate_deploy("app", _svc({"resources": {"reservations": {"cpus": "0.5"}}})) + + def test_reservations_devices_rejected(self) -> None: + with pytest.raises(UnsupportedComposeError, match=r"reservations.devices is not supported"): + validate_deploy("app", _svc({"resources": {"reservations": {"devices": []}}})) + + def test_limit_value_bool_rejected(self) -> None: + with pytest.raises(UnsupportedComposeError, match="must be a number or string"): + validate_deploy("app", _svc({"resources": {"limits": {"memory": True}}})) + + def test_memory_conflict_rejected(self) -> None: + deploy = {"resources": {"limits": {"memory": "256m"}}} + with pytest.raises(UnsupportedComposeError, match=r"'mem_limit' conflicts with deploy.resources.limits.memory"): + validate_deploy("app", _svc(deploy, mem_limit="512m")) + + def test_cpus_conflict_rejected(self) -> None: + deploy = {"resources": {"limits": {"cpus": "1"}}} + with pytest.raises(UnsupportedComposeError, match=r"'cpus' conflicts with deploy.resources.limits.cpus"): + validate_deploy("app", _svc(deploy, cpus="2")) + + def test_pids_conflict_rejected(self) -> None: + deploy = {"resources": {"limits": {"pids": 50}}} + with pytest.raises(UnsupportedComposeError, match=r"'pids_limit' conflicts with deploy.resources.limits.pids"): + validate_deploy("app", _svc(deploy, pids_limit=100)) + + def test_reservation_memory_conflict_rejected(self) -> None: + deploy = {"resources": {"reservations": {"memory": "128m"}}} + match = r"'mem_reservation' conflicts with deploy.resources.reservations.memory" + with pytest.raises(UnsupportedComposeError, match=match): + validate_deploy("app", _svc(deploy, mem_reservation="256m")) + + +class TestDeployResourceFlags: + def test_no_deploy_no_flags(self) -> None: + assert deploy_resource_flags({"image": "x"}) == [] + + def test_limits_emitted(self) -> None: + deploy = {"resources": {"limits": {"cpus": "0.5", "memory": "256m", "pids": 100}}} + assert deploy_resource_flags(_svc(deploy)) == [ + "--cpus", + _Expand(value="0.5"), + "--memory", + _Expand(value="256m"), + "--pids-limit", + _Expand(value="100"), + ] + + def test_reservation_memory_emitted(self) -> None: + deploy = {"resources": {"reservations": {"memory": "128m"}}} + assert deploy_resource_flags(_svc(deploy)) == ["--memory-reservation", _Expand(value="128m")] From f0c555f7c3f40605f381fdacaf0f54066bd867d2 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 11 Jul 2026 14:06:16 +0300 Subject: [PATCH 4/5] docs: record resource limits in the supported subset --- architecture/supported-subset.md | 57 ++++++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/architecture/supported-subset.md b/architecture/supported-subset.md index 1fde0be..aeb8f84 100644 --- a/architecture/supported-subset.md +++ b/architecture/supported-subset.md @@ -174,6 +174,45 @@ warns (ignored, behavior-neutral inside a single pod) or raises `configs`) are not auto-imported by `extends` — as in Compose, the extending service must declare what it needs. +## Resource limits + +Compose exposes container resource limits two ways — the legacy scalar +service keys and the Compose-spec `deploy.resources` block — and compose2pod +honors both, refusing loudly on overlap rather than picking a precedence. + +- **Legacy keys** (`SERVICE_KEYS` in `compose2pod/keys.py`) map straight onto + podman run flags: `mem_limit` → `--memory`, `memswap_limit` → + `--memory-swap`, `mem_reservation` → `--memory-reservation`, + `mem_swappiness` → `--memory-swappiness`, `cpus` → `--cpus`, `cpu_shares` → + `--cpu-shares`, `cpu_quota` → `--cpu-quota`, `cpu_period` → `--cpu-period`, + `cpuset` → `--cpuset-cpus`, `pids_limit` → `--pids-limit`, `shm_size` → + `--shm-size`, `oom_score_adj` → `--oom-score-adj`. Each of these twelve is a + number-scalar key (`_number_scalar`/`_validate_number`): the value is + passed through unchanged (a `${VAR}` inside stays live at run time, per + Variable interpolation, below); a non-number, non-string value — including + a bool — is refused. `oom_kill_disable` → `--oom-kill-disable` is the one + boolean-typed exception in this group: like `read_only`/`init`/`privileged`, + it validates as an actual bool (`_bool`/`_validate_bool`) and emits the bare + flag only when true (nothing when false or absent). +- **`deploy.resources`** (`compose2pod/resources.py`, wired in from + `validate_deploy`/`deploy_resource_flags` called by `parsing.py`/`emit.py`): + under `deploy`, only `resources` is honored — any other `deploy` subkey + (`replicas`, `placement`, `restart_policy`, ...) raises. Within `resources`, + only `limits` and `reservations` are read. `limits.cpus`/`limits.memory`/ + `limits.pids` map to `--cpus`/`--memory`/`--pids-limit`; + `reservations.memory` maps to `--memory-reservation`. `reservations.cpus` + and `reservations.devices` have no podman equivalent and are refused + outright, regardless of value. +- **Refuse on conflict:** when a legacy key and its `deploy.resources` + counterpart both set the same flag — `mem_limit`/`limits.memory`, + `cpus`/`limits.cpus`, `pids_limit`/`limits.pids`, and + `mem_reservation`/`reservations.memory` — conversion refuses loudly rather + than picking a precedence the Compose spec itself leaves undefined. +- **Non-goals:** `blkio_config` and the Windows-only `cpu_count`/ + `cpu_percent` remain rejected — neither key is in the service-key registry + or the structural-key set, so each hits the generic "everything else + raises" gate. + ## Healthcheck keys - **Supported:** `test`, `interval`, `timeout`, `retries`, `start_period`. @@ -359,13 +398,17 @@ enumeration ever appears to drift: override — otherwise the CI image is used, not the compose value), `command`, `entrypoint`, `environment`, `env_file`, `volumes`, `tmpfs`, and the healthcheck `test` command. -- **Service-key registry fields** whose spec wraps its value in `_Expand`: - `user`, `working_dir`, `platform`, `group_add`, `cap_add`, `cap_drop`, - `security_opt`, `devices`, `labels`, `annotations`, `extra_hosts`, and - `ulimits` — every `SERVICE_KEYS` entry except `pull_policy` (a validated - enum emitted verbatim from `PULL_POLICY_MAP`) and the three boolean flags - `init`/`read_only`/`privileged` (each emits a bare flag with no value to - interpolate). +- **Service-key registry fields** whose spec wraps its value in `_Expand` — + e.g. `user`, `working_dir`, `platform`, `group_add`, `cap_add`, `cap_drop`, + `security_opt`, `devices`, `labels`, `annotations`, `extra_hosts`, + `ulimits`, and every numeric resource-limit key (`mem_limit`, `cpus`, + `pids_limit`, ...). The rule, not the list, is authoritative: this is every + `SERVICE_KEYS` entry whose `emit` wraps its value (the + `_scalar`/`_number_scalar`/`_list`/`_map` factories, plus the custom + `extra_hosts`/`ulimits` emitters) except `pull_policy` (a validated enum + emitted verbatim from `PULL_POLICY_MAP`) and the four boolean flags + `init`/`read_only`/`privileged`/`oom_kill_disable` (each emits a bare flag + with no value to interpolate). Everything else is never interpolated: `build`'s own contents (context, dockerfile, args — never read), `depends_on`, `networks`, `hostname`, and From cbf38982d40e2c892118a975e589f81089216d67 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 11 Jul 2026 14:22:46 +0300 Subject: [PATCH 5/5] refactor: no-op empty deploy.resources blocks and share the number check --- compose2pod/keys.py | 6 +++++- compose2pod/resources.py | 8 ++++++-- tests/test_resources.py | 6 ++++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/compose2pod/keys.py b/compose2pod/keys.py index 6d656b6..5dbd722 100644 --- a/compose2pod/keys.py +++ b/compose2pod/keys.py @@ -56,8 +56,12 @@ def _validate_bool(name: str, key: str, value: Any) -> None: # noqa: ANN401 - C raise UnsupportedComposeError(msg) +def _is_number(value: Any) -> bool: # noqa: ANN401 - Compose values are untyped YAML/JSON + return not isinstance(value, bool) and isinstance(value, int | float | str) + + def _validate_number(name: str, key: str, value: Any) -> None: # noqa: ANN401 - Compose values are untyped YAML/JSON - if isinstance(value, bool) or not isinstance(value, int | float | str): + if not _is_number(value): msg = f"service {name!r}: '{key}' must be a number or string" raise UnsupportedComposeError(msg) diff --git a/compose2pod/resources.py b/compose2pod/resources.py index ce2c8c4..50e17a1 100644 --- a/compose2pod/resources.py +++ b/compose2pod/resources.py @@ -3,7 +3,7 @@ from typing import Any from compose2pod.exceptions import UnsupportedComposeError -from compose2pod.keys import Token, _Expand +from compose2pod.keys import Token, _Expand, _is_number # deploy.resources.limits. -> (podman flag, conflicting legacy key) @@ -11,12 +11,14 @@ def _check_number(name: str, field: str, value: Any) -> None: # noqa: ANN401 - Compose values are untyped - if isinstance(value, bool) or not isinstance(value, int | float | str): + if not _is_number(value): msg = f"service {name!r}: {field} must be a number or string" raise UnsupportedComposeError(msg) def _validate_limits(name: str, svc: dict[str, Any], limits: Any) -> None: # noqa: ANN401 - Compose values are untyped + if limits is None: + return if not isinstance(limits, dict): msg = f"service {name!r}: deploy.resources.limits must be a mapping" raise UnsupportedComposeError(msg) @@ -33,6 +35,8 @@ def _validate_limits(name: str, svc: dict[str, Any], limits: Any) -> None: # no def _validate_reservations(name: str, svc: dict[str, Any], reservations: Any) -> None: # noqa: ANN401 - Compose values are untyped + if reservations is None: + return if not isinstance(reservations, dict): msg = f"service {name!r}: deploy.resources.reservations must be a mapping" raise UnsupportedComposeError(msg) diff --git a/tests/test_resources.py b/tests/test_resources.py index e72370d..de3c0da 100644 --- a/tests/test_resources.py +++ b/tests/test_resources.py @@ -89,6 +89,12 @@ def test_reservation_memory_conflict_rejected(self) -> None: with pytest.raises(UnsupportedComposeError, match=match): validate_deploy("app", _svc(deploy, mem_reservation="256m")) + def test_null_limits_is_noop(self) -> None: + validate_deploy("app", _svc({"resources": {"limits": None}})) + + def test_null_reservations_is_noop(self) -> None: + validate_deploy("app", _svc({"resources": {"reservations": None}})) + class TestDeployResourceFlags: def test_no_deploy_no_flags(self) -> None: