From 24006dffef9be121703590589c38c41b28ae7bf6 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Fri, 17 Jul 2026 14:56:00 +0300 Subject: [PATCH 1/5] docs: design long-form volumes type: image mount --- .../changes/2026-07-17.03-image-mount-type.md | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 planning/changes/2026-07-17.03-image-mount-type.md diff --git a/planning/changes/2026-07-17.03-image-mount-type.md b/planning/changes/2026-07-17.03-image-mount-type.md new file mode 100644 index 0000000..85c3bf9 --- /dev/null +++ b/planning/changes/2026-07-17.03-image-mount-type.md @@ -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=,target=[,subpath=]`. 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=`. 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=,target=` and, + from the `image:` sub-map, `subpath=`. **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=,target=`; 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. From d437e4ec9e0cb0227d81a2858f4d10ad17fedb55 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Fri, 17 Jul 2026 15:09:42 +0300 Subject: [PATCH 2/5] feat: accept the long-form volumes type: image mount --- compose2pod/emit.py | 8 ++++---- compose2pod/parsing.py | 26 ++++++++++++++++++-------- tests/test_emit.py | 19 +++++++++++++++++++ tests/test_parsing.py | 29 +++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 12 deletions(-) diff --git a/compose2pod/emit.py b/compose2pod/emit.py index 5448e7f..617ecae 100644 --- a/compose2pod/emit.py +++ b/compose2pod/emit.py @@ -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']}") @@ -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, {})) diff --git a/compose2pod/parsing.py b/compose2pod/parsing.py index 3cd07cc..e8fb040 100644 --- a/compose2pod/parsing.py +++ b/compose2pod/parsing.py @@ -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 @@ -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"} @@ -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 -- @@ -303,10 +305,18 @@ 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).""" + if "subpath" in options and not isinstance(options["subpath"], str): + msg = f"service {name!r}: image 'subpath' must be a string" + 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, } @@ -324,10 +334,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: diff --git a/tests/test_emit.py b/tests/test_emit.py index ad32618..5afd467 100644 --- a/tests/test_emit.py +++ b/tests/test_emit.py @@ -198,6 +198,25 @@ def test_mount_tmpfs_size_and_mode(self) -> None: flags = run_flags("app", svc, "p", "/proj") assert flags[4:6] == ["--mount", Expand(value="type=tmpfs,target=/d,tmpfs-size=1m,tmpfs-mode=1777")] + def test_mount_image_type(self) -> None: + svc = {"image": "x", "volumes": [{"type": "image", "source": "nginx:alpine", "target": "/img"}]} + flags = run_flags("app", svc, "p", "/proj") + assert flags[4:6] == ["--mount", Expand(value="type=image,source=nginx:alpine,target=/img")] + + def test_mount_image_subpath(self) -> None: + svc = { + "image": "x", + "volumes": [{"type": "image", "source": "nginx", "target": "/img", "image": {"subpath": "etc"}}], + } + flags = run_flags("app", svc, "p", "/proj") + assert flags[4:6] == ["--mount", Expand(value="type=image,source=nginx,target=/img,subpath=etc")] + + def test_mount_image_read_only_emits_no_ro(self) -> None: + # podman image mounts are inherently read-only; `ro` is rejected. read_only is validated but not emitted. + svc = {"image": "x", "volumes": [{"type": "image", "source": "nginx", "target": "/img", "read_only": True}]} + flags = run_flags("app", svc, "p", "/proj") + assert flags[4:6] == ["--mount", Expand(value="type=image,source=nginx,target=/img")] + def test_secret_flag_emitted(self) -> None: flags = run_flags("app", {"image": "x", "secrets": ["db"]}, "test-pod", "/b") assert flags[-2:] == ["--secret", "source=test-pod-db,target=db"] diff --git a/tests/test_parsing.py b/tests/test_parsing.py index c601469..c277683 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -151,6 +151,35 @@ def test_long_volume_variable_target_accepted(self) -> None: validate({"services": {"app": {"image": "x", "volumes": [{"type": "volume", "target": "${MNT}"}]}}}) == [] ) + def test_image_mount_accepted(self) -> None: + for entry in ( + {"type": "image", "source": "nginx", "target": "/img"}, + {"type": "image", "source": "nginx", "target": "/img", "read_only": True}, + {"type": "image", "source": "nginx", "target": "/img", "read_only": False}, + {"type": "image", "source": "nginx", "target": "/img", "image": {"subpath": "sub"}}, + ): + assert validate({"services": {"app": {"image": "x", "volumes": [entry]}}}) == [] + + def test_image_mount_rejects(self) -> None: + cases = [ + ({"type": "image", "target": "/img"}, r"image volume 'source' must be a string"), + ( + {"type": "image", "source": "nginx", "target": "/img", "image": {"bogus": 1}}, + r"image options: unsupported keys", + ), + ( + {"type": "image", "source": "nginx", "target": "/img", "image": {"subpath": 5}}, + r"image 'subpath' must be a string", + ), + ( + {"type": "image", "source": "nginx", "target": "/img", "bind": {"propagation": "rshared"}}, + r"volume: unsupported keys", + ), + ] + for entry, msg in cases: + with pytest.raises(UnsupportedComposeError, match=msg): + validate({"services": {"app": {"image": "x", "volumes": [entry]}}}) + def test_nested_options_accepted(self) -> None: # No top-level volumes here, so each accepts cleanly (== []). for entry in ( From 2b3f21d868b50a5c925f6280667ee9bb7cdef93d Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Fri, 17 Jul 2026 15:15:39 +0300 Subject: [PATCH 3/5] test: pin image mount source is not resolved as a project-dir path --- tests/test_emit.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_emit.py b/tests/test_emit.py index 5afd467..f8f2280 100644 --- a/tests/test_emit.py +++ b/tests/test_emit.py @@ -217,6 +217,13 @@ def test_mount_image_read_only_emits_no_ro(self) -> None: flags = run_flags("app", svc, "p", "/proj") assert flags[4:6] == ["--mount", Expand(value="type=image,source=nginx,target=/img")] + def test_mount_image_source_not_path_resolved(self) -> None: + # An image source is an image reference, not a path: a leading "." must NOT + # resolve against project_dir (unlike a bind source). + svc = {"image": "x", "volumes": [{"type": "image", "source": "./local", "target": "/img"}]} + flags = run_flags("app", svc, "p", "/proj") + assert flags[4:6] == ["--mount", Expand(value="type=image,source=./local,target=/img")] + def test_secret_flag_emitted(self) -> None: flags = run_flags("app", {"image": "x", "secrets": ["db"]}, "test-pod", "/b") assert flags[-2:] == ["--secret", "source=test-pod-db,target=db"] From 84824082e1c3f64a17cb25e0abc99b2faa1b2e06 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Fri, 17 Jul 2026 15:20:52 +0300 Subject: [PATCH 4/5] test: image mount conformance + integration; promote docs --- architecture/supported-subset.md | 43 +++++++++++-------- planning/deferred.md | 12 ++---- .../corpus/volumes_long_form_image_type.yaml | 7 +++ tests/conformance/test_corpus.py | 15 +++++++ tests/integration/test_volume_image_mount.py | 20 +++++++++ 5 files changed, 70 insertions(+), 27 deletions(-) create mode 100644 tests/conformance/corpus/volumes_long_form_image_type.yaml create mode 100644 tests/integration/test_volume_image_mount.py diff --git a/architecture/supported-subset.md b/architecture/supported-subset.md index 3e8dfb3..5e980bc 100644 --- a/architecture/supported-subset.md +++ b/architecture/supported-subset.md @@ -757,23 +757,27 @@ 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=`; `selinux` -(`z`/`Z`) becomes `relabel=shared`/`relabel=private`; `subpath` becomes -`subpath=`; `tmpfs.size`/`tmpfs.mode` become `tmpfs-size=`/ -`tmpfs-mode=`. 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=`; `tmpfs.size`/`tmpfs.mode` +become `tmpfs-size=`/`tmpfs-mode=`. 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. @@ -793,12 +797,15 @@ Each accepted entry is emitted as a single `--mount` flag `--project-dir`, the same as the short form), `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 diff --git a/planning/deferred.md b/planning/deferred.md index d4afa49..18dd371 100644 --- a/planning/deferred.md +++ b/planning/deferred.md @@ -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 `:\` so the drive letter stays @@ -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 diff --git a/tests/conformance/corpus/volumes_long_form_image_type.yaml b/tests/conformance/corpus/volumes_long_form_image_type.yaml new file mode 100644 index 0000000..8d72e8e --- /dev/null +++ b/tests/conformance/corpus/volumes_long_form_image_type.yaml @@ -0,0 +1,7 @@ +services: + app: + image: nginx + volumes: + - type: image + source: nginx + target: /img diff --git a/tests/conformance/test_corpus.py b/tests/conformance/test_corpus.py index 275b305..4c44d85 100644 --- a/tests/conformance/test_corpus.py +++ b/tests/conformance/test_corpus.py @@ -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" diff --git a/tests/integration/test_volume_image_mount.py b/tests/integration/test_volume_image_mount.py new file mode 100644 index 0000000..a5103fd --- /dev/null +++ b/tests/integration/test_volume_image_mount.py @@ -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 From 948ffe755f14c815b045fb5cb062cbd721bfd3a3 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Fri, 17 Jul 2026 15:34:44 +0300 Subject: [PATCH 5/5] fix: require an absolute subpath on a type: image mount podman rejects a relative image subpath ("must be an absolute path") even though docker compose config accepts one; volume subpath is unaffected since podman resolves it against the volume root. --- architecture/supported-subset.md | 7 ++++++- compose2pod/parsing.py | 12 ++++++++++-- tests/test_emit.py | 4 ++-- tests/test_parsing.py | 8 +++++++- 4 files changed, 25 insertions(+), 6 deletions(-) diff --git a/architecture/supported-subset.md b/architecture/supported-subset.md index 5e980bc..c418742 100644 --- a/architecture/supported-subset.md +++ b/architecture/supported-subset.md @@ -774,7 +774,12 @@ carry the nested option sub-map matching its own `type` — `bind: `rshared`/`slave`/`rslave`) becomes `bind-propagation=`; `selinux` (`z`/`Z`) becomes `relabel=shared`/`relabel=private`; `subpath` (on either `volume` or `image`) becomes `subpath=`; `tmpfs.size`/`tmpfs.mode` -become `tmpfs-size=`/`tmpfs-mode=`. Two options stay refused as +become `tmpfs-size=`/`tmpfs-mode=`. 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 diff --git a/compose2pod/parsing.py b/compose2pod/parsing.py index e8fb040..bd3e861 100644 --- a/compose2pod/parsing.py +++ b/compose2pod/parsing.py @@ -306,10 +306,18 @@ def _validate_tmpfs_options(name: str, options: dict[str, Any]) -> None: def _validate_image_options(name: str, options: dict[str, Any]) -> None: - """Check an image entry's nested `image:` option map (measured: strict, `subpath` only).""" - if "subpath" in options and not isinstance(options["subpath"], str): + """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]] = { diff --git a/tests/test_emit.py b/tests/test_emit.py index f8f2280..2f6bcf0 100644 --- a/tests/test_emit.py +++ b/tests/test_emit.py @@ -206,10 +206,10 @@ def test_mount_image_type(self) -> None: def test_mount_image_subpath(self) -> None: svc = { "image": "x", - "volumes": [{"type": "image", "source": "nginx", "target": "/img", "image": {"subpath": "etc"}}], + "volumes": [{"type": "image", "source": "nginx", "target": "/img", "image": {"subpath": "/etc"}}], } flags = run_flags("app", svc, "p", "/proj") - assert flags[4:6] == ["--mount", Expand(value="type=image,source=nginx,target=/img,subpath=etc")] + assert flags[4:6] == ["--mount", Expand(value="type=image,source=nginx,target=/img,subpath=/etc")] def test_mount_image_read_only_emits_no_ro(self) -> None: # podman image mounts are inherently read-only; `ro` is rejected. read_only is validated but not emitted. diff --git a/tests/test_parsing.py b/tests/test_parsing.py index c277683..815f897 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -156,7 +156,9 @@ def test_image_mount_accepted(self) -> None: {"type": "image", "source": "nginx", "target": "/img"}, {"type": "image", "source": "nginx", "target": "/img", "read_only": True}, {"type": "image", "source": "nginx", "target": "/img", "read_only": False}, - {"type": "image", "source": "nginx", "target": "/img", "image": {"subpath": "sub"}}, + {"type": "image", "source": "nginx", "target": "/img", "image": {}}, + {"type": "image", "source": "nginx", "target": "/img", "image": {"subpath": "/sub"}}, + {"type": "image", "source": "nginx", "target": "/img", "image": {"subpath": "${SUB}"}}, ): assert validate({"services": {"app": {"image": "x", "volumes": [entry]}}}) == [] @@ -171,6 +173,10 @@ def test_image_mount_rejects(self) -> None: {"type": "image", "source": "nginx", "target": "/img", "image": {"subpath": 5}}, r"image 'subpath' must be a string", ), + ( + {"type": "image", "source": "nginx", "target": "/img", "image": {"subpath": "bin"}}, + r"image 'subpath' must be an absolute path", + ), ( {"type": "image", "source": "nginx", "target": "/img", "bind": {"propagation": "rshared"}}, r"volume: unsupported keys",