diff --git a/architecture/glossary.md b/architecture/glossary.md new file mode 100644 index 0000000..cf6d4c0 --- /dev/null +++ b/architecture/glossary.md @@ -0,0 +1,23 @@ +# Glossary + +The project's ubiquitous language — the domain terms that code, specs, and +capability pages share. One term, what it *is* (not what it does), and the +synonyms to reject. + +**Service-key spec**: +The `(validate, emit)` pair for one Compose service key — how that key is checked +and how it renders to `podman run` flags. In code, `KeySpec` in `keys.py`. +_Avoid_: handler, plugin, rule. + +**Service-key registry**: +The table mapping each declarative service-key name to its service-key spec +(`SERVICE_KEYS` in `keys.py`); the single source both the gate (`validate`) and +the emitter (`run_flags`) derive from, so they cannot drift apart. +_Avoid_: map, dispatch table, lookup. + +**Structural key**: +A supported service key handled *outside* the service-key registry because the +`emit(value)` interface cannot express it — it needs `project_dir` +(`env_file`, `volumes`), spans keys, or occupies the image/command slot +(`entrypoint`). Structural keys keep their own validate/emit machinery. +_Avoid_: special key, bespoke key (bespoke describes the spec body, not the key). diff --git a/architecture/supported-subset.md b/architecture/supported-subset.md index 9bd6bbb..03c90b3 100644 --- a/architecture/supported-subset.md +++ b/architecture/supported-subset.md @@ -27,6 +27,16 @@ warns (ignored, behavior-neutral inside a single pod) or raises compose2pod never builds: a `build` section is accepted but its contents (context, dockerfile, args) are not read — `image_for` (`compose2pod/emit.py`) runs the CI image supplied via `--image` for any service that has one. +- **Service-key registry:** the declarative, uniformly-shaped flag keys — + `user`, `working_dir`, `platform`, `init`, `read_only`, `privileged`, + `group_add`, `cap_add`, `cap_drop`, `security_opt`, `devices`, `labels`, + `annotations`, `extra_hosts`, `pull_policy`, `ulimits` — are defined once, + each as a `(validate, emit)` pair, in the **service-key registry** + (`SERVICE_KEYS` in `compose2pod/keys.py`); see `architecture/glossary.md` + for the service-key spec / service-key registry / structural key terms. + The remaining keys documented below are **structural keys**, handled + outside the registry because their `emit` needs `project_dir`, spans + multiple keys, or occupies the image/command slot. - **`environment`:** list form (`- KEY=value`, `- KEY`) or mapping form (`KEY: value`, `KEY:`). A null mapping value (`KEY:`) means "pass `KEY` through from the host", emitted as a bare `-e KEY` exactly like the list @@ -133,14 +143,35 @@ colon-less entry that is not absolute (e.g. `./cache`) is malformed and raises. ## Variable interpolation compose2pod does not resolve Compose Spec `${VAR}` references at generation -time. `to_shell()` (`compose2pod/shell.py`) instead re-encodes every -compose-derived string leaf (`environment`, `image`, `command`, `volumes`, -`tmpfs`, `env_file`, healthcheck `test`) into a double-quoted POSIX-shell -fragment with the variable references left live, so the generated script's -own shell expands them against its runtime environment when the script -runs. Only those field values pass through `to_shell()`; other document -fields (service and network names, ports, and the like) are never -interpolated. Supported forms: `$VAR`, `${VAR}`, +time. `to_shell()` (`compose2pod/shell.py`) instead re-encodes each +interpolated string leaf into a double-quoted POSIX-shell fragment with the +variable references left live, so the generated script's own shell expands +them against its runtime environment when the script runs. + +The interpolated set is exactly what `_Expand(...)` wraps in +`compose2pod/emit.py` and `compose2pod/keys.py` — there is no separate list +to maintain by hand, so treat the **service-key registry** +(`SERVICE_KEYS` in `compose2pod/keys.py`; see `architecture/glossary.md`) +and the `_Expand(...)` call sites in `emit.py` as the source of truth if this +enumeration ever appears to drift: + +- **Structural fields:** `image` (only when the service has no `build` + override — otherwise the CI image is used, not the compose value), + `command`, `entrypoint`, `environment`, `env_file`, `volumes`, `tmpfs`, and + the healthcheck `test` command. +- **Service-key registry fields** whose spec wraps its value in `_Expand`: + `user`, `working_dir`, `platform`, `group_add`, `cap_add`, `cap_drop`, + `security_opt`, `devices`, `labels`, `annotations`, `extra_hosts`, and + `ulimits` — every `SERVICE_KEYS` entry except `pull_policy` (a validated + enum emitted verbatim from `PULL_POLICY_MAP`) and the three boolean flags + `init`/`read_only`/`privileged` (each emits a bare flag with no value to + interpolate). + +Everything else is never interpolated: `build`'s own contents (context, +dockerfile, args — never read), `depends_on`, `networks`, `hostname`, and +`container_name` (the last two are emitted as literal `--add-host +host:127.0.0.1` entries, not expanded), and the healthcheck +`timeout`/`start_period`/`retries` numbers. Supported forms: `$VAR`, `${VAR}`, `${VAR:-default}`, `${VAR-default}`, `${VAR:?msg}`, `${VAR?msg}`, `${VAR:+alt}`, `${VAR+alt}`, and `$$` for a literal `$`. The operator forms map onto identical POSIX `sh` parameter expansion; bare `$VAR`/`${VAR}` is diff --git a/compose2pod/emit.py b/compose2pod/emit.py index e5b24f5..0916021 100644 --- a/compose2pod/emit.py +++ b/compose2pod/emit.py @@ -7,48 +7,18 @@ from compose2pod.graph import depends_on, hostnames, startup_order from compose2pod.healthcheck import health_cmd, interval_seconds +from compose2pod.keys import SERVICE_KEYS, Token, _Expand, _key_value_pairs from compose2pod.shell import to_shell, variable_names HEALTHY_WAIT_BUDGET_SECONDS = 120 -_SCALAR_FLAGS: dict[str, str] = {"user": "--user", "working_dir": "--workdir", "platform": "--platform"} - -_BOOL_FLAGS: dict[str, str] = {"init": "--init", "read_only": "--read-only", "privileged": "--privileged"} - -_LIST_FLAGS: dict[str, str] = { - "group_add": "--group-add", - "cap_add": "--cap-add", - "cap_drop": "--cap-drop", - "security_opt": "--security-opt", - "devices": "--device", -} - -_MAP_FLAGS: dict[str, str] = {"labels": "--label", "annotations": "--annotation"} - -PULL_POLICY_MAP: dict[str, str] = { - "always": "always", - "never": "never", - "missing": "missing", - "if_not_present": "missing", -} - - -@dataclasses.dataclass(frozen=True) -class _Expand: - """A token whose Compose variable references expand at script-run time.""" - - value: str - - -Token = str | _Expand - def image_for(svc: dict[str, Any], ci_image: str) -> Token: """Services with a build section run the freshly built CI image.""" if "build" in svc: return ci_image - return _Expand(svc["image"]) + return _Expand(value=svc["image"]) def command_tokens(svc: dict[str, Any]) -> list[Token]: @@ -57,8 +27,8 @@ def command_tokens(svc: dict[str, Any]) -> list[Token]: if command is None: return [] if isinstance(command, str): - return ["/bin/sh", "-c", _Expand(command)] - return [_Expand(str(token)) for token in command] + return ["/bin/sh", "-c", _Expand(value=command)] + return [_Expand(value=str(token)) for token in command] def entrypoint_tokens(svc: dict[str, Any]) -> list[Token]: @@ -67,15 +37,15 @@ def entrypoint_tokens(svc: dict[str, Any]) -> list[Token]: if entrypoint is None: return [] if isinstance(entrypoint, str): - return ["/bin/sh", "-c", _Expand(entrypoint)] - return [_Expand(str(token)) for token in entrypoint] + return ["/bin/sh", "-c", _Expand(value=entrypoint)] + return [_Expand(value=str(token)) for token in entrypoint] def _add_health_flags(flags: list[Token], healthcheck: dict[str, Any]) -> None: """Add healthcheck flags to the flags list.""" cmd = health_cmd(healthcheck.get("test")) if cmd is not None: - flags += ["--health-cmd", _Expand(cmd)] + flags += ["--health-cmd", _Expand(value=cmd)] if "timeout" in healthcheck: flags += ["--health-timeout", str(healthcheck["timeout"])] if "start_period" in healthcheck: @@ -84,34 +54,16 @@ def _add_health_flags(flags: list[Token], healthcheck: dict[str, Any]) -> None: flags += ["--health-retries", str(healthcheck["retries"])] -def _key_value_pairs(value: list[Any] | dict[str, Any]) -> list[Any]: - """Compose list/map key-value section as 'KEY=value' / 'KEY' entries. - - A null map value yields a bare 'KEY'. Meaning is caller-defined: '-e KEY' - passes the host value through; '--label KEY' sets an empty label. - """ - if isinstance(value, list): - return value - return [key if val is None else f"{key}={val}" for key, val in value.items()] - - -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()] - - def _add_env_flags(flags: list[Token], svc: dict[str, Any], project_dir: str) -> None: """Add -e and --env-file flags to the flags list.""" # A null environment value means "pass KEY through from the host" (bare `-e KEY`). for pair in _key_value_pairs(svc.get("environment") or {}): - flags += ["-e", _Expand(str(pair))] + flags += ["-e", _Expand(value=str(pair))] env_files = svc.get("env_file") or [] if isinstance(env_files, str): env_files = [env_files] for env_file in env_files: - flags += ["--env-file", _Expand(str(Path(project_dir, env_file)))] + flags += ["--env-file", _Expand(value=str(Path(project_dir, env_file)))] def _add_volume_flags(flags: list[Token], svc: dict[str, Any], project_dir: str) -> None: @@ -119,7 +71,7 @@ def _add_volume_flags(flags: list[Token], svc: dict[str, Any], project_dir: str) for volume in svc.get("volumes") or []: if ":" not in volume: # Anonymous volume: a bare container path, no host source to translate. - flags += ["-v", _Expand(volume)] + flags += ["-v", _Expand(value=volume)] continue source, destination = volume.split(":", 1) if source.startswith("."): @@ -127,55 +79,12 @@ def _add_volume_flags(flags: list[Token], svc: dict[str, Any], project_dir: str) source = str(Path(project_dir, source)) # Absolute bind mount (starts with "/") and named volume (bare # identifier) are both kept as-is — neither is a path to translate. - flags += ["-v", _Expand(f"{source}:{destination}")] + flags += ["-v", _Expand(value=f"{source}:{destination}")] tmpfs = svc.get("tmpfs") or [] if isinstance(tmpfs, str): tmpfs = [tmpfs] for mount in tmpfs: - flags += ["--tmpfs", _Expand(mount)] - - -def _add_extra_host_flags(flags: list[Token], svc: dict[str, Any]) -> None: - """Add per-service --add-host flags from extra_hosts (explicit host:ip).""" - for entry in _extra_host_pairs(svc.get("extra_hosts") or []): - flags += ["--add-host", _Expand(str(entry))] - - -def _add_pull_policy_flag(flags: list[Token], svc: dict[str, Any]) -> None: - """Add --pull from pull_policy (a validated enum, mapped to podman's vocabulary).""" - policy = svc.get("pull_policy") - if policy is not None: - flags += ["--pull", PULL_POLICY_MAP[policy]] - - -def _ulimit_args(ulimits: dict[str, Any]) -> list[str]: - """Compose ulimits as podman `name=soft:hard` / `name=value` args.""" - args: list[str] = [] - for limit, spec in ulimits.items(): - args.append(f"{limit}={spec['soft']}:{spec['hard']}" if isinstance(spec, dict) else f"{limit}={spec}") - return args - - -def _add_ulimit_flags(flags: list[Token], svc: dict[str, Any]) -> None: - """Add --ulimit flags from ulimits (a soft/hard mapping or a scalar).""" - for arg in _ulimit_args(svc.get("ulimits") or {}): - flags += ["--ulimit", _Expand(arg)] - - -def _add_declarative_flags(flags: list[Token], svc: dict[str, Any]) -> None: - """Add the scalar-, boolean-, list-, and label-driven flags to the flags list.""" - for key, flag in _SCALAR_FLAGS.items(): - if key in svc: - flags += [flag, _Expand(str(svc[key]))] - for key, flag in _BOOL_FLAGS.items(): - if svc.get(key): - flags.append(flag) - for key, flag in _LIST_FLAGS.items(): - for item in svc.get(key) or []: - flags += [flag, _Expand(str(item))] - for key, flag in _MAP_FLAGS.items(): - for pair in _key_value_pairs(svc.get(key) or {}): - flags += [flag, _Expand(str(pair))] + flags += ["--tmpfs", _Expand(value=mount)] def run_flags(name: str, svc: dict[str, Any], pod: str, hosts: list[str], project_dir: str) -> list[Token]: @@ -186,10 +95,9 @@ def run_flags(name: str, svc: dict[str, Any], pod: str, hosts: list[str], projec _add_env_flags(flags, svc, project_dir) _add_volume_flags(flags, svc, project_dir) _add_health_flags(flags, svc.get("healthcheck") or {}) - _add_declarative_flags(flags, svc) - _add_extra_host_flags(flags, svc) - _add_pull_policy_flag(flags, svc) - _add_ulimit_flags(flags, svc) + for key, spec in SERVICE_KEYS.items(): + if key in svc: + flags += spec.emit(svc[key]) return flags @@ -217,7 +125,7 @@ def run_flags(name: str, svc: dict[str, Any], pod: str, hosts: list[str], projec """ -@dataclasses.dataclass(frozen=True) +@dataclasses.dataclass(frozen=True, slots=True, kw_only=True) class EmitOptions: """Options for emit_script rendering.""" diff --git a/compose2pod/keys.py b/compose2pod/keys.py new file mode 100644 index 0000000..404a980 --- /dev/null +++ b/compose2pod/keys.py @@ -0,0 +1,199 @@ +"""The service-key registry: how each Compose service key is validated and emitted.""" + +import dataclasses +from collections.abc import Callable +from typing import Any + +from compose2pod.exceptions import UnsupportedComposeError # module-level; keys must not import emit/parsing + + +PULL_POLICY_MAP: dict[str, str] = { + "always": "always", + "never": "never", + "missing": "missing", + "if_not_present": "missing", +} + + +@dataclasses.dataclass(frozen=True, slots=True, kw_only=True) +class _Expand: + """A token whose Compose variable references expand at script-run time.""" + + value: str + + +Token = str | _Expand + + +def _key_value_pairs(value: list[Any] | dict[str, Any]) -> list[Any]: + """Compose list/map key-value section as 'KEY=value' / 'KEY' entries. + + A null map value yields a bare 'KEY'. Meaning is caller-defined: '-e KEY' + passes the host value through; '--label KEY' sets an empty label. + """ + if isinstance(value, list): + return value + return [key if val is None else f"{key}={val}" for key, val in value.items()] + + +@dataclasses.dataclass(frozen=True, slots=True, kw_only=True) +class KeySpec: + """A service-key spec: how one Compose service key is validated and emitted.""" + + validate: Callable[[str, str, Any], None] + emit: Callable[[Any], list[Token]] + + +def _validate_scalar(name: str, key: str, value: Any) -> None: # noqa: ANN401 - Compose values are untyped YAML/JSON + if not isinstance(value, str): + msg = f"service {name!r}: '{key}' must be a string" + raise UnsupportedComposeError(msg) + + +def _validate_bool(name: str, key: str, value: Any) -> None: # noqa: ANN401 - Compose values are untyped YAML/JSON + if not isinstance(value, bool): + msg = f"service {name!r}: '{key}' must be a boolean" + 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) + + +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) + + +def _scalar(flag: str) -> KeySpec: + def emit(value: Any) -> list[Token]: # noqa: ANN401 - Compose values are untyped YAML/JSON + return [flag, _Expand(value=str(value))] + + return KeySpec(validate=_validate_scalar, emit=emit) + + +def _bool(flag: str) -> KeySpec: + def emit(value: Any) -> list[Token]: # noqa: ANN401 - Compose values are untyped YAML/JSON + return [flag] if value else [] + + return KeySpec(validate=_validate_bool, emit=emit) + + +def _list(flag: str) -> KeySpec: + def emit(value: Any) -> list[Token]: # noqa: ANN401 - Compose values are untyped YAML/JSON + tokens: list[Token] = [] + for item in value: + tokens += [flag, _Expand(value=str(item))] + return tokens + + return KeySpec(validate=_validate_list, emit=emit) + + +def _map(flag: str) -> KeySpec: + def emit(value: Any) -> list[Token]: # noqa: ANN401 - Compose values are untyped YAML/JSON + tokens: list[Token] = [] + for pair in _key_value_pairs(value): + tokens += [flag, _Expand(value=str(pair))] + return tokens + + return KeySpec(validate=_validate_map, emit=emit) + + +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()] + + +def _emit_extra_hosts(value: Any) -> list[Token]: # noqa: ANN401 - Compose values are untyped YAML/JSON + tokens: list[Token] = [] + for entry in _extra_host_pairs(value): + tokens += ["--add-host", _Expand(value=str(entry))] + return tokens + + +def _validate_pull_policy(name: str, key: str, value: Any) -> None: # noqa: ANN401 - Compose values are untyped + if value is not None and (not isinstance(value, str) or value not in PULL_POLICY_MAP): + allowed = "/".join(PULL_POLICY_MAP) + msg = f"service {name!r}: unsupported {key} {value!r} (use {allowed})" + raise UnsupportedComposeError(msg) + + +def _emit_pull_policy(value: Any) -> list[Token]: # noqa: ANN401 - Compose values are untyped + return ["--pull", PULL_POLICY_MAP[value]] if value is not None else [] + + +def _validate_ulimits(name: str, key: str, value: Any) -> None: # noqa: ANN401 - Compose values are untyped YAML/JSON + if value is None: + return + if not isinstance(value, dict): + msg = f"service {name!r}: '{key}' must be a mapping" + raise UnsupportedComposeError(msg) + for limit, spec in value.items(): + if isinstance(spec, dict): + 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): + 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): + msg = f"service {name!r}: ulimit {limit!r} must be an int or a soft/hard mapping" + raise UnsupportedComposeError(msg) + + +def _ulimit_args(ulimits: dict[str, Any]) -> list[str]: + """Compose ulimits as podman `name=soft:hard` / `name=value` args.""" + args: list[str] = [] + for limit, spec in ulimits.items(): + args.append(f"{limit}={spec['soft']}:{spec['hard']}" if isinstance(spec, dict) else f"{limit}={spec}") + return args + + +def _emit_ulimits(value: Any) -> list[Token]: # noqa: ANN401 - Compose values are untyped YAML/JSON + if value is None: + return [] + tokens: list[Token] = [] + for arg in _ulimit_args(value): + tokens += ["--ulimit", _Expand(value=arg)] + return tokens + + +SERVICE_KEYS: dict[str, KeySpec] = { + "user": _scalar("--user"), + "working_dir": _scalar("--workdir"), + "platform": _scalar("--platform"), + "init": _bool("--init"), + "read_only": _bool("--read-only"), + "privileged": _bool("--privileged"), + "group_add": _list("--group-add"), + "cap_add": _list("--cap-add"), + "cap_drop": _list("--cap-drop"), + "security_opt": _list("--security-opt"), + "devices": _list("--device"), + "labels": _map("--label"), + "annotations": _map("--annotation"), + "extra_hosts": KeySpec(validate=_validate_map, emit=_emit_extra_hosts), + "pull_policy": KeySpec(validate=_validate_pull_policy, emit=_emit_pull_policy), + "ulimits": KeySpec(validate=_validate_ulimits, emit=_emit_ulimits), +} + +STRUCTURAL_KEYS: set[str] = { + "image", + "build", + "command", + "entrypoint", + "environment", + "env_file", + "volumes", + "tmpfs", + "healthcheck", + "depends_on", + "networks", + "hostname", + "container_name", +} diff --git a/compose2pod/parsing.py b/compose2pod/parsing.py index 169a380..f7a5c46 100644 --- a/compose2pod/parsing.py +++ b/compose2pod/parsing.py @@ -2,45 +2,13 @@ from typing import Any -# PULL_POLICY_MAP is shared vocabulary: validation checks membership, emission maps values. -# Deliberate one-way import (emit.py never imports parsing.py), so no cycle. -from compose2pod.emit import PULL_POLICY_MAP from compose2pod.exceptions import UnsupportedComposeError from compose2pod.graph import depends_on from compose2pod.healthcheck import has_healthcheck +from compose2pod.keys import SERVICE_KEYS, STRUCTURAL_KEYS -SUPPORTED_SERVICE_KEYS = { - "image", - "build", - "command", - "entrypoint", - "environment", - "env_file", - "volumes", - "healthcheck", - "depends_on", - "networks", - "hostname", - "container_name", - "tmpfs", - "user", - "working_dir", - "group_add", - "labels", - "read_only", - "init", - "privileged", - "cap_add", - "cap_drop", - "security_opt", - "platform", - "devices", - "annotations", - "extra_hosts", - "pull_policy", - "ulimits", -} +SUPPORTED_SERVICE_KEYS = set(SERVICE_KEYS) | STRUCTURAL_KEYS IGNORED_SERVICE_KEYS = {"ports", "restart", "stdin_open", "tty", "stop_signal", "stop_grace_period"} SUPPORTED_HEALTHCHECK_KEYS = {"test", "interval", "timeout", "retries", "start_period"} SUPPORTED_TOP_LEVEL_KEYS = {"services", "version", "name", "networks", "volumes"} @@ -74,57 +42,11 @@ def _validate_service_volumes(name: str, svc: dict[str, Any]) -> None: # volume implicitly on first reference. -def _validate_service_forms(name: str, svc: dict[str, Any]) -> None: - """Check the process/identity keys use their allowed Compose forms.""" - for key in ("user", "working_dir", "platform"): - if key in svc and not isinstance(svc[key], str): - msg = f"service {name!r}: '{key}' must be a string" - raise UnsupportedComposeError(msg) - for key in ("group_add", "cap_add", "cap_drop", "security_opt", "devices"): - if key in svc and not isinstance(svc[key], list): - msg = f"service {name!r}: '{key}' must be a list" - raise UnsupportedComposeError(msg) - for key in ("labels", "annotations", "extra_hosts"): - if key in svc and not isinstance(svc[key], list | dict): - msg = f"service {name!r}: '{key}' must be a list or mapping" - raise UnsupportedComposeError(msg) +def _validate_entrypoint(name: str, svc: dict[str, Any]) -> None: + """Check the structural entrypoint key's form (it is not a registry key).""" if "entrypoint" in svc and not isinstance(svc["entrypoint"], str | list): msg = f"service {name!r}: 'entrypoint' must be a string or list" raise UnsupportedComposeError(msg) - for key in ("read_only", "init", "privileged"): - if key in svc and not isinstance(svc[key], bool): - msg = f"service {name!r}: '{key}' must be a boolean" - raise UnsupportedComposeError(msg) - - -def _validate_pull_policy(name: str, svc: dict[str, Any]) -> None: - """Check pull_policy is a supported enum value (mapped to podman's --pull).""" - policy = svc.get("pull_policy") - if policy is not None and (not isinstance(policy, str) or policy not in PULL_POLICY_MAP): - allowed = "/".join(PULL_POLICY_MAP) - msg = f"service {name!r}: unsupported pull_policy {policy!r} (use {allowed})" - raise UnsupportedComposeError(msg) - - -def _validate_ulimits(name: str, svc: dict[str, Any]) -> None: - """Check ulimits maps each name to an int/str scalar or a {soft, hard} mapping.""" - ulimits = svc.get("ulimits") - if ulimits is None: - return - if not isinstance(ulimits, dict): - msg = f"service {name!r}: 'ulimits' must be a mapping" - raise UnsupportedComposeError(msg) - for limit, spec in ulimits.items(): - if isinstance(spec, dict): - 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): - 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): - msg = f"service {name!r}: ulimit {limit!r} must be an int or a soft/hard mapping" - raise UnsupportedComposeError(msg) def _validate_service(name: str, svc: dict[str, Any]) -> list[str]: @@ -142,9 +64,10 @@ def _validate_service(name: str, svc: dict[str, Any]) -> list[str]: warnings.append(f"service {name!r}: string entrypoint runs via shell; 'command' is ignored") _validate_service_healthcheck(name, svc) _validate_service_volumes(name, svc) - _validate_service_forms(name, svc) - _validate_pull_policy(name, svc) - _validate_ulimits(name, svc) + _validate_entrypoint(name, svc) + for key, spec in SERVICE_KEYS.items(): + if key in svc: + spec.validate(name, key, svc[key]) return warnings diff --git a/planning/changes/2026-07-09.08-service-key-registry.md b/planning/changes/2026-07-09.08-service-key-registry.md new file mode 100644 index 0000000..d4489fc --- /dev/null +++ b/planning/changes/2026-07-09.08-service-key-registry.md @@ -0,0 +1,109 @@ +--- +summary: Introduce keys.py, a service-key registry — one deep module of per-key (validate, emit) specs — so validate() and run_flags() derive from it instead of three hand-synced enumerations. +--- + +# Design: service-key registry (`keys.py`) + +## Summary + +Collapse the three hand-synced enumerations of the declarative service keys into +one deep module, `compose2pod/keys.py`. It owns the token vocabulary and a +**service-key registry**: a table mapping each of the ~16 per-service flag keys +to a **service-key spec** — a `(validate, emit)` pair. `parsing.validate()` and +`emit.run_flags()` derive from the registry instead of maintaining parallel +lists. This is a behavior-preserving refactor; it also fixes the stale +interpolation list in the architecture doc. + +## Motivation + +A declarative key's knowledge lives in three places that are synced by hand: +`SUPPORTED_SERVICE_KEYS` (membership), the shape tuples in +`_validate_service_forms` (validation), and `_SCALAR_FLAGS`/`_BOOL_FLAGS`/ +`_LIST_FLAGS`/`_MAP_FLAGS` (emission). Nothing at the seam ties them: add a key, +forget one, and validation or emission drifts silently. `extra_hosts` already +diverged (validated with `labels`/`annotations`, emitted alone), and the +architecture doc's interpolation list is stale by ~8 keys. The repo already +knows the fix — `pull_policy` derives from the shared `PULL_POLICY_MAP`, and +`referenced_variables()` re-derives from `_run_tokens` — it just wasn't applied +to the key surface. + +## Design + +**New module `keys.py`** owns, in one place: `Token`, `_Expand`, +`_key_value_pairs`, `PULL_POLICY_MAP` (moved from `emit.py`), plus: + +```python +@dataclass(frozen=True) +class KeySpec: + validate: Callable[[str, str, Any], None] # (service_name, key, value) -> raises + emit: Callable[[Any], list[Token]] # (value) -> tokens +``` + +`SERVICE_KEYS: dict[str, KeySpec]` holds the ~16 declarative-flag keys (`user`, +`working_dir`, `platform`, `init`, `read_only`, `privileged`, `group_add`, +`cap_add`, `cap_drop`, `security_opt`, `devices`, `labels`, `annotations`, +`extra_hosts`, `pull_policy`, `ulimits`), seeded in the current grouped order so +emission order is unchanged. Uniform shapes bind shared module-level functions +(validation is key-agnostic — a scalar check is `isinstance str` regardless of +key; emission binds the flag), e.g. `KeySpec(_validate_scalar, +_emit_scalar("--user"))`. The bespoke keys (`extra_hosts`, `pull_policy`, +`ulimits`) supply their own relocated `validate`/`emit` bodies. + +**Consumers derive from the registry.** `run_flags` and `validate()` each become +one loop: + +```python +for key, spec in SERVICE_KEYS.items(): + if key in svc: + flags += spec.emit(svc[key]) # emit; or spec.validate(name, key, svc[key]) +``` + +This retires `_add_declarative_flags`, the three `_add_*_flags` bespoke helpers, +`_validate_service_forms`, `_validate_pull_policy`, and `_validate_ulimits`. +`_validate_service_forms` disappearing also removes the recurring `C901` +pressure. + +**Membership is derived:** `SUPPORTED_SERVICE_KEYS = set(SERVICE_KEYS) | +STRUCTURAL_KEYS`, where `STRUCTURAL_KEYS` (in `keys.py`) is the explicit set of +keys handled outside the registry. A **structural key** is a supported key the +`emit(value)` interface deliberately can't express — it needs `project_dir` +(`env_file`, `volumes`) or spans keys / the image slot (`entrypoint`), so it +keeps its own machinery (`_add_env_flags`, `_add_volume_flags`, +`_add_health_flags`, `graph.py`, `image_for`, the `entrypoint` handling in +`_run_tokens`). + +**Import direction fixed:** both `parsing` and `emit` import from the neutral +`keys.py`, not the validator-imports-emitter inversion the last review flagged. +`emit.py` shrinks to script assembly. + +New vocabulary (**service-key spec**, **service-key registry**, **structural +key**) is seeded in `architecture/glossary.md`. + +## Non-goals + +- Closing the Candidate-2 validation holes (`hostname`/`tmpfs`/`networks` shape + checks) — a separate change; the registry neither creates nor worsens them. +- A typed validated-document model (Candidate 3) — out of scope. +- Migrating structural keys into the registry — they don't fit `emit(value)`. + +## Testing + +Behavior-preserving: every existing per-key test stays unchanged and green +through all four tasks. New meta-tests: `SERVICE_KEYS`/`STRUCTURAL_KEYS` +disjoint, and a snapshot of `SUPPORTED_SERVICE_KEYS` (the honest-subset contract, +made explicit). `just test-ci` at 100% (the shared shape functions are each +exercised by an existing per-key test); `just lint-ci` clean (`C901` pressure +drops). Land as one PR, four staged SDD tasks (extract vocabulary → registry + +uniform keys → bespoke + derive + guards → doc/glossary), each independently +green. + +## Risk + +- **Mechanical error in the move** (med x low): staged behavior-preserving tasks, + each gated by the unchanged per-key suite. +- **`ty` typing of spec callables** (low x low): `partial`/closure for the + flag-bound emitters must satisfy `Callable[[Any], list[Token]]`; resolved in + Task 2 with whichever form types cleanly. +- **Emission-order change** (low x low): one dict iterates in insertion order + instead of four grouped tables; mitigated by seeding grouped order — the only + order-sensitive tests are within a shape. diff --git a/tests/test_emit.py b/tests/test_emit.py index 168142f..6734a5d 100644 --- a/tests/test_emit.py +++ b/tests/test_emit.py @@ -20,8 +20,8 @@ def test_db_flags(self, chats_compose: dict) -> None: assert flags[:4] == ["--pod", "test-pod", "--name", "test-pod-db"] assert flags[4:6] == ["--add-host", "db:127.0.0.1"] assert flags[6:8] == ["--add-host", "keydb:127.0.0.1"] - assert flags[8:10] == ["-e", _Expand("POSTGRES_PASSWORD=password")] - assert flags[10:12] == ["--health-cmd", _Expand("pg_isready -U database -d database")] + assert flags[8:10] == ["-e", _Expand(value="POSTGRES_PASSWORD=password")] + assert flags[10:12] == ["--health-cmd", _Expand(value="pg_isready -U database -d database")] assert flags[12:14] == ["--health-timeout", "5s"] assert flags[14:16] == ["--health-retries", "15"] # fix #2 @@ -34,120 +34,120 @@ def test_start_period_is_passed_through(self) -> None: def test_env_map_form(self) -> None: svc = {"image": "x", "environment": {"A": "1", "B": "two words"}} flags = run_flags("app", svc, "p", [], "/builds/x") - assert flags[4:8] == ["-e", _Expand("A=1"), "-e", _Expand("B=two words")] + assert flags[4:8] == ["-e", _Expand(value="A=1"), "-e", _Expand(value="B=two words")] def test_env_map_null_value_is_host_passthrough(self) -> None: # A null mapping value means "pass KEY through from the host", like `- KEY`. svc = {"image": "x", "environment": {"PASSTHRU": None, "SET": "v"}} flags = run_flags("app", svc, "p", [], "/builds/x") - assert flags[4:8] == ["-e", _Expand("PASSTHRU"), "-e", _Expand("SET=v")] + assert flags[4:8] == ["-e", _Expand(value="PASSTHRU"), "-e", _Expand(value="SET=v")] def test_env_file_and_volume_resolved_against_project_dir(self) -> None: svc = {"image": "x", "env_file": "tests.env", "volumes": [".:/srv/www/"]} flags = run_flags("app", svc, "p", [], "/builds/chats") - assert flags[4:6] == ["--env-file", _Expand("/builds/chats/tests.env")] - assert flags[6:8] == ["-v", _Expand("/builds/chats:/srv/www/")] + assert flags[4:6] == ["--env-file", _Expand(value="/builds/chats/tests.env")] + assert flags[6:8] == ["-v", _Expand(value="/builds/chats:/srv/www/")] def test_env_file_list_form(self) -> None: svc = {"image": "x", "env_file": ["a.env", "b.env"]} flags = run_flags("app", svc, "p", [], "/builds/x") assert flags[4:8] == [ "--env-file", - _Expand("/builds/x/a.env"), + _Expand(value="/builds/x/a.env"), "--env-file", - _Expand("/builds/x/b.env"), + _Expand(value="/builds/x/b.env"), ] def test_tmpfs_string_form(self) -> None: # S108 flags "/tmp" as an insecure hardcoded temp path; this is a # pass-through string being tested, not a file write. flags = run_flags("app", {"image": "x", "tmpfs": "/tmp:mode=1777"}, "p", [], "/builds/x") # noqa: S108 - assert flags[4:6] == ["--tmpfs", _Expand("/tmp:mode=1777")] # noqa: S108 + assert flags[4:6] == ["--tmpfs", _Expand(value="/tmp:mode=1777")] # noqa: S108 def test_tmpfs_list_form(self) -> None: svc = {"image": "x", "tmpfs": ["/tmp:mode=1777", "/run"]} # noqa: S108 flags = run_flags("app", svc, "p", [], "/builds/x") - assert flags[4:8] == ["--tmpfs", _Expand("/tmp:mode=1777"), "--tmpfs", _Expand("/run")] # noqa: S108 + assert flags[4:8] == ["--tmpfs", _Expand(value="/tmp:mode=1777"), "--tmpfs", _Expand(value="/run")] # noqa: S108 def test_absolute_volume_source_is_kept_as_is(self) -> None: flags = run_flags("app", {"image": "x", "volumes": ["/data/app:/srv/www/"]}, "p", [], "/builds/x") - assert flags[4:6] == ["-v", _Expand("/data/app:/srv/www/")] + assert flags[4:6] == ["-v", _Expand(value="/data/app:/srv/www/")] def test_anonymous_volume_emitted_as_single_path(self) -> None: flags = run_flags("app", {"image": "x", "volumes": ["/var/cache/models"]}, "p", [], "/builds/x") - assert flags[4:6] == ["-v", _Expand("/var/cache/models")] + assert flags[4:6] == ["-v", _Expand(value="/var/cache/models")] def test_named_volume_emitted_without_project_dir_translation(self) -> None: svc = {"image": "x", "volumes": ["pgdata:/var/lib/postgresql/data"]} flags = run_flags("db", svc, "p", [], "/builds/x") - assert flags[4:6] == ["-v", _Expand("pgdata:/var/lib/postgresql/data")] + assert flags[4:6] == ["-v", _Expand(value="pgdata:/var/lib/postgresql/data")] def test_healthcheck_without_timeout_omits_health_timeout_flag(self) -> None: flags = run_flags("app", {"image": "x", "healthcheck": {"test": "true"}}, "p", [], "/builds/x") - assert flags[4:6] == ["--health-cmd", _Expand("true")] + assert flags[4:6] == ["--health-cmd", _Expand(value="true")] assert "--health-timeout" not in flags def test_user_flag(self) -> None: flags = run_flags("app", {"image": "x", "user": "1000:1000"}, "p", [], "/b") - assert flags[4:6] == ["--user", _Expand("1000:1000")] + assert flags[4:6] == ["--user", _Expand(value="1000:1000")] def test_working_dir_flag(self) -> None: flags = run_flags("app", {"image": "x", "working_dir": "/srv/app"}, "p", [], "/b") - assert flags[4:6] == ["--workdir", _Expand("/srv/app")] + assert flags[4:6] == ["--workdir", _Expand(value="/srv/app")] def test_user_and_working_dir_order(self) -> None: svc = {"image": "x", "user": "root", "working_dir": "/app"} flags = run_flags("app", svc, "p", [], "/b") - assert flags[4:8] == ["--user", _Expand("root"), "--workdir", _Expand("/app")] + assert flags[4:8] == ["--user", _Expand(value="root"), "--workdir", _Expand(value="/app")] def test_group_add_flag(self) -> None: flags = run_flags("app", {"image": "x", "group_add": ["docker", 1000]}, "p", [], "/b") - assert flags[4:8] == ["--group-add", _Expand("docker"), "--group-add", _Expand("1000")] + assert flags[4:8] == ["--group-add", _Expand(value="docker"), "--group-add", _Expand(value="1000")] def test_cap_add_flag(self) -> None: flags = run_flags("app", {"image": "x", "cap_add": ["NET_ADMIN", "SYS_TIME"]}, "p", [], "/b") - assert flags[4:8] == ["--cap-add", _Expand("NET_ADMIN"), "--cap-add", _Expand("SYS_TIME")] + assert flags[4:8] == ["--cap-add", _Expand(value="NET_ADMIN"), "--cap-add", _Expand(value="SYS_TIME")] def test_cap_drop_and_security_opt_flags(self) -> None: svc = {"image": "x", "cap_drop": ["ALL"], "security_opt": ["label=disable"]} flags = run_flags("app", svc, "p", [], "/b") - assert flags[4:8] == ["--cap-drop", _Expand("ALL"), "--security-opt", _Expand("label=disable")] + assert flags[4:8] == ["--cap-drop", _Expand(value="ALL"), "--security-opt", _Expand(value="label=disable")] def test_labels_map_form(self) -> None: svc = {"image": "x", "labels": {"team": "api", "tier": "backend"}} flags = run_flags("app", svc, "p", [], "/b") - assert flags[4:8] == ["--label", _Expand("team=api"), "--label", _Expand("tier=backend")] + assert flags[4:8] == ["--label", _Expand(value="team=api"), "--label", _Expand(value="tier=backend")] def test_labels_list_form(self) -> None: svc = {"image": "x", "labels": ["team=api", "standalone"]} flags = run_flags("app", svc, "p", [], "/b") - assert flags[4:8] == ["--label", _Expand("team=api"), "--label", _Expand("standalone")] + assert flags[4:8] == ["--label", _Expand(value="team=api"), "--label", _Expand(value="standalone")] def test_labels_null_value_is_empty_label(self) -> None: # A null map value is an empty label here, NOT the host-passthrough that # `environment`'s null means -- same emitted shape, distinct meaning. flags = run_flags("app", {"image": "x", "labels": {"empty": None}}, "p", [], "/b") - assert flags[4:6] == ["--label", _Expand("empty")] + assert flags[4:6] == ["--label", _Expand(value="empty")] def test_platform_flag(self) -> None: flags = run_flags("app", {"image": "x", "platform": "linux/amd64"}, "p", [], "/b") - assert flags[4:6] == ["--platform", _Expand("linux/amd64")] + assert flags[4:6] == ["--platform", _Expand(value="linux/amd64")] def test_devices_flag(self) -> None: flags = run_flags("app", {"image": "x", "devices": ["/dev/fuse", "/dev/net/tun"]}, "p", [], "/b") - assert flags[4:8] == ["--device", _Expand("/dev/fuse"), "--device", _Expand("/dev/net/tun")] + assert flags[4:8] == ["--device", _Expand(value="/dev/fuse"), "--device", _Expand(value="/dev/net/tun")] def test_annotations_map_form(self) -> None: flags = run_flags("app", {"image": "x", "annotations": {"com.example/team": "api"}}, "p", [], "/b") - assert flags[4:6] == ["--annotation", _Expand("com.example/team=api")] + assert flags[4:6] == ["--annotation", _Expand(value="com.example/team=api")] def test_annotations_null_value_is_bare_key(self) -> None: flags = run_flags("app", {"image": "x", "annotations": {"marker": None}}, "p", [], "/b") - assert flags[4:6] == ["--annotation", _Expand("marker")] + assert flags[4:6] == ["--annotation", _Expand(value="marker")] def test_labels_still_emit_after_map_flags_refactor(self) -> None: flags = run_flags("app", {"image": "x", "labels": {"team": "api"}}, "p", [], "/b") - assert flags[4:6] == ["--label", _Expand("team=api")] + assert flags[4:6] == ["--label", _Expand(value="team=api")] def test_read_only_flag(self) -> None: flags = run_flags("app", {"image": "x", "read_only": True}, "p", [], "/b") @@ -165,15 +165,15 @@ def test_boolean_flag_false_or_absent_is_omitted(self) -> None: def test_extra_hosts_list_form(self) -> None: flags = run_flags("app", {"image": "x", "extra_hosts": ["db.local:10.0.0.5"]}, "p", [], "/b") - assert flags[4:6] == ["--add-host", _Expand("db.local:10.0.0.5")] + assert flags[4:6] == ["--add-host", _Expand(value="db.local:10.0.0.5")] def test_extra_hosts_map_form(self) -> None: flags = run_flags("app", {"image": "x", "extra_hosts": {"db.local": "10.0.0.5"}}, "p", [], "/b") - assert flags[4:6] == ["--add-host", _Expand("db.local:10.0.0.5")] + assert flags[4:6] == ["--add-host", _Expand(value="db.local:10.0.0.5")] def test_extra_hosts_ipv6_value_keeps_colons(self) -> None: flags = run_flags("app", {"image": "x", "extra_hosts": {"myhost": "2001:db8::1"}}, "p", [], "/b") - assert flags[4:6] == ["--add-host", _Expand("myhost:2001:db8::1")] + assert flags[4:6] == ["--add-host", _Expand(value="myhost:2001:db8::1")] def test_pull_policy_maps_if_not_present_to_missing(self) -> None: flags = run_flags("app", {"image": "x", "pull_policy": "if_not_present"}, "p", [], "/b") @@ -187,16 +187,50 @@ def test_pull_policy_passthrough_values(self) -> None: def test_ulimits_mapping_form(self) -> None: svc = {"image": "x", "ulimits": {"nofile": {"soft": 20000, "hard": 40000}}} flags = run_flags("app", svc, "p", [], "/b") - assert flags[4:6] == ["--ulimit", _Expand("nofile=20000:40000")] + assert flags[4:6] == ["--ulimit", _Expand(value="nofile=20000:40000")] def test_ulimits_scalar_form(self) -> None: flags = run_flags("app", {"image": "x", "ulimits": {"nproc": 65535}}, "p", [], "/b") - assert flags[4:6] == ["--ulimit", _Expand("nproc=65535")] + assert flags[4:6] == ["--ulimit", _Expand(value="nproc=65535")] def test_ulimits_mixed_forms(self) -> None: svc = {"image": "x", "ulimits": {"nproc": 65535, "nofile": {"soft": 1024, "hard": 2048}}} flags = run_flags("app", svc, "p", [], "/b") - assert flags[4:8] == ["--ulimit", _Expand("nproc=65535"), "--ulimit", _Expand("nofile=1024:2048")] + assert flags[4:8] == ["--ulimit", _Expand(value="nproc=65535"), "--ulimit", _Expand(value="nofile=1024:2048")] + + def test_null_pull_policy_and_ulimits_emit_nothing(self) -> None: + flags = run_flags("app", {"image": "x", "pull_policy": None, "ulimits": None}, "p", [], "/b") + assert flags == ["--pod", "p", "--name", "p-app"] + + def test_registry_emission_order_across_shape_groups(self) -> None: + # Locks the cross-key flag order (scalar, bool, list, map, extra_hosts, pull_policy, ulimits) + # against a future reordering of the SERVICE_KEYS registry. + svc = { + "image": "x", + "user": "root", + "init": True, + "cap_add": ["NET_ADMIN"], + "labels": {"team": "api"}, + "extra_hosts": {"db": "10.0.0.5"}, + "pull_policy": "always", + "ulimits": {"nofile": 1024}, + } + flags = run_flags("app", svc, "p", [], "/b") + assert flags[4:] == [ + "--user", + _Expand(value="root"), + "--init", + "--cap-add", + _Expand(value="NET_ADMIN"), + "--label", + _Expand(value="team=api"), + "--add-host", + _Expand(value="db:10.0.0.5"), + "--pull", + "always", + "--ulimit", + _Expand(value="nofile=1024"), + ] class TestImageAndCommand: @@ -204,17 +238,17 @@ def test_build_service_uses_ci_image(self, chats_compose: dict) -> None: assert image_for(chats_compose["services"]["application"], "reg/ci:abc") == "reg/ci:abc" def test_plain_service_keeps_image(self, chats_compose: dict) -> None: - assert image_for(chats_compose["services"]["db"], "reg/ci:abc") == _Expand("postgres:13.5-alpine") + assert image_for(chats_compose["services"]["db"], "reg/ci:abc") == _Expand(value="postgres:13.5-alpine") def test_command_list_passes_through(self, chats_compose: dict) -> None: assert command_tokens(chats_compose["services"]["migrations"]) == [ - _Expand("alembic"), - _Expand("upgrade"), - _Expand("head"), + _Expand(value="alembic"), + _Expand(value="upgrade"), + _Expand(value="head"), ] def test_command_string_becomes_shell(self) -> None: - assert command_tokens({"command": "echo hi"}) == ["/bin/sh", "-c", _Expand("echo hi")] + assert command_tokens({"command": "echo hi"}) == ["/bin/sh", "-c", _Expand(value="echo hi")] def test_missing_command_is_empty(self) -> None: assert command_tokens({"image": "x"}) == [] @@ -222,10 +256,10 @@ def test_missing_command_is_empty(self) -> None: class TestEntrypoint: def test_list_form_passes_through(self) -> None: - assert entrypoint_tokens({"entrypoint": ["sleep", "600"]}) == [_Expand("sleep"), _Expand("600")] + assert entrypoint_tokens({"entrypoint": ["sleep", "600"]}) == [_Expand(value="sleep"), _Expand(value="600")] def test_string_form_becomes_shell(self) -> None: - assert entrypoint_tokens({"entrypoint": "serve now"}) == ["/bin/sh", "-c", _Expand("serve now")] + assert entrypoint_tokens({"entrypoint": "serve now"}) == ["/bin/sh", "-c", _Expand(value="serve now")] def test_missing_entrypoint_is_empty(self) -> None: assert entrypoint_tokens({"image": "x"}) == [] diff --git a/tests/test_keys.py b/tests/test_keys.py new file mode 100644 index 0000000..3d400a6 --- /dev/null +++ b/tests/test_keys.py @@ -0,0 +1,40 @@ +from compose2pod.keys import SERVICE_KEYS, STRUCTURAL_KEYS +from compose2pod.parsing import SUPPORTED_SERVICE_KEYS + + +def test_registry_and_structural_keys_are_disjoint() -> None: + assert not (set(SERVICE_KEYS) & STRUCTURAL_KEYS) + + +def test_supported_service_keys_snapshot() -> None: + assert { + "image", + "build", + "command", + "entrypoint", + "environment", + "env_file", + "volumes", + "tmpfs", + "healthcheck", + "depends_on", + "networks", + "hostname", + "container_name", + "user", + "working_dir", + "platform", + "init", + "read_only", + "privileged", + "group_add", + "cap_add", + "cap_drop", + "security_opt", + "devices", + "labels", + "annotations", + "extra_hosts", + "pull_policy", + "ulimits", + } == SUPPORTED_SERVICE_KEYS diff --git a/tests/test_parsing.py b/tests/test_parsing.py index abe6d13..8832b15 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -249,3 +249,9 @@ def test_sysctls_stays_unsupported(self) -> None: # Deliberate: sysctls is pod-level (decisions/2026-07-09-sysctls-pod-level.md), keeps raising. with pytest.raises(UnsupportedComposeError, match="unsupported key 'sysctls'"): validate({"services": {"app": {"image": "x", "sysctls": {"net.core.somaxconn": 1024}}}}) + + def test_pull_policy_null_is_accepted(self) -> None: + assert validate({"services": {"app": {"image": "x", "pull_policy": None}}}) == [] + + def test_ulimits_null_is_accepted(self) -> None: + assert validate({"services": {"app": {"image": "x", "ulimits": None}}}) == []