diff --git a/architecture/supported-subset.md b/architecture/supported-subset.md index 2088007..77165f7 100644 --- a/architecture/supported-subset.md +++ b/architecture/supported-subset.md @@ -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 ` — 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`, diff --git a/compose2pod/emit.py b/compose2pod/emit.py index 149aad6..d928775 100644 --- a/compose2pod/emit.py +++ b/compose2pod/emit.py @@ -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)) diff --git a/compose2pod/parsing.py b/compose2pod/parsing.py index 1747940..cf2a9c5 100644 --- a/compose2pod/parsing.py +++ b/compose2pod/parsing.py @@ -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)" diff --git a/planning/changes/2026-07-08.02-anonymous-volumes.md b/planning/changes/2026-07-08.02-anonymous-volumes.md new file mode 100644 index 0000000..df307ea --- /dev/null +++ b/planning/changes/2026-07-08.02-anonymous-volumes.md @@ -0,0 +1,58 @@ +--- +summary: Emit absolute anonymous volumes as `-v ` 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 `. + +## 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 ` 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 `. + +## Files + +- `compose2pod/emit.py` — `run_flags`: handle the no-colon (anonymous) volume case. +- `tests/test_emit.py` — anonymous volume emits `-v `; 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). diff --git a/planning/releases/0.1.3.md b/planning/releases/0.1.3.md new file mode 100644 index 0000000..2c1a94f --- /dev/null +++ b/planning/releases/0.1.3.md @@ -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 `.** A colon-less volume entry + is a Compose anonymous volume; it is now rendered verbatim as `podman run -v + ` (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 `; non-absolute raises). +- 89 tests at 100% line coverage (enforced); `ruff select=ALL`, `ty`, and + `eof-fixer` clean. diff --git a/tests/test_emit.py b/tests/test_emit.py index b6816ab..497460e 100644 --- a/tests/test_emit.py +++ b/tests/test_emit.py @@ -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"] diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 975e103..5ff0f29 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -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": {}})