Skip to content

Commit 80cebdc

Browse files
authored
feat: accept long-form volume nested option maps (#73)
Accepts the long-form volume nested option maps (bind/volume/tmpfs) for the podman-expressible options: bind {propagation, selinux->relabel}, volume {subpath}, tmpfs {size, mode}, emitted as --mount options. propagation narrowed to podman's enum; tmpfs.size/mode non-negative (mode a non-negative integer -- a float fails podman's crun). Refused (rule-two): bind.create_host_path, volume.nocopy. A sub-map not matching type is refused (stricter than docker). Design: planning/changes/2026-07-17.02-volumes-nested-options.md
1 parent 5ed4090 commit 80cebdc

10 files changed

Lines changed: 424 additions & 28 deletions

File tree

architecture/supported-subset.md

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -762,10 +762,22 @@ limitations — podman's `--mount` rejects them (`invalid filesystem type`) and
762762
can never express them. `image` is refused too, but for a different reason:
763763
docker accepts it and podman *can* express it (`--mount type=image,...`
764764
succeeds) — scope A's parser simply does not parse it yet, so it is a
765-
deferred parser gap (`planning/deferred.md`), not a rule-two refusal. The
766-
nested `bind:`/`volume:`/`tmpfs:` option maps (`propagation`, `subpath`,
767-
`tmpfs.size`/`tmpfs.mode`, etc.) fall out as unsupported keys and raise for
768-
the same deferred-parser reason; `nocopy` is podman-inexpressible regardless.
765+
deferred parser gap (`planning/deferred.md`), not a rule-two refusal. A
766+
long-form entry may also carry the nested option sub-map matching its own
767+
`type``bind: {propagation, selinux}`, `volume: {subpath}`, `tmpfs: {size,
768+
mode}` — each accepted and appended to the emitted `--mount` value:
769+
`propagation` (narrowed to podman's enum `private`/`rprivate`/`shared`/
770+
`rshared`/`slave`/`rslave`) becomes `bind-propagation=<value>`; `selinux`
771+
(`z`/`Z`) becomes `relabel=shared`/`relabel=private`; `subpath` becomes
772+
`subpath=<value>`; `tmpfs.size`/`tmpfs.mode` become `tmpfs-size=<value>`/
773+
`tmpfs-mode=<value>`. Two options stay refused as permanent rule-two
774+
limitations because podman's `--mount` cannot express them at all:
775+
`bind.create_host_path` and `volume.nocopy`. A sub-map that does not match
776+
the entry's own `type` (a `bind:` map on a `type: volume` entry, for
777+
example) is refused too — docker accepts and silently ignores a mismatched
778+
sub-map, but compose2pod treats it as a likely mistake and refuses it, a
779+
deliberate stricter-than-docker check.
780+
769781
A `target` must be an absolute path (a `${VAR}` reference is accepted, being
770782
host-dependent) — podman's `--mount` rejects a relative target for every
771783
type (`invalid container path "rel", must be an absolute path`) even though

compose2pod/emit.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,22 @@ def _env_file_flags(svc: dict[str, Any], project_dir: str) -> list[Token]:
9494
return flags
9595

9696

97+
def _nested_mount_parts(vtype: str, options: dict[str, Any]) -> list[str]:
98+
"""Map a long-form volume entry's validated `bind`/`volume`/`tmpfs` sub-map to `--mount` options."""
99+
if vtype == "bind":
100+
parts = [f"bind-propagation={options['propagation']}"] if "propagation" in options else []
101+
if "selinux" in options:
102+
parts.append(f"relabel={'shared' if options['selinux'] == 'z' else 'private'}")
103+
return parts
104+
if vtype == "volume":
105+
return [f"subpath={options['subpath']}"] if "subpath" in options else []
106+
# vtype == "tmpfs": the gate validates `type` to one of bind/volume/tmpfs, so no other branch is reachable.
107+
parts = [f"tmpfs-size={options['size']}"] if "size" in options else []
108+
if "mode" in options:
109+
parts.append(f"tmpfs-mode={options['mode']}")
110+
return parts
111+
112+
97113
def _mount_flag(entry: dict[str, Any], project_dir: str) -> list[Token]:
98114
"""Render one long-form volume mapping as a `--mount` value."""
99115
parts = [f"type={entry['type']}"]
@@ -105,6 +121,8 @@ def _mount_flag(entry: dict[str, Any], project_dir: str) -> list[Token]:
105121
parts.append(f"target={entry['target']}")
106122
if as_bool(entry.get("read_only", False)):
107123
parts.append("ro")
124+
vtype = entry["type"]
125+
parts += _nested_mount_parts(vtype, entry.get(vtype, {}))
108126
return ["--mount", Expand(value=",".join(parts))]
109127

110128

compose2pod/parsing.py

Lines changed: 104 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,18 @@ def _classify_volume(volume: str) -> tuple[str, str | None]:
162162

163163
_VOLUME_LONG_TYPES = ("bind", "volume", "tmpfs")
164164
_VOLUME_LONG_KEYS = {"type", "source", "target", "read_only", "consistency"}
165+
# Docker's own per-type nested option map keys (measured, docker compose config
166+
# v5.1.2). `create_host_path`/`nocopy` are real docker keys, so they land here
167+
# (not treated as unknown) -- rule-two refuses them separately, in
168+
# `_validate_volume_options`, with a "not supported" message rather than an
169+
# "unknown key" one.
170+
_VOLUME_OPTION_KEYS = {
171+
"bind": {"propagation", "selinux", "create_host_path"},
172+
"volume": {"subpath", "nocopy"},
173+
"tmpfs": {"size", "mode"},
174+
}
175+
_PROPAGATION_VALUES = {"private", "rprivate", "shared", "rshared", "slave", "rslave"}
176+
_SELINUX_VALUES = {"z", "Z"}
165177

166178

167179
def _validate_service_volumes(name: str, svc: dict[str, Any]) -> None:
@@ -193,20 +205,24 @@ def _validate_service_volumes(name: str, svc: dict[str, Any]) -> None:
193205
def _validate_volume_long_form(name: str, entry: dict[str, Any]) -> None:
194206
"""Check one long-syntax volume mapping against Docker's strict schema (measured, v5.1.2).
195207
196-
Scope A: type (bind/volume/tmpfs), source, target, read_only, consistency.
197-
The nested bind/volume/tmpfs option maps fall out as unknown keys (refused,
198-
tracked in planning/deferred.md); cluster/npipe/image types are refused
199-
(podman cannot express them).
208+
type (bind/volume/tmpfs), source, target, read_only, consistency, plus the
209+
one nested option map matching `type` (a mismatched sub-map is refused --
210+
a deliberate stricter-than-docker check; docker accepts-and-ignores it).
211+
cluster/npipe/image types are refused (podman cannot express them).
212+
213+
`type` is validated before the unknown-key check (unlike every other field
214+
here) because the check itself needs `vtype` to know which sub-map key --
215+
`bind`/`volume`/`tmpfs` -- the entry is allowed to carry alongside it.
200216
"""
201217
require_string_keys(f"service {name!r}: volume", entry)
202-
unknown = set(entry) - _VOLUME_LONG_KEYS
203-
if unknown:
204-
msg = f"service {name!r}: volume: unsupported keys {sorted(unknown)}"
205-
raise UnsupportedComposeError(msg)
206218
vtype = entry.get("type")
207219
if vtype not in _VOLUME_LONG_TYPES:
208220
msg = f"service {name!r}: volume 'type' must be one of {list(_VOLUME_LONG_TYPES)}"
209221
raise UnsupportedComposeError(msg)
222+
unknown = set(entry) - _VOLUME_LONG_KEYS - {vtype}
223+
if unknown:
224+
msg = f"service {name!r}: volume: unsupported keys {sorted(unknown)}"
225+
raise UnsupportedComposeError(msg)
210226
target = entry.get("target")
211227
if not isinstance(target, str):
212228
msg = f"service {name!r}: volume 'target' must be a string"
@@ -225,6 +241,86 @@ def _validate_volume_long_form(name: str, entry: dict[str, Any]) -> None:
225241
if "consistency" in entry and not isinstance(entry["consistency"], str):
226242
msg = f"service {name!r}: volume 'consistency' must be a string"
227243
raise UnsupportedComposeError(msg)
244+
if vtype in entry:
245+
_validate_volume_options(name, vtype, entry[vtype])
246+
247+
248+
def _validate_bind_options(name: str, options: dict[str, Any]) -> None:
249+
"""Check a long-form volume entry's `bind:` sub-map (measured, v5.1.2).
250+
251+
`create_host_path` is a real Docker key, but podman's `--mount` cannot
252+
express it, so it is refused with a "not supported" message rather than
253+
folded into the generic unknown-key check the caller already ran.
254+
"""
255+
if "create_host_path" in options:
256+
msg = f"service {name!r}: bind 'create_host_path' is not supported (podman cannot express it)"
257+
raise UnsupportedComposeError(msg)
258+
if "propagation" in options and options["propagation"] not in _PROPAGATION_VALUES:
259+
msg = f"service {name!r}: bind 'propagation' must be one of {sorted(_PROPAGATION_VALUES)}"
260+
raise UnsupportedComposeError(msg)
261+
if "selinux" in options and options["selinux"] not in _SELINUX_VALUES:
262+
msg = f"service {name!r}: bind 'selinux' must be 'z' or 'Z'"
263+
raise UnsupportedComposeError(msg)
264+
265+
266+
def _validate_volume_type_options(name: str, options: dict[str, Any]) -> None:
267+
"""Check a long-form volume entry's `volume:` sub-map (measured, v5.1.2).
268+
269+
`nocopy` is a real Docker key, but podman's `--mount` cannot express it,
270+
so it is refused with a "not supported" message rather than folded into
271+
the generic unknown-key check the caller already ran.
272+
"""
273+
if "nocopy" in options:
274+
msg = f"service {name!r}: volume 'nocopy' is not supported (podman cannot express it)"
275+
raise UnsupportedComposeError(msg)
276+
if "subpath" in options and not isinstance(options["subpath"], str):
277+
msg = f"service {name!r}: volume 'subpath' must be a string"
278+
raise UnsupportedComposeError(msg)
279+
280+
281+
def _validate_tmpfs_options(name: str, options: dict[str, Any]) -> None:
282+
"""Check a long-form volume entry's `tmpfs:` sub-map (measured, v5.1.2).
283+
284+
`size` and `mode` are both unsigned in Docker's own decoder: a negative
285+
native number is refused ("size"/"cannot parse as uint32: -1 overflows"),
286+
measured against `docker compose config` v5.1.2. `mode` additionally
287+
tightens `validate_native_number`'s float acceptance down to integers
288+
only -- Docker itself accepts a float `mode` (it round-trips it verbatim),
289+
but podman 6.0.1's `crun` fails to mount it at run time ("crun: mount
290+
tmpfs: Invalid argument"), so accepting one here would be a real green in
291+
`docker compose config` that is a false green for the generated script.
292+
"""
293+
if "size" in options:
294+
size = options["size"]
295+
values.validate_size(name, "tmpfs size", size, allow_fractional=False)
296+
if isinstance(size, (int, float)) and not isinstance(size, bool) and size < 0:
297+
msg = f"service {name!r}: tmpfs size must be non-negative"
298+
raise UnsupportedComposeError(msg)
299+
if "mode" in options:
300+
mode = options["mode"]
301+
if isinstance(mode, bool) or not isinstance(mode, int) or mode < 0:
302+
msg = f"service {name!r}: tmpfs mode must be a non-negative integer"
303+
raise UnsupportedComposeError(msg)
304+
305+
306+
_VOLUME_OPTION_VALIDATORS: dict[str, Callable[[str, dict[str, Any]], None]] = {
307+
"bind": _validate_bind_options,
308+
"volume": _validate_volume_type_options,
309+
"tmpfs": _validate_tmpfs_options,
310+
}
311+
312+
313+
def _validate_volume_options(name: str, vtype: str, options: Any) -> None: # noqa: ANN401 - Compose values are untyped YAML/JSON
314+
"""Check a long-form volume entry's nested option map (the one matching `type`), measured v5.1.2."""
315+
if not isinstance(options, dict):
316+
msg = f"service {name!r}: {vtype} options must be a mapping"
317+
raise UnsupportedComposeError(msg)
318+
require_string_keys(f"service {name!r}: {vtype} options", options)
319+
unknown = set(options) - _VOLUME_OPTION_KEYS[vtype]
320+
if unknown:
321+
msg = f"service {name!r}: {vtype} options: unsupported keys {sorted(unknown)}"
322+
raise UnsupportedComposeError(msg)
323+
_VOLUME_OPTION_VALIDATORS[vtype](name, options)
228324

229325

230326
def _validate_volume_long_form_source(name: str, vtype: str, source: Any) -> None: # noqa: ANN401 - Compose values are untyped YAML/JSON
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
---
2+
summary: Accept the long-form volume nested option maps (`bind:`/`volume:`/`tmpfs:`) for the options podman `--mount` can express — `propagation`, `selinux` (translated z/Z → `relabel=shared`/`private`), `subpath`, `tmpfs.size`, `tmpfs.mode` — matching `docker compose config` v5.1.2; `volume.nocopy` and `bind.create_host_path` stay refused (rule-two, podman cannot express them), and a sub-map that does not match the entry's `type` is refused (a deliberate stricter-than-Docker check catching a likely mistake).
3+
---
4+
5+
# Design: long-form volume nested option maps
6+
7+
## Summary
8+
9+
The scope-A long-form volumes work (`2026-07-16.05`) refused the nested
10+
`bind:`/`volume:`/`tmpfs:` option maps as unknown keys. This accepts them for the
11+
options podman `--mount` can express, appending the mapped options to the
12+
`--mount` value. Options podman cannot express (`volume.nocopy`,
13+
`bind.create_host_path`) stay refused (rule-two). A sub-map keyed for a `type`
14+
other than the entry's own is refused.
15+
16+
## Motivation
17+
18+
The nested option maps are the remaining long-form `volumes` gap
19+
(`planning/deferred.md`). Measured against `docker compose config` v5.1.2 and
20+
podman 6.0.1.
21+
22+
## Design
23+
24+
### Grammar (measured)
25+
26+
For a long-form entry `{type, source, target, read_only, consistency}`, add one
27+
optional sub-map — the one matching `type`:
28+
29+
- **`bind:`** (only for `type: bind`)
30+
- `propagation` — enum `private`/`rprivate`/`shared`/`rshared`/`slave`/`rslave`
31+
`bind-propagation=<value>`. (Docker accepts any string, but podman
32+
validates this enum, so compose2pod narrows to it — a rule-two narrowing:
33+
a propagation podman rejects would fail at run time.)
34+
- `selinux` — enum `z`/`Z` → translated to `relabel=shared` (`z`) /
35+
`relabel=private` (`Z`). (podman's `--mount` spells the SELinux relabel as
36+
`shared`/`private`, not `z`/`Z`.)
37+
- `create_host_path`**refused** (rule-two): podman `--mount` has no
38+
equivalent and does not auto-create a missing bind source. Tracked.
39+
- **`volume:`** (only for `type: volume`)
40+
- `subpath` — string → `subpath=<value>`.
41+
- `nocopy`**refused** (rule-two): podman `--mount` rejects it
42+
(`volume-nocopy: invalid mount option`). Tracked.
43+
- **`tmpfs:`** (only for `type: tmpfs`)
44+
- `size` — a number or size string (`1000`, `"1m"`; a fractional float is
45+
refused) → `tmpfs-size=<value>`.
46+
- `mode` — a native number (`1777`; a string is refused) → `tmpfs-mode=<value>`.
47+
- Unknown sub-map key → refused.
48+
- **A sub-map not matching `type`** (`bind:` on a `type: volume`, etc.) →
49+
**refused**. Docker accepts a mismatched sub-map and silently ignores it; a
50+
mismatched sub-map is always a mistake, so compose2pod refuses it — a
51+
deliberate stricter-than-Docker check (a tracked over-reject) that also keeps
52+
`nocopy`/`create_host_path` unconditional refusals rather than type-dependent.
53+
54+
### Validation (`parsing.py`)
55+
56+
`_validate_volume_long_form` gains the matching sub-map to its allowed keys
57+
(base keys + `bind`/`volume`/`tmpfs` where it equals `type`). A new
58+
`_validate_volume_options(name, vtype, options)` enforces the per-type sub-map
59+
schema above (enum for `propagation`/`selinux`, size/number for `tmpfs.size/mode`,
60+
string for `subpath`, and the `nocopy`/`create_host_path` refusals). Presence of
61+
a non-matching sub-map key is refused by the same unknown-key check that already
62+
guards the base keys.
63+
64+
### Emit (`emit._mount_flag`)
65+
66+
After the existing `type`/`source`/`target`/`ro` parts, append the matching
67+
sub-map's mapped options, in a stable order:
68+
69+
- `bind`: `bind-propagation=<propagation>`, `relabel=<shared|private>` (from
70+
`selinux`).
71+
- `volume`: `subpath=<subpath>`.
72+
- `tmpfs`: `tmpfs-size=<size>`, `tmpfs-mode=<mode>`.
73+
74+
Each stays part of the single comma-joined `Expand` token, so a `${VAR}` in a
75+
value interpolates at run time.
76+
77+
## Non-goals
78+
79+
- **`volume.nocopy`, `bind.create_host_path`** — rule-two refusals (podman
80+
cannot express them); tracked, not deferred parsers.
81+
- **Accepting a mismatched sub-map** — refused by design (see Grammar); recorded
82+
as a stricter-than-Docker over-reject.
83+
- **`cluster`/`npipe`/`image` types** — unchanged (still refused / deferred per
84+
`2026-07-16.05`).
85+
86+
## Testing (TDD; Docker + podman oracles)
87+
88+
- **parsing**: accept each sub-map on its matching type with valid options
89+
(`propagation: rshared`, `selinux: z`, `subpath: s`, `size: "1m"`/`1000`,
90+
`mode: 1777`); reject a bad `propagation` enum value, a bad `selinux` value, a
91+
`tmpfs.size` fractional float, a `tmpfs.mode` string, `volume.nocopy`,
92+
`bind.create_host_path`, an unknown sub-map key, and a mismatched sub-map
93+
(`bind:` on `type: volume`).
94+
- **emit**: each mapped option appears in the `--mount` value with the right
95+
podman spelling; `selinux: z``relabel=shared`, `Z``relabel=private`; a
96+
`${VAR}` in an option value interpolates.
97+
- **conformance**: a corpus doc with a `bind: {propagation}` entry flips
98+
over-reject → both-accept (dedicated assertion); an integration test on real
99+
podman (a bind mount with `propagation` round-trips).
100+
- **promotion**: `architecture/supported-subset.md` (nested options) and
101+
`planning/deferred.md` (the long-form volumes bullet narrows to the refused
102+
`nocopy`/`create_host_path` + mismatched-sub-map residual).
103+
- `just test-ci` @ 100% coverage, `just lint-ci`, `just check-planning`,
104+
`just test-conformance`.
105+
106+
## Risk
107+
108+
- **A mapped podman option spelled wrong** (low × high): fails on podman.
109+
Mitigated by the integration test (real podman round-trip) and per-option emit
110+
unit tests; every spelling was measured against podman 6.0.1.
111+
- **`selinux` z/Z translation inverted** (low × med): measured — `z` = shared
112+
(`relabel=shared`), `Z` = private (`relabel=private`); the emit test pins both.
113+
- **`propagation` enum drift from podman** (low × low): the six values are
114+
measured accepted by both oracles; a value outside them is refused before emit.

planning/deferred.md

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,6 @@ subset, not a bug and not a design position. Each item below is a **form** of a
1111
capability compose2pod already supports, refused only because the parser was
1212
never written. Every one was measured against `docker compose config` v5.1.2.
1313

14-
- **Long-form `volumes` nested option maps.** The mapping form itself
15-
(`type`/`source`/`target`/`read_only`/`consistency`) is now accepted and
16-
emitted as `--mount` (`2026-07-16.05-volumes-long-form`). Still refused:
17-
the nested `bind:`/`volume:`/`tmpfs:` option map a long-form entry may
18-
carry (`propagation`, `subpath`, `tmpfs.size`/`tmpfs.mode`) — podman's
19-
`--mount` can express these, so they are a genuine parser gap, not a
20-
design position; `nocopy` is podman-inexpressible and would stay refused
21-
either way.
2214
- **Long-form `volumes` `image` mount type.** `type: image` (docker: ACCEPTS,
2315
measured `docker compose config` v5.1.2) is refused by scope A's parser
2416
(`type` must be `bind`/`volume`/`tmpfs`), but podman *can* express it
@@ -41,10 +33,9 @@ never written. Every one was measured against `docker compose config` v5.1.2.
4133
tilde-bind-mount fix that discovered it.
4234

4335
**Revisit trigger:** a user reports a compose file that `docker compose` runs and
44-
compose2pod refuses — most likely a long-form `volumes` entry's nested
45-
`bind:`/`volume:`/`tmpfs:` option map, now that the mapping form itself is
46-
accepted, or the Windows drive-letter bind above. The conformance harness
47-
reports these as `over-reject`, so they stay visible rather than forgotten.
36+
compose2pod refuses — most likely a long-form `volumes` entry's `type: image`,
37+
or the Windows drive-letter bind above. The conformance harness reports these
38+
as `over-reject`, so they stay visible rather than forgotten.
4839

4940
Two other `over-reject` cells the harness reports — `sysctls: ["a"]` and
5041
`volumes: ["a"]` — are *not* deferred parsers: they are measured legitimate
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
services:
2+
app:
3+
image: nginx
4+
volumes:
5+
- type: bind
6+
source: ./data
7+
target: /data
8+
bind:
9+
propagation: rshared

tests/conformance/test_corpus.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,3 +97,19 @@ def test_volumes_long_form_is_no_longer_an_over_rejection(
9797
"""
9898
path = Path(__file__).parent / "corpus" / "volumes_long_form.yaml"
9999
assert assert_rule(yaml.safe_load(path.read_text())) == "both-accept"
100+
101+
102+
def test_volumes_long_form_bind_options_is_no_longer_an_over_rejection(
103+
assert_rule: Callable[[dict[str, Any]], str],
104+
) -> None:
105+
"""The long-form entry's nested `bind:` option map now parses instead of raising.
106+
107+
Same reasoning as the over-rejection tests above: the generic corpus run alone
108+
would stay green even pre-fix, filing `volumes_long_form_bind_options` under
109+
the allowed 'over-reject' verdict instead of catching a regression. The
110+
stronger claim -- both oracles ACCEPT a `bind: {propagation: rshared}` sub-map
111+
on a `type: bind` entry -- needs this dedicated assertion on the verdict
112+
itself.
113+
"""
114+
path = Path(__file__).parent / "corpus" / "volumes_long_form_bind_options.yaml"
115+
assert assert_rule(yaml.safe_load(path.read_text())) == "both-accept"

0 commit comments

Comments
 (0)