|
| 1 | +--- |
| 2 | +summary: Accept the long-form (mapping) `volumes` entry — `{type, source, target, read_only, consistency}` for `type` in bind/volume/tmpfs — matching `docker compose config` v5.1.2, emitting `podman run --mount`; nested option maps and the `image` type stay refused as a tracked deferred-parser gap (podman could express `image`; scope A doesn't parse it yet); `cluster`/`npipe` stay refused as permanent rule-two limitations podman cannot express. |
| 3 | +--- |
| 4 | + |
| 5 | +# Design: long-form (mapping) volumes |
| 6 | + |
| 7 | +## Summary |
| 8 | + |
| 9 | +`volumes` accepts only the short string syntax today; a mapping entry raises |
| 10 | +"only short volume syntax is supported". This change accepts the long form |
| 11 | +`{type, source, target, read_only, consistency}` for `type` in |
| 12 | +`bind`/`volume`/`tmpfs`, emitting `podman run --mount type=…,target=…[,source=…][,ro]`. |
| 13 | +The nested `bind:`/`volume:`/`tmpfs:` option maps and the `image` type are |
| 14 | +left as a tracked follow-up (scope A doesn't parse them yet, but podman |
| 15 | +could express both — a deferred parser gap, not a refusal); |
| 16 | +`cluster`/`npipe` are refused as permanent rule-two limitations (podman: |
| 17 | +`invalid filesystem type`). |
| 18 | + |
| 19 | +## Motivation |
| 20 | + |
| 21 | +Long-form `volumes` is the last user-facing over-reject in the conformance |
| 22 | +harness (`planning/deferred.md`; corpus `volumes_long_form`) — a common form in |
| 23 | +hand-written and generated compose files. Measured against `docker compose |
| 24 | +config` v5.1.2 and podman 6.0.1. |
| 25 | + |
| 26 | +## Design |
| 27 | + |
| 28 | +### Grammar (measured) |
| 29 | + |
| 30 | +A `volumes` list entry may be a string (short, unchanged) or a mapping (long |
| 31 | +form). The mapping is a strict schema: |
| 32 | + |
| 33 | +- `type` — **required**; `bind`/`volume`/`tmpfs` supported. `cluster`/`npipe` |
| 34 | + refused: rule-two limitations podman `--mount` rejects (`invalid filesystem |
| 35 | + type`) and can never express. `image` also refused in scope A, but for a |
| 36 | + different reason — docker accepts it and podman `--mount type=image,...` |
| 37 | + *can* express it, so it is a deferred parser gap (`planning/deferred.md`), |
| 38 | + not a rule-two refusal. |
| 39 | +- `target` — **required** string. |
| 40 | +- `source` — **required** string for `bind` (Docker: "field Source must not be |
| 41 | + empty"); optional string for `volume` (absent → anonymous); docker accepts |
| 42 | + a `source` on `tmpfs` too, but podman's `--mount` cannot express one |
| 43 | + (`"source" option not supported for "tmpfs" mount types`) — a rule-two |
| 44 | + refusal, not a docker-schema rule, so compose2pod refuses it here. |
| 45 | +- `read_only` — optional bool via `values.is_bool_like` (the quoted form works, |
| 46 | + reusing `2026-07-16.01`). |
| 47 | +- `consistency` — optional, accepted and ignored (legacy macOS hint; no podman |
| 48 | + equivalent). |
| 49 | +- Nested `bind:`/`volume:`/`tmpfs:` option maps — refused (scope A; tracked in |
| 50 | + `deferred.md`). |
| 51 | +- Unknown key — refused (strict, matching Docker). |
| 52 | + |
| 53 | +### Validation (`parsing.py`) |
| 54 | + |
| 55 | +`_validate_service_volumes` stops rejecting a non-string entry outright: a |
| 56 | +mapping is routed to a new `_validate_volume_long_form` enforcing the schema |
| 57 | +above. The short-string path (`_classify_volume`, the anonymous-absolute-path |
| 58 | +rule) is unchanged. |
| 59 | + |
| 60 | +### Named-volume references |
| 61 | + |
| 62 | +A long-form `{type: volume, source: <bare-name>}` references a named volume just |
| 63 | +like a short-form `name:/path`. The reference walker (`_named_volume_source` / |
| 64 | +`_validate_volume_references`) is extended to read a mapping entry's `source`, so |
| 65 | +an undefined named volume is still caught. A `bind` source (a path) needs no |
| 66 | +declaration, as today. |
| 67 | + |
| 68 | +### Emit (`emit._volume_flags`) |
| 69 | + |
| 70 | +A mapping entry emits `--mount` (the short-string `-v` path is unchanged): |
| 71 | + |
| 72 | +- `type: bind` → `--mount type=bind,source=<S>,target=<T>[,ro]`; `S` resolved |
| 73 | + against `project_dir` when relative, reusing the short-form bind logic. |
| 74 | +- `type: volume` with `source` → `--mount type=volume,source=<S>,target=<T>[,ro]`; |
| 75 | + without `source` → `--mount type=volume,target=<T>` (anonymous). |
| 76 | +- `type: tmpfs` → `--mount type=tmpfs,target=<T>`. |
| 77 | +- `read_only: true` appends `,ro`; false/absent omits it (coerced via |
| 78 | + `values.as_bool`, so a quoted `"false"` does not leak `ro`). |
| 79 | + |
| 80 | +The `--mount` value is a single comma-joined `Expand` token, so a `${VAR}` in |
| 81 | +`source`/`target` interpolates at run time exactly as the short form's does. |
| 82 | + |
| 83 | +## Non-goals |
| 84 | + |
| 85 | +- **Nested `bind`/`volume`/`tmpfs` option maps** (`propagation`, `subpath`, |
| 86 | + `tmpfs.size/mode`, `nocopy`) and the **`image` type** — scope A leaves these |
| 87 | + refused; tracked as deferred parser gaps (`planning/deferred.md`), not |
| 88 | + rule-two refusals — podman can express all of them (`--mount |
| 89 | + type=image,...` succeeds) except `volume.nocopy`, which would be a genuine |
| 90 | + rule-two refusal anyway (podman: `invalid mount option`). |
| 91 | +- **`cluster`/`npipe` types** — permanent rule-two refusals (podman: `invalid |
| 92 | + filesystem type`). |
| 93 | +- **`-v`-vs-`--mount` for the short form** — the short string form keeps `-v`. |
| 94 | + |
| 95 | +## Testing (TDD; Docker + podman oracles) |
| 96 | + |
| 97 | +- **parsing**: accept each type (`bind` with source, `volume` with/without |
| 98 | + source, `tmpfs`); reject a missing `target`, a `bind` without `source`, a |
| 99 | + `cluster`/`npipe`/`image` type, a nested option map, an unknown key; accept |
| 100 | + `read_only: "yes"`; the short string form still validates. |
| 101 | +- **emit**: each type renders the right `--mount` value; a relative `bind` |
| 102 | + source resolves against `project_dir`; `read_only: true`→`,ro`, |
| 103 | + `false`→no `ro`; a `${VAR}` source interpolates. |
| 104 | +- **references**: a long-form `{type: volume, source: undefined}` is still caught |
| 105 | + by the undefined-named-volume check; a declared/auto-created source passes. |
| 106 | +- **conformance**: `volumes_long_form.yaml` flips over-reject → both-accept, with |
| 107 | + a dedicated `both-accept` assertion; an integration test on real podman |
| 108 | + (a long-form bind mount round-trips a file into the container). |
| 109 | +- **promotion**: `architecture/supported-subset.md` (volumes long form) and |
| 110 | + `planning/deferred.md` (the "Long-form volumes" bullet narrows to the nested |
| 111 | + option maps). |
| 112 | +- `just test-ci` @ 100% coverage, `just lint-ci`, `just check-planning`, |
| 113 | + `just test-conformance`. |
| 114 | + |
| 115 | +## Risk |
| 116 | + |
| 117 | +- **`--mount` value assembled wrong** (low × high): a misordered or mis-joined |
| 118 | + option string fails on podman. Mitigated by the integration test (real podman |
| 119 | + round-trip) and per-type emit unit tests. |
| 120 | +- **A long-form source escapes the named-volume reference check** (low × med): |
| 121 | + covered by the undefined-source reference test; the walker change is the one |
| 122 | + place both short and long forms feed the check. |
| 123 | +- **`type` optionality mis-measured** (low × low): measured — `type` is required |
| 124 | + (a type-less `{source, target}` is refused by Docker), so requiring it is exact |
| 125 | + parity, not an over-reject. |
0 commit comments