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:
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user