Skip to content

Commit 73f168b

Browse files
committed
feat: gate that every claim about podman is measured against podman
Rule two rested on statements no test ran. `compose2pod/podman.py` now holds every claim the tool makes about podman's behaviour, and a refusal citing podman draws its clause from there, so the claims can be enumerated rather than grepped for. The gate pairs each (site, claim) in the source with the row that measures it, in both directions: a new podman-citing refusal without a row goes red, and so does a row whose site or claim no longer exists. Closes #109
1 parent e0de766 commit 73f168b

7 files changed

Lines changed: 204 additions & 19 deletions

File tree

‎AGENTS.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ for what it does; read them. What reading a single module will **not** tell you:
2828
- The `SERVICE_KEYS` / `STRUCTURAL_KEYS` split in `keys.py` is a design ruling about which keys can
2929
share one `emit(value)` interface, not a leftover. A new key belongs in the registry only if
3030
it fits that signature without widening it.
31+
- `compose2pod/podman.py` is not a pipeline stage: it holds every claim the tool makes about
32+
podman's behaviour, so a refusal citing podman can be enumerated rather than grepped for.
33+
`tests/test_podman_claim_coverage.py` requires each claim to be measured against real podman.
34+
A new refusal whose reason is podman's belongs there, or the gate goes red.
3135
- `tests/conformance/` generates its probe matrix from
3236
`SERVICE_KEYS | STRUCTURAL_KEYS | IGNORED_SERVICE_KEYS`, so adding a key probes it against
3337
`docker compose config` automatically. It is CI-only (`just test-conformance`,

‎compose2pod/parsing.py‎

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from collections.abc import Callable
55
from typing import Any
66

7-
from compose2pod import stores, values
7+
from compose2pod import podman, stores, values
88
from compose2pod.exceptions import UnsupportedComposeError
99
from compose2pod.graph import depends_on, hostnames
1010
from compose2pod.healthcheck import has_healthcheck, health_cmd, interval_seconds
@@ -200,7 +200,7 @@ def _reject_drive_shaped_volume(name: str, volume: str) -> None:
200200
raise UnsupportedComposeError(msg)
201201
msg = (
202202
f"{preamble}an anonymous volume whose target is the whole string, and "
203-
"podman refuses a container path that is not absolute "
203+
f"{podman.REFUSES_RELATIVE_CONTAINER_PATH} "
204204
"(name a one-character volume through the long form instead)"
205205
)
206206
raise UnsupportedComposeError(msg)
@@ -219,10 +219,6 @@ def _reject_drive_shaped_volume(name: str, volume: str) -> None:
219219
"tmpfs": {"size", "mode"},
220220
"image": {"subpath"},
221221
}
222-
# compose2pod supports podman 4.9 and up (README, docs/adr/0006-docker-rejection-parity.md):
223-
# a form is accepted only when podman expresses it across that whole range, so a key
224-
# that arrives in a later podman is refused until the floor reaches it.
225-
_PODMAN_FLOOR = "4.9"
226222
_PROPAGATION_VALUES = {"private", "rprivate", "shared", "rshared", "slave", "rslave"}
227223
_SELINUX_VALUES = {"z", "Z"}
228224

@@ -310,7 +306,7 @@ def _validate_bind_options(name: str, options: dict[str, Any]) -> None:
310306
folded into the generic unknown-key check the caller already ran.
311307
"""
312308
if "create_host_path" in options:
313-
msg = f"service {name!r}: bind 'create_host_path' is not supported (podman cannot express it)"
309+
msg = f"service {name!r}: bind 'create_host_path' is not supported ({podman.CANNOT_EXPRESS})"
314310
raise UnsupportedComposeError(msg)
315311
if "propagation" in options and options["propagation"] not in _PROPAGATION_VALUES:
316312
msg = f"service {name!r}: bind 'propagation' must be one of {sorted(_PROPAGATION_VALUES)}"
@@ -322,10 +318,7 @@ def _validate_bind_options(name: str, options: dict[str, Any]) -> None:
322318

323319
def _reject_subpath(name: str, vtype: str, added_in: str) -> None:
324320
"""Refuse a nested `subpath`: podman gained the mount option after the supported floor."""
325-
msg = (
326-
f"service {name!r}: {vtype} 'subpath' is not supported "
327-
f"(podman {added_in} adds the mount option, and compose2pod supports podman {_PODMAN_FLOOR} and up)"
328-
)
321+
msg = f"service {name!r}: {vtype} 'subpath' is not supported ({podman.adds_the_mount_option_in(added_in)})"
329322
raise UnsupportedComposeError(msg)
330323

331324

@@ -344,7 +337,7 @@ def _validate_volume_type_options(name: str, options: dict[str, Any]) -> None:
344337
if "nocopy" in options:
345338
msg = (
346339
f"service {name!r}: volume 'nocopy' is not supported here: the long form mounts with "
347-
"--mount, whose grammar has no nocopy (use the short syntax, which emits -v and podman honours)"
340+
f"--mount, whose grammar has no nocopy ({podman.NOCOPY_NEEDS_THE_SHORT_FORM})"
348341
)
349342
raise UnsupportedComposeError(msg)
350343
if "subpath" in options:

‎compose2pod/podman.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""Every claim compose2pod makes about podman, in one place.
2+
3+
A refusal whose reason is podman's behaviour draws its clause from here instead of
4+
spelling one inline. The point is not the wording, which changes freely, but the name:
5+
`tests/test_podman_claim_coverage.py` reads this module's attributes to find the sites
6+
that make such a claim, and requires each one to be measured by a row in
7+
`tests/integration/refusals.py`. Grepping for prose cannot do that -- the message is
8+
assembled from an f-string, a shared preamble or a lookup table, depending on the site.
9+
10+
That gate is what issue #86 was missing: an unmeasured "podman can express it" became
11+
#104's shipped acceptance of a script that dies at `podman run`.
12+
"""
13+
14+
# compose2pod supports podman FLOOR and up (README, docs/adr/0006-docker-rejection-parity.md).
15+
# A form is accepted only where podman expresses it across that whole range.
16+
FLOOR = "4.9"
17+
18+
CANNOT_EXPRESS = "podman cannot express it"
19+
REFUSES_RELATIVE_CONTAINER_PATH = "podman refuses a container path that is not absolute"
20+
NOCOPY_NEEDS_THE_SHORT_FORM = "use the short syntax, which emits -v and podman honours"
21+
NO_RESERVATION_FLAG = "podman run has no reservation flag for it"
22+
GPUS_RESERVES_NOTHING = "podman run's --gpus accepts any value and reserves nothing"
23+
24+
25+
def adds_the_mount_option_in(version: str) -> str:
26+
"""Spell the clause for a mount option podman gained above the floor."""
27+
return f"podman {version} adds the mount option, and compose2pod supports podman {FLOOR} and up"

‎compose2pod/resources.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from collections.abc import Callable
44
from typing import Any
55

6-
from compose2pod import values
6+
from compose2pod import podman, values
77
from compose2pod.exceptions import UnsupportedComposeError
88
from compose2pod.keys import Expand, Token, require_string_keys
99

@@ -53,8 +53,8 @@ def _validate_limits(name: str, svc: dict[str, Any], limits: Any) -> None: # no
5353
# podman run has, and `--gpus` exists but is hidden and accepts `nonsense` as
5454
# readily as `all`, so emitting it would reserve nothing and say it had.
5555
_RESERVATION_REFUSALS = {
56-
"cpus": "podman run has no reservation flag for it",
57-
"devices": "podman run's --gpus accepts any value and reserves nothing",
56+
"cpus": podman.NO_RESERVATION_FLAG,
57+
"devices": podman.GPUS_RESERVES_NOTHING,
5858
}
5959

6060

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

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,15 @@ flag podman does not have and `STUB_FLAGS` for one it has that validates nothing
3232
`nonsense` is a worse reason to emit it than a flag that fails, since the script would report
3333
success for something it never did. `tests/integration/acceptances.py` runs the
3434
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
37-
[#109](https://github.com/modern-python/compose2pod/issues/109) phase 3, and it has to carry the
38-
exemptions first: not every refusal this document names turns out to be one podman makes.
35+
directions: #104 and #114 each shipped a form the gate accepted and podman would not run. What keeps
36+
the two in step is `compose2pod/podman.py`: every refusal whose reason is podman's behaviour draws
37+
its clause from there, and `tests/test_podman_claim_coverage.py` pairs each site that makes a claim
38+
with the row that measures it, in both directions. The handle is the attribute name, not the
39+
wording, because prose is not a registry -- a message is assembled from an f-string, a shared
40+
preamble or a lookup table depending on the site. A refusal that makes no claim is out of scope by
41+
construction, which is how `network_mode` needs no row; a refusal whose reason is podman's but whose
42+
message keeps that to itself is invisible to the gate, and that list is
43+
[#121](https://github.com/modern-python/compose2pod/issues/121).
3944
Verdicts are per version, and the supported range is stated rather than implied: the rulings here
4045
are measured against `docker compose config` v5.1.2 and podman 4.9.3, and compose2pod supports
4146
podman 4.9 and up. Rule two reads across that whole range. A form is accepted only where podman

‎tests/integration/refusals.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@
2828
- `STUB_FLAGS` -- the flag exists and accepts deliberate nonsense, so emitting it would
2929
exit 0 having done nothing, which is worse than refusing.
3030
31+
A row measuring a refusal whose message draws a clause from `compose2pod/podman.py` names
32+
the `site` that makes the claim and the `claim` it makes, which is what
33+
`tests/test_podman_claim_coverage.py` gates on. A row whose refusal makes no such claim --
34+
a relative target, an unsupported long-form `type` -- leaves both empty: the reason is
35+
podman's, but the message keeps it to itself, which is issue #121's subject.
36+
3137
Four claims, four experiments. `network_mode` alone has no row: it is refused under
3238
ADR-0003, not rule two, and podman honours it (#115). The gate that every rule-two site
3339
has a row is issue #109 phase 3, and that is the exemption it has to know about.
@@ -54,6 +60,8 @@ class Refusal:
5460
refusal_match: str
5561
podman_argv: list[str]
5662
control_argv: list[str]
63+
site: str = ""
64+
claim: str = ""
5765

5866

5967
@dataclass(frozen=True)
@@ -71,6 +79,8 @@ class Limitation:
7179
refusal_match: str
7280
podman_argv: list[str]
7381
host_dir: str = ""
82+
site: str = ""
83+
claim: str = ""
7484

7585

7686
@dataclass(frozen=True)
@@ -85,6 +95,8 @@ class AbsentFlag:
8595
compose: dict[str, Any]
8696
refusal_match: str
8797
unknown_argv: list[list[str]]
98+
site: str = ""
99+
claim: str = ""
88100

89101

90102
@dataclass(frozen=True)
@@ -99,6 +111,8 @@ class StubFlag:
99111
compose: dict[str, Any]
100112
refusal_match: str
101113
nonsense_argv: list[str]
114+
site: str = ""
115+
claim: str = ""
102116

103117

104118
def _one_volume(entry: "str | dict[str, Any]") -> dict[str, Any]:
@@ -126,20 +140,26 @@ def _one_volume(entry: "str | dict[str, Any]") -> dict[str, Any]:
126140
),
127141
Refusal(
128142
id="drive-shaped-source-no-target",
143+
site="parsing._reject_drive_shaped_volume",
144+
claim="REFUSES_RELATIVE_CONTAINER_PATH",
129145
compose=_one_volume("C:\\data"),
130146
refusal_match=_NOT_ABSOLUTE,
131147
podman_argv=["--mount", "type=volume,dst=C:\\data"],
132148
control_argv=_ANONYMOUS_CONTROL,
133149
),
134150
Refusal(
135151
id="single-letter-source-with-path",
152+
site="parsing._reject_drive_shaped_volume",
153+
claim="REFUSES_RELATIVE_CONTAINER_PATH",
136154
compose=_one_volume("v:/data"),
137155
refusal_match=_NOT_ABSOLUTE,
138156
podman_argv=["--mount", "type=volume,dst=v:/data"],
139157
control_argv=_ANONYMOUS_CONTROL,
140158
),
141159
Refusal(
142160
id="single-letter-source-empty-target",
161+
site="parsing._reject_drive_shaped_volume",
162+
claim="REFUSES_RELATIVE_CONTAINER_PATH",
143163
compose=_one_volume("v:"),
144164
refusal_match=_NOT_ABSOLUTE,
145165
podman_argv=["--mount", "type=volume,dst=v:"],
@@ -169,6 +189,8 @@ def _one_volume(entry: "str | dict[str, Any]") -> dict[str, Any]:
169189
Refusal(
170190
# Measured: podman creates a missing bind source in no spelling, `-v` or `--mount`.
171191
id="bind-create-host-path",
192+
site="parsing._validate_bind_options",
193+
claim="CANNOT_EXPRESS",
172194
compose=_one_volume({"type": "bind", "source": "./src", "target": "/var", "bind": {"create_host_path": True}}),
173195
refusal_match="bind 'create_host_path' is not supported",
174196
podman_argv=["--mount", "type=bind,src={host}/absent,dst=/var"],
@@ -177,6 +199,8 @@ def _one_volume(entry: "str | dict[str, Any]") -> dict[str, Any]:
177199
Refusal(
178200
# `source=`/`target=` rather than the `src=`/`dst=` above: the spelling #114 measured.
179201
id="image-subpath",
202+
site="parsing._reject_subpath",
203+
claim="adds_the_mount_option_in",
180204
compose=_one_volume(
181205
{"type": "image", "source": "busybox:1.36", "target": "/mnt", "image": {"subpath": "/bin"}}
182206
),
@@ -186,6 +210,8 @@ def _one_volume(entry: "str | dict[str, Any]") -> dict[str, Any]:
186210
),
187211
Refusal(
188212
id="volume-subpath",
213+
site="parsing._reject_subpath",
214+
claim="adds_the_mount_option_in",
189215
compose=_one_volume({"type": "volume", "target": "/mnt", "volume": {"subpath": "/sub"}}),
190216
refusal_match="volume 'subpath' is not supported",
191217
podman_argv=["--mount", "type=volume,target=/mnt,subpath=/sub"],
@@ -203,6 +229,8 @@ def _reservation(field: str, value: object) -> dict[str, Any]:
203229
# Measured: `-v vol:/etc:nocopy` leaves only podman's own hosts/hostname/resolv.conf
204230
# in the volume, so the image copy-up really is suppressed.
205231
id="volume-nocopy",
232+
site="parsing._validate_volume_type_options",
233+
claim="NOCOPY_NEEDS_THE_SHORT_FORM",
206234
compose={
207235
"services": {
208236
"app": {
@@ -235,6 +263,8 @@ def _reservation(field: str, value: object) -> dict[str, Any]:
235263
ABSENT_FLAGS: list[AbsentFlag] = [
236264
AbsentFlag(
237265
id="reservations-cpus",
266+
site="resources._RESERVATION_REFUSALS",
267+
claim="NO_RESERVATION_FLAG",
238268
compose=_reservation("cpus", "0.5"),
239269
refusal_match="podman run has no reservation flag for it",
240270
unknown_argv=[["--cpu-reservation", "1"], ["--cpus-reservation", "1"]],
@@ -245,6 +275,8 @@ def _reservation(field: str, value: object) -> dict[str, Any]:
245275
STUB_FLAGS: list[StubFlag] = [
246276
StubFlag(
247277
id="reservations-devices",
278+
site="resources._RESERVATION_REFUSALS",
279+
claim="GPUS_RESERVES_NOTHING",
248280
compose=_reservation("devices", [{"capabilities": ["gpu"]}]),
249281
refusal_match="--gpus accepts any value and reserves nothing",
250282
nonsense_argv=["--gpus", "nonsense"],
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import ast
2+
import pathlib
3+
import typing
4+
5+
from compose2pod import podman
6+
from tests.integration.refusals import ABSENT_FLAGS, LIMITATIONS, REFUSALS, STUB_FLAGS
7+
8+
9+
_PACKAGE: typing.Final = pathlib.Path(__file__).resolve().parent.parent / "compose2pod"
10+
_CLAIMS_MODULE: typing.Final = "podman"
11+
12+
13+
def _site_of(statement: ast.stmt) -> str:
14+
if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
15+
return statement.name
16+
targets = getattr(statement, "targets", [])
17+
return next((target.id for target in targets if isinstance(target, ast.Name)), "")
18+
19+
20+
def _claims_in(statement: ast.stmt) -> set[str]:
21+
return {
22+
node.attr
23+
for node in ast.walk(statement)
24+
if isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name) and node.value.id == _CLAIMS_MODULE
25+
}
26+
27+
28+
def claim_sites(package: pathlib.Path) -> set[tuple[str, str]]:
29+
"""Every (site, claim) pair in a package: where it draws a clause from the claims module.
30+
31+
A site is the top-level function, class or assignment that holds the reference, named
32+
`<module>.<site>` -- the granularity a measurement is written at. The scan reads
33+
attribute access rather than message text because a message is assembled from an
34+
f-string, a shared preamble or a lookup table depending on the site, and prose is not
35+
a registry.
36+
"""
37+
found: set[tuple[str, str]] = set()
38+
for file in sorted(package.glob("*.py")):
39+
if file.stem == _CLAIMS_MODULE:
40+
continue
41+
for statement in ast.parse(file.read_text(encoding="utf-8")).body:
42+
site = _site_of(statement)
43+
found |= {(f"{file.stem}.{site}", claim) for claim in _claims_in(statement) if site}
44+
return found
45+
46+
47+
def _measured_pairs() -> set[tuple[str, str]]:
48+
return {
49+
(row.site, row.claim)
50+
for table in (REFUSALS, LIMITATIONS, ABSENT_FLAGS, STUB_FLAGS)
51+
for row in table
52+
if row.site
53+
}
54+
55+
56+
def test_every_claim_compose2pod_makes_about_podman_is_measured_by_a_row() -> None:
57+
"""INVARIANT: a refusal whose reason is podman's behaviour is measured against real podman.
58+
59+
Broken by adding a refusal that draws a clause from `compose2pod/podman.py` without a row
60+
in `tests/integration/refusals.py`. That is issue #86's mistake mechanised: an asserted
61+
"podman cannot express it" became #104's shipped acceptance of a script that dies at
62+
`podman run`, and #114 repeated it from the other side.
63+
64+
Out of scope by construction: a refusal that makes no claim here. `network_mode` is
65+
refused under docs/adr/0003-the-shared-namespace-decides-key-classification.md and podman
66+
honours it, so it has no claim and needs no row. A refusal whose reason *is* podman's but
67+
whose message keeps that to itself is invisible to this gate, which is issue #121.
68+
"""
69+
unmeasured = sorted(claim_sites(_PACKAGE) - _measured_pairs())
70+
71+
assert unmeasured == [], "\n".join(
72+
f"{site} claims podman.{claim} and no row measures it" for site, claim in unmeasured
73+
)
74+
75+
76+
def test_every_row_naming_a_claim_names_one_the_source_still_makes() -> None:
77+
"""INVARIANT: a row measures a claim that exists, at the site that makes it.
78+
79+
Broken by renaming a claim, moving a refusal to another function, or dropping the refusal
80+
and leaving the row behind. A row that measures nothing passes forever.
81+
"""
82+
stale = sorted(_measured_pairs() - claim_sites(_PACKAGE))
83+
84+
assert stale == [], "\n".join(f"row claims {site} uses podman.{claim}; it does not" for site, claim in stale)
85+
86+
87+
def test_every_claim_a_row_names_is_an_attribute_of_the_claims_module() -> None:
88+
"""A typo in a row's `claim` would otherwise pair with a typo in the scan and cancel out."""
89+
missing = sorted({claim for _, claim in _measured_pairs() if not hasattr(podman, claim)})
90+
91+
assert missing == []
92+
93+
94+
def test_a_claim_made_with_no_row_is_reported_with_its_site(tmp_path: pathlib.Path) -> None:
95+
"""The scanner is exercised against a known result, so an empty scan cannot pass as a green one."""
96+
(tmp_path / "parsing.py").write_text(
97+
"from compose2pod import podman\n\n\ndef _refuse():\n raise ValueError(podman.CANNOT_EXPRESS)\n",
98+
encoding="utf-8",
99+
)
100+
101+
assert claim_sites(tmp_path) == {("parsing._refuse", "CANNOT_EXPRESS")}
102+
103+
104+
def test_a_claim_held_in_a_module_level_table_is_found(tmp_path: pathlib.Path) -> None:
105+
"""`resources.py` keeps its two reservation clauses in a dict, outside any function."""
106+
(tmp_path / "resources.py").write_text(
107+
"from compose2pod import podman\n\n_REFUSALS = {'cpus': podman.NO_RESERVATION_FLAG}\n",
108+
encoding="utf-8",
109+
)
110+
111+
assert claim_sites(tmp_path) == {("resources._REFUSALS", "NO_RESERVATION_FLAG")}
112+
113+
114+
def test_the_claims_module_is_not_scanned_against_itself(tmp_path: pathlib.Path) -> None:
115+
"""Its own helper spells a clause from `FLOOR`, which is a definition, not a claim made."""
116+
(tmp_path / "podman.py").write_text("FLOOR = '4.9'\n\n\ndef clause():\n return podman.FLOOR\n", encoding="utf-8")
117+
118+
assert claim_sites(tmp_path) == set()
119+
120+
121+
def test_a_module_naming_nothing_from_the_claims_module_contributes_no_pairs(tmp_path: pathlib.Path) -> None:
122+
(tmp_path / "graph.py").write_text("def walk():\n return 1\n", encoding="utf-8")
123+
124+
assert claim_sites(tmp_path) == set()

0 commit comments

Comments
 (0)