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
7 changes: 7 additions & 0 deletions architecture/supported-subset.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ warns (ignored, behavior-neutral inside a single pod) or raises
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.

A single absolute container path with no `source:target` (e.g.
`- /var/cache/models`) is accepted as an **anonymous volume** and emitted
verbatim as `-v <path>` — podman creates an anonymous volume at that path (the
common way to shadow a subdirectory of a bind mount). No host-path translation
is applied, since the entry names a container path, not a host source. A
colon-less entry that is not absolute (e.g. `./cache`) is malformed and raises.

## depends_on

All three conditions are honored: `service_started`, `service_healthy`,
Expand Down
4 changes: 4 additions & 0 deletions compose2pod/emit.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ def run_flags(name: str, svc: dict[str, Any], pod: str, hosts: list[str], projec
for env_file in env_files:
flags += ["--env-file", str(Path(project_dir, env_file))]
for volume in svc.get("volumes") or []:
if ":" not in volume:
# Anonymous volume: a bare container path, no host source to translate.
flags += ["-v", volume]
continue
source, destination = volume.split(":", 1)
if not source.startswith("/"):
source = str(Path(project_dir, source))
Expand Down
6 changes: 6 additions & 0 deletions compose2pod/parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ def _validate_service_volumes(name: str, svc: dict[str, Any]) -> None:
if not isinstance(volume, str):
msg = f"service {name!r}: only short volume syntax is supported"
raise UnsupportedComposeError(msg)
if ":" not in volume:
# Anonymous volume: must be an absolute container path.
if not volume.startswith("/"):
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)"
Expand Down
58 changes: 58 additions & 0 deletions planning/changes/2026-07-08.02-anonymous-volumes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
---
summary: Emit absolute anonymous volumes as `-v <path>` instead of crashing; reject non-absolute colon-less entries loudly.
---

# Change: Support anonymous volumes

**Lane:** lightweight — one source file (`emit.py`), a resolved design choice,
two tests, plus the capability-doc promotion.

## Goal

A Compose short-form volume can be a single container path with no
`source:target` (an *anonymous volume*, e.g. `- /var/cache/models`). Such an
entry passes `validate()` (its path starts with `/`) but crashes
`emit.run_flags` at `volume.split(":", 1)` with
`ValueError: not enough values to unpack (expected 2, got 1)`. This shipped in
0.1.2 and breaks real compose files that use anonymous volumes to shadow
subdirectories of a bind mount. Fix: emit an anonymous volume as `-v <path>`.

## Approach

In `run_flags` (`compose2pod/emit.py`), branch on whether the volume string
contains a `:`. No colon → anonymous volume: append `["-v", volume]` verbatim
(no `project_dir` translation — an anonymous volume names a container path, not
a host source) and continue. With a colon → the existing bind-mount path
(source translated against `project_dir` when relative).

Design choice (resolved): **support**, not reject. Anonymous volumes are valid
Compose; podman's `-v <path>` creates an anonymous volume at that path, which is
exactly the compose semantics. Rejecting in `validate()` would fix the crash but
break compose files that rely on them.

`_validate_service_volumes` also gains a guard: a colon-less entry that is *not*
absolute (e.g. `./cache`) is malformed Compose and now raises
`UnsupportedComposeError` rather than slipping through to emit a broken
`-v ./cache`. This keeps the project's "refuse loudly, never emit a silently
broken script" contract. Absolute anonymous volumes (`/var/cache/models`)
continue to be accepted.

Promote into `architecture/supported-subset.md`: the Volumes section currently
says "Short bind-mount syntax only (`source:target`)" — extend it to note that a
single absolute container path is accepted as an anonymous volume and emitted as
`-v <path>`.

## Files

- `compose2pod/emit.py` — `run_flags`: handle the no-colon (anonymous) volume case.
- `tests/test_emit.py` — anonymous volume emits `-v <path>`; regression for the crash.
- `tests/test_parsing.py` — anonymous volume passes `validate()` (pins the accept).
- `architecture/supported-subset.md` — Volumes section promotion.

## Verification

- [ ] Failing test first: `run_flags` with `volumes: ["/var/cache/models"]` — expected RED `ValueError: not enough values to unpack`.
- [ ] Apply the `emit.py` branch.
- [ ] Test passes: the emitted flags contain `["-v", "/var/cache/models"]`.
- [ ] `just test-ci` — full suite green at 100% line coverage.
- [ ] `just lint-ci` — clean (ruff ALL, ruff format, ty, eof-fixer, planning check).
32 changes: 32 additions & 0 deletions planning/releases/0.1.3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# compose2pod 0.1.3 — anonymous volumes no longer crash

A patch release fixing a crash on Compose files that use anonymous volumes. A
short-form volume that is a single container path with no `source:target` (e.g.
`- /var/cache/models`) passed validation but then crashed the emitter with
`ValueError: not enough values to unpack`. Such files now convert.

## Fix

- **Anonymous volumes are emitted as `-v <path>`.** A colon-less volume entry
is a Compose anonymous volume; it is now rendered verbatim as `podman run -v
<path>` (podman creates an anonymous volume at that container path) instead of
crashing `run_flags`. No host-path translation is applied — the entry names a
container path, not a host source.
- **Malformed colon-less volumes are rejected loudly.** A colon-less entry that
is not absolute (e.g. `./cache`) is invalid Compose and now raises a clear
`unsupported`-style error instead of silently emitting a broken `-v ./cache`,
keeping compose2pod's "refuse loudly, never emit a silently broken script"
contract.

## Downstream

No action needed — additive and backward compatible. Compose files that
previously crashed with `ValueError: not enough values to unpack` on an
anonymous volume now emit a pod script; nothing that converted before changes.

## Internals

- `architecture/supported-subset.md` documents anonymous volumes in the Volumes
matrix (absolute accepted and emitted as `-v <path>`; non-absolute raises).
- 89 tests at 100% line coverage (enforced); `ruff select=ALL`, `ty`, and
`eof-fixer` clean.
4 changes: 4 additions & 0 deletions tests/test_emit.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ def test_absolute_volume_source_is_kept_as_is(self) -> None:
flags = run_flags("app", {"image": "x", "volumes": ["/data/app:/srv/www/"]}, "p", [], "/builds/x")
assert flags[4:6] == ["-v", "/data/app:/srv/www/"]

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_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
7 changes: 7 additions & 0 deletions tests/test_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ def test_long_volume_syntax_raises(self) -> None:
with pytest.raises(UnsupportedComposeError, match="short volume syntax"):
validate(compose)

def test_anonymous_volume_is_accepted(self) -> None:
assert validate({"services": {"app": {"image": "x", "volumes": ["/var/cache/models"]}}}) == []

def test_relative_anonymous_volume_raises(self) -> None:
with pytest.raises(UnsupportedComposeError, match="absolute"):
validate({"services": {"app": {"image": "x", "volumes": ["./cache"]}}})

def test_no_services_raises(self) -> None:
with pytest.raises(UnsupportedComposeError, match="no services"):
validate({"services": {}})
Expand Down