diff --git a/architecture/supported-subset.md b/architecture/supported-subset.md index aeb8f84..0bd8dc0 100644 --- a/architecture/supported-subset.md +++ b/architecture/supported-subset.md @@ -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-container — see 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 @@ -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 diff --git a/compose2pod/emit.py b/compose2pod/emit.py index bed9aab..35bb4d1 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.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 @@ -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: @@ -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) diff --git a/compose2pod/keys.py b/compose2pod/keys.py index 5dbd722..bee5326 100644 --- a/compose2pod/keys.py +++ b/compose2pod/keys.py @@ -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", } diff --git a/compose2pod/parsing.py b/compose2pod/parsing.py index 846bd71..a5b432c 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.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 @@ -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]) @@ -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 diff --git a/compose2pod/pod.py b/compose2pod/pod.py new file mode 100644 index 0000000..fcba661 --- /dev/null +++ b/compose2pod/pod.py @@ -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) diff --git a/planning/changes/2026-07-11.03-pod-dns-sysctls.md b/planning/changes/2026-07-11.03-pod-dns-sysctls.md new file mode 100644 index 0000000..3141a9a --- /dev/null +++ b/planning/changes/2026-07-11.03-pod-dns-sysctls.md @@ -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. diff --git a/planning/decisions/2026-07-09-sysctls-pod-level.md b/planning/decisions/2026-07-09-sysctls-pod-level.md index 7346eb0..9f72b7f 100644 --- a/planning/decisions/2026-07-09-sysctls-pod-level.md +++ b/planning/decisions/2026-07-09-sysctls-pod-level.md @@ -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. diff --git a/planning/deferred.md b/planning/deferred.md index 5a5aa53..7e96277 100644 --- a/planning/deferred.md +++ b/planning/deferred.md @@ -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 diff --git a/tests/test_emit.py b/tests/test_emit.py index 5ad6796..1d25af5 100644 --- a/tests/test_emit.py +++ b/tests/test_emit.py @@ -601,6 +601,16 @@ def test_ulimits_compose_through_emit_script(self) -> None: assert '--ulimit "nproc=65535"' in script assert '--ulimit "nofile=20000:40000"' in script + def test_pod_create_carries_dns_and_sysctl_flags(self) -> None: + svc = {"image": "x", "dns": ["1.1.1.1", "8.8.8.8"], "sysctls": {"net.core.somaxconn": 1024}} + script = self._single(svc) + assert 'podman pod create --name p --dns "1.1.1.1" --dns "8.8.8.8"' in script + assert '--sysctl "net.core.somaxconn=1024"' in script + + def test_pod_create_unchanged_without_pod_options(self) -> None: + script = self._single({"image": "x"}) + assert "podman pod create --name p\n" in script + class TestReferencedVariables: def _options(self, command: str = "") -> EmitOptions: @@ -618,6 +628,10 @@ def test_collects_from_interpolated_fields_sorted(self) -> None: compose = {"services": {"app": {"image": "${IMG}", "environment": {"A": "${AVAR}", "B": "${BVAR}"}}}} assert referenced_variables(compose, self._options()) == ["AVAR", "BVAR", "IMG"] + def test_dns_variable_is_referenced(self) -> None: + doc = {"services": {"app": {"image": "x", "dns": ["${DNS_HOST}"]}}} + assert "DNS_HOST" in referenced_variables(doc, self._options()) + def test_includes_env_file_excludes_non_interpolated_fields(self) -> None: compose = { "services": { diff --git a/tests/test_keys.py b/tests/test_keys.py index 30b5f14..fcd8baa 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -53,4 +53,8 @@ def test_supported_service_keys_snapshot() -> None: "shm_size", "oom_score_adj", "oom_kill_disable", + "dns", + "dns_search", + "dns_opt", + "sysctls", } == SUPPORTED_SERVICE_KEYS diff --git a/tests/test_parsing.py b/tests/test_parsing.py index e951ee8..5d47e1e 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -39,6 +39,18 @@ 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_dns_and_sysctls_accepted_with_pod_wide_warning(self) -> None: + svc = {"image": "x", "dns": ["1.1.1.1"], "sysctls": {"net.core.somaxconn": 1024}} + warnings = validate({"services": {"app": svc}}) + assert any("pod-wide" in w for w in warnings) + + def test_dns_bad_shape_rejected_at_gate(self) -> None: + with pytest.raises(UnsupportedComposeError, match="must be a string or list of strings"): + validate({"services": {"app": {"image": "x", "dns": {"bad": "shape"}}}}) + + def test_no_pod_options_no_pod_wide_warning(self) -> None: + assert validate({"services": {"app": {"image": "x"}}}) == [] + 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}}}) @@ -268,11 +280,6 @@ 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}}}}) - def test_pull_policy_null_is_accepted(self) -> None: assert validate({"services": {"app": {"image": "x", "pull_policy": None}}}) == [] diff --git a/tests/test_pod.py b/tests/test_pod.py new file mode 100644 index 0000000..b1859b5 --- /dev/null +++ b/tests/test_pod.py @@ -0,0 +1,96 @@ +import pytest + +from compose2pod.exceptions import UnsupportedComposeError +from compose2pod.keys import _Expand +from compose2pod.pod import pod_create_flags, uses_pod_options, validate_pod_options + + +class TestValidatePodOptions: + def test_no_pod_options_is_noop(self) -> None: + validate_pod_options("app", {"image": "x"}) + + def test_dns_string_and_list_accepted(self) -> None: + validate_pod_options("app", {"image": "x", "dns": "1.1.1.1"}) + validate_pod_options("app", {"image": "x", "dns": ["1.1.1.1", "8.8.8.8"]}) + + def test_dns_search_and_opt_accepted(self) -> None: + validate_pod_options("app", {"image": "x", "dns_search": ["corp.internal"], "dns_opt": ["ndots:2"]}) + + def test_dns_non_string_list_rejected(self) -> None: + with pytest.raises(UnsupportedComposeError, match=r"'dns' must be a string or list of strings"): + validate_pod_options("app", {"image": "x", "dns": [1]}) + + def test_dns_non_string_scalar_rejected(self) -> None: + with pytest.raises(UnsupportedComposeError, match=r"'dns' must be a string or list of strings"): + validate_pod_options("app", {"image": "x", "dns": 5}) + + def test_sysctls_mapping_accepted(self) -> None: + validate_pod_options("app", {"image": "x", "sysctls": {"net.core.somaxconn": 1024}}) + + def test_sysctls_list_accepted(self) -> None: + validate_pod_options("app", {"image": "x", "sysctls": ["net.core.somaxconn=1024"]}) + + def test_sysctls_bool_value_rejected(self) -> None: + with pytest.raises(UnsupportedComposeError, match="value must be a string or number"): + validate_pod_options("app", {"image": "x", "sysctls": {"net.x": True}}) + + def test_sysctls_list_entry_without_equals_rejected(self) -> None: + with pytest.raises(UnsupportedComposeError, match=r"must be 'key=value' strings"): + validate_pod_options("app", {"image": "x", "sysctls": ["net.x"]}) + + def test_sysctls_wrong_type_rejected(self) -> None: + with pytest.raises(UnsupportedComposeError, match="'sysctls' must be a mapping or a list"): + validate_pod_options("app", {"image": "x", "sysctls": "net.x=1"}) + + +class TestUsesPodOptions: + def test_true_when_declared(self) -> None: + assert uses_pod_options({"a": {"image": "x"}, "b": {"image": "y", "dns": ["1.1.1.1"]}}) is True + + def test_false_when_absent(self) -> None: + assert uses_pod_options({"a": {"image": "x"}}) is False + + +class TestPodCreateFlags: + def test_no_options_no_flags(self) -> None: + assert pod_create_flags({"a": {"image": "x"}}, ["a"]) == [] + + def test_dns_union_across_closure_dedup_first_seen(self) -> None: + services = {"a": {"dns": ["1.1.1.1", "8.8.8.8"]}, "b": {"dns": "8.8.8.8"}} + assert pod_create_flags(services, ["a", "b"]) == [ + "--dns", + _Expand(value="1.1.1.1"), + "--dns", + _Expand(value="8.8.8.8"), + ] + + def test_dns_search_and_opt_flags(self) -> None: + services = {"a": {"dns_search": "corp.internal", "dns_opt": ["ndots:2"]}} + assert pod_create_flags(services, ["a"]) == [ + "--dns-search", + _Expand(value="corp.internal"), + "--dns-option", + _Expand(value="ndots:2"), + ] + + def test_sysctls_mapping_and_list_merge(self) -> None: + services = {"a": {"sysctls": {"net.core.somaxconn": 1024}}, "b": {"sysctls": ["net.ipv4.tcp_syncookies=1"]}} + assert pod_create_flags(services, ["a", "b"]) == [ + "--sysctl", + _Expand(value="net.core.somaxconn=1024"), + "--sysctl", + _Expand(value="net.ipv4.tcp_syncookies=1"), + ] + + def test_sysctls_same_key_same_value_merges(self) -> None: + services = {"a": {"sysctls": {"net.x": "1"}}, "b": {"sysctls": {"net.x": 1}}} + assert pod_create_flags(services, ["a", "b"]) == ["--sysctl", _Expand(value="net.x=1")] + + def test_sysctls_same_key_conflict_refused(self) -> None: + services = {"a": {"sysctls": {"net.x": "1"}}, "b": {"sysctls": {"net.x": "2"}}} + with pytest.raises(UnsupportedComposeError, match=r"conflicting sysctl 'net.x'"): + pod_create_flags(services, ["a", "b"]) + + def test_non_closure_service_options_ignored(self) -> None: + services = {"a": {"image": "x"}, "extra": {"dns": ["9.9.9.9"]}} + assert pod_create_flags(services, ["a"]) == []