From 0e74e558f943f0656e9fdb7aafabc1e654c9eee9 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 13 Jul 2026 08:45:15 +0300 Subject: [PATCH 1/5] test: add CI-only podman integration harness with healthy-gating scenario --- justfile | 5 + .../2026-07-12.04-integration-harness.md | 141 ++++++++++++++++++ pyproject.toml | 8 +- tests/integration/__init__.py | 0 tests/integration/conftest.py | 98 ++++++++++++ tests/integration/test_healthy_gating.py | 32 ++++ 6 files changed, 282 insertions(+), 2 deletions(-) create mode 100644 planning/changes/2026-07-12.04-integration-harness.md create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/conftest.py create mode 100644 tests/integration/test_healthy_gating.py diff --git a/justfile b/justfile index c9647fb..01ebfd9 100644 --- a/justfile +++ b/justfile @@ -40,6 +40,11 @@ test-ci: test-branch: uv run --no-sync pytest --cov=. --cov-branch --cov-fail-under=100 +# Integration tests: execute generated scripts against real podman. CI-only; needs podman. +# The CLI -m overrides the default `-m 'not integration'` in pyproject addopts. +test-integration: + uv run --no-sync pytest -m integration + # Build + publish to PyPI. Version comes from the git tag ($GITHUB_REF_NAME); no pyproject bump. # Auth via PyPI Trusted Publishing (OIDC); uv publish auto-detects the CI id-token. publish: diff --git a/planning/changes/2026-07-12.04-integration-harness.md b/planning/changes/2026-07-12.04-integration-harness.md new file mode 100644 index 0000000..771d0c7 --- /dev/null +++ b/planning/changes/2026-07-12.04-integration-harness.md @@ -0,0 +1,141 @@ +--- +summary: Add a CI-only integration harness under `tests/integration/` that renders a compose doc via the public `emit_script`, executes the generated script against real podman on an `ubuntu-latest` job, and asserts run-to-completion on the script's exit code and captured output (behavioral probes — the generated script self-destructs its pod via `trap EXIT`, so live post-run `exec`/`inspect` is impossible); kept out of the fast suite and the 100% coverage gate via an `integration` marker, `addopts = -m "not integration"`, and a coverage `omit`. +--- + +# Design: Podman integration-test harness + +## Summary + +Add end-to-end integration tests that actually run compose2pod's output. A new +`tests/integration/` package renders a compose document through the public +`emit_script`, executes the generated `run.sh` against **real podman** +run-to-completion, and asserts **behaviorally** on the script's exit code and +captured stdout/stderr. The tests are a reusable harness (`run_pod` fixture +returning a `PodRun` object) seeded with a few scenarios and built to grow. They +run in a dedicated `ubuntu-latest` CI job and are deliberately kept out of the +fast unit suite and the 100% line-coverage gate. + +Behavioral (not live `exec`/`inspect`) because the generated script installs +`trap '' EXIT` (`emit.py:232`) and runs the target in the +foreground (`emit.py:191`): the pod is gone the instant `sh run.sh` returns, so +there is nothing to attach to afterward. Under `set -eu` (`emit.py:115`), a clean +exit already proves pod creation, every `-d` dependency start, every +`service_healthy` gate (`wait_healthy` returns non-zero otherwise), every +completion-gated one-shot exiting 0, and the target exiting 0 or an allowed +code. Each scenario's assertion is therefore encoded as the target service's own +command (a probe that exits non-zero on failure). + +## Motivation + +Every existing test asserts on the **text** of the generated script +(`test_emit.py`, `test_cli.py`, ...). `subprocess` appears in the suite only to +invoke the CLI via `python -m`; podman is never executed. So the tool's entire +value proposition — "the generated script really starts a working single pod in +CI podman, with `depends_on`/healthcheck gating, `127.0.0.1` service reach, and +`--add-host` name resolution" — is unverified end to end. A change to emit could +produce a script that renders perfectly and fails to run, and every test would +stay green. This harness closes that gap and gives a place to pin future +regressions against real container behavior. + +## Design + +**Layout.** New package `tests/integration/` with `conftest.py` (the harness) +and `test_*.py` scenarios. Nothing in the core package changes; the harness +consumes only the public API (`emit_script`, `EmitOptions`). + +**Isolation from the fast suite and the coverage gate.** + +- Register an `integration` marker in `pyproject`. `tests/integration/conftest.py` + auto-marks every item whose path is under the package as `integration` via a + `tryfirst` `pytest_collection_modifyitems` hook (so marking happens before + pytest's `-m` deselection), and a separate autouse fixture skips each + integration test when `shutil.which("podman") is None` — contributors without + podman get skips, never failures. +- Set `addopts = -m "not integration"` so `just test`, `just test-ci`, and the + 3.10-3.14 matrix job never collect these. A CLI `-m integration` overrides the + addopts expression, so a new `just test-integration` recipe runs + `pytest -m integration` with **no coverage**. +- Add `tests/integration/*` to `[tool.coverage] run.omit` so the + `--cov-fail-under=100` gate stays green even though the harness never runs in + the gated job. + +**The harness.** A `run_pod` fixture-factory: + +```python +def run_pod(compose, *, target, command="", project_dir=None, timeout=180) -> PodRun +``` + +renders a unique-pod-named script via `emit_script` (a fresh `EmitOptions` with +a dummy `ci_image` since scenarios set `image:` directly, `command` mapping to +the target override, empty `artifacts`/`allow_exit_codes`), writes `run.sh` to +`tmp_path`, and runs `sh run.sh` under a timeout, capturing output. Pod names are +unique per run (e.g. `c2p-it-`, `n` from a fixture counter) so containers are +the deterministic `{pod}-{service}` (`emit.py:98`). It yields: + +```python +class PodRun: + pod: str + returncode: int + stdout: str + stderr: str +``` + +The script's own `trap EXIT` removes the pod; the harness *also* runs +`podman pod rm -f ` in teardown as a defensive backstop (covers a timeout +kill or a crash before the trap installs) and never masks the original assertion +error. A subprocess timeout guards against hangs. + +**Seed scenarios** (behavioral probes; parametrizable, built to grow): + +1. **Healthy-gating with a real service** — a `db` service on the `postgres` + image with a `pg_isready` healthcheck, plus an `app` service (also the + `postgres` image, so one pull) with `depends_on: {db: service_healthy}` whose + command is `pg_isready -h 127.0.0.1 -p 5432`. Script exit 0 proves the health + gate waited *and* the app reached postgres over the pod-shared `127.0.0.1`. +2. **`service_completed_successfully`** — a one-shot `init` (busybox `sh -c 'exit + 0'`) gating an `app` target (busybox `echo ready`) via + `depends_on: {init: service_completed_successfully}`. Under `set -e`, a + non-zero `init` aborts the script, so exit 0 proves the one-shot ran to + completion before the target. +3. **Bind volume** — write a sentinel file to `tmp_path`, run with + `project_dir=tmp_path` and a busybox target mounting `./data.txt:/mnt/data.txt` + whose command is `cat /mnt/data.txt`. Assert exit 0 and the sentinel appears in + `stdout` (also exercises the relative-bind `project_dir` resolution path). + +**CI.** A new `integration` job in `.github/workflows/_checks.yml` on +`ubuntu-latest` (podman preinstalled), single Python (3.12), mirroring the +`pytest` job's setup and ending with `just test-integration`. + +## Non-goals + +- **Reproducing the constrained env** (read-only `/proc/sys`, no netavark, no + systemd). Default rootless podman on the runner exercises the single-pod / + `127.0.0.1` / add-host / healthcheck-polling paths, which is where the logic + lives; forcing the constrained env needs nested containers and is fragile. +- **First-class local tooling.** It is a CI job; a dev with podman may run the + marker, but no local runner is built. +- **Full data-driven scenario files** (compose YAML + expectations JSON). The + `PodRun` object is the building block a later data-driven form would reuse; not + built now. +- **Matrix across Python versions.** Integration behavior is podman-side, not + Python-version-sensitive; one interpreter suffices. + +## Testing + +`just test-integration` on the new CI job: the three seed scenarios pass against +real podman (green job = generated scripts run). `just test-ci` still passes at +100% (marker + `omit` keep the harness out of the gate); `just lint-ci` clean +over the new files; `just check-planning` clean. + +## Risk + +- **CI flakiness / image pulls** (med x med): postgres pull and healthcheck + timing can be slow or flaky. Mitigated by generous per-run timeouts, small + images for the other scenarios, and force-`rm` teardown so a leaked pod never + breaks the next run. +- **Coverage-gate leak** (low x high): if the `omit`/marker wiring is wrong the + harness either drags the gate below 100% or silently runs in the matrix job. + Mitigated by verifying `just test-ci` collects zero integration items and stays + at 100% before merge. +- **Podman absent for a contributor** (low x low): handled by a per-test skip + (autouse fixture) on missing `podman`, so the default suite is unaffected. diff --git a/pyproject.toml b/pyproject.toml index f095a65..3959355 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,13 +74,17 @@ isort.lines-after-imports = 2 isort.no-lines-before = ["standard-library", "local-folder"] [tool.pytest.ini_options] -addopts = "" +addopts = "-m 'not integration'" testpaths = ["tests"] pythonpath = ["."] +markers = [ + "integration: end-to-end tests that execute generated scripts against real podman (CI-only)", +] [tool.coverage] report.exclude_also = ["if typing.TYPE_CHECKING:"] # The __main__.py shim is a pure `sys.exit(main())` entrypoint exercised only via # subprocess (tests/test_cli.py::test_python_m_runs), which the parent coverage process # can't measure; omit it rather than enable subprocess/branch coverage machinery. -run.omit = ["compose2pod/__main__.py"] +# tests/integration/* only runs in the dedicated podman CI job, never in the gated run. +run.omit = ["compose2pod/__main__.py", "tests/integration/*"] diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..fb00685 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,98 @@ +"""Integration harness: render a compose doc, run the generated script on real podman. + +The generated script installs `trap '' EXIT` and runs the target +in the foreground, so the pod is gone the instant `sh run.sh` returns. Assertions are +therefore behavioral: under the script's `set -eu`, a clean exit proves pod creation, +dependency startup, `service_healthy` gating, completion-gating, and a passing target +probe. Each scenario encodes its check as the target service's own command. +""" + +import shutil +import subprocess +from collections.abc import Callable, Iterator +from dataclasses import dataclass +from pathlib import Path + +import pytest + +from compose2pod import EmitOptions, emit_script + + +_PODMAN = shutil.which("podman") +_SH = shutil.which("sh") +_INTEGRATION_DIR = Path(__file__).parent + + +@pytest.hookimpl(tryfirst=True) +def pytest_collection_modifyitems(items: "list[pytest.Item]") -> None: + """Auto-mark every test under tests/integration/ as `integration` by location.""" + for item in items: + if _INTEGRATION_DIR in item.path.parents: + item.add_marker(pytest.mark.integration) + + +@dataclass(frozen=True) +class PodRun: + """Result of running one generated script to completion.""" + + pod: str + returncode: int + stdout: str + stderr: str + + +@pytest.fixture(autouse=True) +def _require_podman() -> None: + """Skip every integration test when podman is not installed.""" + if _PODMAN is None: + pytest.skip("podman not installed") + + +@pytest.fixture +def run_pod(tmp_path: Path) -> Iterator[Callable[..., PodRun]]: + """Render `compose` for `target`, run the script, return a `PodRun`. + + Force-removes every pod it created on teardown as a defensive backstop, in + case the script's own EXIT trap did not fire (timeout kill or early crash). + """ + created: list[str] = [] + counter = 0 + + def _run( + compose: dict, + *, + target: str, + command: str = "", + project_dir: "str | Path | None" = None, + timeout: int = 180, + ) -> PodRun: + nonlocal counter + counter += 1 + pod = f"c2p-it-{counter}" + created.append(pod) + options = EmitOptions( + target=target, + ci_image="unused:latest", # only used by services with a `build:` section + command=command, + pod=pod, + project_dir=str(project_dir if project_dir is not None else tmp_path), + artifacts=[], + allow_exit_codes=[], + ) + script = tmp_path / f"{pod}.sh" + script.write_text(emit_script(compose, options)) + assert _SH is not None # narrows for the type checker; `sh` presence is a harness precondition + proc = subprocess.run( # noqa: S603 - _SH is an absolute path from shutil.which, not untrusted input + [_SH, str(script)], + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + return PodRun(pod=pod, returncode=proc.returncode, stdout=proc.stdout, stderr=proc.stderr) + + yield _run + + for pod in created: + assert _PODMAN is not None # narrows for the type checker; _require_podman already skipped otherwise + subprocess.run([_PODMAN, "pod", "rm", "-f", pod], capture_output=True, check=False) # noqa: S603 diff --git a/tests/integration/test_healthy_gating.py b/tests/integration/test_healthy_gating.py new file mode 100644 index 0000000..509c6ab --- /dev/null +++ b/tests/integration/test_healthy_gating.py @@ -0,0 +1,32 @@ +"""service_healthy gating against a real postgres, reached over the pod's 127.0.0.1.""" + +from collections.abc import Callable + +from tests.integration.conftest import PodRun + + +def test_healthy_gating_reaches_postgres(run_pod: Callable[..., PodRun]) -> None: + compose = { + "services": { + "db": { + "image": "postgres:16-alpine", + "environment": ["POSTGRES_PASSWORD=pw"], + "healthcheck": { + "test": ["CMD-SHELL", "pg_isready -U postgres"], + "interval": "1s", + "timeout": "5s", + "retries": 30, + }, + }, + "app": { + "image": "postgres:16-alpine", # reuse the image so there is a single pull + "depends_on": {"db": {"condition": "service_healthy"}}, + "command": ["pg_isready", "-h", "127.0.0.1", "-p", "5432"], + }, + }, + } + run = run_pod(compose, target="app") + # Exit 0 proves: db reached `healthy` (wait_healthy returned 0) AND the app + # reached postgres over the pod-shared 127.0.0.1 (pg_isready exits 0). + assert run.returncode == 0, run.stderr + assert "accepting connections" in run.stdout From e77d4bd8b9991bdb660d165ae3baf0aad0750acb Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 13 Jul 2026 09:18:18 +0300 Subject: [PATCH 2/5] test: add completion-gating and bind-volume integration scenarios --- tests/integration/test_bind_volume.py | 22 ++++++++++++++++++++ tests/integration/test_completion_gating.py | 23 +++++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 tests/integration/test_bind_volume.py create mode 100644 tests/integration/test_completion_gating.py diff --git a/tests/integration/test_bind_volume.py b/tests/integration/test_bind_volume.py new file mode 100644 index 0000000..23fbdfc --- /dev/null +++ b/tests/integration/test_bind_volume.py @@ -0,0 +1,22 @@ +"""Short-form bind volume: a host file resolved against project_dir is mounted and read.""" + +from collections.abc import Callable +from pathlib import Path + +from tests.integration.conftest import PodRun + + +def test_bind_volume_is_mounted(run_pod: Callable[..., PodRun], tmp_path: Path) -> None: + (tmp_path / "data.txt").write_text("sentinel-42\n") + compose = { + "services": { + "app": { + "image": "busybox:1.36", + "volumes": ["./data.txt:/mnt/data.txt"], # relative -> resolved against project_dir + "command": ["cat", "/mnt/data.txt"], + }, + }, + } + run = run_pod(compose, target="app", project_dir=tmp_path) + assert run.returncode == 0, run.stderr + assert "sentinel-42" in run.stdout diff --git a/tests/integration/test_completion_gating.py b/tests/integration/test_completion_gating.py new file mode 100644 index 0000000..95d92cf --- /dev/null +++ b/tests/integration/test_completion_gating.py @@ -0,0 +1,23 @@ +"""service_completed_successfully ordering: a one-shot must finish before the target.""" + +from collections.abc import Callable + +from tests.integration.conftest import PodRun + + +def test_completion_gating_orders_one_shot(run_pod: Callable[..., PodRun]) -> None: + compose = { + "services": { + "init": {"image": "busybox:1.36", "command": ["sh", "-c", "exit 0"]}, + "app": { + "image": "busybox:1.36", + "depends_on": {"init": {"condition": "service_completed_successfully"}}, + "command": ["echo", "ready"], + }, + }, + } + run = run_pod(compose, target="app") + # The one-shot runs blocking (`podman run --rm`) before the target; under + # `set -e` a non-zero init would abort the script. Exit 0 + "ready" proves order. + assert run.returncode == 0, run.stderr + assert "ready" in run.stdout From 9e62712b751ca9050e9fa14ef249b1d6e2cbe3f1 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 13 Jul 2026 09:21:55 +0300 Subject: [PATCH 3/5] ci: run the podman integration harness in a dedicated job --- .github/workflows/_checks.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/_checks.yml b/.github/workflows/_checks.yml index ec53dd7..ad164d4 100644 --- a/.github/workflows/_checks.yml +++ b/.github/workflows/_checks.yml @@ -33,3 +33,18 @@ jobs: - run: uv python pin ${{ matrix.python-version }} - run: just install - run: just test-ci + + integration: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: extractions/setup-just@v4 + - uses: astral-sh/setup-uv@v8.2.0 + with: + enable-cache: true + cache-dependency-glob: "**/pyproject.toml" + - run: uv python install 3.12 + - run: uv python pin 3.12 + - run: just install + - run: podman --version + - run: just test-integration From d9ea6ef31c10f50ea3f7def59f3dfcd91da426ed Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 13 Jul 2026 09:32:39 +0300 Subject: [PATCH 4/5] test: make completion-gating probe fail on regression and pod names unique --- tests/integration/conftest.py | 6 ++--- tests/integration/test_completion_gating.py | 27 +++++++++++++++------ 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index fb00685..dbec375 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -12,6 +12,7 @@ from collections.abc import Callable, Iterator from dataclasses import dataclass from pathlib import Path +from uuid import uuid4 import pytest @@ -56,7 +57,6 @@ def run_pod(tmp_path: Path) -> Iterator[Callable[..., PodRun]]: case the script's own EXIT trap did not fire (timeout kill or early crash). """ created: list[str] = [] - counter = 0 def _run( compose: dict, @@ -66,9 +66,7 @@ def _run( project_dir: "str | Path | None" = None, timeout: int = 180, ) -> PodRun: - nonlocal counter - counter += 1 - pod = f"c2p-it-{counter}" + pod = f"c2p-it-{uuid4().hex[:8]}" created.append(pod) options = EmitOptions( target=target, diff --git a/tests/integration/test_completion_gating.py b/tests/integration/test_completion_gating.py index 95d92cf..5241635 100644 --- a/tests/integration/test_completion_gating.py +++ b/tests/integration/test_completion_gating.py @@ -1,23 +1,34 @@ -"""service_completed_successfully ordering: a one-shot must finish before the target.""" +"""service_completed_successfully: the target observably requires the one-shot's output. + +The one-shot sleeps, then writes a flag to a shared bind mount; the target reads it. +If completion-gating regressed (one-shot backgrounded instead of run blocking), the +target would run during the sleep, cat a missing file, exit non-zero, and fail the +script under `set -e` -- so exit 0 + the flag content genuinely proves ordering. +""" from collections.abc import Callable +from pathlib import Path from tests.integration.conftest import PodRun -def test_completion_gating_orders_one_shot(run_pod: Callable[..., PodRun]) -> None: +def test_completion_gating_orders_one_shot(run_pod: Callable[..., PodRun], tmp_path: Path) -> None: + (tmp_path / "shared").mkdir() compose = { "services": { - "init": {"image": "busybox:1.36", "command": ["sh", "-c", "exit 0"]}, + "init": { + "image": "busybox:1.36", + "volumes": ["./shared:/shared"], + "command": ["sh", "-c", "sleep 2; echo done > /shared/flag"], + }, "app": { "image": "busybox:1.36", + "volumes": ["./shared:/shared"], "depends_on": {"init": {"condition": "service_completed_successfully"}}, - "command": ["echo", "ready"], + "command": ["sh", "-c", "cat /shared/flag"], }, }, } - run = run_pod(compose, target="app") - # The one-shot runs blocking (`podman run --rm`) before the target; under - # `set -e` a non-zero init would abort the script. Exit 0 + "ready" proves order. + run = run_pod(compose, target="app", project_dir=tmp_path) assert run.returncode == 0, run.stderr - assert "ready" in run.stdout + assert "done" in run.stdout From 8dcfd50f0c9f8b4c1e42bec0c855b45dc2c8a4dc Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 13 Jul 2026 10:20:52 +0300 Subject: [PATCH 5/5] fix: emit add-host at podman pod create instead of podman run Current podman rejects --add-host on a container joining a pod ("network cannot be configured when it is shared with a pod"). Moves alias/hostname add-host emission from run_flags to pod_create_flags, alongside dns/sysctls, and folds extra_hosts in as a pod-level key merged into the same add-host set (conflict refused rather than guessed at). --- architecture/supported-subset.md | 84 ++++++----- compose2pod/emit.py | 12 +- compose2pod/keys.py | 9 +- compose2pod/parsing.py | 3 +- compose2pod/pod.py | 48 ++++++- .../2026-07-13.01-pod-level-add-host.md | 113 +++++++++++++++ tests/test_emit.py | 134 ++++++++---------- tests/test_parsing.py | 4 +- tests/test_pod.py | 68 ++++++++- 9 files changed, 335 insertions(+), 140 deletions(-) create mode 100644 planning/changes/2026-07-13.01-pod-level-add-host.md diff --git a/architecture/supported-subset.md b/architecture/supported-subset.md index 0267a1e..d876ac6 100644 --- a/architecture/supported-subset.md +++ b/architecture/supported-subset.md @@ -31,10 +31,12 @@ warns (ignored, behavior-neutral inside a single pod) or raises - **Service-key registry:** the declarative, uniformly-shaped flag keys — `user`, `working_dir`, `platform`, `init`, `read_only`, `privileged`, `group_add`, `cap_add`, `cap_drop`, `security_opt`, `devices`, `labels`, - `annotations`, `extra_hosts`, `pull_policy`, `ulimits` — are defined once, - each as a `(validate, emit)` pair, in the **service-key registry** - (`SERVICE_KEYS` in `compose2pod/keys.py`); see `architecture/glossary.md` - for the service-key spec / service-key registry / structural key terms. + `annotations`, `pull_policy`, `ulimits` — are defined once, each as a + `(validate, emit)` pair, in the **service-key registry** (`SERVICE_KEYS` in + `compose2pod/keys.py`); see `architecture/glossary.md` for the service-key + spec / service-key registry / structural key terms. `extra_hosts` is a + supported key but not a registry entry — it is pod-level, see Pod-level + options below. The remaining keys documented below are **structural keys**, handled outside the registry because their `emit` needs `project_dir`, spans multiple keys, or occupies the image/command slot. @@ -67,9 +69,11 @@ warns (ignored, behavior-neutral inside a single pod) or raises - **`annotations`:** list or mapping, emitted as repeated `--annotation` (`KEY=value`, or bare `KEY` for a null value), sharing the `_MAP_FLAGS` machinery with `labels`. -- **`extra_hosts`:** list (`- host:ip`) or mapping (`host: ip`), emitted as - per-service `--add-host host:ip`. Distinct from the alias/hostname entries - (which resolve to `127.0.0.1`); IPv6 values keep their colons. +- **`extra_hosts`:** list (`- host:ip`) or mapping (`host: ip`), pod-level like + `dns`/`sysctls` — see the Pod-level options section below. Distinct from + the alias/hostname set in one respect: alias/hostname resolution is + always fixed at `127.0.0.1`, while `extra_hosts` carries a user-specified + address; IPv6 values keep their colons. - **`pull_policy`:** a validated enum mapped to podman's `--pull` (`if_not_present` → `missing`; `always`/`never`/`missing` pass through), emitted literally. `build` and unknown values are rejected — compose2pod @@ -181,36 +185,48 @@ it, so a handful of Compose keys cannot be per-container `podman run` flags. compose2pod hoists them onto `podman pod create` instead (`compose2pod/pod.py`) — the tool's only pod-create flags. -- **Supported:** `dns`, `dns_search`, `dns_opt`, `sysctls` — mapped to - `--dns`, `--dns-search`, `--dns-option`, `--sysctl` respectively (`_DNS_KEYS`, +- **Supported:** `dns`, `dns_search`, `dns_opt`, `sysctls`, `extra_hosts` — + mapped to `--dns`, `--dns-search`, `--dns-option`, `--sysctl`, and (merged + with the alias/hostname set) `--add-host` respectively (`_DNS_KEYS`, `pod.py`). -- **Aggregation is closure-scoped:** `pod_create_flags(services, order)` is - called with `order` — the target's dependency closure (`startup_order`) — - exactly like other closure-scoped constructs (secrets, configs). `dns` / - `dns_search` / `dns_opt` are unioned across the closure (deduplicated, - first-seen order); `sysctls` are unioned by key, and two services in the - closure setting the same key to different values is refused - (`UnsupportedComposeError: conflicting sysctl ...`) rather than resolved by - last-writer-wins. +- **Aggregation is closure-scoped:** `pod_create_flags(services, order, + hosts)` is called with `order` — the target's dependency closure + (`startup_order`) — exactly like other closure-scoped constructs (secrets, + configs). `dns` / `dns_search` / `dns_opt` are unioned across the closure + (deduplicated, first-seen order); `sysctls` are unioned by key, and two + services in the closure setting the same key to different values is + refused (`UnsupportedComposeError: conflicting sysctl ...`) rather than + resolved by last-writer-wins. `--add-host` is seeded from the + alias/hostname set (`hosts`, computed document-wide by `graph.hostnames`, + not closure-scoped — pre-existing, orthogonal behavior), then layered with + each closure service's `extra_hosts` (closure-scoped like `dns`/`sysctls`); + a host name landing on two different addresses — across two services' + `extra_hosts`, or between an `extra_hosts` entry and an alias's fixed + `127.0.0.1` — is refused (`UnsupportedComposeError: conflicting host ...`), + the same refuse-rather-than-guess rule as the `sysctls` conflict. - **Value shapes:** `dns` / `dns_search` / `dns_opt` accept a string or a list of strings; `sysctls` accepts a mapping (`key: value`) or a list of `"key=value"` strings, each value a string or number. A `${VAR}` inside a value is wrapped in `_Expand` like other interpolated fields, so it stays live at run time and counts toward `referenced_variables` — the generated script's own shell expands it when it runs, not compose2pod at generation - time. + time. `--add-host` entries render differently by source: an alias/hostname + entry stays a plain unquoted token (pre-existing behavior, unchanged by + this move), while an `extra_hosts` entry goes through `_Expand` (quoted, + `${VAR}`-live) — same as before it was per-service. - **Pod-wide divergence:** unlike every other service key, these apply to every container in the pod once emitted — including services that never - declared them — because the pod shares one `/etc/resolv.conf` and one - sysctl set. `validate()` (`compose2pod/parsing.py`) is target-agnostic - shape validation over the whole document: whenever any service anywhere - declares `dns` / `dns_search` / `dns_opt` / `sysctls` (`uses_pod_options`), - it emits the warning "dns/sysctls apply pod-wide -- all containers in the - pod share one /etc/resolv.conf and sysctl set", regardless of whether that + declared them — because the pod shares one `/etc/resolv.conf`, one sysctl + set, and one `/etc/hosts`. `validate()` (`compose2pod/parsing.py`) is + target-agnostic shape validation over the whole document: whenever any + service anywhere declares `dns` / `dns_search` / `dns_opt` / `sysctls` / + `extra_hosts` (`uses_pod_options`), it emits the warning "dns/sysctls/ + extra_hosts apply pod-wide -- all containers in the pod share one + /etc/resolv.conf, sysctl set, and /etc/hosts", regardless of whether that service turns out to be inside the target's closure. Conversely, at emit - time a `dns` / `sysctls` declaration on a service outside the target's - closure is silently ignored by `pod_create_flags` — no flag is emitted for - it, since that service is never run. + time a `dns` / `sysctls` / `extra_hosts` declaration on a service outside + the target's closure is silently ignored by `pod_create_flags` — no flag + is emitted for it, since that service is never run. - **Non-goals:** per-service DNS/sysctls — impossible inside a shared-namespace pod, not a compose2pod limitation; last-writer-wins on a sysctl key conflict — refused instead, matching the refuse-on-conflict @@ -444,13 +460,13 @@ enumeration ever appears to drift: the healthcheck `test` command. - **Service-key registry fields** whose spec wraps its value in `_Expand` — e.g. `user`, `working_dir`, `platform`, `group_add`, `cap_add`, `cap_drop`, - `security_opt`, `devices`, `labels`, `annotations`, `extra_hosts`, - `ulimits`, and every numeric resource-limit key (`mem_limit`, `cpus`, - `pids_limit`, ...). The rule, not the list, is authoritative: this is every - `SERVICE_KEYS` entry whose `emit` wraps its value (the - `_scalar`/`_number_scalar`/`_list`/`_map` factories, plus the custom - `extra_hosts`/`ulimits` emitters) except `pull_policy` (a validated enum - emitted verbatim from `PULL_POLICY_MAP`) and the four boolean flags + `security_opt`, `devices`, `labels`, `annotations`, `ulimits`, and every + numeric resource-limit key (`mem_limit`, `cpus`, `pids_limit`, ...). The + rule, not the list, is authoritative: this is every `SERVICE_KEYS` entry + whose `emit` wraps its value (the `_scalar`/`_number_scalar`/`_list`/`_map` + factories, plus the custom `ulimits` emitter) except `pull_policy` (a + validated enum emitted verbatim from `PULL_POLICY_MAP`) and the four + boolean flags `init`/`read_only`/`privileged`/`oom_kill_disable` (each emits a bare flag with no value to interpolate). diff --git a/compose2pod/emit.py b/compose2pod/emit.py index 73c3144..118e4d5 100644 --- a/compose2pod/emit.py +++ b/compose2pod/emit.py @@ -93,11 +93,9 @@ def _add_volume_flags(flags: list[Token], svc: dict[str, Any], project_dir: str) flags += ["--tmpfs", _Expand(value=mount)] -def run_flags(name: str, svc: dict[str, Any], pod: str, hosts: list[str], project_dir: str) -> list[Token]: +def run_flags(name: str, svc: dict[str, Any], pod: str, project_dir: str) -> list[Token]: """Flag tokens (unquoted) for `podman run` of one service.""" flags: list[Token] = ["--pod", pod, "--name", f"{pod}-{name}"] - for host in hosts: - flags += ["--add-host", f"{host}:127.0.0.1"] _add_env_flags(flags, svc, project_dir) _add_volume_flags(flags, svc, project_dir) _add_health_flags(flags, svc.get("healthcheck") or {}) @@ -168,9 +166,9 @@ def _collect_vars(tokens: list[Token], names: set[str]) -> None: names.update(variable_names(token.value)) -def _run_tokens(name: str, services: dict[str, Any], options: EmitOptions, hosts: list[str]) -> list[Token]: +def _run_tokens(name: str, services: dict[str, Any], options: EmitOptions) -> list[Token]: svc = services[name] - tokens = run_flags(name, svc, options.pod, hosts, options.project_dir) + tokens = run_flags(name, svc, options.pod, options.project_dir) entrypoint = entrypoint_tokens(svc) if entrypoint: tokens += ["--entrypoint", entrypoint[0]] @@ -230,7 +228,7 @@ def _plan(compose: dict[str, Any], options: EmitOptions) -> PlannedScript: if store_teardown: teardown += f"; {store_teardown}" lines.append(f"trap '{teardown}' EXIT") - pod_flags = pod_create_flags(services, order) + pod_flags = pod_create_flags(services, order, hosts) _collect_vars(pod_flags, names) pod_create = f"podman pod create --name {shlex.quote(options.pod)}" if pod_flags: @@ -246,7 +244,7 @@ def _plan(compose: dict[str, Any], options: EmitOptions) -> PlannedScript: attempts = max(HEALTHY_WAIT_BUDGET_SECONDS // interval, 1) lines.append(f"wait_healthy {shlex.quote(f'{options.pod}-{dep}')} {attempts} {interval}") waited.add(dep) - run_tokens = _run_tokens(name, services, options, hosts) + run_tokens = _run_tokens(name, services, options) _collect_vars(run_tokens, names) if name == options.target: _emit_target(lines, run_tokens, options) diff --git a/compose2pod/keys.py b/compose2pod/keys.py index bee5326..3a7965e 100644 --- a/compose2pod/keys.py +++ b/compose2pod/keys.py @@ -126,13 +126,6 @@ def _extra_host_pairs(value: list[Any] | dict[str, Any]) -> list[Any]: return [f"{host}:{ip}" for host, ip in value.items()] -def _emit_extra_hosts(value: Any) -> list[Token]: # noqa: ANN401 - Compose values are untyped YAML/JSON - tokens: list[Token] = [] - for entry in _extra_host_pairs(value): - tokens += ["--add-host", _Expand(value=str(entry))] - return tokens - - def _validate_pull_policy(name: str, key: str, value: Any) -> None: # noqa: ANN401 - Compose values are untyped if value is not None and (not isinstance(value, str) or value not in PULL_POLICY_MAP): allowed = "/".join(PULL_POLICY_MAP) @@ -194,7 +187,6 @@ def _emit_ulimits(value: Any) -> list[Token]: # noqa: ANN401 - Compose values a "devices": _list("--device"), "labels": _map("--label"), "annotations": _map("--annotation"), - "extra_hosts": KeySpec(validate=_validate_map, emit=_emit_extra_hosts), "pull_policy": KeySpec(validate=_validate_pull_policy, emit=_emit_pull_policy), "ulimits": KeySpec(validate=_validate_ulimits, emit=_emit_ulimits), "mem_limit": _number_scalar("--memory"), @@ -233,4 +225,5 @@ def _emit_ulimits(value: Any) -> list[Token]: # noqa: ANN401 - Compose values a "dns_search", "dns_opt", "sysctls", + "extra_hosts", } diff --git a/compose2pod/parsing.py b/compose2pod/parsing.py index 3bf4535..f7d747d 100644 --- a/compose2pod/parsing.py +++ b/compose2pod/parsing.py @@ -137,6 +137,7 @@ def validate(compose: dict[str, Any]) -> list[str]: stores.validate(compose) if uses_pod_options(services): warnings.append( - "dns/sysctls apply pod-wide -- all containers in the pod share one /etc/resolv.conf and sysctl set" + "dns/sysctls/extra_hosts apply pod-wide -- all containers in the pod share one " + "/etc/resolv.conf, sysctl set, and /etc/hosts" ) return warnings diff --git a/compose2pod/pod.py b/compose2pod/pod.py index fcba661..4a203b6 100644 --- a/compose2pod/pod.py +++ b/compose2pod/pod.py @@ -3,11 +3,11 @@ from typing import Any from compose2pod.exceptions import UnsupportedComposeError -from compose2pod.keys import Token, _Expand +from compose2pod.keys import Token, _Expand, _extra_host_pairs, _validate_map _DNS_KEYS = {"dns": "--dns", "dns_search": "--dns-search", "dns_opt": "--dns-option"} -_POD_OPTION_KEYS = (*_DNS_KEYS, "sysctls") +_POD_OPTION_KEYS = (*_DNS_KEYS, "sysctls", "extra_hosts") def _as_str_list(name: str, key: str, value: Any) -> list[str]: # noqa: ANN401 - Compose values are untyped @@ -47,6 +47,8 @@ def validate_pod_options(name: str, svc: dict[str, Any]) -> None: _as_str_list(name, key, svc[key]) if "sysctls" in svc: _sysctl_pairs(name, svc["sysctls"]) + if "extra_hosts" in svc: + _validate_map(name, "extra_hosts", svc["extra_hosts"]) def uses_pod_options(services: dict[str, Any]) -> bool: @@ -85,10 +87,42 @@ def _sysctl_flags(services: dict[str, Any], order: list[str]) -> list[Token]: return tokens -def pod_create_flags(services: dict[str, Any], order: list[str]) -> list[Token]: - """Pod-create flag tokens aggregated across the closure `order`. +def _add_host_flags(services: dict[str, Any], order: list[str], hosts: list[str]) -> list[Token]: + """Merge alias/hostname hosts (fixed 127.0.0.1) with extra_hosts (order-scoped) into one add-host set. - dns/dns_search/dns_opt are unioned (dedup, first-seen order); sysctls are - unioned by key and a same-key value conflict is refused. + A host name landing on two different addresses -- across services' extra_hosts, + or against an alias's fixed 127.0.0.1 -- is refused rather than guessed at, matching + the sysctls conflict rule below. Alias entries render as plain tokens (unquoted, as + before this move); extra_hosts entries render via `_Expand` (as before, quoted/interpolated) + -- relocating the flags changes nothing else observable about either source. """ - return _dns_flags(services, order) + _sysctl_flags(services, order) + merged: dict[str, str] = {} + from_extra_hosts: set[str] = set() + for host in hosts: + merged[host] = "127.0.0.1" + for name in order: + svc = services[name] + if "extra_hosts" not in svc: + continue + for entry in _extra_host_pairs(svc["extra_hosts"]): + host, _sep, addr = str(entry).partition(":") + if merged.get(host, addr) != addr: + msg = f"service {name!r}: conflicting host {host!r} ({merged[host]!r} vs {addr!r})" + raise UnsupportedComposeError(msg) + merged[host] = addr + from_extra_hosts.add(host) + tokens: list[Token] = [] + for host, addr in merged.items(): + value = _Expand(value=f"{host}:{addr}") if host in from_extra_hosts else f"{host}:{addr}" + tokens += ["--add-host", value] + return tokens + + +def pod_create_flags(services: dict[str, Any], order: list[str], hosts: list[str]) -> list[Token]: + """Pod-create flag tokens aggregated across the closure `order`, plus `hosts` for add-host. + + Add-host is merged from alias/hostname resolution (`hosts`, fixed 127.0.0.1) and each + closure service's `extra_hosts`, conflict-checked (see `_add_host_flags`). dns/dns_search/ + dns_opt are unioned (dedup, first-seen order); sysctls are unioned by key. + """ + return _add_host_flags(services, order, hosts) + _dns_flags(services, order) + _sysctl_flags(services, order) diff --git a/planning/changes/2026-07-13.01-pod-level-add-host.md b/planning/changes/2026-07-13.01-pod-level-add-host.md new file mode 100644 index 0000000..f55568e --- /dev/null +++ b/planning/changes/2026-07-13.01-pod-level-add-host.md @@ -0,0 +1,113 @@ +--- +summary: Move all `--add-host` emission (alias/hostname resolution and `extra_hosts`) from per-service `podman run` to `podman pod create`, since current podman rejects network config on a container joining a pod; `extra_hosts` becomes pod-wide like `dns`/`sysctls`, with a same-host-different-address conflict (against another service's `extra_hosts` or an alias's fixed `127.0.0.1`) refused rather than guessed at. +--- + +# Design: pod-level `--add-host` + +## Summary + +Current podman rejects `--add-host` on `podman run` for a container joining a +pod (`invalid config provided: extra host entries must be specified on the +pod: network cannot be configured when it is shared with a pod`), discovered +by the new integration harness (`planning/changes/2026-07-12.04-integration-harness.md`) +on its first real CI run. Every generated script with more than a trivial +single-service pod currently fails at run time. The fix hoists all +`--add-host` emission onto `podman pod create`, alongside `dns`/`sysctls` +(`compose2pod/pod.py`). This forces `extra_hosts` — previously genuinely +per-service — to become pod-wide like `dns`/`sysctls` already are; a same-host +conflict is refused rather than resolved by guessing. + +## Motivation + +`run_flags` (`compose2pod/emit.py:96-100`) emits `--add-host :127.0.0.1` +for every alias/hostname/container_name in the whole document, on every +service's `podman run` line — and `extra_hosts` (`compose2pod/keys.py:197`) +emits a per-service `--add-host host:ip` the same way. Both are now rejected +outright by podman once a container joins a pod. The integration harness's +first CI run (PR #41) demonstrated this on all three seed scenarios: +`Error: invalid config provided: extra host entries must be specified on the +pod: network cannot be configured when it is shared with a pod`. + +## Design + +**Alias/hostname hosts move as-is.** They were already effectively pod-wide +(the exact same set was redundantly re-emitted on every service's `run` +line), so relocating them to `podman pod create` changes nothing observable +except where the flag lives. + +**`extra_hosts` becomes a pod-level key, mirroring `dns`/`sysctls`:** +- Removed from `SERVICE_KEYS` (`compose2pod/keys.py`) — no longer a + per-container emit. Its `_extra_host_pairs` normalizer (list → items, + mapping → `"host:ip"` strings, IPv6-safe) stays in `keys.py` and is now + imported by `pod.py`. `_emit_extra_hosts` (the old per-service Token + emitter) is deleted — dead once `extra_hosts` leaves `SERVICE_KEYS`. +- Added to `STRUCTURAL_KEYS` (`compose2pod/keys.py`) so it stays a + recognized/supported service key. +- Added to `_POD_OPTION_KEYS` (`compose2pod/pod.py`), so `uses_pod_options` + correctly fires the existing pod-wide-effects warning + (`compose2pod/parsing.py`) whenever any service declares it. The warning + text extends from `"dns/sysctls apply pod-wide ..."` to also name + `extra_hosts`/`/etc/hosts`. +- Shape-checked in `validate_pod_options` (`compose2pod/pod.py`) via the + existing `_validate_map` (list or mapping) — moved out of the generic + `SERVICE_KEYS` validate loop, same relocation pattern as its emit. + +**Merge and conflict rule** (new `_add_host_flags` in `pod.py`, alongside +`_dns_flags`/`_sysctl_flags`, folded into `pod_create_flags`, which gains a +third `hosts: list[str]` parameter): +- Seed the merged `{host: address}` map from the alias/hostname set (`hosts`, + computed once by `graph.hostnames`), all fixed at `127.0.0.1` — these can + never conflict with each other (same fixed value). +- Layer each closure service's `extra_hosts` on top, closure-scoped exactly + like `dns`/`sysctls` (`order`, not the whole document). +- A host name landing on two different addresses — from two services' + `extra_hosts`, or from an `extra_hosts` entry against an alias's fixed + `127.0.0.1` — raises `UnsupportedComposeError`, the same + refuse-rather-than-guess rule already used for a `sysctls` key conflict. + One rule for every host-merge source, no source-priority special-casing. + +**`run_flags` drops its `hosts` parameter entirely** (the add-host loop is +deleted); `_run_tokens`, and the `_plan` call site, drop the argument too. +`_plan` passes `hosts` (already computed for its own purposes) into +`pod_create_flags` instead. + +## Non-goals + +- Per-service DNS/sysctls remain impossible inside a shared-namespace pod — + unrelated to this change, already documented. +- No new validation of individual `extra_hosts` entry shape beyond "list or + mapping" (e.g. a colon-less list entry) — matches the pre-existing + shallow validation; podman surfaces a malformed entry at run time, as it + already would have. +- No change to how aliases are collected (`graph.hostnames`) or scoped + (whole document, not closure-limited) — pre-existing, orthogonal behavior. + +## Testing + +`just test-ci` at 100%: `pod_create_flags` gains add-host cases (alias-only, +extra_hosts-only, both merged, same-value dedup across sources, conflicting +addresses refused — extra_hosts vs extra_hosts, and extra_hosts vs an alias); +`run_flags` no longer emits `--add-host` for any input (extra_hosts or +otherwise) and drops the `hosts` parameter at every call site; the +whole-script tests (`test_add_host_on_every_run`, +`test_hostname_becomes_add_host_entry`, +`test_container_name_becomes_add_host_entry`, +`test_image_host_metadata_keys_compose_on_one_service`) are re-pointed at the +single `podman pod create` line instead of every `podman run` line; the +`extra_hosts` cases inside `test_registry_emission_order_across_shape_groups` +(a `SERVICE_KEYS` order-lock test) are removed, since `extra_hosts` is no +longer a `SERVICE_KEYS` entry. `just lint-ci` and `just check-planning` +clean. Final proof: the integration harness's three scenarios (PR #41) go +green in the dedicated CI job against real podman. + +## Risk + +- **Silent conflict-detection gap** (low x high): a host conflict slips + through unmerged/undetected. Mitigated by one merge function, one rule, + exercised directly in `pod.py` unit tests plus the end-to-end + `emit_script` tests. +- **Extra_hosts scope-broadening surprise** (med x low): a user relying on + today's genuinely-per-service `extra_hosts` gets a same-host conflict + error instead of silent divergence. Mitigated by the loud refusal (never a + silently wrong resolution) and the existing `uses_pod_options` warning + now also covering `extra_hosts`. diff --git a/tests/test_emit.py b/tests/test_emit.py index 82aa056..aec3dd2 100644 --- a/tests/test_emit.py +++ b/tests/test_emit.py @@ -14,46 +14,41 @@ from compose2pod.parsing import validate -_EXPECTED_RUN_LINES: int = 4 - - class TestRunFlags: def test_db_flags(self, chats_compose: dict) -> None: - flags = run_flags("db", chats_compose["services"]["db"], "test-pod", ["db", "keydb"], "/builds/chats") + flags = run_flags("db", chats_compose["services"]["db"], "test-pod", "/builds/chats") assert flags[:4] == ["--pod", "test-pod", "--name", "test-pod-db"] - assert flags[4:6] == ["--add-host", "db:127.0.0.1"] - assert flags[6:8] == ["--add-host", "keydb:127.0.0.1"] - assert flags[8:10] == ["-e", _Expand(value="POSTGRES_PASSWORD=password")] - assert flags[10:12] == ["--health-cmd", _Expand(value="pg_isready -U database -d database")] - assert flags[12:14] == ["--health-timeout", "5s"] - assert flags[14:16] == ["--health-retries", "15"] # fix #2 + assert flags[4:6] == ["-e", _Expand(value="POSTGRES_PASSWORD=password")] + assert flags[6:8] == ["--health-cmd", _Expand(value="pg_isready -U database -d database")] + assert flags[8:10] == ["--health-timeout", "5s"] + assert flags[10:12] == ["--health-retries", "15"] # fix #2 def test_start_period_is_passed_through(self) -> None: svc = {"image": "x", "healthcheck": {"test": "true", "start_period": "30s"}} - flags = run_flags("app", svc, "p", [], "/b") + flags = run_flags("app", svc, "p", "/b") assert "--health-start-period" in flags assert flags[flags.index("--health-start-period") + 1] == "30s" def test_env_map_form(self) -> None: svc = {"image": "x", "environment": {"A": "1", "B": "two words"}} - flags = run_flags("app", svc, "p", [], "/builds/x") + flags = run_flags("app", svc, "p", "/builds/x") assert flags[4:8] == ["-e", _Expand(value="A=1"), "-e", _Expand(value="B=two words")] def test_env_map_null_value_is_host_passthrough(self) -> None: # A null mapping value means "pass KEY through from the host", like `- KEY`. svc = {"image": "x", "environment": {"PASSTHRU": None, "SET": "v"}} - flags = run_flags("app", svc, "p", [], "/builds/x") + flags = run_flags("app", svc, "p", "/builds/x") assert flags[4:8] == ["-e", _Expand(value="PASSTHRU"), "-e", _Expand(value="SET=v")] def test_env_file_and_volume_resolved_against_project_dir(self) -> None: svc = {"image": "x", "env_file": "tests.env", "volumes": [".:/srv/www/"]} - flags = run_flags("app", svc, "p", [], "/builds/chats") + flags = run_flags("app", svc, "p", "/builds/chats") assert flags[4:6] == ["--env-file", _Expand(value="/builds/chats/tests.env")] assert flags[6:8] == ["-v", _Expand(value="/builds/chats:/srv/www/")] def test_env_file_list_form(self) -> None: svc = {"image": "x", "env_file": ["a.env", "b.env"]} - flags = run_flags("app", svc, "p", [], "/builds/x") + flags = run_flags("app", svc, "p", "/builds/x") assert flags[4:8] == [ "--env-file", _Expand(value="/builds/x/a.env"), @@ -64,174 +59,162 @@ def test_env_file_list_form(self) -> None: def test_tmpfs_string_form(self) -> None: # S108 flags "/tmp" as an insecure hardcoded temp path; this is a # pass-through string being tested, not a file write. - flags = run_flags("app", {"image": "x", "tmpfs": "/tmp:mode=1777"}, "p", [], "/builds/x") # noqa: S108 + flags = run_flags("app", {"image": "x", "tmpfs": "/tmp:mode=1777"}, "p", "/builds/x") # noqa: S108 assert flags[4:6] == ["--tmpfs", _Expand(value="/tmp:mode=1777")] # noqa: S108 def test_tmpfs_list_form(self) -> None: svc = {"image": "x", "tmpfs": ["/tmp:mode=1777", "/run"]} # noqa: S108 - flags = run_flags("app", svc, "p", [], "/builds/x") + flags = run_flags("app", svc, "p", "/builds/x") assert flags[4:8] == ["--tmpfs", _Expand(value="/tmp:mode=1777"), "--tmpfs", _Expand(value="/run")] # noqa: S108 def test_absolute_volume_source_is_kept_as_is(self) -> None: - flags = run_flags("app", {"image": "x", "volumes": ["/data/app:/srv/www/"]}, "p", [], "/builds/x") + flags = run_flags("app", {"image": "x", "volumes": ["/data/app:/srv/www/"]}, "p", "/builds/x") assert flags[4:6] == ["-v", _Expand(value="/data/app:/srv/www/")] def test_anonymous_volume_emitted_as_single_path(self) -> None: - flags = run_flags("app", {"image": "x", "volumes": ["/var/cache/models"]}, "p", [], "/builds/x") + flags = run_flags("app", {"image": "x", "volumes": ["/var/cache/models"]}, "p", "/builds/x") assert flags[4:6] == ["-v", _Expand(value="/var/cache/models")] def test_named_volume_emitted_without_project_dir_translation(self) -> None: svc = {"image": "x", "volumes": ["pgdata:/var/lib/postgresql/data"]} - flags = run_flags("db", svc, "p", [], "/builds/x") + flags = run_flags("db", svc, "p", "/builds/x") assert flags[4:6] == ["-v", _Expand(value="pgdata:/var/lib/postgresql/data")] def test_secret_flag_emitted(self) -> None: - flags = run_flags("app", {"image": "x", "secrets": ["db"]}, "test-pod", [], "/b") + flags = run_flags("app", {"image": "x", "secrets": ["db"]}, "test-pod", "/b") assert flags[-2:] == ["--secret", "source=test-pod-db,target=db"] def test_healthcheck_without_timeout_omits_health_timeout_flag(self) -> None: - flags = run_flags("app", {"image": "x", "healthcheck": {"test": "true"}}, "p", [], "/builds/x") + flags = run_flags("app", {"image": "x", "healthcheck": {"test": "true"}}, "p", "/builds/x") assert flags[4:6] == ["--health-cmd", _Expand(value="true")] assert "--health-timeout" not in flags def test_user_flag(self) -> None: - flags = run_flags("app", {"image": "x", "user": "1000:1000"}, "p", [], "/b") + flags = run_flags("app", {"image": "x", "user": "1000:1000"}, "p", "/b") assert flags[4:6] == ["--user", _Expand(value="1000:1000")] def test_working_dir_flag(self) -> None: - flags = run_flags("app", {"image": "x", "working_dir": "/srv/app"}, "p", [], "/b") + flags = run_flags("app", {"image": "x", "working_dir": "/srv/app"}, "p", "/b") assert flags[4:6] == ["--workdir", _Expand(value="/srv/app")] def test_user_and_working_dir_order(self) -> None: svc = {"image": "x", "user": "root", "working_dir": "/app"} - flags = run_flags("app", svc, "p", [], "/b") + flags = run_flags("app", svc, "p", "/b") assert flags[4:8] == ["--user", _Expand(value="root"), "--workdir", _Expand(value="/app")] def test_mem_limit_flag(self) -> None: - flags = run_flags("app", {"image": "x", "mem_limit": "512m"}, "p", [], "/b") + flags = run_flags("app", {"image": "x", "mem_limit": "512m"}, "p", "/b") assert flags[4:6] == ["--memory", _Expand(value="512m")] def test_cpus_numeric_value_flag(self) -> None: - flags = run_flags("app", {"image": "x", "cpus": 0.5}, "p", [], "/b") + flags = run_flags("app", {"image": "x", "cpus": 0.5}, "p", "/b") assert flags[4:6] == ["--cpus", _Expand(value="0.5")] def test_pids_limit_int_flag(self) -> None: - flags = run_flags("app", {"image": "x", "pids_limit": 100}, "p", [], "/b") + flags = run_flags("app", {"image": "x", "pids_limit": 100}, "p", "/b") assert flags[4:6] == ["--pids-limit", _Expand(value="100")] def test_cpuset_and_shm_size_flags(self) -> None: svc = {"image": "x", "cpuset": "0-3", "shm_size": "64m"} - flags = run_flags("app", svc, "p", [], "/b") + flags = run_flags("app", svc, "p", "/b") assert flags[4:8] == ["--cpuset-cpus", _Expand(value="0-3"), "--shm-size", _Expand(value="64m")] def test_oom_kill_disable_bool_flag(self) -> None: - assert run_flags("app", {"image": "x", "oom_kill_disable": True}, "p", [], "/b")[4:5] == ["--oom-kill-disable"] - assert "--oom-kill-disable" not in run_flags("app", {"image": "x", "oom_kill_disable": False}, "p", [], "/b") + assert run_flags("app", {"image": "x", "oom_kill_disable": True}, "p", "/b")[4:5] == ["--oom-kill-disable"] + assert "--oom-kill-disable" not in run_flags("app", {"image": "x", "oom_kill_disable": False}, "p", "/b") def test_group_add_flag(self) -> None: - flags = run_flags("app", {"image": "x", "group_add": ["docker", 1000]}, "p", [], "/b") + flags = run_flags("app", {"image": "x", "group_add": ["docker", 1000]}, "p", "/b") assert flags[4:8] == ["--group-add", _Expand(value="docker"), "--group-add", _Expand(value="1000")] def test_cap_add_flag(self) -> None: - flags = run_flags("app", {"image": "x", "cap_add": ["NET_ADMIN", "SYS_TIME"]}, "p", [], "/b") + flags = run_flags("app", {"image": "x", "cap_add": ["NET_ADMIN", "SYS_TIME"]}, "p", "/b") assert flags[4:8] == ["--cap-add", _Expand(value="NET_ADMIN"), "--cap-add", _Expand(value="SYS_TIME")] def test_cap_drop_and_security_opt_flags(self) -> None: svc = {"image": "x", "cap_drop": ["ALL"], "security_opt": ["label=disable"]} - flags = run_flags("app", svc, "p", [], "/b") + flags = run_flags("app", svc, "p", "/b") assert flags[4:8] == ["--cap-drop", _Expand(value="ALL"), "--security-opt", _Expand(value="label=disable")] def test_labels_map_form(self) -> None: svc = {"image": "x", "labels": {"team": "api", "tier": "backend"}} - flags = run_flags("app", svc, "p", [], "/b") + flags = run_flags("app", svc, "p", "/b") assert flags[4:8] == ["--label", _Expand(value="team=api"), "--label", _Expand(value="tier=backend")] def test_labels_list_form(self) -> None: svc = {"image": "x", "labels": ["team=api", "standalone"]} - flags = run_flags("app", svc, "p", [], "/b") + flags = run_flags("app", svc, "p", "/b") assert flags[4:8] == ["--label", _Expand(value="team=api"), "--label", _Expand(value="standalone")] def test_labels_null_value_is_empty_label(self) -> None: # A null map value is an empty label here, NOT the host-passthrough that # `environment`'s null means -- same emitted shape, distinct meaning. - flags = run_flags("app", {"image": "x", "labels": {"empty": None}}, "p", [], "/b") + flags = run_flags("app", {"image": "x", "labels": {"empty": None}}, "p", "/b") assert flags[4:6] == ["--label", _Expand(value="empty")] def test_platform_flag(self) -> None: - flags = run_flags("app", {"image": "x", "platform": "linux/amd64"}, "p", [], "/b") + flags = run_flags("app", {"image": "x", "platform": "linux/amd64"}, "p", "/b") assert flags[4:6] == ["--platform", _Expand(value="linux/amd64")] def test_devices_flag(self) -> None: - flags = run_flags("app", {"image": "x", "devices": ["/dev/fuse", "/dev/net/tun"]}, "p", [], "/b") + flags = run_flags("app", {"image": "x", "devices": ["/dev/fuse", "/dev/net/tun"]}, "p", "/b") assert flags[4:8] == ["--device", _Expand(value="/dev/fuse"), "--device", _Expand(value="/dev/net/tun")] def test_annotations_map_form(self) -> None: - flags = run_flags("app", {"image": "x", "annotations": {"com.example/team": "api"}}, "p", [], "/b") + flags = run_flags("app", {"image": "x", "annotations": {"com.example/team": "api"}}, "p", "/b") assert flags[4:6] == ["--annotation", _Expand(value="com.example/team=api")] def test_annotations_null_value_is_bare_key(self) -> None: - flags = run_flags("app", {"image": "x", "annotations": {"marker": None}}, "p", [], "/b") + flags = run_flags("app", {"image": "x", "annotations": {"marker": None}}, "p", "/b") assert flags[4:6] == ["--annotation", _Expand(value="marker")] def test_labels_still_emit_after_map_flags_refactor(self) -> None: - flags = run_flags("app", {"image": "x", "labels": {"team": "api"}}, "p", [], "/b") + flags = run_flags("app", {"image": "x", "labels": {"team": "api"}}, "p", "/b") assert flags[4:6] == ["--label", _Expand(value="team=api")] def test_read_only_flag(self) -> None: - flags = run_flags("app", {"image": "x", "read_only": True}, "p", [], "/b") + flags = run_flags("app", {"image": "x", "read_only": True}, "p", "/b") assert flags[4:5] == ["--read-only"] def test_all_boolean_flags_emit_when_true(self) -> None: svc = {"image": "x", "init": True, "read_only": True, "privileged": True} - flags = run_flags("app", svc, "p", [], "/b") + flags = run_flags("app", svc, "p", "/b") assert flags[4:7] == ["--init", "--read-only", "--privileged"] def test_boolean_flag_false_or_absent_is_omitted(self) -> None: - flags = run_flags("app", {"image": "x", "read_only": False}, "p", [], "/b") + flags = run_flags("app", {"image": "x", "read_only": False}, "p", "/b") assert "--read-only" not in flags assert flags == ["--pod", "p", "--name", "p-app"] - def test_extra_hosts_list_form(self) -> None: - flags = run_flags("app", {"image": "x", "extra_hosts": ["db.local:10.0.0.5"]}, "p", [], "/b") - assert flags[4:6] == ["--add-host", _Expand(value="db.local:10.0.0.5")] - - def test_extra_hosts_map_form(self) -> None: - flags = run_flags("app", {"image": "x", "extra_hosts": {"db.local": "10.0.0.5"}}, "p", [], "/b") - assert flags[4:6] == ["--add-host", _Expand(value="db.local:10.0.0.5")] - - def test_extra_hosts_ipv6_value_keeps_colons(self) -> None: - flags = run_flags("app", {"image": "x", "extra_hosts": {"myhost": "2001:db8::1"}}, "p", [], "/b") - assert flags[4:6] == ["--add-host", _Expand(value="myhost:2001:db8::1")] - def test_pull_policy_maps_if_not_present_to_missing(self) -> None: - flags = run_flags("app", {"image": "x", "pull_policy": "if_not_present"}, "p", [], "/b") + flags = run_flags("app", {"image": "x", "pull_policy": "if_not_present"}, "p", "/b") assert flags[4:6] == ["--pull", "missing"] def test_pull_policy_passthrough_values(self) -> None: for value in ("always", "never", "missing"): - flags = run_flags("app", {"image": "x", "pull_policy": value}, "p", [], "/b") + flags = run_flags("app", {"image": "x", "pull_policy": value}, "p", "/b") assert flags[4:6] == ["--pull", value] def test_ulimits_mapping_form(self) -> None: svc = {"image": "x", "ulimits": {"nofile": {"soft": 20000, "hard": 40000}}} - flags = run_flags("app", svc, "p", [], "/b") + flags = run_flags("app", svc, "p", "/b") assert flags[4:6] == ["--ulimit", _Expand(value="nofile=20000:40000")] def test_ulimits_scalar_form(self) -> None: - flags = run_flags("app", {"image": "x", "ulimits": {"nproc": 65535}}, "p", [], "/b") + flags = run_flags("app", {"image": "x", "ulimits": {"nproc": 65535}}, "p", "/b") assert flags[4:6] == ["--ulimit", _Expand(value="nproc=65535")] def test_ulimits_mixed_forms(self) -> None: svc = {"image": "x", "ulimits": {"nproc": 65535, "nofile": {"soft": 1024, "hard": 2048}}} - flags = run_flags("app", svc, "p", [], "/b") + flags = run_flags("app", svc, "p", "/b") assert flags[4:8] == ["--ulimit", _Expand(value="nproc=65535"), "--ulimit", _Expand(value="nofile=1024:2048")] def test_null_pull_policy_and_ulimits_emit_nothing(self) -> None: - flags = run_flags("app", {"image": "x", "pull_policy": None, "ulimits": None}, "p", [], "/b") + flags = run_flags("app", {"image": "x", "pull_policy": None, "ulimits": None}, "p", "/b") assert flags == ["--pod", "p", "--name", "p-app"] def test_registry_emission_order_across_shape_groups(self) -> None: - # Locks the cross-key flag order (scalar, bool, list, map, extra_hosts, pull_policy, ulimits) + # Locks the cross-key flag order (scalar, bool, list, map, pull_policy, ulimits) # against a future reordering of the SERVICE_KEYS registry. svc = { "image": "x", @@ -239,11 +222,10 @@ def test_registry_emission_order_across_shape_groups(self) -> None: "init": True, "cap_add": ["NET_ADMIN"], "labels": {"team": "api"}, - "extra_hosts": {"db": "10.0.0.5"}, "pull_policy": "always", "ulimits": {"nofile": 1024}, } - flags = run_flags("app", svc, "p", [], "/b") + flags = run_flags("app", svc, "p", "/b") assert flags[4:] == [ "--user", _Expand(value="root"), @@ -252,8 +234,6 @@ def test_registry_emission_order_across_shape_groups(self) -> None: _Expand(value="NET_ADMIN"), "--label", _Expand(value="team=api"), - "--add-host", - _Expand(value="db:10.0.0.5"), "--pull", "always", "--ulimit", @@ -265,7 +245,7 @@ def test_deploy_resource_flags_emitted(self) -> None: "image": "x", "deploy": {"resources": {"limits": {"memory": "256m"}, "reservations": {"memory": "128m"}}}, } - flags = run_flags("app", svc, "p", [], "/b") + flags = run_flags("app", svc, "p", "/b") assert flags[4:8] == ["--memory", _Expand(value="256m"), "--memory-reservation", _Expand(value="128m")] @@ -413,12 +393,13 @@ def test_failure_branch_prints_oom_diagnostics(self, chats_compose: dict) -> Non assert "OOMKilled={{.State.OOMKilled}}" in script[gate:] assert "podman ps -a" in script[gate:] - def test_add_host_on_every_run(self, chats_compose: dict) -> None: + def test_add_host_becomes_pod_level(self, chats_compose: dict) -> None: script = self.make_script(chats_compose) + pod_create = next(line for line in script.splitlines() if line.startswith("podman pod create")) + assert "--add-host keydb-test-server-0:127.0.0.1" in pod_create run_lines = [line for line in script.splitlines() if line.startswith("podman run")] - assert len(run_lines) == _EXPECTED_RUN_LINES for line in run_lines: - assert "--add-host keydb-test-server-0:127.0.0.1" in line + assert "--add-host" not in line def test_wait_healthy_function_uses_healthcheck_run(self, chats_compose: dict) -> None: script = self.make_script(chats_compose) @@ -607,12 +588,15 @@ def test_ulimits_compose_through_emit_script(self) -> None: def test_pod_create_carries_dns_and_sysctl_flags(self) -> None: svc = {"image": "x", "dns": ["1.1.1.1", "8.8.8.8"], "sysctls": {"net.core.somaxconn": 1024}} script = self._single(svc) - assert 'podman pod create --name p --dns "1.1.1.1" --dns "8.8.8.8"' in script + # `app` (the service's own name) is always a self-alias, so it precedes dns/sysctls. + assert 'podman pod create --name p --add-host app:127.0.0.1 --dns "1.1.1.1" --dns "8.8.8.8"' in script assert '--sysctl "net.core.somaxconn=1024"' in script - def test_pod_create_unchanged_without_pod_options(self) -> None: + def test_pod_create_carries_only_self_alias_without_pod_options(self) -> None: + # A service's own name is always a resolvable alias (`graph.hostnames`), so even + # with no dns/sysctls/extra_hosts declared, pod create still carries its add-host. script = self._single({"image": "x"}) - assert "podman pod create --name p\n" in script + assert "podman pod create --name p --add-host app:127.0.0.1\n" in script class TestReferencedVariables: diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 5d47e1e..ea696d6 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -224,7 +224,9 @@ def test_annotations_non_list_or_map_raises(self) -> None: validate({"services": {"app": {"image": "x", "annotations": "k=v"}}}) def test_extra_hosts_accepted(self) -> None: - assert validate({"services": {"app": {"image": "x", "extra_hosts": {"db.local": "10.0.0.5"}}}}) == [] + # extra_hosts is pod-level like dns/sysctls, so it carries the pod-wide warning. + warnings = validate({"services": {"app": {"image": "x", "extra_hosts": {"db.local": "10.0.0.5"}}}}) + assert any("pod-wide" in w for w in warnings) def test_extra_hosts_non_list_or_map_raises(self) -> None: with pytest.raises(UnsupportedComposeError, match=r"'extra_hosts' must be a list or mapping"): diff --git a/tests/test_pod.py b/tests/test_pod.py index b1859b5..9908e37 100644 --- a/tests/test_pod.py +++ b/tests/test_pod.py @@ -53,11 +53,11 @@ def test_false_when_absent(self) -> None: class TestPodCreateFlags: def test_no_options_no_flags(self) -> None: - assert pod_create_flags({"a": {"image": "x"}}, ["a"]) == [] + assert pod_create_flags({"a": {"image": "x"}}, ["a"], []) == [] def test_dns_union_across_closure_dedup_first_seen(self) -> None: services = {"a": {"dns": ["1.1.1.1", "8.8.8.8"]}, "b": {"dns": "8.8.8.8"}} - assert pod_create_flags(services, ["a", "b"]) == [ + assert pod_create_flags(services, ["a", "b"], []) == [ "--dns", _Expand(value="1.1.1.1"), "--dns", @@ -66,7 +66,7 @@ def test_dns_union_across_closure_dedup_first_seen(self) -> None: def test_dns_search_and_opt_flags(self) -> None: services = {"a": {"dns_search": "corp.internal", "dns_opt": ["ndots:2"]}} - assert pod_create_flags(services, ["a"]) == [ + assert pod_create_flags(services, ["a"], []) == [ "--dns-search", _Expand(value="corp.internal"), "--dns-option", @@ -75,7 +75,7 @@ def test_dns_search_and_opt_flags(self) -> None: def test_sysctls_mapping_and_list_merge(self) -> None: services = {"a": {"sysctls": {"net.core.somaxconn": 1024}}, "b": {"sysctls": ["net.ipv4.tcp_syncookies=1"]}} - assert pod_create_flags(services, ["a", "b"]) == [ + assert pod_create_flags(services, ["a", "b"], []) == [ "--sysctl", _Expand(value="net.core.somaxconn=1024"), "--sysctl", @@ -84,13 +84,67 @@ def test_sysctls_mapping_and_list_merge(self) -> None: def test_sysctls_same_key_same_value_merges(self) -> None: services = {"a": {"sysctls": {"net.x": "1"}}, "b": {"sysctls": {"net.x": 1}}} - assert pod_create_flags(services, ["a", "b"]) == ["--sysctl", _Expand(value="net.x=1")] + assert pod_create_flags(services, ["a", "b"], []) == ["--sysctl", _Expand(value="net.x=1")] def test_sysctls_same_key_conflict_refused(self) -> None: services = {"a": {"sysctls": {"net.x": "1"}}, "b": {"sysctls": {"net.x": "2"}}} with pytest.raises(UnsupportedComposeError, match=r"conflicting sysctl 'net.x'"): - pod_create_flags(services, ["a", "b"]) + pod_create_flags(services, ["a", "b"], []) def test_non_closure_service_options_ignored(self) -> None: services = {"a": {"image": "x"}, "extra": {"dns": ["9.9.9.9"]}} - assert pod_create_flags(services, ["a"]) == [] + assert pod_create_flags(services, ["a"], []) == [] + + +class TestAddHostFlags: + def test_alias_only_hosts_produce_add_host(self) -> None: + # Alias entries render as plain tokens (unquoted), matching pre-move `run_flags` behavior. + services = {"a": {"image": "x"}} + assert pod_create_flags(services, ["a"], ["web", "db"]) == [ + "--add-host", + "web:127.0.0.1", + "--add-host", + "db:127.0.0.1", + ] + + def test_extra_hosts_list_form(self) -> None: + services = {"a": {"extra_hosts": ["db:10.0.0.5"]}} + assert pod_create_flags(services, ["a"], []) == ["--add-host", _Expand(value="db:10.0.0.5")] + + def test_extra_hosts_mapping_form(self) -> None: + services = {"a": {"extra_hosts": {"db": "10.0.0.5"}}} + assert pod_create_flags(services, ["a"], []) == ["--add-host", _Expand(value="db:10.0.0.5")] + + def test_extra_hosts_ipv6_value_keeps_colons(self) -> None: + services = {"a": {"extra_hosts": {"myhost": "2001:db8::1"}}} + assert pod_create_flags(services, ["a"], []) == [ + "--add-host", + _Expand(value="myhost:2001:db8::1"), + ] + + def test_alias_and_extra_hosts_on_different_hosts_both_appear(self) -> None: + services = {"a": {"extra_hosts": {"db": "10.0.0.5"}}} + assert pod_create_flags(services, ["a"], ["web"]) == [ + "--add-host", + "web:127.0.0.1", + "--add-host", + _Expand(value="db:10.0.0.5"), + ] + + def test_same_host_same_address_across_sources_dedups_silently(self) -> None: + services = {"a": {"extra_hosts": {"web": "127.0.0.1"}}} + assert pod_create_flags(services, ["a"], ["web"]) == ["--add-host", _Expand(value="web:127.0.0.1")] + + def test_conflicting_extra_hosts_across_services_refused(self) -> None: + services = {"a": {"extra_hosts": {"db": "10.0.0.5"}}, "b": {"extra_hosts": {"db": "10.0.0.6"}}} + with pytest.raises(UnsupportedComposeError, match="conflicting host"): + pod_create_flags(services, ["a", "b"], []) + + def test_conflicting_alias_and_extra_hosts_refused(self) -> None: + services = {"a": {"extra_hosts": {"web": "10.0.0.5"}}} + with pytest.raises(UnsupportedComposeError, match="conflicting host"): + pod_create_flags(services, ["a"], ["web"]) + + def test_non_closure_service_extra_hosts_ignored(self) -> None: + services = {"a": {"image": "x"}, "extra": {"extra_hosts": {"db": "10.0.0.5"}}} + assert pod_create_flags(services, ["a"], []) == []