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
Original file line number Diff line number Diff line change
@@ -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.
44 changes: 44 additions & 0 deletions tests/integration/test_depends_on_default.py
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions tests/integration/test_network_aliases.py
Original file line number Diff line number Diff line change
@@ -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