Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions compose2pod/emit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
88 changes: 32 additions & 56 deletions compose2pod/stores.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
56 changes: 42 additions & 14 deletions tests/test_stores.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import re
from typing import Any

import pytest
Expand All @@ -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:
Expand All @@ -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"]))
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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"}),
]
Loading