|
| 1 | +--- |
| 2 | +summary: An explicit null service value is refused everywhere Docker refuses it (all keys but command/entrypoint/deploy), accepted where Docker accepts it, and treated as "not specified" by an extends merge so the other side's value survives. |
| 3 | +--- |
| 4 | + |
| 5 | +# Design: Match Docker's null-value policy |
| 6 | + |
| 7 | +## Summary |
| 8 | + |
| 9 | +compose2pod's treatment of an explicitly-null service value (`environment:` with |
| 10 | +nothing after it) was ad hoc — an accident of which keys happened to get a |
| 11 | +hand-written validator. It diverged from Docker on **13 keys**, in both |
| 12 | +directions, and `extends` refused a null that the gate accepted, so a document |
| 13 | +valid on its own became invalid through inheritance. |
| 14 | + |
| 15 | +One rule, taken from Docker: **an explicit null is refused for every service key |
| 16 | +except `command`, `entrypoint`, and `deploy`.** An `extends` merge treats a null |
| 17 | +side as "not specified", so the other side's value survives. |
| 18 | + |
| 19 | +## Motivation |
| 20 | + |
| 21 | +Measured against `docker compose config` (v5.1.2), key by key. Docker accepts an |
| 22 | +explicit null for exactly three service keys — `command`, `entrypoint`, `deploy` |
| 23 | +— and refuses it for every other one. compose2pod disagreed on 13: |
| 24 | + |
| 25 | +| | compose2pod | Docker | |
| 26 | +|---|---|---| |
| 27 | +| `entrypoint` | refuses | **accepts** | |
| 28 | +| `environment`, `env_file`, `volumes`, `tmpfs`, `healthcheck`, `depends_on`, `networks`, `hostname`, `container_name`, `secrets`, `configs`, `pull_policy`, `ulimits` | accepts | **refuses** | |
| 29 | + |
| 30 | +The split had no design behind it: `labels:` (null) was refused while |
| 31 | +`environment:` (null) was accepted, purely because `labels` routes through |
| 32 | +`validate_map` and `environment` had a validator that early-returned on `None`. |
| 33 | + |
| 34 | +Two consequences: |
| 35 | + |
| 36 | +- **A bare key is silently ignored.** `environment:` with its contents deleted |
| 37 | + emits no `-e` flags and says nothing. Docker refuses it, because it is nearly |
| 38 | + always a mistake. Quietly dropping it is the failure this project's gate |
| 39 | + exists to prevent. |
| 40 | +- **`extends` refused what the gate accepted.** Every mergeable key that |
| 41 | + tolerated a null standalone raised `cannot merge ... across incompatible |
| 42 | + forms` when that null met a real value on the other side — so a valid document |
| 43 | + became invalid by being inherited from. |
| 44 | + |
| 45 | +Docker's own `extends` does not do this: a null in the extending service is |
| 46 | +simply inherited over. Verified — a child declaring `environment:` and |
| 47 | +`volumes:` as nulls comes out of `docker compose config` with the base's values |
| 48 | +intact. |
| 49 | + |
| 50 | +## Design |
| 51 | + |
| 52 | +**The gate** grows one check in `_validate_service`, ahead of the per-key |
| 53 | +validators: a service key present with a `None` value raises, unless it is one |
| 54 | +of `command`/`entrypoint`/`deploy` (the three Docker allows, where null means |
| 55 | +"not specified") or an `x-` extension field (arbitrary user payload). One rule, |
| 56 | +derived from Docker, rather than 13 per-key decisions to keep in sync. |
| 57 | + |
| 58 | +`entrypoint` stops refusing a null, matching Docker and its sibling `command` |
| 59 | +(which already accepts one). |
| 60 | + |
| 61 | +**The merge** treats a null side as absent, so the other side's value survives: |
| 62 | + |
| 63 | +- null in the extending service → the base's value is inherited (Docker's |
| 64 | + behavior). |
| 65 | +- null in the base → the extending service's value wins. |
| 66 | +- null on both → null survives resolution and the gate refuses it, exactly as |
| 67 | + Docker refuses a null that no inheritance overwrites. |
| 68 | + |
| 69 | +This is what makes the invariant hold in both directions: a form the gate accepts |
| 70 | +standalone is accepted through `extends`, and a form it refuses standalone is |
| 71 | +refused through `extends` (`2026-07-14.05`). |
| 72 | + |
| 73 | +## Non-goals |
| 74 | + |
| 75 | +- Not changing what a null *means* where Docker allows it: `command:`, |
| 76 | + `entrypoint:`, and `deploy:` still mean "not specified" and emit nothing. |
| 77 | +- Not touching null handling inside a value (`environment: {KEY: null}` is a |
| 78 | + host-passthrough and stays exactly as it is). This is about a null *key value*. |
| 79 | + |
| 80 | +## Behavior change |
| 81 | + |
| 82 | +**Breaking, deliberately.** A document with a bare `environment:`, `volumes:`, |
| 83 | +`healthcheck:`, `depends_on:`, `secrets:`, `configs:`, `ulimits:`, `env_file:`, |
| 84 | +`tmpfs:`, `networks:`, `hostname:`, `container_name:` or `pull_policy:` now |
| 85 | +raises where it previously ran. Such a document is **not valid Compose** — |
| 86 | +`docker compose config` rejects it — so it only ever worked here by accident, |
| 87 | +and it silently did nothing. |
| 88 | + |
| 89 | +One over-rejection is lifted: `entrypoint:` (null) is now accepted, as Docker |
| 90 | +accepts it — and in an `extends` chain it resets the inherited entrypoint rather |
| 91 | +than inheriting it, again matching Docker. |
| 92 | + |
| 93 | +## Testing |
| 94 | + |
| 95 | +`just test-ci` at 100%. A parametrized test pins the gate's verdict against Docker's measured one for |
| 96 | +34 service keys, so the rule cannot drift back into an enumeration. It is a |
| 97 | +sample, not the whole key universe (56): the ignored keys, `image`/`build` and |
| 98 | +the numeric resource keys are not in the table, though each was checked against |
| 99 | +Docker by hand and behaves correctly -- the gate's rule is uniform, so the |
| 100 | +sample is what pins it. Emit-level tests pin the `command`/`entrypoint` |
| 101 | +reset — a merged null must not emit the base's argv — since asserting only that |
| 102 | +the document validates would green-light the wrong behavior. |
| 103 | + |
| 104 | +## Risk |
| 105 | + |
| 106 | +- **A user with a bare key gets an error.** Intended: the key did nothing, and |
| 107 | + Docker refuses it. The message names the key and says a null is not allowed. |
0 commit comments