From 3662215a1ef5b70f0b584e2acd480f2b9f63e65c Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Fri, 10 Jul 2026 16:43:43 +0300 Subject: [PATCH 1/5] docs: design compose configs support --- planning/changes/2026-07-10.03-configs.md | 130 ++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 planning/changes/2026-07-10.03-configs.md diff --git a/planning/changes/2026-07-10.03-configs.md b/planning/changes/2026-07-10.03-configs.md new file mode 100644 index 0000000..2b6a861 --- /dev/null +++ b/planning/changes/2026-07-10.03-configs.md @@ -0,0 +1,130 @@ +--- +summary: Support compose configs (Bucket B) by extracting a shared `store.py` module that both secrets and configs delegate to — top-level file:/environment:/content: definitions become pod-namespaced `podman secret create` + `--secret` mounts, configs default to the container-root target `/` and add an inline `content:` source; external:true and relative config targets are rejected. +--- + +# Design: compose configs + +## Summary + +Translate compose `configs` into the single-pod script using the same podman +secret store secrets already use. Configs are the non-sensitive sibling of +secrets: near-identical shape, but they mount at the container-root path +`/` (not `/run/secrets/`) and add an inline `content:` source. +Rather than duplicate the secret machinery, this change extracts it into a new +leaf module `compose2pod/store.py` parameterized by a `StoreKind`; `secrets.py` +and `configs.py` shrink to thin kind-specs, and `emit.py`/`parsing.py` gain a +single kind-unioning seam. Because the secret kind keeps store prefix `""`, the +refactor emits byte-identical output for existing documents. + +## Motivation + +Configs are a common CI need — a service reads an nginx/app config from a known +path. They map onto podman's secret store almost as cleanly as secrets do +(`--secret target=` accepts a fully-qualified path, so `/` placement is a +one-option change). Secrets and configs are now two real consumers of the same +store machinery, which by the repo's own "two adapters = a real seam" rule +justifies extracting the shared core instead of writing a second copy in +`configs.py`. Doing the extraction now — while the secret suite is at 100% +coverage to guard behavior — is cheaper than after the logic diverges. + +## Design + +**`store.py` (leaf; imports `keys`, `shell`, `exceptions`)** owns the store +machinery, parameterized by a frozen `StoreKind`: + +```python +@dataclasses.dataclass(frozen=True, slots=True, kw_only=True) +class StoreKind: + label: str # "secret" | "config" — error wording + top_key: str # "secrets" | "configs" + prefix: str # "" | "config-" → store = f"{pod}-{prefix}{name}" + sources: frozenset[str] # {file,environment} | {file,environment,content} + default_target: Callable[[str], str] # secret: name ; config: "/"+name + require_absolute_target: bool # secret: False ; config: True +``` + +It exposes kind-parameterized primitives (`validate_store`, `referenced_names`, +`flags`, `create_lines`, `referenced_variables`, each taking a `kind`) **and** +the union entry points that iterate a `kinds` tuple — emit's single seam: +`validate_all`, `all_flags`, `all_create_lines`, `all_teardown_names`, +`all_referenced_variables`. + +`secrets.py` and `configs.py` become thin kind-specs: + +```python +# secrets.py — prefix="" preserves the exact current store names +SECRET = StoreKind(label="secret", top_key="secrets", prefix="", + sources=frozenset({"file", "environment"}), + default_target=lambda n: n, require_absolute_target=False) +# configs.py +CONFIG = StoreKind(label="config", top_key="configs", prefix="config-", + sources=frozenset({"file", "environment", "content"}), + default_target=lambda n: f"/{n}", require_absolute_target=True) +``` + +A small `STORE_KINDS = (SECRET, CONFIG)` aggregator (importing both specs — no +cycle, since `store.py` is the leaf) is what `emit.py` and `parsing.py` pass to +the union functions. + +**Sources.** `file:` → `podman secret create `; +`environment:` → `printf '%s' "${VAR-}" | podman secret create -`; +`content:` (configs only) → `printf '%s' | podman secret +create -`. `content:` runs through `to_shell`, so `${VAR}` stays live +and expands at script-run time — the tool's established deferred-interpolation +model, applied consistently (`$$` → literal `$`, malformed refs refused). A +`content:` source in a *secret* is rejected (not in the secret kind's +`sources`). + +**Target.** No long-form `target:` → `kind.default_target(name)` (`/` for +configs). An explicit config `target:` must be absolute; a relative target is +refused (podman would misroute a non-absolute target to `/run/secrets/`). + +**Emit wiring.** Configs are podman secrets under the hood, so emit's three +existing secret seams become kind-unioning over `STORE_KINDS`: the trap's +`podman secret rm` lists both `-` and `-config-`; create +lines emit both after `podman pod create`; per-service `--secret` flags and the +`referenced_variables()` note union both kinds. + +**Validation** (`validate_all`): each kind's top-level `configs`/`secrets` is a +mapping of name → def with exactly one allowed source; `external: true`, +unknown/multiple sources, and non-mapping defs are rejected; secret/config +names and env-var names pass the reused `fullmatch` charset checks; long-form +`uid`/`gid`/`mode` reject non-scalar/bool; a relative config `target:` is +rejected. `configs` joins `SUPPORTED_TOP_LEVEL_KEYS` and, as a structural key, +`SUPPORTED_SERVICE_KEYS`. + +## Non-goals + +- `external: true` — rejected for both kinds (same v1 boundary as secrets; + compose2pod creates the store from the source). +- Non-absolute long-form config `target:` — refused, not normalized (Compose + does not clearly define relative-target placement). +- Deduplicating a config and a same-named secret into one store — they are + distinct stores by the `config-` prefix (see Risk). +- A distinct non-secret delivery mechanism for configs (bind mounts, tmpfs) — + the podman secret store is the faithful-enough v1 carrier. + +## Testing + +`just test-ci` at 100%. The existing secret suite is the regression guard that +the `store.py` extraction is behavior-preserving (byte-identical create/flags/ +trap/vars for secret-only documents). New config coverage: file/environment/ +content create lines; `/` default target and absolute long-form target; +relative-target rejection; `content:` run-time `${VAR}` expansion and its +appearance in `referenced_variables()`; `content:` rejected in a secret; +`config-` pod-namespacing with no collision against a same-named secret; and an +`emit_script` integration whose trap `secret rm` and create lines union secrets +and configs. `just lint-ci` clean (watch `C901` on the union functions — keep +them small) and `just check-planning`. + +## Risk + +- **Store-name collision on a `config-`-prefixed secret** (low × low): a secret + literally named `config-x` collides with a config named `x` (both + `-config-x`). Same class as an existing pod-name collision — the user's + namespace, documented not guarded. +- **Refactor regression in the extracted `store.py`** (low × med): the secret + seam moves behind a parameterized module; mitigated by the 100%-covered + secret suite asserting byte-identical output (prefix `""`). +- **`C901` on the union functions** (low × low): iterating kinds adds branches; + extract a small helper if a function crosses the limit, as prior bundles did. From 27ef97d2bca9311771d9e4fcc290149eafa01d6f Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Fri, 10 Jul 2026 17:46:20 +0300 Subject: [PATCH 2/5] refactor: extract shared store.py from secrets, byte-identical --- compose2pod/emit.py | 15 +-- compose2pod/parsing.py | 5 +- compose2pod/secrets.py | 147 ++--------------------------- compose2pod/store.py | 205 +++++++++++++++++++++++++++++++++++++++++ compose2pod/stores.py | 6 ++ tests/test_secrets.py | 137 --------------------------- tests/test_store.py | 142 ++++++++++++++++++++++++++++ 7 files changed, 373 insertions(+), 284 deletions(-) create mode 100644 compose2pod/store.py create mode 100644 compose2pod/stores.py delete mode 100644 tests/test_secrets.py create mode 100644 tests/test_store.py diff --git a/compose2pod/emit.py b/compose2pod/emit.py index a89f7e1..ea1775d 100644 --- a/compose2pod/emit.py +++ b/compose2pod/emit.py @@ -8,8 +8,9 @@ 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.secrets import referenced_secret_names, secret_create_lines, secret_flags, secret_referenced_variables 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 @@ -99,7 +100,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 += secret_flags(svc, pod) + flags += all_flags(svc, pod, STORE_KINDS) return flags @@ -186,7 +187,6 @@ def emit_script(compose: dict[str, Any], options: EmitOptions) -> str: services = compose["services"] hosts = hostnames(services) order = startup_order(services, options.target) - secret_names = referenced_secret_names(services, order) completion_gated = { dep for svc in services.values() @@ -196,12 +196,13 @@ 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" - if secret_names: - stores = " ".join(f"{options.pod}-{name}" for name in secret_names) + 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" lines.append(f"trap '{teardown}' EXIT") lines.append(f"podman pod create --name {shlex.quote(options.pod)}") - lines.extend(secret_create_lines(compose, options.pod, options.project_dir, secret_names)) + lines.extend(all_create_lines(compose, order, options.pod, options.project_dir, STORE_KINDS)) waited: set[str] = set() for name in order: for dep, condition in depends_on(services[name]).items(): @@ -235,5 +236,5 @@ def referenced_variables(compose: dict[str, Any], options: EmitOptions) -> list[ for token in _run_tokens(name, services, options, hosts): if isinstance(token, _Expand): names.update(variable_names(token.value)) - names |= secret_referenced_variables(compose, options.project_dir, referenced_secret_names(services, order)) + names |= all_referenced_variables(compose, order, options.project_dir, STORE_KINDS) return sorted(names) diff --git a/compose2pod/parsing.py b/compose2pod/parsing.py index 1fac3f0..a2e5056 100644 --- a/compose2pod/parsing.py +++ b/compose2pod/parsing.py @@ -6,7 +6,8 @@ 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.secrets import validate_secrets +from compose2pod.store import validate_all +from compose2pod.stores import STORE_KINDS SUPPORTED_SERVICE_KEYS = set(SERVICE_KEYS) | STRUCTURAL_KEYS @@ -127,5 +128,5 @@ 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_secrets(compose) + validate_all(compose, STORE_KINDS) return warnings diff --git a/compose2pod/secrets.py b/compose2pod/secrets.py index eb572ac..d2792eb 100644 --- a/compose2pod/secrets.py +++ b/compose2pod/secrets.py @@ -1,141 +1,12 @@ -"""Translate compose secrets into podman secret store create / mount / remove.""" +"""The secret StoreKind: a podman secret mounted at /run/secrets/.""" -import re -from pathlib import Path -from typing import Any +from compose2pod.store import StoreKind -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"} -_SECRET_NAME = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_.-]*$") -_ENV_NAME = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$") - - -def _validate_secret_def(name: str, definition: Any) -> None: # noqa: ANN401 - Compose values are untyped - if not _SECRET_NAME.fullmatch(name): - msg = f"secret name {name!r} must match [a-zA-Z0-9][a-zA-Z0-9_.-]*" - raise UnsupportedComposeError(msg) - if not isinstance(definition, dict): - msg = f"secret {name!r} must be a mapping" - raise UnsupportedComposeError(msg) - unknown = set(definition) - {"file", "environment"} - if unknown: - if "external" in unknown: - msg = f"secret {name!r}: external secrets are not supported (use a file: or environment: source)" - raise UnsupportedComposeError(msg) - msg = f"secret {name!r}: unsupported keys {sorted(unknown)}" - raise UnsupportedComposeError(msg) - sources = [key for key in ("file", "environment") if isinstance(definition.get(key), str)] - if len(sources) != 1: - msg = f"secret {name!r} must have exactly one of 'file' or 'environment' (a string)" - raise UnsupportedComposeError(msg) - if sources[0] == "environment" and not _ENV_NAME.fullmatch(definition["environment"]): - msg = f"secret {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]) -> 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}: secret {key!r} must be an int or string" - raise UnsupportedComposeError(msg) - - -def _ref_source(name: str, ref: Any) -> str: # noqa: ANN401 - Compose values are untyped - if isinstance(ref, str): - return ref - if not isinstance(ref, dict): - msg = f"service {name!r}: secret entry must be a string or mapping" - raise UnsupportedComposeError(msg) - unknown = set(ref) - _LONG_FORM_KEYS - if unknown: - msg = f"service {name!r}: unsupported secret keys {sorted(unknown)}" - raise UnsupportedComposeError(msg) - source = ref.get("source") - if not isinstance(source, str): - msg = f"service {name!r}: secret entry 'source' must be a string" - raise UnsupportedComposeError(msg) - _check_long_form_scalars(name, ref) - return source - - -def validate_secrets(compose: dict[str, Any]) -> None: - """Validate top-level secret definitions and every service's references to them.""" - defs = compose.get("secrets") - if defs is not None and not isinstance(defs, dict): - msg = "top-level 'secrets' must be a mapping" - raise UnsupportedComposeError(msg) - defs = defs or {} - for name, definition in defs.items(): - _validate_secret_def(name, definition) - for name, svc in (compose.get("services") or {}).items(): - refs = svc.get("secrets") - if refs is None: - continue - if not isinstance(refs, list): - msg = f"service {name!r}: secrets must be a list" - raise UnsupportedComposeError(msg) - for ref in refs: - source = _ref_source(name, ref) - if source not in defs: - msg = f"service {name!r}: unknown secret {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_secret_names(services: dict[str, Any], order: list[str]) -> list[str]: - """Secret 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("secrets") or []: - seen[ref if isinstance(ref, str) else ref["source"]] = None - return list(seen) - - -def secret_flags(svc: dict[str, Any], pod: str) -> list[Token]: - """Per-service `--secret source=-,target=...` flag tokens.""" - tokens: list[Token] = [] - for ref in svc.get("secrets") or []: - opts = {} if isinstance(ref, str) else ref - source = ref if isinstance(ref, str) else ref["source"] - parts = [f"source={pod}-{source}", f"target={opts.get('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 secret_create_lines(compose: dict[str, Any], pod: str, project_dir: str, names: list[str]) -> list[str]: - """`podman secret create` lines for the referenced secrets (file or environment source).""" - defs = compose.get("secrets") or {} - lines: list[str] = [] - for name in names: - definition = defs[name] - store = f"{pod}-{name}" - if isinstance(definition.get("file"), str): - path = to_shell(str(Path(project_dir, definition["file"]))) - lines.append(f"podman secret create {store} {path}") - else: - var = definition["environment"] - lines.append(f"printf '%s' \"${{{var}-}}\" | podman secret create {store} -") - return lines - - -def secret_referenced_variables(compose: dict[str, Any], project_dir: str, names: list[str]) -> set[str]: - """Run-time variable names the secret create lines expand (env-source vars + file-path vars).""" - defs = compose.get("secrets") 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"]))) - else: - result.add(definition["environment"]) - return result +SECRET = StoreKind( + label="secret", + top_key="secrets", + prefix="", + sources=frozenset({"file", "environment"}), + default_target=lambda name: name, +) diff --git a/compose2pod/store.py b/compose2pod/store.py new file mode 100644 index 0000000..2369fc4 --- /dev/null +++ b/compose2pod/store.py @@ -0,0 +1,205 @@ +"""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] + + +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 _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) + 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}") + 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"]))) + 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 new file mode 100644 index 0000000..083fa5b --- /dev/null +++ b/compose2pod/stores.py @@ -0,0 +1,6 @@ +"""The registry of store kinds that emit and parsing weave into the script.""" + +from compose2pod.secrets import SECRET + + +STORE_KINDS = (SECRET,) diff --git a/tests/test_secrets.py b/tests/test_secrets.py deleted file mode 100644 index 0da69c0..0000000 --- a/tests/test_secrets.py +++ /dev/null @@ -1,137 +0,0 @@ -from typing import Any - -import pytest - -from compose2pod.exceptions import UnsupportedComposeError -from compose2pod.secrets import ( - referenced_secret_names, - secret_create_lines, - secret_flags, - secret_referenced_variables, - validate_secrets, -) - - -def _doc(secrets: Any = None, svc_secrets: Any = None) -> dict[str, Any]: # noqa: ANN401 - Compose values are untyped - svc = {"image": "x"} - if svc_secrets is not None: - svc["secrets"] = svc_secrets - doc = {"services": {"app": svc}} - if secrets is not None: - doc["secrets"] = secrets - return doc - - -class TestValidateSecrets: - def test_file_and_environment_sources_accepted(self) -> None: - doc = _doc({"a": {"file": "./a.txt"}, "b": {"environment": "B"}}, ["a", "b"]) - validate_secrets(doc) # no raise - - 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_secrets(doc) - - def test_external_rejected(self) -> None: - with pytest.raises(UnsupportedComposeError, match="external secrets are not supported"): - validate_secrets(_doc({"a": {"external": True}}, ["a"])) - - def test_two_sources_rejected(self) -> None: - with pytest.raises(UnsupportedComposeError, match="exactly one of 'file' or 'environment'"): - validate_secrets(_doc({"a": {"file": "./a", "environment": "A"}}, ["a"])) - - def test_unknown_definition_key_rejected(self) -> None: - with pytest.raises(UnsupportedComposeError, match="unsupported keys"): - validate_secrets(_doc({"a": {"file": "./a", "driver": "x"}}, ["a"])) - - def test_non_mapping_definition_rejected(self) -> None: - with pytest.raises(UnsupportedComposeError, match="secret 'a' must be a mapping"): - validate_secrets(_doc({"a": ["./a"]}, ["a"])) - - def test_unknown_referenced_secret_rejected(self) -> None: - with pytest.raises(UnsupportedComposeError, match="unknown secret 'ghost'"): - validate_secrets(_doc({"a": {"file": "./a"}}, ["ghost"])) - - def test_service_secrets_not_a_list_rejected(self) -> None: - with pytest.raises(UnsupportedComposeError, match="secrets must be a list"): - validate_secrets(_doc({"a": {"file": "./a"}}, {"a": {}})) - - def test_long_form_unknown_key_rejected(self) -> None: - with pytest.raises(UnsupportedComposeError, match="unsupported secret keys"): - validate_secrets(_doc({"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"): - validate_secrets({"services": {"app": {"image": "x"}}, "secrets": ["a"]}) - - def test_non_string_or_mapping_reference_rejected(self) -> None: - with pytest.raises(UnsupportedComposeError, match="secret entry must be a string or mapping"): - validate_secrets(_doc({"a": {"file": "./a"}}, [123])) - - def test_long_form_non_string_source_rejected(self) -> None: - with pytest.raises(UnsupportedComposeError, match="secret entry 'source' must be a string"): - validate_secrets(_doc({"a": {"file": "./a"}}, [{"source": 123}])) - - def test_injecting_secret_name_rejected(self) -> None: - with pytest.raises(UnsupportedComposeError, match="must match"): - validate_secrets(_doc({"n'; touch /tmp/x; '": {"file": "./a"}}, None)) - - def test_dotted_dashed_secret_name_accepted(self) -> None: - validate_secrets(_doc({"db.pw-1": {"file": "./a"}}, ["db.pw-1"])) - - def test_bad_env_var_name_rejected(self) -> None: - with pytest.raises(UnsupportedComposeError, match="not a valid identifier"): - validate_secrets(_doc({"s": {"environment": 'X"; touch /tmp/x; "'}}, None)) - - def test_newline_in_secret_name_rejected(self) -> None: - with pytest.raises(UnsupportedComposeError, match="must match"): - validate_secrets(_doc({"db\n": {"file": "./a"}}, None)) - - def test_newline_in_env_var_name_rejected(self) -> None: - with pytest.raises(UnsupportedComposeError, match="not a valid identifier"): - validate_secrets(_doc({"s": {"environment": "VAR\n"}}, None)) - - def test_non_scalar_long_form_value_rejected(self) -> None: - with pytest.raises(UnsupportedComposeError, match="secret 'mode' must be an int or string"): - validate_secrets(_doc({"s": {"file": "./a"}}, [{"source": "s", "mode": True}])) - with pytest.raises(UnsupportedComposeError, match="secret 'uid' must be an int or string"): - validate_secrets(_doc({"s": {"file": "./a"}}, [{"source": "s", "uid": [1]}])) - - -class TestSecretEmission: - def test_short_form_flag(self) -> None: - assert secret_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 secret_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 secret_flags(svc, "p") == ["--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_secret_names(services, ["a", "b"]) == ["s1", "s2"] - - def test_file_source_create_line(self) -> None: - doc = {"secrets": {"db": {"file": "./db.txt"}}} - assert secret_create_lines(doc, "p", "/proj", ["db"]) == ['podman secret create p-db "/proj/db.txt"'] - - def test_environment_source_create_line(self) -> None: - doc = {"secrets": {"k": {"environment": "API_KEY"}}} - assert secret_create_lines(doc, "p", "/proj", ["k"]) == [ - "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 secret_referenced_variables(doc, "/proj", ["k", "f"]) == {"API_KEY", "DIR"} - - def test_non_string_file_takes_environment_branch(self) -> None: - # validate_secrets picks the *string* source (environment); emission must agree, - # not branch on `file` key presence and crash in Path() on the non-string value. - doc = {"secrets": {"k": {"file": ["x"], "environment": "API_KEY"}}} - assert secret_create_lines(doc, "p", "/proj", ["k"]) == [ - "printf '%s' \"${API_KEY-}\" | podman secret create p-k -" - ] - assert secret_referenced_variables(doc, "/proj", ["k"]) == {"API_KEY"} diff --git a/tests/test_store.py b/tests/test_store.py new file mode 100644 index 0000000..fe45419 --- /dev/null +++ b/tests/test_store.py @@ -0,0 +1,142 @@ +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"} From 9691c223a0af660b7d98f49e1cd8ba2834e32a33 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Fri, 10 Jul 2026 17:56:18 +0300 Subject: [PATCH 3/5] feat: add config store kind with content source and absolute target rule --- compose2pod/configs.py | 13 +++++++++ compose2pod/secrets.py | 1 + compose2pod/store.py | 12 ++++++++ tests/test_configs.py | 66 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 92 insertions(+) create mode 100644 compose2pod/configs.py create mode 100644 tests/test_configs.py diff --git a/compose2pod/configs.py b/compose2pod/configs.py new file mode 100644 index 0000000..6ba0da4 --- /dev/null +++ b/compose2pod/configs.py @@ -0,0 +1,13 @@ +"""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/secrets.py b/compose2pod/secrets.py index d2792eb..f0cac9e 100644 --- a/compose2pod/secrets.py +++ b/compose2pod/secrets.py @@ -9,4 +9,5 @@ prefix="", sources=frozenset({"file", "environment"}), default_target=lambda name: name, + require_absolute_target=False, ) diff --git a/compose2pod/store.py b/compose2pod/store.py index 2369fc4..df0e91e 100644 --- a/compose2pod/store.py +++ b/compose2pod/store.py @@ -25,6 +25,7 @@ class StoreKind: 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 @@ -62,6 +63,12 @@ def _check_long_form_scalars(name: str, ref: dict[str, Any], kind: StoreKind) -> 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 @@ -77,6 +84,7 @@ def _ref_source(name: str, ref: Any, kind: StoreKind) -> str: # noqa: ANN401 - 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 @@ -139,6 +147,8 @@ def create_lines(compose: dict[str, Any], pod: str, project_dir: str, names: lis 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} -") @@ -153,6 +163,8 @@ def referenced_variables(compose: dict[str, Any], project_dir: str, names: list[ 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 diff --git a/tests/test_configs.py b/tests/test_configs.py new file mode 100644 index 0000000..2c0c4d6 --- /dev/null +++ b/tests/test_configs.py @@ -0,0 +1,66 @@ +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"} From 206f55e6903980b83109430ae13d5af57844d679 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Fri, 10 Jul 2026 18:02:49 +0300 Subject: [PATCH 4/5] feat: wire compose configs through the store union seam --- compose2pod/keys.py | 1 + compose2pod/parsing.py | 2 +- compose2pod/stores.py | 3 ++- tests/test_emit.py | 21 +++++++++++++++++++++ tests/test_keys.py | 1 + tests/test_parsing.py | 17 +++++++++++++++++ 6 files changed, 43 insertions(+), 2 deletions(-) diff --git a/compose2pod/keys.py b/compose2pod/keys.py index 232656c..dd07fb4 100644 --- a/compose2pod/keys.py +++ b/compose2pod/keys.py @@ -197,4 +197,5 @@ def _emit_ulimits(value: Any) -> list[Token]: # noqa: ANN401 - Compose values a "hostname", "container_name", "secrets", + "configs", } diff --git a/compose2pod/parsing.py b/compose2pod/parsing.py index a2e5056..8866de2 100644 --- a/compose2pod/parsing.py +++ b/compose2pod/parsing.py @@ -13,7 +13,7 @@ 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", "secrets"} +SUPPORTED_TOP_LEVEL_KEYS = {"services", "version", "name", "networks", "volumes", "secrets", "configs"} DEPENDS_ON_CONDITIONS = {"service_started", "service_healthy", "service_completed_successfully"} diff --git a/compose2pod/stores.py b/compose2pod/stores.py index 083fa5b..afed5fb 100644 --- a/compose2pod/stores.py +++ b/compose2pod/stores.py @@ -1,6 +1,7 @@ """The registry of store kinds that emit and parsing weave into the script.""" +from compose2pod.configs import CONFIG from compose2pod.secrets import SECRET -STORE_KINDS = (SECRET,) +STORE_KINDS = (SECRET, CONFIG) diff --git a/tests/test_emit.py b/tests/test_emit.py index 427ca89..3c33c8d 100644 --- a/tests/test_emit.py +++ b/tests/test_emit.py @@ -302,6 +302,27 @@ def test_env_secret_referenced_variable_noted(self) -> None: ) assert "API_KEY" in referenced_variables(doc, options) + def test_config_create_trap_and_mount(self) -> None: + doc = { + "services": {"app": {"image": "x", "configs": [{"source": "nginx", "target": "/etc/nginx.conf"}]}}, + "configs": {"nginx": {"file": "./nginx.conf"}}, + } + script = self._script(doc) + assert 'podman secret create test-pod-config-nginx "/proj/nginx.conf"' in script + assert "podman secret rm test-pod-config-nginx" in script + assert "--secret source=test-pod-config-nginx,target=/etc/nginx.conf" in script + + def test_secret_and_same_named_config_are_distinct_stores(self) -> None: + doc = { + "services": {"app": {"image": "x", "secrets": ["app"], "configs": ["app"]}}, + "secrets": {"app": {"file": "./s"}}, + "configs": {"app": {"file": "./c"}}, + } + script = self._script(doc) + assert "podman secret rm test-pod-app test-pod-config-app" in script + assert "--secret source=test-pod-app,target=app" in script + assert "--secret source=test-pod-config-app,target=/app" in script + class TestEmitScript: def make_script(self, chats_compose: dict) -> str: diff --git a/tests/test_keys.py b/tests/test_keys.py index 48446ef..810a587 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -22,6 +22,7 @@ def test_supported_service_keys_snapshot() -> None: "hostname", "container_name", "secrets", + "configs", "user", "working_dir", "platform", diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 747a038..666abe1 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -293,3 +293,20 @@ def test_unknown_secret_reference_raises(self) -> None: doc = {"services": {"app": {"image": "x", "secrets": ["ghost"]}}} with pytest.raises(UnsupportedComposeError, match="unknown secret 'ghost'"): validate(doc) + + def test_config_short_form_accepted(self) -> None: + doc = {"services": {"app": {"image": "x", "configs": ["c"]}}, "configs": {"c": {"content": "hi"}}} + assert validate(doc) == [] + + def test_unknown_config_reference_rejected(self) -> None: + doc = {"services": {"app": {"image": "x", "configs": ["ghost"]}}, "configs": {"c": {"file": "./c"}}} + with pytest.raises(UnsupportedComposeError, match="unknown config"): + validate(doc) + + def test_relative_config_target_rejected_at_gate(self) -> None: + doc = { + "services": {"app": {"image": "x", "configs": [{"source": "c", "target": "c.conf"}]}}, + "configs": {"c": {"file": "./c"}}, + } + with pytest.raises(UnsupportedComposeError, match="must be an absolute path"): + validate(doc) From 1ae9632bddd5c0016021b3127b8d5c23ed857dfa Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Fri, 10 Jul 2026 18:11:07 +0300 Subject: [PATCH 5/5] docs: record configs support in the supported subset --- architecture/supported-subset.md | 123 ++++++++++++++++++++++++------- 1 file changed, 97 insertions(+), 26 deletions(-) diff --git a/architecture/supported-subset.md b/architecture/supported-subset.md index daa0499..115e253 100644 --- a/architecture/supported-subset.md +++ b/architecture/supported-subset.md @@ -9,7 +9,7 @@ warns (ignored, behavior-neutral inside a single pod) or raises ## Top-level keys - **Supported:** `services` (required, non-empty), `version`, `name`, - `networks`, `secrets`. + `networks`, `volumes`, `secrets`, `configs`. - **Ignored (warns):** `networks` — all services share the pod's single network namespace, so top-level network definitions have no effect. - **Extension fields:** any key prefixed `x-` is accepted and ignored @@ -170,51 +170,122 @@ 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_secret_def`, `compose2pod/secrets.py`). + key raises generically (`_validate_def`, `compose2pod/store.py`, via the + `SECRET` `StoreKind` in `compose2pod/secrets.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_secrets`, - `compose2pod/secrets.py`). + `validate()` time (`_ref_source`/`validate_all`, `compose2pod/store.py` -- + `validate_all` 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_secret_names`, driven by the same - `startup_order` closure used to decide which services run at all). + `podman secret create` call (`referenced_names`, `compose2pod/store.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` (`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 - (`secret_create_lines`). + `podman pod create` and before any `podman run` by `all_create_lines` + (`compose2pod/store.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`). - **Mounting:** each service reference becomes a `--secret source=-,target=` flag on that service's - `podman run`, 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 (`secret_flags`). When `uid`/`gid`/`mode` are omitted, podman itself - applies its own defaults: the secret is mounted at `/run/secrets/`, - owned `0:0`, mode `0444`. + `podman run`, assembled per service by `all_flags` (`compose2pod/store.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 + `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 - (`emit_script`). + (`all_teardown_names`, `compose2pod/store.py`, called from `emit_script`, + `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 (`secret_referenced_variables`, folded into `referenced_variables()` - in `compose2pod/emit.py`); see Variable interpolation, below, for the note - itself. + time (`referenced_variables`, `compose2pod/store.py`, assembled across + store kinds by `all_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 nothing: `external: true`, an unknown `source`, a definition with neither or both of `file`/`environment`, and an unrecognized long-form key. +## Configs + +- **Top-level `configs:` definitions:** each entry must be a mapping with + exactly one of `file:` (a host path, resolved against `--project-dir` when + 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`). +- **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`). +- **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`). +- **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 + `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 + `content:` source pipes the literal text through `to_shell()` into that + 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`). +- **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`). + 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 + (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: + 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`). +- **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`). +- **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 + `compose2pod/emit.py`); see Variable interpolation, below, for the note + itself. +- Everything else raises `UnsupportedComposeError` rather than silently + doing nothing: `external: true`, an unknown `source`, a definition with + not exactly one of `file`/`environment`/`content`, an unrecognized + long-form key, and a relative long-form `target`. + ## Variable interpolation compose2pod does not resolve Compose Spec `${VAR}` references at generation