diff --git a/planning/changes/2026-07-13.03-grow-integration-scenarios-env.md b/planning/changes/2026-07-13.03-grow-integration-scenarios-env.md new file mode 100644 index 0000000..be6f8c7 --- /dev/null +++ b/planning/changes/2026-07-13.03-grow-integration-scenarios-env.md @@ -0,0 +1,79 @@ +--- +summary: Add two more integration scenarios -- `env_file` (a real file resolved against `project_dir`, loaded into the container's actual environment) and runtime `${VAR}` interpolation (a variable set via `monkeypatch.setenv` just before `run_pod`, proving the reference survives generation as a live shell expansion and podman actually sets it) -- reusing the existing `run_pod` fixture unchanged. +--- + +# Design: grow the integration scenario set (env_file, runtime interpolation) + +## Summary + +Two more scenarios in `tests/integration/`, continuing the growth started in +`planning/changes/2026-07-13.02-grow-integration-scenarios.md`. Both reuse the +`run_pod` fixture from PR #41 unchanged -- no harness, `conftest.py`, +`pyproject.toml`, or CI changes. Both are behavioral probes (script exit code ++ stdout), matching every existing scenario. + +## Motivation + +Two mechanisms remain untested against real podman: `env_file` (a +compose-authoring convenience that reads a host file and loads it into the +container's environment via `--env-file`) and `${VAR}` runtime interpolation +(compose2pod's core design choice to leave `${VAR}` references live in the +generated script rather than resolving them at generation time, per +`architecture/supported-subset.md`'s Variable interpolation section). Both +involve a real podman flag (`--env-file`, `-e`) and a real shell expansion +step that only a running script can exercise. + +## Design + +**Scenario 1 -- `env_file`** (`test_env_file.py`): write `app.env` containing +`COLOR=teal-9\n` to `tmp_path`; the service declares `env_file: "app.env"`; +`run_pod(..., project_dir=tmp_path)`. Command: `echo "$COLOR"`. Mirrors +`test_bind_volume.py`'s file-in-`tmp_path` + `project_dir` override shape +exactly. Proves `--env-file` resolves against `project_dir` +(`compose2pod/emit.py:_add_env_flags`) and podman actually loads the file +into the container's real environment. + +**Scenario 2 -- runtime `${VAR}` interpolation** (`test_variable_interpolation.py`): +`monkeypatch.setenv("C2P_IT_TOKEN", )` before calling +`run_pod`; the service declares `environment: ["TOKEN=${C2P_IT_TOKEN}"]`; +command `echo "$TOKEN"`. `run_pod`'s `subprocess.run` call has no explicit +`env=` override (`tests/integration/conftest.py`), so it inherits the current +process environment -- exactly the one `monkeypatch` just modified. Exit 0 + +the distinctive value in stdout proves the full chain for real: the `${VAR}` +reference survives generation as a live shell expansion (not a baked-in +literal), `to_shell`'s quoting produces a valid `podman run -e TOKEN=...` +invocation once the outer script's own shell expands it, and podman correctly +sets that as the container's actual environment variable. + +Both files: no `pytestmark` (the existing `tryfirst` `pytest_collection_modifyitems` +hook in `conftest.py` auto-marks by location); `Callable[..., PodRun]` typed +`run_pod` parameter, matching every existing scenario file. + +## Non-goals + +- Compose's own `.env`-file-at-project-root convention (implicit variable + defaults) -- out of scope; `env_file:` is the explicit per-service key + already supported and is what this scenario tests. +- Multiple `env_file` entries (list form) -- already unit-tested + (`tests/test_emit.py::test_env_file_list_form`); one file is enough to + prove the real podman load path. +- A second interpolation variable / multiple `${VAR}` references in one + scenario -- one is enough to prove the run-time-expansion mechanism; more + is unit-test territory (`tests/test_shell.py`), not a new podman-facing risk. + +## Testing + +`uv run --no-sync pytest -m integration --collect-only -q` collects 8 (the 6 +existing scenarios plus these 2). `just test-ci` stays at 100% with all 8 +deselected. `just lint-ci` and `just check-planning` clean. Real signal: the +`integration` CI job green against real podman, same as every prior round. + +## Risk + +- **`monkeypatch.setenv` leaking into other tests in the same session** (low + x low): `monkeypatch` is function-scoped and pytest tears it down + automatically after the test, so this is a non-issue -- named for + completeness, not because it's expected to occur. +- **A CI runner already defining `C2P_IT_TOKEN`** (very low x low): + vanishingly unlikely given the deliberately distinctive name; not worth + additional defensive code. diff --git a/tests/integration/test_env_file.py b/tests/integration/test_env_file.py new file mode 100644 index 0000000..7c6f3fb --- /dev/null +++ b/tests/integration/test_env_file.py @@ -0,0 +1,29 @@ +"""env_file: a real host file, resolved against project_dir, loaded into the container's env.""" + +from collections.abc import Callable +from pathlib import Path + +from tests.integration.conftest import PodRun + + +def test_env_file_is_loaded(run_pod: Callable[..., PodRun], tmp_path: Path) -> None: + (tmp_path / "app.env").write_text("COLOR=teal-9\n") + compose = { + "services": { + "app": { + "image": "busybox:1.36", + "env_file": "app.env", + # `$$` escapes to a literal `$` (Compose syntax): a bare `$COLOR` here + # would instead be compose2pod's OWN interpolation, resolved by the + # OUTER script against ITS environment -- not what we want, since COLOR + # only exists inside the container via --env-file. + "command": ["sh", "-c", 'echo "$$COLOR"'], + }, + }, + } + run = run_pod(compose, target="app", project_dir=tmp_path) + # Exit 0 + the value in stdout proves --env-file resolved against + # project_dir and podman actually loaded the file into the container's + # real environment. + assert run.returncode == 0, run.stderr + assert "teal-9" in run.stdout diff --git a/tests/integration/test_variable_interpolation.py b/tests/integration/test_variable_interpolation.py new file mode 100644 index 0000000..dd7eb9d --- /dev/null +++ b/tests/integration/test_variable_interpolation.py @@ -0,0 +1,37 @@ +"""Runtime ${VAR} interpolation: a variable left live in the script, expanded when it runs. + +`run_pod`'s subprocess call inherits the current process environment (no explicit +`env=` override), so setting the variable via `monkeypatch.setenv` just before calling +it proves the full chain for real: the `${VAR}` reference survives compose2pod's +generation step as a live shell expansion (never baked into a literal), the outer +script's shell expands it into a valid `podman run -e TOKEN=...` invocation, and podman +sets that as the container's actual environment variable. +""" + +from collections.abc import Callable + +import pytest + +from tests.integration.conftest import PodRun + + +def test_variable_interpolation_resolves_at_run_time( + run_pod: Callable[..., PodRun], monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("C2P_IT_TOKEN", "runtime-value-73") + compose = { + "services": { + "app": { + "image": "busybox:1.36", + "environment": ["TOKEN=${C2P_IT_TOKEN}"], + # `$$` escapes to a literal `$` (Compose syntax): a bare `$TOKEN` here + # would instead be compose2pod's OWN interpolation, resolved by the + # OUTER script against ITS environment (which has C2P_IT_TOKEN, not + # TOKEN) -- we want the CONTAINER's shell to read its own $TOKEN. + "command": ["sh", "-c", 'echo "$$TOKEN"'], + }, + }, + } + run = run_pod(compose, target="app") + assert run.returncode == 0, run.stderr + assert "runtime-value-73" in run.stdout