vars: fix regenerating a specific generator

This was broken after re-designing the API -> added a test
This commit is contained in:
DavHau
2025-08-20 14:32:13 +07:00
parent 6996a6340a
commit de0b1b2d70
9 changed files with 88 additions and 30 deletions

View File

@@ -550,7 +550,13 @@ const InstallSummary = () => {
return; return;
} }
// Extract generator names from prompt values
// TODO: This is wrong. We need to extend run_generators to be able to compute
// a sane closure over a list of provided generators.
const generators = Object.keys(store.install.promptValues || {});
const runGenerators = client.fetch("run_generators", { const runGenerators = client.fetch("run_generators", {
generators: generators.length > 0 ? generators : undefined,
prompt_values: store.install.promptValues, prompt_values: store.install.promptValues,
machines: [ machines: [
{ {

View File

@@ -119,6 +119,28 @@ def test_generate_public_and_secret_vars(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
flake_with_sops: ClanFlake, flake_with_sops: ClanFlake,
) -> None: ) -> None:
"""Test generation of public and secret vars with dependencies.
Generator dependency graph:
my_generator (standalone)
├── my_value (public)
├── my_secret (secret)
└── value_with_default (public, has default)
my_shared_generator (shared=True)
└── my_shared_value (public)
dependent_generator (depends on my_shared_generator)
└── my_secret (secret, copies from my_shared_value)
This test verifies:
- Public and secret vars are stored correctly
- Shared generators work across dependencies
- Default values are handled properly
- Regeneration with --regenerate updates all values
- Regeneration with --regenerate --generator only updates specified generator
"""
flake = flake_with_sops flake = flake_with_sops
config = flake.machines["my_machine"] config = flake.machines["my_machine"]
@@ -126,14 +148,13 @@ def test_generate_public_and_secret_vars(
my_generator = config["clan"]["core"]["vars"]["generators"]["my_generator"] my_generator = config["clan"]["core"]["vars"]["generators"]["my_generator"]
my_generator["files"]["my_value"]["secret"] = False my_generator["files"]["my_value"]["secret"] = False
my_generator["files"]["my_secret"]["secret"] = True my_generator["files"]["my_secret"]["secret"] = True
my_generator["script"] = (
'echo -n public$RANDOM > "$out"/my_value; echo -n secret$RANDOM > "$out"/my_secret; echo -n non-default$RANDOM > "$out"/value_with_default'
)
my_generator["files"]["value_with_default"]["secret"] = False my_generator["files"]["value_with_default"]["secret"] = False
my_generator["files"]["value_with_default"]["value"]["_type"] = "override" my_generator["files"]["value_with_default"]["value"]["_type"] = "override"
my_generator["files"]["value_with_default"]["value"]["priority"] = 1000 # mkDefault my_generator["files"]["value_with_default"]["value"]["priority"] = 1000 # mkDefault
my_generator["files"]["value_with_default"]["value"]["content"] = "default_value" my_generator["files"]["value_with_default"]["value"]["content"] = "default_value"
my_generator["script"] = (
'echo -n public$RANDOM > "$out"/my_value; echo -n secret$RANDOM > "$out"/my_secret; echo -n non-default$RANDOM > "$out"/value_with_default'
)
my_shared_generator = config["clan"]["core"]["vars"]["generators"][ my_shared_generator = config["clan"]["core"]["vars"]["generators"][
"my_shared_generator" "my_shared_generator"
@@ -257,6 +278,44 @@ def test_generate_public_and_secret_vars(
assert shared_value != shared_value_new, ( assert shared_value != shared_value_new, (
"Shared value should change after regeneration" "Shared value should change after regeneration"
) )
# test that after regenerating a shared generator, it and its dependents are regenerated
cli.run(
[
"vars",
"generate",
"--flake",
str(flake.path),
"my_machine",
"--regenerate",
"--no-sandbox",
"--generator",
"my_shared_generator",
]
)
# test that the shared generator is regenerated
shared_value_after_regeneration = get_machine_var(
machine, "my_shared_generator/my_shared_value"
).printable_value
assert shared_value_after_regeneration != shared_value_new, (
"Shared value should change after regenerating my_shared_generator"
)
# test that the dependent generator is also regenerated (because it depends on my_shared_generator)
secret_value_after_regeneration = sops_store.get(
dependent_generator, "my_secret"
).decode()
assert secret_value_after_regeneration != secret_value_new, (
"Dependent generator's secret should change after regenerating my_shared_generator"
)
assert secret_value_after_regeneration == shared_value_after_regeneration, (
"Dependent generator's secret should match the new shared value"
)
# test that my_generator is NOT regenerated (it doesn't depend on my_shared_generator)
public_value_after_regeneration = get_machine_var(
machine, "my_generator/my_value"
).printable_value
assert public_value_after_regeneration == public_value_new, (
"my_generator value should NOT change after regenerating only my_shared_generator"
)
# TODO: it doesn't actually test if the group has access # TODO: it doesn't actually test if the group has access

View File

@@ -37,11 +37,8 @@ def generate_command(args: argparse.Namespace) -> None:
run_generators( run_generators(
machines, machines,
generators=args.generator generators=args.generator,
if args.generator full_closure=args.regenerate if args.regenerate is not None else False,
else "all"
if args.regenerate
else "minimal",
no_sandbox=args.no_sandbox, no_sandbox=args.no_sandbox,
) )

View File

@@ -383,7 +383,7 @@ def run_command(
machine_obj: Machine = Machine(args.machine, args.flake) machine_obj: Machine = Machine(args.machine, args.flake)
generate_facts([machine_obj]) generate_facts([machine_obj])
run_generators([machine_obj]) run_generators([machine_obj], generators=None, full_closure=False)
vm: VmConfig = inspect_vm(machine=machine_obj) vm: VmConfig = inspect_vm(machine=machine_obj)

View File

@@ -78,7 +78,7 @@ def run_machine_flash(
system_config_nix: dict[str, Any] = {} system_config_nix: dict[str, Any] = {}
generate_facts([machine]) generate_facts([machine])
run_generators([machine]) run_generators([machine], generators=None, full_closure=False)
if system_config.language: if system_config.language:
if system_config.language not in list_languages(): if system_config.language not in list_languages():

View File

@@ -88,7 +88,7 @@ def run_machine_install(opts: InstallOptions, target_host: Remote) -> None:
# Notify the UI about what we are doing # Notify the UI about what we are doing
notify_install_step("generators") notify_install_step("generators")
generate_facts([machine]) generate_facts([machine])
run_generators([machine]) run_generators([machine], generators=None, full_closure=False)
with ( with (
TemporaryDirectory(prefix="nixos-install-") as _base_directory, TemporaryDirectory(prefix="nixos-install-") as _base_directory,

View File

@@ -78,7 +78,7 @@ def morph_machine(
machine = Machine(name=name, flake=Flake(str(flakedir))) machine = Machine(name=name, flake=Flake(str(flakedir)))
run_generators([machine], generators="minimal") run_generators([machine], generators=None, full_closure=False)
machine.secret_vars_store.populate_dir( machine.secret_vars_store.populate_dir(
machine.name, machine.name,

View File

@@ -148,7 +148,7 @@ def run_machine_update(
target_host_root = stack.enter_context(_target_host.become_root()) target_host_root = stack.enter_context(_target_host.become_root())
generate_facts([machine], service=None, regenerate=False) generate_facts([machine], service=None, regenerate=False)
run_generators([machine], generators="minimal") run_generators([machine], generators=None, full_closure=False)
# Upload secrets to the target host using root # Upload secrets to the target host using root
upload_secrets(machine, target_host_root) upload_secrets(machine, target_host_root)

View File

@@ -97,7 +97,8 @@ strategies (e.g., interactive CLI, GUI, or programmatic).
@API.register @API.register
def run_generators( def run_generators(
machines: list[Machine], machines: list[Machine],
generators: str | list[str] = "minimal", generators: str | list[str] | None = None,
full_closure: bool = False,
prompt_values: dict[str, dict[str, str]] | PromptFunc = lambda g: g.ask_prompts(), prompt_values: dict[str, dict[str, str]] | PromptFunc = lambda g: g.ask_prompts(),
no_sandbox: bool = False, no_sandbox: bool = False,
) -> None: ) -> None:
@@ -105,12 +106,13 @@ def run_generators(
Args: Args:
machines: The machines to run generators for. machines: The machines to run generators for.
generators: Can be: generators: Can be:
- str: Single generator name to run (ensuring dependencies are met) - None: Run all generators (with closure based on full_closure parameter)
- str: Single generator name to run (with closure based on full_closure parameter)
- list[str]: Specific generator names to run exactly as provided. - list[str]: Specific generator names to run exactly as provided.
Dependency generators are not added automatically in this case. Dependency generators are not added automatically in this case.
The caller must ensure that all dependencies are included. The caller must ensure that all dependencies are included.
- "all": Run all generators (full closure) full_closure: Whether to include all dependencies (True) or only missing ones (False).
- "minimal": Run only missing generators (minimal closure) (default) Only used when generators is None or a string.
prompt_values: A dictionary mapping generator names to their prompt values, prompt_values: A dictionary mapping generator names to their prompt values,
or a function that returns prompt values for a generator. or a function that returns prompt values for a generator.
no_sandbox: Whether to disable sandboxing when executing the generator. no_sandbox: Whether to disable sandboxing when executing the generator.
@@ -119,16 +121,8 @@ def run_generators(
executing the generator. executing the generator.
""" """
for machine in machines: for machine in machines:
if generators == "all": if isinstance(generators, list):
generator_objects = get_generators(machine, full_closure=True) # List of generator names - use them exactly as provided
elif generators == "minimal":
generator_objects = get_generators(machine, full_closure=False)
elif isinstance(generators, str) and generators not in ["all", "minimal"]:
# Single generator name - compute minimal closure for it
generator_objects = get_generators(
machine, full_closure=False, generator_name=generators
)
elif isinstance(generators, list):
if len(generators) == 0: if len(generators) == 0:
return return
# Create GeneratorKeys for this specific machine # Create GeneratorKeys for this specific machine
@@ -138,8 +132,10 @@ def run_generators(
all_generators = get_generators(machine, full_closure=True) all_generators = get_generators(machine, full_closure=True)
generator_objects = [g for g in all_generators if g.key in generator_keys] generator_objects = [g for g in all_generators if g.key in generator_keys]
else: else:
msg = f"Invalid generators argument: {generators}. Must be 'all', 'minimal', a generator name, or a list of generator names" # None or single string - use get_generators with closure parameter
raise ValueError(msg) generator_objects = get_generators(
machine, full_closure=full_closure, generator_name=generators
)
# If prompt function provided, ask all prompts # If prompt function provided, ask all prompts
# TODO: make this more lazy and ask for every generator on execution # TODO: make this more lazy and ask for every generator on execution