diff --git a/planning/changes/2026-07-13.02-grow-integration-scenarios.md b/planning/changes/2026-07-13.02-grow-integration-scenarios.md new file mode 100644 index 0000000..f2fbeca --- /dev/null +++ b/planning/changes/2026-07-13.02-grow-integration-scenarios.md @@ -0,0 +1,103 @@ +--- +summary: Add three integration scenarios to the harness from PR #41 -- pod-level `dns`/`sysctls`/`extra_hosts` (the exact area that just broke), `secrets`/`configs`, and resource limits (`deploy.resources.limits` acceptance plus a value-checked `ulimits`) -- reusing the existing `run_pod` fixture unchanged. +--- + +# Design: grow the integration scenario set + +## Summary + +Three new scenarios in `tests/integration/`, each a single new test file using +the `run_pod` fixture shipped in PR #41 (`planning/changes/2026-07-12.04-integration-harness.md`) +unchanged. No harness, `conftest.py`, `pyproject.toml`, or CI changes. Every +scenario is a behavioral probe (script exit code + stdout), matching the +existing seed scenarios, for the same reason: the generated script +self-destructs its pod on exit, so there is nothing to `podman exec`/`inspect` +afterward. + +## Motivation + +The harness's first real CI run caught a genuine podman-compat bug in the +pod-level `--add-host` emission (`planning/changes/2026-07-13.01-pod-level-add-host.md`) +that every unit test missed. That is exactly the class of bug the harness +exists to catch, and the areas most likely to hide another one are the ones +that talk to podman in ways a string-comparison unit test cannot verify: +pod-wide `dns`/`sysctls`/`extra_hosts` (only unit-tested even after the fix), +`secrets`/`configs` (podman secret lifecycle), and resource limits (podman +flag acceptance). This round adds one scenario per area. + +## Design + +**Scenario 1 -- pod-level options** (`test_pod_level_options.py`): one +service declares `dns: ["9.9.9.9"]`, `sysctls: {"net.core.somaxconn": +"1024"}`, and `extra_hosts: {"external-svc": "10.0.0.9"}`; its command chains +three checks with `&&`: +``` +grep 9.9.9.9 /etc/resolv.conf && grep 1024 /proc/sys/net/core/somaxconn && grep external-svc:10.0.0.9 /etc/hosts +``` +No dependency ordering needed -- one container reading its own environment. +`net.core.somaxconn` is network-namespaced and known rootless-safe (podman's +own docs use it as the canonical rootless sysctl example); `dns`/`extra_hosts` +have no such restriction. + +**Scenario 2 -- secrets/configs** (`test_secrets_and_configs.py`): one +service references one `secrets:` entry (`file:` source) and one `configs:` +entry (`content:` source, the one source `configs` supports that `secrets` +doesn't -- exercising both source-kind branches in `compose2pod/stores.py` in +one pass). Command: `cat /run/secrets/mysecret && cat /myconfig`, asserting +both contents appear in stdout. Proves the full lifecycle: `podman secret +create`, the `--secret source=...,target=...` mount at both default target +shapes, and that `podman secret rm` teardown doesn't interfere with the run. + +**Scenario 3 -- resource limits** (`test_resource_limits.py`): one service +sets `deploy: {resources: {limits: {memory: "128m", cpus: "0.5", pids: +100}}}` and `ulimits: {nofile: 1024}`. Command: +`[ "$(ulimit -n)" = 1024 ] && echo ok`. Two distinct verification strategies, +deliberately different in strength: +- `memory`/`cpus`/`pids` (-> `--memory`/`--cpus`/`--pids-limit`): **acceptance-only** + -- if podman rejected any of these flags (the same failure class as the + add-host bug), the script would fail to start and `run.returncode` would be + nonzero. No value is checked from inside the container. +- `ulimits` (-> `--ulimit`): **value-checked** via the `ulimit -n` shell + builtin, cgroup-independent and reliable rootless or rootful on any runner. + +The split is deliberate, not an oversight: verifying the exact `--memory`/ +`--pids-limit` value would require reading `/sys/fs/cgroup/memory.max` / +`pids.max` inside the container, which assumes a cgroup v2 unified hierarchy +on the CI runner. That assumption could make the scenario flake for reasons +that have nothing to do with compose2pod's correctness. Acceptance-only +targets the actual risk class this harness exists to catch (podman refusing +a flag combination outright) at zero environment-fragility cost. + +## Non-goals + +- `dns_search`/`dns_opt` -- same code family as `dns` (`_DNS_KEYS` in + `compose2pod/pod.py`), lower marginal value; not a new code path. +- Cgroup-based value verification for `memory`/`pids` -- environment-fragile + (cgroup v1 vs v2), deferred; see Design above. +- `deploy.resources.reservations` -- a scheduling hint (`--memory-reservation`), + not a hard limit; podman does not expose a way to observe it from inside a + container, so there is nothing behavioral to assert. +- Multi-service secret/config sharing -- a single service is enough to prove + the `podman secret create` + mount mechanism; sharing one secret across + services is unit-tested already (`tests/test_stores.py`). + +## Testing + +`uv run --no-sync pytest -m integration --collect-only -q` collects 6 tests +(the 3 existing seed scenarios plus these 3). `just test-ci` stays at 100% +with all 6 deselected. `just lint-ci` and `just check-planning` clean. The +real signal is the `integration` CI job going green against real podman on +`ubuntu-latest`, same as the seed scenarios. + +## Risk + +- **Rootless sysctl restriction** (low x med): if `net.core.somaxconn` turns + out not to be settable on the GitHub Actions rootless podman build despite + being the documented canonical example, the scenario fails for an + environment reason unrelated to compose2pod. Mitigated by picking the + specific sysctl podman's own docs use for this exact purpose. +- **Acceptance-only under-verifies `deploy.resources.limits`** (low x low): a + regression that silently drops the `--memory` value (but keeps the flag + present with a garbage value) would not be caught. Accepted per the Design + section's fragility trade-off; a future round can add cgroup-file + verification if this trade-off turns out wrong in practice. diff --git a/tests/integration/test_pod_level_options.py b/tests/integration/test_pod_level_options.py new file mode 100644 index 0000000..4587866 --- /dev/null +++ b/tests/integration/test_pod_level_options.py @@ -0,0 +1,34 @@ +"""Pod-level dns/sysctls/extra_hosts merge onto podman pod create -- the area that broke first.""" + +from collections.abc import Callable + +from tests.integration.conftest import PodRun + + +def test_pod_level_options_land_on_the_pod(run_pod: Callable[..., PodRun]) -> None: + compose = { + "services": { + "app": { + "image": "busybox:1.36", + "dns": ["9.9.9.9"], + "sysctls": {"net.core.somaxconn": "1024"}, + "extra_hosts": {"external-svc": "10.0.0.9"}, + "command": [ + "sh", + "-c", + "grep 9.9.9.9 /etc/resolv.conf " + "&& grep 1024 /proc/sys/net/core/somaxconn " + "&& grep 10.0.0.9 /etc/hosts " + "&& grep external-svc /etc/hosts", + ], + }, + }, + } + run = run_pod(compose, target="app") + # Exit 0 proves all three landed on the pod (podman pod create), not the + # container -- exactly the merge that regressed in the add-host bug. + # /etc/hosts is "iphostname" (podman's --add-host FLAG syntax + # is "host:ip", but the file it writes is the other order, unquoted) -- + # checked as two independent substrings rather than one combined pattern + # to stay robust to the exact separator. + assert run.returncode == 0, run.stderr diff --git a/tests/integration/test_resource_limits.py b/tests/integration/test_resource_limits.py new file mode 100644 index 0000000..9f9ed1c --- /dev/null +++ b/tests/integration/test_resource_limits.py @@ -0,0 +1,32 @@ +"""Resource limits: deploy.resources.limits is acceptance-only, ulimits is value-checked. + +Verifying the exact --memory/--pids-limit value would require reading cgroup files +inside the container, which assumes a cgroup v2 unified hierarchy on the CI runner -- +an assumption that could make this scenario flake for reasons unrelated to compose2pod's +correctness. Acceptance-only (the script simply runs to completion) targets the actual +risk class this harness exists to catch: podman refusing a flag combination outright, +the same failure mode as the add-host bug. ulimits has no such fragility -- `ulimit -n` +is a shell builtin, independent of cgroups -- so its value is checked directly. +""" + +from collections.abc import Callable + +from tests.integration.conftest import PodRun + + +def test_resource_limits_are_accepted_and_ulimit_is_applied(run_pod: Callable[..., PodRun]) -> None: + compose = { + "services": { + "app": { + "image": "busybox:1.36", + "deploy": {"resources": {"limits": {"memory": "128m", "cpus": "0.5", "pids": 100}}}, + "ulimits": {"nofile": 1024}, + "command": ["sh", "-c", '[ "$(ulimit -n)" = 1024 ] && echo ulimit-ok'], + }, + }, + } + run = run_pod(compose, target="app") + # Exit 0 proves podman accepted --memory/--cpus/--pids-limit (or the run would + # fail to start) AND that --ulimit nofile=1024 took effect inside the container. + assert run.returncode == 0, run.stderr + assert "ulimit-ok" in run.stdout diff --git a/tests/integration/test_secrets_and_configs.py b/tests/integration/test_secrets_and_configs.py new file mode 100644 index 0000000..92fc9af --- /dev/null +++ b/tests/integration/test_secrets_and_configs.py @@ -0,0 +1,30 @@ +"""Secrets and configs: both compile to podman secret create, mounted at different paths.""" + +from collections.abc import Callable +from pathlib import Path + +from tests.integration.conftest import PodRun + + +def test_secret_and_config_are_mounted(run_pod: Callable[..., PodRun], tmp_path: Path) -> None: + (tmp_path / "secret.txt").write_text("shh-secret-99\n") + compose = { + "services": { + "app": { + "image": "busybox:1.36", + "secrets": ["mysecret"], + "configs": ["myconfig"], + "command": ["sh", "-c", "cat /run/secrets/mysecret && cat /myconfig"], + }, + }, + "secrets": {"mysecret": {"file": "secret.txt"}}, + "configs": {"myconfig": {"content": "config-value-7\n"}}, + } + run = run_pod(compose, target="app", project_dir=tmp_path) + # Exit 0 + both contents in stdout proves: podman secret create (file source + # for the secret, content source for the config), the --secret mount at both + # default target shapes (/run/secrets/ and /), and that secret rm + # teardown doesn't interfere with the run. + assert run.returncode == 0, run.stderr + assert "shh-secret-99" in run.stdout + assert "config-value-7" in run.stdout