Skip to content

Commit e0de766

Browse files
authored
test: measure that an accepted mount compiles to a flag podman runs (#124)
`refusals.py` only ever measured that podman refuses what we refuse. The direction #104 and #114 actually failed in -- the gate accepts, emit renders, `podman run` dies -- had no oracle at all. Of the nested options that reach a `--mount`, exactly one (bind propagation, one value of six) was ever executed. Each row pins its flag to what `emit` produces before running it, so a row cannot drift into measuring a flag the tool does not ship. Closes #118
1 parent 6e1fc79 commit e0de766

4 files changed

Lines changed: 153 additions & 2 deletions

File tree

‎docs/adr/0006-docker-rejection-parity.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,10 @@ four kinds of claim need four experiments: `REFUSALS` for the mounts podman will
3030
two), and, where the claim is about podman's flag surface rather than a mount, `ABSENT_FLAGS` for a
3131
flag podman does not have and `STUB_FLAGS` for one it has that validates nothing -- a flag accepting
3232
`nonsense` is a worse reason to emit it than a flag that fails, since the script would report
33-
success for something it never did. A gate that every rule-two site has a row is
33+
success for something it never did. `tests/integration/acceptances.py` runs the
34+
mirror image, every flag a long-form mount compiles to, because a rule-two claim fails in both
35+
directions: #104 and #114 each shipped a form the gate accepted and podman would not run. A gate
36+
that every rule-two site has a row is
3437
[#109](https://github.com/modern-python/compose2pod/issues/109) phase 3, and it has to carry the
3538
exemptions first: not every refusal this document names turns out to be one podman makes.
3639
Verdicts are per version, and the supported range is stated rather than implied: the rulings here

‎tests/integration/acceptances.py‎

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"""Every flag compose2pod emits for a long-form mount, run once against the floor podman.
2+
3+
`refusals.py` measures one direction: that podman will not make a mount we refuse.
4+
Nothing measured the other, and that is the direction #104 and #114 both failed in --
5+
a document passes the gate, emits a flag, and the script dies at `podman run`. Unit
6+
coverage cannot catch it, because `test_emit.py` asserts the string compose2pod
7+
produces, which is a fact about compose2pod, not about podman.
8+
9+
A row is the inverse of a `Refusal`, and its `expected_argv` is checked against what
10+
`emit` really produces before it is run. That check is deliberately the opposite of
11+
`refusals.py`'s rule, where the argv is hand-written so an emit bug cannot hide behind
12+
a parity claim: here the emitted flag is the thing under test, so the row has to be
13+
pinned to it or it measures a flag nobody ships.
14+
15+
A row goes red when a podman in the supported range drops or renames an option --
16+
the same maintenance trigger the `subpath` refusals carry, pointing the other way.
17+
"""
18+
19+
from dataclasses import dataclass
20+
from typing import Any
21+
22+
23+
@dataclass(frozen=True)
24+
class Acceptance:
25+
"""A form the gate accepts, the flag it compiles to, and podman's verdict on that flag.
26+
27+
`{host}` in `expected_argv` is substituted with the test's project directory, which is
28+
also what `emit` resolves a relative bind source against. `host_dir` is created under it
29+
first, because a bind whose source does not exist fails for that reason instead of
30+
proving anything about the option being measured.
31+
"""
32+
33+
id: str
34+
service: dict[str, Any]
35+
expected_argv: list[str]
36+
host_dir: str = ""
37+
38+
39+
def _bind(nested: dict[str, Any]) -> dict[str, Any]:
40+
return {
41+
"image": "busybox:1.36",
42+
"volumes": [{"type": "bind", "source": "./d", "target": "/data", "bind": nested}],
43+
}
44+
45+
46+
def _tmpfs(nested: dict[str, Any]) -> dict[str, Any]:
47+
return {"image": "busybox:1.36", "volumes": [{"type": "tmpfs", "target": "/data", "tmpfs": nested}]}
48+
49+
50+
def _mount(value: str) -> list[str]:
51+
return ["--mount", value]
52+
53+
54+
ACCEPTANCES: list[Acceptance] = [
55+
Acceptance(
56+
id=f"bind-propagation-{propagation}",
57+
service=_bind({"propagation": propagation}),
58+
expected_argv=_mount(f"type=bind,source={{host}}/d,target=/data,bind-propagation={propagation}"),
59+
host_dir="d",
60+
)
61+
# Every value `_PROPAGATION_VALUES` admits. Only `rprivate` had ever run.
62+
for propagation in ("private", "rprivate", "shared", "rshared", "slave", "rslave")
63+
] + [
64+
Acceptance(
65+
id="bind-relabel-shared",
66+
service=_bind({"selinux": "z"}),
67+
expected_argv=_mount("type=bind,source={host}/d,target=/data,relabel=shared"),
68+
host_dir="d",
69+
),
70+
Acceptance(
71+
id="bind-relabel-private",
72+
service=_bind({"selinux": "Z"}),
73+
expected_argv=_mount("type=bind,source={host}/d,target=/data,relabel=private"),
74+
host_dir="d",
75+
),
76+
Acceptance(
77+
id="tmpfs-size",
78+
service=_tmpfs({"size": "1m"}),
79+
expected_argv=_mount("type=tmpfs,target=/data,tmpfs-size=1m"),
80+
),
81+
Acceptance(
82+
id="tmpfs-mode",
83+
service=_tmpfs({"mode": 1777}),
84+
expected_argv=_mount("type=tmpfs,target=/data,tmpfs-mode=1777"),
85+
),
86+
Acceptance(
87+
id="tmpfs-size-and-mode",
88+
service=_tmpfs({"size": "1m", "mode": 1777}),
89+
expected_argv=_mount("type=tmpfs,target=/data,tmpfs-size=1m,tmpfs-mode=1777"),
90+
),
91+
Acceptance(
92+
id="bind-read-only",
93+
service={
94+
"image": "busybox:1.36",
95+
"volumes": [{"type": "bind", "source": "./d", "target": "/data", "read_only": True}],
96+
},
97+
expected_argv=_mount("type=bind,source={host}/d,target=/data,ro"),
98+
host_dir="d",
99+
),
100+
Acceptance(
101+
id="volume-read-only",
102+
service={"image": "busybox:1.36", "volumes": [{"type": "volume", "target": "/data", "read_only": True}]},
103+
expected_argv=_mount("type=volume,target=/data,ro"),
104+
),
105+
Acceptance(
106+
id="service-tmpfs",
107+
service={"image": "busybox:1.36", "tmpfs": "/data"},
108+
expected_argv=["--tmpfs", "/data"],
109+
),
110+
]

‎tests/integration/refusals.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
can express it*, and refuses where podman cannot. Nothing ran podman to check which
55
side of that line a refusal sat on, which is how issue #86's unmeasured "podman can
66
express it" became #104's shipped acceptance of a script that dies at `podman run`.
7-
These tables turn each claim back into a measurement, in both directions:
7+
These tables turn each claim back into a measurement. The mirror image, that a form
8+
the gate *accepts* compiles to a flag podman runs, is `acceptances.py`.
89
910
- `REFUSALS` -- podman will not make this mount, so refusing is rule two.
1011
- `LIMITATIONS` -- podman *will* make it and we refuse anyway, so the refusal is the
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""The other half of rule two: a form the gate accepts compiles to a flag podman runs."""
2+
3+
from collections.abc import Callable
4+
from pathlib import Path
5+
6+
import pytest
7+
8+
from compose2pod.emit import Expand, run_flags
9+
from compose2pod.parsing import validate
10+
from tests.integration.acceptances import ACCEPTANCES, Acceptance
11+
12+
13+
def _flag_values(service: dict, project_dir: Path) -> list[str]:
14+
tokens = run_flags("app", service, "pod", str(project_dir))
15+
return [token.value if isinstance(token, Expand) else str(token) for token in tokens]
16+
17+
18+
def _contains(haystack: list[str], needle: list[str]) -> bool:
19+
return any(haystack[i : i + len(needle)] == needle for i in range(len(haystack) - len(needle) + 1))
20+
21+
22+
@pytest.mark.parametrize("acceptance", ACCEPTANCES, ids=lambda acceptance: acceptance.id)
23+
def test_an_accepted_form_compiles_to_a_flag_podman_runs(
24+
acceptance: Acceptance, probe_podman: Callable[[str, list[str]], int], tmp_path: Path
25+
) -> None:
26+
compose = {"services": {"app": acceptance.service}}
27+
validate(compose)
28+
29+
(tmp_path / acceptance.host_dir).mkdir(parents=True, exist_ok=True)
30+
expected = [part.format(host=tmp_path) for part in acceptance.expected_argv]
31+
assert _contains(_flag_values(acceptance.service, tmp_path), expected), (
32+
"emit no longer produces this flag, so podman's verdict on it says nothing about the tool"
33+
)
34+
35+
assert probe_podman(acceptance.id, expected) == 0, (
36+
"podman refused a flag compose2pod emits, so an accepted document compiles to a script that dies"
37+
)

0 commit comments

Comments
 (0)