diff --git a/compose2pod/emit.py b/compose2pod/emit.py index e1e21c6..570f691 100644 --- a/compose2pod/emit.py +++ b/compose2pod/emit.py @@ -377,8 +377,9 @@ def _plan(compose: dict[str, Any], options: EmitOptions) -> PlannedScript: # running as a non-root user can read the bind-mounted /etc/hosts. Without # this, glibc falls through to DNS and name resolution fails. lines.append('chmod 644 "$hostsfile"') - lines.extend(stores.create_lines(compose, order, options.pod, options.project_dir)) - names |= stores.referenced_variables(compose, order, options.project_dir) + for step in stores.create_steps(compose, order, options.pod, options.project_dir): + lines.append(step.line) + names |= step.variables waited: set[str] = set() for name in order: for dep, condition in depends_on(name, services[name]).items(): diff --git a/compose2pod/stores.py b/compose2pod/stores.py index 4f104e7..92453fa 100644 --- a/compose2pod/stores.py +++ b/compose2pod/stores.py @@ -195,48 +195,29 @@ def _flags_for(svc: dict[str, Any], pod: str, kind: StoreKind) -> list[Token]: return tokens -def _create_lines_for( - compose: dict[str, Any], - pod: str, - project_dir: str, - names: list[str], - kind: StoreKind, -) -> list[str]: - """`podman secret create` lines for the referenced stores (file or environment source).""" - defs = compose.get(kind.top_key) or {} - lines: list[str] = [] - for name in names: - definition = defs[name] - store = f"{pod}-{kind.prefix}{name}" - if isinstance(definition.get("file"), str): - path = to_shell(str(Path(project_dir, definition["file"]))) - lines.append(f"podman secret create {store} {path}") - elif isinstance(definition.get("content"), str): - lines.append(f"printf '%s' {to_shell(definition['content'])} | podman secret create {store} -") - else: - var = definition["environment"] - lines.append(f"printf '%s' \"${{{var}-}}\" | podman secret create {store} -") - return lines - - -def _referenced_variables_for( - compose: dict[str, Any], - project_dir: str, - names: list[str], - kind: StoreKind, -) -> set[str]: - """Run-time variable names the create lines expand (env-source vars + file-path vars).""" - defs = compose.get(kind.top_key) or {} - result: set[str] = set() - for name in names: - definition = defs[name] - if isinstance(definition.get("file"), str): - result |= variable_names(str(Path(project_dir, definition["file"]))) - elif isinstance(definition.get("content"), str): - result |= variable_names(definition["content"]) - else: - result.add(definition["environment"]) - return result +@dataclasses.dataclass(frozen=True, slots=True, kw_only=True) +class CreateStep: + """One `podman secret create` line paired with the run-time variables it expands.""" + + line: str + variables: frozenset[str] + + +def _create(definition: dict[str, Any], store: str, project_dir: str) -> CreateStep: + """Render one store's create line and the variables that line expands, from one binding. + + Whichever source the store declares, the expandable text is bound once and + both outputs are read off that binding, so a line cannot expand a variable + the caller is never told about. + """ + if isinstance(definition.get("file"), str): + value = str(Path(project_dir, definition["file"])) + line = f"podman secret create {store} {to_shell(value)}" + else: + content = definition.get("content") + value = content if isinstance(content, str) else "${" + definition["environment"] + "}" + line = f"printf '%s' {to_shell(value)} | podman secret create {store} -" + return CreateStep(line=line, variables=frozenset(variable_names(value))) def validate(compose: dict[str, Any]) -> None: @@ -268,19 +249,14 @@ def teardown_line(compose: dict[str, Any], order: list[str], pod: str) -> str: return f"podman secret rm {' '.join(names)} >/dev/null 2>&1 || true" -def create_lines(compose: dict[str, Any], order: list[str], pod: str, project_dir: str) -> list[str]: - """`podman secret create` lines for every referenced store (secrets, then configs).""" - services = compose.get("services") or {} - lines: list[str] = [] - for kind in _STORE_KINDS: - lines += _create_lines_for(compose, pod, project_dir, _referenced_names(services, order, kind), kind) - return lines - - -def referenced_variables(compose: dict[str, Any], order: list[str], project_dir: str) -> set[str]: - """Run-time variable names every referenced store's create lines expand.""" +def create_steps(compose: dict[str, Any], order: list[str], pod: str, project_dir: str) -> list[CreateStep]: + """Create line plus expanded variables for every referenced store (secrets, then configs).""" services = compose.get("services") or {} - result: set[str] = set() + steps: list[CreateStep] = [] for kind in _STORE_KINDS: - result |= _referenced_variables_for(compose, project_dir, _referenced_names(services, order, kind), kind) - return result + defs = compose.get(kind.top_key) or {} + steps += [ + _create(defs[name], f"{pod}-{kind.prefix}{name}", project_dir) + for name in _referenced_names(services, order, kind) + ] + return steps diff --git a/tests/test_stores.py b/tests/test_stores.py index f2d6e5f..12c1cba 100644 --- a/tests/test_stores.py +++ b/tests/test_stores.py @@ -1,3 +1,4 @@ +import re from typing import Any import pytest @@ -6,6 +7,10 @@ from compose2pod.exceptions import UnsupportedComposeError +_ESCAPE = re.compile(r"\\.") +_RENDERED_VARIABLE = re.compile(r"\$\{([a-zA-Z_][a-zA-Z0-9_]*)") + + def _doc(top_key: str, defs: Any = None, refs: Any = None) -> dict[str, Any]: # noqa: ANN401 - Compose values are untyped svc: dict[str, Any] = {"image": "x"} if refs is not None: @@ -16,6 +21,20 @@ def _doc(top_key: str, defs: Any = None, refs: Any = None) -> dict[str, Any]: # return doc +def _lines(doc: dict[str, Any]) -> list[str]: + return [step.line for step in stores.create_steps(doc, ["app"], "p", "/proj")] + + +def _variables(doc: dict[str, Any]) -> set[str]: + return {name for step in stores.create_steps(doc, ["app"], "p", "/proj") for name in step.variables} + + +def _expanded_names(line: str) -> set[str]: + """Variables a rendered line really expands, read back off the text `to_shell` produced.""" + # `to_shell` escapes a literal dollar as `\$`, so a rendered `\${A}` is inert text. + return set(_RENDERED_VARIABLE.findall(_ESCAPE.sub("", line))) + + class TestValidateSecretKind: def test_file_and_environment_sources_accepted(self) -> None: stores.validate(_doc("secrets", {"a": {"file": "./a.txt"}, "b": {"environment": "B"}}, ["a", "b"])) @@ -222,27 +241,23 @@ def test_referenced_names_deduped_in_order_via_teardown(self) -> None: def test_file_source_create_line(self) -> None: doc = {"services": {"app": {"secrets": ["db"]}}, "secrets": {"db": {"file": "./db.txt"}}} - assert stores.create_lines(doc, ["app"], "p", "/proj") == ['podman secret create p-db "/proj/db.txt"'] + assert _lines(doc) == ['podman secret create p-db "/proj/db.txt"'] def test_environment_source_create_line(self) -> None: doc = {"services": {"app": {"secrets": ["k"]}}, "secrets": {"k": {"environment": "API_KEY"}}} - assert stores.create_lines(doc, ["app"], "p", "/proj") == [ - "printf '%s' \"${API_KEY-}\" | podman secret create p-k -" - ] + assert _lines(doc) == ["printf '%s' \"${API_KEY-}\" | podman secret create p-k -"] def test_referenced_variables_from_env_and_path(self) -> None: doc = { "services": {"app": {"secrets": ["k", "f"]}}, "secrets": {"k": {"environment": "API_KEY"}, "f": {"file": "${DIR}/s.txt"}}, } - assert stores.referenced_variables(doc, ["app"], "/proj") == {"API_KEY", "DIR"} + assert _variables(doc) == {"API_KEY", "DIR"} def test_non_string_file_takes_environment_branch(self) -> None: doc = {"services": {"app": {"secrets": ["k"]}}, "secrets": {"k": {"file": ["x"], "environment": "API_KEY"}}} - assert stores.create_lines(doc, ["app"], "p", "/proj") == [ - "printf '%s' \"${API_KEY-}\" | podman secret create p-k -" - ] - assert stores.referenced_variables(doc, ["app"], "/proj") == {"API_KEY"} + assert _lines(doc) == ["printf '%s' \"${API_KEY-}\" | podman secret create p-k -"] + assert _variables(doc) == {"API_KEY"} class TestConfigEmission: @@ -258,17 +273,15 @@ def test_long_form_absolute_target(self) -> None: def test_content_source_create_line_expands_at_run_time(self) -> None: doc = {"services": {"app": {"configs": ["c"]}}, "configs": {"c": {"content": "token=${API_TOKEN}"}}} - assert stores.create_lines(doc, ["app"], "p", "/proj") == [ - "printf '%s' \"token=${API_TOKEN-}\" | podman secret create p-config-c -" - ] + assert _lines(doc) == ["printf '%s' \"token=${API_TOKEN-}\" | podman secret create p-config-c -"] def test_file_source_create_line_is_config_prefixed(self) -> None: doc = {"services": {"app": {"configs": ["c"]}}, "configs": {"c": {"file": "./c.conf"}}} - assert stores.create_lines(doc, ["app"], "p", "/proj") == ['podman secret create p-config-c "/proj/c.conf"'] + assert _lines(doc) == ['podman secret create p-config-c "/proj/c.conf"'] def test_content_referenced_variables(self) -> None: doc = {"services": {"app": {"configs": ["c"]}}, "configs": {"c": {"content": "a=${A} b=${B}"}}} - assert stores.referenced_variables(doc, ["app"], "/proj") == {"A", "B"} + assert _variables(doc) == {"A", "B"} class TestBothKinds: @@ -287,3 +300,18 @@ def test_teardown_line_lists_secrets_then_configs(self) -> None: def test_teardown_line_empty_without_stores(self) -> None: assert stores.teardown_line({"services": {"app": {"image": "x"}}}, ["app"], "p") == "" + + +class TestCreateStepPairing: + def test_each_step_reports_the_variables_its_own_line_expands(self) -> None: + doc = { + "services": {"app": {"secrets": ["k", "f"], "configs": ["c"]}}, + "secrets": {"k": {"environment": "API_KEY"}, "f": {"file": "${DIR}/s.txt"}}, + "configs": {"c": {"content": "a=${A} b=$$B c=$${D}"}}, + } + steps = stores.create_steps(doc, ["app"], "p", "/proj") + assert [(_expanded_names(step.line), set(step.variables)) for step in steps] == [ + ({"API_KEY"}, {"API_KEY"}), + ({"DIR"}, {"DIR"}), + ({"A"}, {"A"}), + ]