Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 30 additions & 18 deletions architecture/supported-subset.md
Original file line number Diff line number Diff line change
Expand Up @@ -757,23 +757,32 @@ boolean-typed exception in this group, validated as an actual bool like

A `volumes` entry may be the short string syntax or the long-form mapping
`{type, source, target, read_only, consistency}`. `type` must be `bind`,
`volume`, or `tmpfs`. `cluster` and `npipe` are refused as permanent rule-two
limitations — podman's `--mount` rejects them (`invalid filesystem type`) and
can never express them. `image` is refused too, but for a different reason:
docker accepts it and podman *can* express it (`--mount type=image,...`
succeeds) — scope A's parser simply does not parse it yet, so it is a
deferred parser gap (`planning/deferred.md`), not a rule-two refusal. A
long-form entry may also carry the nested option sub-map matching its own
`type` — `bind: {propagation, selinux}`, `volume: {subpath}`, `tmpfs: {size,
mode}` — each accepted and appended to the emitted `--mount` value:
`volume`, `tmpfs`, or `image`. `cluster` and `npipe` are refused as permanent
rule-two limitations — podman's `--mount` rejects them (`invalid filesystem
type`) and can never express them. A `type: image` entry mounts an image's
rootfs at `target`: `source` (an image reference) is required — podman
refuses a sourceless image mount (`must set source and destination for image
volume`), a rule-two narrowing the same way `bind`'s required `source` is.
`read_only` is accepted and validated on an image entry but never emitted: an
image mount is inherently read-only and podman's `--mount` refuses a `ro`
option on one, so a `true` is a redundant no-op and a `false` a harmless one,
matching Docker's own accept-and-ignore stance. A long-form entry may also
carry the nested option sub-map matching its own `type` — `bind:
{propagation, selinux}`, `volume: {subpath}`, `tmpfs: {size, mode}`, `image:
{subpath}` — each accepted and appended to the emitted `--mount` value:
`propagation` (narrowed to podman's enum `private`/`rprivate`/`shared`/
`rshared`/`slave`/`rslave`) becomes `bind-propagation=<value>`; `selinux`
(`z`/`Z`) becomes `relabel=shared`/`relabel=private`; `subpath` becomes
`subpath=<value>`; `tmpfs.size`/`tmpfs.mode` become `tmpfs-size=<value>`/
`tmpfs-mode=<value>`. Two options stay refused as permanent rule-two
limitations because podman's `--mount` cannot express them at all:
`bind.create_host_path` and `volume.nocopy`. A sub-map that does not match
the entry's own `type` (a `bind:` map on a `type: volume` entry, for
(`z`/`Z`) becomes `relabel=shared`/`relabel=private`; `subpath` (on either
`volume` or `image`) becomes `subpath=<value>`; `tmpfs.size`/`tmpfs.mode`
become `tmpfs-size=<value>`/`tmpfs-mode=<value>`. An `image` subpath must be
an absolute path — podman requires one (`must be an absolute path`) while
docker accepts a relative one, a rule-two narrowing (a `${VAR}` subpath is
exempt, as host-dependent). A `volume` subpath has no such requirement and
may stay relative — podman resolves it against the volume root. Two options
stay refused as
permanent rule-two limitations because podman's `--mount` cannot express them
at all: `bind.create_host_path` and `volume.nocopy`. A sub-map that does not
match the entry's own `type` (a `bind:` map on a `type: volume` entry, for
example) is refused too — docker accepts and silently ignores a mismatched
sub-map, but compose2pod treats it as a likely mistake and refuses it, a
deliberate stricter-than-docker check.
Expand All @@ -793,12 +802,15 @@ Each accepted entry is emitted as a single `--mount` flag
`--project-dir`, the same as the short form), `target=<target>`, and a
trailing `ro` when `read_only` is truthy — `read_only` accepts the quoted
`"true"`/`"false"` form via the same `is_bool_like` check every other
boolean field uses. `consistency` is accepted and validated as a string but
boolean field uses. An `image`-type entry is the one exception: `ro` is never
appended, regardless of `read_only`, since the mount is read-only by
construction and podman refuses the option outright. `consistency` is
accepted and validated as a string but
otherwise ignored — podman's `--mount` has no consistency knob. A long-form
`volume`-type entry whose `source` is a bare identifier is cross-checked
against the top-level `volumes:` block exactly like a short-form named
volume (below) — a `bind`/`tmpfs` entry's `source`, or an absent one, needs
no declaration.
volume (below) — a `bind`/`tmpfs`/`image` entry's `source`, or an absent one,
needs no declaration.

The `volumes` key itself
must be a list — a bare string raises, rather than being destructured one
Expand Down
8 changes: 4 additions & 4 deletions compose2pod/emit.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,15 +95,15 @@ def _env_file_flags(svc: dict[str, Any], project_dir: str) -> list[Token]:


def _nested_mount_parts(vtype: str, options: dict[str, Any]) -> list[str]:
"""Map a long-form volume entry's validated `bind`/`volume`/`tmpfs` sub-map to `--mount` options."""
"""Map a long-form volume entry's validated `bind`/`volume`/`tmpfs`/`image` sub-map to `--mount` options."""
if vtype == "bind":
parts = [f"bind-propagation={options['propagation']}"] if "propagation" in options else []
if "selinux" in options:
parts.append(f"relabel={'shared' if options['selinux'] == 'z' else 'private'}")
return parts
if vtype == "volume":
if vtype in ("volume", "image"):
return [f"subpath={options['subpath']}"] if "subpath" in options else []
# vtype == "tmpfs": the gate validates `type` to one of bind/volume/tmpfs, so no other branch is reachable.
# vtype == "tmpfs": the gate validates `type` to bind/volume/tmpfs/image, so no other branch is reachable.
parts = [f"tmpfs-size={options['size']}"] if "size" in options else []
if "mode" in options:
parts.append(f"tmpfs-mode={options['mode']}")
Expand All @@ -119,7 +119,7 @@ def _mount_flag(entry: dict[str, Any], project_dir: str) -> list[Token]:
source = str(Path(project_dir, source))
parts.append(f"source={source}")
parts.append(f"target={entry['target']}")
if as_bool(entry.get("read_only", False)):
if entry["type"] != "image" and as_bool(entry.get("read_only", False)):
parts.append("ro")
vtype = entry["type"]
parts += _nested_mount_parts(vtype, entry.get(vtype, {}))
Expand Down
34 changes: 26 additions & 8 deletions compose2pod/parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ def _classify_volume(volume: str) -> tuple[str, str | None]:
return "bind", None


_VOLUME_LONG_TYPES = ("bind", "volume", "tmpfs")
_VOLUME_LONG_TYPES = ("bind", "volume", "tmpfs", "image")
_VOLUME_LONG_KEYS = {"type", "source", "target", "read_only", "consistency"}
# Docker's own per-type nested option map keys (measured, docker compose config
# v5.1.2). `create_host_path`/`nocopy` are real docker keys, so they land here
Expand All @@ -171,6 +171,7 @@ def _classify_volume(volume: str) -> tuple[str, str | None]:
"bind": {"propagation", "selinux", "create_host_path"},
"volume": {"subpath", "nocopy"},
"tmpfs": {"size", "mode"},
"image": {"subpath"},
}
_PROPAGATION_VALUES = {"private", "rprivate", "shared", "rshared", "slave", "rslave"}
_SELINUX_VALUES = {"z", "Z"}
Expand Down Expand Up @@ -205,10 +206,11 @@ def _validate_service_volumes(name: str, svc: dict[str, Any]) -> None:
def _validate_volume_long_form(name: str, entry: dict[str, Any]) -> None:
"""Check one long-syntax volume mapping against Docker's strict schema (measured, v5.1.2).

type (bind/volume/tmpfs), source, target, read_only, consistency, plus the
one nested option map matching `type` (a mismatched sub-map is refused --
a deliberate stricter-than-docker check; docker accepts-and-ignores it).
cluster/npipe/image types are refused (podman cannot express them).
type (bind/volume/tmpfs/image), source, target, read_only, consistency,
plus the one nested option map matching `type` (a mismatched sub-map is
refused -- a deliberate stricter-than-docker check; docker
accepts-and-ignores it). cluster/npipe types are refused (podman cannot
express them).

`type` is validated before the unknown-key check (unlike every other field
here) because the check itself needs `vtype` to know which sub-map key --
Expand Down Expand Up @@ -303,10 +305,26 @@ def _validate_tmpfs_options(name: str, options: dict[str, Any]) -> None:
raise UnsupportedComposeError(msg)


def _validate_image_options(name: str, options: dict[str, Any]) -> None:
"""Check an image entry's nested `image:` option map (measured: strict, `subpath` only, absolute)."""
if "subpath" not in options:
return
subpath = options["subpath"]
if not isinstance(subpath, str):
msg = f"service {name!r}: image 'subpath' must be a string"
raise UnsupportedComposeError(msg)
if not subpath.startswith("/") and not values.has_variable(subpath):
# podman requires an absolute image subpath (`must be an absolute path`);
# docker accepts a relative one -> a rule-two narrowing. A ${VAR} is host-dependent.
msg = f"service {name!r}: image 'subpath' must be an absolute path"
raise UnsupportedComposeError(msg)


_VOLUME_OPTION_VALIDATORS: dict[str, Callable[[str, dict[str, Any]], None]] = {
"bind": _validate_bind_options,
"volume": _validate_volume_type_options,
"tmpfs": _validate_tmpfs_options,
"image": _validate_image_options,
}


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


def _validate_volume_long_form_source(name: str, vtype: str, source: Any) -> None: # noqa: ANN401 - Compose values are untyped YAML/JSON
"""Check a long-form volume entry's 'source': required for bind, refused for tmpfs, optional string for volume."""
if vtype == "bind":
"""Check a long-form volume entry's 'source': required for bind/image, refused for tmpfs, optional for volume."""
if vtype in ("bind", "image"):
if not isinstance(source, str):
msg = f"service {name!r}: bind volume 'source' must be a string"
msg = f"service {name!r}: {vtype} volume 'source' must be a string"
raise UnsupportedComposeError(msg)
elif vtype == "tmpfs":
if source is not None:
Expand Down
96 changes: 96 additions & 0 deletions planning/changes/2026-07-17.03-image-mount-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
---
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).
---

# Design: long-form volumes `type: image` mount

## Summary

The long-form `volumes` work (`2026-07-16.05`, `2026-07-17.02`) accepts
`type` in `bind`/`volume`/`tmpfs` but refuses `type: image` — a deferred parser
gap (`planning/deferred.md`): Docker accepts it and podman *can* express it. This
adds `image` as a fourth supported type, emitting `podman run --mount
type=image,source=<ref>,target=<path>[,subpath=<sub>]`. It closes the last
podman-expressible over-rejection in the conformance harness.

## Motivation

`type: image` mounts an image's rootfs into the container. Measured against
`docker compose config` v5.1.2 and podman 6.0.1: Docker accepts it and `podman
run --mount type=image,source=busybox:1.36,target=/img` succeeds. It is the only
remaining refused-but-podman-expressible long-form volume shape.

## Design

### Grammar (measured)

An `image` entry is a long-form mapping like the others, with these per-type
rules:

- **`source`** — **required** string (the image reference). Docker accepts a
sourceless image entry at config, but podman refuses one (`must set source and
destination for image volume`), so compose2pod requires it — a rule-two
narrowing, the same stance `bind` takes.
- **`target`** — required, absolute (the existing checks apply unchanged).
- **`read_only`** — accepted (bool, via `values.is_bool_like`) but **not
emitted**. An image mount is inherently read-only; podman refuses both a
`readonly` and an `rw` option on it. Docker accepts `read_only: true`/`false`
and ignores it; compose2pod matches — validated, never appended as `ro`. (No
over-rejection: `true` is a redundant no-op, `false` a harmless one.)
- **`image: {subpath}`** — the nested option map for an image entry (measured:
strict, `subpath` only) → `subpath=<v>`. A sub-map not matching `type: image`
is refused, per the existing mismatched-sub-map rule (`2026-07-17.02`).

### Validation (`parsing.py`)

- Add `"image"` to `_VOLUME_LONG_TYPES`.
- `_validate_volume_long_form_source`: require a string `source` for `image` (the
same branch as `bind`).
- Add `"image": {"subpath"}` to `_VOLUME_OPTION_KEYS`; the image sub-map validator
checks `subpath` is a string (reuse the `volume` sub-map's `subpath` check).

### Emit (`emit._mount_flag` / `_nested_mount_parts`)

- `_mount_flag`: an `image` entry emits `type=image,source=<S>,target=<T>` and,
from the `image:` sub-map, `subpath=<v>`. **Skip the `ro` append for `image`** —
podman rejects the option; the mount is read-only regardless.
- `_nested_mount_parts`: the `image` branch emits `subpath` from the image sub-map
(identical spelling to `volume`'s `subpath`).

## Non-goals

- **A sourceless image mount** — refused (podman requires source). Rule-two,
measured.
- **`read_only` changing the mount's read-only-ness** — an image mount is always
read-only; `read_only` is validated but inert, matching Docker.
- **A mismatched sub-map on an image entry** (`bind:` on `type: image`) — refused,
per the existing stricter-than-Docker rule.

## Testing (TDD; Docker + podman oracles)

- **parsing**: accept `{type: image, source, target}`, with `read_only:
true`/`false`, and with `image: {subpath}`; reject a sourceless image entry, an
unknown `image:` sub-map key, a non-string `subpath`, and a mismatched sub-map
(`bind:` on `type: image`).
- **emit**: `type=image,source=<S>,target=<T>`; with `subpath` appended; and
crucially **no `ro`** even when `read_only: true`.
- **conformance**: a `type: image` corpus doc flips over-reject → `both-accept`
(dedicated assertion).
- **integration**: a `type: image` mount round-trips on real podman (mount an
image's rootfs and read a file from it).
- **promotion**: remove the `image` bullet from `planning/deferred.md`; update
`architecture/supported-subset.md` (image now supported).
- `just test-ci` @ 100% coverage, `just lint-ci`, `just check-planning`,
`just test-conformance`.

## Risk

- **The `ro`-skip-for-image is missed** (low × high): a `read_only: true` image
entry would emit `type=image,...,ro`, which podman refuses at run time.
Mitigated by the explicit no-`ro` emit test and the integration round-trip.
- **`source` required-ness diverges** (low × low): measured — podman refuses a
sourceless image mount, so requiring it is exact rule-two parity, not an
over-rejection beyond the sourceless case.
- **A docker `image:` sub-map key beyond `subpath`** (low × low): measured only
`subpath`; another key would be refused as unknown (a tracked over-reject if one
exists), never a false green.
12 changes: 3 additions & 9 deletions planning/deferred.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,6 @@ subset, not a bug and not a design position. Each item below is a **form** of a
capability compose2pod already supports, refused only because the parser was
never written. Every one was measured against `docker compose config` v5.1.2.

- **Long-form `volumes` `image` mount type.** `type: image` (docker: ACCEPTS,
measured `docker compose config` v5.1.2) is refused by scope A's parser
(`type` must be `bind`/`volume`/`tmpfs`), but podman *can* express it
(`--mount type=image,source=busybox:1.36,target=/img` succeeds, measured
podman 6.0.1) — a genuine parser gap, not a rule-two refusal like
`cluster`/`npipe` (podman: `invalid filesystem type`).
- **Windows drive-letter volume source.** `volumes: ["C:\data:/var"]` with no
top-level declaration: Docker ACCEPTS (measured, `docker compose config`
v5.1.2 -- it special-cases a leading `<letter>:\` so the drive letter stays
Expand All @@ -33,9 +27,9 @@ never written. Every one was measured against `docker compose config` v5.1.2.
tilde-bind-mount fix that discovered it.

**Revisit trigger:** a user reports a compose file that `docker compose` runs and
compose2pod refuses — most likely a long-form `volumes` entry's `type: image`,
or the Windows drive-letter bind above. The conformance harness reports these
as `over-reject`, so they stay visible rather than forgotten.
compose2pod refuses — most likely the Windows drive-letter bind above. The
conformance harness reports these as `over-reject`, so they stay visible
rather than forgotten.

Two other `over-reject` cells the harness reports — `sysctls: ["a"]` and
`volumes: ["a"]` — are *not* deferred parsers: they are measured legitimate
Expand Down
7 changes: 7 additions & 0 deletions tests/conformance/corpus/volumes_long_form_image_type.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
services:
app:
image: nginx
volumes:
- type: image
source: nginx
target: /img
15 changes: 15 additions & 0 deletions tests/conformance/test_corpus.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,18 @@ def test_volumes_long_form_bind_options_is_no_longer_an_over_rejection(
"""
path = Path(__file__).parent / "corpus" / "volumes_long_form_bind_options.yaml"
assert assert_rule(yaml.safe_load(path.read_text())) == "both-accept"


def test_volumes_long_form_image_type_is_no_longer_an_over_rejection(
assert_rule: Callable[[dict[str, Any]], str],
) -> None:
"""The long-form entry's `type: image` now parses instead of raising.

Same reasoning as the over-rejection tests above: the generic corpus run alone
would stay green even pre-fix, filing `volumes_long_form_image_type` under
the allowed 'over-reject' verdict instead of catching a regression. The
stronger claim -- both oracles ACCEPT `{type: image, source: nginx, target:
/img}` -- needs this dedicated assertion on the verdict itself.
"""
path = Path(__file__).parent / "corpus" / "volumes_long_form_image_type.yaml"
assert assert_rule(yaml.safe_load(path.read_text())) == "both-accept"
20 changes: 20 additions & 0 deletions tests/integration/test_volume_image_mount.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""Long-form volumes type: image -- an image's rootfs mounts into the container."""

from collections.abc import Callable
from pathlib import Path

from tests.integration.conftest import PodRun


def test_image_mount_exposes_the_image_rootfs(run_pod: Callable[..., PodRun], tmp_path: Path) -> None:
compose = {
"services": {
"app": {
"image": "busybox:1.36",
"volumes": [{"type": "image", "source": "busybox:1.36", "target": "/img"}],
"command": ["test", "-e", "/img/bin/busybox"], # exit 0 iff the image rootfs mounted
},
},
}
run = run_pod(compose, target="app", project_dir=tmp_path)
assert run.returncode == 0, run.stderr
Loading