From 14e9c6cb526b47f373348809206b81b116422df2 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 12 Jul 2026 13:20:58 +0300 Subject: [PATCH] refactor: collapse the store cluster into one stores.py module Fold store.py, configs.py, secrets.py, and the old stores.py tuple into a single stores.py that mirrors keys.py (StoreKind + SECRET/CONFIG + a module-private _STORE_KINDS + machinery). The public interface hides the kinds tuple (flags/create_lines/teardown_line/referenced_variables/validate), and teardown emission moves into the module so emit no longer names the podman "secret" noun. Behavior-preserving: the emitted script is byte-identical. Tests drive the public interface at 100% coverage. See planning/changes/2026-07-12.02-collapse-store-cluster.md. --- architecture/glossary.md | 15 + architecture/supported-subset.md | 64 ++--- compose2pod/configs.py | 13 - compose2pod/emit.py | 16 +- compose2pod/parsing.py | 5 +- compose2pod/secrets.py | 13 - compose2pod/store.py | 217 --------------- compose2pod/stores.py | 260 +++++++++++++++++- .../2026-07-12.02-collapse-store-cluster.md | 97 +++++++ tests/test_configs.py | 66 ----- tests/test_store.py | 142 ---------- tests/test_stores.py | 203 ++++++++++++++ 12 files changed, 613 insertions(+), 498 deletions(-) delete mode 100644 compose2pod/configs.py delete mode 100644 compose2pod/secrets.py delete mode 100644 compose2pod/store.py create mode 100644 planning/changes/2026-07-12.02-collapse-store-cluster.md delete mode 100644 tests/test_configs.py delete mode 100644 tests/test_store.py create mode 100644 tests/test_stores.py diff --git a/architecture/glossary.md b/architecture/glossary.md index cf6d4c0..4d28ae3 100644 --- a/architecture/glossary.md +++ b/architecture/glossary.md @@ -21,3 +21,18 @@ A supported service key handled *outside* the service-key registry because the (`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). + +**Store kind**: +One flavor of podman-secret-backed store — a Compose `secret` or `config` — with +its own namespacing prefix, allowed sources, and default mount target +(`StoreKind` in `stores.py`). Both kinds render as podman secrets (podman has no +config primitive), so they differ in namespacing and mount, never in the podman +noun; the noun lives in `stores.py` alone. +_Avoid_: secret type, store type, backend. + +**Store registry**: +The tuple of every store kind (`_STORE_KINDS = (SECRET, CONFIG)` in `stores.py`), +module-private so the store interface (`validate`, `flags`, `create_lines`, +`teardown_line`, `referenced_variables`) hides the kinds from callers — the same +single-source shape as the service-key registry. +_Avoid_: store list, kinds table. diff --git a/architecture/supported-subset.md b/architecture/supported-subset.md index aa10acb..0267a1e 100644 --- a/architecture/supported-subset.md +++ b/architecture/supported-subset.md @@ -305,51 +305,52 @@ colon-less entry that is not absolute (e.g. `./cache`) is malformed and raises. relative) or `environment:` (a host environment variable name), both as a plain string. `external: true` gets its own rejection message (compose's "must already exist" secrets have no analogue here); any other unrecognized - key raises generically (`_validate_def`, `compose2pod/store.py`, via the - `SECRET` `StoreKind` in `compose2pod/secrets.py`). + key raises generically (`_validate_def`, `compose2pod/stores.py`, via the + `SECRET` `StoreKind` in `compose2pod/stores.py`). - **Per-service `secrets:` references:** short form (`- name`) or long form (a mapping with `source` and optionally `target`, `uid`, `gid`, `mode`). `source` must name a top-level secret; an unknown `source` raises at - `validate()` time (`_ref_source`/`validate_all`, `compose2pod/store.py` -- - `validate_all` dispatches per store kind through the internal + `validate()` time (`_ref_source`/`stores.validate`, `compose2pod/stores.py` -- + `stores.validate` dispatches per store kind through the internal `_validate_kind`). - **Closure-scoped creation:** only secrets referenced (by `source`) from somewhere in the target service's dependency closure are ever created, so a top-level secret nothing in the closure references never becomes a - `podman secret create` call (`referenced_names`, `compose2pod/store.py`, + `podman secret create` call (`_referenced_names`, `compose2pod/stores.py`, driven by the same `startup_order` closure used to decide which services run at all). - **Creation:** each referenced secret becomes one pod-namespaced `podman secret create - ...` line, emitted right after - `podman pod create` and before any `podman run` by `all_create_lines` - (`compose2pod/store.py`, called from `emit_script`, `compose2pod/emit.py`). + `podman pod create` and before any `podman run` by `stores.create_lines` + (`compose2pod/stores.py`, called from `emit_script`, `compose2pod/emit.py`). A `file:` source resolves `Path(project_dir, file)` through `to_shell()`, so a `${VAR}` in the path expands live when the script runs, exactly like other interpolated fields. An `environment:` source instead pipes `printf '%s' "${VAR-}"` into `podman secret create ... -`, where the `${VAR-}` means an *unset* host variable yields an empty secret rather than - failing the script (`create_lines`, `compose2pod/store.py`). + failing the script (`_create_lines_for`, `compose2pod/stores.py`). - **Mounting:** each service reference becomes a `--secret source=-,target=` flag on that service's - `podman run`, assembled per service by `all_flags` (`compose2pod/store.py`, + `podman run`, assembled per service by `stores.flags` (`compose2pod/stores.py`, called from `emit_script`, `compose2pod/emit.py`), where `target` defaults to the secret's own name when the reference doesn't give one (short form, or long form without `target`). `uid`/`gid`/`mode` are only added when the long form gives them explicitly; `mode` renders as a 4-digit octal string when given as a Python int (`0o400` becomes `"0400"`) and passes through - verbatim when given as a string (`flags`, `compose2pod/store.py`). When + verbatim when given as a string (`_flags_for`, `compose2pod/stores.py`). When `uid`/`gid`/`mode` are omitted, podman itself applies its own defaults: the secret is mounted at `/run/secrets/`, owned `0:0`, mode `0444`. - **Teardown:** the EXIT trap that force-removes the pod also runs `podman secret rm - ...` for every referenced secret, so the - store never outlives the pod even when the script exits abnormally - (`all_teardown_names`, `compose2pod/store.py`, called from `emit_script`, - `compose2pod/emit.py`). + store never outlives the pod even when the script exits abnormally. The + store module returns the complete best-effort trap fragment and + `emit_script` splices it after the pod-removal fragment + (`stores.teardown_line`, `compose2pod/stores.py`; `compose2pod/emit.py`). - **Variable interpolation:** an `environment:` source's variable name, and any `${VAR}` inside a `file:` path, both count toward the CLI's informational stderr note of variables the generated script expands at run - time (`referenced_variables`, `compose2pod/store.py`, assembled across - store kinds by `all_referenced_variables` and folded into + time (`_referenced_variables_for`, `compose2pod/stores.py`, assembled across + store kinds by `stores.referenced_variables` and folded into `referenced_variables()` in `compose2pod/emit.py`); see Variable interpolation, below, for the note itself. - Everything else raises `UnsupportedComposeError` rather than silently doing @@ -363,24 +364,24 @@ colon-less entry that is not absolute (e.g. `./cache`) is malformed and raises. relative), `environment:` (a host environment variable name), or `content:` (inline literal text), all as a plain string. `external: true` gets its own rejection message, mirroring secrets; any other unrecognized key raises - generically (`_validate_def`, `compose2pod/store.py`, via the `CONFIG` - `StoreKind` in `compose2pod/configs.py`). + generically (`_validate_def`, `compose2pod/stores.py`, via the `CONFIG` + `StoreKind` in `compose2pod/stores.py`). - **Per-service `configs:` references:** short form (`- name`) or long form (a mapping with `source` and optionally `target`, `uid`, `gid`, `mode`). `source` must name a top-level config; an unknown `source` raises at - `validate()` time (`_ref_source`/`validate_all`, `compose2pod/store.py`). + `validate()` time (`_ref_source`/`stores.validate`, `compose2pod/stores.py`). - **Closure-scoped creation:** only configs referenced (by `source`) from somewhere in the target service's dependency closure are ever created, so a top-level config nothing in the closure references never becomes a `podman secret create` call -- the same closure-scoped-creation rule - secrets follow (`referenced_names`, `compose2pod/store.py`). + secrets follow (`_referenced_names`, `compose2pod/stores.py`). - **Creation and delivery:** configs are delivered through podman's secret store, exactly like secrets, but pod- *and kind*-namespaced with a `config-` prefix (store name `-config-`, vs. a secret's bare `-`), so a config never collides with a same-named secret. Each referenced config becomes one `podman secret create -config- ...` line, emitted right after `podman pod create` and before any - `podman run` by `all_create_lines` (`compose2pod/store.py`, called from + `podman run` by `stores.create_lines` (`compose2pod/stores.py`, called from `emit_script`, `compose2pod/emit.py`). A `file:` source resolves `Path(project_dir, file)` through `to_shell()`; an `environment:` source pipes `printf '%s' "${VAR-}"` into `podman secret create ... -`; and a @@ -388,32 +389,33 @@ colon-less entry that is not absolute (e.g. `./cache`) is malformed and raises. same `podman secret create ... -` form, so a `${VAR}` written inside `content:` stays live and expands against the generated script's own runtime environment when the script runs -- the same deferred-interpolation - model as `file:`/`environment:` (`create_lines`, `compose2pod/store.py`). + model as `file:`/`environment:` (`_create_lines_for`, `compose2pod/stores.py`). - **Mounting:** each service reference becomes a `--secret source=-config-,target=` flag on that - service's `podman run`, assembled per service by `all_flags` - (`compose2pod/store.py`, called from `emit_script`, `compose2pod/emit.py`). + service's `podman run`, assembled per service by `stores.flags` + (`compose2pod/stores.py`, called from `emit_script`, `compose2pod/emit.py`). Unlike a secret, whose default `target` is its own name (mounted by podman under `/run/secrets/`), a config's default `target` is the container-root absolute path `/` (`CONFIG.default_target`, - `compose2pod/configs.py`). A long-form `target` must be an absolute path + `compose2pod/stores.py`). A long-form `target` must be an absolute path (start with `/`); a relative target raises (`CONFIG.require_absolute_target`, checked by `_check_target`, - `compose2pod/store.py`). `uid`/`gid`/`mode` behave exactly as for secrets: + `compose2pod/stores.py`). `uid`/`gid`/`mode` behave exactly as for secrets: only added when the long form gives them explicitly, `mode` renders as a 4-digit octal string when given as a Python int and passes through - verbatim when given as a string (`flags`, `compose2pod/store.py`). + verbatim when given as a string (`_flags_for`, `compose2pod/stores.py`). - **Teardown:** the EXIT trap that force-removes the pod also runs `podman secret rm -config- ...` for every referenced config, so the store never outlives the pod even when the script exits abnormally -- - byte-for-byte the same teardown parity as secrets (`all_teardown_names`, - `compose2pod/store.py`, called from `emit_script`, `compose2pod/emit.py`). + byte-for-byte the same teardown parity as secrets (`stores.teardown_line`, + `compose2pod/stores.py`, spliced into the trap by `emit_script`, + `compose2pod/emit.py`). - **Variable interpolation:** an `environment:` source's variable name, any `${VAR}` inside a `file:` path, and any `${VAR}` inside `content:` all count toward the CLI's informational stderr note of variables the - generated script expands at run time (`referenced_variables`, - `compose2pod/store.py`, assembled across store kinds by - `all_referenced_variables` and folded into `referenced_variables()` in + generated script expands at run time (`_referenced_variables_for`, + `compose2pod/stores.py`, assembled across store kinds by + `stores.referenced_variables` and folded into `referenced_variables()` in `compose2pod/emit.py`); see Variable interpolation, below, for the note itself. - Everything else raises `UnsupportedComposeError` rather than silently diff --git a/compose2pod/configs.py b/compose2pod/configs.py deleted file mode 100644 index 6ba0da4..0000000 --- a/compose2pod/configs.py +++ /dev/null @@ -1,13 +0,0 @@ -"""The config StoreKind: a podman secret mounted at the container-root path /.""" - -from compose2pod.store import StoreKind - - -CONFIG = StoreKind( - label="config", - top_key="configs", - prefix="config-", - sources=frozenset({"file", "environment", "content"}), - default_target=lambda name: f"/{name}", - require_absolute_target=True, -) diff --git a/compose2pod/emit.py b/compose2pod/emit.py index 9f0d973..989cf75 100644 --- a/compose2pod/emit.py +++ b/compose2pod/emit.py @@ -6,6 +6,7 @@ from pathlib import Path from typing import Any +from compose2pod import stores from compose2pod.exceptions import UnsupportedComposeError from compose2pod.graph import depends_on, hostnames, startup_order from compose2pod.healthcheck import health_cmd, interval_seconds @@ -13,8 +14,6 @@ from compose2pod.pod import pod_create_flags from compose2pod.resources import deploy_resource_flags from compose2pod.shell import to_shell, variable_names -from compose2pod.store import all_create_lines, all_flags, all_referenced_variables, all_teardown_names -from compose2pod.stores import STORE_KINDS HEALTHY_WAIT_BUDGET_SECONDS = 120 @@ -105,7 +104,7 @@ def run_flags(name: str, svc: dict[str, Any], pod: str, hosts: list[str], projec for key, spec in SERVICE_KEYS.items(): if key in svc: flags += spec.emit(svc[key]) - flags += all_flags(svc, pod, STORE_KINDS) + flags += stores.flags(svc, pod) flags += deploy_resource_flags(svc) return flags @@ -205,17 +204,16 @@ def emit_script(compose: dict[str, Any], options: EmitOptions) -> str: lines = [_SCRIPT_HEADER] teardown = f"podman pod rm -f {shlex.quote(options.pod)} >/dev/null 2>&1 || true" - store_names = all_teardown_names(services, order, options.pod, STORE_KINDS) - if store_names: - stores = " ".join(store_names) - teardown += f"; podman secret rm {stores} >/dev/null 2>&1 || true" + store_teardown = stores.teardown_line(compose, order, options.pod) + if store_teardown: + teardown += f"; {store_teardown}" lines.append(f"trap '{teardown}' EXIT") pod_flags = pod_create_flags(services, order) pod_create = f"podman pod create --name {shlex.quote(options.pod)}" if pod_flags: pod_create += " " + _render(pod_flags) lines.append(pod_create) - lines.extend(all_create_lines(compose, order, options.pod, options.project_dir, STORE_KINDS)) + lines.extend(stores.create_lines(compose, order, options.pod, options.project_dir)) waited: set[str] = set() for name in order: for dep, condition in depends_on(services[name]).items(): @@ -252,5 +250,5 @@ def referenced_variables(compose: dict[str, Any], options: EmitOptions) -> list[ for token in pod_create_flags(services, order): if isinstance(token, _Expand): names.update(variable_names(token.value)) - names |= all_referenced_variables(compose, order, options.project_dir, STORE_KINDS) + names |= stores.referenced_variables(compose, order, options.project_dir) return sorted(names) diff --git a/compose2pod/parsing.py b/compose2pod/parsing.py index a5b432c..3bf4535 100644 --- a/compose2pod/parsing.py +++ b/compose2pod/parsing.py @@ -2,14 +2,13 @@ from typing import Any +from compose2pod import stores from compose2pod.exceptions import UnsupportedComposeError from compose2pod.graph import depends_on, hostnames from compose2pod.healthcheck import has_healthcheck, interval_seconds from compose2pod.keys import SERVICE_KEYS, STRUCTURAL_KEYS from compose2pod.pod import uses_pod_options, validate_pod_options from compose2pod.resources import validate_deploy -from compose2pod.store import validate_all -from compose2pod.stores import STORE_KINDS SUPPORTED_SERVICE_KEYS = set(SERVICE_KEYS) | STRUCTURAL_KEYS @@ -135,7 +134,7 @@ def validate(compose: dict[str, Any]) -> list[str]: warnings.extend(_validate_service(name, svc)) hostnames(services) # validate hostname/container_name/networks shapes at the gate _validate_depends_on(services) - validate_all(compose, STORE_KINDS) + stores.validate(compose) if uses_pod_options(services): warnings.append( "dns/sysctls apply pod-wide -- all containers in the pod share one /etc/resolv.conf and sysctl set" diff --git a/compose2pod/secrets.py b/compose2pod/secrets.py deleted file mode 100644 index f0cac9e..0000000 --- a/compose2pod/secrets.py +++ /dev/null @@ -1,13 +0,0 @@ -"""The secret StoreKind: a podman secret mounted at /run/secrets/.""" - -from compose2pod.store import StoreKind - - -SECRET = StoreKind( - label="secret", - top_key="secrets", - prefix="", - sources=frozenset({"file", "environment"}), - default_target=lambda name: name, - require_absolute_target=False, -) diff --git a/compose2pod/store.py b/compose2pod/store.py deleted file mode 100644 index df0e91e..0000000 --- a/compose2pod/store.py +++ /dev/null @@ -1,217 +0,0 @@ -"""Podman secret store machinery shared by compose secrets and configs.""" - -import dataclasses -import re -from collections.abc import Callable -from pathlib import Path -from typing import Any - -from compose2pod.exceptions import UnsupportedComposeError -from compose2pod.keys import Token -from compose2pod.shell import to_shell, variable_names - - -_LONG_FORM_KEYS = {"source", "target", "uid", "gid", "mode"} -_NAME = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_.-]*$") -_ENV_NAME = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$") - - -@dataclasses.dataclass(frozen=True, slots=True, kw_only=True) -class StoreKind: - """One store flavor (secret or config): namespacing, sources, default mount.""" - - label: str - top_key: str - prefix: str - sources: frozenset[str] - default_target: Callable[[str], str] - require_absolute_target: bool - - -def _validate_def(name: str, definition: Any, kind: StoreKind) -> None: # noqa: ANN401 - Compose values are untyped - if not _NAME.fullmatch(name): - msg = f"{kind.label} name {name!r} must match [a-zA-Z0-9][a-zA-Z0-9_.-]*" - raise UnsupportedComposeError(msg) - if not isinstance(definition, dict): - msg = f"{kind.label} {name!r} must be a mapping" - raise UnsupportedComposeError(msg) - unknown = set(definition) - kind.sources - if unknown: - if "external" in unknown: - msg = ( - f"{kind.label} {name!r}: external {kind.label}s are not supported (use a file: or environment: source)" - ) - raise UnsupportedComposeError(msg) - msg = f"{kind.label} {name!r}: unsupported keys {sorted(unknown)}" - raise UnsupportedComposeError(msg) - sources = [key for key in sorted(kind.sources) if isinstance(definition.get(key), str)] - if len(sources) != 1: - allowed = " or ".join(f"'{source}'" for source in sorted(kind.sources)) - msg = f"{kind.label} {name!r} must have exactly one of {allowed} (a string)" - raise UnsupportedComposeError(msg) - if isinstance(definition.get("environment"), str) and not _ENV_NAME.fullmatch(definition["environment"]): - msg = ( - f"{kind.label} {name!r}: environment variable name {definition['environment']!r} is not a valid identifier" - ) - raise UnsupportedComposeError(msg) - - -def _check_long_form_scalars(name: str, ref: dict[str, Any], kind: StoreKind) -> None: - for key in ("uid", "gid", "mode"): - if key in ref and (isinstance(ref[key], bool) or not isinstance(ref[key], int | str)): - msg = f"service {name!r}: {kind.label} {key!r} must be an int or string" - raise UnsupportedComposeError(msg) - - -def _check_target(name: str, ref: dict[str, Any], kind: StoreKind) -> None: - if kind.require_absolute_target and isinstance(ref.get("target"), str) and not ref["target"].startswith("/"): - msg = f"service {name!r}: {kind.label} target {ref['target']!r} must be an absolute path" - raise UnsupportedComposeError(msg) - - -def _ref_source(name: str, ref: Any, kind: StoreKind) -> str: # noqa: ANN401 - Compose values are untyped - if isinstance(ref, str): - return ref - if not isinstance(ref, dict): - msg = f"service {name!r}: {kind.label} entry must be a string or mapping" - raise UnsupportedComposeError(msg) - unknown = set(ref) - _LONG_FORM_KEYS - if unknown: - msg = f"service {name!r}: unsupported {kind.label} keys {sorted(unknown)}" - raise UnsupportedComposeError(msg) - source = ref.get("source") - if not isinstance(source, str): - msg = f"service {name!r}: {kind.label} entry 'source' must be a string" - raise UnsupportedComposeError(msg) - _check_long_form_scalars(name, ref, kind) - _check_target(name, ref, kind) - return source - - -def _validate_kind(compose: dict[str, Any], kind: StoreKind) -> None: - defs = compose.get(kind.top_key) - if defs is not None and not isinstance(defs, dict): - msg = f"top-level {kind.top_key!r} must be a mapping" - raise UnsupportedComposeError(msg) - defs = defs or {} - for name, definition in defs.items(): - _validate_def(name, definition, kind) - for name, svc in (compose.get("services") or {}).items(): - refs = svc.get(kind.top_key) - if refs is None: - continue - if not isinstance(refs, list): - msg = f"service {name!r}: {kind.top_key} must be a list" - raise UnsupportedComposeError(msg) - for ref in refs: - source = _ref_source(name, ref, kind) - if source not in defs: - msg = f"service {name!r}: unknown {kind.label} {source!r}" - raise UnsupportedComposeError(msg) - - -def _mode_str(mode: Any) -> str: # noqa: ANN401 - Compose values are untyped - return format(mode, "04o") if isinstance(mode, int) else str(mode) - - -def referenced_names(services: dict[str, Any], order: list[str], kind: StoreKind) -> list[str]: - """Store names referenced by services in `order`, unique in first-seen order.""" - seen: dict[str, None] = {} - for name in order: - for ref in services[name].get(kind.top_key) or []: - seen[ref if isinstance(ref, str) else ref["source"]] = None - return list(seen) - - -def flags(svc: dict[str, Any], pod: str, kind: StoreKind) -> list[Token]: - """Per-service `--secret source=-,target=...` flag tokens.""" - tokens: list[Token] = [] - for ref in svc.get(kind.top_key) or []: - opts = {} if isinstance(ref, str) else ref - source = ref if isinstance(ref, str) else ref["source"] - parts = [f"source={pod}-{kind.prefix}{source}", f"target={opts.get('target', kind.default_target(source))}"] - parts += [f"{key}={opts[key]}" for key in ("uid", "gid") if key in opts] - if "mode" in opts: - parts.append(f"mode={_mode_str(opts['mode'])}") - tokens += ["--secret", ",".join(parts)] - return tokens - - -def create_lines(compose: dict[str, Any], pod: str, project_dir: str, names: list[str], kind: StoreKind) -> list[str]: - """`podman secret create` lines for the referenced stores (file or environment source).""" - defs = compose.get(kind.top_key) or {} - lines: list[str] = [] - for name in names: - definition = defs[name] - store = f"{pod}-{kind.prefix}{name}" - if isinstance(definition.get("file"), str): - path = to_shell(str(Path(project_dir, definition["file"]))) - lines.append(f"podman secret create {store} {path}") - elif isinstance(definition.get("content"), str): - lines.append(f"printf '%s' {to_shell(definition['content'])} | podman secret create {store} -") - else: - var = definition["environment"] - lines.append(f"printf '%s' \"${{{var}-}}\" | podman secret create {store} -") - return lines - - -def referenced_variables(compose: dict[str, Any], project_dir: str, names: list[str], kind: StoreKind) -> set[str]: - """Run-time variable names the create lines expand (env-source vars + file-path vars).""" - defs = compose.get(kind.top_key) or {} - result: set[str] = set() - for name in names: - definition = defs[name] - if isinstance(definition.get("file"), str): - result |= variable_names(str(Path(project_dir, definition["file"]))) - elif isinstance(definition.get("content"), str): - result |= variable_names(definition["content"]) - else: - result.add(definition["environment"]) - return result - - -def validate_all(compose: dict[str, Any], kinds: tuple[StoreKind, ...]) -> None: - """Validate every kind's top-level definitions and service references.""" - for kind in kinds: - _validate_kind(compose, kind) - - -def all_flags(svc: dict[str, Any], pod: str, kinds: tuple[StoreKind, ...]) -> list[Token]: - tokens: list[Token] = [] - for kind in kinds: - tokens += flags(svc, pod, kind) - return tokens - - -def all_teardown_names(services: dict[str, Any], order: list[str], pod: str, kinds: tuple[StoreKind, ...]) -> list[str]: - names: list[str] = [] - for kind in kinds: - names += [f"{pod}-{kind.prefix}{name}" for name in referenced_names(services, order, kind)] - return names - - -def all_create_lines( - compose: dict[str, Any], - order: list[str], - pod: str, - project_dir: str, - kinds: tuple[StoreKind, ...], -) -> list[str]: - services = compose.get("services") or {} - lines: list[str] = [] - for kind in kinds: - lines += create_lines(compose, pod, project_dir, referenced_names(services, order, kind), kind) - return lines - - -def all_referenced_variables( - compose: dict[str, Any], - order: list[str], - project_dir: str, - kinds: tuple[StoreKind, ...], -) -> set[str]: - services = compose.get("services") or {} - result: set[str] = set() - for kind in kinds: - result |= referenced_variables(compose, project_dir, referenced_names(services, order, kind), kind) - return result diff --git a/compose2pod/stores.py b/compose2pod/stores.py index afed5fb..b9e56c2 100644 --- a/compose2pod/stores.py +++ b/compose2pod/stores.py @@ -1,7 +1,259 @@ -"""The registry of store kinds that emit and parsing weave into the script.""" +"""The store registry: how compose secrets and configs are validated and emitted. -from compose2pod.configs import CONFIG -from compose2pod.secrets import SECRET +Both compose `secrets` and `configs` render as podman secrets -- podman has no +config primitive -- so the two `StoreKind`s differ only in namespacing and +default mount, never in the podman noun. This module owns that noun end to end; +callers never name `secret`. +""" +import dataclasses +import re +from collections.abc import Callable +from pathlib import Path +from typing import Any -STORE_KINDS = (SECRET, CONFIG) +from compose2pod.exceptions import UnsupportedComposeError +from compose2pod.keys import Token +from compose2pod.shell import to_shell, variable_names + + +_LONG_FORM_KEYS = {"source", "target", "uid", "gid", "mode"} +_NAME = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_.-]*$") +_ENV_NAME = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$") + + +@dataclasses.dataclass(frozen=True, slots=True, kw_only=True) +class StoreKind: + """One store flavor (secret or config): namespacing, sources, default mount.""" + + label: str + top_key: str + prefix: str + sources: frozenset[str] + default_target: Callable[[str], str] + require_absolute_target: bool + + +# The secret StoreKind: a podman secret mounted at /run/secrets/. +SECRET = StoreKind( + label="secret", + top_key="secrets", + prefix="", + sources=frozenset({"file", "environment"}), + default_target=lambda name: name, + require_absolute_target=False, +) + +# The config StoreKind: a podman secret mounted at the container-root path /. +CONFIG = StoreKind( + label="config", + top_key="configs", + prefix="config-", + sources=frozenset({"file", "environment", "content"}), + default_target=lambda name: f"/{name}", + require_absolute_target=True, +) + +# Order is significant: it fixes the flag/create/teardown emission order +# (secrets before configs) across the whole script. +_STORE_KINDS = (SECRET, CONFIG) + + +def _validate_def(name: str, definition: Any, kind: StoreKind) -> None: # noqa: ANN401 - Compose values are untyped + if not _NAME.fullmatch(name): + msg = f"{kind.label} name {name!r} must match [a-zA-Z0-9][a-zA-Z0-9_.-]*" + raise UnsupportedComposeError(msg) + if not isinstance(definition, dict): + msg = f"{kind.label} {name!r} must be a mapping" + raise UnsupportedComposeError(msg) + unknown = set(definition) - kind.sources + if unknown: + if "external" in unknown: + msg = ( + f"{kind.label} {name!r}: external {kind.label}s are not supported (use a file: or environment: source)" + ) + raise UnsupportedComposeError(msg) + msg = f"{kind.label} {name!r}: unsupported keys {sorted(unknown)}" + raise UnsupportedComposeError(msg) + sources = [key for key in sorted(kind.sources) if isinstance(definition.get(key), str)] + if len(sources) != 1: + allowed = " or ".join(f"'{source}'" for source in sorted(kind.sources)) + msg = f"{kind.label} {name!r} must have exactly one of {allowed} (a string)" + raise UnsupportedComposeError(msg) + if isinstance(definition.get("environment"), str) and not _ENV_NAME.fullmatch(definition["environment"]): + msg = ( + f"{kind.label} {name!r}: environment variable name {definition['environment']!r} is not a valid identifier" + ) + raise UnsupportedComposeError(msg) + + +def _check_long_form_scalars(name: str, ref: dict[str, Any], kind: StoreKind) -> None: + for key in ("uid", "gid", "mode"): + if key in ref and (isinstance(ref[key], bool) or not isinstance(ref[key], int | str)): + msg = f"service {name!r}: {kind.label} {key!r} must be an int or string" + raise UnsupportedComposeError(msg) + + +def _check_target(name: str, ref: dict[str, Any], kind: StoreKind) -> None: + if kind.require_absolute_target and isinstance(ref.get("target"), str) and not ref["target"].startswith("/"): + msg = f"service {name!r}: {kind.label} target {ref['target']!r} must be an absolute path" + raise UnsupportedComposeError(msg) + + +def _ref_source(name: str, ref: Any, kind: StoreKind) -> str: # noqa: ANN401 - Compose values are untyped + if isinstance(ref, str): + return ref + if not isinstance(ref, dict): + msg = f"service {name!r}: {kind.label} entry must be a string or mapping" + raise UnsupportedComposeError(msg) + unknown = set(ref) - _LONG_FORM_KEYS + if unknown: + msg = f"service {name!r}: unsupported {kind.label} keys {sorted(unknown)}" + raise UnsupportedComposeError(msg) + source = ref.get("source") + if not isinstance(source, str): + msg = f"service {name!r}: {kind.label} entry 'source' must be a string" + raise UnsupportedComposeError(msg) + _check_long_form_scalars(name, ref, kind) + _check_target(name, ref, kind) + return source + + +def _validate_kind(compose: dict[str, Any], kind: StoreKind) -> None: + defs = compose.get(kind.top_key) + if defs is not None and not isinstance(defs, dict): + msg = f"top-level {kind.top_key!r} must be a mapping" + raise UnsupportedComposeError(msg) + defs = defs or {} + for name, definition in defs.items(): + _validate_def(name, definition, kind) + for name, svc in (compose.get("services") or {}).items(): + refs = svc.get(kind.top_key) + if refs is None: + continue + if not isinstance(refs, list): + msg = f"service {name!r}: {kind.top_key} must be a list" + raise UnsupportedComposeError(msg) + for ref in refs: + source = _ref_source(name, ref, kind) + if source not in defs: + msg = f"service {name!r}: unknown {kind.label} {source!r}" + raise UnsupportedComposeError(msg) + + +def _mode_str(mode: Any) -> str: # noqa: ANN401 - Compose values are untyped + return format(mode, "04o") if isinstance(mode, int) else str(mode) + + +def _referenced_names(services: dict[str, Any], order: list[str], kind: StoreKind) -> list[str]: + """Store names referenced by services in `order`, unique in first-seen order.""" + seen: dict[str, None] = {} + for name in order: + for ref in services[name].get(kind.top_key) or []: + seen[ref if isinstance(ref, str) else ref["source"]] = None + return list(seen) + + +def _flags_for(svc: dict[str, Any], pod: str, kind: StoreKind) -> list[Token]: + """Per-service `--secret source=-,target=...` flag tokens.""" + tokens: list[Token] = [] + for ref in svc.get(kind.top_key) or []: + opts = {} if isinstance(ref, str) else ref + source = ref if isinstance(ref, str) else ref["source"] + parts = [f"source={pod}-{kind.prefix}{source}", f"target={opts.get('target', kind.default_target(source))}"] + parts += [f"{key}={opts[key]}" for key in ("uid", "gid") if key in opts] + if "mode" in opts: + parts.append(f"mode={_mode_str(opts['mode'])}") + tokens += ["--secret", ",".join(parts)] + return tokens + + +def _create_lines_for( + compose: dict[str, Any], + pod: str, + project_dir: str, + names: list[str], + kind: StoreKind, +) -> list[str]: + """`podman secret create` lines for the referenced stores (file or environment source).""" + defs = compose.get(kind.top_key) or {} + lines: list[str] = [] + for name in names: + definition = defs[name] + store = f"{pod}-{kind.prefix}{name}" + if isinstance(definition.get("file"), str): + path = to_shell(str(Path(project_dir, definition["file"]))) + lines.append(f"podman secret create {store} {path}") + elif isinstance(definition.get("content"), str): + lines.append(f"printf '%s' {to_shell(definition['content'])} | podman secret create {store} -") + else: + var = definition["environment"] + lines.append(f"printf '%s' \"${{{var}-}}\" | podman secret create {store} -") + return lines + + +def _referenced_variables_for( + compose: dict[str, Any], + project_dir: str, + names: list[str], + kind: StoreKind, +) -> set[str]: + """Run-time variable names the create lines expand (env-source vars + file-path vars).""" + defs = compose.get(kind.top_key) or {} + result: set[str] = set() + for name in names: + definition = defs[name] + if isinstance(definition.get("file"), str): + result |= variable_names(str(Path(project_dir, definition["file"]))) + elif isinstance(definition.get("content"), str): + result |= variable_names(definition["content"]) + else: + result.add(definition["environment"]) + return result + + +def validate(compose: dict[str, Any]) -> None: + """Validate every kind's top-level definitions and service references.""" + for kind in _STORE_KINDS: + _validate_kind(compose, kind) + + +def flags(svc: dict[str, Any], pod: str) -> list[Token]: + """Per-service `--secret` flag tokens across every store kind (secrets, then configs).""" + tokens: list[Token] = [] + for kind in _STORE_KINDS: + tokens += _flags_for(svc, pod, kind) + return tokens + + +def teardown_line(compose: dict[str, Any], order: list[str], pod: str) -> str: + """Best-effort EXIT-trap fragment removing every referenced store, or "" if none. + + Returns the complete fragment -- suppression plus `|| true` -- so a failed + removal can never abort the trap and leak the pod. + """ + services = compose.get("services") or {} + names: list[str] = [] + for kind in _STORE_KINDS: + names += [f"{pod}-{kind.prefix}{name}" for name in _referenced_names(services, order, kind)] + if not names: + return "" + return f"podman secret rm {' '.join(names)} >/dev/null 2>&1 || true" + + +def create_lines(compose: dict[str, Any], order: list[str], pod: str, project_dir: str) -> list[str]: + """`podman secret create` lines for every referenced store (secrets, then configs).""" + services = compose.get("services") or {} + lines: list[str] = [] + for kind in _STORE_KINDS: + lines += _create_lines_for(compose, pod, project_dir, _referenced_names(services, order, kind), kind) + return lines + + +def referenced_variables(compose: dict[str, Any], order: list[str], project_dir: str) -> set[str]: + """Run-time variable names every referenced store's create lines expand.""" + services = compose.get("services") or {} + result: set[str] = set() + for kind in _STORE_KINDS: + result |= _referenced_variables_for(compose, project_dir, _referenced_names(services, order, kind), kind) + return result diff --git a/planning/changes/2026-07-12.02-collapse-store-cluster.md b/planning/changes/2026-07-12.02-collapse-store-cluster.md new file mode 100644 index 0000000..2ed839b --- /dev/null +++ b/planning/changes/2026-07-12.02-collapse-store-cluster.md @@ -0,0 +1,97 @@ +--- +summary: Collapse the four-file store cluster into one deep stores.py module, hide the kinds tuple behind the interface, and move podman-secret teardown emission out of emit. +--- + +# Design: Collapse the store cluster into one deep module + +## Summary + +Fold `store.py`, `stores.py`, `configs.py`, and `secrets.py` into a single +`stores.py` (mirroring `keys.py`), internalize the `STORE_KINDS` tuple so +callers stop threading it, and move the `podman secret rm` teardown emission out +of `emit.py` into the store module. Behavior of the emitted script is unchanged; +this is a restructuring for locality. + +## Motivation + +One store concept is spread across four modules — machinery (`store.py`, 217 +LoC), a registry tuple (`stores.py`, 7 LoC), and two `StoreKind` data literals +(`configs.py`, `secrets.py`, 13 LoC each). Three costs: + +- **The podman noun leaks.** `"secret"` appears in `store.py` (`--secret`, + `podman secret create`) *and* in `emit.py:211` (`podman secret rm`). `emit` + reaching across the seam to re-state that stores are podman secrets is the + leak. +- **`STORE_KINDS` is noise at every call site.** `emit` threads it through four + `all_*` calls and `parsing` through one; the tuple is never varied. +- **Testing is inverted.** ~45 assertions test the per-kind functions directly; + the aggregate layer `emit` actually calls is covered only transitively. + +## Design + +**One module `stores.py`** holds `StoreKind` + `SECRET` + `CONFIG` + a +module-private `_STORE_KINDS = (SECRET, CONFIG)` + all machinery. Delete the +other three files. (Also retires the `compose2pod.secrets` module that shadowed +stdlib `secrets`.) + +**Interface** — qualified `from compose2pod import stores`, no `kinds` param; the +aggregate functions all take `compose` first: + +| Public | Replaces | +|---|---| +| `stores.flags(svc, pod)` | `all_flags(.., KINDS)` | +| `stores.create_lines(compose, order, pod, project_dir)` | `all_create_lines(.., KINDS)` | +| `stores.teardown_line(compose, order, pod)` | `all_teardown_names(.., KINDS)` | +| `stores.referenced_variables(compose, order, project_dir)` | `all_referenced_variables(.., KINDS)` | +| `stores.validate(compose)` | `validate_all(.., KINDS)` | + +Per-kind bodies become private helpers (`_flags_for(svc, pod, kind)`, …) that +the public functions loop over `_STORE_KINDS`. Qualified import avoids the clash +between `stores.validate` and `parsing`'s own `validate`. + +**The noun stays in `stores.py` only.** `StoreKind` gets *no* noun field — the +noun does not vary per kind (configs *are* podman secrets; the per-kind axis is +already `prefix`/`default_target`/`sources`/`require_absolute_target`). +`teardown_line` returns the complete best-effort fragment +`podman secret rm >/dev/null 2>&1 || true` (or `""`). `emit` builds its +own `podman pod rm` fragment, then splices: `if frag: teardown += f"; {frag}"` — +and no longer names `secret`. Create lines stay bare (no suppression): under +`set -eu` a failed create must fail loud, a failed teardown must fail quiet — an +asymmetry now visible in one module. + +**Tests** through the public interface. `test_store.py` + `test_configs.py` +merge into `test_stores.py`, driving `stores.*` with single-kind and mixed +composes; per-kind helpers covered transitively; `SECRET`/`CONFIG` no longer +imported by tests. Migration is cheap: the aggregate over an absent kind +contributes nothing, so most assertions survive by dropping the kind arg. + +**Glossary.** Add store terms (`StoreKind`, store registry) to +`architecture/glossary.md` in the implementing PR, alongside the existing +service-key entries. + +## Non-goals + +- No change to the emitted script or the supported subset — pure restructuring. + `architecture/supported-subset.md`'s Secrets/Configs sections still need their + module-path and function-name citations repointed from the deleted files to + `stores.py` and the new interface, but the described behavior is unchanged. +- Not typing the compose dict — orthogonal, and rejected in + `decisions/2026-07-10-reject-parse-dont-validate.md`. + +## Testing + +- `just test-ci` passes at 100% line coverage (`--cov-fail-under=100`) — every + private per-kind branch reached through the public interface. +- `just lint-ci` clean (ruff `select=ALL`, ty, eof-fixer, planning check). +- Golden check: the emitted script for a secrets+configs compose is + byte-identical before and after (behavior-preserving). + +## Risk + +- **Coverage gaps from privatizing helpers** (likely / low impact): a per-kind + branch no longer reachable through public calls. Mitigation: the 100% gate + fails the PR; add a targeted mixed-kind compose. +- **Teardown fragment regression** (low / high impact): a missing + `|| true` would let a failed `podman secret rm` abort the trap and leak the + pod. Mitigation: the byte-identical golden check plus an explicit + `teardown_line` assertion. diff --git a/tests/test_configs.py b/tests/test_configs.py deleted file mode 100644 index 2c0c4d6..0000000 --- a/tests/test_configs.py +++ /dev/null @@ -1,66 +0,0 @@ -from typing import Any - -import pytest - -from compose2pod.configs import CONFIG -from compose2pod.exceptions import UnsupportedComposeError -from compose2pod.store import create_lines, flags, referenced_variables, validate_all - - -_KINDS = (CONFIG,) - - -def _doc(configs: Any = None, svc_configs: Any = None) -> dict[str, Any]: # noqa: ANN401 - Compose values are untyped - svc: dict[str, Any] = {"image": "x"} - if svc_configs is not None: - svc["configs"] = svc_configs - doc: dict[str, Any] = {"services": {"app": svc}} - if configs is not None: - doc["configs"] = configs - return doc - - -class TestValidateConfigKind: - def test_content_source_accepted(self) -> None: - validate_all(_doc({"c": {"content": "hello"}}, ["c"]), _KINDS) - - def test_absolute_long_form_target_accepted(self) -> None: - validate_all(_doc({"c": {"file": "./c"}}, [{"source": "c", "target": "/etc/c.conf"}]), _KINDS) - - def test_relative_long_form_target_rejected(self) -> None: - with pytest.raises(UnsupportedComposeError, match=r"config target 'c.conf' must be an absolute path"): - validate_all(_doc({"c": {"file": "./c"}}, [{"source": "c", "target": "c.conf"}]), _KINDS) - - def test_external_rejected_with_config_wording(self) -> None: - with pytest.raises(UnsupportedComposeError, match="external configs are not supported"): - validate_all(_doc({"c": {"external": True}}, ["c"]), _KINDS) - - def test_unknown_referenced_config_rejected(self) -> None: - with pytest.raises(UnsupportedComposeError, match="unknown config"): - validate_all(_doc({"c": {"file": "./c"}}, ["ghost"]), _KINDS) - - -class TestConfigEmission: - def test_short_form_flag_defaults_to_root_target(self) -> None: - assert flags({"configs": ["nginx"]}, "p", CONFIG) == ["--secret", "source=p-config-nginx,target=/nginx"] - - def test_long_form_absolute_target(self) -> None: - svc = {"configs": [{"source": "nginx", "target": "/etc/nginx/nginx.conf", "mode": 0o444}]} - assert flags(svc, "p", CONFIG) == [ - "--secret", - "source=p-config-nginx,target=/etc/nginx/nginx.conf,mode=0444", - ] - - def test_content_source_create_line_expands_at_run_time(self) -> None: - doc = {"configs": {"c": {"content": "token=${API_TOKEN}"}}} - assert create_lines(doc, "p", "/proj", ["c"], CONFIG) == [ - "printf '%s' \"token=${API_TOKEN-}\" | podman secret create p-config-c -" - ] - - def test_file_source_create_line_is_config_prefixed(self) -> None: - doc = {"configs": {"c": {"file": "./c.conf"}}} - assert create_lines(doc, "p", "/proj", ["c"], CONFIG) == ['podman secret create p-config-c "/proj/c.conf"'] - - def test_content_referenced_variables(self) -> None: - doc = {"configs": {"c": {"content": "a=${A} b=${B}"}}} - assert referenced_variables(doc, "/proj", ["c"], CONFIG) == {"A", "B"} diff --git a/tests/test_store.py b/tests/test_store.py deleted file mode 100644 index fe45419..0000000 --- a/tests/test_store.py +++ /dev/null @@ -1,142 +0,0 @@ -from typing import Any - -import pytest - -from compose2pod.exceptions import UnsupportedComposeError -from compose2pod.secrets import SECRET -from compose2pod.store import ( - create_lines, - flags, - referenced_names, - referenced_variables, - validate_all, -) - - -_KINDS = (SECRET,) - - -def _doc(secrets: Any = None, svc_secrets: Any = None) -> dict[str, Any]: # noqa: ANN401 - Compose values are untyped - svc: dict[str, Any] = {"image": "x"} - if svc_secrets is not None: - svc["secrets"] = svc_secrets - doc: dict[str, Any] = {"services": {"app": svc}} - if secrets is not None: - doc["secrets"] = secrets - return doc - - -class TestValidateSecretKind: - def test_file_and_environment_sources_accepted(self) -> None: - validate_all(_doc({"a": {"file": "./a.txt"}, "b": {"environment": "B"}}, ["a", "b"]), _KINDS) - - def test_long_form_reference_accepted(self) -> None: - doc = _doc({"a": {"file": "./a.txt"}}, [{"source": "a", "target": "t", "uid": "1", "gid": "2", "mode": 0o400}]) - validate_all(doc, _KINDS) - - def test_external_rejected(self) -> None: - with pytest.raises(UnsupportedComposeError, match="external secrets are not supported"): - validate_all(_doc({"a": {"external": True}}, ["a"]), _KINDS) - - def test_two_sources_rejected(self) -> None: - with pytest.raises(UnsupportedComposeError, match="must have exactly one of"): - validate_all(_doc({"a": {"file": "./a", "environment": "A"}}, ["a"]), _KINDS) - - def test_unknown_definition_key_rejected(self) -> None: - with pytest.raises(UnsupportedComposeError, match="unsupported keys"): - validate_all(_doc({"a": {"file": "./a", "driver": "x"}}, ["a"]), _KINDS) - - def test_non_mapping_definition_rejected(self) -> None: - with pytest.raises(UnsupportedComposeError, match="must be a mapping"): - validate_all(_doc({"a": ["./a"]}, ["a"]), _KINDS) - - def test_unknown_referenced_secret_rejected(self) -> None: - with pytest.raises(UnsupportedComposeError, match="unknown secret"): - validate_all(_doc({"a": {"file": "./a"}}, ["ghost"]), _KINDS) - - def test_service_secrets_not_a_list_rejected(self) -> None: - with pytest.raises(UnsupportedComposeError, match="secrets must be a list"): - validate_all(_doc({"a": {"file": "./a"}}, {"a": {}}), _KINDS) - - def test_long_form_unknown_key_rejected(self) -> None: - with pytest.raises(UnsupportedComposeError, match="unsupported secret keys"): - validate_all(_doc({"a": {"file": "./a"}}, [{"source": "a", "bogus": 1}]), _KINDS) - - def test_top_level_secrets_not_a_mapping_rejected(self) -> None: - with pytest.raises(UnsupportedComposeError, match="top-level 'secrets' must be a mapping"): - validate_all({"services": {"app": {"image": "x"}}, "secrets": ["a"]}, _KINDS) - - def test_non_string_or_mapping_reference_rejected(self) -> None: - with pytest.raises(UnsupportedComposeError, match="entry must be a string or mapping"): - validate_all(_doc({"a": {"file": "./a"}}, [123]), _KINDS) - - def test_long_form_non_string_source_rejected(self) -> None: - with pytest.raises(UnsupportedComposeError, match="entry 'source' must be a string"): - validate_all(_doc({"a": {"file": "./a"}}, [{"source": 123}]), _KINDS) - - def test_injecting_secret_name_rejected(self) -> None: - with pytest.raises(UnsupportedComposeError, match="must match"): - validate_all(_doc({"n'; touch /tmp/x; '": {"file": "./a"}}, None), _KINDS) - - def test_dotted_dashed_secret_name_accepted(self) -> None: - validate_all(_doc({"db.pw-1": {"file": "./a"}}, ["db.pw-1"]), _KINDS) - - def test_bad_env_var_name_rejected(self) -> None: - with pytest.raises(UnsupportedComposeError, match="is not a valid identifier"): - validate_all(_doc({"s": {"environment": 'X"; touch /tmp/x; "'}}, None), _KINDS) - - def test_newline_in_secret_name_rejected(self) -> None: - with pytest.raises(UnsupportedComposeError, match="must match"): - validate_all(_doc({"db\n": {"file": "./a"}}, None), _KINDS) - - def test_newline_in_env_var_name_rejected(self) -> None: - with pytest.raises(UnsupportedComposeError, match="is not a valid identifier"): - validate_all(_doc({"s": {"environment": "VAR\n"}}, None), _KINDS) - - def test_non_scalar_long_form_value_rejected(self) -> None: - with pytest.raises(UnsupportedComposeError, match="must be an int or string"): - validate_all(_doc({"s": {"file": "./a"}}, [{"source": "s", "mode": True}]), _KINDS) - with pytest.raises(UnsupportedComposeError, match="must be an int or string"): - validate_all(_doc({"s": {"file": "./a"}}, [{"source": "s", "uid": [1]}]), _KINDS) - - def test_content_in_secret_rejected(self) -> None: - with pytest.raises(UnsupportedComposeError, match="unsupported keys"): - validate_all(_doc({"a": {"content": "x"}}, None), _KINDS) - - -class TestSecretEmission: - def test_short_form_flag(self) -> None: - assert flags({"secrets": ["db"]}, "p", SECRET) == ["--secret", "source=p-db,target=db"] - - def test_long_form_flag_with_opts(self) -> None: - svc = {"secrets": [{"source": "db", "target": "pw", "uid": "1000", "gid": "1000", "mode": 0o400}]} - assert flags(svc, "p", SECRET) == ["--secret", "source=p-db,target=pw,uid=1000,gid=1000,mode=0400"] - - def test_string_mode_passes_through(self) -> None: - svc = {"secrets": [{"source": "db", "mode": "0440"}]} - assert flags(svc, "p", SECRET) == ["--secret", "source=p-db,target=db,mode=0440"] - - def test_referenced_names_deduped_in_order(self) -> None: - services = {"a": {"secrets": ["s1", "s2"]}, "b": {"secrets": [{"source": "s1"}]}} - assert referenced_names(services, ["a", "b"], SECRET) == ["s1", "s2"] - - def test_file_source_create_line(self) -> None: - doc = {"secrets": {"db": {"file": "./db.txt"}}} - assert create_lines(doc, "p", "/proj", ["db"], SECRET) == ['podman secret create p-db "/proj/db.txt"'] - - def test_environment_source_create_line(self) -> None: - doc = {"secrets": {"k": {"environment": "API_KEY"}}} - assert create_lines(doc, "p", "/proj", ["k"], SECRET) == [ - "printf '%s' \"${API_KEY-}\" | podman secret create p-k -" - ] - - def test_referenced_variables_from_env_and_path(self) -> None: - doc = {"secrets": {"k": {"environment": "API_KEY"}, "f": {"file": "${DIR}/s.txt"}}} - assert referenced_variables(doc, "/proj", ["k", "f"], SECRET) == {"API_KEY", "DIR"} - - def test_non_string_file_takes_environment_branch(self) -> None: - doc = {"secrets": {"k": {"file": ["x"], "environment": "API_KEY"}}} - assert create_lines(doc, "p", "/proj", ["k"], SECRET) == [ - "printf '%s' \"${API_KEY-}\" | podman secret create p-k -" - ] - assert referenced_variables(doc, "/proj", ["k"], SECRET) == {"API_KEY"} diff --git a/tests/test_stores.py b/tests/test_stores.py new file mode 100644 index 0000000..944bc33 --- /dev/null +++ b/tests/test_stores.py @@ -0,0 +1,203 @@ +from typing import Any + +import pytest + +from compose2pod import stores +from compose2pod.exceptions import UnsupportedComposeError + + +def _doc(top_key: str, defs: Any = None, refs: Any = None) -> dict[str, Any]: # noqa: ANN401 - Compose values are untyped + svc: dict[str, Any] = {"image": "x"} + if refs is not None: + svc[top_key] = refs + doc: dict[str, Any] = {"services": {"app": svc}} + if defs is not None: + doc[top_key] = defs + return doc + + +class TestValidateSecretKind: + def test_file_and_environment_sources_accepted(self) -> None: + stores.validate(_doc("secrets", {"a": {"file": "./a.txt"}, "b": {"environment": "B"}}, ["a", "b"])) + + def test_long_form_reference_accepted(self) -> None: + doc = _doc( + "secrets", + {"a": {"file": "./a.txt"}}, + [{"source": "a", "target": "t", "uid": "1", "gid": "2", "mode": 0o400}], + ) + stores.validate(doc) + + def test_external_rejected(self) -> None: + with pytest.raises(UnsupportedComposeError, match="external secrets are not supported"): + stores.validate(_doc("secrets", {"a": {"external": True}}, ["a"])) + + def test_two_sources_rejected(self) -> None: + with pytest.raises(UnsupportedComposeError, match="must have exactly one of"): + stores.validate(_doc("secrets", {"a": {"file": "./a", "environment": "A"}}, ["a"])) + + def test_unknown_definition_key_rejected(self) -> None: + with pytest.raises(UnsupportedComposeError, match="unsupported keys"): + stores.validate(_doc("secrets", {"a": {"file": "./a", "driver": "x"}}, ["a"])) + + def test_non_mapping_definition_rejected(self) -> None: + with pytest.raises(UnsupportedComposeError, match="must be a mapping"): + stores.validate(_doc("secrets", {"a": ["./a"]}, ["a"])) + + def test_unknown_referenced_secret_rejected(self) -> None: + with pytest.raises(UnsupportedComposeError, match="unknown secret"): + stores.validate(_doc("secrets", {"a": {"file": "./a"}}, ["ghost"])) + + def test_service_secrets_not_a_list_rejected(self) -> None: + with pytest.raises(UnsupportedComposeError, match="secrets must be a list"): + stores.validate(_doc("secrets", {"a": {"file": "./a"}}, {"a": {}})) + + def test_long_form_unknown_key_rejected(self) -> None: + with pytest.raises(UnsupportedComposeError, match="unsupported secret keys"): + stores.validate(_doc("secrets", {"a": {"file": "./a"}}, [{"source": "a", "bogus": 1}])) + + def test_top_level_secrets_not_a_mapping_rejected(self) -> None: + with pytest.raises(UnsupportedComposeError, match="top-level 'secrets' must be a mapping"): + stores.validate({"services": {"app": {"image": "x"}}, "secrets": ["a"]}) + + def test_non_string_or_mapping_reference_rejected(self) -> None: + with pytest.raises(UnsupportedComposeError, match="entry must be a string or mapping"): + stores.validate(_doc("secrets", {"a": {"file": "./a"}}, [123])) + + def test_long_form_non_string_source_rejected(self) -> None: + with pytest.raises(UnsupportedComposeError, match="entry 'source' must be a string"): + stores.validate(_doc("secrets", {"a": {"file": "./a"}}, [{"source": 123}])) + + def test_injecting_secret_name_rejected(self) -> None: + with pytest.raises(UnsupportedComposeError, match="must match"): + stores.validate(_doc("secrets", {"n'; touch /tmp/x; '": {"file": "./a"}})) + + def test_dotted_dashed_secret_name_accepted(self) -> None: + stores.validate(_doc("secrets", {"db.pw-1": {"file": "./a"}}, ["db.pw-1"])) + + def test_bad_env_var_name_rejected(self) -> None: + with pytest.raises(UnsupportedComposeError, match="is not a valid identifier"): + stores.validate(_doc("secrets", {"s": {"environment": 'X"; touch /tmp/x; "'}})) + + def test_newline_in_secret_name_rejected(self) -> None: + with pytest.raises(UnsupportedComposeError, match="must match"): + stores.validate(_doc("secrets", {"db\n": {"file": "./a"}})) + + def test_newline_in_env_var_name_rejected(self) -> None: + with pytest.raises(UnsupportedComposeError, match="is not a valid identifier"): + stores.validate(_doc("secrets", {"s": {"environment": "VAR\n"}})) + + def test_non_scalar_long_form_value_rejected(self) -> None: + with pytest.raises(UnsupportedComposeError, match="must be an int or string"): + stores.validate(_doc("secrets", {"s": {"file": "./a"}}, [{"source": "s", "mode": True}])) + with pytest.raises(UnsupportedComposeError, match="must be an int or string"): + stores.validate(_doc("secrets", {"s": {"file": "./a"}}, [{"source": "s", "uid": [1]}])) + + def test_content_in_secret_rejected(self) -> None: + with pytest.raises(UnsupportedComposeError, match="unsupported keys"): + stores.validate(_doc("secrets", {"a": {"content": "x"}})) + + +class TestValidateConfigKind: + def test_content_source_accepted(self) -> None: + stores.validate(_doc("configs", {"c": {"content": "hello"}}, ["c"])) + + def test_absolute_long_form_target_accepted(self) -> None: + stores.validate(_doc("configs", {"c": {"file": "./c"}}, [{"source": "c", "target": "/etc/c.conf"}])) + + def test_relative_long_form_target_rejected(self) -> None: + with pytest.raises(UnsupportedComposeError, match=r"config target 'c.conf' must be an absolute path"): + stores.validate(_doc("configs", {"c": {"file": "./c"}}, [{"source": "c", "target": "c.conf"}])) + + def test_external_rejected_with_config_wording(self) -> None: + with pytest.raises(UnsupportedComposeError, match="external configs are not supported"): + stores.validate(_doc("configs", {"c": {"external": True}}, ["c"])) + + def test_unknown_referenced_config_rejected(self) -> None: + with pytest.raises(UnsupportedComposeError, match="unknown config"): + stores.validate(_doc("configs", {"c": {"file": "./c"}}, ["ghost"])) + + +class TestSecretEmission: + def test_short_form_flag(self) -> None: + assert stores.flags({"secrets": ["db"]}, "p") == ["--secret", "source=p-db,target=db"] + + def test_long_form_flag_with_opts(self) -> None: + svc = {"secrets": [{"source": "db", "target": "pw", "uid": "1000", "gid": "1000", "mode": 0o400}]} + assert stores.flags(svc, "p") == ["--secret", "source=p-db,target=pw,uid=1000,gid=1000,mode=0400"] + + def test_string_mode_passes_through(self) -> None: + svc = {"secrets": [{"source": "db", "mode": "0440"}]} + assert stores.flags(svc, "p") == ["--secret", "source=p-db,target=db,mode=0440"] + + def test_referenced_names_deduped_in_order_via_teardown(self) -> None: + compose = {"services": {"a": {"secrets": ["s1", "s2"]}, "b": {"secrets": [{"source": "s1"}]}}} + assert stores.teardown_line(compose, ["a", "b"], "p") == "podman secret rm p-s1 p-s2 >/dev/null 2>&1 || true" + + def test_file_source_create_line(self) -> None: + doc = {"services": {"app": {"secrets": ["db"]}}, "secrets": {"db": {"file": "./db.txt"}}} + assert stores.create_lines(doc, ["app"], "p", "/proj") == ['podman secret create p-db "/proj/db.txt"'] + + def test_environment_source_create_line(self) -> None: + doc = {"services": {"app": {"secrets": ["k"]}}, "secrets": {"k": {"environment": "API_KEY"}}} + assert stores.create_lines(doc, ["app"], "p", "/proj") == [ + "printf '%s' \"${API_KEY-}\" | podman secret create p-k -" + ] + + def test_referenced_variables_from_env_and_path(self) -> None: + doc = { + "services": {"app": {"secrets": ["k", "f"]}}, + "secrets": {"k": {"environment": "API_KEY"}, "f": {"file": "${DIR}/s.txt"}}, + } + assert stores.referenced_variables(doc, ["app"], "/proj") == {"API_KEY", "DIR"} + + def test_non_string_file_takes_environment_branch(self) -> None: + doc = {"services": {"app": {"secrets": ["k"]}}, "secrets": {"k": {"file": ["x"], "environment": "API_KEY"}}} + assert stores.create_lines(doc, ["app"], "p", "/proj") == [ + "printf '%s' \"${API_KEY-}\" | podman secret create p-k -" + ] + assert stores.referenced_variables(doc, ["app"], "/proj") == {"API_KEY"} + + +class TestConfigEmission: + def test_short_form_flag_defaults_to_root_target(self) -> None: + assert stores.flags({"configs": ["nginx"]}, "p") == ["--secret", "source=p-config-nginx,target=/nginx"] + + def test_long_form_absolute_target(self) -> None: + svc = {"configs": [{"source": "nginx", "target": "/etc/nginx/nginx.conf", "mode": 0o444}]} + assert stores.flags(svc, "p") == [ + "--secret", + "source=p-config-nginx,target=/etc/nginx/nginx.conf,mode=0444", + ] + + def test_content_source_create_line_expands_at_run_time(self) -> None: + doc = {"services": {"app": {"configs": ["c"]}}, "configs": {"c": {"content": "token=${API_TOKEN}"}}} + assert stores.create_lines(doc, ["app"], "p", "/proj") == [ + "printf '%s' \"token=${API_TOKEN-}\" | podman secret create p-config-c -" + ] + + def test_file_source_create_line_is_config_prefixed(self) -> None: + doc = {"services": {"app": {"configs": ["c"]}}, "configs": {"c": {"file": "./c.conf"}}} + assert stores.create_lines(doc, ["app"], "p", "/proj") == ['podman secret create p-config-c "/proj/c.conf"'] + + def test_content_referenced_variables(self) -> None: + doc = {"services": {"app": {"configs": ["c"]}}, "configs": {"c": {"content": "a=${A} b=${B}"}}} + assert stores.referenced_variables(doc, ["app"], "/proj") == {"A", "B"} + + +class TestBothKinds: + def test_flags_emit_secrets_then_configs(self) -> None: + svc = {"secrets": ["s"], "configs": ["c"]} + assert stores.flags(svc, "p") == [ + "--secret", + "source=p-s,target=s", + "--secret", + "source=p-config-c,target=/c", + ] + + def test_teardown_line_lists_secrets_then_configs(self) -> None: + compose = {"services": {"app": {"secrets": ["s"], "configs": ["c"]}}} + assert stores.teardown_line(compose, ["app"], "p") == "podman secret rm p-s p-config-c >/dev/null 2>&1 || true" + + def test_teardown_line_empty_without_stores(self) -> None: + assert stores.teardown_line({"services": {"app": {"image": "x"}}}, ["app"], "p") == ""