diff --git a/architecture/supported-subset.md b/architecture/supported-subset.md index f94ba68..221b0d4 100644 --- a/architecture/supported-subset.md +++ b/architecture/supported-subset.md @@ -755,7 +755,40 @@ boolean-typed exception in this group, validated as an actual bool like ## Volumes -Short syntax only; the long mapping form raises. The `volumes` key itself +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. The +nested `bind:`/`volume:`/`tmpfs:` option maps (`propagation`, `subpath`, +`tmpfs.size`/`tmpfs.mode`, etc.) fall out as unsupported keys and raise for +the same deferred-parser reason; `nocopy` is podman-inexpressible regardless. +A `target` must be an absolute path (a `${VAR}` reference is accepted, being +host-dependent) — podman's `--mount` rejects a relative target for every +type (`invalid container path "rel", must be an absolute path`) even though +docker accepts one, matching the short-form anonymous-volume refusal (below). +A `tmpfs`-type entry's `source` is refused even though docker accepts one: +podman's `--mount` has no way to express a `source` on a `tmpfs` mount +(`"source" option not supported for "tmpfs" mount types`), so this is a +rule-two refusal, not a docker-schema rule. + +Each accepted entry is emitted as a single `--mount` flag +(`compose2pod/emit.py`'s `_mount_flag`) rather than `-v`: `type=`, +`source=` (a relative bind `source` is resolved against +`--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 +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. + +The `volumes` key itself must be a list — a bare string raises, rather than being destructured one character at a time. A `source:target` entry is one of two kinds, told apart by whether `source` matches Docker's own volume-name grammar diff --git a/compose2pod/emit.py b/compose2pod/emit.py index 824333e..3ee395e 100644 --- a/compose2pod/emit.py +++ b/compose2pod/emit.py @@ -94,10 +94,27 @@ def _env_file_flags(svc: dict[str, Any], project_dir: str) -> list[Token]: return flags +def _mount_flag(entry: dict[str, Any], project_dir: str) -> list[Token]: + """Render one long-form volume mapping as a `--mount` value.""" + parts = [f"type={entry['type']}"] + source = entry.get("source") + if source is not None: + if entry["type"] == "bind" and source.startswith("."): + 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)): + parts.append("ro") + return ["--mount", Expand(value=",".join(parts))] + + def _volume_flags(svc: dict[str, Any], project_dir: str) -> list[Token]: - """-v and --tmpfs flag tokens.""" + """-v, --mount and --tmpfs flag tokens.""" flags: list[Token] = [] for volume in svc.get("volumes") or []: + if isinstance(volume, dict): + flags += _mount_flag(volume, project_dir) + continue if ":" not in volume: # Anonymous volume: a bare container path, no host source to translate. flags += ["-v", Expand(value=volume)] diff --git a/compose2pod/parsing.py b/compose2pod/parsing.py index 589007c..9ca4709 100644 --- a/compose2pod/parsing.py +++ b/compose2pod/parsing.py @@ -160,8 +160,12 @@ def _classify_volume(volume: str) -> tuple[str, str | None]: return "bind", None +_VOLUME_LONG_TYPES = ("bind", "volume", "tmpfs") +_VOLUME_LONG_KEYS = {"type", "source", "target", "read_only", "consistency"} + + def _validate_service_volumes(name: str, svc: dict[str, Any]) -> None: - """Check volumes is a list of short bind-mount entries.""" + """Check volumes is a list of short-syntax strings or long-syntax mappings.""" volumes = svc.get("volumes") if volumes is None: return @@ -170,8 +174,11 @@ def _validate_service_volumes(name: str, svc: dict[str, Any]) -> None: msg = f"service {name!r}: 'volumes' must be a list" raise UnsupportedComposeError(msg) for volume in volumes: + if isinstance(volume, dict): + _validate_volume_long_form(name, volume) + continue if not isinstance(volume, str): - msg = f"service {name!r}: only short volume syntax is supported" + msg = f"service {name!r}: volume entry must be a string or mapping" raise UnsupportedComposeError(msg) kind, _ = _classify_volume(volume) if kind == "anonymous" and not volume.startswith("/"): @@ -183,9 +190,76 @@ def _validate_service_volumes(name: str, svc: dict[str, Any]) -> None: # that cross-checks a named entry's source against a declaration. -def _named_volume_source(volume: str) -> str | None: - """Return a colon-form volume entry's bare-identifier source, or None if it needs no declaration.""" - kind, source = _classify_volume(volume) +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). + + Scope A: type (bind/volume/tmpfs), source, target, read_only, consistency. + The nested bind/volume/tmpfs option maps fall out as unknown keys (refused, + tracked in planning/deferred.md); cluster/npipe/image types are refused + (podman cannot express them). + """ + require_string_keys(f"service {name!r}: volume", entry) + unknown = set(entry) - _VOLUME_LONG_KEYS + if unknown: + msg = f"service {name!r}: volume: unsupported keys {sorted(unknown)}" + raise UnsupportedComposeError(msg) + vtype = entry.get("type") + if vtype not in _VOLUME_LONG_TYPES: + msg = f"service {name!r}: volume 'type' must be one of {list(_VOLUME_LONG_TYPES)}" + raise UnsupportedComposeError(msg) + target = entry.get("target") + if not isinstance(target, str): + msg = f"service {name!r}: volume 'target' must be a string" + raise UnsupportedComposeError(msg) + if not target.startswith("/") and not values.has_variable(target): + # podman rejects a relative --mount target for every type ("must be + # an absolute path"); docker accepts it. A ${VAR} target is + # host-dependent, so it is carved out like every other + # values.has_variable case in this file. + msg = f"service {name!r}: volume 'target' must be an absolute path" + raise UnsupportedComposeError(msg) + _validate_volume_long_form_source(name, vtype, entry.get("source")) + if "read_only" in entry and not values.is_bool_like(entry["read_only"]): + msg = f"service {name!r}: volume 'read_only' must be a boolean" + raise UnsupportedComposeError(msg) + if "consistency" in entry and not isinstance(entry["consistency"], str): + msg = f"service {name!r}: volume 'consistency' must be a string" + raise UnsupportedComposeError(msg) + + +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": + if not isinstance(source, str): + msg = f"service {name!r}: bind volume 'source' must be a string" + raise UnsupportedComposeError(msg) + elif vtype == "tmpfs": + if source is not None: + msg = f"service {name!r}: tmpfs volume takes no 'source'" + raise UnsupportedComposeError(msg) + elif source is not None and not isinstance(source, str): # volume + msg = f"service {name!r}: volume 'source' must be a string" + raise UnsupportedComposeError(msg) + + +def _named_volume_source(volume: object) -> str | None: + """Return a volume entry's bare-identifier named source, or None if it needs no declaration. + + A colon-form short-syntax string is classified via `_classify_volume`. A + long-syntax mapping needs its own check: only a `volume`-type entry names + a volume at all, and only when its `source` is a bare identifier (an + absent source is an anonymous volume; a `bind`/`tmpfs` entry's `source`, + if any, is a host path, never a name to cross-check). + """ + if isinstance(volume, dict): + source = volume.get("source") + if volume.get("type") == "volume" and isinstance(source, str) and stores.NAME_PATTERN.fullmatch(source): + return source + return None + # _validate_service_volumes has already confirmed every non-dict entry is + # a str -- the signature is `object`, not `str | dict`, purely so a + # caller need not narrow first; ty cannot see that upstream guarantee. + kind, source = _classify_volume(volume) # ty: ignore[invalid-argument-type] return source if kind == "named" else None @@ -848,8 +922,9 @@ def _validate_volume_references(compose: dict[str, Any], services: dict[str, Any Runs after the per-service loop in `validate()` (`_validate_service` -> `_validate_service_volumes` has already confirmed every service's `volumes` - is a list of strings, and that a colon-less entry is an absolute path), so - `svc.get("volumes") or []` here is safe to iterate. + is a list of strings or long-form mappings, and that a colon-less string + entry is an absolute path), so `svc.get("volumes") or []` here is safe to + iterate -- `_named_volume_source` handles both shapes. A `${VAR}`-carrying source needs no separate carve-out the way other host-state-dependent grammars in this file need `values.has_variable`: diff --git a/planning/changes/2026-07-16.05-volumes-long-form.md b/planning/changes/2026-07-16.05-volumes-long-form.md new file mode 100644 index 0000000..ecbf8d2 --- /dev/null +++ b/planning/changes/2026-07-16.05-volumes-long-form.md @@ -0,0 +1,125 @@ +--- +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. +--- + +# Design: long-form (mapping) volumes + +## Summary + +`volumes` accepts only the short string syntax today; a mapping entry raises +"only short volume syntax is supported". This change accepts the long form +`{type, source, target, read_only, consistency}` for `type` in +`bind`/`volume`/`tmpfs`, emitting `podman run --mount type=…,target=…[,source=…][,ro]`. +The nested `bind:`/`volume:`/`tmpfs:` option maps and the `image` type are +left as a tracked follow-up (scope A doesn't parse them yet, but podman +could express both — a deferred parser gap, not a refusal); +`cluster`/`npipe` are refused as permanent rule-two limitations (podman: +`invalid filesystem type`). + +## Motivation + +Long-form `volumes` is the last user-facing over-reject in the conformance +harness (`planning/deferred.md`; corpus `volumes_long_form`) — a common form in +hand-written and generated compose files. Measured against `docker compose +config` v5.1.2 and podman 6.0.1. + +## Design + +### Grammar (measured) + +A `volumes` list entry may be a string (short, unchanged) or a mapping (long +form). The mapping is a strict schema: + +- `type` — **required**; `bind`/`volume`/`tmpfs` supported. `cluster`/`npipe` + refused: rule-two limitations podman `--mount` rejects (`invalid filesystem + type`) and can never express. `image` also refused in scope A, but for a + different reason — docker accepts it and podman `--mount type=image,...` + *can* express it, so it is a deferred parser gap (`planning/deferred.md`), + not a rule-two refusal. +- `target` — **required** string. +- `source` — **required** string for `bind` (Docker: "field Source must not be + empty"); optional string for `volume` (absent → anonymous); docker accepts + a `source` on `tmpfs` too, but podman's `--mount` cannot express one + (`"source" option not supported for "tmpfs" mount types`) — a rule-two + refusal, not a docker-schema rule, so compose2pod refuses it here. +- `read_only` — optional bool via `values.is_bool_like` (the quoted form works, + reusing `2026-07-16.01`). +- `consistency` — optional, accepted and ignored (legacy macOS hint; no podman + equivalent). +- Nested `bind:`/`volume:`/`tmpfs:` option maps — refused (scope A; tracked in + `deferred.md`). +- Unknown key — refused (strict, matching Docker). + +### Validation (`parsing.py`) + +`_validate_service_volumes` stops rejecting a non-string entry outright: a +mapping is routed to a new `_validate_volume_long_form` enforcing the schema +above. The short-string path (`_classify_volume`, the anonymous-absolute-path +rule) is unchanged. + +### Named-volume references + +A long-form `{type: volume, source: }` references a named volume just +like a short-form `name:/path`. The reference walker (`_named_volume_source` / +`_validate_volume_references`) is extended to read a mapping entry's `source`, so +an undefined named volume is still caught. A `bind` source (a path) needs no +declaration, as today. + +### Emit (`emit._volume_flags`) + +A mapping entry emits `--mount` (the short-string `-v` path is unchanged): + +- `type: bind` → `--mount type=bind,source=,target=[,ro]`; `S` resolved + against `project_dir` when relative, reusing the short-form bind logic. +- `type: volume` with `source` → `--mount type=volume,source=,target=[,ro]`; + without `source` → `--mount type=volume,target=` (anonymous). +- `type: tmpfs` → `--mount type=tmpfs,target=`. +- `read_only: true` appends `,ro`; false/absent omits it (coerced via + `values.as_bool`, so a quoted `"false"` does not leak `ro`). + +The `--mount` value is a single comma-joined `Expand` token, so a `${VAR}` in +`source`/`target` interpolates at run time exactly as the short form's does. + +## Non-goals + +- **Nested `bind`/`volume`/`tmpfs` option maps** (`propagation`, `subpath`, + `tmpfs.size/mode`, `nocopy`) and the **`image` type** — scope A leaves these + refused; tracked as deferred parser gaps (`planning/deferred.md`), not + rule-two refusals — podman can express all of them (`--mount + type=image,...` succeeds) except `volume.nocopy`, which would be a genuine + rule-two refusal anyway (podman: `invalid mount option`). +- **`cluster`/`npipe` types** — permanent rule-two refusals (podman: `invalid + filesystem type`). +- **`-v`-vs-`--mount` for the short form** — the short string form keeps `-v`. + +## Testing (TDD; Docker + podman oracles) + +- **parsing**: accept each type (`bind` with source, `volume` with/without + source, `tmpfs`); reject a missing `target`, a `bind` without `source`, a + `cluster`/`npipe`/`image` type, a nested option map, an unknown key; accept + `read_only: "yes"`; the short string form still validates. +- **emit**: each type renders the right `--mount` value; a relative `bind` + source resolves against `project_dir`; `read_only: true`→`,ro`, + `false`→no `ro`; a `${VAR}` source interpolates. +- **references**: a long-form `{type: volume, source: undefined}` is still caught + by the undefined-named-volume check; a declared/auto-created source passes. +- **conformance**: `volumes_long_form.yaml` flips over-reject → both-accept, with + a dedicated `both-accept` assertion; an integration test on real podman + (a long-form bind mount round-trips a file into the container). +- **promotion**: `architecture/supported-subset.md` (volumes long form) and + `planning/deferred.md` (the "Long-form volumes" bullet narrows to the nested + option maps). +- `just test-ci` @ 100% coverage, `just lint-ci`, `just check-planning`, + `just test-conformance`. + +## Risk + +- **`--mount` value assembled wrong** (low × high): a misordered or mis-joined + option string fails on podman. Mitigated by the integration test (real podman + round-trip) and per-type emit unit tests. +- **A long-form source escapes the named-volume reference check** (low × med): + covered by the undefined-source reference test; the walker change is the one + place both short and long forms feed the check. +- **`type` optionality mis-measured** (low × low): measured — `type` is required + (a type-less `{source, target}` is refused by Docker), so requiring it is exact + parity, not an over-reject. diff --git a/planning/deferred.md b/planning/deferred.md index ed6e1e2..34fe92b 100644 --- a/planning/deferred.md +++ b/planning/deferred.md @@ -11,8 +11,20 @@ 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`.** The mapping form raises; podman expresses it with - `--mount`. +- **Long-form `volumes` nested option maps.** The mapping form itself + (`type`/`source`/`target`/`read_only`/`consistency`) is now accepted and + emitted as `--mount` (`2026-07-16.05-volumes-long-form`). Still refused: + the nested `bind:`/`volume:`/`tmpfs:` option map a long-form entry may + carry (`propagation`, `subpath`, `tmpfs.size`/`tmpfs.mode`) — podman's + `--mount` can express these, so they are a genuine parser gap, not a + design position; `nocopy` is podman-inexpressible and would stay refused + either way. +- **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 @@ -29,9 +41,10 @@ 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 long-form `volumes`, a common form in -hand-written and generated compose files alike. The conformance harness reports -these as `over-reject`, so they stay visible rather than forgotten. +compose2pod refuses — most likely a long-form `volumes` entry's nested +`bind:`/`volume:`/`tmpfs:` option map, now that the mapping form itself is +accepted, or 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/test_corpus.py b/tests/conformance/test_corpus.py index 5312284..c5e6d18 100644 --- a/tests/conformance/test_corpus.py +++ b/tests/conformance/test_corpus.py @@ -82,3 +82,18 @@ def test_healthcheck_compound_duration_is_no_longer_an_over_rejection( """ path = Path(__file__).parent / "corpus" / "healthcheck_compound_duration.yaml" assert assert_rule(yaml.safe_load(path.read_text())) == "both-accept" + + +def test_volumes_long_form_is_no_longer_an_over_rejection( + assert_rule: Callable[[dict[str, Any]], str], +) -> None: + """The long-syntax (mapping) volume entry 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` under the allowed + 'over-reject' verdict instead of catching a regression. The stronger claim -- + both oracles ACCEPT `{type: bind, source: ./data, target: /data}` -- needs + this dedicated assertion on the verdict itself. + """ + path = Path(__file__).parent / "corpus" / "volumes_long_form.yaml" + assert assert_rule(yaml.safe_load(path.read_text())) == "both-accept" diff --git a/tests/integration/test_volume_long_form.py b/tests/integration/test_volume_long_form.py new file mode 100644 index 0000000..36030a5 --- /dev/null +++ b/tests/integration/test_volume_long_form.py @@ -0,0 +1,22 @@ +"""Long-form volumes: a {type: bind} mapping round-trips a host file into the container.""" + +from collections.abc import Callable +from pathlib import Path + +from tests.integration.conftest import PodRun + + +def test_long_form_bind_mount_is_read(run_pod: Callable[..., PodRun], tmp_path: Path) -> None: + (tmp_path / "data.txt").write_text("mount-ok-73\n") + compose = { + "services": { + "app": { + "image": "busybox:1.36", + "volumes": [{"type": "bind", "source": "./data.txt", "target": "/data.txt", "read_only": True}], + "command": ["cat", "/data.txt"], + }, + }, + } + run = run_pod(compose, target="app", project_dir=tmp_path) + assert run.returncode == 0, run.stderr + assert "mount-ok-73" in run.stdout diff --git a/tests/test_emit.py b/tests/test_emit.py index e24d4f9..9f3c6e8 100644 --- a/tests/test_emit.py +++ b/tests/test_emit.py @@ -125,6 +125,32 @@ def test_named_volume_emitted_without_project_dir_translation(self) -> None: flags = run_flags("db", svc, "p", "/builds/x") assert flags[4:6] == ["-v", Expand(value="pgdata:/var/lib/postgresql/data")] + def test_long_form_bind_mount_relative_source_resolved(self) -> None: + svc = {"image": "x", "volumes": [{"type": "bind", "source": "./data", "target": "/data"}]} + flags = run_flags("app", svc, "p", "/proj") + assert flags[4:6] == ["--mount", Expand(value="type=bind,source=/proj/data,target=/data")] + + def test_long_form_bind_mount_read_only(self) -> None: + svc = {"image": "x", "volumes": [{"type": "bind", "source": "/abs", "target": "/d", "read_only": True}]} + flags = run_flags("app", svc, "p", "/proj") + assert flags[4:6] == ["--mount", Expand(value="type=bind,source=/abs,target=/d,ro")] + + def test_long_form_read_only_quoted_false_omits_ro(self) -> None: + svc = {"image": "x", "volumes": [{"type": "bind", "source": "/abs", "target": "/d", "read_only": "false"}]} + flags = run_flags("app", svc, "p", "/proj") + assert flags[4:6] == ["--mount", Expand(value="type=bind,source=/abs,target=/d")] + + def test_long_form_named_volume(self) -> None: + svc = {"image": "x", "volumes": [{"type": "volume", "source": "v", "target": "/data"}]} + flags = run_flags("app", svc, "p", "/proj") + assert flags[4:6] == ["--mount", Expand(value="type=volume,source=v,target=/data")] + + def test_long_form_anonymous_volume_and_tmpfs(self) -> None: + anon = run_flags("app", {"image": "x", "volumes": [{"type": "volume", "target": "/d"}]}, "p", "/proj") + assert anon[4:6] == ["--mount", Expand(value="type=volume,target=/d")] + tmp = run_flags("app", {"image": "x", "volumes": [{"type": "tmpfs", "target": "/t"}]}, "p", "/proj") + assert tmp[4:6] == ["--mount", Expand(value="type=tmpfs,target=/t")] + 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 3b8c0e4..700da77 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -80,10 +80,80 @@ def test_named_volume_is_accepted(self) -> None: } assert any("volumes" in w for w in validate(compose)) - def test_long_volume_syntax_raises(self) -> None: - compose = {"services": {"app": {"image": "x", "volumes": [{"type": "bind", "source": ".", "target": "/s"}]}}} - with pytest.raises(UnsupportedComposeError, match="short volume syntax"): - validate(compose) + def test_long_volume_syntax_accepted(self) -> None: + # No top-level volumes here, so these accept cleanly (== []); the named + # source case is separate (it warns about the ignored top-level block). + for entry in ( + {"type": "bind", "source": "./data", "target": "/data"}, + {"type": "bind", "source": "/abs", "target": "/data", "read_only": True}, + {"type": "volume", "target": "/data"}, + {"type": "tmpfs", "target": "/tmp"}, # noqa: S108 + {"type": "bind", "source": "/a", "target": "/d", "read_only": "yes", "consistency": "cached"}, + ): + assert validate({"services": {"app": {"image": "x", "volumes": [entry]}}}) == [] + + def test_long_volume_declared_named_source_accepted(self) -> None: + doc = { + "services": {"app": {"image": "x", "volumes": [{"type": "volume", "source": "v", "target": "/d"}]}}, + "volumes": {"v": {}}, + } + # Reference resolves; the only message is the top-level-volumes-ignored warning. + assert "ignoring top-level 'volumes'" in " ".join(validate(doc)) + + def test_long_volume_undefined_named_source_rejected(self) -> None: + with pytest.raises(UnsupportedComposeError, match=r"undefined volume 'ghost'"): + validate( + { + "services": { + "app": {"image": "x", "volumes": [{"type": "volume", "source": "ghost", "target": "/d"}]} + } + } + ) + + def test_long_volume_entry_rejects(self) -> None: + # Each case drives one distinct reject branch (all six needed for 100% coverage). + cases = [ + ([5], r"volume entry must be a string or mapping"), + ([{"type": "cluster", "source": "x", "target": "/d"}], r"volume 'type' must be one of"), + ([{"type": "volume", "target": 5}], r"volume 'target' must be a string"), + ([{"type": "bind", "target": "/d"}], r"bind volume 'source' must be a string"), + ([{"type": "tmpfs", "source": "x", "target": "/t"}], r"tmpfs volume takes no 'source'"), + ([{"type": "volume", "source": 5, "target": "/d"}], r"volume 'source' must be a string"), + ( + [{"type": "bind", "source": "/a", "target": "/d", "read_only": 5}], + r"volume 'read_only' must be a boolean", + ), + ( + [{"type": "bind", "source": "/a", "target": "/d", "consistency": 5}], + r"volume 'consistency' must be a string", + ), + ( + [{"type": "bind", "source": "/a", "target": "/d", "bind": {"propagation": "rshared"}}], + r"volume: unsupported keys", + ), + ([{"type": "volume", "source": "v", "target": "/d", "bogus": 1}], r"volume: unsupported keys"), + ] + for vols, msg in cases: + with pytest.raises(UnsupportedComposeError, match=msg): + validate({"services": {"app": {"image": "x", "volumes": vols}}}) + + def test_long_volume_relative_target_rejected(self) -> None: + # podman rejects a relative --mount target for every type ("must be an + # absolute path"); docker accepts it. Matches the short-form + # anonymous-volume refusal above. + for entry in ( + {"type": "volume", "target": "rel"}, + {"type": "bind", "source": "/a", "target": "rel"}, + ): + with pytest.raises(UnsupportedComposeError, match=r"volume 'target' must be an absolute path"): + validate({"services": {"app": {"image": "x", "volumes": [entry]}}}) + + def test_long_volume_variable_target_accepted(self) -> None: + # A ${VAR}-carrying target is host-dependent -- accepted, matching + # every other values.has_variable carve-out in parsing.py. + assert ( + validate({"services": {"app": {"image": "x", "volumes": [{"type": "volume", "target": "${MNT}"}]}}}) == [] + ) def test_anonymous_volume_is_accepted(self) -> None: assert validate({"services": {"app": {"image": "x", "volumes": ["/var/cache/models"]}}}) == []