diff --git a/pkgs/clan-app/ui/src/workflows/Install/steps/installSteps.tsx b/pkgs/clan-app/ui/src/workflows/Install/steps/installSteps.tsx index fdaedd0fc..41d0511fe 100644 --- a/pkgs/clan-app/ui/src/workflows/Install/steps/installSteps.tsx +++ b/pkgs/clan-app/ui/src/workflows/Install/steps/installSteps.tsx @@ -550,7 +550,13 @@ const InstallSummary = () => { 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", { + generators: generators.length > 0 ? generators : undefined, prompt_values: store.install.promptValues, machines: [ { diff --git a/pkgs/clan-cli/clan_cli/tests/test_vars.py b/pkgs/clan-cli/clan_cli/tests/test_vars.py index 998e78f79..bc0e7574c 100644 --- a/pkgs/clan-cli/clan_cli/tests/test_vars.py +++ b/pkgs/clan-cli/clan_cli/tests/test_vars.py @@ -119,6 +119,28 @@ def test_generate_public_and_secret_vars( monkeypatch: pytest.MonkeyPatch, flake_with_sops: ClanFlake, ) -> 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 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["files"]["my_value"]["secret"] = False 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"]["value"]["_type"] = "override" my_generator["files"]["value_with_default"]["value"]["priority"] = 1000 # mkDefault 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" @@ -257,6 +278,44 @@ def test_generate_public_and_secret_vars( assert shared_value != shared_value_new, ( "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 diff --git a/pkgs/clan-cli/clan_cli/vars/generate.py b/pkgs/clan-cli/clan_cli/vars/generate.py index f6e06c98a..7fc2d02d9 100644 --- a/pkgs/clan-cli/clan_cli/vars/generate.py +++ b/pkgs/clan-cli/clan_cli/vars/generate.py @@ -37,11 +37,8 @@ def generate_command(args: argparse.Namespace) -> None: run_generators( machines, - generators=args.generator - if args.generator - else "all" - if args.regenerate - else "minimal", + generators=args.generator, + full_closure=args.regenerate if args.regenerate is not None else False, no_sandbox=args.no_sandbox, ) diff --git a/pkgs/clan-cli/clan_cli/vms/run.py b/pkgs/clan-cli/clan_cli/vms/run.py index acfb12838..b492a11a0 100644 --- a/pkgs/clan-cli/clan_cli/vms/run.py +++ b/pkgs/clan-cli/clan_cli/vms/run.py @@ -383,7 +383,7 @@ def run_command( machine_obj: Machine = Machine(args.machine, args.flake) generate_facts([machine_obj]) - run_generators([machine_obj]) + run_generators([machine_obj], generators=None, full_closure=False) vm: VmConfig = inspect_vm(machine=machine_obj) diff --git a/pkgs/clan-cli/clan_lib/flash/flash.py b/pkgs/clan-cli/clan_lib/flash/flash.py index 7388c1cea..a5dda9c91 100644 --- a/pkgs/clan-cli/clan_lib/flash/flash.py +++ b/pkgs/clan-cli/clan_lib/flash/flash.py @@ -78,7 +78,7 @@ def run_machine_flash( system_config_nix: dict[str, Any] = {} generate_facts([machine]) - run_generators([machine]) + run_generators([machine], generators=None, full_closure=False) if system_config.language: if system_config.language not in list_languages(): diff --git a/pkgs/clan-cli/clan_lib/machines/install.py b/pkgs/clan-cli/clan_lib/machines/install.py index b447fcad1..3272e743f 100644 --- a/pkgs/clan-cli/clan_lib/machines/install.py +++ b/pkgs/clan-cli/clan_lib/machines/install.py @@ -88,7 +88,7 @@ def run_machine_install(opts: InstallOptions, target_host: Remote) -> None: # Notify the UI about what we are doing notify_install_step("generators") generate_facts([machine]) - run_generators([machine]) + run_generators([machine], generators=None, full_closure=False) with ( TemporaryDirectory(prefix="nixos-install-") as _base_directory, diff --git a/pkgs/clan-cli/clan_lib/machines/morph.py b/pkgs/clan-cli/clan_lib/machines/morph.py index 4863febc2..c093437ef 100644 --- a/pkgs/clan-cli/clan_lib/machines/morph.py +++ b/pkgs/clan-cli/clan_lib/machines/morph.py @@ -78,7 +78,7 @@ def morph_machine( 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.name, diff --git a/pkgs/clan-cli/clan_lib/machines/update.py b/pkgs/clan-cli/clan_lib/machines/update.py index 4ca7bc1d9..099ddf749 100644 --- a/pkgs/clan-cli/clan_lib/machines/update.py +++ b/pkgs/clan-cli/clan_lib/machines/update.py @@ -148,7 +148,7 @@ def run_machine_update( target_host_root = stack.enter_context(_target_host.become_root()) 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(machine, target_host_root) diff --git a/pkgs/clan-cli/clan_lib/vars/generate.py b/pkgs/clan-cli/clan_lib/vars/generate.py index 9a6e47399..91d3d482a 100644 --- a/pkgs/clan-cli/clan_lib/vars/generate.py +++ b/pkgs/clan-cli/clan_lib/vars/generate.py @@ -97,7 +97,8 @@ strategies (e.g., interactive CLI, GUI, or programmatic). @API.register def run_generators( 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(), no_sandbox: bool = False, ) -> None: @@ -105,12 +106,13 @@ def run_generators( Args: machines: The machines to run generators for. 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. Dependency generators are not added automatically in this case. The caller must ensure that all dependencies are included. - - "all": Run all generators (full closure) - - "minimal": Run only missing generators (minimal closure) (default) + full_closure: Whether to include all dependencies (True) or only missing ones (False). + Only used when generators is None or a string. prompt_values: A dictionary mapping generator names to their prompt values, or a function that returns prompt values for a generator. no_sandbox: Whether to disable sandboxing when executing the generator. @@ -119,16 +121,8 @@ def run_generators( executing the generator. """ for machine in machines: - if generators == "all": - generator_objects = get_generators(machine, full_closure=True) - 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 isinstance(generators, list): + # List of generator names - use them exactly as provided if len(generators) == 0: return # Create GeneratorKeys for this specific machine @@ -138,8 +132,10 @@ def run_generators( all_generators = get_generators(machine, full_closure=True) generator_objects = [g for g in all_generators if g.key in generator_keys] else: - msg = f"Invalid generators argument: {generators}. Must be 'all', 'minimal', a generator name, or a list of generator names" - raise ValueError(msg) + # None or single string - use get_generators with closure parameter + generator_objects = get_generators( + machine, full_closure=full_closure, generator_name=generators + ) # If prompt function provided, ask all prompts # TODO: make this more lazy and ask for every generator on execution