diff --git a/planning/changes/2026-07-13.04-grow-integration-scenarios-aliases-depends-on.md b/planning/changes/2026-07-13.04-grow-integration-scenarios-aliases-depends-on.md new file mode 100644 index 0000000..38b88d3 --- /dev/null +++ b/planning/changes/2026-07-13.04-grow-integration-scenarios-aliases-depends-on.md @@ -0,0 +1,88 @@ +--- +summary: Add two more integration scenarios -- network aliases (long-form `networks: {aliases: [...]}`, a distinct code path from the hostname/container_name case already covered) and `depends_on` with no condition (defaults to `service_started`, the weakest guarantee: no wait, no completion gate) -- reusing the existing `run_pod` fixture unchanged. +--- + +# Design: grow the integration scenario set (network aliases, depends_on default) + +## Summary + +Two more scenarios in `tests/integration/`, continuing the growth from +`planning/changes/2026-07-13.02-grow-integration-scenarios.md` and +`.03-grow-integration-scenarios-env.md`. Both reuse the `run_pod` fixture +unchanged -- no harness, `conftest.py`, `pyproject.toml`, or CI changes. Both +are behavioral probes (script exit code + stdout). + +## Motivation + +Two remaining gaps in the harness, ranked lower than the prior rounds' but +still real: network aliases (`networks: {default: {aliases: [...]}}`) exit +through a genuinely different code branch than `hostname`/`container_name` +(`graph.py:_host_names`'s networks-dict extraction), even though the +downstream `--add-host` emission is already proven twice over. `depends_on` +with no `condition` key (defaults to `service_started`, per +`graph.py:depends_on`) is the one dependency condition with zero +synchronization -- unlike `service_healthy` (`wait_healthy`) and +`service_completed_successfully` (blocking `--rm`), it is never directly +exercised, only implicitly present as the absence of a gate in every other +scenario. + +## Design + +**Scenario 1 -- network aliases** (`test_network_aliases.py`): one service +declares `networks: {"default": {"aliases": ["cache-alias"]}}`. Command: +`grep cache-alias /etc/hosts && grep 127.0.0.1 /etc/hosts`. Mirrors +`test_pod_level_options.py`'s grep-based style exactly, isolating the +aliases-extraction branch of `_host_names` specifically. + +**Scenario 2 -- `depends_on` default condition** (`test_depends_on_default.py`): +`helper` (busybox, mounts a shared bind volume) runs `sleep 1; echo started > +/shared/flag`, emitted as plain `podman run -d` (verified via local +`emit_script` generation: no `wait_healthy`, no `--rm` blocking -- confirming +this condition genuinely provides no gate). `app` declares +`depends_on: {"helper": {}}` (no `condition` key, defaults to +`service_started`) and polls for the marker with a bounded retry loop +(`for i in $(seq 1 20); do [ -f /shared/flag ] && cat /shared/flag && exit 0; +sleep 1; done; exit 1`) rather than checking once -- because `podman run -d` +returns once the container *launches*, not once its command finishes, a +single immediate check would be a coin-flip race. The retry loop is not a +test-hygiene compromise; it is the correct shape for testing a condition +whose entire point is "no synchronization is provided, the caller must +provide their own." Command-substitution syntax (`$(seq ...)`) was verified +locally (via direct `emit_script` generation, learning from the immediately +prior round's `$`-interpolation bug) to survive compose2pod's own +`${VAR}`/`$VAR` interpolation untouched -- parentheses don't match that +regex, matching the already-proven `test_resource_limits.py`'s `$(ulimit -n)` +precedent. + +Both files: no `pytestmark`, `Callable[..., PodRun]` typing, matching every +existing scenario file. + +## Non-goals + +- Long-form `networks:` mapping value shapes beyond `aliases` (e.g. + `ipv4_address`) -- unsupported by compose2pod entirely, not a gap in this + harness. +- `depends_on` list form (`depends_on: ["helper"]`, which also defaults to + `service_started`) -- already unit-tested (`graph.py:depends_on`'s list + branch); the mapping-form-with-empty-dict shape is what needs a real-podman + proof, since it is the one that reaches `spec.get("condition", ...)`. +- Tuning the retry loop's interval/count for maximum tightness -- 20x1s is a + generous, cheap budget; not worth over-optimizing. + +## Testing + +`uv run --no-sync pytest -m integration --collect-only -q` collects 10 (the +8 existing scenarios plus these 2). `just test-ci` stays at 100% with all 10 +deselected. `just lint-ci` and `just check-planning` clean. Real signal: the +`integration` CI job green against real podman. + +## Risk + +- **Retry loop flaking under CI load** (low x low): 20 seconds of budget is + generous relative to the 1-second sleep it is bridging; a slower runner + would need to be dramatically slower to exhaust it. +- **Aliases scenario proving an already-proven mechanism** (acknowledged, not + a defect): the downstream `--add-host` emission is shared with the + hostname/container_name/extra_hosts cases already covered; this scenario's + marginal value is isolating the one untested branch that FEEDS that + mechanism (`_host_names`'s aliases extraction), not the mechanism itself. diff --git a/tests/integration/test_depends_on_default.py b/tests/integration/test_depends_on_default.py new file mode 100644 index 0000000..c581e3f --- /dev/null +++ b/tests/integration/test_depends_on_default.py @@ -0,0 +1,44 @@ +"""depends_on with no condition (defaults to service_started): the one condition with no gate. + +Unlike service_healthy (wait_healthy) and service_completed_successfully (blocking +--rm), service_started provides zero synchronization -- helper is started detached +(-d) and app runs immediately after, with no wait. A single immediate check of +helper's side effect would be a race (podman run -d returns once the container +LAUNCHES, not once its command finishes), so app polls with a bounded retry loop +instead -- the correct shape for testing a condition whose entire point is "the +caller must provide their own synchronization." +""" + +from collections.abc import Callable +from pathlib import Path + +from tests.integration.conftest import PodRun + + +def test_depends_on_default_condition_is_service_started(run_pod: Callable[..., PodRun], tmp_path: Path) -> None: + (tmp_path / "shared").mkdir() + compose = { + "services": { + "helper": { + "image": "busybox:1.36", + "volumes": ["./shared:/shared"], + "command": ["sh", "-c", "sleep 1; echo started > /shared/flag"], + }, + "app": { + "image": "busybox:1.36", + "volumes": ["./shared:/shared"], + "depends_on": {"helper": {}}, # no condition key -> defaults to service_started + "command": [ + "sh", + "-c", + "for i in $(seq 1 20); do [ -f /shared/flag ] && cat /shared/flag && exit 0; sleep 1; done; exit 1", + ], + }, + }, + } + run = run_pod(compose, target="app", project_dir=tmp_path) + # Exit 0 + "started" in stdout proves startup ordering (helper's podman run -d + # line is emitted before app's) AND that this condition provides no built-in + # wait -- app had to poll for it itself. + assert run.returncode == 0, run.stderr + assert "started" in run.stdout diff --git a/tests/integration/test_network_aliases.py b/tests/integration/test_network_aliases.py new file mode 100644 index 0000000..0783d0e --- /dev/null +++ b/tests/integration/test_network_aliases.py @@ -0,0 +1,22 @@ +"""Long-form networks: aliases -- a distinct code path from hostname/container_name.""" + +from collections.abc import Callable + +from tests.integration.conftest import PodRun + + +def test_network_alias_lands_on_the_pod(run_pod: Callable[..., PodRun]) -> None: + compose = { + "services": { + "app": { + "image": "busybox:1.36", + "networks": {"default": {"aliases": ["cache-alias"]}}, + "command": ["sh", "-c", "grep cache-alias /etc/hosts && grep 127.0.0.1 /etc/hosts"], + }, + }, + } + run = run_pod(compose, target="app") + # Exit 0 proves the long-form networks.aliases entry (a distinct code path + # from hostname/container_name in graph.py's _host_names) reaches the + # pod-level --add-host set, resolving to 127.0.0.1 like every other alias. + assert run.returncode == 0, run.stderr