From 8f9082c771e7190162c1f228d7b3f168224a37fc Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Wed, 8 Jul 2026 18:01:18 +0300 Subject: [PATCH 1/3] docs: plan named-volume support --- .../changes/2026-07-08.04-named-volumes.md | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 planning/changes/2026-07-08.04-named-volumes.md diff --git a/planning/changes/2026-07-08.04-named-volumes.md b/planning/changes/2026-07-08.04-named-volumes.md new file mode 100644 index 0000000..aa2450f --- /dev/null +++ b/planning/changes/2026-07-08.04-named-volumes.md @@ -0,0 +1,109 @@ +--- +summary: Accept named volumes (top-level `volumes:` declarations and service-level named-volume references), emitted as plain podman `-v :`. +--- + +# Design: Support named volumes + +## Summary + +compose2pod rejects the top-level `volumes:` key outright and rejects any +service-level volume whose source is not a host path (`.`/`/`-prefixed) — +i.e. a Compose named volume reference like `calutrondb:/var/lib/postgresql/data` +— with "named volume ... is not supported (bind mounts only)". A real compose +file uses a named volume for Postgres data persistence, which is idiomatic +Compose and cannot be worked around without editing the compose file. This +change accepts both: the top-level `volumes:` block is ignored (its +driver/options aren't read — podman creates the named volume implicitly on +first reference), and a service-level named-volume reference is emitted +verbatim as `-v :`, letting podman create and mount it exactly +as `podman run -v` already does outside Compose. + +## Motivation + +```yaml +services: + db: + volumes: + - calutrondb:/var/lib/postgresql/data +volumes: + calutrondb: + driver: local +``` + +Both the top-level block and the service reference are current hard errors. +Bind-mount workarounds exist, but the ask is to convert the compose file as +written — named volumes are first-class Compose syntax, not an edge case. + +## Design + +### 1. Ignore the top-level `volumes:` key (`parsing.py::validate`) + +Mirror the existing top-level `networks` treatment exactly: add a warning +when `"volumes" in compose`, do not add it to `SUPPORTED_TOP_LEVEL_KEYS` +(warnings and "supported-but-unread" are different categories already +established for `networks`). No content of the block (driver, driver_opts, +external) is read — podman creates a named volume implicitly the first time +it is referenced by a `podman run -v :...`, with default driver +semantics. Declaring `driver: local` (the default) has no observable effect; +a non-default driver would only matter if compose2pod called `podman volume +create` explicitly, which it does not and does not need to for this to work. + +### 2. Accept named-volume service references (`parsing.py::_validate_service_volumes`) + +Currently: a colon-containing volume's source must start with `.` or `/`, +else raise. New rule: a source that is neither `.`/`/`-prefixed **nor** a bind +mount is a named-volume reference — accept it unconditionally (no format +validation of the volume name; malformed names surface as a podman error at +run time, same as any other podman argument compose2pod passes through +unvalidated, e.g. image names). + +### 3. Emit named volumes without path translation (`emit.py::run_flags`) + +Currently every colon-containing volume's source is resolved against +`project_dir` when it doesn't start with `/`. A named volume's source is an +identifier, not a path — resolving `calutrondb` against `project_dir` would +corrupt it into `/proj/calutrondb`. Distinguish by the same `.`/`/`-prefix +check used in validation: if the source starts with `.` or `/`, treat as a +bind mount (existing behavior, translate relative sources); otherwise pass the +source through verbatim as `-v :`. + +### 4. Promote into `architecture/supported-subset.md` + +Update the Volumes section: named volumes are now accepted and passed through +to podman unvalidated; only the long mapping form still raises. + +## Non-goals + +- No explicit `podman volume create` step, no reading of `driver`/`driver_opts` + from the top-level block — podman's implicit creation on first `-v` reference + is sufficient and keeps this change small. +- No volume cleanup in the `EXIT` trap. Named volumes persist on the host after + the pod is removed (identical to `docker compose down` without `-v`, and to + plain `podman run -v name:...` outside any compose tooling) — this is + existing, well-understood podman/docker behavior, not a compose2pod-specific + surprise. +- No validation of the volume name's format (Docker's `[a-zA-Z0-9][\w.-]*`) — + compose2pod does not validate other pass-through identifiers (image names, + env values) either; podman will reject bad ones at run time. + +## Testing + +TDD, `just test-ci` at 100% line coverage. + +- Unit (`test_parsing.py`): a top-level `volumes:` key is accepted and warns. + `test_named_volume_raises` is repurposed to assert acceptance (the rejected + behavior it pinned is the one being removed). +- Unit (`test_emit.py` via `run_flags`): a named-volume reference emits + `-v :` verbatim, with no `project_dir` prefix — distinct from + the existing relative-bind-mount test, which must still translate. +- Integration (`test_emit.py`): the Motivation compose document round-trips + through `validate` → `emit_script`. + +## Risk + +Low-medium. The path-vs-name disambiguation (`.`/`/`-prefix) is shared, +tested logic already proven correct for the anonymous-volume change; the new +code is the mirror-image branch (treat as opaque identifier instead of path). +Main risk is a compose file using a *relative* named volume alias that +happens to start with neither `.` nor `/` but was intended as something else +— not a real Compose construct, so not a practical concern. From 42ddba3f49e35b5ba419002c64930465cbab78dd Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Wed, 8 Jul 2026 18:10:08 +0300 Subject: [PATCH 2/3] feat: accept named volumes (top-level volumes: and named-volume refs) A colon-containing service volume whose source is a bare identifier (not .-or-/-prefixed) is a Compose named volume, e.g. calutrondb:/var/lib/.... Previously rejected as unsupported; now accepted and emitted verbatim as '-v :' -- podman creates the named volume implicitly on first reference, matching plain 'podman run -v' semantics (confirmed against podman's --volume docs). The top-level volumes: block (driver/options) is accepted and ignored, mirroring the existing top-level networks treatment, since implicit creation with default options needs nothing read from it. --- architecture/supported-subset.md | 16 ++++++++++++++-- compose2pod/emit.py | 5 ++++- compose2pod/parsing.py | 11 ++++++----- tests/test_emit.py | 23 +++++++++++++++++++++++ tests/test_parsing.py | 11 ++++++++--- 5 files changed, 55 insertions(+), 11 deletions(-) diff --git a/architecture/supported-subset.md b/architecture/supported-subset.md index 5e8b338..d453e20 100644 --- a/architecture/supported-subset.md +++ b/architecture/supported-subset.md @@ -48,8 +48,20 @@ warns (ignored, behavior-neutral inside a single pod) or raises ## Volumes -Short bind-mount syntax only (`source:target`). The source must be a host path -(starts with `.` or `/`); named volumes and the long mapping form raise. +Short syntax only; the long mapping form raises. A `source:target` entry is +one of two kinds, told apart by whether `source` starts with `.` or `/`: + +- **Bind mount** (`source` starts with `.` or `/`): the host path, resolved + against `--project-dir` when relative. +- **Named volume** (`source` is a bare identifier, e.g. `pgdata:/var/lib/...`): + passed through verbatim as `-v :` — no format validation, no + path translation. Podman creates the named volume implicitly on first + reference (same as plain `podman run -v`), so no explicit `podman volume + create` step is needed. The volume persists on the host after the pod is + removed, identical to `docker compose down` without `-v`. The top-level + `volumes:` block (declaring drivers/options) is accepted but ignored — its + contents are never read, since implicit creation with default options is + sufficient. A single absolute container path with no `source:target` (e.g. `- /var/cache/models`) is accepted as an **anonymous volume** and emitted diff --git a/compose2pod/emit.py b/compose2pod/emit.py index d928775..881403c 100644 --- a/compose2pod/emit.py +++ b/compose2pod/emit.py @@ -62,8 +62,11 @@ def run_flags(name: str, svc: dict[str, Any], pod: str, hosts: list[str], projec flags += ["-v", volume] continue source, destination = volume.split(":", 1) - if not source.startswith("/"): + if source.startswith("."): + # Relative bind mount: resolve against project_dir. source = str(Path(project_dir, source)) + # Absolute bind mount (starts with "/") and named volume (bare + # identifier) are both kept as-is — neither is a path to translate. flags += ["-v", f"{source}:{destination}"] healthcheck = svc.get("healthcheck") or {} _add_health_flags(flags, healthcheck) diff --git a/compose2pod/parsing.py b/compose2pod/parsing.py index 090bcc8..4242ee8 100644 --- a/compose2pod/parsing.py +++ b/compose2pod/parsing.py @@ -22,7 +22,7 @@ } IGNORED_SERVICE_KEYS = {"ports", "restart", "stdin_open", "tty"} SUPPORTED_HEALTHCHECK_KEYS = {"test", "interval", "timeout", "retries", "start_period"} -SUPPORTED_TOP_LEVEL_KEYS = {"services", "version", "name", "networks"} +SUPPORTED_TOP_LEVEL_KEYS = {"services", "version", "name", "networks", "volumes"} DEPENDS_ON_CONDITIONS = {"service_started", "service_healthy", "service_completed_successfully"} @@ -48,10 +48,9 @@ def _validate_service_volumes(name: str, svc: dict[str, Any]) -> None: msg = f"service {name!r}: anonymous volume '{volume}' must be an absolute path" raise UnsupportedComposeError(msg) continue - source = volume.split(":", 1)[0] - if not source.startswith((".", "/")): - msg = f"service {name!r}: named volume '{source}' is not supported (bind mounts only)" - raise UnsupportedComposeError(msg) + # Colon-containing volume: bind mount (host path source) or named volume + # (bare identifier source) — both are accepted; podman creates a named + # volume implicitly on first reference. def _validate_service(name: str, svc: dict[str, Any]) -> list[str]: @@ -98,6 +97,8 @@ def validate(compose: dict[str, Any]) -> list[str]: raise UnsupportedComposeError(msg) if "networks" in compose: warnings.append("ignoring top-level 'networks' (all services share the pod namespace)") + if "volumes" in compose: + warnings.append("ignoring top-level 'volumes' (podman creates named volumes on first reference)") services = compose.get("services") or {} if not services: msg = "no services defined" diff --git a/tests/test_emit.py b/tests/test_emit.py index db3b203..fcc40c5 100644 --- a/tests/test_emit.py +++ b/tests/test_emit.py @@ -53,6 +53,11 @@ def test_anonymous_volume_emitted_as_single_path(self) -> None: flags = run_flags("app", {"image": "x", "volumes": ["/var/cache/models"]}, "p", [], "/builds/x") assert flags[4:6] == ["-v", "/var/cache/models"] + def test_named_volume_emitted_without_project_dir_translation(self) -> None: + svc = {"image": "x", "volumes": ["pgdata:/var/lib/postgresql/data"]} + flags = run_flags("db", svc, "p", [], "/builds/x") + assert flags[4:6] == ["-v", "pgdata:/var/lib/postgresql/data"] + def test_healthcheck_without_timeout_omits_health_timeout_flag(self) -> None: flags = run_flags("app", {"image": "x", "healthcheck": {"test": "true"}}, "p", [], "/builds/x") assert flags[4:6] == ["--health-cmd", "true"] @@ -184,6 +189,24 @@ def test_container_name_becomes_add_host_entry(self) -> None: script = emit_script(compose=compose, options=options) assert "--add-host calutron-ronline:127.0.0.1" in script + def test_named_volume_round_trips_through_validate_and_emit(self) -> None: + compose = { + "services": {"db": {"image": "postgres:16", "volumes": ["calutrondb:/var/lib/postgresql/data"]}}, + "volumes": {"calutrondb": {"driver": "local"}}, + } + options = EmitOptions( + target="db", + ci_image="ci", + command="", + pod="testpod", + project_dir="/proj", + artifacts=[], + allow_exit_codes=[], + ) + assert validate(compose) == ["ignoring top-level 'volumes' (podman creates named volumes on first reference)"] + script = emit_script(compose=compose, options=options) + assert "-v calutrondb:/var/lib/postgresql/data" in script + def test_target_without_command_uses_service_command(self, chats_compose: dict) -> None: options = EmitOptions( target="application", diff --git a/tests/test_parsing.py b/tests/test_parsing.py index cde9c4b..72da17f 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -26,9 +26,9 @@ def test_unsupported_healthcheck_key_raises(self) -> None: with pytest.raises(UnsupportedComposeError, match="start_interval"): validate(compose) - def test_named_volume_raises(self) -> None: - with pytest.raises(UnsupportedComposeError, match="pgdata"): - validate({"services": {"db": {"image": "x", "volumes": ["pgdata:/var/lib/postgresql/data"]}}}) + def test_named_volume_is_accepted(self) -> None: + compose = {"services": {"db": {"image": "x", "volumes": ["pgdata:/var/lib/postgresql/data"]}}} + assert validate(compose) == [] def test_long_volume_syntax_raises(self) -> None: compose = {"services": {"app": {"image": "x", "volumes": [{"type": "bind", "source": ".", "target": "/s"}]}}} @@ -54,6 +54,11 @@ def test_top_level_networks_is_ignored_with_warning(self) -> None: warnings = validate({"services": {"app": {"image": "x"}}, "networks": {"default": None}}) assert any("networks" in w for w in warnings) + def test_top_level_volumes_is_ignored_with_warning(self) -> None: + compose = {"services": {"app": {"image": "x"}}, "volumes": {"pgdata": {"driver": "local"}}} + warnings = validate(compose) + assert any("volumes" in w for w in warnings) + def test_service_healthy_dependency_without_healthcheck_raises(self) -> None: compose = { "services": { From fe8fab850b8baf41a4a200dd278caeec01e6121f Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Wed, 8 Jul 2026 18:13:38 +0300 Subject: [PATCH 3/3] docs: note named-volume driver/external limitation Per reviewer feedback: the top-level volumes: block's driver/driver_opts and external:true are never read, so a non-default driver or an external volume that should already exist has no effect -- podman always creates the volume implicitly with default options. --- architecture/supported-subset.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/architecture/supported-subset.md b/architecture/supported-subset.md index d453e20..254abe2 100644 --- a/architecture/supported-subset.md +++ b/architecture/supported-subset.md @@ -60,8 +60,10 @@ one of two kinds, told apart by whether `source` starts with `.` or `/`: create` step is needed. The volume persists on the host after the pod is removed, identical to `docker compose down` without `-v`. The top-level `volumes:` block (declaring drivers/options) is accepted but ignored — its - contents are never read, since implicit creation with default options is - sufficient. + contents are never read. This assumes a default-driver, non-`external` + volume; a non-default `driver`/`driver_opts` or `external: true` (which + Compose treats as "must already exist") has no effect, since podman always + creates the volume implicitly with default options on first reference. A single absolute container path with no `source:target` (e.g. `- /var/cache/models`) is accepted as an **anonymous volume** and emitted