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
79 changes: 79 additions & 0 deletions planning/changes/2026-07-13.03-grow-integration-scenarios-env.md
Original file line number Diff line number Diff line change
@@ -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", <distinctive value>)` 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.
29 changes: 29 additions & 0 deletions tests/integration/test_env_file.py
Original file line number Diff line number Diff line change
@@ -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
37 changes: 37 additions & 0 deletions tests/integration/test_variable_interpolation.py
Original file line number Diff line number Diff line change
@@ -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