From bcecbc42a86908f2f7b91e9e449fc15f9d3a1a3a Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Thu, 9 Jul 2026 17:03:06 +0300 Subject: [PATCH 1/4] docs: design ulimits service key and reclassify sysctls as pod-level --- .../2026-07-09-compose-spec-coverage.md | 24 +++--- .../2026-07-09.07-ulimits-service-key.md | 79 +++++++++++++++++++ .../decisions/2026-07-09-sysctls-pod-level.md | 52 ++++++++++++ planning/deferred.md | 15 ++++ 4 files changed, 161 insertions(+), 9 deletions(-) create mode 100644 planning/changes/2026-07-09.07-ulimits-service-key.md create mode 100644 planning/decisions/2026-07-09-sysctls-pod-level.md diff --git a/planning/audits/2026-07-09-compose-spec-coverage.md b/planning/audits/2026-07-09-compose-spec-coverage.md index 32292d0..29aaad3 100644 --- a/planning/audits/2026-07-09-compose-spec-coverage.md +++ b/planning/audits/2026-07-09-compose-spec-coverage.md @@ -52,16 +52,22 @@ network namespace, so none conflicts with the pod. | `platform` | `--platform` | Direct | | `ulimits` | `--ulimit` | soft/hard map form | | `annotations` | `--annotation` | Direct | -| `sysctls` | `--sysctl` | net-namespace sysctls are pod-level; non-net ones are per-container | -**Shipped:** `entrypoint`, `user`, `working_dir`, `labels`, `group_add` -(`changes/2026-07-09.03`); `read_only`, `init`, `privileged`, `cap_add`, -`cap_drop`, `security_opt` (`changes/2026-07-09.04`). - -**Moved out:** `stop_signal`/`stop_grace_period` were listed here but are inert -under the `podman pod rm -f` force teardown (no graceful `podman stop` is ever -emitted), so they are reclassified accepted-but-inert — see -`decisions/2026-07-09-stop-lifecycle-keys-inert.md`. +**Shipped (all of the above):** `entrypoint`, `user`, `working_dir`, `labels`, +`group_add` (`changes/2026-07-09.03`); `read_only`, `init`, `privileged`, +`cap_add`, `cap_drop`, `security_opt` (`changes/2026-07-09.04`); `platform`, +`devices`, `annotations`, `extra_hosts`, `pull_policy` (`changes/2026-07-09.06`); +`ulimits` (`changes/2026-07-09.07`). Per-container Bucket A is complete. + +**Moved out:** +- `stop_signal`/`stop_grace_period` — inert under the `podman pod rm -f` force + teardown (no graceful `podman stop` is ever emitted), reclassified + accepted-but-inert (warn + ignore). See + `decisions/2026-07-09-stop-lifecycle-keys-inert.md`. +- `sysctls` — pod-level, not per-container: only namespaced sysctls (`net.*`, + the IPC `kernel.*`/`fs.mqueue.*` set) are settable, and the pod owns net + ipc, + so they belong on `podman pod create --sysctl`. Deferred with `dns`; keeps + raising. See `decisions/2026-07-09-sysctls-pod-level.md` and `deferred.md`. ## Bucket B — meaningful, needs translation or pod-wide reconciliation diff --git a/planning/changes/2026-07-09.07-ulimits-service-key.md b/planning/changes/2026-07-09.07-ulimits-service-key.md new file mode 100644 index 0000000..b4a8699 --- /dev/null +++ b/planning/changes/2026-07-09.07-ulimits-service-key.md @@ -0,0 +1,79 @@ +--- +summary: Support the ulimits service key (nested-map and scalar forms translated to podman --ulimit name=soft:hard); reclassify sysctls out of Bucket A as pod-level, deferred alongside dns. +--- + +# Design: ulimits service key (and sysctls reclassification) + +## Summary + +Add `ulimits`, the last clean per-container Bucket A key: a mapping of limit +name to a scalar or a `{soft, hard}` mapping, translated to podman's +`--ulimit name=value` / `--ulimit name=soft:hard`. In the same change, reclassify +`sysctls` out of Bucket A: it is pod-level (net/ipc-namespaced) and cannot be an +honest per-container flag, so it stays refused and is deferred alongside `dns`. + +## Motivation + +`ulimits` is a common CI knob (raising `nofile` for a service under load) and +maps cleanly to a per-container `podman run` flag with no pod interaction. +`sysctls` looked like a sibling in the audit, but a podman-docs check showed the +only settable sysctls are namespaced to net or ipc, and a Podman pod shares both +by default -- so they belong on `podman pod create --sysctl`, pod-wide, not on a +per-container run. This finishes the per-container Bucket A work honestly. + +## Design + +**`ulimits` emission (`emit.py`).** A dedicated helper (not the `_*_FLAGS` +tables -- the value shape is bespoke): + +```python +def _ulimit_args(ulimits: dict[str, Any]) -> list[str]: + args = [] + for limit, spec in ulimits.items(): + args.append(f"{limit}={spec['soft']}:{spec['hard']}" if isinstance(spec, dict) else f"{limit}={spec}") + return args +``` + +`_add_ulimit_flags(flags, svc)` emits `["--ulimit", _Expand(arg)]` per arg and is +called from `run_flags` beside the other `_add_*` helpers. `_Expand` keeps +`${VAR}` live and routes the args through `referenced_variables()` for free. + +**`ulimits` validation (`parsing.py`).** A dedicated `_validate_ulimits(name, +svc)` helper -- kept out of `_validate_service_forms`, which already sits at the +ruff `C901` complexity limit (adding `pull_policy` there tripped it last bundle; +`ulimits`'s nested logic would too). `ulimits` must be a mapping; each value is +an `int`/`str` scalar or a mapping with **exactly** `soft` and `hard`; anything +else raises. Add `ulimits` to `SUPPORTED_SERVICE_KEYS`; promote into +`architecture/supported-subset.md`. + +**`sysctls` reclassification (docs only -- it already raises).** Podman only +permits namespaced sysctls (`net.*` for the network namespace; a fixed +`kernel.*`/`fs.mqueue.*` set for the IPC namespace) and rejects them when that +namespace is not owned by the container -- which it never is inside a shared-namespace +pod. So `sysctls` is pod-level, like `dns`. It stays refused (not ignored -- +dropping it silently would lose requested behavior, unlike `stop_signal`). +Recorded in `decisions/2026-07-09-sysctls-pod-level.md`, `deferred.md` (with +`dns`), and the audit. + +## Non-goals + +- Building `sysctls` (pod-level hoisting to `podman pod create --sysctl`) -- + deferred with `dns`; needs the pod-level-aggregation pattern. +- `ulimits` scalar `name=value` does not expand to `name=value:value` -- podman + already treats a single value as both soft and hard. + +## Testing + +`just test-ci` at 100% line coverage. `ulimits`: mapping form +(`nofile={soft}:{hard}`), scalar form (`nproc=N`), and both on one service; +rejections (non-mapping `ulimits`; a mapping value missing `soft` or `hard`, or +with extra keys; a list-valued limit); `${VAR}` interpolation reaching +`referenced_variables()`. `just lint-ci` clean (watch `_validate_service_forms` +complexity -- the new validation is a separate helper for exactly this reason). + +## Risk + +- **`_validate_service_forms` C901** (med x low): mitigated by putting `ulimits` + validation in its own `_validate_ulimits` helper from the start. +- **`sysctls` should-it-warn-or-raise** (low x low): kept raising, matching `dns`; + the decision records why (not behavior-neutral). diff --git a/planning/decisions/2026-07-09-sysctls-pod-level.md b/planning/decisions/2026-07-09-sysctls-pod-level.md new file mode 100644 index 0000000..7346eb0 --- /dev/null +++ b/planning/decisions/2026-07-09-sysctls-pod-level.md @@ -0,0 +1,52 @@ +--- +status: accepted +summary: sysctls is reclassified out of Bucket A as pod-level (net/ipc-namespaced sysctls belong on podman pod create, not a per-container run); it stays refused and is deferred alongside dns. +supersedes: null +superseded_by: null +--- + +# sysctls is pod-level, not per-container + +**Decision:** `sysctls` is not supported as a per-container flag and is +reclassified out of Bucket A. The only sysctls podman lets a container set are +namespaced to the network or IPC namespace, and a Podman pod owns both, so +sysctls belong on `podman pod create --sysctl` (pod-wide) -- the same shape as +`dns`. It stays refused (raises), and pod-level support is deferred alongside +`dns`. + +## Context + +The spec-coverage audit (`audits/2026-07-09-compose-spec-coverage.md`) listed +`sysctls` in Bucket A as a per-container `--sysctl` flag, with a note that +`net.*` are pod-level. Revisiting during the `ulimits` bundle +(`changes/2026-07-09.07-ulimits-service-key.md`), the podman docs show the note +understated it: + +- For the **network** namespace, only `net.*` sysctls are allowed, and only if + that namespace is owned by the container. +- For the **IPC** namespace, only a fixed set (`kernel.msgmax`, `kernel.sem`, + `kernel.shm*`, `fs.mqueue.*`, ...) is allowed, and only if owned. +- Non-namespaced sysctls cannot be set in a container at all. + +A Podman pod shares net + ipc by default, so a container joining the pod owns +neither namespace. Every settable sysctl is therefore pod-level in this model; +a per-container `podman run --sysctl` would be rejected or wrong. + +## Decision & rationale + +- **Not per-container.** Emitting `--sysctl` on `podman run` cannot honor the + request in a shared-namespace pod. The honest home is + `podman pod create --sysctl`, unioned/conflict-checked across services -- the + same pod-level-aggregation design `dns` needs (`deferred.md`). +- **Refuse, do not ignore.** Unlike `stop_signal`/`stop_grace_period` (inert, so + warn-and-ignore), a `sysctls` request is *not* behavior-neutral -- silently + dropping it would lose behavior the user asked for. So `sysctls` keeps raising + as unsupported, matching `dns`. +- The behavior is documented, not observed on a live podman in a pod; validate + before building pod-level support. + +## Revisit trigger + +- The pod-level-aggregation pattern is built (for `dns` or otherwise), giving + `sysctls` a `podman pod create --sysctl` home; or a live podman run + contradicts the documented per-container-`--sysctl`-in-pod behavior. diff --git a/planning/deferred.md b/planning/deferred.md index b3c9a41..5a5aa53 100644 --- a/planning/deferred.md +++ b/planning/deferred.md @@ -19,6 +19,21 @@ resolver or search domain inside the pod, or a live podman run contradicts the documented "`--dns` invalid on a pod-joined container" behavior this deferral assumes. Needs its own change file; validate the podman behavior first. +## Support `sysctls` as a pod-wide option + +Same shape as `dns` above. Podman only permits namespaced sysctls (`net.*` for +the network namespace; a fixed `kernel.*`/`fs.mqueue.*` set for IPC) and only +when the container owns that namespace — which it never does inside a +shared-namespace pod. So `sysctls` cannot be a per-container `--sysctl` flag; it +must be hoisted to `podman pod create --sysctl` and reconciled across services. +It stays refused (raises) until then, not ignored — a `sysctls` request is not +behavior-neutral. See `decisions/2026-07-09-sysctls-pod-level.md`. + +**Revisit trigger:** the pod-level-aggregation pattern is built (most likely for +`dns`, above), giving `sysctls` a natural `podman pod create --sysctl` home; or a +live podman run contradicts the documented behavior. Shares the aggregation +design with `dns` — do both together. Validate the podman behavior first. + ## Add the compose2pod brand lockup to the README header Sibling org repos (`db-retry`, `eof-fixer`, `semvertag`, …) open their README From 12d5f69646d99bd74efba970b6652153d36e2c31 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Thu, 9 Jul 2026 17:16:29 +0300 Subject: [PATCH 2/4] feat: support ulimits service key --- compose2pod/emit.py | 15 +++++++++++++++ compose2pod/parsing.py | 20 ++++++++++++++++++++ tests/test_emit.py | 18 ++++++++++++++++++ tests/test_parsing.py | 21 +++++++++++++++++++++ 4 files changed, 74 insertions(+) diff --git a/compose2pod/emit.py b/compose2pod/emit.py index 1c2d269..e5b24f5 100644 --- a/compose2pod/emit.py +++ b/compose2pod/emit.py @@ -148,6 +148,20 @@ def _add_pull_policy_flag(flags: list[Token], svc: dict[str, Any]) -> None: flags += ["--pull", PULL_POLICY_MAP[policy]] +def _ulimit_args(ulimits: dict[str, Any]) -> list[str]: + """Compose ulimits as podman `name=soft:hard` / `name=value` args.""" + args: list[str] = [] + for limit, spec in ulimits.items(): + args.append(f"{limit}={spec['soft']}:{spec['hard']}" if isinstance(spec, dict) else f"{limit}={spec}") + return args + + +def _add_ulimit_flags(flags: list[Token], svc: dict[str, Any]) -> None: + """Add --ulimit flags from ulimits (a soft/hard mapping or a scalar).""" + for arg in _ulimit_args(svc.get("ulimits") or {}): + flags += ["--ulimit", _Expand(arg)] + + 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(): @@ -175,6 +189,7 @@ def run_flags(name: str, svc: dict[str, Any], pod: str, hosts: list[str], projec _add_declarative_flags(flags, svc) _add_extra_host_flags(flags, svc) _add_pull_policy_flag(flags, svc) + _add_ulimit_flags(flags, svc) return flags diff --git a/compose2pod/parsing.py b/compose2pod/parsing.py index 8256d63..f2cc90e 100644 --- a/compose2pod/parsing.py +++ b/compose2pod/parsing.py @@ -39,6 +39,7 @@ "annotations", "extra_hosts", "pull_policy", + "ulimits", } IGNORED_SERVICE_KEYS = {"ports", "restart", "stdin_open", "tty", "stop_signal", "stop_grace_period"} SUPPORTED_HEALTHCHECK_KEYS = {"test", "interval", "timeout", "retries", "start_period"} @@ -105,6 +106,24 @@ def _validate_pull_policy(name: str, svc: dict[str, Any]) -> None: raise UnsupportedComposeError(msg) +def _validate_ulimits(name: str, svc: dict[str, Any]) -> None: + """Check ulimits maps each name to an int/str scalar or a {soft, hard} mapping.""" + ulimits = svc.get("ulimits") + if ulimits is None: + return + if not isinstance(ulimits, dict): + msg = f"service {name!r}: 'ulimits' must be a mapping" + raise UnsupportedComposeError(msg) + for limit, spec in ulimits.items(): + if isinstance(spec, dict): + if set(spec) != {"soft", "hard"}: + msg = f"service {name!r}: ulimit {limit!r} mapping must have exactly 'soft' and 'hard'" + raise UnsupportedComposeError(msg) + elif not isinstance(spec, int | str): + msg = f"service {name!r}: ulimit {limit!r} must be an int or a soft/hard mapping" + raise UnsupportedComposeError(msg) + + def _validate_service(name: str, svc: dict[str, Any]) -> list[str]: """Validate one service; returns warnings, raises UnsupportedComposeError.""" warnings: list[str] = [] @@ -122,6 +141,7 @@ def _validate_service(name: str, svc: dict[str, Any]) -> list[str]: _validate_service_volumes(name, svc) _validate_service_forms(name, svc) _validate_pull_policy(name, svc) + _validate_ulimits(name, svc) return warnings diff --git a/tests/test_emit.py b/tests/test_emit.py index 70e03df..e949d02 100644 --- a/tests/test_emit.py +++ b/tests/test_emit.py @@ -184,6 +184,20 @@ def test_pull_policy_passthrough_values(self) -> None: flags = run_flags("app", {"image": "x", "pull_policy": value}, "p", [], "/b") assert flags[4:6] == ["--pull", value] + def test_ulimits_mapping_form(self) -> None: + svc = {"image": "x", "ulimits": {"nofile": {"soft": 20000, "hard": 40000}}} + flags = run_flags("app", svc, "p", [], "/b") + assert flags[4:6] == ["--ulimit", _Expand("nofile=20000:40000")] + + def test_ulimits_scalar_form(self) -> None: + flags = run_flags("app", {"image": "x", "ulimits": {"nproc": 65535}}, "p", [], "/b") + assert flags[4:6] == ["--ulimit", _Expand("nproc=65535")] + + def test_ulimits_mixed_forms(self) -> None: + svc = {"image": "x", "ulimits": {"nproc": 65535, "nofile": {"soft": 1024, "hard": 2048}}} + flags = run_flags("app", svc, "p", [], "/b") + assert flags[4:8] == ["--ulimit", _Expand("nproc=65535"), "--ulimit", _Expand("nofile=1024:2048")] + class TestImageAndCommand: def test_build_service_uses_ci_image(self, chats_compose: dict) -> None: @@ -519,3 +533,7 @@ def test_collects_from_capability_and_security_lists(self) -> None: 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"] + + def test_collects_from_ulimits(self) -> None: + compose = {"services": {"app": {"image": "x", "ulimits": {"nofile": "${MAX}"}}}} + assert referenced_variables(compose, self._options()) == ["MAX"] diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 3e132ff..17191c9 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -224,3 +224,24 @@ def test_pull_policy_build_raises(self) -> None: def test_pull_policy_unknown_raises(self) -> None: with pytest.raises(UnsupportedComposeError, match="pull_policy 'sometimes'"): validate({"services": {"app": {"image": "x", "pull_policy": "sometimes"}}}) + + def test_ulimits_accepted(self) -> None: + svc = {"image": "x", "ulimits": {"nproc": 65535, "nofile": {"soft": 1024, "hard": 2048}}} + assert validate({"services": {"app": svc}}) == [] + + def test_ulimits_non_mapping_raises(self) -> None: + with pytest.raises(UnsupportedComposeError, match=r"'ulimits' must be a mapping"): + validate({"services": {"app": {"image": "x", "ulimits": ["nofile=1024"]}}}) + + def test_ulimits_mapping_missing_hard_raises(self) -> None: + with pytest.raises(UnsupportedComposeError, match="must have exactly 'soft' and 'hard'"): + validate({"services": {"app": {"image": "x", "ulimits": {"nofile": {"soft": 1024}}}}}) + + def test_ulimits_list_valued_limit_raises(self) -> None: + with pytest.raises(UnsupportedComposeError, match="must be an int or a soft/hard mapping"): + validate({"services": {"app": {"image": "x", "ulimits": {"nofile": [1, 2]}}}}) + + def test_sysctls_stays_unsupported(self) -> None: + # Deliberate: sysctls is pod-level (decisions/2026-07-09-sysctls-pod-level.md), keeps raising. + with pytest.raises(UnsupportedComposeError, match="unsupported key 'sysctls'"): + validate({"services": {"app": {"image": "x", "sysctls": {"net.core.somaxconn": 1024}}}}) From 23c164e1a36afb2cc979a281b47c1c2003c5ff78 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Thu, 9 Jul 2026 18:24:50 +0300 Subject: [PATCH 3/4] docs: promote ulimits to supported subset --- architecture/supported-subset.md | 8 +++++++- tests/test_emit.py | 6 ++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/architecture/supported-subset.md b/architecture/supported-subset.md index 18b7bc6..36671a8 100644 --- a/architecture/supported-subset.md +++ b/architecture/supported-subset.md @@ -23,7 +23,7 @@ warns (ignored, behavior-neutral inside a single pod) or raises `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`, - `platform`, `devices`, `annotations`, `extra_hosts`, `pull_policy`. + `platform`, `devices`, `annotations`, `extra_hosts`, `pull_policy`, `ulimits`. 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. @@ -63,6 +63,12 @@ warns (ignored, behavior-neutral inside a single pod) or raises (`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. +- **`ulimits`:** a mapping of limit name to either a scalar (`nproc: 65535` → + `--ulimit nproc=65535`, podman sets soft = hard) or a `{soft, hard}` mapping + (`nofile: {soft, hard}` → `--ulimit nofile=soft:hard`). A mapping value must + have exactly `soft` and `hard`; other shapes are rejected. (`sysctls`, by + contrast, is refused — it is pod-level, not per-container; see + `planning/decisions/2026-07-09-sysctls-pod-level.md`.) - **`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 e949d02..168142f 100644 --- a/tests/test_emit.py +++ b/tests/test_emit.py @@ -473,6 +473,12 @@ def test_image_host_metadata_keys_compose_on_one_service(self) -> None: ): assert fragment in script + def test_ulimits_compose_through_emit_script(self) -> None: + svc = {"image": "x", "ulimits": {"nproc": 65535, "nofile": {"soft": 20000, "hard": 40000}}} + script = self._single(svc) + assert '--ulimit "nproc=65535"' in script + assert '--ulimit "nofile=20000:40000"' in script + class TestReferencedVariables: def _options(self, command: str = "") -> EmitOptions: From 4771dea0006e6b1f0e64b4e5abfc101dc7b97213 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Thu, 9 Jul 2026 18:35:00 +0300 Subject: [PATCH 4/4] feat: reject non-scalar ulimit soft/hard values at parse time --- architecture/supported-subset.md | 3 ++- compose2pod/parsing.py | 3 +++ planning/changes/2026-07-09.07-ulimits-service-key.md | 9 ++++++--- tests/test_parsing.py | 4 ++++ 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/architecture/supported-subset.md b/architecture/supported-subset.md index 36671a8..9bd6bbb 100644 --- a/architecture/supported-subset.md +++ b/architecture/supported-subset.md @@ -66,7 +66,8 @@ warns (ignored, behavior-neutral inside a single pod) or raises - **`ulimits`:** a mapping of limit name to either a scalar (`nproc: 65535` → `--ulimit nproc=65535`, podman sets soft = hard) or a `{soft, hard}` mapping (`nofile: {soft, hard}` → `--ulimit nofile=soft:hard`). A mapping value must - have exactly `soft` and `hard`; other shapes are rejected. (`sysctls`, by + have exactly `soft` and `hard`, each an int or string; other shapes are + rejected. (`sysctls`, by contrast, is refused — it is pod-level, not per-container; see `planning/decisions/2026-07-09-sysctls-pod-level.md`.) - **`labels`:** list (`- KEY=value` / `- KEY`) or mapping (`KEY: value` / `KEY:`), diff --git a/compose2pod/parsing.py b/compose2pod/parsing.py index f2cc90e..169a380 100644 --- a/compose2pod/parsing.py +++ b/compose2pod/parsing.py @@ -119,6 +119,9 @@ def _validate_ulimits(name: str, svc: dict[str, Any]) -> None: if set(spec) != {"soft", "hard"}: msg = f"service {name!r}: ulimit {limit!r} mapping must have exactly 'soft' and 'hard'" raise UnsupportedComposeError(msg) + if not isinstance(spec["soft"], int | str) or not isinstance(spec["hard"], int | str): + msg = f"service {name!r}: ulimit {limit!r} 'soft' and 'hard' must be int or str" + raise UnsupportedComposeError(msg) elif not isinstance(spec, int | str): msg = f"service {name!r}: ulimit {limit!r} must be an int or a soft/hard mapping" raise UnsupportedComposeError(msg) diff --git a/planning/changes/2026-07-09.07-ulimits-service-key.md b/planning/changes/2026-07-09.07-ulimits-service-key.md index b4a8699..ba8812f 100644 --- a/planning/changes/2026-07-09.07-ulimits-service-key.md +++ b/planning/changes/2026-07-09.07-ulimits-service-key.md @@ -42,8 +42,10 @@ called from `run_flags` beside the other `_add_*` helpers. `_Expand` keeps svc)` helper -- kept out of `_validate_service_forms`, which already sits at the ruff `C901` complexity limit (adding `pull_policy` there tripped it last bundle; `ulimits`'s nested logic would too). `ulimits` must be a mapping; each value is -an `int`/`str` scalar or a mapping with **exactly** `soft` and `hard`; anything -else raises. Add `ulimits` to `SUPPORTED_SERVICE_KEYS`; promote into +an `int`/`str` scalar or a mapping with **exactly** `soft` and `hard`, each an +`int`/`str` (a non-scalar `soft`/`hard` is rejected at parse time rather than +mis-emitted as a garbage `--ulimit` flag); anything else raises. Add `ulimits` +to `SUPPORTED_SERVICE_KEYS`; promote into `architecture/supported-subset.md`. **`sysctls` reclassification (docs only -- it already raises).** Podman only @@ -67,7 +69,8 @@ Recorded in `decisions/2026-07-09-sysctls-pod-level.md`, `deferred.md` (with `just test-ci` at 100% line coverage. `ulimits`: mapping form (`nofile={soft}:{hard}`), scalar form (`nproc=N`), and both on one service; rejections (non-mapping `ulimits`; a mapping value missing `soft` or `hard`, or -with extra keys; a list-valued limit); `${VAR}` interpolation reaching +with extra keys; a non-scalar `soft`/`hard`; a list-valued limit); `${VAR}` +interpolation reaching `referenced_variables()`. `just lint-ci` clean (watch `_validate_service_forms` complexity -- the new validation is a separate helper for exactly this reason). diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 17191c9..abe6d13 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -241,6 +241,10 @@ def test_ulimits_list_valued_limit_raises(self) -> None: with pytest.raises(UnsupportedComposeError, match="must be an int or a soft/hard mapping"): validate({"services": {"app": {"image": "x", "ulimits": {"nofile": [1, 2]}}}}) + def test_ulimits_non_scalar_soft_hard_raises(self) -> None: + with pytest.raises(UnsupportedComposeError, match="'soft' and 'hard' must be int or str"): + validate({"services": {"app": {"image": "x", "ulimits": {"nofile": {"soft": [1, 2], "hard": 3}}}}}) + def test_sysctls_stays_unsupported(self) -> None: # Deliberate: sysctls is pod-level (decisions/2026-07-09-sysctls-pod-level.md), keeps raising. with pytest.raises(UnsupportedComposeError, match="unsupported key 'sysctls'"):