Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
37d5c51
docs: spec closing the structural-key gate
lesnik512 Jul 13, 2026
a4553ba
fix: reject a malformed 'environment' at the gate
lesnik512 Jul 13, 2026
fd48b9d
fix: reject a malformed 'env_file' at the gate
lesnik512 Jul 13, 2026
61084dc
fix: validate env_file list entries are strings at gate
lesnik512 Jul 13, 2026
1802ef2
fix: reject a non-list 'volumes' at the gate
lesnik512 Jul 13, 2026
fc085b9
fix: reject a malformed --artifact instead of crashing
lesnik512 Jul 13, 2026
8b63be5
docs: promote the closed gate into architecture/
lesnik512 Jul 13, 2026
76f84d3
fix: reject a service with no usable image at the gate
lesnik512 Jul 13, 2026
04699d3
fix: reject a malformed 'command' at the gate
lesnik512 Jul 13, 2026
d3d2d87
fix: reject tmpfs list entries that aren't strings
lesnik512 Jul 13, 2026
66f5fc1
fix: reject a non-mapping 'services' value at the gate
lesnik512 Jul 13, 2026
c5135fd
fix: reject non-list network aliases at the gate
lesnik512 Jul 13, 2026
7759039
fix: reject non-scalar healthcheck timeout/start_period/retries
lesnik512 Jul 13, 2026
9713e65
refactor: move pod-name validation into _plan
lesnik512 Jul 13, 2026
a98289d
docs: document the closed structural-key gate
lesnik512 Jul 13, 2026
a71ce42
fix: accept null command at the gate
lesnik512 Jul 13, 2026
c9fff97
fix: guard Expand.value against non-str at construction
lesnik512 Jul 13, 2026
735bb8a
fix: gate healthcheck.test's shape at validate() time
lesnik512 Jul 13, 2026
09ac975
fix: reject non-scalar list/map elements across structural keys
lesnik512 Jul 13, 2026
9355094
fix: reject non-string depends_on list entries at the gate
lesnik512 Jul 13, 2026
f3a773a
refactor: share tmpfs/env_file's string-or-string-list validator
lesnik512 Jul 13, 2026
ca9600d
docs: document the Expand chokepoint and this round's gate closures
lesnik512 Jul 13, 2026
2cc588f
fix: reject non-string target in store long-form references
lesnik512 Jul 13, 2026
420dbc7
fix: reject non-string depends_on entries in extends merge
lesnik512 Jul 13, 2026
5639c90
fix: reject non-string list elements in pairs_to_mapping
lesnik512 Jul 13, 2026
116d74d
fix: reject non-string mapping keys at the gate
lesnik512 Jul 13, 2026
9a8e30c
fix: normalize boolean map values like Docker instead of rejecting them
lesnik512 Jul 13, 2026
e667124
fix: sort unsupported extends keys by repr to avoid a raw crash
lesnik512 Jul 14, 2026
79c4201
fix: reject boolean ulimits values instead of leaking them
lesnik512 Jul 14, 2026
70e5697
fix: guard more sorted(unknown-keys) call sites against mixed-type keys
lesnik512 Jul 14, 2026
bb39c7c
docs: correct overclaiming and document Round 4's gate closures
lesnik512 Jul 14, 2026
ff04d9f
fix: replace hand-placed non-string-key checks with a document-wide s…
lesnik512 Jul 14, 2026
36e816e
fix: sweep a service named x-* like any other service
lesnik512 Jul 14, 2026
a3356cb
fix: narrow the sweep to regions validate() actually reads
lesnik512 Jul 14, 2026
306ff92
test: assert the sweep actually recurses into lists
lesnik512 Jul 14, 2026
7aa2c5c
docs: correct sweep-scope claims in supported-subset.md and the plan
lesnik512 Jul 14, 2026
ab38d41
fix(graph): reject non-string depends_on condition before set lookup
lesnik512 Jul 14, 2026
b9a2606
fix(emit): treat a null healthcheck scalar as unset
lesnik512 Jul 14, 2026
5af45cb
fix(parsing): don't treat identifier-keyed service keys as extension …
lesnik512 Jul 14, 2026
d6832d5
fix(emit): validate EmitOptions.artifacts/allow_exit_codes shapes
lesnik512 Jul 14, 2026
92f08c2
docs: record round 7 findings and rulings, drop changelog voice
lesnik512 Jul 14, 2026
9b06ab8
fix: validate the compose document inside _plan
lesnik512 Jul 14, 2026
e65c249
docs: correct the public-surface and warning-order claims
lesnik512 Jul 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
276 changes: 252 additions & 24 deletions architecture/supported-subset.md

Large diffs are not rendered by default.

51 changes: 45 additions & 6 deletions compose2pod/emit.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from compose2pod.graph import depends_on, hostnames, startup_order
from compose2pod.healthcheck import health_cmd, interval_seconds
from compose2pod.keys import SERVICE_KEYS, Expand, Token, key_value_pairs
from compose2pod.parsing import validate
from compose2pod.pod import pod_create_flags
from compose2pod.resources import deploy_resource_flags
from compose2pod.shell import to_shell, variable_names
Expand Down Expand Up @@ -53,11 +54,17 @@ def _health_flags(healthcheck: dict[str, Any]) -> list[Token]:
cmd = health_cmd(healthcheck.get("test"))
if cmd is not None:
flags += ["--health-cmd", Expand(value=cmd)]
if "timeout" in healthcheck:
# An explicit `null` scalar means unset, not "emit the literal string
# 'None'" -- the same treatment `environment`/`volumes`/`command`
# give a null value elsewhere in this package, and matching `docker
# compose config`, which treats an explicitly-null key as absent.
# Keyed off value, not presence, so `timeout: null` and an omitted
# `timeout` behave identically.
if healthcheck.get("timeout") is not None:
flags += ["--health-timeout", str(healthcheck["timeout"])]
if "start_period" in healthcheck:
if healthcheck.get("start_period") is not None:
flags += ["--health-start-period", str(healthcheck["start_period"])]
if "retries" in healthcheck:
if healthcheck.get("retries") is not None:
flags += ["--health-retries", str(healthcheck["retries"])]
return flags

Expand Down Expand Up @@ -221,14 +228,49 @@ def _emit_target(lines: list[str], tokens: list[Token], options: EmitOptions) ->
lines.append("esac")


def _validate_options(options: EmitOptions) -> None:
"""Check option values emit destructures or interpolates unquoted.

CLI-unreachable (argparse enforces `artifact` as `str` and
`allow_exit_codes` as `int`) but a library caller can pass `EmitOptions`
directly: a non-string `artifact` would crash raw on the ':' membership
test below, and a non-int `allow_exit_codes` entry is interpolated
unquoted into the generated `case "$rc" in ...)` pattern -- shell
injection, not just a crash.
"""
for artifact in options.artifacts:
if not isinstance(artifact, str) or ":" not in artifact:
msg = f"artifact {artifact!r} must be in SRC:DST form"
raise UnsupportedComposeError(msg)
for code in options.allow_exit_codes:
if isinstance(code, bool) or not isinstance(code, int):
msg = f"allow_exit_codes entry {code!r} must be an int"
raise UnsupportedComposeError(msg)


def _plan(compose: dict[str, Any], options: EmitOptions) -> PlannedScript:
"""Walk the target's dependency closure once, building the script and its variables.

Every token source is visited a single time and feeds both outputs: each
service's `run_tokens` and the `pod_create_flags` render into lines *and*
have their `Expand` variables collected, so the script and the variable
list cannot disagree about what the script expands at run time.

`emit_script` and `referenced_variables` are both public entry points that
project this traversal, so a library caller can reach either one without
ever calling `validate()` first. Validating `compose` here -- and nowhere
else -- makes both safe by construction: this is the one place a caller
cannot route around. `cli.py` also calls `validate()` itself (to surface
warnings), so this repeats that pass; `validate()` only reads `compose`
and returns warnings, so the repeat is side-effect-free. The warnings from
this pass have no channel to reach a caller here -- `cli.py` surfaces its
own copy from its own call -- so they are discarded on purpose.
"""
_validate_options(options)
_ = validate(compose) # re-validate; warnings already surfaced by the caller, if any
if not POD_NAME_PATTERN.fullmatch(options.pod):
msg = f"invalid pod name {options.pod!r}"
raise UnsupportedComposeError(msg)
services = compose["services"]
hosts = hostnames(services)
order = startup_order(services, options.target)
Expand Down Expand Up @@ -275,9 +317,6 @@ def _plan(compose: dict[str, Any], options: EmitOptions) -> PlannedScript:

def emit_script(compose: dict[str, Any], options: EmitOptions) -> str:
"""Render the full pod test script for `target` and its dependency closure."""
if not POD_NAME_PATTERN.fullmatch(options.pod):
msg = f"invalid pod name {options.pod!r}"
raise UnsupportedComposeError(msg)
return _plan(compose, options).script


Expand Down
12 changes: 11 additions & 1 deletion compose2pod/extends.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,13 @@ def _extends_target(name: str, ext: Any) -> str: # noqa: ANN401 - Compose value
raise UnsupportedComposeError(msg)
unknown = set(ext) - {"service"}
if unknown:
msg = f"service {name!r}: unsupported 'extends' keys {sorted(unknown)}"
# sorted(unknown) would crash raw (TypeError: '<' not supported...)
# if unknown holds keys of more than one incomparable type -- e.g. an
# int key alongside a str key, which a hostile 'extends' mapping can
# freely produce since this runs ahead of validate()'s gate. Sorting
# by repr keeps the message deterministic without assuming key types
# are mutually comparable.
msg = f"service {name!r}: unsupported 'extends' keys {sorted(unknown, key=repr)}"
raise UnsupportedComposeError(msg)
service = ext.get("service")
if not isinstance(service, str):
Expand All @@ -40,6 +46,10 @@ def _as_mapping(key: str, name: str, value: Any) -> dict[str, Any]: # noqa: ANN
if key == "environment":
return pairs_to_mapping(name, key, value)
if key == "depends_on":
for dep in value:
if not isinstance(dep, str):
msg = f"service {name!r}: depends_on entry {dep!r} must be a string"
raise UnsupportedComposeError(msg)
return {dep: {} for dep in value}
msg = f"service {name!r}: cannot merge {key!r} across incompatible forms"
raise UnsupportedComposeError(msg)
Expand Down
28 changes: 26 additions & 2 deletions compose2pod/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ def depends_on(svc: dict[str, Any]) -> dict[str, str]:
"""Normalize dependencies of a service to a name -> condition mapping."""
deps = svc.get("depends_on") or {}
if isinstance(deps, list):
for dep in deps:
if not isinstance(dep, str):
msg = f"depends_on entry {dep!r} must be a string"
raise UnsupportedComposeError(msg)
return cast(dict[str, str], dict.fromkeys(deps, "service_started"))
if not isinstance(deps, dict):
msg = "'depends_on' must be a list or mapping"
Expand All @@ -18,7 +22,20 @@ def depends_on(svc: dict[str, Any]) -> dict[str, str]:
if not isinstance(spec, dict):
msg = f"depends_on entry {dep!r} must be a mapping"
raise UnsupportedComposeError(msg)
result[dep] = spec.get("condition", "service_started")
condition = spec.get("condition", "service_started")
if not isinstance(condition, str):
# Callers (parsing._validate_depends_on) test membership in a
# `set` of known condition strings -- `x in a_set` hashes `x`,
# so an unhashable condition (a dict or list) would otherwise
# crash raw with `TypeError: unhashable type` instead of failing
# clean. Checked here, not there: this function already owns
# every other depends_on shape check (list vs mapping, spec must
# be a mapping), so a bad condition type belongs with them, and
# every caller of `depends_on` -- not just validate() -- gets the
# same protection.
msg = f"depends_on entry {dep!r}: condition must be a string"
raise UnsupportedComposeError(msg)
result[dep] = condition
return result


Expand All @@ -39,7 +56,14 @@ def _host_names(name: str, svc: dict[str, Any]) -> list[str]:
if isinstance(networks, dict):
for network in networks.values():
if isinstance(network, dict):
result.extend(network.get("aliases") or [])
aliases = network.get("aliases")
if aliases is None:
continue
# A string would be iterated character-wise by extend() below.
if not isinstance(aliases, list) or not all(isinstance(alias, str) for alias in aliases):
msg = f"service {name!r}: aliases must be a list of strings"
raise UnsupportedComposeError(msg)
result.extend(aliases)
return result


Expand Down
6 changes: 3 additions & 3 deletions compose2pod/healthcheck.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,12 @@ def health_cmd(test: object) -> str | None:
raise UnsupportedComposeError(msg)
kind = test[0]
if kind == "CMD-SHELL":
if len(test) < _CMD_MIN_LENGTH:
if len(test) < _CMD_MIN_LENGTH or not isinstance(test[1], str):
msg = f"unsupported healthcheck test: {test!r}"
raise UnsupportedComposeError(msg)
return test[1] # ty: ignore
return test[1]
if kind == "CMD":
if len(test) < _CMD_MIN_LENGTH:
if len(test) < _CMD_MIN_LENGTH or not all(isinstance(item, str) for item in test[1:]):
msg = f"unsupported healthcheck test: {test!r}"
raise UnsupportedComposeError(msg)
return json.dumps(test[1:])
Expand Down
96 changes: 87 additions & 9 deletions compose2pod/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,44 @@

@dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
class Expand:
"""A token whose Compose variable references expand at script-run time."""
"""A token whose Compose variable references expand at script-run time.

Every emitted `Expand` eventually reaches `shell.to_shell`/`variable_names`,
both of which assume `value` is a `str` (they run a compiled regex over
it) and crash raw (a `TypeError`, not `UnsupportedComposeError`) otherwise.
Rather than trust every call site across keys.py/emit.py/pod.py/
resources.py to have cast or validated first, this is the one chokepoint:
a non-str value is rejected right here, so any per-key validator gap
becomes a clean error instead of a raw crash downstream, no matter which
key leaked it. This is defense-in-depth, not a substitute for validating
shape at the gate -- it only turns an otherwise-unhandled crash into a
clean one; it can't catch a non-str value that a caller has already
coerced to str() (see the silent str()/repr() corruption class instead).
"""

value: str

def __post_init__(self) -> None:
if not isinstance(self.value, str):
msg = f"internal: Expand.value must be a string, got {type(self.value).__name__}: {self.value!r}"
raise UnsupportedComposeError(msg)


Token = str | Expand


def _render_scalar(value: Any) -> str: # noqa: ANN401 - Compose values are untyped YAML/JSON
"""Render a map scalar value the way `docker compose config` does.

A bool renders lowercase ('true'/'false'), matching Docker's own
normalization -- Python's str(True) == 'True' would otherwise leak into
the emitted flag value verbatim.
"""
if isinstance(value, bool):
return "true" if value else "false"
return str(value)


def key_value_pairs(value: list[Any] | dict[str, Any]) -> list[Any]:
"""Compose list/map key-value section as 'KEY=value' / 'KEY' entries.

Expand All @@ -33,7 +63,7 @@ def key_value_pairs(value: list[Any] | dict[str, Any]) -> list[Any]:
"""
if isinstance(value, list):
return value
return [key if val is None else f"{key}={val}" for key, val in value.items()]
return [key if val is None else f"{key}={_render_scalar(val)}" for key, val in value.items()]


@dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
Expand Down Expand Up @@ -61,22 +91,60 @@ def is_number(value: Any) -> bool: # noqa: ANN401 - Compose values are untyped
return not isinstance(value, bool) and isinstance(value, int | float | str)


def require_string_keys(where: str, mapping: dict[Any, Any]) -> None:
"""Check every key of a raw YAML/JSON mapping is a string.

PyYAML routinely produces non-string keys (a bare `3:` is an int; under
YAML 1.1, a bare `on:`/`off:` is a bool). Every mapping-key consumer
downstream (`sorted()`, `str.startswith`, a compiled regex) assumes
`str` and crashes raw otherwise. This is the one shared check the gate
runs before it reads any of `mapping`'s keys, so a non-string key fails
clean regardless of which mapping it turned up in.
"""
for key in mapping:
if not isinstance(key, str):
msg = f"{where}: key {key!r} must be a string"
raise UnsupportedComposeError(msg)


def _validate_number(name: str, key: str, value: Any) -> None: # noqa: ANN401 - Compose values are untyped YAML/JSON
if not is_number(value):
msg = f"service {name!r}: '{key}' must be a number or string"
raise UnsupportedComposeError(msg)


def _validate_list_elements(name: str, key: str, value: list[Any]) -> None:
"""Check every list element is a string, so emit can't str() a non-string into the script."""
for item in value:
if not isinstance(item, str):
msg = f"service {name!r}: '{key}' entries must be strings"
raise UnsupportedComposeError(msg)


def _validate_map_values(name: str, key: str, value: dict[str, Any]) -> None:
"""Check every map value is a scalar or null, so emit can't repr() a dict/list into the script."""
for val in value.values():
if val is not None and not is_number(val) and not isinstance(val, bool):
msg = f"service {name!r}: '{key}' values must be a string, number, boolean, or null"
raise UnsupportedComposeError(msg)


def _validate_list(name: str, key: str, value: Any) -> None: # noqa: ANN401 - Compose values are untyped YAML/JSON
if not isinstance(value, list):
msg = f"service {name!r}: '{key}' must be a list"
raise UnsupportedComposeError(msg)
_validate_list_elements(name, key, value)


def validate_map(name: str, key: str, value: Any) -> None: # noqa: ANN401 - Compose values are untyped YAML/JSON
if not isinstance(value, list | dict):
msg = f"service {name!r}: '{key}' must be a list or mapping"
raise UnsupportedComposeError(msg)
if isinstance(value, list):
_validate_list_elements(name, key, value)
return
if isinstance(value, dict):
_validate_map_values(name, key, value)
return
msg = f"service {name!r}: '{key}' must be a list or mapping"
raise UnsupportedComposeError(msg)


def _as_list(name: str, key: str, value: Any) -> list[Any]: # noqa: ANN401 - Compose values are untyped YAML/JSON
Expand All @@ -101,7 +169,10 @@ def pairs_to_mapping(name: str, key: str, value: Any) -> dict[str, Any]: # noqa
if isinstance(value, list):
result: dict[str, Any] = {}
for item in value:
pair_key, sep, pair_value = str(item).partition("=")
if not isinstance(item, str):
msg = f"service {name!r}: '{key}' entries must be strings"
raise UnsupportedComposeError(msg)
pair_key, sep, pair_value = item.partition("=")
result[pair_key] = pair_value if sep else None
return result
msg = f"service {name!r}: cannot merge {key!r} across incompatible forms"
Expand Down Expand Up @@ -158,7 +229,7 @@ def extra_host_pairs(value: list[Any] | dict[str, Any]) -> list[Any]:
"""Compose extra_hosts as 'host:ip' entries; map values keep their colons (IPv6-safe)."""
if isinstance(value, list):
return value
return [f"{host}:{ip}" for host, ip in value.items()]
return [f"{host}:{_render_scalar(ip)}" for host, ip in value.items()]


def _validate_pull_policy(name: str, key: str, value: Any) -> None: # noqa: ANN401 - Compose values are untyped
Expand All @@ -183,10 +254,17 @@ def _validate_ulimits(name: str, key: str, value: Any) -> None: # noqa: ANN401
if set(spec) != {"soft", "hard"}:
msg = f"service {name!r}: ulimit {limit!r} mapping must have exactly 'soft' and 'hard'"
raise UnsupportedComposeError(msg)
if not isinstance(spec["soft"], int | str) or not isinstance(spec["hard"], int | str):
# bool IS an int in Python, so a plain `isinstance(..., int | str)`
# would silently let a boolean soft/hard value through.
if any(
isinstance(spec[bound], bool) or not isinstance(spec[bound], int | str) for bound in ("soft", "hard")
):
msg = f"service {name!r}: ulimit {limit!r} 'soft' and 'hard' must be int or str"
raise UnsupportedComposeError(msg)
elif not isinstance(spec, int | str):
elif isinstance(spec, bool) or not isinstance(spec, int | str):
# A boolean ulimit is meaningless -- unlike environment's bool
# (which Docker normalizes to a string), there is no sensible
# normalization here, so it is rejected rather than coerced.
msg = f"service {name!r}: ulimit {limit!r} must be an int or a soft/hard mapping"
raise UnsupportedComposeError(msg)

Expand Down
Loading