From a9ee148b64b50863c19ce8e3ff162fb10cd445ab Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Fri, 10 Jul 2026 12:53:37 +0300 Subject: [PATCH 1/8] docs: design compose secrets support --- planning/changes/2026-07-10.02-secrets.md | 96 +++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 planning/changes/2026-07-10.02-secrets.md diff --git a/planning/changes/2026-07-10.02-secrets.md b/planning/changes/2026-07-10.02-secrets.md new file mode 100644 index 0000000..889dc49 --- /dev/null +++ b/planning/changes/2026-07-10.02-secrets.md @@ -0,0 +1,96 @@ +--- +summary: Support compose secrets (first Bucket B feature) via a new secrets.py module — top-level file:/environment: definitions become pod-namespaced `podman secret create` + `--secret` mounts with full long-form target/uid/gid/mode; external:true is rejected. +--- + +# Design: compose secrets + +## Summary + +Translate compose `secrets` into the single-pod script using podman's own secret +store. A new `compose2pod/secrets.py` module owns the translation; `parsing.py` +gates it and `emit.py` weaves the store lifecycle into the script. Top-level +`file:`/`environment:` secret definitions become `podman secret create` calls, +service-level references become `podman run --secret` mounts at +`/run/secrets/`, and the store is torn down in the existing EXIT trap. +This is the first Bucket B feature (real translation, not a flag pass-through). + +## Motivation + +Secrets are a core CI need — a test service reads a DB password or an API key +from `/run/secrets/`. Podman `--secret` maps compose secrets almost 1:1 +(same default mount `/run/secrets/`, mode `0444`, uid/gid `0`; `target`/ +`uid`/`gid`/`mode` are direct opts), and secrets mount as per-container files, so +they are genuinely per-container-supportable in the pod (unlike `dns`/`sysctls`). + +## Design + +**`secrets.py`** owns: the set of secret names referenced by services in the +target's closure; the `podman secret create` lines (file vs env source); and the +per-service `--secret` flag tokens. It is a deep module — `emit.py` calls it for +the create/rm lines and flags; `parsing.py` calls it (or a sibling validator) +for the shape checks. + +**Generated script** (pod `test-pod`; a service references a `file:` secret +`db_password` and an `environment: API_KEY` secret `api_key`): + +```sh +trap 'podman pod rm -f test-pod ... ; podman secret rm test-pod-db_password test-pod-api_key >/dev/null 2>&1 || true' EXIT +podman pod create --name test-pod +podman secret create test-pod-db_password "/builds/proj/db_pw.txt" +printf '%s' "${API_KEY-}" | podman secret create test-pod-api_key - +... +podman run -d ... --secret source=test-pod-db_password,target=db_password ... +``` + +- **Pod-namespaced store names** (`-`) isolate concurrent CI runs + (podman secrets are per-user global); `target=` maps back to + `/run/secrets/`. +- `create` lines emit right after `podman pod create` (secrets must exist before + `--secret`); the EXIT **trap** removes them alongside the pod, so a failed + create under `set -e` tears everything down. +- `file:` path resolves against `--project-dir` and is `_Expand`-rendered (like + volumes). `environment:` uses `${VAR-}` (empty-if-unset, survives `set -u`); + `VAR` flows into the `referenced_variables()` stderr note. Long-form + `target`/`uid`/`gid`/`mode` become literal `--secret` opts; `mode: 0440` + (PyYAML parses as octal int `288`) renders back to `0440`, a string `"0440"` + passes through. + +**Validation** (`parsing.py`, a cross-cutting `_validate_secrets(compose)` — a +service ref points at a top-level def, so it needs the whole document): top-level +`secrets:` is a mapping of name -> definition with **exactly one** of `file:` / +`environment:` (a string); `external: true`, multiple sources, unknown source +keys, or a non-mapping def -> reject. Service `secrets:` is a list of short +strings or long mappings (`source` required + optional `target`/`uid`/`gid`/ +`mode`); an unknown source name -> `unknown secret 'X'`. `secrets` joins +`SUPPORTED_TOP_LEVEL_KEYS` and, as a structural key, `SUPPORTED_SERVICE_KEYS`. + +## Non-goals + +- `external: true` -- rejected (v1 boundary; compose2pod creates the store from + the file, and referencing a maybe-absent podman secret fails late). Revisit if + a real pre-created-podman-secret need appears; own decision file. +- Creating secrets not referenced in the target's closure (matches how emit only + runs the closure). +- Matching compose's "skip an empty env-source secret": an unset var yields an + empty secret (`${VAR-}`), the simplest faithful-enough v1 behavior. + +## Testing + +`just test-ci` at 100%. Validation accept/reject matrix (file/env accepted; +`external`, multiple sources, unknown source key, non-mapping def, unknown +referenced secret, malformed long form rejected). Emission: file `create`, env +`printf | create -`, `--secret source/target/uid/gid/mode` opts, `mode` int-> +octal string and string passthrough, pod-namespacing, the trap `secret rm`, and +`referenced_variables()` collecting env-source vars. An `emit_script` integration +with a secret referenced across the closure. `just lint-ci` clean (watch `C901` +on the new translation functions -- keep them small/split). + +## Risk + +- **Store-name collision on reused pod names** (low x med): namespacing is + `-`; two runs sharing a pod name collide, same class as an existing + pod-name collision — the user's responsibility. +- **mode representation** (med x low): PyYAML octal-int vs string; mitigated by + handling both and testing `0440` int and `"0440"` string. +- **New-module scope** (low x low): `secrets.py` spans validate + emit; kept a + deep module with a small interface (referenced set, create lines, flags). From 720704ecf868d03cc107bcea18d40bf76874d757 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Fri, 10 Jul 2026 13:07:37 +0300 Subject: [PATCH 2/8] feat: validate compose secrets definitions and references --- compose2pod/keys.py | 1 + compose2pod/parsing.py | 4 ++- compose2pod/secrets.py | 65 +++++++++++++++++++++++++++++++++++++++++ tests/test_keys.py | 1 + tests/test_parsing.py | 13 +++++++-- tests/test_secrets.py | 66 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 compose2pod/secrets.py create mode 100644 tests/test_secrets.py diff --git a/compose2pod/keys.py b/compose2pod/keys.py index 404a980..232656c 100644 --- a/compose2pod/keys.py +++ b/compose2pod/keys.py @@ -196,4 +196,5 @@ def _emit_ulimits(value: Any) -> list[Token]: # noqa: ANN401 - Compose values a "networks", "hostname", "container_name", + "secrets", } diff --git a/compose2pod/parsing.py b/compose2pod/parsing.py index 4f6de19..1fac3f0 100644 --- a/compose2pod/parsing.py +++ b/compose2pod/parsing.py @@ -6,12 +6,13 @@ 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 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"} +SUPPORTED_TOP_LEVEL_KEYS = {"services", "version", "name", "networks", "volumes", "secrets"} DEPENDS_ON_CONDITIONS = {"service_started", "service_healthy", "service_completed_successfully"} @@ -126,4 +127,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) return warnings diff --git a/compose2pod/secrets.py b/compose2pod/secrets.py new file mode 100644 index 0000000..74cc67c --- /dev/null +++ b/compose2pod/secrets.py @@ -0,0 +1,65 @@ +"""Translate compose secrets into podman secret store create / mount / remove.""" + +from typing import Any + +from compose2pod.exceptions import UnsupportedComposeError + + +_LONG_FORM_KEYS = {"source", "target", "uid", "gid", "mode"} + + +def _validate_secret_def(name: str, definition: Any) -> None: # noqa: ANN401 + 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) + + +def _ref_source(name: str, ref: Any) -> str: # noqa: ANN401 + 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) + 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) diff --git a/tests/test_keys.py b/tests/test_keys.py index 3d400a6..48446ef 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -21,6 +21,7 @@ def test_supported_service_keys_snapshot() -> None: "networks", "hostname", "container_name", + "secrets", "user", "working_dir", "platform", diff --git a/tests/test_parsing.py b/tests/test_parsing.py index cc411de..747a038 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -54,8 +54,8 @@ def test_no_services_raises(self) -> None: validate({"services": {}}) def test_unknown_top_level_key_raises(self) -> None: - with pytest.raises(UnsupportedComposeError, match="secrets"): - validate({"services": {"app": {"image": "x"}}, "secrets": {}}) + with pytest.raises(UnsupportedComposeError, match="foo"): + validate({"services": {"app": {"image": "x"}}, "foo": {}}) def test_top_level_networks_is_ignored_with_warning(self) -> None: warnings = validate({"services": {"app": {"image": "x"}}, "networks": {"default": None}}) @@ -284,3 +284,12 @@ def test_malformed_depends_on_raises_at_gate(self) -> None: def test_valid_networks_forms_accepted(self) -> None: assert validate({"services": {"app": {"image": "x", "networks": ["n1"]}}}) == [] assert validate({"services": {"app": {"image": "x", "networks": {"default": None}}}}) == [] + + def test_secrets_top_level_and_service_ref_accepted(self) -> None: + doc = {"services": {"app": {"image": "x", "secrets": ["db"]}}, "secrets": {"db": {"file": "./db.txt"}}} + assert validate(doc) == [] + + 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) diff --git a/tests/test_secrets.py b/tests/test_secrets.py new file mode 100644 index 0000000..139eef9 --- /dev/null +++ b/tests/test_secrets.py @@ -0,0 +1,66 @@ +from typing import Any + +import pytest + +from compose2pod.exceptions import UnsupportedComposeError +from compose2pod.secrets import validate_secrets + + +def _doc(secrets: Any = None, svc_secrets: Any = None) -> dict[str, Any]: # noqa: ANN401 + 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}])) From 135f6c5995a35bc876b114fb8deb730ac5840577 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Fri, 10 Jul 2026 13:13:01 +0300 Subject: [PATCH 3/8] feat: emit podman secret create lines, --secret flags, and referenced vars --- compose2pod/secrets.py | 63 ++++++++++++++++++++++++++++++++++++++++-- tests/test_secrets.py | 41 +++++++++++++++++++++++++-- 2 files changed, 100 insertions(+), 4 deletions(-) diff --git a/compose2pod/secrets.py b/compose2pod/secrets.py index 74cc67c..95bad29 100644 --- a/compose2pod/secrets.py +++ b/compose2pod/secrets.py @@ -1,14 +1,17 @@ """Translate compose secrets into podman secret store create / mount / remove.""" +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"} -def _validate_secret_def(name: str, definition: Any) -> None: # noqa: ANN401 +def _validate_secret_def(name: str, definition: Any) -> None: # noqa: ANN401 - Compose values are untyped if not isinstance(definition, dict): msg = f"secret {name!r} must be a mapping" raise UnsupportedComposeError(msg) @@ -25,7 +28,7 @@ def _validate_secret_def(name: str, definition: Any) -> None: # noqa: ANN401 raise UnsupportedComposeError(msg) -def _ref_source(name: str, ref: Any) -> str: # noqa: ANN401 +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): @@ -63,3 +66,59 @@ def validate_secrets(compose: dict[str, Any]) -> None: 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 "file" in definition: + 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 "file" in definition: + result |= variable_names(str(Path(project_dir, definition["file"]))) + else: + result.add(definition["environment"]) + return result diff --git a/tests/test_secrets.py b/tests/test_secrets.py index 139eef9..229a3f1 100644 --- a/tests/test_secrets.py +++ b/tests/test_secrets.py @@ -3,10 +3,16 @@ import pytest from compose2pod.exceptions import UnsupportedComposeError -from compose2pod.secrets import validate_secrets +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 +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 @@ -64,3 +70,34 @@ def test_non_string_or_mapping_reference_rejected(self) -> None: 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}])) + + +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"} From f0e6d6a8f64e0d85d9cff509022e02f1f2181cd8 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Fri, 10 Jul 2026 13:20:21 +0300 Subject: [PATCH 4/8] feat: weave the secret store lifecycle and --secret mounts into the script --- compose2pod/emit.py | 11 ++++++++++- tests/test_emit.py | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/compose2pod/emit.py b/compose2pod/emit.py index 0916021..a89f7e1 100644 --- a/compose2pod/emit.py +++ b/compose2pod/emit.py @@ -8,6 +8,7 @@ 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 @@ -98,6 +99,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) return flags @@ -184,6 +186,7 @@ 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() @@ -192,8 +195,13 @@ def emit_script(compose: dict[str, Any], options: EmitOptions) -> str: } lines = [_SCRIPT_HEADER] - lines.append(f"trap 'podman pod rm -f {shlex.quote(options.pod)} >/dev/null 2>&1 || true' EXIT") + 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) + 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)) waited: set[str] = set() for name in order: for dep, condition in depends_on(services[name]).items(): @@ -227,4 +235,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)) return sorted(names) diff --git a/tests/test_emit.py b/tests/test_emit.py index 6734a5d..427ca89 100644 --- a/tests/test_emit.py +++ b/tests/test_emit.py @@ -82,6 +82,10 @@ def test_named_volume_emitted_without_project_dir_translation(self) -> None: flags = run_flags("db", svc, "p", [], "/builds/x") assert flags[4:6] == ["-v", _Expand(value="pgdata:/var/lib/postgresql/data")] + def test_secret_flag_emitted(self) -> None: + flags = run_flags("app", {"image": "x", "secrets": ["db"]}, "test-pod", [], "/b") + assert flags[-2:] == ["--secret", "source=test-pod-db,target=db"] + def test_healthcheck_without_timeout_omits_health_timeout_flag(self) -> None: flags = run_flags("app", {"image": "x", "healthcheck": {"test": "true"}}, "p", [], "/builds/x") assert flags[4:6] == ["--health-cmd", _Expand(value="true")] @@ -265,6 +269,40 @@ def test_missing_entrypoint_is_empty(self) -> None: assert entrypoint_tokens({"image": "x"}) == [] +class TestSecretsLifecycle: + def _script(self, doc: dict) -> str: + options = EmitOptions( + target="app", + ci_image="ci", + command="", + pod="test-pod", + project_dir="/proj", + artifacts=[], + allow_exit_codes=[], + ) + return emit_script(compose=doc, options=options) + + def test_file_secret_create_and_trap_and_mount(self) -> None: + doc = {"services": {"app": {"image": "x", "secrets": ["db"]}}, "secrets": {"db": {"file": "./db.txt"}}} + script = self._script(doc) + assert 'podman secret create test-pod-db "/proj/db.txt"' in script + assert "podman secret rm test-pod-db" in script + assert "--secret source=test-pod-db,target=db" in script + + def test_env_secret_referenced_variable_noted(self) -> None: + doc = {"services": {"app": {"image": "x", "secrets": ["k"]}}, "secrets": {"k": {"environment": "API_KEY"}}} + options = EmitOptions( + target="app", + ci_image="ci", + command="", + pod="p", + project_dir="/proj", + artifacts=[], + allow_exit_codes=[], + ) + assert "API_KEY" in referenced_variables(doc, options) + + class TestEmitScript: def make_script(self, chats_compose: dict) -> str: options = EmitOptions( From daa4502b079b83f71897681f66e33f96d53e33b1 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Fri, 10 Jul 2026 13:27:31 +0300 Subject: [PATCH 5/8] docs: record secrets support in the supported subset --- architecture/supported-subset.md | 61 +++++++++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 4 deletions(-) diff --git a/architecture/supported-subset.md b/architecture/supported-subset.md index d8bf80d..daa0499 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`. + `networks`, `secrets`. - **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 @@ -21,9 +21,10 @@ warns (ignored, behavior-neutral inside a single pod) or raises - **Supported:** `image`, `build`, `command`, `entrypoint`, `environment`, `env_file`, `volumes`, `healthcheck`, `depends_on`, `networks`, `hostname`, - `container_name`, `tmpfs`, `user`, `working_dir`, `group_add`, `labels`, - `read_only`, `init`, `privileged`, `cap_add`, `cap_drop`, `security_opt`, - `platform`, `devices`, `annotations`, `extra_hosts`, `pull_policy`, `ulimits`. + `container_name`, `tmpfs`, `secrets`, `user`, `working_dir`, `group_add`, + `labels`, `read_only`, `init`, `privileged`, `cap_add`, `cap_drop`, + `security_opt`, `platform`, `devices`, `annotations`, `extra_hosts`, + `pull_policy`, `ulimits`. compose2pod never builds: a `build` section is accepted but its contents (context, dockerfile, args) are not read — `image_for` (`compose2pod/emit.py`) runs the CI image supplied via `--image` for any service that has one. @@ -162,6 +163,58 @@ common way to shadow a subdirectory of a bind mount). No host-path translation is applied, since the entry names a container path, not a host source. A colon-less entry that is not absolute (e.g. `./cache`) is malformed and raises. +## Secrets + +- **Top-level `secrets:` definitions:** each entry must be a mapping with + exactly one of `file:` (a host path, resolved against `--project-dir` when + 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`). +- **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`). +- **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). +- **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`). +- **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`. +- **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`). +- **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. +- 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. + ## Variable interpolation compose2pod does not resolve Compose Spec `${VAR}` references at generation From db08f12aed40b948cdda3593be1acd12ab3c49a0 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Fri, 10 Jul 2026 14:28:54 +0300 Subject: [PATCH 6/8] fix: reject unsafe secret and env-var names to prevent shell injection --- compose2pod/secrets.py | 17 +++++++++++++++++ tests/test_secrets.py | 17 +++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/compose2pod/secrets.py b/compose2pod/secrets.py index 95bad29..5267549 100644 --- a/compose2pod/secrets.py +++ b/compose2pod/secrets.py @@ -1,5 +1,6 @@ """Translate compose secrets into podman secret store create / mount / remove.""" +import re from pathlib import Path from typing import Any @@ -9,9 +10,14 @@ _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.match(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) @@ -26,6 +32,16 @@ def _validate_secret_def(name: str, definition: Any) -> None: # noqa: ANN401 - 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.match(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 @@ -42,6 +58,7 @@ def _ref_source(name: str, ref: Any) -> str: # noqa: ANN401 - Compose values ar 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 diff --git a/tests/test_secrets.py b/tests/test_secrets.py index 229a3f1..c7c6ce8 100644 --- a/tests/test_secrets.py +++ b/tests/test_secrets.py @@ -71,6 +71,23 @@ 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_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: From 06aa66cbc7c0aac170b9c2091708d095e2a40c2d Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Fri, 10 Jul 2026 14:33:40 +0300 Subject: [PATCH 7/8] fix: use fullmatch so a trailing newline can't bypass secret name validation --- compose2pod/secrets.py | 4 ++-- tests/test_secrets.py | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/compose2pod/secrets.py b/compose2pod/secrets.py index 5267549..1727cbb 100644 --- a/compose2pod/secrets.py +++ b/compose2pod/secrets.py @@ -15,7 +15,7 @@ def _validate_secret_def(name: str, definition: Any) -> None: # noqa: ANN401 - Compose values are untyped - if not _SECRET_NAME.match(name): + 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): @@ -32,7 +32,7 @@ def _validate_secret_def(name: str, definition: Any) -> None: # noqa: ANN401 - 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.match(definition["environment"]): + 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) diff --git a/tests/test_secrets.py b/tests/test_secrets.py index c7c6ce8..e1a25a1 100644 --- a/tests/test_secrets.py +++ b/tests/test_secrets.py @@ -82,6 +82,14 @@ 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}])) From fdb3fce1bdf9983a4e16b03ad25624781629aa3a Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Fri, 10 Jul 2026 14:41:01 +0300 Subject: [PATCH 8/8] fix: branch secret emission on string source, not file-key presence validate_secrets picks the source by string-ness (isinstance(def.get(k), str)), but secret_create_lines/secret_referenced_variables branched on "file" in def (key presence). A def like {file: [x], environment: VAR} validated as an env secret yet crashed raw in the file branch (TypeError in Path), violating the refuse-loudly contract. Align emission with validation's string-ness check. --- compose2pod/secrets.py | 4 ++-- tests/test_secrets.py | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/compose2pod/secrets.py b/compose2pod/secrets.py index 1727cbb..eb572ac 100644 --- a/compose2pod/secrets.py +++ b/compose2pod/secrets.py @@ -119,7 +119,7 @@ def secret_create_lines(compose: dict[str, Any], pod: str, project_dir: str, nam for name in names: definition = defs[name] store = f"{pod}-{name}" - if "file" in definition: + if isinstance(definition.get("file"), str): path = to_shell(str(Path(project_dir, definition["file"]))) lines.append(f"podman secret create {store} {path}") else: @@ -134,7 +134,7 @@ def secret_referenced_variables(compose: dict[str, Any], project_dir: str, names result: set[str] = set() for name in names: definition = defs[name] - if "file" in definition: + if isinstance(definition.get("file"), str): result |= variable_names(str(Path(project_dir, definition["file"]))) else: result.add(definition["environment"]) diff --git a/tests/test_secrets.py b/tests/test_secrets.py index e1a25a1..0da69c0 100644 --- a/tests/test_secrets.py +++ b/tests/test_secrets.py @@ -126,3 +126,12 @@ def test_environment_source_create_line(self) -> None: 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"}