From 0aa6288edb9cb21584a8c700a164b082c4fdd04a Mon Sep 17 00:00:00 2001 From: DavHau Date: Tue, 8 Jul 2025 18:11:48 +0700 Subject: [PATCH] refactor: decouple vars stores from machine instances Stores now get machine context from generator objects instead of storing it internally. This enables future machine-independent generators and reduces coupling. - StoreBase.__init__ only takes flake parameter - Store methods receive machine as explicit parameter - Fixed all callers to pass machine context --- pkgs/clan-cli/clan_cli/tests/test_vars.py | 288 ++++++++++-------- pkgs/clan-cli/clan_cli/vars/_types.py | 27 +- pkgs/clan-cli/clan_cli/vars/check.py | 1 + pkgs/clan-cli/clan_cli/vars/fix.py | 4 +- pkgs/clan-cli/clan_cli/vars/generate.py | 8 +- .../clan_cli/vars/public_modules/in_repo.py | 12 +- .../clan_cli/vars/public_modules/vm.py | 43 +-- .../clan_cli/vars/secret_modules/fs.py | 10 +- .../vars/secret_modules/password_store.py | 76 ++--- .../clan_cli/vars/secret_modules/sops.py | 71 +++-- .../clan_cli/vars/secret_modules/vm.py | 40 ++- pkgs/clan-cli/clan_cli/vars/upload.py | 6 +- pkgs/clan-cli/clan_lib/machines/delete.py | 4 +- pkgs/clan-cli/clan_lib/machines/install.py | 4 +- pkgs/clan-cli/clan_lib/machines/machines.py | 4 +- pkgs/clan-cli/clan_lib/machines/morph.py | 4 +- 16 files changed, 350 insertions(+), 252 deletions(-) diff --git a/pkgs/clan-cli/clan_cli/tests/test_vars.py b/pkgs/clan-cli/clan_cli/tests/test_vars.py index ba569589c..1cee76df4 100644 --- a/pkgs/clan-cli/clan_cli/tests/test_vars.py +++ b/pkgs/clan-cli/clan_cli/tests/test_vars.py @@ -161,7 +161,7 @@ def test_generate_public_and_secret_vars( assert check_vars(machine.name, machine.flake) # get last commit message commit_message = run( - ["git", "log", "-3", "--pretty=%B"], + ["git", "log", "-5", "--pretty=%B"], ).stdout.strip() assert ( "Update vars via generator my_generator for machine my_machine" @@ -184,18 +184,18 @@ def test_generate_public_and_secret_vars( == "shared" ) vars_text = stringify_all_vars(machine) - in_repo_store = in_repo.FactStore( - machine="my_machine", flake=Flake(str(flake.path)) - ) - assert not in_repo_store.exists(Generator("my_generator"), "my_secret") - sops_store = sops.SecretStore(machine="my_machine", flake=Flake(str(flake.path))) - assert sops_store.exists(Generator("my_generator"), "my_secret") - assert sops_store.get(Generator("my_generator"), "my_secret").decode() == "secret" - assert sops_store.exists(Generator("dependent_generator"), "my_secret") - assert ( - sops_store.get(Generator("dependent_generator"), "my_secret").decode() - == "shared" + flake_obj = Flake(str(flake.path)) + my_generator = Generator("my_generator", machine="my_machine", _flake=flake_obj) + dependent_generator = Generator( + "dependent_generator", machine="my_machine", _flake=flake_obj ) + in_repo_store = in_repo.FactStore(flake=flake_obj) + assert not in_repo_store.exists(my_generator, "my_secret") + sops_store = sops.SecretStore(flake=flake_obj) + assert sops_store.exists(my_generator, "my_secret") + assert sops_store.get(my_generator, "my_secret").decode() == "secret" + assert sops_store.exists(dependent_generator, "my_secret") + assert sops_store.get(dependent_generator, "my_secret").decode() == "shared" assert "my_generator/my_value: public" in vars_text assert "my_generator/my_secret" in vars_text @@ -262,19 +262,20 @@ def test_generate_secret_var_sops_with_default_group( monkeypatch.chdir(flake.path) cli.run(["secrets", "groups", "add-user", "my_group", sops_setup.user]) cli.run(["vars", "generate", "--flake", str(flake.path), "my_machine"]) - in_repo_store = in_repo.FactStore( - machine="my_machine", flake=Flake(str(flake.path)) + flake_obj = Flake(str(flake.path)) + first_generator = Generator( + "first_generator", machine="my_machine", _flake=flake_obj ) - assert not in_repo_store.exists(Generator("first_generator"), "my_secret") - sops_store = sops.SecretStore(machine="my_machine", flake=Flake(str(flake.path))) - assert sops_store.exists(Generator("first_generator"), "my_secret") - assert ( - sops_store.get(Generator("first_generator"), "my_secret").decode() == "hello\n" - ) - assert sops_store.exists(Generator("second_generator"), "my_secret") - assert ( - sops_store.get(Generator("second_generator"), "my_secret").decode() == "hello\n" + second_generator = Generator( + "second_generator", machine="my_machine", _flake=flake_obj ) + in_repo_store = in_repo.FactStore(flake=flake_obj) + assert not in_repo_store.exists(first_generator, "my_secret") + sops_store = sops.SecretStore(flake=flake_obj) + assert sops_store.exists(first_generator, "my_secret") + assert sops_store.get(first_generator, "my_secret").decode() == "hello\n" + assert sops_store.exists(second_generator, "my_secret") + assert sops_store.get(second_generator, "my_secret").decode() == "hello\n" # add another user to the group and check if secret gets re-encrypted pubkey_user2 = sops_setup.keys[1] @@ -292,12 +293,14 @@ def test_generate_secret_var_sops_with_default_group( cli.run(["secrets", "groups", "add-user", "my_group", "user2"]) # check if new user can access the secret monkeypatch.setenv("USER", "user2") - assert sops_store.user_has_access( - "user2", Generator("first_generator", share=False), "my_secret" + first_generator_with_share = Generator( + "first_generator", share=False, machine="my_machine", _flake=flake_obj ) - assert sops_store.user_has_access( - "user2", Generator("second_generator", share=False), "my_secret" + second_generator_with_share = Generator( + "second_generator", share=False, machine="my_machine", _flake=flake_obj ) + assert sops_store.user_has_access("user2", first_generator_with_share, "my_secret") + assert sops_store.user_has_access("user2", second_generator_with_share, "my_secret") # Rotate key of a user pubkey_user3 = sops_setup.keys[2] @@ -314,12 +317,8 @@ def test_generate_secret_var_sops_with_default_group( ] ) monkeypatch.setenv("USER", "user2") - assert sops_store.user_has_access( - "user2", Generator("first_generator", share=False), "my_secret" - ) - assert sops_store.user_has_access( - "user2", Generator("second_generator", share=False), "my_secret" - ) + assert sops_store.user_has_access("user2", first_generator_with_share, "my_secret") + assert sops_store.user_has_access("user2", second_generator_with_share, "my_secret") @pytest.mark.with_core @@ -351,21 +350,21 @@ def test_generated_shared_secret_sops( cli.run(["vars", "generate", "--flake", str(flake.path), "machine2"]) assert check_vars(machine2.name, machine2.flake) assert check_vars(machine2.name, machine2.flake) - m1_sops_store = sops.SecretStore(machine1.name, machine1.flake) - m2_sops_store = sops.SecretStore(machine2.name, machine2.flake) - assert m1_sops_store.exists( - Generator("my_shared_generator", share=True), "my_shared_secret" + m1_sops_store = sops.SecretStore(machine1.flake) + m2_sops_store = sops.SecretStore(machine2.flake) + # Create generators with machine context for testing + generator_m1 = Generator( + "my_shared_generator", share=True, machine="machine1", _flake=machine1.flake ) - assert m2_sops_store.exists( - Generator("my_shared_generator", share=True), "my_shared_secret" - ) - assert m1_sops_store.machine_has_access( - Generator("my_shared_generator", share=True), "my_shared_secret" - ) - assert m2_sops_store.machine_has_access( - Generator("my_shared_generator", share=True), "my_shared_secret" + generator_m2 = Generator( + "my_shared_generator", share=True, machine="machine2", _flake=machine2.flake ) + assert m1_sops_store.exists(generator_m1, "my_shared_secret") + assert m2_sops_store.exists(generator_m2, "my_shared_secret") + assert m1_sops_store.machine_has_access(generator_m1, "my_shared_secret") + assert m2_sops_store.machine_has_access(generator_m2, "my_shared_secret") + @pytest.mark.with_core def test_generate_secret_var_password_store( @@ -412,43 +411,72 @@ def test_generate_secret_var_password_store( ["git", "config", "user.name", "Test User"], cwd=password_store_dir, check=True ) - machine = Machine(name="my_machine", flake=Flake(str(flake.path))) + flake_obj = Flake(str(flake.path)) + machine = Machine(name="my_machine", flake=flake_obj) assert not check_vars(machine.name, machine.flake) cli.run(["vars", "generate", "--flake", str(flake.path), "my_machine"]) assert check_vars(machine.name, machine.flake) - store = password_store.SecretStore( - machine="my_machine", flake=Flake(str(flake.path)) + store = password_store.SecretStore(flake=flake_obj) + my_generator = Generator( + "my_generator", share=False, files=[], machine="my_machine", _flake=flake_obj ) - assert store.exists(Generator("my_generator", share=False, files=[]), "my_secret") - assert not store.exists( - Generator("my_generator", share=True, files=[]), "my_secret" + my_generator_shared = Generator( + "my_generator", share=True, files=[], machine="my_machine", _flake=flake_obj ) - assert store.exists( - Generator("my_shared_generator", share=True, files=[]), "my_shared_secret" + my_shared_generator = Generator( + "my_shared_generator", + share=True, + files=[], + machine="my_machine", + _flake=flake_obj, ) - assert not store.exists( - Generator("my_shared_generator", share=False, files=[]), "my_shared_secret" + my_shared_generator_not_shared = Generator( + "my_shared_generator", + share=False, + files=[], + machine="my_machine", + _flake=flake_obj, ) + assert store.exists(my_generator, "my_secret") + assert not store.exists(my_generator_shared, "my_secret") + assert store.exists(my_shared_generator, "my_shared_secret") + assert not store.exists(my_shared_generator_not_shared, "my_shared_secret") - generator = Generator(name="my_generator", share=False, files=[]) + generator = Generator( + name="my_generator", + share=False, + files=[], + machine="my_machine", + _flake=flake_obj, + ) assert store.get(generator, "my_secret").decode() == "hello\n" vars_text = stringify_all_vars(machine) assert "my_generator/my_secret" in vars_text - my_generator = Generator("my_generator", share=False, files=[]) + my_generator = Generator( + "my_generator", share=False, files=[], machine="my_machine", _flake=flake_obj + ) var_name = "my_secret" store.delete(my_generator, var_name) assert not store.exists(my_generator, var_name) - store.delete_store() - store.delete_store() # check idempotency - my_generator2 = Generator("my_generator2", share=False, files=[]) + store.delete_store("my_machine") + store.delete_store("my_machine") # check idempotency + my_generator2 = Generator( + "my_generator2", share=False, files=[], machine="my_machine", _flake=flake_obj + ) var_name = "my_secret2" assert not store.exists(my_generator2, var_name) # The shared secret should still be there, # not sure if we can delete those automatically: - my_shared_generator = Generator("my_shared_generator", share=True, files=[]) + my_shared_generator = Generator( + "my_shared_generator", + share=True, + files=[], + machine="my_machine", + _flake=flake_obj, + ) var_name = "my_shared_secret" assert store.exists(my_shared_generator, var_name) @@ -492,29 +520,25 @@ def test_generate_secret_for_multiple_machines( monkeypatch.chdir(flake.path) cli.run(["vars", "generate", "--flake", str(flake.path)]) # check if public vars have been created correctly - in_repo_store1 = in_repo.FactStore(machine="machine1", flake=Flake(str(flake.path))) - in_repo_store2 = in_repo.FactStore(machine="machine2", flake=Flake(str(flake.path))) - assert in_repo_store1.exists(Generator("my_generator"), "my_value") - assert in_repo_store2.exists(Generator("my_generator"), "my_value") - assert ( - in_repo_store1.get(Generator("my_generator"), "my_value").decode() - == "machine1\n" - ) - assert ( - in_repo_store2.get(Generator("my_generator"), "my_value").decode() - == "machine2\n" - ) + flake_obj = Flake(str(flake.path)) + in_repo_store1 = in_repo.FactStore(flake=flake_obj) + in_repo_store2 = in_repo.FactStore(flake=flake_obj) + + # Create generators for each machine + gen1 = Generator("my_generator", machine="machine1", _flake=flake_obj) + gen2 = Generator("my_generator", machine="machine2", _flake=flake_obj) + + assert in_repo_store1.exists(gen1, "my_value") + assert in_repo_store2.exists(gen2, "my_value") + assert in_repo_store1.get(gen1, "my_value").decode() == "machine1\n" + assert in_repo_store2.get(gen2, "my_value").decode() == "machine2\n" # check if secret vars have been created correctly - sops_store1 = sops.SecretStore(machine="machine1", flake=Flake(str(flake.path))) - sops_store2 = sops.SecretStore(machine="machine2", flake=Flake(str(flake.path))) - assert sops_store1.exists(Generator("my_generator"), "my_secret") - assert sops_store2.exists(Generator("my_generator"), "my_secret") - assert ( - sops_store1.get(Generator("my_generator"), "my_secret").decode() == "machine1\n" - ) - assert ( - sops_store2.get(Generator("my_generator"), "my_secret").decode() == "machine2\n" - ) + sops_store1 = sops.SecretStore(flake=flake_obj) + sops_store2 = sops.SecretStore(flake=flake_obj) + assert sops_store1.exists(gen1, "my_secret") + assert sops_store2.exists(gen2, "my_secret") + assert sops_store1.get(gen1, "my_secret").decode() == "machine1\n" + assert sops_store2.get(gen2, "my_secret").decode() == "machine2\n" @pytest.mark.with_core @@ -550,28 +574,27 @@ def test_prompt( iter(["line input", "my\nmultiline\ninput\n", "prompt_persist"]), ) cli.run(["vars", "generate", "--flake", str(flake.path), "my_machine"]) - in_repo_store = in_repo.FactStore( - machine="my_machine", flake=Flake(str(flake.path)) - ) - assert in_repo_store.exists(Generator("my_generator"), "line_value") - assert ( - in_repo_store.get(Generator("my_generator"), "line_value").decode() - == "line input" + flake_obj = Flake(str(flake.path)) + my_generator = Generator("my_generator", machine="my_machine", _flake=flake_obj) + my_generator_with_details = Generator( + name="my_generator", + share=False, + files=[], + machine="my_machine", + _flake=flake_obj, ) + in_repo_store = in_repo.FactStore(flake=flake_obj) + assert in_repo_store.exists(my_generator, "line_value") + assert in_repo_store.get(my_generator, "line_value").decode() == "line input" - assert in_repo_store.exists(Generator("my_generator"), "multiline_value") + assert in_repo_store.exists(my_generator, "multiline_value") assert ( - in_repo_store.get(Generator("my_generator"), "multiline_value").decode() + in_repo_store.get(my_generator, "multiline_value").decode() == "my\nmultiline\ninput\n" ) - sops_store = sops.SecretStore(machine="my_machine", flake=Flake(str(flake.path))) - assert sops_store.exists( - Generator(name="my_generator", share=False, files=[]), "prompt_persist" - ) - assert ( - sops_store.get(Generator(name="my_generator"), "prompt_persist").decode() - == "prompt_persist" - ) + sops_store = sops.SecretStore(flake=flake_obj) + assert sops_store.exists(my_generator_with_details, "prompt_persist") + assert sops_store.get(my_generator, "prompt_persist").decode() == "prompt_persist" @pytest.mark.with_core @@ -606,21 +629,27 @@ def test_multi_machine_shared_vars( monkeypatch.chdir(flake.path) machine1 = Machine(name="machine1", flake=Flake(str(flake.path))) machine2 = Machine(name="machine2", flake=Flake(str(flake.path))) - sops_store_1 = sops.SecretStore(machine1.name, machine1.flake) - sops_store_2 = sops.SecretStore(machine2.name, machine2.flake) - in_repo_store_1 = in_repo.FactStore(machine1.name, machine1.flake) - in_repo_store_2 = in_repo.FactStore(machine2.name, machine2.flake) - generator = Generator("shared_generator", share=True) + sops_store_1 = sops.SecretStore(machine1.flake) + sops_store_2 = sops.SecretStore(machine2.flake) + in_repo_store_1 = in_repo.FactStore(machine1.flake) + in_repo_store_2 = in_repo.FactStore(machine2.flake) + # Create generators with machine context for testing + generator_m1 = Generator( + "shared_generator", share=True, machine="machine1", _flake=machine1.flake + ) + generator_m2 = Generator( + "shared_generator", share=True, machine="machine2", _flake=machine2.flake + ) # generate for machine 1 cli.run(["vars", "generate", "--flake", str(flake.path), "machine1"]) # read out values for machine 1 - m1_secret = sops_store_1.get(generator, "my_secret") - m1_value = in_repo_store_1.get(generator, "my_value") + m1_secret = sops_store_1.get(generator_m1, "my_secret") + m1_value = in_repo_store_1.get(generator_m1, "my_value") # generate for machine 2 cli.run(["vars", "generate", "--flake", str(flake.path), "machine2"]) # ensure values are the same for both machines - assert sops_store_2.get(generator, "my_secret") == m1_secret - assert in_repo_store_2.get(generator, "my_value") == m1_value + assert sops_store_2.get(generator_m2, "my_secret") == m1_secret + assert in_repo_store_2.get(generator_m2, "my_value") == m1_value # ensure shared secret stays available for all machines after regeneration # regenerate for machine 1 @@ -628,15 +657,15 @@ def test_multi_machine_shared_vars( ["vars", "generate", "--flake", str(flake.path), "machine1", "--regenerate"] ) # ensure values changed - new_secret_1 = sops_store_1.get(generator, "my_secret") - new_value_1 = in_repo_store_1.get(generator, "my_value") - new_secret_2 = sops_store_2.get(generator, "my_secret") + new_secret_1 = sops_store_1.get(generator_m1, "my_secret") + new_value_1 = in_repo_store_1.get(generator_m1, "my_value") + new_secret_2 = sops_store_2.get(generator_m2, "my_secret") assert new_secret_1 != m1_secret assert new_value_1 != m1_value # ensure that both machines still have access to the same secret assert new_secret_1 == new_secret_2 - assert sops_store_1.machine_has_access(generator, "my_secret") - assert sops_store_2.machine_has_access(generator, "my_secret") + assert sops_store_1.machine_has_access(generator_m1, "my_secret") + assert sops_store_2.machine_has_access(generator_m2, "my_secret") @pytest.mark.with_core @@ -665,9 +694,10 @@ def test_api_set_prompts( }, ) machine = Machine(name="my_machine", flake=Flake(str(flake.path))) - store = in_repo.FactStore(machine.name, machine.flake) - assert store.exists(Generator("my_generator"), "prompt1") - assert store.get(Generator("my_generator"), "prompt1").decode() == "input1" + store = in_repo.FactStore(machine.flake) + my_generator = Generator("my_generator", machine="my_machine", _flake=machine.flake) + assert store.exists(my_generator, "prompt1") + assert store.get(my_generator, "prompt1").decode() == "input1" run_generators( machine_name="my_machine", base_dir=flake.path, @@ -678,7 +708,7 @@ def test_api_set_prompts( } }, ) - assert store.get(Generator("my_generator"), "prompt1").decode() == "input2" + assert store.get(my_generator, "prompt1").decode() == "input2" generators = get_generators( machine_name="my_machine", @@ -815,19 +845,21 @@ def test_migration( assert "Migrated var my_generator/my_value" in caplog.text assert "Migrated secret var my_generator/my_secret" in caplog.text - in_repo_store = in_repo.FactStore( - machine="my_machine", flake=Flake(str(flake.path)) + flake_obj = Flake(str(flake.path)) + my_generator = Generator("my_generator", machine="my_machine", _flake=flake_obj) + other_generator = Generator( + "other_generator", machine="my_machine", _flake=flake_obj ) - sops_store = sops.SecretStore(machine="my_machine", flake=Flake(str(flake.path))) - assert in_repo_store.exists(Generator("my_generator"), "my_value") - assert in_repo_store.get(Generator("my_generator"), "my_value").decode() == "hello" - assert sops_store.exists(Generator("my_generator"), "my_secret") - assert sops_store.get(Generator("my_generator"), "my_secret").decode() == "hello" + in_repo_store = in_repo.FactStore(flake=flake_obj) + sops_store = sops.SecretStore(flake=flake_obj) + assert in_repo_store.exists(my_generator, "my_value") + assert in_repo_store.get(my_generator, "my_value").decode() == "hello" + assert sops_store.exists(my_generator, "my_secret") + assert sops_store.get(my_generator, "my_secret").decode() == "hello" - assert in_repo_store.exists(Generator("other_generator"), "other_value") + assert in_repo_store.exists(other_generator, "other_value") assert ( - in_repo_store.get(Generator("other_generator"), "other_value").decode() - == "value-from-vars" + in_repo_store.get(other_generator, "other_value").decode() == "value-from-vars" ) diff --git a/pkgs/clan-cli/clan_cli/vars/_types.py b/pkgs/clan-cli/clan_cli/vars/_types.py index f209317ba..5d835c82b 100644 --- a/pkgs/clan-cli/clan_cli/vars/_types.py +++ b/pkgs/clan-cli/clan_cli/vars/_types.py @@ -29,8 +29,7 @@ class GeneratorUpdate: class StoreBase(ABC): - def __init__(self, machine: str, flake: Flake) -> None: - self.machine = machine + def __init__(self, flake: Flake) -> None: self.flake = flake @property @@ -38,6 +37,19 @@ class StoreBase(ABC): def store_name(self) -> str: pass + def get_machine(self, generator: "Generator") -> str: + """Get machine name from generator, asserting it's not None for now.""" + if generator.machine is None: + if generator.share: + # Shared generators don't need a machine for most operations + # but some operations (like SOPS key management) might still need one + # This is a temporary workaround - we should handle this better + msg = f"Shared generator '{generator.name}' requires a machine context for this operation" + raise ClanError(msg) + msg = f"Generator '{generator.name}' has no machine associated" + raise ClanError(msg) + return generator.machine + # get a single fact @abstractmethod def get(self, generator: "Generator", name: str) -> bytes: @@ -65,6 +77,7 @@ class StoreBase(ABC): def health_check( self, + machine: str, generator: "Generator | None" = None, file_name: str | None = None, ) -> str | None: @@ -72,6 +85,7 @@ class StoreBase(ABC): def fix( self, + machine: str, generator: "Generator | None" = None, file_name: str | None = None, ) -> None: @@ -87,7 +101,8 @@ class StoreBase(ABC): def rel_dir(self, generator: "Generator", var_name: str) -> Path: if generator.share: return Path("shared") / generator.name / var_name - return Path("per-machine") / self.machine / generator.name / var_name + machine = self.get_machine(generator) + return Path("per-machine") / machine / generator.name / var_name def directory(self, generator: "Generator", var_name: str) -> Path: return self.flake.path / "vars" / self.rel_dir(generator, var_name) @@ -134,7 +149,7 @@ class StoreBase(ABC): """ @abstractmethod - def delete_store(self) -> Iterable[Path]: + def delete_store(self, machine: str) -> Iterable[Path]: """Delete the store (all vars) for this machine. .. note:: @@ -181,9 +196,9 @@ class StoreBase(ABC): return stored_hash == target_hash @abstractmethod - def populate_dir(self, output_dir: Path, phases: list[str]) -> None: + def populate_dir(self, machine: str, output_dir: Path, phases: list[str]) -> None: pass @abstractmethod - def upload(self, host: Remote, phases: list[str]) -> None: + def upload(self, machine: str, host: Remote, phases: list[str]) -> None: pass diff --git a/pkgs/clan-cli/clan_cli/vars/check.py b/pkgs/clan-cli/clan_cli/vars/check.py index 9da277db9..f9a1bd072 100644 --- a/pkgs/clan-cli/clan_cli/vars/check.py +++ b/pkgs/clan-cli/clan_cli/vars/check.py @@ -65,6 +65,7 @@ def vars_status( missing_secret_vars.append(file) else: msg = machine.secret_vars_store.health_check( + machine=machine.name, generator=generator, file_name=file.name, ) diff --git a/pkgs/clan-cli/clan_cli/vars/fix.py b/pkgs/clan-cli/clan_cli/vars/fix.py index 6ad0e6ab2..2e6f10454 100644 --- a/pkgs/clan-cli/clan_cli/vars/fix.py +++ b/pkgs/clan-cli/clan_cli/vars/fix.py @@ -24,8 +24,8 @@ def fix_vars(machine: Machine, generator_name: None | str = None) -> None: raise ClanError(err_msg) for generator in generators: - machine.public_vars_store.fix(generator=generator) - machine.secret_vars_store.fix(generator=generator) + machine.public_vars_store.fix(machine.name, generator=generator) + machine.secret_vars_store.fix(machine.name, generator=generator) def fix_command(args: argparse.Namespace) -> None: diff --git a/pkgs/clan-cli/clan_cli/vars/generate.py b/pkgs/clan-cli/clan_cli/vars/generate.py index 4b9e701c1..7572a30bf 100644 --- a/pkgs/clan-cli/clan_cli/vars/generate.py +++ b/pkgs/clan-cli/clan_cli/vars/generate.py @@ -533,8 +533,12 @@ def create_machine_vars_interactive( _generator = generator break - pub_healtcheck_msg = machine.public_vars_store.health_check(_generator) - sec_healtcheck_msg = machine.secret_vars_store.health_check(_generator) + pub_healtcheck_msg = machine.public_vars_store.health_check( + machine.name, _generator + ) + sec_healtcheck_msg = machine.secret_vars_store.health_check( + machine.name, _generator + ) if pub_healtcheck_msg or sec_healtcheck_msg: msg = f"Health check failed for machine {machine.name}:\n" diff --git a/pkgs/clan-cli/clan_cli/vars/public_modules/in_repo.py b/pkgs/clan-cli/clan_cli/vars/public_modules/in_repo.py index c3081eda9..9b32a5f50 100644 --- a/pkgs/clan-cli/clan_cli/vars/public_modules/in_repo.py +++ b/pkgs/clan-cli/clan_cli/vars/public_modules/in_repo.py @@ -14,8 +14,8 @@ class FactStore(StoreBase): def is_secret_store(self) -> bool: return False - def __init__(self, machine: str, flake: Flake) -> None: - super().__init__(machine, flake) + def __init__(self, flake: Flake) -> None: + super().__init__(flake) self.works_remotely = False @property @@ -61,18 +61,18 @@ class FactStore(StoreBase): fact_folder.rmdir() return [fact_folder] - def delete_store(self) -> Iterable[Path]: + def delete_store(self, machine: str) -> Iterable[Path]: flake_root = self.flake.path - store_folder = flake_root / "vars/per-machine" / self.machine + store_folder = flake_root / "vars/per-machine" / machine if not store_folder.exists(): return [] shutil.rmtree(store_folder) return [store_folder] - def populate_dir(self, output_dir: Path, phases: list[str]) -> None: + def populate_dir(self, machine: str, output_dir: Path, phases: list[str]) -> None: msg = "populate_dir is not implemented for public vars stores" raise NotImplementedError(msg) - def upload(self, host: Remote, phases: list[str]) -> None: + def upload(self, machine: str, host: Remote, phases: list[str]) -> None: msg = "upload is not implemented for public vars stores" raise NotImplementedError(msg) diff --git a/pkgs/clan-cli/clan_cli/vars/public_modules/vm.py b/pkgs/clan-cli/clan_cli/vars/public_modules/vm.py index fd983f262..b76840c68 100644 --- a/pkgs/clan-cli/clan_cli/vars/public_modules/vm.py +++ b/pkgs/clan-cli/clan_cli/vars/public_modules/vm.py @@ -18,21 +18,26 @@ class FactStore(StoreBase): def is_secret_store(self) -> bool: return False - def __init__(self, machine: str, flake: Flake) -> None: - super().__init__(machine, flake) + def __init__(self, flake: Flake) -> None: + super().__init__(flake) self.works_remotely = False - self.dir = vm_state_dir(flake.identifier, machine) / "facts" - log.debug( - f"FactStore initialized with dir {self.dir}", - extra={"command_prefix": machine}, - ) @property def store_name(self) -> str: return "vm" + def get_dir(self, machine: str) -> Path: + """Get the directory for a given machine.""" + vars_dir = vm_state_dir(self.flake.identifier, machine) / "facts" + log.debug( + f"FactStore using dir {vars_dir}", + extra={"command_prefix": machine}, + ) + return vars_dir + def exists(self, generator: Generator, name: str) -> bool: - fact_path = self.dir / generator.name / name + machine = self.get_machine(generator) + fact_path = self.get_dir(machine) / generator.name / name return fact_path.exists() def _set( @@ -41,21 +46,24 @@ class FactStore(StoreBase): var: Var, value: bytes, ) -> Path | None: - fact_path = self.dir / generator.name / var.name + machine = self.get_machine(generator) + fact_path = self.get_dir(machine) / generator.name / var.name fact_path.parent.mkdir(parents=True, exist_ok=True) fact_path.write_bytes(value) return None # get a single fact def get(self, generator: Generator, name: str) -> bytes: - fact_path = self.dir / generator.name / name + machine = self.get_machine(generator) + fact_path = self.get_dir(machine) / generator.name / name if fact_path.exists(): return fact_path.read_bytes() msg = f"Fact {name} for service {generator.name} not found" raise ClanError(msg) def delete(self, generator: Generator, name: str) -> Iterable[Path]: - fact_dir = self.dir / generator.name + machine = self.get_machine(generator) + fact_dir = self.get_dir(machine) / generator.name fact_file = fact_dir / name fact_file.unlink() empty = None @@ -63,16 +71,17 @@ class FactStore(StoreBase): fact_dir.rmdir() return [fact_file] - def delete_store(self) -> Iterable[Path]: - if not self.dir.exists(): + def delete_store(self, machine: str) -> Iterable[Path]: + vars_dir = self.get_dir(machine) + if not vars_dir.exists(): return [] - shutil.rmtree(self.dir) - return [self.dir] + shutil.rmtree(vars_dir) + return [vars_dir] - def populate_dir(self, output_dir: Path, phases: list[str]) -> None: + def populate_dir(self, machine: str, output_dir: Path, phases: list[str]) -> None: msg = "populate_dir is not implemented for public vars stores" raise NotImplementedError(msg) - def upload(self, host: Remote, phases: list[str]) -> None: + def upload(self, machine: str, host: Remote, phases: list[str]) -> None: msg = "upload is not implemented for public vars stores" raise NotImplementedError(msg) diff --git a/pkgs/clan-cli/clan_cli/vars/secret_modules/fs.py b/pkgs/clan-cli/clan_cli/vars/secret_modules/fs.py index 66c55341c..2251d7bf1 100644 --- a/pkgs/clan-cli/clan_cli/vars/secret_modules/fs.py +++ b/pkgs/clan-cli/clan_cli/vars/secret_modules/fs.py @@ -13,8 +13,8 @@ class SecretStore(StoreBase): def is_secret_store(self) -> bool: return True - def __init__(self, machine: str, flake: Flake) -> None: - super().__init__(machine, flake) + def __init__(self, flake: Flake) -> None: + super().__init__(flake) self.dir = Path(tempfile.gettempdir()) / "clan_secrets" self.dir.mkdir(parents=True, exist_ok=True) @@ -40,7 +40,7 @@ class SecretStore(StoreBase): secret_file = self.dir / generator.name / name return secret_file.read_bytes() - def populate_dir(self, output_dir: Path, phases: list[str]) -> None: + def populate_dir(self, machine: str, output_dir: Path, phases: list[str]) -> None: if output_dir.exists(): shutil.rmtree(output_dir) shutil.copytree(self.dir, output_dir) @@ -52,11 +52,11 @@ class SecretStore(StoreBase): secret_file.unlink() return [] - def delete_store(self) -> list[Path]: + def delete_store(self, machine: str) -> list[Path]: if self.dir.exists(): shutil.rmtree(self.dir) return [] - def upload(self, host: Remote, phases: list[str]) -> None: + def upload(self, machine: str, host: Remote, phases: list[str]) -> None: msg = "Cannot upload secrets with FS backend" raise NotImplementedError(msg) diff --git a/pkgs/clan-cli/clan_cli/vars/secret_modules/password_store.py b/pkgs/clan-cli/clan_cli/vars/secret_modules/password_store.py index 68e163ea6..851dab41c 100644 --- a/pkgs/clan-cli/clan_cli/vars/secret_modules/password_store.py +++ b/pkgs/clan-cli/clan_cli/vars/secret_modules/password_store.py @@ -20,8 +20,8 @@ class SecretStore(StoreBase): def is_secret_store(self) -> bool: return True - def __init__(self, machine: str, flake: Flake) -> None: - super().__init__(machine, flake) + def __init__(self, flake: Flake) -> None: + super().__init__(flake) self.entry_prefix = "clan-vars" self._store_dir: Path | None = None @@ -29,25 +29,25 @@ class SecretStore(StoreBase): def store_name(self) -> str: return "password_store" - @property - def store_dir(self) -> Path: - """Get the password store directory, cached after first access.""" - if self._store_dir is None: - result = self._run_pass("git", "rev-parse", "--show-toplevel", check=False) + def store_dir(self, machine: str) -> Path: + """Get the password store directory, cached per machine.""" + if not self._store_dir: + result = self._run_pass( + machine, "git", "rev-parse", "--show-toplevel", check=False + ) if result.returncode != 0: msg = "Password store must be a git repository" raise ValueError(msg) self._store_dir = Path(result.stdout.strip().decode()) return self._store_dir - @property - def _pass_command(self) -> str: + def _pass_command(self, machine: str) -> str: out_path = self.flake.select_machine( - self.machine, "config.clan.core.vars.password-store.passPackage.outPath" + machine, "config.clan.core.vars.password-store.passPackage.outPath" ) main_program = ( self.flake.select_machine( - self.machine, + machine, "config.clan.core.vars.password-store.passPackage.?meta.?mainProgram", ) .get("meta", {}) @@ -80,11 +80,12 @@ class SecretStore(StoreBase): def _run_pass( self, + machine: str, *args: str, input: bytes | None = None, # noqa: A002 check: bool = True, ) -> subprocess.CompletedProcess[bytes]: - cmd = [self._pass_command, *args] + cmd = [self._pass_command(machine), *args] # We need bytes support here, so we can not use clan cmd. # If you change this to run( add bytes support to it first! # otherwise we mangle binary secrets (which is annoying to debug) @@ -101,37 +102,44 @@ class SecretStore(StoreBase): var: Var, value: bytes, ) -> Path | None: + machine = self.get_machine(generator) pass_call = ["insert", "-m", str(self.entry_dir(generator, var.name))] - self._run_pass(*pass_call, input=value, check=True) + self._run_pass(machine, *pass_call, input=value, check=True) return None # we manage the files outside of the git repo def get(self, generator: Generator, name: str) -> bytes: + machine = self.get_machine(generator) pass_name = str(self.entry_dir(generator, name)) - return self._run_pass("show", pass_name).stdout + return self._run_pass(machine, "show", pass_name).stdout def exists(self, generator: Generator, name: str) -> bool: + machine = self.get_machine(generator) pass_name = str(self.entry_dir(generator, name)) # Check if the file exists with either .age or .gpg extension - age_file = self.store_dir / f"{pass_name}.age" - gpg_file = self.store_dir / f"{pass_name}.gpg" + store_dir = self.store_dir(machine) + age_file = store_dir / f"{pass_name}.age" + gpg_file = store_dir / f"{pass_name}.gpg" return age_file.exists() or gpg_file.exists() def delete(self, generator: Generator, name: str) -> Iterable[Path]: + machine = self.get_machine(generator) pass_name = str(self.entry_dir(generator, name)) - self._run_pass("rm", "--force", pass_name, check=True) + self._run_pass(machine, "rm", "--force", pass_name, check=True) return [] - def delete_store(self) -> Iterable[Path]: - machine_dir = Path(self.entry_prefix) / "per-machine" / self.machine + def delete_store(self, machine: str) -> Iterable[Path]: + machine_dir = Path(self.entry_prefix) / "per-machine" / machine # Check if the directory exists in the password store before trying to delete - result = self._run_pass("ls", str(machine_dir), check=False) + result = self._run_pass(machine, "ls", str(machine_dir), check=False) if result.returncode == 0: - self._run_pass("rm", "--force", "--recursive", str(machine_dir), check=True) + self._run_pass( + machine, "rm", "--force", "--recursive", str(machine_dir), check=True + ) return [] - def generate_hash(self) -> bytes: + def generate_hash(self, machine: str) -> bytes: result = self._run_pass( - "git", "log", "-1", "--format=%H", self.entry_prefix, check=False + machine, "git", "log", "-1", "--format=%H", self.entry_prefix, check=False ) git_hash = result.stdout.strip() @@ -141,7 +149,7 @@ class SecretStore(StoreBase): from clan_cli.vars.generate import Generator manifest = [] - generators = Generator.generators_from_flake(self.machine, self.flake) + generators = Generator.generators_from_flake(machine, self.flake) for generator in generators: for file in generator.files: manifest.append(f"{generator.name}/{file.name}".encode()) @@ -149,8 +157,8 @@ class SecretStore(StoreBase): manifest.append(git_hash) return b"\n".join(manifest) - def needs_upload(self, host: Remote) -> bool: - local_hash = self.generate_hash() + def needs_upload(self, machine: str, host: Remote) -> bool: + local_hash = self.generate_hash(machine) if not local_hash: return True @@ -159,7 +167,7 @@ class SecretStore(StoreBase): remote_hash = host.run( [ "cat", - f"{self.flake.select_machine(self.machine, 'config.clan.core.vars.password-store.secretLocation')}/.pass_info", + f"{self.flake.select_machine(machine, 'config.clan.core.vars.password-store.secretLocation')}/.pass_info", ], RunOpts(log=Log.STDERR, check=False), ).stdout.strip() @@ -169,10 +177,10 @@ class SecretStore(StoreBase): return local_hash != remote_hash.encode() - def populate_dir(self, output_dir: Path, phases: list[str]) -> None: + def populate_dir(self, machine: str, output_dir: Path, phases: list[str]) -> None: from clan_cli.vars.generate import Generator - vars_generators = Generator.generators_from_flake(self.machine, self.flake) + vars_generators = Generator.generators_from_flake(machine, self.flake) if "users" in phases: with tarfile.open( output_dir / "secrets_for_users.tar.gz", "w:gz" @@ -231,23 +239,23 @@ class SecretStore(StoreBase): out_file.parent.mkdir(parents=True, exist_ok=True) out_file.write_bytes(self.get(generator, file.name)) - hash_data = self.generate_hash() + hash_data = self.generate_hash(machine) if hash_data: (output_dir / ".pass_info").write_bytes(hash_data) - def upload(self, host: Remote, phases: list[str]) -> None: + def upload(self, machine: str, host: Remote, phases: list[str]) -> None: if "partitioning" in phases: msg = "Cannot upload partitioning secrets" raise NotImplementedError(msg) - if not self.needs_upload(host): + if not self.needs_upload(machine, host): log.info("Secrets already uploaded") return with TemporaryDirectory(prefix="vars-upload-") as _tempdir: pass_dir = Path(_tempdir).resolve() - self.populate_dir(pass_dir, phases) + self.populate_dir(machine, pass_dir, phases) upload_dir = Path( self.flake.select_machine( - self.machine, "config.clan.core.vars.password-store.secretLocation" + machine, "config.clan.core.vars.password-store.secretLocation" ) ) upload(host, pass_dir, upload_dir) 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 a0068d294..9188dedbf 100644 --- a/pkgs/clan-cli/clan_cli/vars/secret_modules/sops.py +++ b/pkgs/clan-cli/clan_cli/vars/secret_modules/sops.py @@ -48,13 +48,15 @@ class SecretStore(StoreBase): def is_secret_store(self) -> bool: return True - def __init__(self, machine: str, flake: Flake) -> None: - super().__init__(machine, flake) + def __init__(self, flake: Flake) -> None: + super().__init__(flake) + def ensure_machine_key(self, machine: str) -> None: + """Ensure machine has sops keys initialized.""" # no need to generate keys if we don't manage secrets from clan_cli.vars.generate import Generator - vars_generators = Generator.generators_from_flake(self.machine, self.flake) + vars_generators = Generator.generators_from_flake(machine, self.flake) if not vars_generators: return has_secrets = False @@ -65,19 +67,19 @@ class SecretStore(StoreBase): if not has_secrets: return - if has_machine(self.flake.path, self.machine): + if has_machine(self.flake.path, machine): return priv_key, pub_key = sops.generate_private_key() encrypt_secret( self.flake.path, - sops_secrets_folder(self.flake.path) / f"{self.machine}-age.key", + sops_secrets_folder(self.flake.path) / f"{machine}-age.key", priv_key, add_groups=self.flake.select_machine( - self.machine, "config.clan.core.sops.defaultGroups" + machine, "config.clan.core.sops.defaultGroups" ), age_plugins=load_age_plugins(self.flake), ) - add_machine(self.flake.path, self.machine, pub_key, False) + add_machine(self.flake.path, machine, pub_key, False) @property def store_name(self) -> str: @@ -90,7 +92,9 @@ class SecretStore(StoreBase): return self.key_has_access(key_dir, generator, secret_name) def machine_has_access(self, generator: Generator, secret_name: str) -> bool: - key_dir = sops_machines_folder(self.flake.path) / self.machine + machine = self.get_machine(generator) + self.ensure_machine_key(machine) + key_dir = sops_machines_folder(self.flake.path) / machine return self.key_has_access(key_dir, generator, secret_name) def key_has_access( @@ -106,7 +110,10 @@ class SecretStore(StoreBase): @override def health_check( - self, generator: Generator | None = None, file_name: str | None = None + self, + machine: str, + generator: Generator | None = None, + file_name: str | None = None, ) -> str | None: """ Apply local updates to secrets like re-encrypting with missing keys @@ -116,7 +123,7 @@ class SecretStore(StoreBase): if generator is None: from clan_cli.vars.generate import Generator - generators = Generator.generators_from_flake(self.machine, self.flake) + generators = Generator.generators_from_flake(machine, self.flake) else: generators = [generator] file_found = False @@ -141,7 +148,7 @@ class SecretStore(StoreBase): if outdated: msg = ( "The local state of some secret vars is inconsistent and needs to be updated.\n" - f"Run 'clan vars fix {self.machine}' to apply the necessary changes." + f"Run 'clan vars fix {machine}' to apply the necessary changes." "Problems to fix:\n" "\n".join(o[2] for o in outdated if o[2]) ) @@ -154,6 +161,8 @@ class SecretStore(StoreBase): var: Var, value: bytes, ) -> Path | None: + machine = self.get_machine(generator) + self.ensure_machine_key(machine) secret_folder = self.secret_path(generator, var.name) # create directory if it doesn't exist secret_folder.mkdir(parents=True, exist_ok=True) @@ -162,9 +171,9 @@ class SecretStore(StoreBase): self.flake.path, secret_folder, value, - add_machines=[self.machine] if var.deploy else [], + add_machines=[machine] if var.deploy else [], add_groups=self.flake.select_machine( - self.machine, "config.clan.core.sops.defaultGroups" + machine, "config.clan.core.sops.defaultGroups" ), git_commit=False, age_plugins=load_age_plugins(self.flake), @@ -182,20 +191,20 @@ class SecretStore(StoreBase): shutil.rmtree(secret_dir) return [secret_dir] - def delete_store(self) -> Iterable[Path]: + def delete_store(self, machine: str) -> Iterable[Path]: flake_root = self.flake.path - store_folder = flake_root / "vars/per-machine" / self.machine + store_folder = flake_root / "vars/per-machine" / machine if not store_folder.exists(): return [] shutil.rmtree(store_folder) return [store_folder] - def populate_dir(self, output_dir: Path, phases: list[str]) -> None: + def populate_dir(self, machine: str, output_dir: Path, phases: list[str]) -> None: from clan_cli.vars.generate import Generator - vars_generators = Generator.generators_from_flake(self.machine, self.flake) + vars_generators = Generator.generators_from_flake(machine, self.flake) if "users" in phases or "services" in phases: - key_name = f"{self.machine}-age.key" + key_name = f"{machine}-age.key" if not has_secret(sops_secrets_folder(self.flake.path) / key_name): # skip uploading the secret, not managed by us return @@ -237,13 +246,13 @@ class SecretStore(StoreBase): target_path.chmod(file.mode) @override - def upload(self, host: Remote, phases: list[str]) -> None: + def upload(self, machine: str, host: Remote, phases: list[str]) -> None: if "partitioning" in phases: msg = "Cannot upload partitioning secrets" raise NotImplementedError(msg) with TemporaryDirectory(prefix="sops-upload-") as _tempdir: sops_upload_dir = Path(_tempdir).resolve() - self.populate_dir(sops_upload_dir, phases) + self.populate_dir(machine, sops_upload_dir, phases) upload(host, sops_upload_dir, Path("/var/lib/sops-nix")) def exists(self, generator: Generator, name: str) -> bool: @@ -251,17 +260,18 @@ class SecretStore(StoreBase): return (secret_folder / "secret").exists() def ensure_machine_has_access(self, generator: Generator, name: str) -> None: + machine = self.get_machine(generator) if self.machine_has_access(generator, name): return secret_folder = self.secret_path(generator, name) add_secret( self.flake.path, - self.machine, + machine, secret_folder, age_plugins=load_age_plugins(self.flake), ) - def collect_keys_for_secret(self, path: Path) -> set[sops.SopsKey]: + def collect_keys_for_secret(self, machine: str, path: Path) -> set[sops.SopsKey]: from clan_cli.secrets.secrets import ( collect_keys_for_path, collect_keys_for_type, @@ -269,7 +279,7 @@ class SecretStore(StoreBase): keys = collect_keys_for_path(path) for group in self.flake.select_machine( - self.machine, "config.clan.core.sops.defaultGroups" + machine, "config.clan.core.sops.defaultGroups" ): keys.update( collect_keys_for_type( @@ -285,9 +295,10 @@ class SecretStore(StoreBase): return keys def needs_fix(self, generator: Generator, name: str) -> tuple[bool, str | None]: + machine = self.get_machine(generator) secret_path = self.secret_path(generator, name) current_recipients = sops.get_recipients(secret_path) - wanted_recipients = self.collect_keys_for_secret(secret_path) + wanted_recipients = self.collect_keys_for_secret(machine, secret_path) needs_update = current_recipients != wanted_recipients recipients_to_add = wanted_recipients - current_recipients var_id = f"{generator.name}/{name}" @@ -295,20 +306,23 @@ class SecretStore(StoreBase): f"One or more recipient keys were added to secret{' shared' if generator.share else ''} var '{var_id}', but it was never re-encrypted.\n" f"This could have been a malicious actor trying to add their keys, please investigate.\n" f"Added keys: {', '.join(f'{r.key_type.name}:{r.pubkey}' for r in recipients_to_add)}\n" - f"If this is intended, run 'clan vars fix {self.machine}' to re-encrypt the secret." + f"If this is intended, run 'clan vars fix {machine}' to re-encrypt the secret." ) return needs_update, msg @override def fix( - self, generator: Generator | None = None, file_name: str | None = None + self, + machine: str, + generator: Generator | None = None, + file_name: str | None = None, ) -> None: from clan_cli.secrets.secrets import update_keys if generator is None: from clan_cli.vars.generate import Generator - generators = Generator.generators_from_flake(self.machine, self.flake) + generators = Generator.generators_from_flake(machine, self.flake) else: generators = [generator] file_found = False @@ -327,8 +341,9 @@ class SecretStore(StoreBase): age_plugins = load_age_plugins(self.flake) + gen_machine = self.get_machine(generator) for group in self.flake.select_machine( - self.machine, "config.clan.core.sops.defaultGroups" + gen_machine, "config.clan.core.sops.defaultGroups" ): allow_member( groups_folder(secret_path), diff --git a/pkgs/clan-cli/clan_cli/vars/secret_modules/vm.py b/pkgs/clan-cli/clan_cli/vars/secret_modules/vm.py index 08cbef14c..995c8903b 100644 --- a/pkgs/clan-cli/clan_cli/vars/secret_modules/vm.py +++ b/pkgs/clan-cli/clan_cli/vars/secret_modules/vm.py @@ -14,35 +14,43 @@ class SecretStore(StoreBase): def is_secret_store(self) -> bool: return True - def __init__(self, machine: str, flake: Flake) -> None: - super().__init__(machine, flake) - self.dir = vm_state_dir(flake.identifier, machine) / "secrets" - self.dir.mkdir(parents=True, exist_ok=True) + def __init__(self, flake: Flake) -> None: + super().__init__(flake) @property def store_name(self) -> str: return "vm" + def get_dir(self, machine: str) -> Path: + """Get the directory for a given machine, creating it if needed.""" + vars_dir = vm_state_dir(self.flake.identifier, machine) / "secrets" + vars_dir.mkdir(parents=True, exist_ok=True) + return vars_dir + def _set( self, generator: Generator, var: Var, value: bytes, ) -> Path | None: - secret_file = self.dir / generator.name / var.name + machine = self.get_machine(generator) + secret_file = self.get_dir(machine) / generator.name / var.name secret_file.parent.mkdir(parents=True, exist_ok=True) secret_file.write_bytes(value) return None # we manage the files outside of the git repo def exists(self, generator: "Generator", name: str) -> bool: - return (self.dir / generator.name / name).exists() + machine = self.get_machine(generator) + return (self.get_dir(machine) / generator.name / name).exists() def get(self, generator: Generator, name: str) -> bytes: - secret_file = self.dir / generator.name / name + machine = self.get_machine(generator) + secret_file = self.get_dir(machine) / generator.name / name return secret_file.read_bytes() def delete(self, generator: Generator, name: str) -> Iterable[Path]: - secret_dir = self.dir / generator.name + machine = self.get_machine(generator) + secret_dir = self.get_dir(machine) / generator.name secret_file = secret_dir / name secret_file.unlink() empty = None @@ -50,17 +58,19 @@ class SecretStore(StoreBase): secret_dir.rmdir() return [secret_file] - def delete_store(self) -> Iterable[Path]: - if not self.dir.exists(): + def delete_store(self, machine: str) -> Iterable[Path]: + vars_dir = self.get_dir(machine) + if not vars_dir.exists(): return [] - shutil.rmtree(self.dir) - return [self.dir] + shutil.rmtree(vars_dir) + return [vars_dir] - def populate_dir(self, output_dir: Path, phases: list[str]) -> None: + def populate_dir(self, machine: str, output_dir: Path, phases: list[str]) -> None: + vars_dir = self.get_dir(machine) if output_dir.exists(): shutil.rmtree(output_dir) - shutil.copytree(self.dir, output_dir) + shutil.copytree(vars_dir, output_dir) - def upload(self, host: Remote, phases: list[str]) -> None: + def upload(self, machine: str, host: Remote, phases: list[str]) -> None: msg = "Cannot upload secrets to VMs" raise NotImplementedError(msg) diff --git a/pkgs/clan-cli/clan_cli/vars/upload.py b/pkgs/clan-cli/clan_cli/vars/upload.py index c8a2dbc9f..d71e49ee9 100644 --- a/pkgs/clan-cli/clan_cli/vars/upload.py +++ b/pkgs/clan-cli/clan_cli/vars/upload.py @@ -10,12 +10,14 @@ log = logging.getLogger(__name__) def upload_secret_vars(machine: Machine, host: Remote) -> None: - machine.secret_vars_store.upload(host, phases=["activation", "users", "services"]) + machine.secret_vars_store.upload( + machine.name, host, phases=["activation", "users", "services"] + ) def populate_secret_vars(machine: Machine, directory: Path) -> None: machine.secret_vars_store.populate_dir( - directory, phases=["activation", "users", "services"] + machine.name, directory, phases=["activation", "users", "services"] ) diff --git a/pkgs/clan-cli/clan_lib/machines/delete.py b/pkgs/clan-cli/clan_lib/machines/delete.py index d0c2beecc..14d40a925 100644 --- a/pkgs/clan-cli/clan_lib/machines/delete.py +++ b/pkgs/clan-cli/clan_lib/machines/delete.py @@ -58,8 +58,8 @@ def delete_machine(machine: Machine) -> None: changed_paths.append(secret_path) shutil.rmtree(secret_path) - changed_paths.extend(machine.public_vars_store.delete_store()) - changed_paths.extend(machine.secret_vars_store.delete_store()) + changed_paths.extend(machine.public_vars_store.delete_store(machine.name)) + changed_paths.extend(machine.secret_vars_store.delete_store(machine.name)) # Remove the machine's key, and update secrets & vars that referenced it: if secrets_has_machine(machine.flake.path, machine.name): secrets_machine_remove(machine.flake.path, machine.name) diff --git a/pkgs/clan-cli/clan_lib/machines/install.py b/pkgs/clan-cli/clan_lib/machines/install.py index 7627c1160..8aae94d59 100644 --- a/pkgs/clan-cli/clan_lib/machines/install.py +++ b/pkgs/clan-cli/clan_lib/machines/install.py @@ -66,13 +66,13 @@ def run_machine_install(opts: InstallOptions, target_host: Remote) -> None: upload_dir.mkdir(parents=True) machine.secret_facts_store.upload(upload_dir) machine.secret_vars_store.populate_dir( - upload_dir, phases=["activation", "users", "services"] + machine.name, upload_dir, phases=["activation", "users", "services"] ) partitioning_secrets = base_directory / "partitioning_secrets" partitioning_secrets.mkdir(parents=True) machine.secret_vars_store.populate_dir( - partitioning_secrets, phases=["partitioning"] + machine.name, partitioning_secrets, phases=["partitioning"] ) if opts.password: diff --git a/pkgs/clan-cli/clan_lib/machines/machines.py b/pkgs/clan-cli/clan_lib/machines/machines.py index 2b79a734d..179fc055a 100644 --- a/pkgs/clan-cli/clan_lib/machines/machines.py +++ b/pkgs/clan-cli/clan_lib/machines/machines.py @@ -104,13 +104,13 @@ class Machine: def secret_vars_store(self) -> StoreBase: secret_module = self.select("config.clan.core.vars.settings.secretModule") module = importlib.import_module(secret_module) - return module.SecretStore(machine=self.name, flake=self.flake) + return module.SecretStore(flake=self.flake) @cached_property def public_vars_store(self) -> StoreBase: public_module = self.select("config.clan.core.vars.settings.publicModule") module = importlib.import_module(public_module) - return module.FactStore(machine=self.name, flake=self.flake) + return module.FactStore(flake=self.flake) @property def facts_data(self) -> dict[str, dict[str, Any]]: diff --git a/pkgs/clan-cli/clan_lib/machines/morph.py b/pkgs/clan-cli/clan_lib/machines/morph.py index 7855ee67d..478899b6f 100644 --- a/pkgs/clan-cli/clan_lib/machines/morph.py +++ b/pkgs/clan-cli/clan_lib/machines/morph.py @@ -81,7 +81,9 @@ def morph_machine( generate_vars([machine], generator_name=None, regenerate=False) machine.secret_vars_store.populate_dir( - output_dir=Path("/run/secrets"), phases=["activation", "users", "services"] + machine.name, + output_dir=Path("/run/secrets"), + phases=["activation", "users", "services"], ) # run(["nixos-facter", "-o", f"{flakedir}/machines/{name}/facter.json"]).stdout