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
46 changes: 44 additions & 2 deletions architecture/supported-subset.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,8 @@ warns (ignored, behavior-neutral inside a single pod) or raises
(`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`.)
contrast, is pod-level rather than per-containersee the
Pod-level options section below.)
- **`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 Expand Up @@ -174,6 +174,48 @@ 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.

## Pod-level options

A Podman pod shares one network namespace across every container joined to
it, so a handful of Compose keys cannot be per-container `podman run` flags.
compose2pod hoists them onto `podman pod create` instead
(`compose2pod/pod.py`) — the tool's only pod-create flags.

- **Supported:** `dns`, `dns_search`, `dns_opt`, `sysctls` — mapped to
`--dns`, `--dns-search`, `--dns-option`, `--sysctl` respectively (`_DNS_KEYS`,
`pod.py`).
- **Aggregation is closure-scoped:** `pod_create_flags(services, order)` is
called with `order` — the target's dependency closure (`startup_order`) —
exactly like other closure-scoped constructs (secrets, configs). `dns` /
`dns_search` / `dns_opt` are unioned across the closure (deduplicated,
first-seen order); `sysctls` are unioned by key, and two services in the
closure setting the same key to different values is refused
(`UnsupportedComposeError: conflicting sysctl ...`) rather than resolved by
last-writer-wins.
- **Value shapes:** `dns` / `dns_search` / `dns_opt` accept a string or a list
of strings; `sysctls` accepts a mapping (`key: value`) or a list of
`"key=value"` strings, each value a string or number. A `${VAR}` inside a
value is wrapped in `_Expand` like other interpolated fields, so it stays
live at run time and counts toward `referenced_variables` — the generated
script's own shell expands it when it runs, not compose2pod at generation
time.
- **Pod-wide divergence:** unlike every other service key, these apply to
every container in the pod once emitted — including services that never
declared them — because the pod shares one `/etc/resolv.conf` and one
sysctl set. `validate()` (`compose2pod/parsing.py`) is target-agnostic
shape validation over the whole document: whenever any service anywhere
declares `dns` / `dns_search` / `dns_opt` / `sysctls` (`uses_pod_options`),
it emits the warning "dns/sysctls apply pod-wide -- all containers in the
pod share one /etc/resolv.conf and sysctl set", regardless of whether that
service turns out to be inside the target's closure. Conversely, at emit
time a `dns` / `sysctls` declaration on a service outside the target's
closure is silently ignored by `pod_create_flags` — no flag is emitted for
it, since that service is never run.
- **Non-goals:** per-service DNS/sysctls — impossible inside a
shared-namespace pod, not a compose2pod limitation; last-writer-wins on a
sysctl key conflict — refused instead, matching the refuse-on-conflict
policy used elsewhere (see Resource limits, below).

## Resource limits

Compose exposes container resource limits two ways — the legacy scalar
Expand Down
10 changes: 9 additions & 1 deletion 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.pod import pod_create_flags
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
Expand Down Expand Up @@ -203,7 +204,11 @@ def emit_script(compose: dict[str, Any], options: EmitOptions) -> str:
stores = " ".join(store_names)
teardown += f"; podman secret rm {stores} >/dev/null 2>&1 || true"
lines.append(f"trap '{teardown}' EXIT")
lines.append(f"podman pod create --name {shlex.quote(options.pod)}")
pod_flags = pod_create_flags(services, order)
pod_create = f"podman pod create --name {shlex.quote(options.pod)}"
if pod_flags:
pod_create += " " + _render(pod_flags)
lines.append(pod_create)
lines.extend(all_create_lines(compose, order, options.pod, options.project_dir, STORE_KINDS))
waited: set[str] = set()
for name in order:
Expand Down Expand Up @@ -238,5 +243,8 @@ def referenced_variables(compose: dict[str, Any], options: EmitOptions) -> list[
for token in _run_tokens(name, services, options, hosts):
if isinstance(token, _Expand):
names.update(variable_names(token.value))
for token in pod_create_flags(services, order):
if isinstance(token, _Expand):
names.update(variable_names(token.value))
names |= all_referenced_variables(compose, order, options.project_dir, STORE_KINDS)
return sorted(names)
4 changes: 4 additions & 0 deletions compose2pod/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,4 +229,8 @@ def _emit_ulimits(value: Any) -> list[Token]: # noqa: ANN401 - Compose values a
"secrets",
"configs",
"deploy",
"dns",
"dns_search",
"dns_opt",
"sysctls",
}
6 changes: 6 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.pod import uses_pod_options, validate_pod_options
from compose2pod.resources import validate_deploy
from compose2pod.store import validate_all
from compose2pod.stores import STORE_KINDS
Expand Down Expand Up @@ -89,6 +90,7 @@ def _validate_service(name: str, svc: Any) -> list[str]: # noqa: ANN401 - Compo
_validate_entrypoint(name, svc)
_validate_tmpfs(name, svc)
validate_deploy(name, svc)
validate_pod_options(name, svc)
for key, spec in SERVICE_KEYS.items():
if key in svc:
spec.validate(name, key, svc[key])
Expand Down Expand Up @@ -134,4 +136,8 @@ def validate(compose: dict[str, Any]) -> list[str]:
hostnames(services) # validate hostname/container_name/networks shapes at the gate
_validate_depends_on(services)
validate_all(compose, STORE_KINDS)
if uses_pod_options(services):
warnings.append(
"dns/sysctls apply pod-wide -- all containers in the pod share one /etc/resolv.conf and sysctl set"
)
return warnings
94 changes: 94 additions & 0 deletions compose2pod/pod.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""Aggregate compose dns/sysctls onto pod-level `podman pod create` flags."""

from typing import Any

from compose2pod.exceptions import UnsupportedComposeError
from compose2pod.keys import Token, _Expand


_DNS_KEYS = {"dns": "--dns", "dns_search": "--dns-search", "dns_opt": "--dns-option"}
_POD_OPTION_KEYS = (*_DNS_KEYS, "sysctls")


def _as_str_list(name: str, key: str, value: Any) -> list[str]: # noqa: ANN401 - Compose values are untyped
items = [value] if isinstance(value, str) else value
if not isinstance(items, list) or not all(isinstance(item, str) for item in items):
msg = f"service {name!r}: {key!r} must be a string or list of strings"
raise UnsupportedComposeError(msg)
return items


def _sysctl_pairs(name: str, value: Any) -> list[tuple[str, str]]: # noqa: ANN401 - Compose values are untyped
if isinstance(value, dict):
pairs: list[tuple[str, str]] = []
for key, val in value.items():
if isinstance(val, bool) or not isinstance(val, str | int | float):
msg = f"service {name!r}: sysctl {key!r} value must be a string or number"
raise UnsupportedComposeError(msg)
pairs.append((str(key), str(val)))
return pairs
if isinstance(value, list):
pairs = []
for item in value:
if not isinstance(item, str) or "=" not in item:
msg = f"service {name!r}: sysctls list entries must be 'key=value' strings"
raise UnsupportedComposeError(msg)
key, _sep, val = item.partition("=")
pairs.append((key, val))
return pairs
msg = f"service {name!r}: 'sysctls' must be a mapping or a list of 'key=value' strings"
raise UnsupportedComposeError(msg)


def validate_pod_options(name: str, svc: dict[str, Any]) -> None:
"""Shape-check a service's pod-level dns/sysctls declarations."""
for key in _DNS_KEYS:
if key in svc:
_as_str_list(name, key, svc[key])
if "sysctls" in svc:
_sysctl_pairs(name, svc["sysctls"])


def uses_pod_options(services: dict[str, Any]) -> bool:
"""Whether any service declares a pod-level dns/sysctls option."""
return any(key in svc for svc in services.values() for key in _POD_OPTION_KEYS)


def _dns_flags(services: dict[str, Any], order: list[str]) -> list[Token]:
tokens: list[Token] = []
for key, flag in _DNS_KEYS.items():
seen: dict[str, None] = {}
for name in order:
svc = services[name]
if key in svc:
for value in _as_str_list(name, key, svc[key]):
seen[value] = None
for value in seen:
tokens += [flag, _Expand(value=value)]
return tokens


def _sysctl_flags(services: dict[str, Any], order: list[str]) -> list[Token]:
merged: dict[str, str] = {}
for name in order:
svc = services[name]
if "sysctls" not in svc:
continue
for key, val in _sysctl_pairs(name, svc["sysctls"]):
if merged.get(key, val) != val:
msg = f"service {name!r}: conflicting sysctl {key!r} ({merged[key]!r} vs {val!r})"
raise UnsupportedComposeError(msg)
merged[key] = val
tokens: list[Token] = []
for key, val in merged.items():
tokens += ["--sysctl", _Expand(value=f"{key}={val}")]
return tokens


def pod_create_flags(services: dict[str, Any], order: list[str]) -> list[Token]:
"""Pod-create flag tokens aggregated across the closure `order`.

dns/dns_search/dns_opt are unioned (dedup, first-seen order); sysctls are
unioned by key and a same-key value conflict is refused.
"""
return _dns_flags(services, order) + _sysctl_flags(services, order)
112 changes: 112 additions & 0 deletions planning/changes/2026-07-11.03-pod-dns-sysctls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
---
summary: Support pod-level dns/dns_search/dns_opt/sysctls — the tool's first `podman pod create` flags. A new pod.py hoists these per-service compose keys onto the single pod, unioning the additive dns families across the closure and refusing a same-key sysctl value conflict; a pod-wide warning flags the per-service->pod-wide scope change.
---

# Design: pod-level dns and sysctls

## Summary

Support `dns`, `dns_search`, `dns_opt`, and `sysctls` by hoisting them onto
`podman pod create` — the tool's first pod-create flags (every prior feature
added `podman run` flags). A new module `compose2pod/pod.py` aggregates these
per-service compose declarations across the target's dependency closure into
the single pod: the additive dns families are unioned, and a `sysctls` same-key
value conflict is refused loudly. Because a Podman pod shares one network
namespace, these options are unavoidably pod-wide; a warning flags that scope
change.

## Motivation

This is the deferred pod-level-aggregation item from
`decisions/2026-07-09-sysctls-pod-level.md` and `deferred.md`. `dns` and
`sysctls` currently raise as unsupported. They cannot be per-container
`podman run` flags: `--dns` is coupled to the network namespace and podman
rejects it on a pod-joined container, and the only settable sysctls are
namespaced (net/ipc), which a Podman pod owns — so a container in the pod owns
neither. `podman pod create` exposes `--dns`/`--dns-search`/`--dns-option`/
`--sysctl`, each documented as shared across all containers in the pod (verified
against `podman-pod-create.1`), which is their honest home. Building the
aggregation pattern for `dns` gives `sysctls` its home too, so both ship
together per the deferral.

## Design

**Module `compose2pod/pod.py`** (imports only `keys` for `_Expand`/`Token` and
`exceptions`):

- `validate_pod_options(name, svc)` — per-service shape checks: `dns`/
`dns_search`/`dns_opt` are a string or a list of strings; `sysctls` is a
mapping (scalar, non-bool values) or a list of `key=value` strings. Called
from `parsing._validate_service`; target-agnostic.
- `pod_create_flags(services, order)` — aggregates across the closure `order`
(from `startup_order`) into `list[Token]`; raises `UnsupportedComposeError`
on a sysctl conflict.
- `uses_pod_options(services)` — whether any service declares these, for the
warning.

The four keys join `STRUCTURAL_KEYS` (pod-level, not registry run-flags).
`emit_script` renders the pod flags onto the create line via the existing
`_render`/`to_shell` path, so a `${VAR}` in a value stays live at run time and
flows into the `referenced_variables()` note:

```
podman pod create --name test-pod --dns "1.1.1.1" --dns "8.8.8.8" \
--dns-search "corp.internal" --sysctl "net.core.somaxconn=1024"
```

**Reconciliation** (values compared as their raw compose strings, before
run-time interpolation):

- **dns / dns_search / dns_opt** — unioned across the closure services (dedup,
first-seen order). These are additive `/etc/resolv.conf` bags; combining is
faithful, and there is no single-value conflict to refuse.
- **sysctls** — unioned by key. Two closure services setting the *same key* to
*different values* is refused (`service 'x': conflicting sysctl 'net.x'
('1' vs '2')`) — a pod holds one value per key. Same key + same value, or
different keys, merge. This mirrors `resources.py`'s legacy-vs-deploy
conflict refusal.

**Closure-scoped, emit-time.** Aggregation and the sysctl conflict run over the
target's closure — the services actually in the pod — at emit time. A
`dns`/`sysctls` on a service outside the closure is ignored (that service is
never run), like its other config. Per-service *shape* validation is
target-agnostic in `validate()`.

**Pod-wide warning.** When any service declares one of these keys, `validate()`
returns one warning: `dns/sysctls apply pod-wide -- all containers in the pod
share one /etc/resolv.conf and sysctl set`. The options are applied (not
dropped), but their scope widens from per-service to pod-wide; the warning is
the honest heads-up, consistent with the tool's other divergence warnings.

## Non-goals

- Per-service DNS/sysctls — physically impossible in a shared-namespace pod;
collapsed pod-wide and documented.
- Last-writer-wins for a sysctl key — a true conflict is refused, never
silently resolved.
- Refusing a differing-resolver union — dns bags are additive; the pod-wide
effect is warned/documented, not refused.

## Testing

`just test-ci` at 100%: each key emits its pod-create flag; string and list
forms; sysctls mapping and `k=v` list forms; union/dedup of dns across two
closure services; a sysctl same-key conflict refused and same-key-same-value
merged; a non-closure service's `dns` absent from the script; the pod-wide
warning from `validate`; a `${VAR}` in a dns value reaching
`referenced_variables()`; a service with no pod options emits a byte-identical
`podman pod create` line; the supported-keys snapshot gains the four keys.
`just lint-ci` clean (watch `C901` on the aggregator -- split a per-family
helper if needed) and `just check-planning`.

## Risk

- **Resolver-order surprise** (low x med): unioning differing resolvers can
change resolution order versus a per-service intent. Inherent to the shared
pod netns; mitigated by the warning and the architecture note.
- **`C901` on the aggregator** (low x low): three dns families plus sysctls;
extract a per-family helper if it crosses the limit, as prior bundles did.
- **Documented-not-observed podman behavior** (low x low): the deferral assumed
`--dns` is pod-level; `podman-pod-create.1` confirms the flags exist and are
pod-shared. The per-container-rejection detail is documentary; the pod-level
home is the verified, invariant-preserving choice regardless.
9 changes: 9 additions & 0 deletions planning/decisions/2026-07-09-sysctls-pod-level.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,12 @@ a per-container `podman run --sysctl` would be rejected or wrong.
- 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.

## Resolved

The revisit trigger was met: `changes/2026-07-11.03-pod-dns-sysctls.md` built
the pod-level-aggregation pattern, so `sysctls` (and `dns`/`dns_search`/
`dns_opt`) are now supported via `podman pod create --sysctl`/`--dns`, unioned
and conflict-checked across the target's closure. This decision's reasoning
(sysctls is pod-level, not per-container) was realized, not reversed, so its
status stays `accepted` as the record of why the pod-level home was chosen.
32 changes: 0 additions & 32 deletions planning/deferred.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,38 +2,6 @@

Real-but-unscheduled items, each with a revisit trigger.

## Support `dns` / `dns_search` / `dns_opt` as pod-wide options

Unlike `--add-host` (a per-container `/etc/hosts` edit the tool already emits),
`--dns` is coupled to the network namespace, and podman rejects it on a
container that has joined a pod's netns. So `dns` cannot be a per-container
`podman run` flag — it must be hoisted to `podman pod create --dns` and applied
pod-wide, which means reconciling the values across services (union, or error on
disagreement). This is the tool's first pod-level aggregated option; it is
feasible and invariant-preserving but has no demonstrated CI demand yet. See
`decisions/2026-07-09-reject-namespace-network-keys.md` and
`audits/2026-07-09-compose-spec-coverage.md`.

**Revisit trigger:** a user needs a service to resolve names through a specific
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
Loading