Merge pull request 'refactor: decouple vars stores from machine instances' (#4269) from davhau/vars-new into main

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4269
This commit is contained in:
kenji
2025-07-08 18:11:03 +00:00
16 changed files with 350 additions and 252 deletions

View File

@@ -161,7 +161,7 @@ def test_generate_public_and_secret_vars(
assert check_vars(machine.name, machine.flake) assert check_vars(machine.name, machine.flake)
# get last commit message # get last commit message
commit_message = run( commit_message = run(
["git", "log", "-3", "--pretty=%B"], ["git", "log", "-5", "--pretty=%B"],
).stdout.strip() ).stdout.strip()
assert ( assert (
"Update vars via generator my_generator for machine my_machine" "Update vars via generator my_generator for machine my_machine"
@@ -184,18 +184,18 @@ def test_generate_public_and_secret_vars(
== "shared" == "shared"
) )
vars_text = stringify_all_vars(machine) vars_text = stringify_all_vars(machine)
in_repo_store = in_repo.FactStore( flake_obj = Flake(str(flake.path))
machine="my_machine", flake=Flake(str(flake.path)) my_generator = Generator("my_generator", machine="my_machine", _flake=flake_obj)
) dependent_generator = Generator(
assert not in_repo_store.exists(Generator("my_generator"), "my_secret") "dependent_generator", machine="my_machine", _flake=flake_obj
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"
) )
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_value: public" in vars_text
assert "my_generator/my_secret" 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) monkeypatch.chdir(flake.path)
cli.run(["secrets", "groups", "add-user", "my_group", sops_setup.user]) cli.run(["secrets", "groups", "add-user", "my_group", sops_setup.user])
cli.run(["vars", "generate", "--flake", str(flake.path), "my_machine"]) cli.run(["vars", "generate", "--flake", str(flake.path), "my_machine"])
in_repo_store = in_repo.FactStore( flake_obj = Flake(str(flake.path))
machine="my_machine", flake=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") second_generator = Generator(
sops_store = sops.SecretStore(machine="my_machine", flake=Flake(str(flake.path))) "second_generator", machine="my_machine", _flake=flake_obj
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"
) )
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 # add another user to the group and check if secret gets re-encrypted
pubkey_user2 = sops_setup.keys[1] 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"]) cli.run(["secrets", "groups", "add-user", "my_group", "user2"])
# check if new user can access the secret # check if new user can access the secret
monkeypatch.setenv("USER", "user2") monkeypatch.setenv("USER", "user2")
assert sops_store.user_has_access( first_generator_with_share = Generator(
"user2", Generator("first_generator", share=False), "my_secret" "first_generator", share=False, machine="my_machine", _flake=flake_obj
) )
assert sops_store.user_has_access( second_generator_with_share = Generator(
"user2", Generator("second_generator", share=False), "my_secret" "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 # Rotate key of a user
pubkey_user3 = sops_setup.keys[2] pubkey_user3 = sops_setup.keys[2]
@@ -314,12 +317,8 @@ def test_generate_secret_var_sops_with_default_group(
] ]
) )
monkeypatch.setenv("USER", "user2") monkeypatch.setenv("USER", "user2")
assert sops_store.user_has_access( assert sops_store.user_has_access("user2", first_generator_with_share, "my_secret")
"user2", Generator("first_generator", share=False), "my_secret" assert sops_store.user_has_access("user2", second_generator_with_share, "my_secret")
)
assert sops_store.user_has_access(
"user2", Generator("second_generator", share=False), "my_secret"
)
@pytest.mark.with_core @pytest.mark.with_core
@@ -351,21 +350,21 @@ def test_generated_shared_secret_sops(
cli.run(["vars", "generate", "--flake", str(flake.path), "machine2"]) cli.run(["vars", "generate", "--flake", str(flake.path), "machine2"])
assert check_vars(machine2.name, machine2.flake) assert check_vars(machine2.name, machine2.flake)
assert check_vars(machine2.name, machine2.flake) assert check_vars(machine2.name, machine2.flake)
m1_sops_store = sops.SecretStore(machine1.name, machine1.flake) m1_sops_store = sops.SecretStore(machine1.flake)
m2_sops_store = sops.SecretStore(machine2.name, machine2.flake) m2_sops_store = sops.SecretStore(machine2.flake)
assert m1_sops_store.exists( # Create generators with machine context for testing
Generator("my_shared_generator", share=True), "my_shared_secret" generator_m1 = Generator(
"my_shared_generator", share=True, machine="machine1", _flake=machine1.flake
) )
assert m2_sops_store.exists( generator_m2 = Generator(
Generator("my_shared_generator", share=True), "my_shared_secret" "my_shared_generator", share=True, machine="machine2", _flake=machine2.flake
)
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"
) )
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 @pytest.mark.with_core
def test_generate_secret_var_password_store( 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 ["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) assert not check_vars(machine.name, machine.flake)
cli.run(["vars", "generate", "--flake", str(flake.path), "my_machine"]) cli.run(["vars", "generate", "--flake", str(flake.path), "my_machine"])
assert check_vars(machine.name, machine.flake) assert check_vars(machine.name, machine.flake)
store = password_store.SecretStore( store = password_store.SecretStore(flake=flake_obj)
machine="my_machine", flake=Flake(str(flake.path)) 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") my_generator_shared = Generator(
assert not store.exists( "my_generator", share=True, files=[], machine="my_machine", _flake=flake_obj
Generator("my_generator", share=True, files=[]), "my_secret"
) )
assert store.exists( my_shared_generator = Generator(
Generator("my_shared_generator", share=True, files=[]), "my_shared_secret" "my_shared_generator",
share=True,
files=[],
machine="my_machine",
_flake=flake_obj,
) )
assert not store.exists( my_shared_generator_not_shared = Generator(
Generator("my_shared_generator", share=False, files=[]), "my_shared_secret" "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" assert store.get(generator, "my_secret").decode() == "hello\n"
vars_text = stringify_all_vars(machine) vars_text = stringify_all_vars(machine)
assert "my_generator/my_secret" in vars_text 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" var_name = "my_secret"
store.delete(my_generator, var_name) store.delete(my_generator, var_name)
assert not store.exists(my_generator, var_name) assert not store.exists(my_generator, var_name)
store.delete_store() store.delete_store("my_machine")
store.delete_store() # check idempotency store.delete_store("my_machine") # check idempotency
my_generator2 = Generator("my_generator2", share=False, files=[]) my_generator2 = Generator(
"my_generator2", share=False, files=[], machine="my_machine", _flake=flake_obj
)
var_name = "my_secret2" var_name = "my_secret2"
assert not store.exists(my_generator2, var_name) assert not store.exists(my_generator2, var_name)
# The shared secret should still be there, # The shared secret should still be there,
# not sure if we can delete those automatically: # 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" var_name = "my_shared_secret"
assert store.exists(my_shared_generator, var_name) assert store.exists(my_shared_generator, var_name)
@@ -492,29 +520,25 @@ def test_generate_secret_for_multiple_machines(
monkeypatch.chdir(flake.path) monkeypatch.chdir(flake.path)
cli.run(["vars", "generate", "--flake", str(flake.path)]) cli.run(["vars", "generate", "--flake", str(flake.path)])
# check if public vars have been created correctly # check if public vars have been created correctly
in_repo_store1 = in_repo.FactStore(machine="machine1", flake=Flake(str(flake.path))) flake_obj = Flake(str(flake.path))
in_repo_store2 = in_repo.FactStore(machine="machine2", flake=Flake(str(flake.path))) in_repo_store1 = in_repo.FactStore(flake=flake_obj)
assert in_repo_store1.exists(Generator("my_generator"), "my_value") in_repo_store2 = in_repo.FactStore(flake=flake_obj)
assert in_repo_store2.exists(Generator("my_generator"), "my_value")
assert ( # Create generators for each machine
in_repo_store1.get(Generator("my_generator"), "my_value").decode() gen1 = Generator("my_generator", machine="machine1", _flake=flake_obj)
== "machine1\n" gen2 = Generator("my_generator", machine="machine2", _flake=flake_obj)
)
assert ( assert in_repo_store1.exists(gen1, "my_value")
in_repo_store2.get(Generator("my_generator"), "my_value").decode() assert in_repo_store2.exists(gen2, "my_value")
== "machine2\n" 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 # check if secret vars have been created correctly
sops_store1 = sops.SecretStore(machine="machine1", flake=Flake(str(flake.path))) sops_store1 = sops.SecretStore(flake=flake_obj)
sops_store2 = sops.SecretStore(machine="machine2", flake=Flake(str(flake.path))) sops_store2 = sops.SecretStore(flake=flake_obj)
assert sops_store1.exists(Generator("my_generator"), "my_secret") assert sops_store1.exists(gen1, "my_secret")
assert sops_store2.exists(Generator("my_generator"), "my_secret") assert sops_store2.exists(gen2, "my_secret")
assert ( assert sops_store1.get(gen1, "my_secret").decode() == "machine1\n"
sops_store1.get(Generator("my_generator"), "my_secret").decode() == "machine1\n" assert sops_store2.get(gen2, "my_secret").decode() == "machine2\n"
)
assert (
sops_store2.get(Generator("my_generator"), "my_secret").decode() == "machine2\n"
)
@pytest.mark.with_core @pytest.mark.with_core
@@ -550,28 +574,27 @@ def test_prompt(
iter(["line input", "my\nmultiline\ninput\n", "prompt_persist"]), iter(["line input", "my\nmultiline\ninput\n", "prompt_persist"]),
) )
cli.run(["vars", "generate", "--flake", str(flake.path), "my_machine"]) cli.run(["vars", "generate", "--flake", str(flake.path), "my_machine"])
in_repo_store = in_repo.FactStore( flake_obj = Flake(str(flake.path))
machine="my_machine", flake=Flake(str(flake.path)) my_generator = Generator("my_generator", machine="my_machine", _flake=flake_obj)
) my_generator_with_details = Generator(
assert in_repo_store.exists(Generator("my_generator"), "line_value") name="my_generator",
assert ( share=False,
in_repo_store.get(Generator("my_generator"), "line_value").decode() files=[],
== "line input" 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 ( assert (
in_repo_store.get(Generator("my_generator"), "multiline_value").decode() in_repo_store.get(my_generator, "multiline_value").decode()
== "my\nmultiline\ninput\n" == "my\nmultiline\ninput\n"
) )
sops_store = sops.SecretStore(machine="my_machine", flake=Flake(str(flake.path))) sops_store = sops.SecretStore(flake=flake_obj)
assert sops_store.exists( assert sops_store.exists(my_generator_with_details, "prompt_persist")
Generator(name="my_generator", share=False, files=[]), "prompt_persist" assert sops_store.get(my_generator, "prompt_persist").decode() == "prompt_persist"
)
assert (
sops_store.get(Generator(name="my_generator"), "prompt_persist").decode()
== "prompt_persist"
)
@pytest.mark.with_core @pytest.mark.with_core
@@ -606,21 +629,27 @@ def test_multi_machine_shared_vars(
monkeypatch.chdir(flake.path) monkeypatch.chdir(flake.path)
machine1 = Machine(name="machine1", flake=Flake(str(flake.path))) machine1 = Machine(name="machine1", flake=Flake(str(flake.path)))
machine2 = Machine(name="machine2", 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_1 = sops.SecretStore(machine1.flake)
sops_store_2 = sops.SecretStore(machine2.name, machine2.flake) sops_store_2 = sops.SecretStore(machine2.flake)
in_repo_store_1 = in_repo.FactStore(machine1.name, machine1.flake) in_repo_store_1 = in_repo.FactStore(machine1.flake)
in_repo_store_2 = in_repo.FactStore(machine2.name, machine2.flake) in_repo_store_2 = in_repo.FactStore(machine2.flake)
generator = Generator("shared_generator", share=True) # 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 # generate for machine 1
cli.run(["vars", "generate", "--flake", str(flake.path), "machine1"]) cli.run(["vars", "generate", "--flake", str(flake.path), "machine1"])
# read out values for machine 1 # read out values for machine 1
m1_secret = sops_store_1.get(generator, "my_secret") m1_secret = sops_store_1.get(generator_m1, "my_secret")
m1_value = in_repo_store_1.get(generator, "my_value") m1_value = in_repo_store_1.get(generator_m1, "my_value")
# generate for machine 2 # generate for machine 2
cli.run(["vars", "generate", "--flake", str(flake.path), "machine2"]) cli.run(["vars", "generate", "--flake", str(flake.path), "machine2"])
# ensure values are the same for both machines # ensure values are the same for both machines
assert sops_store_2.get(generator, "my_secret") == m1_secret assert sops_store_2.get(generator_m2, "my_secret") == m1_secret
assert in_repo_store_2.get(generator, "my_value") == m1_value assert in_repo_store_2.get(generator_m2, "my_value") == m1_value
# ensure shared secret stays available for all machines after regeneration # ensure shared secret stays available for all machines after regeneration
# regenerate for machine 1 # regenerate for machine 1
@@ -628,15 +657,15 @@ def test_multi_machine_shared_vars(
["vars", "generate", "--flake", str(flake.path), "machine1", "--regenerate"] ["vars", "generate", "--flake", str(flake.path), "machine1", "--regenerate"]
) )
# ensure values changed # ensure values changed
new_secret_1 = sops_store_1.get(generator, "my_secret") new_secret_1 = sops_store_1.get(generator_m1, "my_secret")
new_value_1 = in_repo_store_1.get(generator, "my_value") new_value_1 = in_repo_store_1.get(generator_m1, "my_value")
new_secret_2 = sops_store_2.get(generator, "my_secret") new_secret_2 = sops_store_2.get(generator_m2, "my_secret")
assert new_secret_1 != m1_secret assert new_secret_1 != m1_secret
assert new_value_1 != m1_value assert new_value_1 != m1_value
# ensure that both machines still have access to the same secret # ensure that both machines still have access to the same secret
assert new_secret_1 == new_secret_2 assert new_secret_1 == new_secret_2
assert sops_store_1.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, "my_secret") assert sops_store_2.machine_has_access(generator_m2, "my_secret")
@pytest.mark.with_core @pytest.mark.with_core
@@ -665,9 +694,10 @@ def test_api_set_prompts(
}, },
) )
machine = Machine(name="my_machine", flake=Flake(str(flake.path))) machine = Machine(name="my_machine", flake=Flake(str(flake.path)))
store = in_repo.FactStore(machine.name, machine.flake) store = in_repo.FactStore(machine.flake)
assert store.exists(Generator("my_generator"), "prompt1") my_generator = Generator("my_generator", machine="my_machine", _flake=machine.flake)
assert store.get(Generator("my_generator"), "prompt1").decode() == "input1" assert store.exists(my_generator, "prompt1")
assert store.get(my_generator, "prompt1").decode() == "input1"
run_generators( run_generators(
machine_name="my_machine", machine_name="my_machine",
base_dir=flake.path, 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( generators = get_generators(
machine_name="my_machine", machine_name="my_machine",
@@ -815,19 +845,21 @@ def test_migration(
assert "Migrated var my_generator/my_value" in caplog.text assert "Migrated var my_generator/my_value" in caplog.text
assert "Migrated secret var my_generator/my_secret" in caplog.text assert "Migrated secret var my_generator/my_secret" in caplog.text
in_repo_store = in_repo.FactStore( flake_obj = Flake(str(flake.path))
machine="my_machine", flake=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))) in_repo_store = in_repo.FactStore(flake=flake_obj)
assert in_repo_store.exists(Generator("my_generator"), "my_value") sops_store = sops.SecretStore(flake=flake_obj)
assert in_repo_store.get(Generator("my_generator"), "my_value").decode() == "hello" assert in_repo_store.exists(my_generator, "my_value")
assert sops_store.exists(Generator("my_generator"), "my_secret") assert in_repo_store.get(my_generator, "my_value").decode() == "hello"
assert sops_store.get(Generator("my_generator"), "my_secret").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 ( assert (
in_repo_store.get(Generator("other_generator"), "other_value").decode() in_repo_store.get(other_generator, "other_value").decode() == "value-from-vars"
== "value-from-vars"
) )

View File

@@ -29,8 +29,7 @@ class GeneratorUpdate:
class StoreBase(ABC): class StoreBase(ABC):
def __init__(self, machine: str, flake: Flake) -> None: def __init__(self, flake: Flake) -> None:
self.machine = machine
self.flake = flake self.flake = flake
@property @property
@@ -38,6 +37,19 @@ class StoreBase(ABC):
def store_name(self) -> str: def store_name(self) -> str:
pass 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 # get a single fact
@abstractmethod @abstractmethod
def get(self, generator: "Generator", name: str) -> bytes: def get(self, generator: "Generator", name: str) -> bytes:
@@ -65,6 +77,7 @@ class StoreBase(ABC):
def health_check( def health_check(
self, self,
machine: str,
generator: "Generator | None" = None, generator: "Generator | None" = None,
file_name: str | None = None, file_name: str | None = None,
) -> str | None: ) -> str | None:
@@ -72,6 +85,7 @@ class StoreBase(ABC):
def fix( def fix(
self, self,
machine: str,
generator: "Generator | None" = None, generator: "Generator | None" = None,
file_name: str | None = None, file_name: str | None = None,
) -> None: ) -> None:
@@ -87,7 +101,8 @@ class StoreBase(ABC):
def rel_dir(self, generator: "Generator", var_name: str) -> Path: def rel_dir(self, generator: "Generator", var_name: str) -> Path:
if generator.share: if generator.share:
return Path("shared") / generator.name / var_name 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: def directory(self, generator: "Generator", var_name: str) -> Path:
return self.flake.path / "vars" / self.rel_dir(generator, var_name) return self.flake.path / "vars" / self.rel_dir(generator, var_name)
@@ -134,7 +149,7 @@ class StoreBase(ABC):
""" """
@abstractmethod @abstractmethod
def delete_store(self) -> Iterable[Path]: def delete_store(self, machine: str) -> Iterable[Path]:
"""Delete the store (all vars) for this machine. """Delete the store (all vars) for this machine.
.. note:: .. note::
@@ -181,9 +196,9 @@ class StoreBase(ABC):
return stored_hash == target_hash return stored_hash == target_hash
@abstractmethod @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 pass
@abstractmethod @abstractmethod
def upload(self, host: Remote, phases: list[str]) -> None: def upload(self, machine: str, host: Remote, phases: list[str]) -> None:
pass pass

View File

@@ -65,6 +65,7 @@ def vars_status(
missing_secret_vars.append(file) missing_secret_vars.append(file)
else: else:
msg = machine.secret_vars_store.health_check( msg = machine.secret_vars_store.health_check(
machine=machine.name,
generator=generator, generator=generator,
file_name=file.name, file_name=file.name,
) )

View File

@@ -24,8 +24,8 @@ def fix_vars(machine: Machine, generator_name: None | str = None) -> None:
raise ClanError(err_msg) raise ClanError(err_msg)
for generator in generators: for generator in generators:
machine.public_vars_store.fix(generator=generator) machine.public_vars_store.fix(machine.name, generator=generator)
machine.secret_vars_store.fix(generator=generator) machine.secret_vars_store.fix(machine.name, generator=generator)
def fix_command(args: argparse.Namespace) -> None: def fix_command(args: argparse.Namespace) -> None:

View File

@@ -533,8 +533,12 @@ def create_machine_vars_interactive(
_generator = generator _generator = generator
break break
pub_healtcheck_msg = machine.public_vars_store.health_check(_generator) pub_healtcheck_msg = machine.public_vars_store.health_check(
sec_healtcheck_msg = machine.secret_vars_store.health_check(_generator) machine.name, _generator
)
sec_healtcheck_msg = machine.secret_vars_store.health_check(
machine.name, _generator
)
if pub_healtcheck_msg or sec_healtcheck_msg: if pub_healtcheck_msg or sec_healtcheck_msg:
msg = f"Health check failed for machine {machine.name}:\n" msg = f"Health check failed for machine {machine.name}:\n"

View File

@@ -14,8 +14,8 @@ class FactStore(StoreBase):
def is_secret_store(self) -> bool: def is_secret_store(self) -> bool:
return False return False
def __init__(self, machine: str, flake: Flake) -> None: def __init__(self, flake: Flake) -> None:
super().__init__(machine, flake) super().__init__(flake)
self.works_remotely = False self.works_remotely = False
@property @property
@@ -61,18 +61,18 @@ class FactStore(StoreBase):
fact_folder.rmdir() fact_folder.rmdir()
return [fact_folder] return [fact_folder]
def delete_store(self) -> Iterable[Path]: def delete_store(self, machine: str) -> Iterable[Path]:
flake_root = self.flake.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(): if not store_folder.exists():
return [] return []
shutil.rmtree(store_folder) shutil.rmtree(store_folder)
return [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" msg = "populate_dir is not implemented for public vars stores"
raise NotImplementedError(msg) 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" msg = "upload is not implemented for public vars stores"
raise NotImplementedError(msg) raise NotImplementedError(msg)

View File

@@ -18,21 +18,26 @@ class FactStore(StoreBase):
def is_secret_store(self) -> bool: def is_secret_store(self) -> bool:
return False return False
def __init__(self, machine: str, flake: Flake) -> None: def __init__(self, flake: Flake) -> None:
super().__init__(machine, flake) super().__init__(flake)
self.works_remotely = False 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 @property
def store_name(self) -> str: def store_name(self) -> str:
return "vm" 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: 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() return fact_path.exists()
def _set( def _set(
@@ -41,21 +46,24 @@ class FactStore(StoreBase):
var: Var, var: Var,
value: bytes, value: bytes,
) -> Path | None: ) -> 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.parent.mkdir(parents=True, exist_ok=True)
fact_path.write_bytes(value) fact_path.write_bytes(value)
return None return None
# get a single fact # get a single fact
def get(self, generator: Generator, name: str) -> bytes: 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(): if fact_path.exists():
return fact_path.read_bytes() return fact_path.read_bytes()
msg = f"Fact {name} for service {generator.name} not found" msg = f"Fact {name} for service {generator.name} not found"
raise ClanError(msg) raise ClanError(msg)
def delete(self, generator: Generator, name: str) -> Iterable[Path]: 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 = fact_dir / name
fact_file.unlink() fact_file.unlink()
empty = None empty = None
@@ -63,16 +71,17 @@ class FactStore(StoreBase):
fact_dir.rmdir() fact_dir.rmdir()
return [fact_file] return [fact_file]
def delete_store(self) -> Iterable[Path]: def delete_store(self, machine: str) -> Iterable[Path]:
if not self.dir.exists(): vars_dir = self.get_dir(machine)
if not vars_dir.exists():
return [] return []
shutil.rmtree(self.dir) shutil.rmtree(vars_dir)
return [self.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" msg = "populate_dir is not implemented for public vars stores"
raise NotImplementedError(msg) 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" msg = "upload is not implemented for public vars stores"
raise NotImplementedError(msg) raise NotImplementedError(msg)

View File

@@ -13,8 +13,8 @@ class SecretStore(StoreBase):
def is_secret_store(self) -> bool: def is_secret_store(self) -> bool:
return True return True
def __init__(self, machine: str, flake: Flake) -> None: def __init__(self, flake: Flake) -> None:
super().__init__(machine, flake) super().__init__(flake)
self.dir = Path(tempfile.gettempdir()) / "clan_secrets" self.dir = Path(tempfile.gettempdir()) / "clan_secrets"
self.dir.mkdir(parents=True, exist_ok=True) self.dir.mkdir(parents=True, exist_ok=True)
@@ -40,7 +40,7 @@ class SecretStore(StoreBase):
secret_file = self.dir / generator.name / name secret_file = self.dir / generator.name / name
return secret_file.read_bytes() 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(): if output_dir.exists():
shutil.rmtree(output_dir) shutil.rmtree(output_dir)
shutil.copytree(self.dir, output_dir) shutil.copytree(self.dir, output_dir)
@@ -52,11 +52,11 @@ class SecretStore(StoreBase):
secret_file.unlink() secret_file.unlink()
return [] return []
def delete_store(self) -> list[Path]: def delete_store(self, machine: str) -> list[Path]:
if self.dir.exists(): if self.dir.exists():
shutil.rmtree(self.dir) shutil.rmtree(self.dir)
return [] 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" msg = "Cannot upload secrets with FS backend"
raise NotImplementedError(msg) raise NotImplementedError(msg)

View File

@@ -20,8 +20,8 @@ class SecretStore(StoreBase):
def is_secret_store(self) -> bool: def is_secret_store(self) -> bool:
return True return True
def __init__(self, machine: str, flake: Flake) -> None: def __init__(self, flake: Flake) -> None:
super().__init__(machine, flake) super().__init__(flake)
self.entry_prefix = "clan-vars" self.entry_prefix = "clan-vars"
self._store_dir: Path | None = None self._store_dir: Path | None = None
@@ -29,25 +29,25 @@ class SecretStore(StoreBase):
def store_name(self) -> str: def store_name(self) -> str:
return "password_store" return "password_store"
@property def store_dir(self, machine: str) -> Path:
def store_dir(self) -> Path: """Get the password store directory, cached per machine."""
"""Get the password store directory, cached after first access.""" if not self._store_dir:
if self._store_dir is None: result = self._run_pass(
result = self._run_pass("git", "rev-parse", "--show-toplevel", check=False) machine, "git", "rev-parse", "--show-toplevel", check=False
)
if result.returncode != 0: if result.returncode != 0:
msg = "Password store must be a git repository" msg = "Password store must be a git repository"
raise ValueError(msg) raise ValueError(msg)
self._store_dir = Path(result.stdout.strip().decode()) self._store_dir = Path(result.stdout.strip().decode())
return self._store_dir return self._store_dir
@property def _pass_command(self, machine: str) -> str:
def _pass_command(self) -> str:
out_path = self.flake.select_machine( 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 = ( main_program = (
self.flake.select_machine( self.flake.select_machine(
self.machine, machine,
"config.clan.core.vars.password-store.passPackage.?meta.?mainProgram", "config.clan.core.vars.password-store.passPackage.?meta.?mainProgram",
) )
.get("meta", {}) .get("meta", {})
@@ -80,11 +80,12 @@ class SecretStore(StoreBase):
def _run_pass( def _run_pass(
self, self,
machine: str,
*args: str, *args: str,
input: bytes | None = None, # noqa: A002 input: bytes | None = None, # noqa: A002
check: bool = True, check: bool = True,
) -> subprocess.CompletedProcess[bytes]: ) -> 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. # We need bytes support here, so we can not use clan cmd.
# If you change this to run( add bytes support to it first! # If you change this to run( add bytes support to it first!
# otherwise we mangle binary secrets (which is annoying to debug) # otherwise we mangle binary secrets (which is annoying to debug)
@@ -101,37 +102,44 @@ class SecretStore(StoreBase):
var: Var, var: Var,
value: bytes, value: bytes,
) -> Path | None: ) -> Path | None:
machine = self.get_machine(generator)
pass_call = ["insert", "-m", str(self.entry_dir(generator, var.name))] 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 return None # we manage the files outside of the git repo
def get(self, generator: Generator, name: str) -> bytes: def get(self, generator: Generator, name: str) -> bytes:
machine = self.get_machine(generator)
pass_name = str(self.entry_dir(generator, name)) 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: def exists(self, generator: Generator, name: str) -> bool:
machine = self.get_machine(generator)
pass_name = str(self.entry_dir(generator, name)) pass_name = str(self.entry_dir(generator, name))
# Check if the file exists with either .age or .gpg extension # Check if the file exists with either .age or .gpg extension
age_file = self.store_dir / f"{pass_name}.age" store_dir = self.store_dir(machine)
gpg_file = self.store_dir / f"{pass_name}.gpg" age_file = store_dir / f"{pass_name}.age"
gpg_file = store_dir / f"{pass_name}.gpg"
return age_file.exists() or gpg_file.exists() return age_file.exists() or gpg_file.exists()
def delete(self, generator: Generator, name: str) -> Iterable[Path]: def delete(self, generator: Generator, name: str) -> Iterable[Path]:
machine = self.get_machine(generator)
pass_name = str(self.entry_dir(generator, name)) 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 [] return []
def delete_store(self) -> Iterable[Path]: def delete_store(self, machine: str) -> Iterable[Path]:
machine_dir = Path(self.entry_prefix) / "per-machine" / self.machine machine_dir = Path(self.entry_prefix) / "per-machine" / machine
# Check if the directory exists in the password store before trying to delete # 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: 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 [] return []
def generate_hash(self) -> bytes: def generate_hash(self, machine: str) -> bytes:
result = self._run_pass( 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() git_hash = result.stdout.strip()
@@ -141,7 +149,7 @@ class SecretStore(StoreBase):
from clan_cli.vars.generate import Generator from clan_cli.vars.generate import Generator
manifest = [] manifest = []
generators = Generator.generators_from_flake(self.machine, self.flake) generators = Generator.generators_from_flake(machine, self.flake)
for generator in generators: for generator in generators:
for file in generator.files: for file in generator.files:
manifest.append(f"{generator.name}/{file.name}".encode()) manifest.append(f"{generator.name}/{file.name}".encode())
@@ -149,8 +157,8 @@ class SecretStore(StoreBase):
manifest.append(git_hash) manifest.append(git_hash)
return b"\n".join(manifest) return b"\n".join(manifest)
def needs_upload(self, host: Remote) -> bool: def needs_upload(self, machine: str, host: Remote) -> bool:
local_hash = self.generate_hash() local_hash = self.generate_hash(machine)
if not local_hash: if not local_hash:
return True return True
@@ -159,7 +167,7 @@ class SecretStore(StoreBase):
remote_hash = host.run( remote_hash = host.run(
[ [
"cat", "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), RunOpts(log=Log.STDERR, check=False),
).stdout.strip() ).stdout.strip()
@@ -169,10 +177,10 @@ class SecretStore(StoreBase):
return local_hash != remote_hash.encode() 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 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: if "users" in phases:
with tarfile.open( with tarfile.open(
output_dir / "secrets_for_users.tar.gz", "w:gz" 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.parent.mkdir(parents=True, exist_ok=True)
out_file.write_bytes(self.get(generator, file.name)) out_file.write_bytes(self.get(generator, file.name))
hash_data = self.generate_hash() hash_data = self.generate_hash(machine)
if hash_data: if hash_data:
(output_dir / ".pass_info").write_bytes(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: if "partitioning" in phases:
msg = "Cannot upload partitioning secrets" msg = "Cannot upload partitioning secrets"
raise NotImplementedError(msg) raise NotImplementedError(msg)
if not self.needs_upload(host): if not self.needs_upload(machine, host):
log.info("Secrets already uploaded") log.info("Secrets already uploaded")
return return
with TemporaryDirectory(prefix="vars-upload-") as _tempdir: with TemporaryDirectory(prefix="vars-upload-") as _tempdir:
pass_dir = Path(_tempdir).resolve() pass_dir = Path(_tempdir).resolve()
self.populate_dir(pass_dir, phases) self.populate_dir(machine, pass_dir, phases)
upload_dir = Path( upload_dir = Path(
self.flake.select_machine( 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) upload(host, pass_dir, upload_dir)

View File

@@ -48,13 +48,15 @@ class SecretStore(StoreBase):
def is_secret_store(self) -> bool: def is_secret_store(self) -> bool:
return True return True
def __init__(self, machine: str, flake: Flake) -> None: def __init__(self, flake: Flake) -> None:
super().__init__(machine, flake) 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 # no need to generate keys if we don't manage secrets
from clan_cli.vars.generate import Generator 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: if not vars_generators:
return return
has_secrets = False has_secrets = False
@@ -65,19 +67,19 @@ class SecretStore(StoreBase):
if not has_secrets: if not has_secrets:
return return
if has_machine(self.flake.path, self.machine): if has_machine(self.flake.path, machine):
return return
priv_key, pub_key = sops.generate_private_key() priv_key, pub_key = sops.generate_private_key()
encrypt_secret( encrypt_secret(
self.flake.path, 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, priv_key,
add_groups=self.flake.select_machine( 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), 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 @property
def store_name(self) -> str: def store_name(self) -> str:
@@ -90,7 +92,9 @@ class SecretStore(StoreBase):
return self.key_has_access(key_dir, generator, secret_name) return self.key_has_access(key_dir, generator, secret_name)
def machine_has_access(self, generator: Generator, secret_name: str) -> bool: 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) return self.key_has_access(key_dir, generator, secret_name)
def key_has_access( def key_has_access(
@@ -106,7 +110,10 @@ class SecretStore(StoreBase):
@override @override
def health_check( 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: ) -> str | None:
""" """
Apply local updates to secrets like re-encrypting with missing keys Apply local updates to secrets like re-encrypting with missing keys
@@ -116,7 +123,7 @@ class SecretStore(StoreBase):
if generator is None: if generator is None:
from clan_cli.vars.generate import Generator 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: else:
generators = [generator] generators = [generator]
file_found = False file_found = False
@@ -141,7 +148,7 @@ class SecretStore(StoreBase):
if outdated: if outdated:
msg = ( msg = (
"The local state of some secret vars is inconsistent and needs to be updated.\n" "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" "Problems to fix:\n"
"\n".join(o[2] for o in outdated if o[2]) "\n".join(o[2] for o in outdated if o[2])
) )
@@ -154,6 +161,8 @@ class SecretStore(StoreBase):
var: Var, var: Var,
value: bytes, value: bytes,
) -> Path | None: ) -> Path | None:
machine = self.get_machine(generator)
self.ensure_machine_key(machine)
secret_folder = self.secret_path(generator, var.name) secret_folder = self.secret_path(generator, var.name)
# create directory if it doesn't exist # create directory if it doesn't exist
secret_folder.mkdir(parents=True, exist_ok=True) secret_folder.mkdir(parents=True, exist_ok=True)
@@ -162,9 +171,9 @@ class SecretStore(StoreBase):
self.flake.path, self.flake.path,
secret_folder, secret_folder,
value, value,
add_machines=[self.machine] if var.deploy else [], add_machines=[machine] if var.deploy else [],
add_groups=self.flake.select_machine( add_groups=self.flake.select_machine(
self.machine, "config.clan.core.sops.defaultGroups" machine, "config.clan.core.sops.defaultGroups"
), ),
git_commit=False, git_commit=False,
age_plugins=load_age_plugins(self.flake), age_plugins=load_age_plugins(self.flake),
@@ -182,20 +191,20 @@ class SecretStore(StoreBase):
shutil.rmtree(secret_dir) shutil.rmtree(secret_dir)
return [secret_dir] return [secret_dir]
def delete_store(self) -> Iterable[Path]: def delete_store(self, machine: str) -> Iterable[Path]:
flake_root = self.flake.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(): if not store_folder.exists():
return [] return []
shutil.rmtree(store_folder) shutil.rmtree(store_folder)
return [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 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: 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): if not has_secret(sops_secrets_folder(self.flake.path) / key_name):
# skip uploading the secret, not managed by us # skip uploading the secret, not managed by us
return return
@@ -237,13 +246,13 @@ class SecretStore(StoreBase):
target_path.chmod(file.mode) target_path.chmod(file.mode)
@override @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: if "partitioning" in phases:
msg = "Cannot upload partitioning secrets" msg = "Cannot upload partitioning secrets"
raise NotImplementedError(msg) raise NotImplementedError(msg)
with TemporaryDirectory(prefix="sops-upload-") as _tempdir: with TemporaryDirectory(prefix="sops-upload-") as _tempdir:
sops_upload_dir = Path(_tempdir).resolve() 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")) upload(host, sops_upload_dir, Path("/var/lib/sops-nix"))
def exists(self, generator: Generator, name: str) -> bool: def exists(self, generator: Generator, name: str) -> bool:
@@ -251,17 +260,18 @@ class SecretStore(StoreBase):
return (secret_folder / "secret").exists() return (secret_folder / "secret").exists()
def ensure_machine_has_access(self, generator: Generator, name: str) -> None: def ensure_machine_has_access(self, generator: Generator, name: str) -> None:
machine = self.get_machine(generator)
if self.machine_has_access(generator, name): if self.machine_has_access(generator, name):
return return
secret_folder = self.secret_path(generator, name) secret_folder = self.secret_path(generator, name)
add_secret( add_secret(
self.flake.path, self.flake.path,
self.machine, machine,
secret_folder, secret_folder,
age_plugins=load_age_plugins(self.flake), 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 ( from clan_cli.secrets.secrets import (
collect_keys_for_path, collect_keys_for_path,
collect_keys_for_type, collect_keys_for_type,
@@ -269,7 +279,7 @@ class SecretStore(StoreBase):
keys = collect_keys_for_path(path) keys = collect_keys_for_path(path)
for group in self.flake.select_machine( for group in self.flake.select_machine(
self.machine, "config.clan.core.sops.defaultGroups" machine, "config.clan.core.sops.defaultGroups"
): ):
keys.update( keys.update(
collect_keys_for_type( collect_keys_for_type(
@@ -285,9 +295,10 @@ class SecretStore(StoreBase):
return keys return keys
def needs_fix(self, generator: Generator, name: str) -> tuple[bool, str | None]: def needs_fix(self, generator: Generator, name: str) -> tuple[bool, str | None]:
machine = self.get_machine(generator)
secret_path = self.secret_path(generator, name) secret_path = self.secret_path(generator, name)
current_recipients = sops.get_recipients(secret_path) 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 needs_update = current_recipients != wanted_recipients
recipients_to_add = wanted_recipients - current_recipients recipients_to_add = wanted_recipients - current_recipients
var_id = f"{generator.name}/{name}" 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"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"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"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 return needs_update, msg
@override @override
def fix( 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: ) -> None:
from clan_cli.secrets.secrets import update_keys from clan_cli.secrets.secrets import update_keys
if generator is None: if generator is None:
from clan_cli.vars.generate import Generator 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: else:
generators = [generator] generators = [generator]
file_found = False file_found = False
@@ -327,8 +341,9 @@ class SecretStore(StoreBase):
age_plugins = load_age_plugins(self.flake) age_plugins = load_age_plugins(self.flake)
gen_machine = self.get_machine(generator)
for group in self.flake.select_machine( for group in self.flake.select_machine(
self.machine, "config.clan.core.sops.defaultGroups" gen_machine, "config.clan.core.sops.defaultGroups"
): ):
allow_member( allow_member(
groups_folder(secret_path), groups_folder(secret_path),

View File

@@ -14,35 +14,43 @@ class SecretStore(StoreBase):
def is_secret_store(self) -> bool: def is_secret_store(self) -> bool:
return True return True
def __init__(self, machine: str, flake: Flake) -> None: def __init__(self, flake: Flake) -> None:
super().__init__(machine, flake) super().__init__(flake)
self.dir = vm_state_dir(flake.identifier, machine) / "secrets"
self.dir.mkdir(parents=True, exist_ok=True)
@property @property
def store_name(self) -> str: def store_name(self) -> str:
return "vm" 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( def _set(
self, self,
generator: Generator, generator: Generator,
var: Var, var: Var,
value: bytes, value: bytes,
) -> Path | None: ) -> 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.parent.mkdir(parents=True, exist_ok=True)
secret_file.write_bytes(value) secret_file.write_bytes(value)
return None # we manage the files outside of the git repo return None # we manage the files outside of the git repo
def exists(self, generator: "Generator", name: str) -> bool: 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: 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() return secret_file.read_bytes()
def delete(self, generator: Generator, name: str) -> Iterable[Path]: 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 = secret_dir / name
secret_file.unlink() secret_file.unlink()
empty = None empty = None
@@ -50,17 +58,19 @@ class SecretStore(StoreBase):
secret_dir.rmdir() secret_dir.rmdir()
return [secret_file] return [secret_file]
def delete_store(self) -> Iterable[Path]: def delete_store(self, machine: str) -> Iterable[Path]:
if not self.dir.exists(): vars_dir = self.get_dir(machine)
if not vars_dir.exists():
return [] return []
shutil.rmtree(self.dir) shutil.rmtree(vars_dir)
return [self.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(): if output_dir.exists():
shutil.rmtree(output_dir) 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" msg = "Cannot upload secrets to VMs"
raise NotImplementedError(msg) raise NotImplementedError(msg)

View File

@@ -10,12 +10,14 @@ log = logging.getLogger(__name__)
def upload_secret_vars(machine: Machine, host: Remote) -> None: 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: def populate_secret_vars(machine: Machine, directory: Path) -> None:
machine.secret_vars_store.populate_dir( machine.secret_vars_store.populate_dir(
directory, phases=["activation", "users", "services"] machine.name, directory, phases=["activation", "users", "services"]
) )

View File

@@ -58,8 +58,8 @@ def delete_machine(machine: Machine) -> None:
changed_paths.append(secret_path) changed_paths.append(secret_path)
shutil.rmtree(secret_path) shutil.rmtree(secret_path)
changed_paths.extend(machine.public_vars_store.delete_store()) changed_paths.extend(machine.public_vars_store.delete_store(machine.name))
changed_paths.extend(machine.secret_vars_store.delete_store()) changed_paths.extend(machine.secret_vars_store.delete_store(machine.name))
# Remove the machine's key, and update secrets & vars that referenced it: # Remove the machine's key, and update secrets & vars that referenced it:
if secrets_has_machine(machine.flake.path, machine.name): if secrets_has_machine(machine.flake.path, machine.name):
secrets_machine_remove(machine.flake.path, machine.name) secrets_machine_remove(machine.flake.path, machine.name)

View File

@@ -66,13 +66,13 @@ def run_machine_install(opts: InstallOptions, target_host: Remote) -> None:
upload_dir.mkdir(parents=True) upload_dir.mkdir(parents=True)
machine.secret_facts_store.upload(upload_dir) machine.secret_facts_store.upload(upload_dir)
machine.secret_vars_store.populate_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 = base_directory / "partitioning_secrets"
partitioning_secrets.mkdir(parents=True) partitioning_secrets.mkdir(parents=True)
machine.secret_vars_store.populate_dir( machine.secret_vars_store.populate_dir(
partitioning_secrets, phases=["partitioning"] machine.name, partitioning_secrets, phases=["partitioning"]
) )
if opts.password: if opts.password:

View File

@@ -104,13 +104,13 @@ class Machine:
def secret_vars_store(self) -> StoreBase: def secret_vars_store(self) -> StoreBase:
secret_module = self.select("config.clan.core.vars.settings.secretModule") secret_module = self.select("config.clan.core.vars.settings.secretModule")
module = importlib.import_module(secret_module) module = importlib.import_module(secret_module)
return module.SecretStore(machine=self.name, flake=self.flake) return module.SecretStore(flake=self.flake)
@cached_property @cached_property
def public_vars_store(self) -> StoreBase: def public_vars_store(self) -> StoreBase:
public_module = self.select("config.clan.core.vars.settings.publicModule") public_module = self.select("config.clan.core.vars.settings.publicModule")
module = importlib.import_module(public_module) module = importlib.import_module(public_module)
return module.FactStore(machine=self.name, flake=self.flake) return module.FactStore(flake=self.flake)
@property @property
def facts_data(self) -> dict[str, dict[str, Any]]: def facts_data(self) -> dict[str, dict[str, Any]]:

View File

@@ -81,7 +81,9 @@ def morph_machine(
generate_vars([machine], generator_name=None, regenerate=False) generate_vars([machine], generator_name=None, regenerate=False)
machine.secret_vars_store.populate_dir( 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 # run(["nixos-facter", "-o", f"{flakedir}/machines/{name}/facter.json"]).stdout