Releases: modern-python/compose2pod
Release list
0.3.1
compose2pod 0.3.1 — the image mount type
A focused follow-up to 0.3.0: the long-form volumes type: image mount, which
closes the last long-form volume form Podman can express but compose2pod did not
yet parse.
Feature
type: imagevolume mounts. A long-form entry{type: image, source, target, read_only, image: {subpath}}compiles topodman run --mount type=image,source=<ref>,target=<path>[,subpath=<abs>], mounting an image's
rootfs into the container. Measured againstdocker compose configv5.1.2 and
Podman 6.0.1:source(the image reference) is required — Podman refuses a sourceless
image mount, so compose2pod does too (a rule-two narrowing; Docker allows it
optional at config).read_onlyis accepted but inert — an image mount is always read-only,
and Podman rejects both areadonlyand anrwoption on it, so norois
emitted (matching what Docker does with the field).image: {subpath}must be an absolute path — Podman requires it for an
image mount (a relativesubpathis rejected at run time), unlike avolume
subpath, which may be relative. A${VAR}is carved out.
Docs
- The README's supported-subset section, stale since before the 0.3.0 parity
work, now leads with the measured parity property (compose2pod refuses every
documentdocker compose configrefuses, verified by the differential
conformance harness) and reflects the full 0.3.0 subset.
Downstream
type: imagecompose files that previously raised now compile. Nothing else
changes — the short form, the other long-form types, and every existing key
behave identically.- With
imagelanded, the honest subset is complete for its scope: every
remaining refusal of a form Docker accepts is either a permanent rule-two limit
(Podman cannot form the flag) or a genuinely niche gap (a Windows drive-letter
volume source on a Linux-targeting tool), each measured and documented in
architecture/supported-subset.md,planning/deferred.md, and
planning/decisions/.
Internals
- Tests at 100% line coverage (enforced);
ruff select=ALL,ty,eof-fixer
clean; the conformance harness (real Docker CLI) and integration harness (real
Podman) both green — the integration suite mounts an image's rootfs on real
Podman, and the final review re-measured every emitted--mountoption against
Podman 6.0.1, catching (and closing) a relative-subpathvalue Podman rejects
at run time.
0.3.0
compose2pod 0.3.0 — measured parity with Docker, and a much wider accepted subset
This release makes the subset's boundary a measured one. compose2pod now
refuses every document docker compose config refuses — verified continuously
by a differential conformance harness that runs both the real Docker CLI and the
real compose2pod pipeline over the same YAML and asserts the rule. On top of that
hard floor, the accepted subset grew substantially: quoted booleans, env_file
and volumes long-form (with --mount), the full Compose duration grammar, and
the volume mount option maps. Where compose2pod still refuses a form Docker
accepts, that refusal is now measured, documented, and — for the permanent ones —
recorded as a decision with the exact Podman limitation behind it.
The parity thesis
The governing rule (decisions/2026-07-14-docker-rejection-parity.md):
accepted(compose2pod) ⊆ accepted(docker) — compose2pod must reject every
document Docker rejects, so a file that compiles is a file Docker would run.
- Reject every document
docker compose configrefuses. The value grammars
(size,number,integer,count,duration,port) now match Docker's
own —mem_limit: "",cpus: somevalue, a unitless healthcheck duration, an
out-of-range port, a whitespace-padded scalar, all refused where Docker refuses
them, instead of being silently accepted and compiled into a script Docker
itself would not run. - Read YAML the way Docker reads it (1.2). A bare
on/off/yes/nostays
an ordinary string (not a boolean), and a bare1e3is the float1000.0—
matching Docker's YAML-1.2 parser, soSSL: onreaches the container as
SSL=onand anon:key no longer resolves toTrue. - Match Docker's null-value policy. A bare
key:(null) is refused exactly
where Docker refuses one, and treated as "unset" exactly where Docker does. - Close the structural-key and reference gates. Non-string mapping keys,
malformed nested shapes, undefined named-volume/network references, and
strict long-form schemas (build,depends_on,networks,volumes,
secrets/configsrefs) are validated to Docker's own grammar. - A differential conformance harness (
tests/conformance/, CI-only) probes
every registry key × every hostile shape plus a hand-authored corpus, running
Docker and compose2pod over identical YAML. A document Docker rejects that
compose2pod accepts fails the build; the reverse (an over-rejection) is
reported and catalogued, never hidden.
Feature — a wider accepted subset
- Quoted booleans on every boolean field.
read_only: "yes",tty: "true",
init: "on", and the same onbuild, network/volume definitions, and
depends_on— the YAML-1.1 boolean spellings Docker casts a string field
through — are now accepted, via one sharedvalues.is_bool_like/as_bool
seam, coercing before emit so a quoted"false"never leaks a set flag. env_filelong-form.env_file: [{path, required, format}]is accepted;
required: falseis honored with a run-time[ -f path ]guard so an absent
optional file is skipped,format: rawis accepted, andpathresolves as
before.volumeslong-form (--mount). The mapping entry
{type, source, target, read_only, consistency}fortypein
bind/volume/tmpfscompiles topodman run --mount, and the nested option
maps —bind: {propagation, selinux},volume: {subpath},
tmpfs: {size, mode}— map to the corresponding--mountoptions (selinux
z/Z→relabel=shared/private).- The full Compose duration grammar for healthcheck
interval. Compound and
larger units —1h,1h30m,1d,1w,1.5d,-1h— are accepted (the
interval paces the polling loop; Podman never sees it), overflow- and
whitespace-safe. - Podman-version guard. Generated scripts warn at run time when Podman is
below the version with the/etc/hostspod-wide fix, so a silent name-
resolution failure surfaces as an explicit warning.
Fix
--add-hostscoped to the target's dependency closure — a host entry for a
service outside the run set no longer lands on the pod-create line.- Two hard-rule false greens, caught by the differential harness's own
adversarial review and closed: a trailing newline slipping past a$-anchored
value grammar (now\Z, reachable via a YAML block scalar), and a padded /
negative / float value on thetmpfsmount sub-schema (Docker validates it as
unsigned; Podman'scrunrejects a float mode).
Internals
- Registry unification.
environmentandtmpfsmoved into the
SERVICE_KEYSregistry (one validate + emit + merge spec per key); the compose
reader (the YAML-1.2 SafeLoader and format dispatch) was extracted into
read.pywith its own test surface.volumeswas deliberately left
hand-rolled — recorded as a decision (its emit needsproject_dir, its
references are document-level). - A decisions log now records the rulings that shape the boundary — the
docker-rejection-parity rule, thelist-of-str(sysctls/volumes: ["a"])
legitimate refusals, thevolumes-stays-hand-rolled call, and the
measured-negative-numerics finding — each with the exact Docker/Podman behavior
and a revisit trigger, so none is re-litigated. - Tests at 100% line coverage (enforced);
ruff select=ALL,ty,eof-fixer
clean; the CI-only integration harness (real Podman) and conformance harness
(real Docker CLI) both green.
Why
0.2.0 rounded out the common compose keys. This release answers a sharper
question — which documents does compose2pod's "yes" actually mean? — with a
measured answer: exactly the ones Docker would run, minus a small, catalogued set
of forms Podman genuinely cannot express. The differential harness makes that a
property the build enforces, not a claim in the README, and it repeatedly caught
false greens that unit tests (which assert on generated text) could not.
Downstream
- Stricter where Docker is strict. A document that previously compiled but
thatdocker compose configrejects — a native number on a duration field, a
malformed size, a null where Docker refuses one, a bareon/yesyou relied on
being a boolean — now raisesUnsupportedComposeError. This is the point: the
script you get is one Docker would run. - Wider where Podman can express it. Quoted booleans,
env_file/volumes
long-form, and compound durations mean fewer documents need pre-editing. - The honest boundary. Three forms Docker accepts stay refused, each measured
and documented: theimagemount type and a Windows drive-letter source
(planning/deferred.md, genuine parser gaps), andsysctls/volumes: ["a"]
(decisions/, permanent — Podman cannot form the flag).
0.2.0
compose2pod 0.2.0 — most of the common compose subset, and a real-podman test harness
This release rounds out the per-container and pod-level keys most compose
files actually use — extends, secrets/configs, resource limits,
ulimits, and pod-wide dns/sysctls/extra_hosts — and adds a CI-only
integration harness that runs generated scripts against real Podman. That
harness caught a real bug on its first run, fixed in this same release: see
Fix, below.
Fix
--add-hostmoved frompodman runtopodman pod create. Current
Podman rejects--add-hoston a container joining a pod ("network cannot
be configured when it is shared with a pod") — so any generated script for
a pod with more than one named service failed at run time. All
--add-hostemission (service-name/hostname/container_name/network
aliases, andextra_hosts) now lands on the singlepodman pod create
line instead, alongsidedns/sysctls.extra_hostsis consequently now
genuinely pod-wide (previously per-service); a host name landing on two
different addresses — across services'extra_hosts, or against an
alias's fixed127.0.0.1— is refused (UnsupportedComposeError) rather
than resolved by guessing, matching the existingsysctlsconflict rule.validate()/emit_script()reject malformed input cleanly instead of
crashing. A malformeddepends_on,healthcheck,hostname/
container_name,tmpfs, or per-servicenetworksshape now raises
UnsupportedComposeErrorat the validation/emit boundary instead of an
uncaughtAttributeError/ValueErrordeeper in the pipeline.emit_scriptvalidates the pod name itself, not only the CLI — an
adversarialEmitOptions.pod(quotes, spaces,$(...)) now raises
cleanly instead of producing a malformed shell trap.
Feature
extends. Same-file Composeextends: {service: <name>}is resolved
before validation (transitive, cycle-checked, per-key merge — concat for
list keys likecap_add/volumes, local-wins merge forenvironment/
labels/healthcheck, override for scalars andcommand/entrypoint).
Cross-fileextends: {file: ...}stays refused, keeping the
single-document input model.secretsandconfigs. Top-levelsecrets:/configs:(file:,
environment:, and — configs only — inlinecontent:sources) compile to
podman secret create, mounted via--secretat/run/secrets/<name>
(secrets) or the container root/<name>(configs, absolutetarget:
only).external: trueis refused; a secret and same-named config don't
collide (distinct store names).- Resource limits. Both the legacy scalar keys (
mem_limit,cpus,
pids_limit,cpu_shares,cpuset,shm_size,oom_score_adj,
oom_kill_disable, ...) and the moderndeploy.resources.limits/
.reservationsblock map onto--memory/--cpus/--pids-limit/
--memory-reservation. A legacy key and itsdeploy.resourcescounterpart
targeting the same flag is refused rather than picking an undefined
precedence. ulimits. A mapping of limit name to a scalar (nproc: 65535) or a
{soft, hard}pair, emitted as--ulimit.- Pod-level
dns/sysctls/extra_hosts. These apply to every
container in the pod (one shared/etc/resolv.conf, sysctl set, and
/etc/hosts) —dns/dns_search/dns_optare unioned across the
target's dependency closure;sysctlsunion by key, refusing a same-key
conflict;extra_hostsmerges the same way (see Fix, above).validate()
warns whenever any service in the document declares one of these, even
outside the target's closure. - Core process, confinement, and metadata keys:
entrypoint,user,
working_dir,group_add,labels,read_only,init,privileged,
cap_add,cap_drop,security_opt,platform,devices,
annotations,pull_policy. Each is an honest, validated passthrough to
itspodman runflag. profilesandstop_signal/stop_grace_periodare now accepted and
ignored (with a warning) rather than rejected — compose2pod's run set is
always--targetplus itsdepends_onclosure, and the generated script
always force-removes the pod, so neither key changes anything it can act
on. A targetdepends_onreaching a service outside its declared profile
still runs it (the closure is authoritative — more permissive than
Compose, never a silent drop).
Packaging
resolve_extendsis exported fromcompose2pod.__all__, alongside
the existingEmitOptions,UnsupportedComposeError,emit_script,
to_shell, andvalidate.
Why
Prior releases built the core single-pod model and its interpolation
semantics; this one rounds out the compose keys real-world compose files
actually use, so fewer documents need pre-editing before they run.
Discovering the --add-host regression only on a real Podman run — not from
any of the 363 existing unit tests, all of which assert on generated
script text — is exactly why the integration harness (see Internals) was
worth building: some regressions are only visible to the runtime it targets.
Downstream
- Compose files using
extra_hostsfor a service-specific override: it
is now pod-wide. A host name yourextra_hostsmaps to one address must
not conflict with another service's mapping, or with an alias's fixed
127.0.0.1— compose2pod now refuses rather than silently picking one. - Any pod that previously failed with
network cannot be configured when it is shared with a podnow works without changes to your compose file. - API consumers:
compose2pod.resolve_extends(compose)is now
available for flatteningextendsoutside the CLI pipeline.
Internals
- 363 tests at 100% line coverage (enforced);
ruff select=ALL,ty, and
eof-fixerclean. - A new CI-only integration harness (
tests/integration/, 10 scenarios)
renders real compose documents and runs the generated script against real
Podman onubuntu-latest, asserting on exit code and output — it caught
the--add-hostregression above on its first run. Kept out of the
100%-coverage gate and the fast suite via a location-based marker. compose2pod/keys.py'sSERVICE_KEYSregistry unifies what was
previously three hand-synced tables (validation, emission, and the
supported-key set) into one(validate, emit)pair per key.
0.1.8
compose2pod 0.1.8 — interpolation moves to run time
This release reverses 0.1.7: Compose ${VAR} interpolation now happens when
the generated script runs, not when compose2pod generates it. compose2pod
emits a script that runs later — typically in CI, where the real secrets and
config live — so resolving against compose2pod's own environment at generation
time baked any variable that was unset then to an empty string. References are
now emitted as live POSIX-shell fragments that the runtime shell expands.
Change
${VAR}is interpolated at script-run time, not generation time.
to_shell()(newcompose2pod/shell.py) re-encodes every compose-derived
value (environment,image,command,volumes,tmpfs,env_file,
healthchecktest) into a double-quoted shell fragment whose variable
references stay live, so the generated script's own shell resolves them
against its runtime environment. All the 0.1.7 forms still work —$VAR,
${VAR},${VAR:-default},${VAR-default},${VAR:?msg},${VAR?msg},
${VAR:+alt},${VAR+alt},$$— mapping onto identical POSIXsh
parameter expansion. A bare$VAR/${VAR}is emitted as${VAR-}so an
unset variable expands to empty under the script'sset -euinstead of
aborting;${VAR:?msg}now fails the script at run time (not at generation).
Command substitution and other shell metacharacters in literal text are
escaped, so nothing but a variable reference is ever live. The CLI prints one
informational note listing the variables the script references at run time.
Fix
environmentnull value is host passthrough. A null mapping value
(KEY:) now emits a bare-e KEY— passingKEYthrough from the host,
identical to the list form- KEY— instead of the literal-e KEY=None.- Malformed braced references are rejected. A braced reference whose text
after the name is not a valid operator (e.g.${FOO!bar}) now raises a clear
UnsupportedComposeErrorinstead of silently dropping the trailing text.
Packaging
- Public API: the generation-time
interpolatefunction is removed;
to_shellis exported in its place.to_shell(value)returns a
runtime-expandable shell fragment, not a resolved value.
Why
The 0.1.7 model resolved ${VAR} against os.environ at generation time. For
compose2pod's actual use — generate a script now, run it later in CI — that was
the wrong moment: the variables are supposed to be unset at generation and set
at run time, so 0.1.7 baked them to empty and emitted a spurious "variable not
set" warning. Deferring to the runtime shell resolves them where they exist.
Downstream
- Compose files: set
${VAR}values in the runtime environment of the
generated script (e.g. your CI job), not in the environment that runs
compose2pod. An unset bare${VAR}now expands to empty at run time; write
${VAR:?message}where you want a missing variable to fail the run. - API consumers:
compose2pod.interpolateis gone. Use
compose2pod.to_shell(value), which emits a runtime-expandable shell fragment
rather than resolving the value in-process.
Internals
architecture/supported-subset.mdrewritten to describe run-time expansion,
the${VAR-}/$$/${VAR:?}semantics,env_fileinterpolation, the
reference note, null-value passthrough, and malformed-reference rejection.- 123 tests at 100% line coverage (enforced);
ruff select=ALL,ty, and
eof-fixerclean.test_shell.pyexecutes emitted fragments under real
sh -eucto prove run-time expansion, the${VAR:?}abort, and
command-substitution inertness.
0.1.7
compose2pod 0.1.7 — variable interpolation
A patch release resolving Compose-spec ${VAR} variable interpolation
against the process environment. Previously, a compose file using ${VAR}
reached the container as the literal placeholder text — shlex.quote
correctly single-quoted the value for safe script embedding, which also
meant the shell never expanded it, so nothing in the pipeline ever
substituted the real value. Compose files that lean on host/CI environment
values now convert and run correctly, matching docker compose.
Fix
${VAR}-style interpolation is resolved. Every string leaf of the
compose document is checked againstos.environbefore validation:$VAR,
${VAR},${VAR:-default},${VAR-default},${VAR:?msg},${VAR?msg},
${VAR:+alt},${VAR+alt}, and$$(literal$) are all supported. An
unset$VAR/${VAR}with no default resolves to an empty string and
prints a warning rather than failing;${VAR:?msg}/${VAR?msg}raises a
clear error instead of silently defaulting.
Downstream
No action needed — additive and backward compatible. A document with no
$-prefixed values is unaffected; one that already relied on the previous
literal pass-through (unlikely, since it's not useful behavior) would now see
the resolved value instead.
Internals
architecture/supported-subset.mddocuments the supported interpolation
forms and the explicit non-goal: no.envfile loading, only the caller's
existing environment is consulted.- 123 tests at 100% line coverage (enforced);
ruff select=ALL,ty, and
eof-fixerclean.
0.1.6
compose2pod 0.1.6 — accept the service tmpfs key
A patch release that stops rejecting valid compose documents which set a
service tmpfs. The validator was over-strict: a service carrying tmpfs: —
a string or list of <path>[:<options>] entries, e.g. /tmp:mode=1777 —
raised unsupported key 'tmpfs'. Such documents now convert.
Fix
- The service
tmpfskey is accepted.validate()no longer rejects a
service that setstmpfs:. Each entry is emitted verbatim aspodman run --tmpfs <value>— Compose's short syntax maps directly onto podman's own
--tmpfs CONTAINER-DIR[:OPTIONS]flag, so no translation is needed. No
format validation; a malformed option string surfaces as a podman error at
run time.
Downstream
No action needed — additive and backward compatible. Documents that previously
failed with unsupported key 'tmpfs' now emit a pod script; nothing that
converted before changes.
Internals
architecture/supported-subset.mddocumentstmpfsin the Service keys
matrix.- 98 tests at 100% line coverage (enforced);
ruff select=ALL,ty, and
eof-fixerclean.
0.1.5
compose2pod 0.1.5 — named volumes
A patch release accepting Compose named volumes, previously a hard error.
Compose files that declare a top-level volumes: block or reference a named
volume in a service (e.g. pgdata:/var/lib/postgresql/data for database data
persistence) now convert without editing the compose file.
Fix
- Named volumes are accepted. A service volume whose source is a bare
identifier rather than a host path (a Compose named volume) is emitted
verbatim as-v <name>:<target>. Podman creates the volume implicitly on
first reference — the same behavior as plainpodman run -v— so no
explicitpodman volume createstep is needed. The volume persists on the
host after the pod is removed, identical todocker compose downwithout
-v. - Top-level
volumes:is accepted (ignored). Mirrors the existing
top-levelnetworkstreatment: the block'sdriver/driver_optsand
externalsettings are never read, since implicit creation with default
options is what podman does regardless. A non-default driver or an
external: truevolume that should pre-exist has no special handling.
Downstream
No action needed — additive and backward compatible. Documents that previously
failed with unsupported top-level keys: ['volumes'] or "named volume ... is
not supported" now emit a pod script; nothing that converted before changes.
Internals
architecture/supported-subset.mddocuments the bind-mount vs named-volume
disambiguation (source starts with.//vs bare identifier) and the
default-driver-only limitation.- 95 tests at 100% line coverage (enforced);
ruff select=ALL,ty, and
eof-fixerclean.
0.1.4
compose2pod 0.1.4 — accept the service container_name key
A patch release that stops rejecting valid compose documents which set a
service container_name. The validator was over-strict: a service carrying
container_name: — used to give a service a stable name that other services
connect to — raised unsupported key 'container_name'. Such documents now
convert.
Fix
- The service
container_namekey is accepted.validate()no longer
rejects a service that setscontainer_name:, and the name is made
resolvable to127.0.0.1inside the pod, exactly likehostnameand network
aliases already are: it joins the--add-hostset so other services reach it
by that name. The actual podman container keeps its internal
{pod}-{service}name (used forpodman cp, healthcheck polling, and
diagnostics) — only name resolution is affected, matching the pod's
shared network namespace.
Downstream
No action needed — additive and backward compatible. Documents that previously
failed with unsupported key 'container_name' now emit a pod script; nothing
that converted before changes.
Internals
architecture/supported-subset.mdmerges thehostname/container_name
note explaining both are shared-namespace-resolvable but never change the
real--name.- 92 tests at 100% line coverage (enforced);
ruff select=ALL,ty, and
eof-fixerclean.
0.1.3
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 <path>. A colon-less volume entry
is a Compose anonymous volume; it is now rendered verbatim aspodman run -v <path>(podman creates an anonymous volume at that container path) instead of
crashingrun_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.mddocuments anonymous volumes in the Volumes
matrix (absolute accepted and emitted as-v <path>; non-absolute raises).- 89 tests at 100% line coverage (enforced);
ruff select=ALL,ty, and
eof-fixerclean.
0.1.2
compose2pod 0.1.2 — accept the service hostname key
A patch release that stops rejecting valid compose documents which set a
service hostname. The validator was over-strict: a service carrying
hostname: — used to give a service a stable name that other services connect
to — raised unsupported key 'hostname'. Such documents now convert.
Fix
- The service
hostnamekey is accepted.validate()no longer rejects a
service that setshostname:, and the hostname is made resolvable to
127.0.0.1inside the pod, exactly like a network alias: it joins the
--add-hostset so other services reach the service by that name. Because all
services share one network namespace (and the pod's UTS namespace), only name
resolution is meaningful — no per-container--hostnameis emitted.
Downstream
No action needed — additive and backward compatible. Documents that previously
failed with unsupported key 'hostname' now emit a pod script; nothing that
converted before changes.
Internals
architecture/supported-subset.mdaddshostnameto the supported
service-key matrix with the shared-namespace resolution rationale.- 86 tests at 100% line coverage (enforced);
ruff select=ALL,ty, and
eof-fixerclean.