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
9 changes: 8 additions & 1 deletion architecture/supported-subset.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -63,6 +63,13 @@ 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`, 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:`),
emitted as repeated `--label`. A null value means an empty label
(`--label KEY`) -- the same emitted shape as `environment`'s null but a
Expand Down
15 changes: 15 additions & 0 deletions compose2pod/emit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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


Expand Down
23 changes: 23 additions & 0 deletions compose2pod/parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down Expand Up @@ -105,6 +106,27 @@ 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)
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)


def _validate_service(name: str, svc: dict[str, Any]) -> list[str]:
"""Validate one service; returns warnings, raises UnsupportedComposeError."""
warnings: list[str] = []
Expand All @@ -122,6 +144,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


Expand Down
24 changes: 15 additions & 9 deletions planning/audits/2026-07-09-compose-spec-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
82 changes: 82 additions & 0 deletions planning/changes/2026-07-09.07-ulimits-service-key.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
---
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`, 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
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 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).

## 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).
52 changes: 52 additions & 0 deletions planning/decisions/2026-07-09-sysctls-pod-level.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 15 additions & 0 deletions planning/deferred.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions tests/test_emit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -459,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:
Expand Down Expand Up @@ -519,3 +539,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"]
25 changes: 25 additions & 0 deletions tests/test_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,3 +224,28 @@ 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_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'"):
validate({"services": {"app": {"image": "x", "sysctls": {"net.core.somaxconn": 1024}}}})