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
5 changes: 4 additions & 1 deletion docs/adr/0006-docker-rejection-parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ four kinds of claim need four experiments: `REFUSALS` for the mounts podman will
two), and, where the claim is about podman's flag surface rather than a mount, `ABSENT_FLAGS` for a
flag podman does not have and `STUB_FLAGS` for one it has that validates nothing -- a flag accepting
`nonsense` is a worse reason to emit it than a flag that fails, since the script would report
success for something it never did. A gate that every rule-two site has a row is
success for something it never did. `tests/integration/acceptances.py` runs the
mirror image, every flag a long-form mount compiles to, because a rule-two claim fails in both
directions: #104 and #114 each shipped a form the gate accepted and podman would not run. A gate
that every rule-two site has a row is
[#109](https://github.com/modern-python/compose2pod/issues/109) phase 3, and it has to carry the
exemptions first: not every refusal this document names turns out to be one podman makes.
Verdicts are per version, and the supported range is stated rather than implied: the rulings here
Expand Down
110 changes: 110 additions & 0 deletions tests/integration/acceptances.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"""Every flag compose2pod emits for a long-form mount, run once against the floor podman.

`refusals.py` measures one direction: that podman will not make a mount we refuse.
Nothing measured the other, and that is the direction #104 and #114 both failed in --
a document passes the gate, emits a flag, and the script dies at `podman run`. Unit
coverage cannot catch it, because `test_emit.py` asserts the string compose2pod
produces, which is a fact about compose2pod, not about podman.

A row is the inverse of a `Refusal`, and its `expected_argv` is checked against what
`emit` really produces before it is run. That check is deliberately the opposite of
`refusals.py`'s rule, where the argv is hand-written so an emit bug cannot hide behind
a parity claim: here the emitted flag is the thing under test, so the row has to be
pinned to it or it measures a flag nobody ships.

A row goes red when a podman in the supported range drops or renames an option --
the same maintenance trigger the `subpath` refusals carry, pointing the other way.
"""

from dataclasses import dataclass
from typing import Any


@dataclass(frozen=True)
class Acceptance:
"""A form the gate accepts, the flag it compiles to, and podman's verdict on that flag.

`{host}` in `expected_argv` is substituted with the test's project directory, which is
also what `emit` resolves a relative bind source against. `host_dir` is created under it
first, because a bind whose source does not exist fails for that reason instead of
proving anything about the option being measured.
"""

id: str
service: dict[str, Any]
expected_argv: list[str]
host_dir: str = ""


def _bind(nested: dict[str, Any]) -> dict[str, Any]:
return {
"image": "busybox:1.36",
"volumes": [{"type": "bind", "source": "./d", "target": "/data", "bind": nested}],
}


def _tmpfs(nested: dict[str, Any]) -> dict[str, Any]:
return {"image": "busybox:1.36", "volumes": [{"type": "tmpfs", "target": "/data", "tmpfs": nested}]}


def _mount(value: str) -> list[str]:
return ["--mount", value]


ACCEPTANCES: list[Acceptance] = [
Acceptance(
id=f"bind-propagation-{propagation}",
service=_bind({"propagation": propagation}),
expected_argv=_mount(f"type=bind,source={{host}}/d,target=/data,bind-propagation={propagation}"),
host_dir="d",
)
# Every value `_PROPAGATION_VALUES` admits. Only `rprivate` had ever run.
for propagation in ("private", "rprivate", "shared", "rshared", "slave", "rslave")
] + [
Acceptance(
id="bind-relabel-shared",
service=_bind({"selinux": "z"}),
expected_argv=_mount("type=bind,source={host}/d,target=/data,relabel=shared"),
host_dir="d",
),
Acceptance(
id="bind-relabel-private",
service=_bind({"selinux": "Z"}),
expected_argv=_mount("type=bind,source={host}/d,target=/data,relabel=private"),
host_dir="d",
),
Acceptance(
id="tmpfs-size",
service=_tmpfs({"size": "1m"}),
expected_argv=_mount("type=tmpfs,target=/data,tmpfs-size=1m"),
),
Acceptance(
id="tmpfs-mode",
service=_tmpfs({"mode": 1777}),
expected_argv=_mount("type=tmpfs,target=/data,tmpfs-mode=1777"),
),
Acceptance(
id="tmpfs-size-and-mode",
service=_tmpfs({"size": "1m", "mode": 1777}),
expected_argv=_mount("type=tmpfs,target=/data,tmpfs-size=1m,tmpfs-mode=1777"),
),
Acceptance(
id="bind-read-only",
service={
"image": "busybox:1.36",
"volumes": [{"type": "bind", "source": "./d", "target": "/data", "read_only": True}],
},
expected_argv=_mount("type=bind,source={host}/d,target=/data,ro"),
host_dir="d",
),
Acceptance(
id="volume-read-only",
service={"image": "busybox:1.36", "volumes": [{"type": "volume", "target": "/data", "read_only": True}]},
expected_argv=_mount("type=volume,target=/data,ro"),
),
Acceptance(
id="service-tmpfs",
service={"image": "busybox:1.36", "tmpfs": "/data"},
expected_argv=["--tmpfs", "/data"],
),
]
3 changes: 2 additions & 1 deletion tests/integration/refusals.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
can express it*, and refuses where podman cannot. Nothing ran podman to check which
side of that line a refusal sat on, which is how issue #86's unmeasured "podman can
express it" became #104's shipped acceptance of a script that dies at `podman run`.
These tables turn each claim back into a measurement, in both directions:
These tables turn each claim back into a measurement. The mirror image, that a form
the gate *accepts* compiles to a flag podman runs, is `acceptances.py`.

- `REFUSALS` -- podman will not make this mount, so refusing is rule two.
- `LIMITATIONS` -- podman *will* make it and we refuse anyway, so the refusal is the
Expand Down
37 changes: 37 additions & 0 deletions tests/integration/test_podman_acceptances.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""The other half of rule two: a form the gate accepts compiles to a flag podman runs."""

from collections.abc import Callable
from pathlib import Path

import pytest

from compose2pod.emit import Expand, run_flags
from compose2pod.parsing import validate
from tests.integration.acceptances import ACCEPTANCES, Acceptance


def _flag_values(service: dict, project_dir: Path) -> list[str]:
tokens = run_flags("app", service, "pod", str(project_dir))
return [token.value if isinstance(token, Expand) else str(token) for token in tokens]


def _contains(haystack: list[str], needle: list[str]) -> bool:
return any(haystack[i : i + len(needle)] == needle for i in range(len(haystack) - len(needle) + 1))


@pytest.mark.parametrize("acceptance", ACCEPTANCES, ids=lambda acceptance: acceptance.id)
def test_an_accepted_form_compiles_to_a_flag_podman_runs(
acceptance: Acceptance, probe_podman: Callable[[str, list[str]], int], tmp_path: Path
) -> None:
compose = {"services": {"app": acceptance.service}}
validate(compose)

(tmp_path / acceptance.host_dir).mkdir(parents=True, exist_ok=True)
expected = [part.format(host=tmp_path) for part in acceptance.expected_argv]
assert _contains(_flag_values(acceptance.service, tmp_path), expected), (
"emit no longer produces this flag, so podman's verdict on it says nothing about the tool"
)

assert probe_podman(acceptance.id, expected) == 0, (
"podman refused a flag compose2pod emits, so an accepted document compiles to a script that dies"
)
Loading