Skip to content

Commit 8ba9033

Browse files
authored
fix: match Docker's null-value policy (#60)
* fix: match Docker's null-value policy compose2pod's treatment of an explicitly-null service value was ad hoc -- an accident of which keys happened to get a hand-written validator. Measured against `docker compose config` (v5.1.2) key by key, it diverged on 13, in both directions: entrypoint: compose2pod refused, Docker accepts environment, env_file, volumes, compose2pod accepted, Docker refuses tmpfs, healthcheck, depends_on, networks, hostname, container_name, secrets, configs, pull_policy, ulimits The split had no design behind it: `labels:` (null) was refused while `environment:` (null) was accepted, purely because one routes through validate_map and the other had a validator that early-returned on None. Docker's rule is exact: an explicit null is accepted for command, entrypoint and deploy -- where it means "not specified" -- and refused everywhere else. That is now one check at the gate, not 13 per-key decisions. extends treats a null side as "not specified", so the other side's value survives: a null in the extending service inherits the base's value (verified byte-for-byte against docker compose config), a null in the base takes the child's, and a null on both survives resolution for the gate to refuse. That restores the invariant in the accepting direction -- a document valid standalone stays valid through extends. Breaking: a bare `environment:` / `volumes:` / etc now raises. Such a document is not valid Compose; it only ever worked here, and it silently did nothing. * fix: a null command/entrypoint resets, it does not inherit The first pass applied "a null side means not specified, so the other side's value survives" uniformly. Docker splits: a child's null `environment:`/ `volumes:`/`deploy:`/... inherits the base's value, but a child's null `command:`/`entrypoint:` ERASES it -- the image's own default runs. Verified against docker compose config: the child's rendered config has no command key. So the uniform rule regressed `command`, which main had right: the merged service silently ran the base's argv instead of the image default. The reset set is {command, entrypoint}, not the null-allowed set -- `deploy` tolerates a null and inherits. The suite passed identically with and without the bug, because nothing asserted the merged command's VALUE. The new tests assert at emit level: a reset command must not put the base's argv in the podman-run line. * fix: make the null guards coherent; correct the base-null doc claim _validate_ulimits raised on a null while _validate_pull_policy returned clean -- two registry keys disagreeing about the same value through the same seam. Null policy belongs in one place, the gate, so neither shape validator special-cases it now: a null reaching a validator is a wrong shape like any other. The emitters stay null-tolerant as defense-in-depth, since run_flags is reachable without the gate and PULL_POLICY_MAP[None] would be a raw KeyError. The architecture doc claimed a base-side null "cannot be observed in a document that passes the gate". False for exactly the three keys that paragraph is about: the gate allows a null command/entrypoint/deploy, so a base with `command:` and a child that sets one is valid, reaches the merge, and the child's value wins -- as Docker does.
1 parent 6729a1b commit 8ba9033

7 files changed

Lines changed: 359 additions & 23 deletions

File tree

architecture/supported-subset.md

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,15 @@ redundant only when reached through `validate()`.
8686
Each entry in `services` must itself be a mapping; any other shape (a bare
8787
string, a list, ...) raises.
8888

89+
**An explicitly-null value raises**, for every service key except `command`,
90+
`entrypoint` and `deploy` — the three where Compose gives a null a meaning
91+
("not specified") and `docker compose config` accepts one. A bare
92+
`environment:` with its contents deleted is a mistake, not an instruction to
93+
emit nothing, so it is refused rather than silently dropped
94+
(`_reject_null_values`). One rule, taken from Docker's own verdict key by key,
95+
rather than a per-key decision to keep in sync. `x-` extension keys are exempt:
96+
their contents are arbitrary user payload compose2pod never reads.
97+
8998
- **Supported:** `image`, `build`, `command`, `entrypoint`, `environment`,
9099
`env_file`, `volumes`, `healthcheck`, `depends_on`, `networks`, `hostname`,
91100
`container_name`, `tmpfs`, `secrets`, `configs`, plus the declarative
@@ -264,9 +273,17 @@ slot (`architecture/glossary.md`).
264273
produces standalone; a structural key raises `cannot merge '<key>' across
265274
incompatible forms`. A malformed *form* is reported against the service the
266275
value belongs to — the base, when it is the base's value at fault, not the
267-
service extending it. (One exception: an explicit `null` on a mergeable key
268-
is valid standalone but refused on merge, and a null in the base is reported
269-
against the extending service.)
276+
service extending it.
277+
- **A null in the extending service means "not specified"** — the base's value
278+
is inherited, as Docker does. **Except `command` and `entrypoint`, where a
279+
null is a *reset*:** Docker erases the inherited value so the image's own
280+
default runs, and so does compose2pod. (`deploy` tolerates a null and
281+
inherits, so the reset set is not simply "the keys that allow a null".)
282+
A null in the *base* means "not specified" the same way, so the extending
283+
service's value wins. This is reachable for exactly the three keys the gate
284+
allows a null on: a base with `command:` and a child that sets one gives the
285+
child's, as Docker does. For every other key the base's null is refused by
286+
the gate first (the base is a service too), so the merge never sees it.
270287
- **Divergences from Compose:** short-form `volumes` are concatenated rather
271288
than merged by target path; podman resolves duplicate mounts at run time.
272289
Referenced resources

compose2pod/extends.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@
2020
# other would accept a shape the gate refuses standalone.
2121
_SCALAR_FORM_KEYS = {"tmpfs", "env_file"}
2222

23+
# The keys where an explicit null in the extending service RESETS the inherited
24+
# value rather than meaning "not specified". Docker erases a child's null
25+
# `command:`/`entrypoint:` so the image's own default runs, while a child's null
26+
# `environment:`/`volumes:`/`deploy:`/... inherits the base's value -- so this is
27+
# not "the keys that tolerate a null" (`deploy` tolerates one and inherits).
28+
_RESET_ON_NULL_KEYS = {"command", "entrypoint"}
29+
2330

2431
def _extends_target(name: str, ext: Any) -> str: # noqa: ANN401 - Compose values are untyped
2532
"""Return the referenced service name, after refusing cross-file and malformed forms."""
@@ -115,7 +122,20 @@ def _merge(base: dict[str, Any], local: dict[str, Any], name: str, base_name: st
115122
merged: dict[str, Any] = dict(base)
116123
for key, local_val in local.items():
117124
spec = SERVICE_KEYS.get(key)
118-
if key in base and spec is not None and spec.merge is not None:
125+
if key in base and local_val is None and key in _RESET_ON_NULL_KEYS:
126+
# An explicit null `command:`/`entrypoint:` in the extending service
127+
# is a *reset*, not an omission: Docker erases the inherited value and
128+
# the image's own default runs. (`deploy:` tolerates a null too but
129+
# inherits, so this is not simply "the keys that allow a null".)
130+
merged[key] = None
131+
elif key in base and (local_val is None or base[key] is None):
132+
# Every other null side means "not specified", so the other side's
133+
# value survives -- what Docker does, and what keeps a document valid
134+
# through `extends` if it is valid standalone. A null on *both* sides
135+
# survives resolution, and the gate refuses it (as Docker refuses a
136+
# null that no inheritance overwrote).
137+
merged[key] = base[key] if local_val is None else local_val
138+
elif key in base and spec is not None and spec.merge is not None:
119139
# A merge must never *widen* what the gate accepts. `resolve_extends`
120140
# runs ahead of `validate()`, so a normalizing merge (list -> mapping)
121141
# can launder a form the key does not have into one it does:

compose2pod/keys.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -260,19 +260,27 @@ def extra_host_entries(value: list[Any] | dict[str, Any]) -> list[tuple[str, str
260260

261261

262262
def _validate_pull_policy(name: str, key: str, value: Any) -> None: # noqa: ANN401 - Compose values are untyped
263-
if value is not None and (not isinstance(value, str) or value not in PULL_POLICY_MAP):
263+
# No `value is None` escape, matching `_validate_ulimits`: the gate refuses a
264+
# null `pull_policy:` outright (`parsing._reject_null_values`), as Docker
265+
# does, so a null reaching a *shape* validator is a wrong shape like any
266+
# other. Null policy lives in one place -- the gate -- not in each validator.
267+
if not isinstance(value, str) or value not in PULL_POLICY_MAP:
264268
allowed = "/".join(PULL_POLICY_MAP)
265269
msg = f"service {name!r}: unsupported {key} {value!r} (use {allowed})"
266270
raise UnsupportedComposeError(msg)
267271

268272

269273
def _emit_pull_policy(value: Any) -> list[Token]: # noqa: ANN401 - Compose values are untyped
274+
# The emitters stay null-tolerant as defense-in-depth: they are reachable
275+
# from `run_flags` without the gate, and `PULL_POLICY_MAP[None]` would be a
276+
# raw KeyError rather than a clean refusal.
270277
return ["--pull", PULL_POLICY_MAP[value]] if value is not None else []
271278

272279

273280
def _validate_ulimits(name: str, key: str, value: Any) -> None: # noqa: ANN401 - Compose values are untyped YAML/JSON
274-
if value is None:
275-
return
281+
# No `value is None` escape: the gate refuses a null `ulimits:` outright
282+
# (`parsing._reject_null_values`), as Docker does, so a null reaching here
283+
# is a wrong shape like any other.
276284
if not isinstance(value, dict):
277285
msg = f"service {name!r}: '{key}' must be a mapping"
278286
raise UnsupportedComposeError(msg)

compose2pod/parsing.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313

1414
SUPPORTED_SERVICE_KEYS = set(SERVICE_KEYS) | STRUCTURAL_KEYS
1515
IGNORED_SERVICE_KEYS = {"ports", "restart", "stdin_open", "tty", "stop_signal", "stop_grace_period", "profiles"}
16+
# The only service keys Docker tolerates an explicit null on, where it means
17+
# "not specified" (measured against `docker compose config`). Every other key
18+
# with a bare `key:` is refused -- see `_reject_null_values`.
19+
NULL_ALLOWED_KEYS = {"command", "entrypoint", "deploy"}
1620
SUPPORTED_HEALTHCHECK_KEYS = {"test", "interval", "timeout", "retries", "start_period"}
1721
_HEALTHCHECK_SCALAR_KEYS = ("timeout", "retries", "start_period")
1822
SUPPORTED_TOP_LEVEL_KEYS = {"services", "version", "name", "networks", "volumes", "secrets", "configs"}
@@ -103,9 +107,12 @@ def _validate_argv_list(name: str, key: str, value: list[Any]) -> None:
103107

104108
def _validate_entrypoint(name: str, svc: dict[str, Any]) -> None:
105109
"""Check the structural entrypoint key's form (it is not a registry key)."""
106-
if "entrypoint" not in svc:
110+
entrypoint = svc.get("entrypoint")
111+
if entrypoint is None:
112+
# An explicit null means "not specified", as it does for its sibling
113+
# `command` and as Docker accepts for both. `entrypoint_tokens` emits
114+
# nothing for it.
107115
return
108-
entrypoint = svc["entrypoint"]
109116
if not isinstance(entrypoint, str | list):
110117
msg = f"service {name!r}: 'entrypoint' must be a string or list"
111118
raise UnsupportedComposeError(msg)
@@ -155,6 +162,25 @@ def _validate_env_file(name: str, svc: dict[str, Any]) -> None:
155162
_validate_string_or_string_list(name, "env_file", svc.get("env_file"))
156163

157164

165+
def _reject_null_values(name: str, svc: dict[str, Any]) -> None:
166+
"""Refuse an explicitly-null service value, everywhere Docker refuses one.
167+
168+
Measured against `docker compose config`: it tolerates an explicit null on
169+
exactly `command`, `entrypoint` and `deploy` -- where a null means "not
170+
specified" -- and refuses one on every other service key, because a bare
171+
`environment:` is almost always a deleted-contents mistake rather than an
172+
intent. Emitting nothing for it silently would be exactly the dropped
173+
behavior this gate exists to catch.
174+
175+
One rule rather than a per-key decision, so the policy cannot drift back
176+
into an enumeration. `x-` keys are arbitrary user payload and are exempt.
177+
"""
178+
for key, value in svc.items():
179+
if value is None and key not in NULL_ALLOWED_KEYS and not key.startswith("x-"):
180+
msg = f"service {name!r}: '{key}' must not be null (Compose refuses a bare '{key}:')"
181+
raise UnsupportedComposeError(msg)
182+
183+
158184
def _validate_service(name: str, svc: Any) -> list[str]: # noqa: ANN401 - Compose values are untyped
159185
"""Validate one service; returns warnings, raises UnsupportedComposeError."""
160186
if not isinstance(svc, dict):
@@ -165,6 +191,7 @@ def _validate_service(name: str, svc: Any) -> list[str]: # noqa: ANN401 - Compo
165191
# contract as a module entry point -- belt-and-braces, not load-bearing
166192
# only by luck of the current call graph.
167193
require_string_keys(f"service {name!r}", svc)
194+
_reject_null_values(name, svc)
168195
warnings: list[str] = []
169196
for key in sorted(svc):
170197
if key.startswith("x-"):
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
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.

tests/test_extends.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import pytest
44

5+
from compose2pod.emit import EmitOptions, emit_script
56
from compose2pod.exceptions import UnsupportedComposeError
67
from compose2pod.extends import _STRUCTURAL_CONCAT_KEYS, _STRUCTURAL_MERGE_KEYS, resolve_extends
78
from compose2pod.keys import SERVICE_KEYS
@@ -557,3 +558,108 @@ def test_tmpfs_scalar_form_still_normalizes(self) -> None:
557558
}
558559
}
559560
assert resolve_extends(doc)["services"]["web"]["tmpfs"] == ["/scratch/base", "/scratch/local"]
561+
562+
563+
class TestNullIsNotSpecified:
564+
"""A null side means 'not specified', so the other side's value survives (as Docker does)."""
565+
566+
def test_null_in_the_extending_service_inherits_the_base(self) -> None:
567+
# `docker compose config` on the same document keeps the base's value.
568+
doc = {
569+
"services": {
570+
"base": {"image": "x", "environment": {"A": "1"}, "volumes": ["./a:/a"]},
571+
"web": {"extends": {"service": "base"}, "environment": None, "volumes": None},
572+
}
573+
}
574+
web = resolve_extends(doc)["services"]["web"]
575+
assert web["environment"] == {"A": "1"}
576+
assert web["volumes"] == ["./a:/a"]
577+
578+
def test_null_in_the_base_takes_the_extending_service_value(self) -> None:
579+
doc = {
580+
"services": {
581+
"base": {"image": "x", "environment": None},
582+
"web": {"extends": {"service": "base"}, "environment": {"B": "2"}},
583+
}
584+
}
585+
assert resolve_extends(doc)["services"]["web"]["environment"] == {"B": "2"}
586+
587+
def test_null_on_both_sides_survives_and_the_gate_refuses_it(self) -> None:
588+
doc = {
589+
"services": {
590+
"base": {"image": "x", "environment": None},
591+
"web": {"extends": {"service": "base"}, "environment": None},
592+
}
593+
}
594+
merged = resolve_extends(doc)
595+
assert merged["services"]["web"]["environment"] is None
596+
with pytest.raises(UnsupportedComposeError, match="'environment' must not be null"):
597+
validate(merged)
598+
599+
def test_a_document_valid_standalone_stays_valid_through_extends(self) -> None:
600+
# The invariant the null handling exists to restore, in the accepting direction.
601+
doc = {
602+
"services": {
603+
"base": {"image": "x", "command": ["run"]},
604+
"web": {"extends": {"service": "base"}, "command": None},
605+
}
606+
}
607+
assert validate(resolve_extends(doc)) == []
608+
609+
610+
class TestNullResetsCommandAndEntrypoint:
611+
"""A null `command`/`entrypoint` in the extending service ERASES the base's.
612+
613+
Docker splits here: `environment:`/`volumes:`/`deploy:` null in a child inherit
614+
the base's value, but `command:`/`entrypoint:` null erase it -- the image's own
615+
default runs. Verified against `docker compose config`: the child's rendered
616+
config has no `command` key at all.
617+
"""
618+
619+
def _script(self, doc: dict) -> str:
620+
options = EmitOptions(
621+
target="web",
622+
ci_image="ci:latest",
623+
command="",
624+
pod="test-pod",
625+
project_dir=".",
626+
artifacts=[],
627+
allow_exit_codes=[],
628+
)
629+
return emit_script(compose=resolve_extends(doc), options=options)
630+
631+
def test_null_command_erases_the_inherited_command(self) -> None:
632+
doc = {
633+
"services": {
634+
"base": {"image": "alpine", "command": ["run", "base"]},
635+
"web": {"extends": {"service": "base"}, "command": None},
636+
}
637+
}
638+
assert resolve_extends(doc)["services"]["web"]["command"] is None
639+
# The image's own default must run -- the base's argv must not be emitted.
640+
# (Assert on the podman-run line, not the whole script: "podman run" contains "run".)
641+
run_line = next(line for line in self._script(doc).splitlines() if "--name test-pod-web" in line)
642+
assert '"run"' not in run_line, run_line
643+
assert '"base"' not in run_line, run_line
644+
645+
def test_null_entrypoint_erases_the_inherited_entrypoint(self) -> None:
646+
doc = {
647+
"services": {
648+
"base": {"image": "alpine", "entrypoint": ["/bin/sh", "-c"]},
649+
"web": {"extends": {"service": "base"}, "entrypoint": None},
650+
}
651+
}
652+
assert resolve_extends(doc)["services"]["web"]["entrypoint"] is None
653+
assert "--entrypoint" not in self._script(doc)
654+
655+
def test_null_deploy_still_inherits(self) -> None:
656+
# deploy allows a null too, but Docker INHERITS it -- so the reset set is
657+
# {command, entrypoint}, not "every key that tolerates a null".
658+
doc = {
659+
"services": {
660+
"base": {"image": "alpine", "deploy": {"resources": {"limits": {"memory": "100M"}}}},
661+
"web": {"extends": {"service": "base"}, "deploy": None},
662+
}
663+
}
664+
assert resolve_extends(doc)["services"]["web"]["deploy"] == {"resources": {"limits": {"memory": "100M"}}}
665+
assert "--memory" in self._script(doc)

0 commit comments

Comments
 (0)