diff --git a/AGENTS.md b/AGENTS.md index 45fca21..226fbf5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,23 +6,23 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co `compose2pod` is a stdlib-only Python package and CLI that converts a Docker Compose document into a POSIX `sh` script running one service and its dependencies as a single Podman pod; -[`CONTEXT.md`](CONTEXT.md) opens with what it does and owns the vocabulary — read it before naming a +[`CONTEXT.md`](CONTEXT.md) opens with what it does and owns the vocabulary. Read it before naming a concept in code, a test name, or an issue title. ## Commands -`just` (task runner) and `uv` (package manager). The [`justfile`](justfile) is the source of truth — -`just --list`, or read it. Every recipe carries its intent as a comment. The one thing it does not -say: **never run bare `ruff check`** — `[tool.ruff]` sets `fix = true` and `unsafe-fixes = true`, so +`just` (task runner) and `uv` (package manager). The [`justfile`](justfile) is the source of truth: +run `just --list`, or read it. Every recipe carries its intent as a comment. The one thing it does not +say: **never run bare `ruff check`**. `[tool.ruff]` sets `fix = true` and `unsafe-fixes = true`, so an unqualified invocation rewrites the tree. ## Architecture The pipeline is `read → resolve_extends → validate → emit_script`, one module per stage, each named -for what it does; read them. What reading a single module will **not** tell you: +for what it does; read them. What reading a single module will not tell you: -- `validate()` (`parsing.py`) is the gate, but it is not the only door: `emit._plan` — the single - traversal both public entry points (`emit_script`, `referenced_variables`) project from — calls +- `validate()` (`parsing.py`) is the gate, but it is not the only door: `emit._plan`, the single + traversal both public entry points (`emit_script`, `referenced_variables`) project from, calls `validate()` itself, so a library caller cannot reach either with a document the gate would reject. That call site is load-bearing, not defensive. - The `SERVICE_KEYS` / `STRUCTURAL_KEYS` split in `keys.py` is a design ruling about which keys can @@ -45,7 +45,7 @@ description, and PyPI does not rewrite relative links, so a relative one 404s on ## Code Style -- The core package stays **zero-dependency**; PyYAML is the optional `[yaml]` extra and nothing else +- The core package stays zero-dependency; PyYAML is the optional `[yaml]` extra and nothing else is added. - Commit messages: conventional-commit subjects, no `Co-authored-by` trailer. diff --git a/CONTEXT.md b/CONTEXT.md index 8d20ddf..7d7d9c1 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -9,20 +9,20 @@ Built for CI and test environments where neither `docker compose` nor A term is listed only when there is a synonym to reject, or a meaning subtle enough that code, tests and docs must agree on it. General programming vocabulary does not -belong here, however heavily this package uses it — nor does anything a module +belong here, however heavily this package uses it, and neither does anything a module docstring already states in its first lines. **Service-key registry**: `SERVICE_KEYS` in `keys.py`: the table mapping each *declarative* Compose service key -to its `KeySpec` — the `(validate, emit, merge)` triple saying how that key is +to its `KeySpec`: the `(validate, emit, merge)` triple saying how that key is checked, how it renders to `podman run` flags, and (for list/map-shaped keys) how it merges across `extends`. It is the single source both the gate (`validate`) and the -emitter (`run_flags`) derive from, so the two cannot drift apart; that single-sourcing -is the whole point of the table, not an incidental property of it. +emitter (`run_flags`) derive from, so the two cannot drift apart. That single-sourcing +is the reason the table exists. **Structural key**: A supported service key handled *outside* the service-key registry, listed in -`STRUCTURAL_KEYS`, because the `emit(value)` interface cannot express it — it needs +`STRUCTURAL_KEYS`, because the `emit(value)` interface cannot express it: it needs `project_dir` (`env_file`, `volumes`), spans keys, or occupies the image/command slot (`entrypoint`). Structural keys keep their own validate/emit machinery, in the module that owns the concern. Which side of this line a key falls on is a design ruling, not @@ -32,8 +32,8 @@ a convenience: see **Token**: The result of rendering one Compose value into a `podman run`/`pod create` argument: `Token = str | Expand | GuardedEnvFile` in `keys.py`. A `str` is already shell-safe -and final; the other two members are deferred — they carry something the *generated -script*, not compose2pod, resolves. Anything walking a flag list has to handle all +and final; the other two members are deferred: they carry something that the generated +script resolves, not compose2pod. Anything walking a flag list has to handle all three. **Expand**: @@ -58,7 +58,7 @@ The two directions of the Docker-rejection parity rule ([ADR-0006](docs/adr/0006-docker-rejection-parity.md)). "Rule two" is named bare in `parsing.py` comments and in tests, with no restatement at the call site. **Rule one**: a document `docker compose config` rejects, compose2pod -rejects too — hard, no exceptions. **Rule two**: a document Docker accepts, +rejects too, as a hard rule with no exceptions. **Rule two**: a document Docker accepts, compose2pod accepts whenever podman can express it. That means every *supported* podman, from the declared floor to the newest version measured, so a mount option only a later podman has is refused until the floor reaches it. Where podman cannot @@ -75,15 +75,15 @@ here into one pattern. The *name grammar* (`values.TOP_LEVEL_NAME`, `[a-zA-Z0-9._-]+`) is what a top-level `services`/`volumes`/`secrets`/`configs` key and a service's long-form `networks` key must match. The *discriminator* (`parsing._is_named_volume_source`) asks a different thing of a short-form -volume source — named volume, or bind? — and does not use the grammar at all: +volume source (named volume, or bind?) and does not use the grammar at all: a leading `.`, `/` or `~` is a host path, anything else is a volume name. Say which one you mean; one pattern doing both jobs is the bug [ADR-0006](docs/adr/0006-docker-rejection-parity.md) records. Neither covers the -top-level `name`, which Docker does not judge at all — it *rewrites* it and +top-level `name`, which Docker does not judge at all; it rewrites it and refuses only an empty result (`parsing._validate_project_name`). **Store**: -The umbrella noun for a Compose `secret` or `config` — the two `StoreKind`s in +The umbrella noun for a Compose `secret` or `config`: the two `StoreKind`s in `stores.py`. Use *store* for anything true of both, which is nearly everything. *Secret* is reserved for the podman primitive both kinds render as, and is spoken only inside `stores.py`; elsewhere it would read as the Compose `secrets:` key and diff --git a/README.md b/README.md index 5e4c422..7e80942 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) [![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty) -Convert a Docker Compose file into a POSIX `sh` script that runs its services as a **single Podman pod**. +Convert a Docker Compose file into a POSIX `sh` script that runs its services as a single Podman pod. Built for CI and test environments where you can't use `docker compose` or `podman kube play`: diff --git a/docs/adr/0003-the-shared-namespace-decides-key-classification.md b/docs/adr/0003-the-shared-namespace-decides-key-classification.md index 7566add..3f59dbb 100644 --- a/docs/adr/0003-the-shared-namespace-decides-key-classification.md +++ b/docs/adr/0003-the-shared-namespace-decides-key-classification.md @@ -4,14 +4,14 @@ Every service runs in one pod sharing `net`, `uts`, `ipc` and `cgroup`, and the namespace, with localhost discovery through a bind-mounted hosts file, is the reason the tool exists. Keys that pull a container out of it (`network_mode`, `external_links`) are refused permanently: `network_mode` moves the container to another namespace, and `external_links` names -a container the generated script never creates, which the pod's hosts file -- where every name -compose2pod writes resolves to `127.0.0.1` -- has no address for, `extra_hosts` being the +a container the generated script never creates. The pod's hosts file, where every name +compose2pod writes resolves to `127.0.0.1`, has no address for it; `extra_hosts` is the supported way to name one. This clause once also swept up `links` and `expose`, which arrived in the same sentence and belong in neither category ([#120](https://github.com/modern-python/compose2pod/issues/120), measured against -`docker compose config` v5.1.2). `links` normalises to a `depends_on` edge plus a hostname alias --- docker refuses `links: [ghost]` exactly as it refuses a ghost `depends_on`, and a self-link as -a cycle -- so it neither escapes the namespace nor is satisfied by it, and ignoring it would drop +`docker compose config` v5.1.2). `links` normalises to a `depends_on` edge plus a hostname alias. +docker refuses `links: [ghost]` exactly as it refuses a ghost `depends_on`, and a self-link as +a cycle, so `links` neither escapes the namespace nor is satisfied by it, and ignoring it would drop a dependency the `--target` closure is built from. Both halves were mechanisms compose2pod already had, which is why the tracked limitation was expected to shrink and did ([#132](https://github.com/modern-python/compose2pod/issues/132)): the edge joins the mapping @@ -27,6 +27,6 @@ and conflict-checked across the closure onto `podman pod create`, because a cont the pod owns neither namespace and podman rejects the per-container flag. `stop_signal` and `stop_grace_period` are accepted but inert for a different reason, in that same table with a warning: the script tears down with `pod rm -f` and never runs `podman stop`, so the flags would -set metadata nothing consults. Inert is not unchecked -- every key in the table keeps docker's own +set metadata nothing consults. Inert is not unchecked: every key in the table keeps docker's own shape rule at the gate, since a document carrying a malformed one is a document docker will not run. diff --git a/docs/adr/0006-docker-rejection-parity.md b/docs/adr/0006-docker-rejection-parity.md index 38568f2..88c91eb 100644 --- a/docs/adr/0006-docker-rejection-parity.md +++ b/docs/adr/0006-docker-rejection-parity.md @@ -1,130 +1,147 @@ # Docker's refusals bind; podman decides what we can accept -Two rules, one direction each. A document `docker compose config` rejects, compose2pod rejects: -`accepted(compose2pod) ⊆ accepted(docker)`, because the tool is a drop-in for `docker compose` on -rootless runners and accepting a file Docker refuses turns a hard error into a green CI run. A -document Docker accepts, compose2pod accepts whenever podman can express it inside a pod. Where -podman cannot, that is a legitimate refusal (`sysctls: ["a"]` with no value; -`volumes: ["a"]`, which podman rejects as a relative mount target; a short-form volume entry Docker -reads as an anonymous volume whose target is not absolute -- a leading single letter is a drive -marker to Docker, never a volume name, so `C:\data`, `v:/data` and `v:` all ask for a container path -podman refuses outright, leaving the long form as the way to name a one-character volume), and where -compose2pod merely does not parse a form yet, that is a tracked limitation, never a design position. -The two are held apart in the refusal messages as well as here: a drive-shaped entry Docker reads as -a *bind* is refused for the short form's own limit, not for podman's, and says so. A refusal this -rule does not reach is one this rule must not claim: `network_mode` is refused because honouring it -pulls a container out of the pod's shared namespace -([ADR-0003](0003-the-shared-namespace-decides-key-classification.md)), which podman expresses -perfectly well even inside a pod (measured, 4.9.3). It carries a refusal site of its own that says -so, rather than the generic unsupported-key message, so the distinction reaches the user who hits -it and not only the reader of this file. -A second refusal cites neither Docker nor podman: a name the pod's `/etc/hosts` cannot -spell. compose2pod writes that file itself under `--no-hosts`, and its grammar is -`ADDRESS NAME...`, fields split on ASCII blanks and `#` opening a comment, so a name -carrying either is read back as something the document never asked for -- two names, a -truncated one, or, for a newline, an extra entry at an address the author chose -([#142](https://github.com/modern-python/compose2pod/issues/142)). Docker takes all of -them, because it answers names from a DNS server and never spells one in a file. The -check sits at the gate, in the two places a *document* can put a field there: -`graph.hostnames` for every name a service is reachable by, and -`pod.validate_pod_options` for both halves of an `extra_hosts` entry -- the address as -much as the host, since a newline in the address is what appends an entry at an address -the document chose. The pod name reaches the same file from the CLI rather than the -document, and is held to `POD_NAME_PATTERN`, which admits no separator either. -Each refusal names the service and the key that declared the field, because seven -sources reach one file and the file cannot say which. One of the seven, a service's own -name, Docker refuses under its own grammar, so there this is rule one arriving early; -[#143](https://github.com/modern-python/compose2pod/issues/143) is what gives that case -the message it deserves. Non-ASCII whitespace is deliberately left through: no libc reading the file -treats it as a separator, so refusing it would be a narrowing the format does not force. -Like `network_mode` this claims nothing about podman and so owes no `REFUSALS` row; -unlike `network_mode` what would lift it is mechanical rather than a position, a -resolver that is not a file. -Docker's verdict binds only on the document, not the host: `env_file` existence, `${VAR:?}`, and a -negative on a top-level numeric key are facts about the machine that runs the script and are -deferred to it. `tests/conformance/` runs both oracles for real over a probe matrix generated -from `SERVICE_KEYS | STRUCTURAL_KEYS | IGNORED_SERVICE_KEYS`, so a new key is probed the moment -it is added, and `tests/integration/refusals.py` measures the other side, pairing each volume-family -refusal above with the `--mount` that expresses what Docker says the document means, so a claim that -podman cannot express something cannot go stale unnoticed either. It carries four verdicts, because -four kinds of claim need four experiments: `REFUSALS` for the mounts podman will not make, -`LIMITATIONS` for the ones it would (which is what keeps a limitation from quietly reading as rule -two), and, where the claim is about podman's flag surface rather than a mount, `ABSENT_FLAGS` for a -flag podman does not have and `STUB_FLAGS` for one it has that validates nothing -- a flag accepting -`nonsense` is a worse reason to emit it than a flag that fails, since the script would report -success for something it never did. `tests/integration/acceptances.py` runs the -mirror image, every flag a long-form mount compiles to, because a rule-two claim fails in both -directions: #104 and #114 each shipped a form the gate accepted and podman would not run. What keeps -the two in step is `compose2pod/podman.py`: every refusal whose reason is podman's behaviour draws -its clause from there, and `tests/test_podman_claim_coverage.py` pairs each site that makes a claim -with the row that measures it, in both directions. The handle is the attribute name, not the -wording, because prose is not a registry -- a message is assembled from an f-string, a shared -preamble or a lookup table depending on the site. A refusal that makes no claim is out of scope by +There are two rules, one for each direction. Rule one: a document `docker compose config` rejects, +compose2pod rejects, so `accepted(compose2pod) ⊆ accepted(docker)`. The tool is a drop-in for +`docker compose` on rootless runners, and accepting a file Docker refuses turns a hard error into a +green CI run. Rule two: a document Docker accepts, compose2pod accepts whenever podman can express +it inside a pod. + +Where podman cannot express it, the refusal is legitimate. Examples are `sysctls: ["a"]` with no +value; `volumes: ["a"]`, which podman rejects as a relative mount target; and a short-form volume +entry Docker reads as an anonymous volume whose target is not absolute. To Docker a leading single +letter is a drive marker, never a volume name, so `C:\data`, `v:/data` and `v:` all ask for a +container path podman refuses outright, and the long form is the way to name a one-character +volume. Where compose2pod merely does not parse a form yet, that is a tracked limitation, never a +design position. The refusal messages keep the two apart as well: a drive-shaped entry Docker +reads as a bind is refused for the short form's own limit, not for podman's, and the message says +so. + +This rule must not claim a refusal it does not reach. `network_mode` is refused because honouring +it pulls a container out of the pod's shared namespace +([ADR-0003](0003-the-shared-namespace-decides-key-classification.md)), and podman expresses that +perfectly well even inside a pod (measured, 4.9.3). It has a refusal site of its own that says so, +instead of the generic unsupported-key message, so the distinction reaches the user who hits it and +not only the reader of this file. + +A second refusal cites neither Docker nor podman: a name the pod's `/etc/hosts` cannot spell. +compose2pod writes that file itself under `--no-hosts`. Its grammar is `ADDRESS NAME...`, with +fields split on ASCII blanks and `#` opening a comment, so a name carrying either is read back as +something the document never asked for: two names, a truncated one, or, for a newline, an extra +entry at an address the author chose ([#142](https://github.com/modern-python/compose2pod/issues/142)). +Docker takes all of them, because it answers names from a DNS server and never spells one in a +file. + +The check sits at the gate, in the two places a document can put a field there. `graph.hostnames` +checks every name a service is reachable by, and `pod.validate_pod_options` checks both halves of +an `extra_hosts` entry. The address is checked as much as the host, since a newline in the address +is what appends an entry at an address the document chose. The pod name reaches the same file from +the CLI instead of the document, and is held to `POD_NAME_PATTERN`, which admits no separator +either. + +Each refusal names the service and the key that declared the field, because seven sources reach one +file and the file cannot say which. Docker refuses one of the seven, a service's own name, under its +own grammar, so in that case this check is rule one arriving early; +[#143](https://github.com/modern-python/compose2pod/issues/143) is what gives that case the message +it deserves. Non-ASCII whitespace is deliberately let through: no libc reading the file treats it as +a separator, so refusing it would be a narrowing the format does not force. Like `network_mode`, +this refusal claims nothing about podman and so owes no `REFUSALS` row. Unlike `network_mode`, what +would lift it is mechanical and not a position: a resolver that is not a file. + +Docker's verdict binds only on the document, not the host. `env_file` existence, `${VAR:?}`, and a +negative on a top-level numeric key are facts about the machine that runs the script, and are +deferred to it. + +`tests/conformance/` runs both oracles for real over a probe matrix generated from +`SERVICE_KEYS | STRUCTURAL_KEYS | IGNORED_SERVICE_KEYS`, so a new key is probed the moment it is +added. `tests/integration/refusals.py` measures the other side. It pairs each volume-family refusal +above with the `--mount` that expresses what Docker says the document means, so a claim that podman +cannot express something cannot go stale unnoticed either. It carries four verdicts, because four +kinds of claim need four experiments. `REFUSALS` covers the mounts podman will not make. +`LIMITATIONS` covers the ones it would, which keeps a limitation from reading as rule two without +anyone noticing. Where the claim is about podman's flag surface and not a mount, `ABSENT_FLAGS` +covers a flag podman does not have and `STUB_FLAGS` one it has that validates nothing. A flag +accepting `nonsense` is a worse reason to emit it than a flag that fails, since the script would +report success for something it never did. `tests/integration/acceptances.py` runs the mirror +image, every flag a long-form mount compiles to, because a rule-two claim fails in both directions: +#104 and #114 each shipped a form the gate accepted and podman would not run. + +`compose2pod/podman.py` keeps the two in step. Every refusal whose reason is podman's behaviour +draws its clause from there, and `tests/test_podman_claim_coverage.py` pairs each site that makes a +claim with the row that measures it, in both directions. The handle is the attribute name and not +the wording, because prose is not a registry: depending on the site, a message is assembled from an +f-string, a shared preamble or a lookup table. A refusal that makes no claim is out of scope by construction, which is how `network_mode` needs no row. A refusal whose reason is podman's but -whose message keeps that to itself would be invisible to that gate, so the table closes it from -the other side: every `REFUSALS` row names a claim, because the table's premise is that podman -will not make the mount. That is what emptied -[#121](https://github.com/modern-python/compose2pod/issues/121)'s list, which is also why a -long-form `type: cluster` no longer refuses with the same message as a misspelled one -- docker -takes cluster and npipe, so refusing them is rule two and says so, while `bnid` is rule one and -podman has nothing to do with it. -Verdicts are per version, and the supported range is stated rather than implied: the rulings here -are measured against `docker compose config` v5.1.2 and podman 4.9.3, and compose2pod supports -podman 4.9 and up. Rule two reads across that whole range. A form is accepted only where podman -expresses it at the floor as well as at the newest release measured (6.1), so a mount option a -later podman adds is refused until the floor reaches it, and one that a later podman breaks is -refused too. That is one rule where the repo previously had two and stated neither: a nested -`subpath` is refused because podman adds it above the floor (5.1 for an image mount, 5.4 for a -named volume) ([#114](https://github.com/modern-python/compose2pod/issues/114)), and a float tmpfs -`mode` is refused at the other end of the range, where podman 6.0.1's `crun` will not mount it. -The `integration` job pins `ubuntu-24.04` for the same reason: it is the runner that ships the -floor, and on a newer one the job would measure a podman no user of the floor has. -The hard rule has no exceptions left. It has had four classes, and each of the last two -outlived a version of that sentence. Docker's identifier grammar, `[a-zA-Z0-9._-]+`, was -never modelled: a service, volume, secret or config could be named `a b`, `a/b` or `""`, -all of which `docker compose config` refuses outright and all of which compose2pod took -([#143](https://github.com/modern-python/compose2pod/issues/143)). One pattern in `values` -holds it now, applied where Docker applies it -- which is not where it reads: the keys of -top-level `services`, `volumes`, `secrets` and `configs`, and a service's *long-form* `networks` -mapping key, but neither a top-level `networks:` key nor a short-form list entry, each measured -rather than assumed and each pinned. The same pattern had a second job it was quietly bad at: -`stores.NAME_PATTERN` demanded an alphanumeric first character Docker does not, over-rejecting -`.a`, `-a` and `_a`, while doubling as the short-form bind-versus-named-volume test, which is not -a grammar question at all: Docker reads a leading `.`, `/` or `~` as a host path and every other -spelling as a volume name, `a/b` and `a b` included. One pattern could not be both -- widening it -would have read `.env:/app/.env` as a named volume, and leaving it would have kept `a/b:/x` -accepted where Docker says `refers to undefined volume`. They are two rules now, and the long -form is a third: there `type` has already said which kind the entry is, so any source names a -volume. -Why the harness never saw it: the generated matrix varies a key's *value* over hostile shapes and +whose message keeps that to itself would be invisible to that gate, so the table closes it from the +other side: every `REFUSALS` row names a claim, because the table's premise is that podman will not +make the mount. That is what emptied +[#121](https://github.com/modern-python/compose2pod/issues/121)'s list. It is also why a long-form +`type: cluster` no longer refuses with the same message as a misspelled one. Docker takes cluster +and npipe, so refusing them is rule two and says so, while `bnid` is rule one and podman has +nothing to do with it. + +Verdicts are per version, and the supported range is stated explicitly. The rulings here are +measured against `docker compose config` v5.1.2 and podman 4.9.3, and compose2pod supports podman +4.9 and up. Rule two reads across that whole range. A form is accepted only where podman expresses +it at the floor as well as at the newest release measured (6.1). A mount option a later podman adds +is therefore refused until the floor reaches it, and one that a later podman breaks is refused too. +That is one rule where the repo previously had two and stated neither. A nested `subpath` is +refused because podman adds it above the floor (5.1 for an image mount, 5.4 for a named volume) +([#114](https://github.com/modern-python/compose2pod/issues/114)), and a float tmpfs `mode` is +refused at the other end of the range, where podman 6.0.1's `crun` will not mount it. The +`integration` job pins `ubuntu-24.04` for the same reason: it is the runner that ships the floor, +and on a newer one the job would measure a podman no user of the floor has. + +The hard rule has no exceptions left. It has had four classes, and each of the last two outlived a +version of that sentence. + +Docker's identifier grammar, `[a-zA-Z0-9._-]+`, was never modelled. A service, volume, secret or +config could be named `a b`, `a/b` or `""`. `docker compose config` refuses all of them outright, +and compose2pod took all of them ([#143](https://github.com/modern-python/compose2pod/issues/143)). +One pattern in `values` holds it now, applied where Docker applies it, which is not everywhere +Docker reads a name. It covers the keys of top-level `services`, `volumes`, `secrets` and +`configs`, and a service's long-form `networks` mapping key. It covers neither a top-level +`networks:` key nor a short-form list entry. Each of these was measured, not assumed, and each is +pinned. + +The same pattern had a second job it did badly. `stores.NAME_PATTERN` demanded an alphanumeric +first character Docker does not, over-rejecting `.a`, `-a` and `_a`. It also doubled as the +short-form bind-versus-named-volume test, which is not a grammar question at all: Docker reads a +leading `.`, `/` or `~` as a host path and every other spelling as a volume name, `a/b` and `a b` +included. One pattern could not do both. Widening it would have read `.env:/app/.env` as a named +volume, and leaving it would have kept `a/b:/x` accepted where Docker says +`refers to undefined volume`. They are two rules now, and the long form is a third: there `type` +has already said which kind the entry is, so any source names a volume. + +The harness never saw it because the generated matrix varies a key's value over hostile shapes and never touches a map key, so no probe could reach a name. `test_identifier_grammar_matches_docker` -now generates the missing axis from the same tuple the gate reads. The fourth class turned -up on the sweep meant to confirm the third was the last, and it is a shape neither a -grammar nor a probe over hostile keys could have caught: Docker does not judge the -top-level `name`, it *rewrites* it, and refuses only when nothing survives, so -`name: "ä"` is a document it will not read. `parsing._validate_project_name` measures that rewrite. -Twice now "no exceptions left" has been written and then falsified, which is the argument -for the generated axes over the enumeration: what keeps the claim true is the probes, not -the list. -The first two exceptions were the same one: a `depends_on` -naming an undefined service, and a dependency cycle, among services outside the target's closure -were rejected by Docker and accepted here -([#87](https://github.com/modern-python/compose2pod/issues/87)), because both checks lived in the -closure walk `--target` drives and a service nothing targets is never walked. `graph.validate_graph` -walks from every service instead, so the verdict on the *document* no longer depends on which -service the caller asked to run -- which is Docker's own behaviour, and the only reading the hard -rule allows. The price is the reason the issue stayed open rather than something the fix hides: one -service's typo now refuses the file for every target in it. Both documents moved out of a residual -catalogue and into `tests/conformance/corpus/`, where `assert_rule` -- which raises on exactly that -combination -- is what holds them closed, so the rule is hard everywhere with nothing suspended. -One over-rejection is settled rather than open: the drive-qualified *bind* (`C:\data:/var`). -podman mounts that source through `--mount` and only the short `-v` spec cannot spell it, so the -capability is reachable today -- the long form emits exactly the invocation measured at exit 0, -and a `LIMITATIONS` row asserts it continuously. What was left was the ergonomic question, and -[#111](https://github.com/modern-python/compose2pod/issues/111) closed it as working as intended: -on Linux that spelling overwhelmingly means a Windows user pointed at a drive that is -not there, and falling back to `--mount` would bind a relative directory literally named -`C:\data`, surfacing much later as an empty mount instead of at once as an error naming the long -form. So the refusal is the tool, not a gap waiting to be filled. What would reopen it is a real -document wanting a colon in a short-form bind source on Linux, which is the evidence +now generates the missing axis from the same tuple the gate reads. + +The fourth class turned up on the sweep meant to confirm the third was the last. Neither a grammar +nor a probe over hostile keys could have caught its shape. Docker does not judge the top-level +`name`; it rewrites it, and refuses only when nothing survives, so `name: "ä"` is a document it +will not read. `parsing._validate_project_name` measures that rewrite. "No exceptions left" has now +been written and then falsified twice, which argues for the generated axes over the enumeration: +the probes keep the claim true, and the list does not. + +The first two exceptions were the same one. A `depends_on` naming an undefined service, and a +dependency cycle, among services outside the target's closure were rejected by Docker and accepted +here ([#87](https://github.com/modern-python/compose2pod/issues/87)). Both checks lived in the +closure walk `--target` drives, and a service nothing targets is never walked. +`graph.validate_graph` walks from every service instead, so the verdict on the document no longer +depends on which service the caller asked to run. That is Docker's own behaviour, and the only +reading the hard rule allows. Its price is the reason the issue stayed open, and the fix does not +hide it: one service's typo now refuses the file for every target in it. Both documents moved out +of a residual catalogue and into `tests/conformance/corpus/`, where `assert_rule`, which raises on +exactly that combination, holds them closed. The rule is hard everywhere, with nothing suspended. + +One over-rejection is settled: the drive-qualified bind (`C:\data:/var`). podman mounts that source +through `--mount`, and only the short `-v` spec cannot spell it, so the capability is reachable +today. The long form emits exactly the invocation measured at exit 0, and a `LIMITATIONS` row +asserts it continuously. What was left was the ergonomic question, and +[#111](https://github.com/modern-python/compose2pod/issues/111) closed it as working as intended. +On Linux that spelling overwhelmingly means a Windows user pointed at a drive that is not there. +Falling back to `--mount` would bind a relative directory literally named `C:\data`, which would +surface much later as an empty mount; the refusal fails at once with an error naming the long form. +So the refusal is the intended behaviour, not a gap waiting to be filled. What would reopen it is a +real document wanting a colon in a short-form bind source on Linux, which is the evidence [#86](https://github.com/modern-python/compose2pod/issues/86) never had.