Merge pull request 'vars: implement dependencies' (#1771) from DavHau/clan-core:DavHau-vars into main
This commit is contained in:
@@ -39,7 +39,7 @@ in
|
|||||||
vars = {
|
vars = {
|
||||||
generators = lib.flip lib.mapAttrs config.clan.core.vars.generators (
|
generators = lib.flip lib.mapAttrs config.clan.core.vars.generators (
|
||||||
_name: generator: {
|
_name: generator: {
|
||||||
inherit (generator) finalScript;
|
inherit (generator) dependencies finalScript;
|
||||||
files = lib.flip lib.mapAttrs generator.files (_name: file: { inherit (file) secret; });
|
files = lib.flip lib.mapAttrs generator.files (_name: file: { inherit (file) secret; });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ in
|
|||||||
description = ''
|
description = ''
|
||||||
A list of other generators that this generator depends on.
|
A list of other generators that this generator depends on.
|
||||||
The output values of these generators will be available to the generator script as files.
|
The output values of these generators will be available to the generator script as files.
|
||||||
For example, the file 'file1' of a dependency named 'dep1' will be available via $dependencies/dep1/file1.
|
For example, the file 'file1' of a dependency named 'dep1' will be available via $in/dep1/file1.
|
||||||
'';
|
'';
|
||||||
type = listOf str;
|
type = listOf str;
|
||||||
default = [ ];
|
default = [ ];
|
||||||
@@ -147,7 +147,7 @@ in
|
|||||||
description = ''
|
description = ''
|
||||||
The script to run to generate the files.
|
The script to run to generate the files.
|
||||||
The script will be run with the following environment variables:
|
The script will be run with the following environment variables:
|
||||||
- $dependencies: The directory containing the output values of all declared dependencies
|
- $in: The directory containing the output values of all declared dependencies
|
||||||
- $out: The output directory to put the generated files
|
- $out: The output directory to put the generated files
|
||||||
- $prompts: The directory containing the prompted values as files
|
- $prompts: The directory containing the prompted values as files
|
||||||
The script should produce the files specified in the 'files' attribute under $out.
|
The script should produce the files specified in the 'files' attribute under $out.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import os
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
from graphlib import TopologicalSorter
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ def read_multiline_input(prompt: str = "Finish with Ctrl-D") -> str:
|
|||||||
return proc.stdout
|
return proc.stdout
|
||||||
|
|
||||||
|
|
||||||
def bubblewrap_cmd(generator: str, generator_dir: Path) -> list[str]:
|
def bubblewrap_cmd(generator: str, generator_dir: Path, dep_tmpdir: Path) -> list[str]:
|
||||||
# fmt: off
|
# fmt: off
|
||||||
return run_cmd(
|
return run_cmd(
|
||||||
[
|
[
|
||||||
@@ -50,6 +51,7 @@ def bubblewrap_cmd(generator: str, generator_dir: Path) -> list[str]:
|
|||||||
"--tmpfs", "/usr/lib/systemd",
|
"--tmpfs", "/usr/lib/systemd",
|
||||||
"--dev", "/dev",
|
"--dev", "/dev",
|
||||||
"--bind", str(generator_dir), str(generator_dir),
|
"--bind", str(generator_dir), str(generator_dir),
|
||||||
|
"--ro-bind", str(dep_tmpdir), str(dep_tmpdir),
|
||||||
"--unshare-all",
|
"--unshare-all",
|
||||||
"--unshare-user",
|
"--unshare-user",
|
||||||
"--uid", "1000",
|
"--uid", "1000",
|
||||||
@@ -60,16 +62,58 @@ def bubblewrap_cmd(generator: str, generator_dir: Path) -> list[str]:
|
|||||||
# fmt: on
|
# fmt: on
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: implement caching to not decrypt the same secret multiple times
|
||||||
|
def decrypt_dependencies(
|
||||||
|
machine: Machine,
|
||||||
|
generator_name: str,
|
||||||
|
secret_vars_store: SecretStoreBase,
|
||||||
|
public_vars_store: FactStoreBase,
|
||||||
|
) -> dict[str, dict[str, bytes]]:
|
||||||
|
generator = machine.vars_generators[generator_name]
|
||||||
|
dependencies = set(generator["dependencies"])
|
||||||
|
decrypted_dependencies = {}
|
||||||
|
for dep_generator in dependencies:
|
||||||
|
decrypted_dependencies[dep_generator] = {}
|
||||||
|
dep_files = machine.vars_generators[dep_generator]["files"]
|
||||||
|
for file_name, file in dep_files.items():
|
||||||
|
if file["secret"]:
|
||||||
|
decrypted_dependencies[dep_generator][file_name] = (
|
||||||
|
secret_vars_store.get(dep_generator, file_name)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
decrypted_dependencies[dep_generator][file_name] = (
|
||||||
|
public_vars_store.get(dep_generator, file_name)
|
||||||
|
)
|
||||||
|
return decrypted_dependencies
|
||||||
|
|
||||||
|
|
||||||
|
# decrypt dependencies and return temporary file tree
|
||||||
|
def dependencies_as_dir(
|
||||||
|
decrypted_dependencies: dict[str, dict[str, bytes]],
|
||||||
|
tmpdir: Path,
|
||||||
|
) -> Path:
|
||||||
|
for dep_generator, files in decrypted_dependencies.items():
|
||||||
|
dep_generator_dir = tmpdir / dep_generator
|
||||||
|
dep_generator_dir.mkdir()
|
||||||
|
dep_generator_dir.chmod(0o700)
|
||||||
|
for file_name, file in files.items():
|
||||||
|
file_path = dep_generator_dir / file_name
|
||||||
|
file_path.touch()
|
||||||
|
file_path.chmod(0o600)
|
||||||
|
file_path.write_bytes(file)
|
||||||
|
return tmpdir
|
||||||
|
|
||||||
|
|
||||||
def execute_generator(
|
def execute_generator(
|
||||||
machine: Machine,
|
machine: Machine,
|
||||||
generator_name: str,
|
generator_name: str,
|
||||||
regenerate: bool,
|
regenerate: bool,
|
||||||
secret_vars_store: SecretStoreBase,
|
secret_vars_store: SecretStoreBase,
|
||||||
public_vars_store: FactStoreBase,
|
public_vars_store: FactStoreBase,
|
||||||
tmpdir: Path,
|
dep_tmpdir: Path,
|
||||||
prompt: Callable[[str], str],
|
prompt: Callable[[str], str],
|
||||||
) -> bool:
|
) -> bool:
|
||||||
generator_dir = tmpdir / generator_name
|
generator_dir = dep_tmpdir / generator_name
|
||||||
# check if all secrets exist and generate them if at least one is missing
|
# check if all secrets exist and generate them if at least one is missing
|
||||||
needs_regeneration = not check_secrets(machine, generator_name=generator_name)
|
needs_regeneration = not check_secrets(machine, generator_name=generator_name)
|
||||||
log.debug(f"{generator_name} needs_regeneration: {needs_regeneration}")
|
log.debug(f"{generator_name} needs_regeneration: {needs_regeneration}")
|
||||||
@@ -79,22 +123,30 @@ def execute_generator(
|
|||||||
msg = f"flake is not a Path: {machine.flake}"
|
msg = f"flake is not a Path: {machine.flake}"
|
||||||
msg += "fact/secret generation is only supported for local flakes"
|
msg += "fact/secret generation is only supported for local flakes"
|
||||||
|
|
||||||
env = os.environ.copy()
|
|
||||||
generator_dir.mkdir(parents=True)
|
|
||||||
env["out"] = str(generator_dir)
|
|
||||||
# compatibility for old outputs.nix users
|
# compatibility for old outputs.nix users
|
||||||
generator = machine.vars_generators[generator_name]["finalScript"]
|
generator = machine.vars_generators[generator_name]["finalScript"]
|
||||||
# if machine.vars_data[generator_name]["generator"]["prompt"]:
|
# if machine.vars_data[generator_name]["generator"]["prompt"]:
|
||||||
# prompt_value = prompt(machine.vars_data[generator_name]["generator"]["prompt"])
|
# prompt_value = prompt(machine.vars_data[generator_name]["generator"]["prompt"])
|
||||||
# env["prompt_value"] = prompt_value
|
# env["prompt_value"] = prompt_value
|
||||||
if sys.platform == "linux":
|
|
||||||
cmd = bubblewrap_cmd(generator, generator_dir)
|
# build temporary file tree of dependencies
|
||||||
else:
|
decrypted_dependencies = decrypt_dependencies(
|
||||||
cmd = ["bash", "-c", generator]
|
machine, generator_name, secret_vars_store, public_vars_store
|
||||||
run(
|
|
||||||
cmd,
|
|
||||||
env=env,
|
|
||||||
)
|
)
|
||||||
|
env = os.environ.copy()
|
||||||
|
generator_dir.mkdir(parents=True)
|
||||||
|
env["out"] = str(generator_dir)
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
dep_tmpdir = dependencies_as_dir(decrypted_dependencies, Path(tmp))
|
||||||
|
env["in"] = str(dep_tmpdir)
|
||||||
|
if sys.platform == "linux":
|
||||||
|
cmd = bubblewrap_cmd(generator, generator_dir, dep_tmpdir=dep_tmpdir)
|
||||||
|
else:
|
||||||
|
cmd = ["bash", "-c", generator]
|
||||||
|
run(
|
||||||
|
cmd,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
files_to_commit = []
|
files_to_commit = []
|
||||||
# store secrets
|
# store secrets
|
||||||
files = machine.vars_generators[generator_name]["files"]
|
files = machine.vars_generators[generator_name]["files"]
|
||||||
@@ -129,6 +181,17 @@ def prompt_func(text: str) -> str:
|
|||||||
return read_multiline_input()
|
return read_multiline_input()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_subgraph(graph: dict[str, set], vertex: str) -> dict[str, set]:
|
||||||
|
visited = set()
|
||||||
|
queue = [vertex]
|
||||||
|
while queue:
|
||||||
|
vertex = queue.pop(0)
|
||||||
|
if vertex not in visited:
|
||||||
|
visited.add(vertex)
|
||||||
|
queue.extend(graph[vertex] - visited)
|
||||||
|
return {k: v for k, v in graph.items() if k in visited}
|
||||||
|
|
||||||
|
|
||||||
def _generate_vars_for_machine(
|
def _generate_vars_for_machine(
|
||||||
machine: Machine,
|
machine: Machine,
|
||||||
generator_name: str | None,
|
generator_name: str | None,
|
||||||
@@ -152,21 +215,40 @@ def _generate_vars_for_machine(
|
|||||||
f"Could not find generator with name: {generator_name}. The following generators are available: {generators}"
|
f"Could not find generator with name: {generator_name}. The following generators are available: {generators}"
|
||||||
)
|
)
|
||||||
|
|
||||||
if generator_name:
|
# if generator_name:
|
||||||
machine_generator_facts = {
|
# machine_generator_facts = {
|
||||||
generator_name: machine.vars_generators[generator_name]
|
# generator_name: machine.vars_generators[generator_name]
|
||||||
}
|
# }
|
||||||
else:
|
# else:
|
||||||
machine_generator_facts = machine.vars_generators
|
# machine_generator_facts = machine.vars_generators
|
||||||
|
|
||||||
for generator_name in machine_generator_facts:
|
graph = {
|
||||||
|
gen_name: set(generator["dependencies"])
|
||||||
|
for gen_name, generator in machine.vars_generators.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
# extract sub-graph if specific generator selected
|
||||||
|
if generator_name:
|
||||||
|
graph = _get_subgraph(graph, generator_name)
|
||||||
|
|
||||||
|
# check if all dependencies actually exist
|
||||||
|
for gen_name, dependencies in graph.items():
|
||||||
|
for dep in dependencies:
|
||||||
|
if dep not in graph:
|
||||||
|
raise ClanError(
|
||||||
|
f"Generator {gen_name} has a dependency on {dep}, which does not exist"
|
||||||
|
)
|
||||||
|
|
||||||
|
# process generators in topological order
|
||||||
|
sorter = TopologicalSorter(graph)
|
||||||
|
for generator_name in sorter.static_order():
|
||||||
machine_updated |= execute_generator(
|
machine_updated |= execute_generator(
|
||||||
machine=machine,
|
machine=machine,
|
||||||
generator_name=generator_name,
|
generator_name=generator_name,
|
||||||
regenerate=regenerate,
|
regenerate=regenerate,
|
||||||
secret_vars_store=secret_vars_store,
|
secret_vars_store=secret_vars_store,
|
||||||
public_vars_store=public_vars_store,
|
public_vars_store=public_vars_store,
|
||||||
tmpdir=local_temp,
|
dep_tmpdir=local_temp,
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
)
|
)
|
||||||
if machine_updated:
|
if machine_updated:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import os
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -23,6 +24,50 @@ def def_value() -> defaultdict:
|
|||||||
nested_dict: Callable[[], dict[str, Any]] = lambda: defaultdict(def_value)
|
nested_dict: Callable[[], dict[str, Any]] = lambda: defaultdict(def_value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_subgraph() -> None:
|
||||||
|
from clan_cli.vars.generate import _get_subgraph
|
||||||
|
|
||||||
|
graph = dict(
|
||||||
|
a={"b", "c"},
|
||||||
|
b={"c"},
|
||||||
|
c=set(),
|
||||||
|
d=set(),
|
||||||
|
)
|
||||||
|
assert _get_subgraph(graph, "a") == {
|
||||||
|
"a": {"b", "c"},
|
||||||
|
"b": {"c"},
|
||||||
|
"c": set(),
|
||||||
|
}
|
||||||
|
assert _get_subgraph(graph, "b") == {"b": {"c"}, "c": set()}
|
||||||
|
|
||||||
|
|
||||||
|
def test_dependencies_as_files() -> None:
|
||||||
|
from clan_cli.vars.generate import dependencies_as_dir
|
||||||
|
|
||||||
|
decrypted_dependencies = dict(
|
||||||
|
gen_1=dict(
|
||||||
|
var_1a=b"var_1a",
|
||||||
|
var_1b=b"var_1b",
|
||||||
|
),
|
||||||
|
gen_2=dict(
|
||||||
|
var_2a=b"var_2a",
|
||||||
|
var_2b=b"var_2b",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with TemporaryDirectory() as tmpdir:
|
||||||
|
dep_tmpdir = dependencies_as_dir(decrypted_dependencies, Path(tmpdir))
|
||||||
|
assert dep_tmpdir.is_dir()
|
||||||
|
assert (dep_tmpdir / "gen_1" / "var_1a").read_bytes() == b"var_1a"
|
||||||
|
assert (dep_tmpdir / "gen_1" / "var_1b").read_bytes() == b"var_1b"
|
||||||
|
assert (dep_tmpdir / "gen_2" / "var_2a").read_bytes() == b"var_2a"
|
||||||
|
assert (dep_tmpdir / "gen_2" / "var_2b").read_bytes() == b"var_2b"
|
||||||
|
# ensure the files are not world readable
|
||||||
|
assert (dep_tmpdir / "gen_1" / "var_1a").stat().st_mode & 0o777 == 0o600
|
||||||
|
assert (dep_tmpdir / "gen_1" / "var_1b").stat().st_mode & 0o777 == 0o600
|
||||||
|
assert (dep_tmpdir / "gen_2" / "var_2a").stat().st_mode & 0o777 == 0o600
|
||||||
|
assert (dep_tmpdir / "gen_2" / "var_2b").stat().st_mode & 0o777 == 0o600
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.impure
|
@pytest.mark.impure
|
||||||
def test_generate_public_var(
|
def test_generate_public_var(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
@@ -155,3 +200,35 @@ def test_generate_secret_for_multiple_machines(
|
|||||||
assert sops_store2.exists("my_generator", "my_secret")
|
assert sops_store2.exists("my_generator", "my_secret")
|
||||||
assert sops_store1.get("my_generator", "my_secret").decode() == "machine1\n"
|
assert sops_store1.get("my_generator", "my_secret").decode() == "machine1\n"
|
||||||
assert sops_store2.get("my_generator", "my_secret").decode() == "machine2\n"
|
assert sops_store2.get("my_generator", "my_secret").decode() == "machine2\n"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.impure
|
||||||
|
def test_dependant_generators(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
temporary_home: Path,
|
||||||
|
) -> None:
|
||||||
|
config = nested_dict()
|
||||||
|
parent_gen = config["clan"]["core"]["vars"]["generators"]["parent_generator"]
|
||||||
|
parent_gen["files"]["my_value"]["secret"] = False
|
||||||
|
parent_gen["script"] = "echo hello > $out/my_value"
|
||||||
|
child_gen = config["clan"]["core"]["vars"]["generators"]["child_generator"]
|
||||||
|
child_gen["files"]["my_value"]["secret"] = False
|
||||||
|
child_gen["dependencies"] = ["parent_generator"]
|
||||||
|
child_gen["script"] = "cat $in/parent_generator/my_value > $out/my_value"
|
||||||
|
flake = generate_flake(
|
||||||
|
temporary_home,
|
||||||
|
flake_template=CLAN_CORE / "templates" / "minimal",
|
||||||
|
machine_configs=dict(my_machine=config),
|
||||||
|
)
|
||||||
|
monkeypatch.chdir(flake.path)
|
||||||
|
cli.run(["vars", "generate", "--flake", str(flake.path), "my_machine"])
|
||||||
|
parent_file_path = (
|
||||||
|
flake.path / "machines" / "my_machine" / "vars" / "child_generator" / "my_value"
|
||||||
|
)
|
||||||
|
assert parent_file_path.is_file()
|
||||||
|
assert parent_file_path.read_text() == "hello\n"
|
||||||
|
child_file_path = (
|
||||||
|
flake.path / "machines" / "my_machine" / "vars" / "child_generator" / "my_value"
|
||||||
|
)
|
||||||
|
assert child_file_path.is_file()
|
||||||
|
assert child_file_path.read_text() == "hello\n"
|
||||||
|
|||||||
Reference in New Issue
Block a user