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
103 changes: 103 additions & 0 deletions planning/changes/2026-07-13.02-grow-integration-scenarios.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 34 additions & 0 deletions tests/integration/test_pod_level_options.py
Original file line number Diff line number Diff line change
@@ -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 "ip<whitespace>hostname" (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
32 changes: 32 additions & 0 deletions tests/integration/test_resource_limits.py
Original file line number Diff line number Diff line change
@@ -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
30 changes: 30 additions & 0 deletions tests/integration/test_secrets_and_configs.py
Original file line number Diff line number Diff line change
@@ -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/<name> and /<name>), 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