Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 50 additions & 7 deletions architecture/supported-subset.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions compose2pod/emit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand Down
31 changes: 31 additions & 0 deletions compose2pod/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,16 @@ 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 not _is_number(value):
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"
Expand All @@ -82,6 +92,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] = []
Expand Down Expand Up @@ -180,6 +197,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] = {
Expand All @@ -198,4 +228,5 @@ def _emit_ulimits(value: Any) -> list[Token]: # noqa: ANN401 - Compose values a
"container_name",
"secrets",
"configs",
"deploy",
}
2 changes: 2 additions & 0 deletions compose2pod/parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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])
Expand Down
97 changes: 97 additions & 0 deletions compose2pod/resources.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""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, _is_number


# deploy.resources.limits.<field> -> (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 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)
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 reservations is None:
return
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
111 changes: 111 additions & 0 deletions planning/changes/2026-07-11.02-resource-limits.md
Original file line number Diff line number Diff line change
@@ -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.
Loading