Skip to content

Commit 64292f3

Browse files
authored
feat: accept the long-form volumes type: image mount (#77)
Adds type: image as a fourth long-form volume type -- mounts an image's rootfs at target, emitted as podman run --mount type=image,source=<ref>,target=<path>[,subpath=<abs>]. source required (rule-two: podman needs it); read_only accepted but inert (image mounts are read-only, no ro emitted); image.subpath must be absolute (podman requires it; volume.subpath stays relative-valid); mismatched sub-map refused. Closes the last podman-expressible long-form volume gap. Design: planning/changes/2026-07-17.03-image-mount-type.md
1 parent fa7e8cf commit 64292f3

10 files changed

Lines changed: 262 additions & 39 deletions

File tree

architecture/supported-subset.md

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -757,23 +757,32 @@ 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` 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. 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:
760+
`volume`, `tmpfs`, or `image`. `cluster` and `npipe` are refused as permanent
761+
rule-two limitations — podman's `--mount` rejects them (`invalid filesystem
762+
type`) and can never express them. A `type: image` entry mounts an image's
763+
rootfs at `target`: `source` (an image reference) is required — podman
764+
refuses a sourceless image mount (`must set source and destination for image
765+
volume`), a rule-two narrowing the same way `bind`'s required `source` is.
766+
`read_only` is accepted and validated on an image entry but never emitted: an
767+
image mount is inherently read-only and podman's `--mount` refuses a `ro`
768+
option on one, so a `true` is a redundant no-op and a `false` a harmless one,
769+
matching Docker's own accept-and-ignore stance. A long-form entry may also
770+
carry the nested option sub-map matching its own `type` — `bind:
771+
{propagation, selinux}`, `volume: {subpath}`, `tmpfs: {size, mode}`, `image:
772+
{subpath}` — each accepted and appended to the emitted `--mount` value:
769773
`propagation` (narrowed to podman's enum `private`/`rprivate`/`shared`/
770774
`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
775+
(`z`/`Z`) becomes `relabel=shared`/`relabel=private`; `subpath` (on either
776+
`volume` or `image`) becomes `subpath=<value>`; `tmpfs.size`/`tmpfs.mode`
777+
become `tmpfs-size=<value>`/`tmpfs-mode=<value>`. An `image` subpath must be
778+
an absolute path — podman requires one (`must be an absolute path`) while
779+
docker accepts a relative one, a rule-two narrowing (a `${VAR}` subpath is
780+
exempt, as host-dependent). A `volume` subpath has no such requirement and
781+
may stay relative — podman resolves it against the volume root. Two options
782+
stay refused as
783+
permanent rule-two limitations because podman's `--mount` cannot express them
784+
at all: `bind.create_host_path` and `volume.nocopy`. A sub-map that does not
785+
match the entry's own `type` (a `bind:` map on a `type: volume` entry, for
777786
example) is refused too — docker accepts and silently ignores a mismatched
778787
sub-map, but compose2pod treats it as a likely mistake and refuses it, a
779788
deliberate stricter-than-docker check.
@@ -793,12 +802,15 @@ Each accepted entry is emitted as a single `--mount` flag
793802
`--project-dir`, the same as the short form), `target=<target>`, and a
794803
trailing `ro` when `read_only` is truthy — `read_only` accepts the quoted
795804
`"true"`/`"false"` form via the same `is_bool_like` check every other
796-
boolean field uses. `consistency` is accepted and validated as a string but
805+
boolean field uses. An `image`-type entry is the one exception: `ro` is never
806+
appended, regardless of `read_only`, since the mount is read-only by
807+
construction and podman refuses the option outright. `consistency` is
808+
accepted and validated as a string but
797809
otherwise ignored — podman's `--mount` has no consistency knob. A long-form
798810
`volume`-type entry whose `source` is a bare identifier is cross-checked
799811
against the top-level `volumes:` block exactly like a short-form named
800-
volume (below) — a `bind`/`tmpfs` entry's `source`, or an absent one, needs
801-
no declaration.
812+
volume (below) — a `bind`/`tmpfs`/`image` entry's `source`, or an absent one,
813+
needs no declaration.
802814

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

compose2pod/emit.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -95,15 +95,15 @@ def _env_file_flags(svc: dict[str, Any], project_dir: str) -> list[Token]:
9595

9696

9797
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."""
98+
"""Map a long-form volume entry's validated `bind`/`volume`/`tmpfs`/`image` sub-map to `--mount` options."""
9999
if vtype == "bind":
100100
parts = [f"bind-propagation={options['propagation']}"] if "propagation" in options else []
101101
if "selinux" in options:
102102
parts.append(f"relabel={'shared' if options['selinux'] == 'z' else 'private'}")
103103
return parts
104-
if vtype == "volume":
104+
if vtype in ("volume", "image"):
105105
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.
106+
# vtype == "tmpfs": the gate validates `type` to bind/volume/tmpfs/image, so no other branch is reachable.
107107
parts = [f"tmpfs-size={options['size']}"] if "size" in options else []
108108
if "mode" in options:
109109
parts.append(f"tmpfs-mode={options['mode']}")
@@ -119,7 +119,7 @@ def _mount_flag(entry: dict[str, Any], project_dir: str) -> list[Token]:
119119
source = str(Path(project_dir, source))
120120
parts.append(f"source={source}")
121121
parts.append(f"target={entry['target']}")
122-
if as_bool(entry.get("read_only", False)):
122+
if entry["type"] != "image" and as_bool(entry.get("read_only", False)):
123123
parts.append("ro")
124124
vtype = entry["type"]
125125
parts += _nested_mount_parts(vtype, entry.get(vtype, {}))

compose2pod/parsing.py

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

162162

163-
_VOLUME_LONG_TYPES = ("bind", "volume", "tmpfs")
163+
_VOLUME_LONG_TYPES = ("bind", "volume", "tmpfs", "image")
164164
_VOLUME_LONG_KEYS = {"type", "source", "target", "read_only", "consistency"}
165165
# Docker's own per-type nested option map keys (measured, docker compose config
166166
# v5.1.2). `create_host_path`/`nocopy` are real docker keys, so they land here
@@ -171,6 +171,7 @@ def _classify_volume(volume: str) -> tuple[str, str | None]:
171171
"bind": {"propagation", "selinux", "create_host_path"},
172172
"volume": {"subpath", "nocopy"},
173173
"tmpfs": {"size", "mode"},
174+
"image": {"subpath"},
174175
}
175176
_PROPAGATION_VALUES = {"private", "rprivate", "shared", "rshared", "slave", "rslave"}
176177
_SELINUX_VALUES = {"z", "Z"}
@@ -205,10 +206,11 @@ def _validate_service_volumes(name: str, svc: dict[str, Any]) -> None:
205206
def _validate_volume_long_form(name: str, entry: dict[str, Any]) -> None:
206207
"""Check one long-syntax volume mapping against Docker's strict schema (measured, v5.1.2).
207208
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).
209+
type (bind/volume/tmpfs/image), source, target, read_only, consistency,
210+
plus the one nested option map matching `type` (a mismatched sub-map is
211+
refused -- a deliberate stricter-than-docker check; docker
212+
accepts-and-ignores it). cluster/npipe types are refused (podman cannot
213+
express them).
212214
213215
`type` is validated before the unknown-key check (unlike every other field
214216
here) because the check itself needs `vtype` to know which sub-map key --
@@ -303,10 +305,26 @@ def _validate_tmpfs_options(name: str, options: dict[str, Any]) -> None:
303305
raise UnsupportedComposeError(msg)
304306

305307

308+
def _validate_image_options(name: str, options: dict[str, Any]) -> None:
309+
"""Check an image entry's nested `image:` option map (measured: strict, `subpath` only, absolute)."""
310+
if "subpath" not in options:
311+
return
312+
subpath = options["subpath"]
313+
if not isinstance(subpath, str):
314+
msg = f"service {name!r}: image 'subpath' must be a string"
315+
raise UnsupportedComposeError(msg)
316+
if not subpath.startswith("/") and not values.has_variable(subpath):
317+
# podman requires an absolute image subpath (`must be an absolute path`);
318+
# docker accepts a relative one -> a rule-two narrowing. A ${VAR} is host-dependent.
319+
msg = f"service {name!r}: image 'subpath' must be an absolute path"
320+
raise UnsupportedComposeError(msg)
321+
322+
306323
_VOLUME_OPTION_VALIDATORS: dict[str, Callable[[str, dict[str, Any]], None]] = {
307324
"bind": _validate_bind_options,
308325
"volume": _validate_volume_type_options,
309326
"tmpfs": _validate_tmpfs_options,
327+
"image": _validate_image_options,
310328
}
311329

312330

@@ -324,10 +342,10 @@ def _validate_volume_options(name: str, vtype: str, options: Any) -> None: # no
324342

325343

326344
def _validate_volume_long_form_source(name: str, vtype: str, source: Any) -> None: # noqa: ANN401 - Compose values are untyped YAML/JSON
327-
"""Check a long-form volume entry's 'source': required for bind, refused for tmpfs, optional string for volume."""
328-
if vtype == "bind":
345+
"""Check a long-form volume entry's 'source': required for bind/image, refused for tmpfs, optional for volume."""
346+
if vtype in ("bind", "image"):
329347
if not isinstance(source, str):
330-
msg = f"service {name!r}: bind volume 'source' must be a string"
348+
msg = f"service {name!r}: {vtype} volume 'source' must be a string"
331349
raise UnsupportedComposeError(msg)
332350
elif vtype == "tmpfs":
333351
if source is not None:
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
---
2+
summary: Accept the long-form `volumes` `type: image` mount — `{source (required), target, read_only, image: {subpath}}` — emitting `podman run --mount type=image,...`, closing the last podman-expressible conformance over-rejection; `read_only` is accept-and-ignored on an image mount (podman image mounts are inherently read-only), and `source` is required (a rule-two narrowing, since podman refuses a sourceless image mount).
3+
---
4+
5+
# Design: long-form volumes `type: image` mount
6+
7+
## Summary
8+
9+
The long-form `volumes` work (`2026-07-16.05`, `2026-07-17.02`) accepts
10+
`type` in `bind`/`volume`/`tmpfs` but refuses `type: image` — a deferred parser
11+
gap (`planning/deferred.md`): Docker accepts it and podman *can* express it. This
12+
adds `image` as a fourth supported type, emitting `podman run --mount
13+
type=image,source=<ref>,target=<path>[,subpath=<sub>]`. It closes the last
14+
podman-expressible over-rejection in the conformance harness.
15+
16+
## Motivation
17+
18+
`type: image` mounts an image's rootfs into the container. Measured against
19+
`docker compose config` v5.1.2 and podman 6.0.1: Docker accepts it and `podman
20+
run --mount type=image,source=busybox:1.36,target=/img` succeeds. It is the only
21+
remaining refused-but-podman-expressible long-form volume shape.
22+
23+
## Design
24+
25+
### Grammar (measured)
26+
27+
An `image` entry is a long-form mapping like the others, with these per-type
28+
rules:
29+
30+
- **`source`****required** string (the image reference). Docker accepts a
31+
sourceless image entry at config, but podman refuses one (`must set source and
32+
destination for image volume`), so compose2pod requires it — a rule-two
33+
narrowing, the same stance `bind` takes.
34+
- **`target`** — required, absolute (the existing checks apply unchanged).
35+
- **`read_only`** — accepted (bool, via `values.is_bool_like`) but **not
36+
emitted**. An image mount is inherently read-only; podman refuses both a
37+
`readonly` and an `rw` option on it. Docker accepts `read_only: true`/`false`
38+
and ignores it; compose2pod matches — validated, never appended as `ro`. (No
39+
over-rejection: `true` is a redundant no-op, `false` a harmless one.)
40+
- **`image: {subpath}`** — the nested option map for an image entry (measured:
41+
strict, `subpath` only) → `subpath=<v>`. A sub-map not matching `type: image`
42+
is refused, per the existing mismatched-sub-map rule (`2026-07-17.02`).
43+
44+
### Validation (`parsing.py`)
45+
46+
- Add `"image"` to `_VOLUME_LONG_TYPES`.
47+
- `_validate_volume_long_form_source`: require a string `source` for `image` (the
48+
same branch as `bind`).
49+
- Add `"image": {"subpath"}` to `_VOLUME_OPTION_KEYS`; the image sub-map validator
50+
checks `subpath` is a string (reuse the `volume` sub-map's `subpath` check).
51+
52+
### Emit (`emit._mount_flag` / `_nested_mount_parts`)
53+
54+
- `_mount_flag`: an `image` entry emits `type=image,source=<S>,target=<T>` and,
55+
from the `image:` sub-map, `subpath=<v>`. **Skip the `ro` append for `image`**
56+
podman rejects the option; the mount is read-only regardless.
57+
- `_nested_mount_parts`: the `image` branch emits `subpath` from the image sub-map
58+
(identical spelling to `volume`'s `subpath`).
59+
60+
## Non-goals
61+
62+
- **A sourceless image mount** — refused (podman requires source). Rule-two,
63+
measured.
64+
- **`read_only` changing the mount's read-only-ness** — an image mount is always
65+
read-only; `read_only` is validated but inert, matching Docker.
66+
- **A mismatched sub-map on an image entry** (`bind:` on `type: image`) — refused,
67+
per the existing stricter-than-Docker rule.
68+
69+
## Testing (TDD; Docker + podman oracles)
70+
71+
- **parsing**: accept `{type: image, source, target}`, with `read_only:
72+
true`/`false`, and with `image: {subpath}`; reject a sourceless image entry, an
73+
unknown `image:` sub-map key, a non-string `subpath`, and a mismatched sub-map
74+
(`bind:` on `type: image`).
75+
- **emit**: `type=image,source=<S>,target=<T>`; with `subpath` appended; and
76+
crucially **no `ro`** even when `read_only: true`.
77+
- **conformance**: a `type: image` corpus doc flips over-reject → `both-accept`
78+
(dedicated assertion).
79+
- **integration**: a `type: image` mount round-trips on real podman (mount an
80+
image's rootfs and read a file from it).
81+
- **promotion**: remove the `image` bullet from `planning/deferred.md`; update
82+
`architecture/supported-subset.md` (image now supported).
83+
- `just test-ci` @ 100% coverage, `just lint-ci`, `just check-planning`,
84+
`just test-conformance`.
85+
86+
## Risk
87+
88+
- **The `ro`-skip-for-image is missed** (low × high): a `read_only: true` image
89+
entry would emit `type=image,...,ro`, which podman refuses at run time.
90+
Mitigated by the explicit no-`ro` emit test and the integration round-trip.
91+
- **`source` required-ness diverges** (low × low): measured — podman refuses a
92+
sourceless image mount, so requiring it is exact rule-two parity, not an
93+
over-rejection beyond the sourceless case.
94+
- **A docker `image:` sub-map key beyond `subpath`** (low × low): measured only
95+
`subpath`; another key would be refused as unknown (a tracked over-reject if one
96+
exists), never a false green.

planning/deferred.md

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +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` `image` mount type.** `type: image` (docker: ACCEPTS,
15-
measured `docker compose config` v5.1.2) is refused by scope A's parser
16-
(`type` must be `bind`/`volume`/`tmpfs`), but podman *can* express it
17-
(`--mount type=image,source=busybox:1.36,target=/img` succeeds, measured
18-
podman 6.0.1) — a genuine parser gap, not a rule-two refusal like
19-
`cluster`/`npipe` (podman: `invalid filesystem type`).
2014
- **Windows drive-letter volume source.** `volumes: ["C:\data:/var"]` with no
2115
top-level declaration: Docker ACCEPTS (measured, `docker compose config`
2216
v5.1.2 -- it special-cases a leading `<letter>:\` so the drive letter stays
@@ -33,9 +27,9 @@ never written. Every one was measured against `docker compose config` v5.1.2.
3327
tilde-bind-mount fix that discovered it.
3428

3529
**Revisit trigger:** a user reports a compose file that `docker compose` runs and
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.
30+
compose2pod refuses — most likely the Windows drive-letter bind above. The
31+
conformance harness reports these as `over-reject`, so they stay visible
32+
rather than forgotten.
3933

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

tests/conformance/test_corpus.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,18 @@ def test_volumes_long_form_bind_options_is_no_longer_an_over_rejection(
113113
"""
114114
path = Path(__file__).parent / "corpus" / "volumes_long_form_bind_options.yaml"
115115
assert assert_rule(yaml.safe_load(path.read_text())) == "both-accept"
116+
117+
118+
def test_volumes_long_form_image_type_is_no_longer_an_over_rejection(
119+
assert_rule: Callable[[dict[str, Any]], str],
120+
) -> None:
121+
"""The long-form entry's `type: image` now parses instead of raising.
122+
123+
Same reasoning as the over-rejection tests above: the generic corpus run alone
124+
would stay green even pre-fix, filing `volumes_long_form_image_type` under
125+
the allowed 'over-reject' verdict instead of catching a regression. The
126+
stronger claim -- both oracles ACCEPT `{type: image, source: nginx, target:
127+
/img}` -- needs this dedicated assertion on the verdict itself.
128+
"""
129+
path = Path(__file__).parent / "corpus" / "volumes_long_form_image_type.yaml"
130+
assert assert_rule(yaml.safe_load(path.read_text())) == "both-accept"
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""Long-form volumes type: image -- an image's rootfs mounts into the container."""
2+
3+
from collections.abc import Callable
4+
from pathlib import Path
5+
6+
from tests.integration.conftest import PodRun
7+
8+
9+
def test_image_mount_exposes_the_image_rootfs(run_pod: Callable[..., PodRun], tmp_path: Path) -> None:
10+
compose = {
11+
"services": {
12+
"app": {
13+
"image": "busybox:1.36",
14+
"volumes": [{"type": "image", "source": "busybox:1.36", "target": "/img"}],
15+
"command": ["test", "-e", "/img/bin/busybox"], # exit 0 iff the image rootfs mounted
16+
},
17+
},
18+
}
19+
run = run_pod(compose, target="app", project_dir=tmp_path)
20+
assert run.returncode == 0, run.stderr

0 commit comments

Comments
 (0)