From d9ed4ffe7c023bf1960fcab562551f7b6ade5146 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 13 Jul 2026 11:59:44 +0300 Subject: [PATCH 1/3] test: add env_file integration scenario --- ...07-13.03-grow-integration-scenarios-env.md | 79 +++++++++++++++++++ tests/integration/test_env_file.py | 25 ++++++ 2 files changed, 104 insertions(+) create mode 100644 planning/changes/2026-07-13.03-grow-integration-scenarios-env.md create mode 100644 tests/integration/test_env_file.py 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..65866ba --- /dev/null +++ b/tests/integration/test_env_file.py @@ -0,0 +1,25 @@ +"""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", + "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 From ee0c5c6eb21579e3bf69aa1d94208a2d398c53fa Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 13 Jul 2026 12:02:46 +0300 Subject: [PATCH 2/3] test: add runtime variable-interpolation integration scenario --- .../test_variable_interpolation.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 tests/integration/test_variable_interpolation.py diff --git a/tests/integration/test_variable_interpolation.py b/tests/integration/test_variable_interpolation.py new file mode 100644 index 0000000..50e7099 --- /dev/null +++ b/tests/integration/test_variable_interpolation.py @@ -0,0 +1,33 @@ +"""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}"], + "command": ["sh", "-c", 'echo "$TOKEN"'], + }, + }, + } + run = run_pod(compose, target="app") + assert run.returncode == 0, run.stderr + assert "runtime-value-73" in run.stdout From dc071ea02d1074446ae5761a7f35c453785f38d3 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 13 Jul 2026 12:12:29 +0300 Subject: [PATCH 3/3] fix: escape $ in container-side shell commands so it isn't interpolated by the outer script Both scenarios wrote a bare $COLOR/$TOKEN meant to be read by the CONTAINER's own shell, but compose2pod's own ${VAR} interpolation applies uniformly to every emitted string -- including command: list entries -- so it got resolved by the OUTER script against ITS OWN environment instead, producing an empty value before the command ever reached the container. Confirmed by generating the script locally: the command rendered as "echo \"${COLOR-}\"" rather than the literal "echo \"$COLOR\"". $$ is Compose's own escape for a literal $, verified via the same local-generation check to render correctly. --- tests/integration/test_env_file.py | 6 +++++- tests/integration/test_variable_interpolation.py | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/integration/test_env_file.py b/tests/integration/test_env_file.py index 65866ba..7c6f3fb 100644 --- a/tests/integration/test_env_file.py +++ b/tests/integration/test_env_file.py @@ -13,7 +13,11 @@ def test_env_file_is_loaded(run_pod: Callable[..., PodRun], tmp_path: Path) -> N "app": { "image": "busybox:1.36", "env_file": "app.env", - "command": ["sh", "-c", 'echo "$COLOR"'], + # `$$` 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"'], }, }, } diff --git a/tests/integration/test_variable_interpolation.py b/tests/integration/test_variable_interpolation.py index 50e7099..dd7eb9d 100644 --- a/tests/integration/test_variable_interpolation.py +++ b/tests/integration/test_variable_interpolation.py @@ -24,7 +24,11 @@ def test_variable_interpolation_resolves_at_run_time( "app": { "image": "busybox:1.36", "environment": ["TOKEN=${C2P_IT_TOKEN}"], - "command": ["sh", "-c", 'echo "$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"'], }, }, }