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
18 changes: 16 additions & 2 deletions architecture/supported-subset.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,22 @@ 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 <name>:<target>` — 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. 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
Expand Down
5 changes: 4 additions & 1 deletion compose2pod/emit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 6 additions & 5 deletions compose2pod/parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}


Expand All @@ -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]:
Expand Down Expand Up @@ -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"
Expand Down
109 changes: 109 additions & 0 deletions planning/changes/2026-07-08.04-named-volumes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
---
summary: Accept named volumes (top-level `volumes:` declarations and service-level named-volume references), emitted as plain podman `-v <name>:<target>`.
---

# 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 <name>:<target>`, 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 <name>:...`, 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 <name>:<target>`.

### 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 <name>:<target>` 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.
23 changes: 23 additions & 0 deletions tests/test_emit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -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",
Expand Down
11 changes: 8 additions & 3 deletions tests/test_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}]}}}
Expand All @@ -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": {
Expand Down