Skip to content

Commit 68f95b0

Browse files
committed
fix: refuse a relative long-form volume target
podman rejects a relative --mount target for every type ("must be an absolute path"); docker accepts one, so scope A's long-form volumes silently emitted a script that fails on podman -- a rule-two gap, inconsistent with the short-form anonymous-volume refusal. A ${VAR} target is carved out (host-dependent), matching every other values.has_variable case in parsing.py. Also corrects two doc-accuracy issues found in final review: 'image' is a deferred parser gap (docker accepts, podman can express it), not a permanent rule-two refusal like cluster/npipe; and tmpfs-with-source is a podman rule-two refusal, not a docker-schema rule (docker accepts it).
1 parent 3dfa3d2 commit 68f95b0

5 files changed

Lines changed: 79 additions & 23 deletions

File tree

‎architecture/supported-subset.md‎

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -757,22 +757,36 @@ boolean-typed exception in this group, validated as an actual bool like
757757

758758
A `volumes` entry may be the short string syntax or the long-form mapping
759759
`{type, source, target, read_only, consistency}`. `type` must be `bind`,
760-
`volume`, or `tmpfs`; `cluster`, `npipe`, and `image` are refused (the latter
761-
is podman-inexpressible, and `cluster`/`npipe` fall out as an unrecognized
762-
`type` alongside them). Each is emitted as a single `--mount` flag
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
763779
(`compose2pod/emit.py`'s `_mount_flag`) rather than `-v`: `type=<type>`,
764780
`source=<source>` (a relative bind `source` is resolved against
765781
`--project-dir`, the same as the short form), `target=<target>`, and a
766782
trailing `ro` when `read_only` is truthy — `read_only` accepts the quoted
767783
`"true"`/`"false"` form via the same `is_bool_like` check every other
768784
boolean field uses. `consistency` is accepted and validated as a string but
769-
otherwise ignored — podman's `--mount` has no consistency knob. The nested
770-
`bind:`/`volume:`/`tmpfs:` option maps (`propagation`, `subpath`,
771-
`tmpfs.size`/`tmpfs.mode`, etc.) fall out as unsupported keys and raise;
772-
`nocopy` is podman-inexpressible regardless. A long-form `volume`-type entry
773-
whose `source` is a bare identifier is cross-checked against the top-level
774-
`volumes:` block exactly like a short-form named volume (below) — a
775-
`bind`/`tmpfs` entry's `source`, or an absent one, needs no declaration.
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.
776790

777791
The `volumes` key itself
778792
must be a list — a bare string raises, rather than being destructured one

‎compose2pod/parsing.py‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,9 +207,17 @@ def _validate_volume_long_form(name: str, entry: dict[str, Any]) -> None:
207207
if vtype not in _VOLUME_LONG_TYPES:
208208
msg = f"service {name!r}: volume 'type' must be one of {list(_VOLUME_LONG_TYPES)}"
209209
raise UnsupportedComposeError(msg)
210-
if not isinstance(entry.get("target"), str):
210+
target = entry.get("target")
211+
if not isinstance(target, str):
211212
msg = f"service {name!r}: volume 'target' must be a string"
212213
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)
213221
_validate_volume_long_form_source(name, vtype, entry.get("source"))
214222
if "read_only" in entry and not values.is_bool_like(entry["read_only"]):
215223
msg = f"service {name!r}: volume 'read_only' must be a boolean"

‎planning/changes/2026-07-16.05-volumes-long-form.md‎

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
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 cluster/npipe/image types stay refused (the former a tracked scope follow-up, the latter rule-two refusals podman cannot express).
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.
33
---
44

55
# Design: long-form (mapping) volumes
@@ -10,9 +10,11 @@ summary: Accept the long-form (mapping) `volumes` entry — `{type, source, targ
1010
"only short volume syntax is supported". This change accepts the long form
1111
`{type, source, target, read_only, consistency}` for `type` in
1212
`bind`/`volume`/`tmpfs`, emitting `podman run --mount type=…,target=…[,source=…][,ro]`.
13-
The nested `bind:`/`volume:`/`tmpfs:` option maps are left as a tracked
14-
follow-up (scope A); `cluster`/`npipe`/`image` types are refused as rule-two
15-
limitations (podman: `invalid filesystem type`).
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`).
1618

1719
## Motivation
1820

@@ -28,12 +30,18 @@ config` v5.1.2 and podman 6.0.1.
2830
A `volumes` list entry may be a string (short, unchanged) or a mapping (long
2931
form). The mapping is a strict schema:
3032

31-
- `type` — **required**; `bind`/`volume`/`tmpfs` supported. `cluster`/`npipe`/`image`
32-
refused (podman `--mount` rejects them: `invalid filesystem type`).
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.
3339
- `target` — **required** string.
3440
- `source` — **required** string for `bind` (Docker: "field Source must not be
35-
empty"); optional string for `volume` (absent → anonymous); not allowed for
36-
`tmpfs`.
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.
3745
- `read_only` — optional bool via `values.is_bool_like` (the quoted form works,
3846
reusing `2026-07-16.01`).
3947
- `consistency` — optional, accepted and ignored (legacy macOS hint; no podman
@@ -75,10 +83,12 @@ The `--mount` value is a single comma-joined `Expand` token, so a `${VAR}` in
7583
## Non-goals
7684

7785
- **Nested `bind`/`volume`/`tmpfs` option maps** (`propagation`, `subpath`,
78-
`tmpfs.size/mode`, `nocopy`) — scope A leaves these refused; tracked as the one
79-
remaining long-form piece. `volume.nocopy` would be a rule-two refusal anyway
80-
(podman: `invalid mount option`); the rest podman can express.
81-
- **`cluster`/`npipe`/`image` types** — rule-two refusals (podman: `invalid
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
8292
filesystem type`).
8393
- **`-v`-vs-`--mount` for the short form** — the short string form keeps `-v`.
8494

‎planning/deferred.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ never written. Every one was measured against `docker compose config` v5.1.2.
1919
`--mount` can express these, so they are a genuine parser gap, not a
2020
design position; `nocopy` is podman-inexpressible and would stay refused
2121
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`).
2228
- **Windows drive-letter volume source.** `volumes: ["C:\data:/var"]` with no
2329
top-level declaration: Docker ACCEPTS (measured, `docker compose config`
2430
v5.1.2 -- it special-cases a leading `<letter>:\` so the drive letter stays

‎tests/test_parsing.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,24 @@ def test_long_volume_entry_rejects(self) -> None:
137137
with pytest.raises(UnsupportedComposeError, match=msg):
138138
validate({"services": {"app": {"image": "x", "volumes": vols}}})
139139

140+
def test_long_volume_relative_target_rejected(self) -> None:
141+
# podman rejects a relative --mount target for every type ("must be an
142+
# absolute path"); docker accepts it. Matches the short-form
143+
# anonymous-volume refusal above.
144+
for entry in (
145+
{"type": "volume", "target": "rel"},
146+
{"type": "bind", "source": "/a", "target": "rel"},
147+
):
148+
with pytest.raises(UnsupportedComposeError, match=r"volume 'target' must be an absolute path"):
149+
validate({"services": {"app": {"image": "x", "volumes": [entry]}}})
150+
151+
def test_long_volume_variable_target_accepted(self) -> None:
152+
# A ${VAR}-carrying target is host-dependent -- accepted, matching
153+
# every other values.has_variable carve-out in parsing.py.
154+
assert (
155+
validate({"services": {"app": {"image": "x", "volumes": [{"type": "volume", "target": "${MNT}"}]}}}) == []
156+
)
157+
140158
def test_anonymous_volume_is_accepted(self) -> None:
141159
assert validate({"services": {"app": {"image": "x", "volumes": ["/var/cache/models"]}}}) == []
142160

0 commit comments

Comments
 (0)