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