fix: match Docker's null-value policy#60
Merged
Conversation
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.
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.
_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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the last item outstanding from the audit (#52):
extendsrefused an explicit null that the gate accepted, so a document valid on its own became invalid through inheritance. Design:planning/changes/2026-07-14.07-null-value-policy.md.Breaking, deliberately. A bare
environment:/volumes:/healthcheck:/ etc now raises. Such a document is not valid Compose —docker compose configrejects it — so it only ever worked here, and it silently did nothing.The problem was arbitrariness, not a missing feature
compose2pod's handling of an explicitly-null service value was an accident of which keys happened to get a hand-written validator.
labels:(null) was refused whileenvironment:(null) was accepted — purely because one routes throughvalidate_mapand the other had a validator that early-returned onNone.I measured every key against real
docker compose config(v5.1.2) rather than reasoning about it. compose2pod diverged on 13 keys, in both directions:entrypointenvironment,env_file,volumes,tmpfs,healthcheck,depends_on,networks,hostname,container_name,secrets,configs,pull_policy,ulimitsDocker's rule is exact: an explicit null is accepted for
command,entrypointanddeploy— where it means "not specified" — and refused everywhere else. That is now one check at the gate, not 13 per-key decisions to keep in sync. Re-measured after the fix: 56/56 keys match Docker.The merge: null means "not specified" — except where it means "reset"
A null in the extending service inherits the base's value, as Docker does.
Except
commandandentrypoint, where Docker treats a null as a reset — it erases the inherited value and the image's own default runs. Verified againstdocker compose config: the child's rendered config has nocommandkey at all.Critically,
deploytolerates a null and inherits, so the reset set is{command, entrypoint}— not the null-allowed set. I got this wrong first time by generalizing from two keys, and it regressedcommand: the merged service silently ran the base's argv instead of the image default. Review caught it.The tell is worth recording: the entire suite passed identically with and without that bug, because nothing asserted the merged command's value. The new tests assert at emit level — a reset
commandmust not put the base's argv in thepodman runline.Verification
extendschains.x-nulls,environment: {KEY: null}(host-passthrough) andlabels: {KEY: null}(empty label) all still work — this is about a null key value, not a null map value.632 tests at 100% coverage; integration green against real podman.
🤖 Generated with Claude Code