diff --git a/pkgs/clan-cli/clan_cli/secrets/machines.py b/pkgs/clan-cli/clan_cli/secrets/machines.py index c57d12d38..a45f7487a 100644 --- a/pkgs/clan-cli/clan_cli/secrets/machines.py +++ b/pkgs/clan-cli/clan_cli/secrets/machines.py @@ -22,19 +22,19 @@ from .sops import read_key, write_key from .types import public_or_private_age_key_type, secret_name_type -def add_machine(flake_dir: Path, name: str, key: str, force: bool) -> None: - path = sops_machines_folder(flake_dir) / name +def add_machine(flake_dir: Path, machine: str, key: str, force: bool) -> None: + path = sops_machines_folder(flake_dir) / machine write_key(path, key, force) paths = [path] def filter_machine_secrets(secret: Path) -> bool: - return secret.joinpath("machines", name).exists() + return secret.joinpath("machines", machine).exists() paths.extend(update_secrets(flake_dir, filter_secrets=filter_machine_secrets)) commit_files( paths, flake_dir, - f"Add machine {name} to secrets", + f"Add machine {machine} to secrets", ) @@ -70,9 +70,9 @@ def list_sops_machines(flake_dir: Path) -> list[str]: return list_objects(path, validate) -def add_secret(flake_dir: Path, machine: str, secret: str) -> None: +def add_secret(flake_dir: Path, machine: str, secret_path: Path) -> None: paths = secrets.allow_member( - secrets.machines_folder(sops_secrets_folder(flake_dir) / secret), + secrets.machines_folder(secret_path), sops_machines_folder(flake_dir), machine, ) @@ -128,7 +128,11 @@ def add_secret_command(args: argparse.Namespace) -> None: if args.flake is None: msg = "Could not find clan flake toplevel directory" raise ClanError(msg) - add_secret(args.flake.path, args.machine, args.secret) + add_secret( + args.flake.path, + args.machine, + sops_secrets_folder(args.flake.path) / args.secret, + ) def remove_secret_command(args: argparse.Namespace) -> None: diff --git a/pkgs/clan-cli/clan_cli/secrets/secrets.py b/pkgs/clan-cli/clan_cli/secrets/secrets.py index a403608b9..54a38529e 100644 --- a/pkgs/clan-cli/clan_cli/secrets/secrets.py +++ b/pkgs/clan-cli/clan_cli/secrets/secrets.py @@ -210,15 +210,18 @@ def allow_member( msg += list_directory(source_folder) raise ClanError(msg) group_folder.mkdir(parents=True, exist_ok=True) - user_target = group_folder / name - if user_target.exists(): - if not user_target.is_symlink(): - msg = f"Cannot add user '{name}' to {group_folder.parent.name} secret. {user_target} exists but is not a symlink" + member = group_folder / name + if member.exists(): + if not member.is_symlink(): + msg = f"Cannot add user '{name}' to {group_folder.parent.name} secret. {member} exists but is not a symlink" raise ClanError(msg) - user_target.unlink() + # return early if the symlink already points to the correct target + if member.resolve() == source: + return [] + member.unlink() - user_target.symlink_to(os.path.relpath(source, user_target.parent)) - changed = [user_target] + member.symlink_to(os.path.relpath(source, member.parent)) + changed = [member] if do_update_keys: changed.extend( update_keys( diff --git a/pkgs/clan-cli/clan_cli/vars/secret_modules/sops.py b/pkgs/clan-cli/clan_cli/vars/secret_modules/sops.py index 845de9438..bd7184705 100644 --- a/pkgs/clan-cli/clan_cli/vars/secret_modules/sops.py +++ b/pkgs/clan-cli/clan_cli/vars/secret_modules/sops.py @@ -2,7 +2,7 @@ from pathlib import Path from clan_cli.machines.machines import Machine from clan_cli.secrets.folders import sops_secrets_folder -from clan_cli.secrets.machines import add_machine, has_machine +from clan_cli.secrets.machines import add_machine, add_secret, has_machine from clan_cli.secrets.secrets import decrypt_secret, encrypt_secret, has_secret from clan_cli.secrets.sops import generate_private_key @@ -80,4 +80,9 @@ class SecretStore(SecretStoreBase): (output_dir / "key.txt").write_text(key) def exists(self, generator_name: str, name: str, shared: bool = False) -> bool: - return (self.directory(generator_name, name, shared) / "secret").exists() + secret_folder = self.secret_path(generator_name, name, shared) + if not (secret_folder / "secret").exists(): + return False + # add_secret will be a no-op if the machine is already added + add_secret(self.machine.flake_dir, self.machine.name, secret_folder) + return True diff --git a/pkgs/clan-cli/tests/test_vars_deployment.py b/pkgs/clan-cli/tests/test_vars_deployment.py index 5c844dd69..3c784d93d 100644 --- a/pkgs/clan-cli/tests/test_vars_deployment.py +++ b/pkgs/clan-cli/tests/test_vars_deployment.py @@ -17,69 +17,102 @@ def test_vm_deployment( temporary_home: Path, sops_setup: SopsSetup, ) -> None: - config = nested_dict() - config["clan"]["virtualisation"]["graphics"] = False - config["services"]["getty"]["autologinUser"] = "root" - config["services"]["openssh"]["enable"] = True - config["networking"]["firewall"]["enable"] = False - my_generator = config["clan"]["core"]["vars"]["generators"]["my_generator"] - my_generator["files"]["my_secret"]["secret"] = True - my_generator["script"] = """ + # machine 1 + machine1_config = nested_dict() + machine1_config["clan"]["virtualisation"]["graphics"] = False + machine1_config["services"]["getty"]["autologinUser"] = "root" + machine1_config["services"]["openssh"]["enable"] = True + machine1_config["networking"]["firewall"]["enable"] = False + m1_generator = machine1_config["clan"]["core"]["vars"]["generators"]["m1_generator"] + m1_generator["files"]["my_secret"]["secret"] = True + m1_generator["script"] = """ echo hello > $out/my_secret """ - my_shared_generator = config["clan"]["core"]["vars"]["generators"][ + m1_shared_generator = machine1_config["clan"]["core"]["vars"]["generators"][ "my_shared_generator" ] - my_shared_generator["share"] = True - my_shared_generator["files"]["shared_secret"]["secret"] = True - my_shared_generator["files"]["no_deploy_secret"]["secret"] = True - my_shared_generator["files"]["no_deploy_secret"]["deploy"] = False - my_shared_generator["script"] = """ + m1_shared_generator["share"] = True + m1_shared_generator["files"]["shared_secret"]["secret"] = True + m1_shared_generator["files"]["no_deploy_secret"]["secret"] = True + m1_shared_generator["files"]["no_deploy_secret"]["deploy"] = False + m1_shared_generator["script"] = """ echo hello > $out/shared_secret echo hello > $out/no_deploy_secret """ + # machine 2 + machine2_config = nested_dict() + machine2_config["clan"]["virtualisation"]["graphics"] = False + machine2_config["services"]["getty"]["autologinUser"] = "root" + machine2_config["services"]["openssh"]["enable"] = True + machine2_config["users"]["users"]["root"]["openssh"]["authorizedKeys"]["keys"] = [ + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDuhpzDHBPvn8nv8RH1MRomDOaXyP4GziQm7r3MZ1Syk grmpf" + ] + machine2_config["networking"]["firewall"]["enable"] = False + machine2_config["clan"]["core"]["vars"]["generators"]["my_shared_generator"] = ( + m1_shared_generator.copy() + ) + flake = generate_flake( temporary_home, flake_template=CLAN_CORE / "templates" / "minimal", - machine_configs={"my_machine": config}, + machine_configs={"m1_machine": machine1_config, "m2_machine": machine2_config}, monkeypatch=monkeypatch, ) monkeypatch.chdir(flake.path) sops_setup.init() - cli.run(["vars", "generate", "my_machine"]) + cli.run(["vars", "generate"]) # check sops secrets not empty - sops_secrets = json.loads( - run( - nix_eval( - [ - f"{flake.path}#nixosConfigurations.my_machine.config.sops.secrets", - ] - ) - ).stdout.strip() - ) - assert sops_secrets != {} + for machine in ["m1_machine", "m2_machine"]: + sops_secrets = json.loads( + run( + nix_eval( + [ + f"{flake.path}#nixosConfigurations.{machine}.config.sops.secrets", + ] + ) + ).stdout.strip() + ) + assert sops_secrets != {} my_secret_path = run( nix_eval( [ - f"{flake.path}#nixosConfigurations.my_machine.config.clan.core.vars.generators.my_generator.files.my_secret.path", + f"{flake.path}#nixosConfigurations.m1_machine.config.clan.core.vars.generators.m1_generator.files.my_secret.path", ] ) ).stdout.strip() assert "no-such-path" not in my_secret_path - vm = run_vm_in_thread("my_machine") - qga = qga_connect("my_machine", vm) + for machine in ["m1_machine", "m2_machine"]: + shared_secret_path = run( + nix_eval( + [ + f"{flake.path}#nixosConfigurations.{machine}.config.clan.core.vars.generators.my_shared_generator.files.shared_secret.path", + ] + ) + ).stdout.strip() + assert "no-such-path" not in shared_secret_path + vm_m1 = run_vm_in_thread("m1_machine") + vm_m2 = run_vm_in_thread("m2_machine", ssh_port=2222) + qga_m1 = qga_connect("m1_machine", vm_m1) + qga_m2 = qga_connect("m2_machine", vm_m2) # check my_secret is deployed - _, out, _ = qga.run("cat /run/secrets/vars/my_generator/my_secret", check=True) + _, out, _ = qga_m1.run("cat /run/secrets/vars/m1_generator/my_secret", check=True) assert out == "hello\n" - # check shared_secret is deployed - _, out, _ = qga.run( + # check shared_secret is deployed on m1 + _, out, _ = qga_m1.run( + "cat /run/secrets/vars/my_shared_generator/shared_secret", check=True + ) + assert out == "hello\n" + # check shared_secret is deployed on m2 + _, out, _ = qga_m2.run( "cat /run/secrets/vars/my_shared_generator/shared_secret", check=True ) assert out == "hello\n" # check no_deploy_secret is not deployed - returncode, out, _ = qga.run( + returncode, out, _ = qga_m1.run( "test -e /run/secrets/vars/my_shared_generator/no_deploy_secret", check=False ) assert returncode != 0 - qga.exec_cmd("poweroff") - wait_vm_down("my_machine", vm) + qga_m1.exec_cmd("poweroff") + qga_m2.exec_cmd("poweroff") + wait_vm_down("m1_machine", vm_m1) + wait_vm_down("m2_machine", vm_m2)