refactor: decouple vars stores from machine instances

Stores now get machine context from generator objects instead of storing
it internally. This enables future machine-independent generators and
reduces coupling.

- StoreBase.__init__ only takes flake parameter
- Store methods receive machine as explicit parameter
- Fixed all callers to pass machine context
This commit is contained in:
DavHau
2025-07-08 18:11:48 +07:00
parent 21b3a5f366
commit 0aa6288edb
16 changed files with 350 additions and 252 deletions

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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:

View File

@@ -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"

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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),

View File

@@ -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)

View File

@@ -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"]
)