Skip to content

Commit 0157863

Browse files
authored
feat: accept long-form (mapping) volumes, emitted as --mount (#71)
Accepts the long-form (mapping) volumes entry {type, source, target, read_only, consistency} for type in bind/volume/tmpfs, matching docker compose config v5.1.2, emitting podman run --mount. Closes the last user-facing over-reject (harness 4->3). target must be absolute (podman requires it, ${VAR} carved out); tmpfs-source, cluster/npipe, relative target = rule-two refusals; nested option maps + the image type = deferred parser gaps. Short-form -v path unchanged. Design: planning/changes/2026-07-16.05-volumes-long-form.md
1 parent 5faf302 commit 0157863

9 files changed

Lines changed: 414 additions & 18 deletions

File tree

architecture/supported-subset.md

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -755,7 +755,40 @@ boolean-typed exception in this group, validated as an actual bool like
755755

756756
## Volumes
757757

758-
Short syntax only; the long mapping form raises. The `volumes` key itself
758+
A `volumes` entry may be the short string syntax or the long-form mapping
759+
`{type, source, target, read_only, consistency}`. `type` must be `bind`,
760+
`volume`, or `tmpfs`. `cluster` and `npipe` are refused as permanent rule-two
761+
limitations — podman's `--mount` rejects them (`invalid filesystem type`) and
762+
can never express them. `image` is refused too, but for a different reason:
763+
docker accepts it and podman *can* express it (`--mount type=image,...`
764+
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.
769+
A `target` must be an absolute path (a `${VAR}` reference is accepted, being
770+
host-dependent) — podman's `--mount` rejects a relative target for every
771+
type (`invalid container path "rel", must be an absolute path`) even though
772+
docker accepts one, matching the short-form anonymous-volume refusal (below).
773+
A `tmpfs`-type entry's `source` is refused even though docker accepts one:
774+
podman's `--mount` has no way to express a `source` on a `tmpfs` mount
775+
(`"source" option not supported for "tmpfs" mount types`), so this is a
776+
rule-two refusal, not a docker-schema rule.
777+
778+
Each accepted entry is emitted as a single `--mount` flag
779+
(`compose2pod/emit.py`'s `_mount_flag`) rather than `-v`: `type=<type>`,
780+
`source=<source>` (a relative bind `source` is resolved against
781+
`--project-dir`, the same as the short form), `target=<target>`, and a
782+
trailing `ro` when `read_only` is truthy — `read_only` accepts the quoted
783+
`"true"`/`"false"` form via the same `is_bool_like` check every other
784+
boolean field uses. `consistency` is accepted and validated as a string but
785+
otherwise ignored — podman's `--mount` has no consistency knob. A long-form
786+
`volume`-type entry whose `source` is a bare identifier is cross-checked
787+
against the top-level `volumes:` block exactly like a short-form named
788+
volume (below) — a `bind`/`tmpfs` entry's `source`, or an absent one, needs
789+
no declaration.
790+
791+
The `volumes` key itself
759792
must be a list — a bare string raises, rather than being destructured one
760793
character at a time. A `source:target` entry is one of two kinds, told apart
761794
by whether `source` matches Docker's own volume-name grammar

compose2pod/emit.py

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

9696

97+
def _mount_flag(entry: dict[str, Any], project_dir: str) -> list[Token]:
98+
"""Render one long-form volume mapping as a `--mount` value."""
99+
parts = [f"type={entry['type']}"]
100+
source = entry.get("source")
101+
if source is not None:
102+
if entry["type"] == "bind" and source.startswith("."):
103+
source = str(Path(project_dir, source))
104+
parts.append(f"source={source}")
105+
parts.append(f"target={entry['target']}")
106+
if as_bool(entry.get("read_only", False)):
107+
parts.append("ro")
108+
return ["--mount", Expand(value=",".join(parts))]
109+
110+
97111
def _volume_flags(svc: dict[str, Any], project_dir: str) -> list[Token]:
98-
"""-v and --tmpfs flag tokens."""
112+
"""-v, --mount and --tmpfs flag tokens."""
99113
flags: list[Token] = []
100114
for volume in svc.get("volumes") or []:
115+
if isinstance(volume, dict):
116+
flags += _mount_flag(volume, project_dir)
117+
continue
101118
if ":" not in volume:
102119
# Anonymous volume: a bare container path, no host source to translate.
103120
flags += ["-v", Expand(value=volume)]

compose2pod/parsing.py

Lines changed: 82 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -160,8 +160,12 @@ def _classify_volume(volume: str) -> tuple[str, str | None]:
160160
return "bind", None
161161

162162

163+
_VOLUME_LONG_TYPES = ("bind", "volume", "tmpfs")
164+
_VOLUME_LONG_KEYS = {"type", "source", "target", "read_only", "consistency"}
165+
166+
163167
def _validate_service_volumes(name: str, svc: dict[str, Any]) -> None:
164-
"""Check volumes is a list of short bind-mount entries."""
168+
"""Check volumes is a list of short-syntax strings or long-syntax mappings."""
165169
volumes = svc.get("volumes")
166170
if volumes is None:
167171
return
@@ -170,8 +174,11 @@ def _validate_service_volumes(name: str, svc: dict[str, Any]) -> None:
170174
msg = f"service {name!r}: 'volumes' must be a list"
171175
raise UnsupportedComposeError(msg)
172176
for volume in volumes:
177+
if isinstance(volume, dict):
178+
_validate_volume_long_form(name, volume)
179+
continue
173180
if not isinstance(volume, str):
174-
msg = f"service {name!r}: only short volume syntax is supported"
181+
msg = f"service {name!r}: volume entry must be a string or mapping"
175182
raise UnsupportedComposeError(msg)
176183
kind, _ = _classify_volume(volume)
177184
if kind == "anonymous" and not volume.startswith("/"):
@@ -183,9 +190,76 @@ def _validate_service_volumes(name: str, svc: dict[str, Any]) -> None:
183190
# that cross-checks a named entry's source against a declaration.
184191

185192

186-
def _named_volume_source(volume: str) -> str | None:
187-
"""Return a colon-form volume entry's bare-identifier source, or None if it needs no declaration."""
188-
kind, source = _classify_volume(volume)
193+
def _validate_volume_long_form(name: str, entry: dict[str, Any]) -> None:
194+
"""Check one long-syntax volume mapping against Docker's strict schema (measured, v5.1.2).
195+
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).
200+
"""
201+
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)
206+
vtype = entry.get("type")
207+
if vtype not in _VOLUME_LONG_TYPES:
208+
msg = f"service {name!r}: volume 'type' must be one of {list(_VOLUME_LONG_TYPES)}"
209+
raise UnsupportedComposeError(msg)
210+
target = entry.get("target")
211+
if not isinstance(target, str):
212+
msg = f"service {name!r}: volume 'target' must be a string"
213+
raise UnsupportedComposeError(msg)
214+
if not target.startswith("/") and not values.has_variable(target):
215+
# podman rejects a relative --mount target for every type ("must be
216+
# an absolute path"); docker accepts it. A ${VAR} target is
217+
# host-dependent, so it is carved out like every other
218+
# values.has_variable case in this file.
219+
msg = f"service {name!r}: volume 'target' must be an absolute path"
220+
raise UnsupportedComposeError(msg)
221+
_validate_volume_long_form_source(name, vtype, entry.get("source"))
222+
if "read_only" in entry and not values.is_bool_like(entry["read_only"]):
223+
msg = f"service {name!r}: volume 'read_only' must be a boolean"
224+
raise UnsupportedComposeError(msg)
225+
if "consistency" in entry and not isinstance(entry["consistency"], str):
226+
msg = f"service {name!r}: volume 'consistency' must be a string"
227+
raise UnsupportedComposeError(msg)
228+
229+
230+
def _validate_volume_long_form_source(name: str, vtype: str, source: Any) -> None: # noqa: ANN401 - Compose values are untyped YAML/JSON
231+
"""Check a long-form volume entry's 'source': required for bind, refused for tmpfs, optional string for volume."""
232+
if vtype == "bind":
233+
if not isinstance(source, str):
234+
msg = f"service {name!r}: bind volume 'source' must be a string"
235+
raise UnsupportedComposeError(msg)
236+
elif vtype == "tmpfs":
237+
if source is not None:
238+
msg = f"service {name!r}: tmpfs volume takes no 'source'"
239+
raise UnsupportedComposeError(msg)
240+
elif source is not None and not isinstance(source, str): # volume
241+
msg = f"service {name!r}: volume 'source' must be a string"
242+
raise UnsupportedComposeError(msg)
243+
244+
245+
def _named_volume_source(volume: object) -> str | None:
246+
"""Return a volume entry's bare-identifier named source, or None if it needs no declaration.
247+
248+
A colon-form short-syntax string is classified via `_classify_volume`. A
249+
long-syntax mapping needs its own check: only a `volume`-type entry names
250+
a volume at all, and only when its `source` is a bare identifier (an
251+
absent source is an anonymous volume; a `bind`/`tmpfs` entry's `source`,
252+
if any, is a host path, never a name to cross-check).
253+
"""
254+
if isinstance(volume, dict):
255+
source = volume.get("source")
256+
if volume.get("type") == "volume" and isinstance(source, str) and stores.NAME_PATTERN.fullmatch(source):
257+
return source
258+
return None
259+
# _validate_service_volumes has already confirmed every non-dict entry is
260+
# a str -- the signature is `object`, not `str | dict`, purely so a
261+
# caller need not narrow first; ty cannot see that upstream guarantee.
262+
kind, source = _classify_volume(volume) # ty: ignore[invalid-argument-type]
189263
return source if kind == "named" else None
190264

191265

@@ -848,8 +922,9 @@ def _validate_volume_references(compose: dict[str, Any], services: dict[str, Any
848922
849923
Runs after the per-service loop in `validate()` (`_validate_service` ->
850924
`_validate_service_volumes` has already confirmed every service's `volumes`
851-
is a list of strings, and that a colon-less entry is an absolute path), so
852-
`svc.get("volumes") or []` here is safe to iterate.
925+
is a list of strings or long-form mappings, and that a colon-less string
926+
entry is an absolute path), so `svc.get("volumes") or []` here is safe to
927+
iterate -- `_named_volume_source` handles both shapes.
853928
854929
A `${VAR}`-carrying source needs no separate carve-out the way other
855930
host-state-dependent grammars in this file need `values.has_variable`:
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
---
2+
summary: Accept the long-form (mapping) `volumes` entry — `{type, source, target, read_only, consistency}` for `type` in bind/volume/tmpfs — matching `docker compose config` v5.1.2, emitting `podman run --mount`; nested option maps and the `image` type stay refused as a tracked deferred-parser gap (podman could express `image`; scope A doesn't parse it yet); `cluster`/`npipe` stay refused as permanent rule-two limitations podman cannot express.
3+
---
4+
5+
# Design: long-form (mapping) volumes
6+
7+
## Summary
8+
9+
`volumes` accepts only the short string syntax today; a mapping entry raises
10+
"only short volume syntax is supported". This change accepts the long form
11+
`{type, source, target, read_only, consistency}` for `type` in
12+
`bind`/`volume`/`tmpfs`, emitting `podman run --mount type=…,target=…[,source=…][,ro]`.
13+
The nested `bind:`/`volume:`/`tmpfs:` option maps and the `image` type are
14+
left as a tracked follow-up (scope A doesn't parse them yet, but podman
15+
could express both — a deferred parser gap, not a refusal);
16+
`cluster`/`npipe` are refused as permanent rule-two limitations (podman:
17+
`invalid filesystem type`).
18+
19+
## Motivation
20+
21+
Long-form `volumes` is the last user-facing over-reject in the conformance
22+
harness (`planning/deferred.md`; corpus `volumes_long_form`) — a common form in
23+
hand-written and generated compose files. Measured against `docker compose
24+
config` v5.1.2 and podman 6.0.1.
25+
26+
## Design
27+
28+
### Grammar (measured)
29+
30+
A `volumes` list entry may be a string (short, unchanged) or a mapping (long
31+
form). The mapping is a strict schema:
32+
33+
- `type`**required**; `bind`/`volume`/`tmpfs` supported. `cluster`/`npipe`
34+
refused: rule-two limitations podman `--mount` rejects (`invalid filesystem
35+
type`) and can never express. `image` also refused in scope A, but for a
36+
different reason — docker accepts it and podman `--mount type=image,...`
37+
*can* express it, so it is a deferred parser gap (`planning/deferred.md`),
38+
not a rule-two refusal.
39+
- `target`**required** string.
40+
- `source`**required** string for `bind` (Docker: "field Source must not be
41+
empty"); optional string for `volume` (absent → anonymous); docker accepts
42+
a `source` on `tmpfs` too, but podman's `--mount` cannot express one
43+
(`"source" option not supported for "tmpfs" mount types`) — a rule-two
44+
refusal, not a docker-schema rule, so compose2pod refuses it here.
45+
- `read_only` — optional bool via `values.is_bool_like` (the quoted form works,
46+
reusing `2026-07-16.01`).
47+
- `consistency` — optional, accepted and ignored (legacy macOS hint; no podman
48+
equivalent).
49+
- Nested `bind:`/`volume:`/`tmpfs:` option maps — refused (scope A; tracked in
50+
`deferred.md`).
51+
- Unknown key — refused (strict, matching Docker).
52+
53+
### Validation (`parsing.py`)
54+
55+
`_validate_service_volumes` stops rejecting a non-string entry outright: a
56+
mapping is routed to a new `_validate_volume_long_form` enforcing the schema
57+
above. The short-string path (`_classify_volume`, the anonymous-absolute-path
58+
rule) is unchanged.
59+
60+
### Named-volume references
61+
62+
A long-form `{type: volume, source: <bare-name>}` references a named volume just
63+
like a short-form `name:/path`. The reference walker (`_named_volume_source` /
64+
`_validate_volume_references`) is extended to read a mapping entry's `source`, so
65+
an undefined named volume is still caught. A `bind` source (a path) needs no
66+
declaration, as today.
67+
68+
### Emit (`emit._volume_flags`)
69+
70+
A mapping entry emits `--mount` (the short-string `-v` path is unchanged):
71+
72+
- `type: bind``--mount type=bind,source=<S>,target=<T>[,ro]`; `S` resolved
73+
against `project_dir` when relative, reusing the short-form bind logic.
74+
- `type: volume` with `source``--mount type=volume,source=<S>,target=<T>[,ro]`;
75+
without `source``--mount type=volume,target=<T>` (anonymous).
76+
- `type: tmpfs``--mount type=tmpfs,target=<T>`.
77+
- `read_only: true` appends `,ro`; false/absent omits it (coerced via
78+
`values.as_bool`, so a quoted `"false"` does not leak `ro`).
79+
80+
The `--mount` value is a single comma-joined `Expand` token, so a `${VAR}` in
81+
`source`/`target` interpolates at run time exactly as the short form's does.
82+
83+
## Non-goals
84+
85+
- **Nested `bind`/`volume`/`tmpfs` option maps** (`propagation`, `subpath`,
86+
`tmpfs.size/mode`, `nocopy`) and the **`image` type** — scope A leaves these
87+
refused; tracked as deferred parser gaps (`planning/deferred.md`), not
88+
rule-two refusals — podman can express all of them (`--mount
89+
type=image,...` succeeds) except `volume.nocopy`, which would be a genuine
90+
rule-two refusal anyway (podman: `invalid mount option`).
91+
- **`cluster`/`npipe` types** — permanent rule-two refusals (podman: `invalid
92+
filesystem type`).
93+
- **`-v`-vs-`--mount` for the short form** — the short string form keeps `-v`.
94+
95+
## Testing (TDD; Docker + podman oracles)
96+
97+
- **parsing**: accept each type (`bind` with source, `volume` with/without
98+
source, `tmpfs`); reject a missing `target`, a `bind` without `source`, a
99+
`cluster`/`npipe`/`image` type, a nested option map, an unknown key; accept
100+
`read_only: "yes"`; the short string form still validates.
101+
- **emit**: each type renders the right `--mount` value; a relative `bind`
102+
source resolves against `project_dir`; `read_only: true``,ro`,
103+
`false`→no `ro`; a `${VAR}` source interpolates.
104+
- **references**: a long-form `{type: volume, source: undefined}` is still caught
105+
by the undefined-named-volume check; a declared/auto-created source passes.
106+
- **conformance**: `volumes_long_form.yaml` flips over-reject → both-accept, with
107+
a dedicated `both-accept` assertion; an integration test on real podman
108+
(a long-form bind mount round-trips a file into the container).
109+
- **promotion**: `architecture/supported-subset.md` (volumes long form) and
110+
`planning/deferred.md` (the "Long-form volumes" bullet narrows to the nested
111+
option maps).
112+
- `just test-ci` @ 100% coverage, `just lint-ci`, `just check-planning`,
113+
`just test-conformance`.
114+
115+
## Risk
116+
117+
- **`--mount` value assembled wrong** (low × high): a misordered or mis-joined
118+
option string fails on podman. Mitigated by the integration test (real podman
119+
round-trip) and per-type emit unit tests.
120+
- **A long-form source escapes the named-volume reference check** (low × med):
121+
covered by the undefined-source reference test; the walker change is the one
122+
place both short and long forms feed the check.
123+
- **`type` optionality mis-measured** (low × low): measured — `type` is required
124+
(a type-less `{source, target}` is refused by Docker), so requiring it is exact
125+
parity, not an over-reject.

planning/deferred.md

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,20 @@ 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`.** The mapping form raises; podman expresses it with
15-
`--mount`.
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.
22+
- **Long-form `volumes` `image` mount type.** `type: image` (docker: ACCEPTS,
23+
measured `docker compose config` v5.1.2) is refused by scope A's parser
24+
(`type` must be `bind`/`volume`/`tmpfs`), but podman *can* express it
25+
(`--mount type=image,source=busybox:1.36,target=/img` succeeds, measured
26+
podman 6.0.1) — a genuine parser gap, not a rule-two refusal like
27+
`cluster`/`npipe` (podman: `invalid filesystem type`).
1628
- **Windows drive-letter volume source.** `volumes: ["C:\data:/var"]` with no
1729
top-level declaration: Docker ACCEPTS (measured, `docker compose config`
1830
v5.1.2 -- it special-cases a leading `<letter>:\` so the drive letter stays
@@ -29,9 +41,10 @@ never written. Every one was measured against `docker compose config` v5.1.2.
2941
tilde-bind-mount fix that discovered it.
3042

3143
**Revisit trigger:** a user reports a compose file that `docker compose` runs and
32-
compose2pod refuses — most likely long-form `volumes`, a common form in
33-
hand-written and generated compose files alike. The conformance harness reports
34-
these as `over-reject`, so they stay visible rather than forgotten.
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.
3548

3649
Two other `over-reject` cells the harness reports — `sysctls: ["a"]` and
3750
`volumes: ["a"]` — are *not* deferred parsers: they are measured legitimate

tests/conformance/test_corpus.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,18 @@ def test_healthcheck_compound_duration_is_no_longer_an_over_rejection(
8282
"""
8383
path = Path(__file__).parent / "corpus" / "healthcheck_compound_duration.yaml"
8484
assert assert_rule(yaml.safe_load(path.read_text())) == "both-accept"
85+
86+
87+
def test_volumes_long_form_is_no_longer_an_over_rejection(
88+
assert_rule: Callable[[dict[str, Any]], str],
89+
) -> None:
90+
"""The long-syntax (mapping) volume entry now parses instead of raising.
91+
92+
Same reasoning as the over-rejection tests above: the generic corpus run alone
93+
would stay green even pre-fix, filing `volumes_long_form` under the allowed
94+
'over-reject' verdict instead of catching a regression. The stronger claim --
95+
both oracles ACCEPT `{type: bind, source: ./data, target: /data}` -- needs
96+
this dedicated assertion on the verdict itself.
97+
"""
98+
path = Path(__file__).parent / "corpus" / "volumes_long_form.yaml"
99+
assert assert_rule(yaml.safe_load(path.read_text())) == "both-accept"

0 commit comments

Comments
 (0)