vars: fix re-generate behavior for dependencies of shared vars (#5001)
fixes https://git.clan.lol/clan/clan-core/issues/3791 This fixes multiple issues we had when re-generating shared vars. Problem 1: shared vars are re-generated for each individual machine instead of just once (see #3791) Problem 2: When a shared var was re-generated for one machine, dependent vars on other machines did not get re-generated, leading to broken state Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5001
This commit is contained in:
@@ -1,6 +0,0 @@
|
|||||||
{ fetchgit }:
|
|
||||||
fetchgit {
|
|
||||||
url = "https://git.clan.lol/clan/clan-core.git";
|
|
||||||
rev = "5d884cecc2585a29b6a3596681839d081b4de192";
|
|
||||||
sha256 = "09is1afmncamavb2q88qac37vmsijxzsy1iz1vr6gsyjq2rixaxc";
|
|
||||||
}
|
|
||||||
@@ -50,6 +50,7 @@
|
|||||||
self.nixosConfigurations."test-flash-machine-${pkgs.hostPlatform.system}".config.system.build.toplevel
|
self.nixosConfigurations."test-flash-machine-${pkgs.hostPlatform.system}".config.system.build.toplevel
|
||||||
self.nixosConfigurations."test-flash-machine-${pkgs.hostPlatform.system}".config.system.build.diskoScript
|
self.nixosConfigurations."test-flash-machine-${pkgs.hostPlatform.system}".config.system.build.diskoScript
|
||||||
self.nixosConfigurations."test-flash-machine-${pkgs.hostPlatform.system}".config.system.build.diskoScript.drvPath
|
self.nixosConfigurations."test-flash-machine-${pkgs.hostPlatform.system}".config.system.build.diskoScript.drvPath
|
||||||
|
(import ../installation/facter-report.nix pkgs.hostPlatform.system)
|
||||||
]
|
]
|
||||||
++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
|
++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
|
||||||
closureInfo = pkgs.closureInfo { rootPaths = dependencies; };
|
closureInfo = pkgs.closureInfo { rootPaths = dependencies; };
|
||||||
|
|||||||
10
checks/installation/facter-report.nix
Normal file
10
checks/installation/facter-report.nix
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
system:
|
||||||
|
builtins.fetchurl {
|
||||||
|
url = "https://git.clan.lol/clan/test-fixtures/raw/commit/4a2bc56d886578124b05060d3fb7eddc38c019f8/nixos-vm-facter-json/${system}.json";
|
||||||
|
sha256 =
|
||||||
|
{
|
||||||
|
aarch64-linux = "sha256:1rlfymk03rmfkm2qgrc8l5kj5i20srx79n1y1h4nzlpwaz0j7hh2";
|
||||||
|
x86_64-linux = "sha256:16myh0ll2gdwsiwkjw5ba4dl23ppwbsanxx214863j7nvzx42pws";
|
||||||
|
}
|
||||||
|
.${system};
|
||||||
|
}
|
||||||
@@ -18,27 +18,23 @@
|
|||||||
fileSystems."/".device = lib.mkDefault "/dev/vda";
|
fileSystems."/".device = lib.mkDefault "/dev/vda";
|
||||||
boot.loader.grub.device = lib.mkDefault "/dev/vda";
|
boot.loader.grub.device = lib.mkDefault "/dev/vda";
|
||||||
|
|
||||||
imports = [ self.nixosModules.test-install-machine-without-system ];
|
imports = [
|
||||||
|
self.nixosModules.test-install-machine-without-system
|
||||||
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
clan.machines.test-install-machine-with-system =
|
clan.machines.test-install-machine-with-system =
|
||||||
{ pkgs, ... }:
|
{ pkgs, ... }:
|
||||||
{
|
{
|
||||||
# https://git.clan.lol/clan/test-fixtures
|
# https://git.clan.lol/clan/test-fixtures
|
||||||
facter.reportPath = builtins.fetchurl {
|
facter.reportPath = import ./facter-report.nix pkgs.hostPlatform.system;
|
||||||
url = "https://git.clan.lol/clan/test-fixtures/raw/commit/4a2bc56d886578124b05060d3fb7eddc38c019f8/nixos-vm-facter-json/${pkgs.hostPlatform.system}.json";
|
|
||||||
sha256 =
|
|
||||||
{
|
|
||||||
aarch64-linux = "sha256:1rlfymk03rmfkm2qgrc8l5kj5i20srx79n1y1h4nzlpwaz0j7hh2";
|
|
||||||
x86_64-linux = "sha256:16myh0ll2gdwsiwkjw5ba4dl23ppwbsanxx214863j7nvzx42pws";
|
|
||||||
}
|
|
||||||
.${pkgs.hostPlatform.system};
|
|
||||||
};
|
|
||||||
|
|
||||||
fileSystems."/".device = lib.mkDefault "/dev/vda";
|
fileSystems."/".device = lib.mkDefault "/dev/vda";
|
||||||
boot.loader.grub.device = lib.mkDefault "/dev/vda";
|
boot.loader.grub.device = lib.mkDefault "/dev/vda";
|
||||||
|
|
||||||
imports = [ self.nixosModules.test-install-machine-without-system ];
|
imports = [ self.nixosModules.test-install-machine-without-system ];
|
||||||
};
|
};
|
||||||
|
|
||||||
flake.nixosModules = {
|
flake.nixosModules = {
|
||||||
test-install-machine-without-system =
|
test-install-machine-without-system =
|
||||||
{ lib, modulesPath, ... }:
|
{ lib, modulesPath, ... }:
|
||||||
@@ -159,6 +155,7 @@
|
|||||||
pkgs.stdenv.drvPath
|
pkgs.stdenv.drvPath
|
||||||
pkgs.bash.drvPath
|
pkgs.bash.drvPath
|
||||||
pkgs.buildPackages.xorg.lndir
|
pkgs.buildPackages.xorg.lndir
|
||||||
|
(import ./facter-report.nix pkgs.hostPlatform.system)
|
||||||
]
|
]
|
||||||
++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
|
++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
pkgs.stdenv.drvPath
|
pkgs.stdenv.drvPath
|
||||||
pkgs.stdenvNoCC
|
pkgs.stdenvNoCC
|
||||||
self.nixosConfigurations.test-morph-machine.config.system.build.toplevel
|
self.nixosConfigurations.test-morph-machine.config.system.build.toplevel
|
||||||
|
(import ../installation/facter-report.nix pkgs.hostPlatform.system)
|
||||||
]
|
]
|
||||||
++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
|
++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
|
||||||
closureInfo = pkgs.closureInfo { rootPaths = dependencies; };
|
closureInfo = pkgs.closureInfo { rootPaths = dependencies; };
|
||||||
|
|||||||
@@ -112,6 +112,7 @@
|
|||||||
pkgs.stdenv.drvPath
|
pkgs.stdenv.drvPath
|
||||||
pkgs.bash.drvPath
|
pkgs.bash.drvPath
|
||||||
pkgs.buildPackages.xorg.lndir
|
pkgs.buildPackages.xorg.lndir
|
||||||
|
(import ../installation/facter-report.nix pkgs.hostPlatform.system)
|
||||||
]
|
]
|
||||||
++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
|
++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
|
||||||
};
|
};
|
||||||
|
|||||||
39
pkgs/clan-cli/clan_cli/python-deps.nix
Normal file
39
pkgs/clan-cli/clan_cli/python-deps.nix
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
python3,
|
||||||
|
fetchFromGitHub,
|
||||||
|
}:
|
||||||
|
rec {
|
||||||
|
asyncore-wsgi = python3.pkgs.buildPythonPackage rec {
|
||||||
|
pname = "asyncore-wsgi";
|
||||||
|
version = "0.0.11";
|
||||||
|
src = fetchFromGitHub {
|
||||||
|
owner = "romanvm";
|
||||||
|
repo = "asyncore-wsgi";
|
||||||
|
rev = "${version}";
|
||||||
|
sha256 = "sha256-06rWCC8qZb9H9qPUDQpzASKOY4VX+Y+Bm9a5e71Hqhc=";
|
||||||
|
};
|
||||||
|
pyproject = true;
|
||||||
|
buildInputs = [
|
||||||
|
python3.pkgs.setuptools
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
web-pdb = python3.pkgs.buildPythonPackage rec {
|
||||||
|
pname = "web-pdb";
|
||||||
|
version = "1.6.3";
|
||||||
|
src = fetchFromGitHub {
|
||||||
|
owner = "romanvm";
|
||||||
|
repo = "python-web-pdb";
|
||||||
|
rev = "${version}";
|
||||||
|
sha256 = "sha256-VG0mHbogx0n1f38h9VVxFQgjvghipAf1rb43/Bwb/8I=";
|
||||||
|
};
|
||||||
|
pyproject = true;
|
||||||
|
buildInputs = [
|
||||||
|
python3.pkgs.setuptools
|
||||||
|
];
|
||||||
|
propagatedBuildInputs = [
|
||||||
|
python3.pkgs.bottle
|
||||||
|
asyncore-wsgi
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -431,20 +431,22 @@ def test_generated_shared_secret_sops(
|
|||||||
generator_m1 = Generator(
|
generator_m1 = Generator(
|
||||||
"my_shared_generator",
|
"my_shared_generator",
|
||||||
share=True,
|
share=True,
|
||||||
machine="machine1",
|
|
||||||
_flake=machine1.flake,
|
_flake=machine1.flake,
|
||||||
)
|
)
|
||||||
generator_m2 = Generator(
|
generator_m2 = Generator(
|
||||||
"my_shared_generator",
|
"my_shared_generator",
|
||||||
share=True,
|
share=True,
|
||||||
machine="machine2",
|
|
||||||
_flake=machine2.flake,
|
_flake=machine2.flake,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert m1_sops_store.exists(generator_m1, "my_shared_secret")
|
assert m1_sops_store.exists(generator_m1, "my_shared_secret")
|
||||||
assert m2_sops_store.exists(generator_m2, "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 m1_sops_store.machine_has_access(
|
||||||
assert m2_sops_store.machine_has_access(generator_m2, "my_shared_secret")
|
generator_m1, "my_shared_secret", "machine1"
|
||||||
|
)
|
||||||
|
assert m2_sops_store.machine_has_access(
|
||||||
|
generator_m2, "my_shared_secret", "machine2"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.with_core
|
@pytest.mark.with_core
|
||||||
@@ -499,6 +501,7 @@ def test_generate_secret_var_password_store(
|
|||||||
cli.run(["vars", "generate", "--flake", str(flake.path), "my_machine"])
|
cli.run(["vars", "generate", "--flake", str(flake.path), "my_machine"])
|
||||||
assert check_vars(machine.name, machine.flake)
|
assert check_vars(machine.name, machine.flake)
|
||||||
store = password_store.SecretStore(flake=flake_obj)
|
store = password_store.SecretStore(flake=flake_obj)
|
||||||
|
store.init_pass_command(machine="my_machine")
|
||||||
my_generator = Generator(
|
my_generator = Generator(
|
||||||
"my_generator",
|
"my_generator",
|
||||||
share=False,
|
share=False,
|
||||||
@@ -744,6 +747,74 @@ def test_shared_vars_must_never_depend_on_machine_specific_vars(
|
|||||||
cli.run(["vars", "generate", "--flake", str(flake.path), "my_machine"])
|
cli.run(["vars", "generate", "--flake", str(flake.path), "my_machine"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.with_core
|
||||||
|
def test_shared_vars_regeneration(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
flake_with_sops: ClanFlake,
|
||||||
|
) -> None:
|
||||||
|
"""Ensure that is a shared generator gets generated on one machine, dependents of that
|
||||||
|
shared generator on other machines get re-generated as well.
|
||||||
|
"""
|
||||||
|
flake = flake_with_sops
|
||||||
|
|
||||||
|
machine1_config = flake.machines["machine1"]
|
||||||
|
machine1_config["nixpkgs"]["hostPlatform"] = "x86_64-linux"
|
||||||
|
shared_generator = machine1_config["clan"]["core"]["vars"]["generators"][
|
||||||
|
"shared_generator"
|
||||||
|
]
|
||||||
|
shared_generator["share"] = True
|
||||||
|
shared_generator["files"]["my_value"]["secret"] = False
|
||||||
|
shared_generator["script"] = 'echo "$RANDOM" > "$out"/my_value'
|
||||||
|
child_generator = machine1_config["clan"]["core"]["vars"]["generators"][
|
||||||
|
"child_generator"
|
||||||
|
]
|
||||||
|
child_generator["share"] = False
|
||||||
|
child_generator["files"]["my_value"]["secret"] = False
|
||||||
|
child_generator["dependencies"] = ["shared_generator"]
|
||||||
|
child_generator["script"] = 'cat "$in"/shared_generator/my_value > "$out"/my_value'
|
||||||
|
# machine 2 is equivalent to machine 1
|
||||||
|
flake.machines["machine2"] = machine1_config
|
||||||
|
flake.refresh()
|
||||||
|
monkeypatch.chdir(flake.path)
|
||||||
|
machine1 = Machine(name="machine1", flake=Flake(str(flake.path)))
|
||||||
|
machine2 = Machine(name="machine2", flake=Flake(str(flake.path)))
|
||||||
|
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
|
||||||
|
child_gen_m1 = Generator(
|
||||||
|
"child_generator", share=False, machine="machine1", _flake=machine1.flake
|
||||||
|
)
|
||||||
|
child_gen_m2 = Generator(
|
||||||
|
"child_generator", share=False, machine="machine2", _flake=machine2.flake
|
||||||
|
)
|
||||||
|
# generate for machine 1
|
||||||
|
cli.run(["vars", "generate", "--flake", str(flake.path), "machine1"])
|
||||||
|
# generate for machine 2
|
||||||
|
cli.run(["vars", "generate", "--flake", str(flake.path), "machine2"])
|
||||||
|
# child value should be the same on both machines
|
||||||
|
assert in_repo_store_1.get(child_gen_m1, "my_value") == in_repo_store_2.get(
|
||||||
|
child_gen_m2, "my_value"
|
||||||
|
), "Child values should be the same after initial generation"
|
||||||
|
|
||||||
|
# regenerate on all machines
|
||||||
|
cli.run(
|
||||||
|
["vars", "generate", "--flake", str(flake.path), "--regenerate"],
|
||||||
|
)
|
||||||
|
# ensure child value after --regenerate is the same on both machines
|
||||||
|
assert in_repo_store_1.get(child_gen_m1, "my_value") == in_repo_store_2.get(
|
||||||
|
child_gen_m2, "my_value"
|
||||||
|
), "Child values should be the same after regenerating all machines"
|
||||||
|
|
||||||
|
# regenerate for machine 1
|
||||||
|
cli.run(
|
||||||
|
["vars", "generate", "--flake", str(flake.path), "machine1", "--regenerate"]
|
||||||
|
)
|
||||||
|
# ensure child value after --regenerate is the same on both machines
|
||||||
|
assert in_repo_store_1.get(child_gen_m1, "my_value") == in_repo_store_2.get(
|
||||||
|
child_gen_m2, "my_value"
|
||||||
|
), "Child values should be the same after regenerating machine1"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.with_core
|
@pytest.mark.with_core
|
||||||
def test_multi_machine_shared_vars(
|
def test_multi_machine_shared_vars(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
@@ -816,8 +887,8 @@ def test_multi_machine_shared_vars(
|
|||||||
assert new_value_1 != m1_value
|
assert new_value_1 != m1_value
|
||||||
# ensure that both machines still have access to the same secret
|
# ensure that both machines still have access to the same secret
|
||||||
assert new_secret_1 == new_secret_2
|
assert new_secret_1 == new_secret_2
|
||||||
assert sops_store_1.machine_has_access(generator_m1, "my_secret")
|
assert sops_store_1.machine_has_access(generator_m1, "my_secret", "machine1")
|
||||||
assert sops_store_2.machine_has_access(generator_m2, "my_secret")
|
assert sops_store_2.machine_has_access(generator_m2, "my_secret", "machine2")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.with_core
|
@pytest.mark.with_core
|
||||||
|
|||||||
@@ -42,11 +42,7 @@ class StoreBase(ABC):
|
|||||||
"""Get machine name from generator, asserting it's not None for now."""
|
"""Get machine name from generator, asserting it's not None for now."""
|
||||||
if generator.machine is None:
|
if generator.machine is None:
|
||||||
if generator.share:
|
if generator.share:
|
||||||
# Shared generators don't need a machine for most operations
|
return "__shared"
|
||||||
# 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"
|
msg = f"Generator '{generator.name}' has no machine associated"
|
||||||
raise ClanError(msg)
|
raise ClanError(msg)
|
||||||
return generator.machine
|
return generator.machine
|
||||||
@@ -62,6 +58,7 @@ class StoreBase(ABC):
|
|||||||
generator: "Generator",
|
generator: "Generator",
|
||||||
var: "Var",
|
var: "Var",
|
||||||
value: bytes,
|
value: bytes,
|
||||||
|
machine: str,
|
||||||
) -> Path | None:
|
) -> Path | None:
|
||||||
"""Override this method to implement the actual creation of the file"""
|
"""Override this method to implement the actual creation of the file"""
|
||||||
|
|
||||||
@@ -140,16 +137,20 @@ class StoreBase(ABC):
|
|||||||
generator: "Generator",
|
generator: "Generator",
|
||||||
var: "Var",
|
var: "Var",
|
||||||
value: bytes,
|
value: bytes,
|
||||||
|
machine: str,
|
||||||
is_migration: bool = False,
|
is_migration: bool = False,
|
||||||
) -> list[Path]:
|
) -> list[Path]:
|
||||||
changed_files: list[Path] = []
|
changed_files: list[Path] = []
|
||||||
|
|
||||||
# if generator was switched from shared to per-machine or vice versa,
|
# if generator was switched from shared to per-machine or vice versa,
|
||||||
# remove the old var first
|
# remove the old var first
|
||||||
if self.exists(
|
prev_generator = dataclasses.replace(
|
||||||
gen := dataclasses.replace(generator, share=not generator.share), var.name
|
generator,
|
||||||
):
|
share=not generator.share,
|
||||||
changed_files += self.delete(gen, var.name)
|
machine=machine if generator.share else None,
|
||||||
|
)
|
||||||
|
if self.exists(prev_generator, var.name):
|
||||||
|
changed_files += self.delete(prev_generator, var.name)
|
||||||
|
|
||||||
if self.exists(generator, var.name):
|
if self.exists(generator, var.name):
|
||||||
if self.is_secret_store:
|
if self.is_secret_store:
|
||||||
@@ -161,7 +162,7 @@ class StoreBase(ABC):
|
|||||||
else:
|
else:
|
||||||
old_val = None
|
old_val = None
|
||||||
old_val_str = "<not set>"
|
old_val_str = "<not set>"
|
||||||
new_file = self._set(generator, var, value)
|
new_file = self._set(generator, var, value, machine)
|
||||||
action_str = "Migrated" if is_migration else "Updated"
|
action_str = "Migrated" if is_migration else "Updated"
|
||||||
log_info: Callable
|
log_info: Callable
|
||||||
if generator.machine is None:
|
if generator.machine is None:
|
||||||
@@ -169,8 +170,8 @@ class StoreBase(ABC):
|
|||||||
else:
|
else:
|
||||||
from clan_lib.machines.machines import Machine # noqa: PLC0415
|
from clan_lib.machines.machines import Machine # noqa: PLC0415
|
||||||
|
|
||||||
machine = Machine(name=generator.machine, flake=self.flake)
|
machine_obj = Machine(name=generator.machine, flake=self.flake)
|
||||||
log_info = machine.info
|
log_info = machine_obj.info
|
||||||
if self.is_secret_store:
|
if self.is_secret_store:
|
||||||
log.info(f"{action_str} secret var {generator.name}/{var.name}\n")
|
log.info(f"{action_str} secret var {generator.name}/{var.name}\n")
|
||||||
elif value != old_val:
|
elif value != old_val:
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
|
from collections.abc import Iterable
|
||||||
from contextlib import ExitStack
|
from contextlib import ExitStack
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from functools import cached_property
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
@@ -15,7 +15,6 @@ from clan_lib.errors import ClanError
|
|||||||
from clan_lib.git import commit_files
|
from clan_lib.git import commit_files
|
||||||
from clan_lib.nix import nix_config, nix_shell, nix_test_store
|
from clan_lib.nix import nix_config, nix_shell, nix_test_store
|
||||||
|
|
||||||
from .check import check_vars
|
|
||||||
from .prompt import Prompt, ask
|
from .prompt import Prompt, ask
|
||||||
from .var import Var
|
from .var import Var
|
||||||
|
|
||||||
@@ -60,9 +59,12 @@ class Generator:
|
|||||||
dependencies: list[GeneratorKey] = field(default_factory=list)
|
dependencies: list[GeneratorKey] = field(default_factory=list)
|
||||||
|
|
||||||
migrate_fact: str | None = None
|
migrate_fact: str | None = None
|
||||||
|
validation_hash: str | None = None
|
||||||
|
|
||||||
machine: str | None = None
|
machine: str | None = None
|
||||||
_flake: "Flake | None" = None
|
_flake: "Flake | None" = None
|
||||||
|
_public_store: "StoreBase | None" = None
|
||||||
|
_secret_store: "StoreBase | None" = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def key(self) -> GeneratorKey:
|
def key(self) -> GeneratorKey:
|
||||||
@@ -71,20 +73,28 @@ class Generator:
|
|||||||
def __hash__(self) -> int:
|
def __hash__(self) -> int:
|
||||||
return hash(self.key)
|
return hash(self.key)
|
||||||
|
|
||||||
@cached_property
|
@property
|
||||||
def exists(self) -> bool:
|
def exists(self) -> bool:
|
||||||
if self.machine is None:
|
"""Check if all files for this generator exist in their respective stores."""
|
||||||
msg = "Machine cannot be None"
|
if self._public_store is None or self._secret_store is None:
|
||||||
|
msg = "Stores must be set to check existence"
|
||||||
raise ClanError(msg)
|
raise ClanError(msg)
|
||||||
if self._flake is None:
|
|
||||||
msg = "Flake cannot be None"
|
# Check if all files exist
|
||||||
raise ClanError(msg)
|
for file in self.files:
|
||||||
return check_vars(self.machine, self._flake, generator_name=self.name)
|
store = self._secret_store if file.secret else self._public_store
|
||||||
|
if not store.exists(self, file.name):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Also check if validation hashes are up to date
|
||||||
|
return self._secret_store.hash_is_valid(
|
||||||
|
self
|
||||||
|
) and self._public_store.hash_is_valid(self)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_machine_generators(
|
def get_machine_generators(
|
||||||
cls: type["Generator"],
|
cls: type["Generator"],
|
||||||
machine_names: list[str],
|
machine_names: Iterable[str],
|
||||||
flake: "Flake",
|
flake: "Flake",
|
||||||
include_previous_values: bool = False,
|
include_previous_values: bool = False,
|
||||||
) -> list["Generator"]:
|
) -> list["Generator"]:
|
||||||
@@ -102,7 +112,7 @@ class Generator:
|
|||||||
config = nix_config()
|
config = nix_config()
|
||||||
system = config["system"]
|
system = config["system"]
|
||||||
|
|
||||||
generators_selector = "config.clan.core.vars.generators.*.{share,dependencies,migrateFact,prompts}"
|
generators_selector = "config.clan.core.vars.generators.*.{share,dependencies,migrateFact,prompts,validationHash}"
|
||||||
files_selector = "config.clan.core.vars.generators.*.files.*.{secret,deploy,owner,group,mode,neededFor}"
|
files_selector = "config.clan.core.vars.generators.*.files.*.{secret,deploy,owner,group,mode,neededFor}"
|
||||||
|
|
||||||
# precache all machines generators and files to avoid multiple calls to nix
|
# precache all machines generators and files to avoid multiple calls to nix
|
||||||
@@ -123,7 +133,7 @@ class Generator:
|
|||||||
generators_selector,
|
generators_selector,
|
||||||
)
|
)
|
||||||
if not generators_data:
|
if not generators_data:
|
||||||
return []
|
continue
|
||||||
|
|
||||||
# Get all file metadata in one select
|
# Get all file metadata in one select
|
||||||
files_data = flake.select_machine(
|
files_data = flake.select_machine(
|
||||||
@@ -162,18 +172,30 @@ class Generator:
|
|||||||
Prompt.from_nix(p) for p in gen_data.get("prompts", {}).values()
|
Prompt.from_nix(p) for p in gen_data.get("prompts", {}).values()
|
||||||
]
|
]
|
||||||
|
|
||||||
|
share = gen_data["share"]
|
||||||
|
|
||||||
generator = cls(
|
generator = cls(
|
||||||
name=gen_name,
|
name=gen_name,
|
||||||
share=gen_data["share"],
|
share=share,
|
||||||
files=files,
|
files=files,
|
||||||
dependencies=[
|
dependencies=[
|
||||||
GeneratorKey(machine=machine_name, name=dep)
|
GeneratorKey(
|
||||||
|
machine=None
|
||||||
|
if generators_data[dep]["share"]
|
||||||
|
else machine_name,
|
||||||
|
name=dep,
|
||||||
|
)
|
||||||
for dep in gen_data["dependencies"]
|
for dep in gen_data["dependencies"]
|
||||||
],
|
],
|
||||||
migrate_fact=gen_data.get("migrateFact"),
|
migrate_fact=gen_data.get("migrateFact"),
|
||||||
|
validation_hash=gen_data.get("validationHash"),
|
||||||
prompts=prompts,
|
prompts=prompts,
|
||||||
machine=machine_name,
|
# only set machine for machine-specific generators
|
||||||
|
# this is essential for the graph algorithms to work correctly
|
||||||
|
machine=None if share else machine_name,
|
||||||
_flake=flake,
|
_flake=flake,
|
||||||
|
_public_store=pub_store,
|
||||||
|
_secret_store=sec_store,
|
||||||
)
|
)
|
||||||
generators.append(generator)
|
generators.append(generator)
|
||||||
|
|
||||||
@@ -204,14 +226,10 @@ class Generator:
|
|||||||
return sec_store.get(self, prompt.name).decode()
|
return sec_store.get(self, prompt.name).decode()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def final_script(self) -> Path:
|
def final_script(self, machine: "Machine") -> Path:
|
||||||
if self.machine is None:
|
|
||||||
msg = "Machine cannot be None"
|
|
||||||
raise ClanError(msg)
|
|
||||||
if self._flake is None:
|
if self._flake is None:
|
||||||
msg = "Flake cannot be None"
|
msg = "Flake cannot be None"
|
||||||
raise ClanError(msg)
|
raise ClanError(msg)
|
||||||
machine = Machine(name=self.machine, flake=self._flake)
|
|
||||||
output = Path(
|
output = Path(
|
||||||
machine.select(
|
machine.select(
|
||||||
f'config.clan.core.vars.generators."{self.name}".finalScript',
|
f'config.clan.core.vars.generators."{self.name}".finalScript',
|
||||||
@@ -222,16 +240,7 @@ class Generator:
|
|||||||
return output
|
return output
|
||||||
|
|
||||||
def validation(self) -> str | None:
|
def validation(self) -> str | None:
|
||||||
if self.machine is None:
|
return self.validation_hash
|
||||||
msg = "Machine cannot be None"
|
|
||||||
raise ClanError(msg)
|
|
||||||
if self._flake is None:
|
|
||||||
msg = "Flake cannot be None"
|
|
||||||
raise ClanError(msg)
|
|
||||||
machine = Machine(name=self.machine, flake=self._flake)
|
|
||||||
return machine.select(
|
|
||||||
f'config.clan.core.vars.generators."{self.name}".validationHash',
|
|
||||||
)
|
|
||||||
|
|
||||||
def decrypt_dependencies(
|
def decrypt_dependencies(
|
||||||
self,
|
self,
|
||||||
@@ -254,11 +263,6 @@ class Generator:
|
|||||||
result: dict[str, dict[str, bytes]] = {}
|
result: dict[str, dict[str, bytes]] = {}
|
||||||
|
|
||||||
for dep_key in set(self.dependencies):
|
for dep_key in set(self.dependencies):
|
||||||
# For now, we only support dependencies from the same machine
|
|
||||||
if dep_key.machine != machine.name:
|
|
||||||
msg = f"Cross-machine dependencies are not supported. Generator {self.name} depends on {dep_key.name} from machine {dep_key.machine}"
|
|
||||||
raise ClanError(msg)
|
|
||||||
|
|
||||||
result[dep_key.name] = {}
|
result[dep_key.name] = {}
|
||||||
|
|
||||||
dep_generator = next(
|
dep_generator = next(
|
||||||
@@ -390,7 +394,7 @@ class Generator:
|
|||||||
value = get_prompt_value(prompt.name)
|
value = get_prompt_value(prompt.name)
|
||||||
prompt_file.write_text(value)
|
prompt_file.write_text(value)
|
||||||
|
|
||||||
final_script = self.final_script()
|
final_script = self.final_script(machine)
|
||||||
|
|
||||||
if sys.platform == "linux" and bwrap.bubblewrap_works():
|
if sys.platform == "linux" and bwrap.bubblewrap_works():
|
||||||
cmd = bubblewrap_cmd(str(final_script), tmpdir)
|
cmd = bubblewrap_cmd(str(final_script), tmpdir)
|
||||||
@@ -430,6 +434,7 @@ class Generator:
|
|||||||
self,
|
self,
|
||||||
file,
|
file,
|
||||||
secret_file.read_bytes(),
|
secret_file.read_bytes(),
|
||||||
|
machine.name,
|
||||||
)
|
)
|
||||||
secret_changed = True
|
secret_changed = True
|
||||||
else:
|
else:
|
||||||
@@ -437,6 +442,7 @@ class Generator:
|
|||||||
self,
|
self,
|
||||||
file,
|
file,
|
||||||
secret_file.read_bytes(),
|
secret_file.read_bytes(),
|
||||||
|
machine.name,
|
||||||
)
|
)
|
||||||
public_changed = True
|
public_changed = True
|
||||||
files_to_commit.extend(file_paths)
|
files_to_commit.extend(file_paths)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
from clan_cli.vars.generator import (
|
from clan_cli.vars.generator import (
|
||||||
Generator,
|
Generator,
|
||||||
GeneratorKey,
|
GeneratorKey,
|
||||||
@@ -9,30 +11,69 @@ def generator_names(generator: list[Generator]) -> list[str]:
|
|||||||
return [gen.name for gen in generator]
|
return [gen.name for gen in generator]
|
||||||
|
|
||||||
|
|
||||||
|
def generator_keys(generator: list[Generator]) -> set[GeneratorKey]:
|
||||||
|
return {gen.key for gen in generator}
|
||||||
|
|
||||||
|
|
||||||
|
def create_mock_stores(exists_map: dict[str, bool]) -> tuple[Mock, Mock]:
|
||||||
|
"""Create mock public and secret stores with specified existence mapping."""
|
||||||
|
public_store = Mock()
|
||||||
|
secret_store = Mock()
|
||||||
|
|
||||||
|
def mock_exists(generator: Generator, _file_name: str) -> bool:
|
||||||
|
return exists_map.get(generator.name, False)
|
||||||
|
|
||||||
|
def mock_hash_valid(generator: Generator) -> bool:
|
||||||
|
return exists_map.get(generator.name, False)
|
||||||
|
|
||||||
|
public_store.exists.side_effect = mock_exists
|
||||||
|
secret_store.exists.side_effect = mock_exists
|
||||||
|
public_store.hash_is_valid.side_effect = mock_hash_valid
|
||||||
|
secret_store.hash_is_valid.side_effect = mock_hash_valid
|
||||||
|
|
||||||
|
return public_store, secret_store
|
||||||
|
|
||||||
|
|
||||||
def test_required_generators() -> None:
|
def test_required_generators() -> None:
|
||||||
|
# Create mock stores
|
||||||
|
exists_map = {
|
||||||
|
"gen_1": True,
|
||||||
|
"gen_2": False,
|
||||||
|
"gen_2a": False,
|
||||||
|
"gen_2b": True,
|
||||||
|
}
|
||||||
|
public_store, secret_store = create_mock_stores(exists_map)
|
||||||
|
|
||||||
# Create generators with proper machine context
|
# Create generators with proper machine context
|
||||||
machine_name = "test_machine"
|
machine_name = "test_machine"
|
||||||
gen_1 = Generator(name="gen_1", dependencies=[], machine=machine_name)
|
gen_1 = Generator(
|
||||||
|
name="gen_1",
|
||||||
|
dependencies=[],
|
||||||
|
machine=machine_name,
|
||||||
|
_public_store=public_store,
|
||||||
|
_secret_store=secret_store,
|
||||||
|
)
|
||||||
gen_2 = Generator(
|
gen_2 = Generator(
|
||||||
name="gen_2",
|
name="gen_2",
|
||||||
dependencies=[gen_1.key],
|
dependencies=[gen_1.key],
|
||||||
machine=machine_name,
|
machine=machine_name,
|
||||||
|
_public_store=public_store,
|
||||||
|
_secret_store=secret_store,
|
||||||
)
|
)
|
||||||
gen_2a = Generator(
|
gen_2a = Generator(
|
||||||
name="gen_2a",
|
name="gen_2a",
|
||||||
dependencies=[gen_2.key],
|
dependencies=[gen_2.key],
|
||||||
machine=machine_name,
|
machine=machine_name,
|
||||||
|
_public_store=public_store,
|
||||||
|
_secret_store=secret_store,
|
||||||
)
|
)
|
||||||
gen_2b = Generator(
|
gen_2b = Generator(
|
||||||
name="gen_2b",
|
name="gen_2b",
|
||||||
dependencies=[gen_2.key],
|
dependencies=[gen_2.key],
|
||||||
machine=machine_name,
|
machine=machine_name,
|
||||||
|
_public_store=public_store,
|
||||||
|
_secret_store=secret_store,
|
||||||
)
|
)
|
||||||
|
|
||||||
gen_1.exists = True
|
|
||||||
gen_2.exists = False
|
|
||||||
gen_2a.exists = False
|
|
||||||
gen_2b.exists = True
|
|
||||||
generators: dict[GeneratorKey, Generator] = {
|
generators: dict[GeneratorKey, Generator] = {
|
||||||
generator.key: generator for generator in [gen_1, gen_2, gen_2a, gen_2b]
|
generator.key: generator for generator in [gen_1, gen_2, gen_2a, gen_2b]
|
||||||
}
|
}
|
||||||
@@ -67,6 +108,10 @@ def test_required_generators() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_shared_generator_invalidates_multiple_machines_dependents() -> None:
|
def test_shared_generator_invalidates_multiple_machines_dependents() -> None:
|
||||||
|
# Create mock stores
|
||||||
|
exists_map = {"shared_gen": False, "gen_1": True, "gen_2": True}
|
||||||
|
public_store, secret_store = create_mock_stores(exists_map)
|
||||||
|
|
||||||
# Create generators with proper machine context
|
# Create generators with proper machine context
|
||||||
machine_1 = "machine_1"
|
machine_1 = "machine_1"
|
||||||
machine_2 = "machine_2"
|
machine_2 = "machine_2"
|
||||||
@@ -74,35 +119,37 @@ def test_shared_generator_invalidates_multiple_machines_dependents() -> None:
|
|||||||
name="shared_gen",
|
name="shared_gen",
|
||||||
dependencies=[],
|
dependencies=[],
|
||||||
machine=None, # Shared generator
|
machine=None, # Shared generator
|
||||||
|
_public_store=public_store,
|
||||||
|
_secret_store=secret_store,
|
||||||
)
|
)
|
||||||
gen_1 = Generator(
|
gen_1 = Generator(
|
||||||
name="gen_1",
|
name="gen_1",
|
||||||
dependencies=[shared_gen.key],
|
dependencies=[shared_gen.key],
|
||||||
machine=machine_1,
|
machine=machine_1,
|
||||||
|
_public_store=public_store,
|
||||||
|
_secret_store=secret_store,
|
||||||
)
|
)
|
||||||
gen_2 = Generator(
|
gen_2 = Generator(
|
||||||
name="gen_2",
|
name="gen_2",
|
||||||
dependencies=[shared_gen.key],
|
dependencies=[shared_gen.key],
|
||||||
machine=machine_2,
|
machine=machine_2,
|
||||||
|
_public_store=public_store,
|
||||||
|
_secret_store=secret_store,
|
||||||
)
|
)
|
||||||
|
|
||||||
shared_gen.exists = False
|
|
||||||
gen_1.exists = True
|
|
||||||
gen_2.exists = True
|
|
||||||
generators: dict[GeneratorKey, Generator] = {
|
generators: dict[GeneratorKey, Generator] = {
|
||||||
generator.key: generator for generator in [shared_gen, gen_1, gen_2]
|
generator.key: generator for generator in [shared_gen, gen_1, gen_2]
|
||||||
}
|
}
|
||||||
|
|
||||||
assert generator_names(all_missing_closure(generators.keys(), generators)) == [
|
assert generator_keys(all_missing_closure(generators.keys(), generators)) == {
|
||||||
"shared_gen",
|
GeneratorKey(name="shared_gen", machine=None),
|
||||||
"gen_1",
|
GeneratorKey(name="gen_1", machine=machine_1),
|
||||||
"gen_2",
|
GeneratorKey(name="gen_2", machine=machine_2),
|
||||||
], (
|
}, (
|
||||||
"All generators should be included in all_missing_closure due to shared dependency"
|
"All generators should be included in all_missing_closure due to shared dependency"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert generator_names(requested_closure([shared_gen.key], generators)) == [
|
assert generator_keys(requested_closure([shared_gen.key], generators)) == {
|
||||||
"shared_gen",
|
GeneratorKey(name="shared_gen", machine=None),
|
||||||
"gen_1",
|
GeneratorKey(name="gen_1", machine=machine_1),
|
||||||
"gen_2",
|
GeneratorKey(name="gen_2", machine=machine_2),
|
||||||
], "All generators should be included in requested_closure due to shared dependency"
|
}, "All generators should be included in requested_closure due to shared dependency"
|
||||||
|
|||||||
@@ -59,13 +59,13 @@ def _migrate_file(
|
|||||||
if file.secret:
|
if file.secret:
|
||||||
old_value = machine.secret_facts_store.get(service_name, fact_name)
|
old_value = machine.secret_facts_store.get(service_name, fact_name)
|
||||||
paths_list = machine.secret_vars_store.set(
|
paths_list = machine.secret_vars_store.set(
|
||||||
generator, file, old_value, is_migration=True
|
generator, file, old_value, machine.name, is_migration=True
|
||||||
)
|
)
|
||||||
paths.extend(paths_list)
|
paths.extend(paths_list)
|
||||||
else:
|
else:
|
||||||
old_value = machine.public_facts_store.get(service_name, fact_name)
|
old_value = machine.public_facts_store.get(service_name, fact_name)
|
||||||
paths_list = machine.public_vars_store.set(
|
paths_list = machine.public_vars_store.set(
|
||||||
generator, file, old_value, is_migration=True
|
generator, file, old_value, machine.name, is_migration=True
|
||||||
)
|
)
|
||||||
paths.extend(paths_list)
|
paths.extend(paths_list)
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class FactStore(StoreBase):
|
|||||||
generator: Generator,
|
generator: Generator,
|
||||||
var: Var,
|
var: Var,
|
||||||
value: bytes,
|
value: bytes,
|
||||||
|
machine: str, # noqa: ARG002
|
||||||
) -> Path | None:
|
) -> Path | None:
|
||||||
if not self.flake.is_local:
|
if not self.flake.is_local:
|
||||||
msg = f"Storing var '{var.id}' in a flake is only supported for local flakes: {self.flake}"
|
msg = f"Storing var '{var.id}' in a flake is only supported for local flakes: {self.flake}"
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ class FactStore(StoreBase):
|
|||||||
generator: Generator,
|
generator: Generator,
|
||||||
var: Var,
|
var: Var,
|
||||||
value: bytes,
|
value: bytes,
|
||||||
|
machine: str,
|
||||||
) -> Path | None:
|
) -> Path | None:
|
||||||
machine = self.get_machine(generator)
|
|
||||||
fact_path = self.get_dir(machine) / generator.name / var.name
|
fact_path = self.get_dir(machine) / generator.name / var.name
|
||||||
fact_path.parent.mkdir(parents=True, exist_ok=True)
|
fact_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
fact_path.write_bytes(value)
|
fact_path.write_bytes(value)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class SecretStore(StoreBase):
|
|||||||
generator: Generator,
|
generator: Generator,
|
||||||
var: Var,
|
var: Var,
|
||||||
value: bytes,
|
value: bytes,
|
||||||
|
machine: str, # noqa: ARG002
|
||||||
) -> Path | None:
|
) -> Path | None:
|
||||||
secret_file = self.dir / generator.name / var.name
|
secret_file = self.dir / generator.name / var.name
|
||||||
secret_file.parent.mkdir(parents=True, exist_ok=True)
|
secret_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|||||||
@@ -21,20 +21,20 @@ class SecretStore(StoreBase):
|
|||||||
def is_secret_store(self) -> bool:
|
def is_secret_store(self) -> bool:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def __init__(self, flake: Flake) -> None:
|
def __init__(self, flake: Flake, pass_cmd: str | None = None) -> None:
|
||||||
super().__init__(flake)
|
super().__init__(flake)
|
||||||
self.entry_prefix = "clan-vars"
|
self.entry_prefix = "clan-vars"
|
||||||
self._store_dir: Path | None = None
|
self._store_dir: Path | None = None
|
||||||
|
self._pass_cmd = pass_cmd
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def store_name(self) -> str:
|
def store_name(self) -> str:
|
||||||
return "password_store"
|
return "password_store"
|
||||||
|
|
||||||
def store_dir(self, machine: str) -> Path:
|
def store_dir(self) -> Path:
|
||||||
"""Get the password store directory, cached per machine."""
|
"""Get the password store directory, cached per machine."""
|
||||||
if not self._store_dir:
|
if not self._store_dir:
|
||||||
result = self._run_pass(
|
result = self._run_pass(
|
||||||
machine,
|
|
||||||
"git",
|
"git",
|
||||||
"rev-parse",
|
"rev-parse",
|
||||||
"--show-toplevel",
|
"--show-toplevel",
|
||||||
@@ -46,7 +46,8 @@ class SecretStore(StoreBase):
|
|||||||
self._store_dir = Path(result.stdout.strip().decode())
|
self._store_dir = Path(result.stdout.strip().decode())
|
||||||
return self._store_dir
|
return self._store_dir
|
||||||
|
|
||||||
def _pass_command(self, machine: str) -> str:
|
def init_pass_command(self, machine: str) -> None:
|
||||||
|
"""Initialize the password store command based on the machine's configuration."""
|
||||||
out_path = self.flake.select_machine(
|
out_path = self.flake.select_machine(
|
||||||
machine,
|
machine,
|
||||||
"config.clan.core.vars.password-store.passPackage.outPath",
|
"config.clan.core.vars.password-store.passPackage.outPath",
|
||||||
@@ -63,7 +64,8 @@ class SecretStore(StoreBase):
|
|||||||
if main_program:
|
if main_program:
|
||||||
binary_path = Path(out_path) / "bin" / main_program
|
binary_path = Path(out_path) / "bin" / main_program
|
||||||
if binary_path.exists():
|
if binary_path.exists():
|
||||||
return str(binary_path)
|
self._pass_cmd = str(binary_path)
|
||||||
|
return
|
||||||
|
|
||||||
# Look for common password store binaries
|
# Look for common password store binaries
|
||||||
bin_dir = Path(out_path) / "bin"
|
bin_dir = Path(out_path) / "bin"
|
||||||
@@ -71,27 +73,34 @@ class SecretStore(StoreBase):
|
|||||||
for binary in ["pass", "passage"]:
|
for binary in ["pass", "passage"]:
|
||||||
binary_path = bin_dir / binary
|
binary_path = bin_dir / binary
|
||||||
if binary_path.exists():
|
if binary_path.exists():
|
||||||
return str(binary_path)
|
self._pass_cmd = str(binary_path)
|
||||||
|
return
|
||||||
|
|
||||||
# If only one binary exists, use it
|
# If only one binary exists, use it
|
||||||
binaries = [f for f in bin_dir.iterdir() if f.is_file()]
|
binaries = [f for f in bin_dir.iterdir() if f.is_file()]
|
||||||
if len(binaries) == 1:
|
if len(binaries) == 1:
|
||||||
return str(binaries[0])
|
self._pass_cmd = str(binaries[0])
|
||||||
|
return
|
||||||
|
|
||||||
msg = "Could not find password store binary in package"
|
msg = "Could not find password store binary in package"
|
||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
def _pass_command(self) -> str:
|
||||||
|
if not self._pass_cmd:
|
||||||
|
msg = "Password store command not initialized. This should be set during SecretStore initialization."
|
||||||
|
raise ValueError(msg)
|
||||||
|
return self._pass_cmd
|
||||||
|
|
||||||
def entry_dir(self, generator: Generator, name: str) -> Path:
|
def entry_dir(self, generator: Generator, name: str) -> Path:
|
||||||
return Path(self.entry_prefix) / self.rel_dir(generator, name)
|
return Path(self.entry_prefix) / self.rel_dir(generator, name)
|
||||||
|
|
||||||
def _run_pass(
|
def _run_pass(
|
||||||
self,
|
self,
|
||||||
machine: str,
|
|
||||||
*args: str,
|
*args: str,
|
||||||
input: bytes | None = None, # noqa: A002
|
input: bytes | None = None, # noqa: A002
|
||||||
check: bool = True,
|
check: bool = True,
|
||||||
) -> subprocess.CompletedProcess[bytes]:
|
) -> subprocess.CompletedProcess[bytes]:
|
||||||
cmd = [self._pass_command(machine), *args]
|
cmd = [self._pass_command(), *args]
|
||||||
# We need bytes support here, so we can not use clan cmd.
|
# We need bytes support here, so we can not use clan cmd.
|
||||||
# If you change this to run( add bytes support to it first!
|
# If you change this to run( add bytes support to it first!
|
||||||
# otherwise we mangle binary secrets (which is annoying to debug)
|
# otherwise we mangle binary secrets (which is annoying to debug)
|
||||||
@@ -107,39 +116,35 @@ class SecretStore(StoreBase):
|
|||||||
generator: Generator,
|
generator: Generator,
|
||||||
var: Var,
|
var: Var,
|
||||||
value: bytes,
|
value: bytes,
|
||||||
|
machine: str, # noqa: ARG002
|
||||||
) -> Path | None:
|
) -> Path | None:
|
||||||
machine = self.get_machine(generator)
|
|
||||||
pass_call = ["insert", "-m", str(self.entry_dir(generator, var.name))]
|
pass_call = ["insert", "-m", str(self.entry_dir(generator, var.name))]
|
||||||
self._run_pass(machine, *pass_call, input=value, check=True)
|
self._run_pass(*pass_call, input=value, check=True)
|
||||||
return None # we manage the files outside of the git repo
|
return None # we manage the files outside of the git repo
|
||||||
|
|
||||||
def get(self, generator: Generator, name: str) -> bytes:
|
def get(self, generator: Generator, name: str) -> bytes:
|
||||||
machine = self.get_machine(generator)
|
|
||||||
pass_name = str(self.entry_dir(generator, name))
|
pass_name = str(self.entry_dir(generator, name))
|
||||||
return self._run_pass(machine, "show", pass_name).stdout
|
return self._run_pass("show", pass_name).stdout
|
||||||
|
|
||||||
def exists(self, generator: Generator, name: str) -> bool:
|
def exists(self, generator: Generator, name: str) -> bool:
|
||||||
machine = self.get_machine(generator)
|
|
||||||
pass_name = str(self.entry_dir(generator, name))
|
pass_name = str(self.entry_dir(generator, name))
|
||||||
# Check if the file exists with either .age or .gpg extension
|
# Check if the file exists with either .age or .gpg extension
|
||||||
store_dir = self.store_dir(machine)
|
store_dir = self.store_dir()
|
||||||
age_file = store_dir / f"{pass_name}.age"
|
age_file = store_dir / f"{pass_name}.age"
|
||||||
gpg_file = store_dir / f"{pass_name}.gpg"
|
gpg_file = store_dir / f"{pass_name}.gpg"
|
||||||
return age_file.exists() or gpg_file.exists()
|
return age_file.exists() or gpg_file.exists()
|
||||||
|
|
||||||
def delete(self, generator: Generator, name: str) -> Iterable[Path]:
|
def delete(self, generator: Generator, name: str) -> Iterable[Path]:
|
||||||
machine = self.get_machine(generator)
|
|
||||||
pass_name = str(self.entry_dir(generator, name))
|
pass_name = str(self.entry_dir(generator, name))
|
||||||
self._run_pass(machine, "rm", "--force", pass_name, check=True)
|
self._run_pass("rm", "--force", pass_name, check=True)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def delete_store(self, machine: str) -> Iterable[Path]:
|
def delete_store(self, machine: str) -> Iterable[Path]:
|
||||||
machine_dir = Path(self.entry_prefix) / "per-machine" / machine
|
machine_dir = Path(self.entry_prefix) / "per-machine" / machine
|
||||||
# Check if the directory exists in the password store before trying to delete
|
# Check if the directory exists in the password store before trying to delete
|
||||||
result = self._run_pass(machine, "ls", str(machine_dir), check=False)
|
result = self._run_pass("ls", str(machine_dir), check=False)
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
self._run_pass(
|
self._run_pass(
|
||||||
machine,
|
|
||||||
"rm",
|
"rm",
|
||||||
"--force",
|
"--force",
|
||||||
"--recursive",
|
"--recursive",
|
||||||
@@ -150,7 +155,6 @@ class SecretStore(StoreBase):
|
|||||||
|
|
||||||
def generate_hash(self, machine: str) -> bytes:
|
def generate_hash(self, machine: str) -> bytes:
|
||||||
result = self._run_pass(
|
result = self._run_pass(
|
||||||
machine,
|
|
||||||
"git",
|
"git",
|
||||||
"log",
|
"log",
|
||||||
"-1",
|
"-1",
|
||||||
|
|||||||
@@ -95,8 +95,9 @@ class SecretStore(StoreBase):
|
|||||||
key_dir = sops_users_folder(self.flake.path) / user
|
key_dir = sops_users_folder(self.flake.path) / user
|
||||||
return self.key_has_access(key_dir, generator, secret_name)
|
return self.key_has_access(key_dir, generator, secret_name)
|
||||||
|
|
||||||
def machine_has_access(self, generator: Generator, secret_name: str) -> bool:
|
def machine_has_access(
|
||||||
machine = self.get_machine(generator)
|
self, generator: Generator, secret_name: str, machine: str
|
||||||
|
) -> bool:
|
||||||
self.ensure_machine_key(machine)
|
self.ensure_machine_key(machine)
|
||||||
key_dir = sops_machines_folder(self.flake.path) / machine
|
key_dir = sops_machines_folder(self.flake.path) / machine
|
||||||
return self.key_has_access(key_dir, generator, secret_name)
|
return self.key_has_access(key_dir, generator, secret_name)
|
||||||
@@ -156,8 +157,8 @@ class SecretStore(StoreBase):
|
|||||||
continue
|
continue
|
||||||
if file.secret and self.exists(generator, file.name):
|
if file.secret and self.exists(generator, file.name):
|
||||||
if file.deploy:
|
if file.deploy:
|
||||||
self.ensure_machine_has_access(generator, file.name)
|
self.ensure_machine_has_access(generator, file.name, machine)
|
||||||
needs_update, msg = self.needs_fix(generator, file.name)
|
needs_update, msg = self.needs_fix(generator, file.name, machine)
|
||||||
if needs_update:
|
if needs_update:
|
||||||
outdated.append((generator.name, file.name, msg))
|
outdated.append((generator.name, file.name, msg))
|
||||||
if file_name and not file_found:
|
if file_name and not file_found:
|
||||||
@@ -177,8 +178,8 @@ class SecretStore(StoreBase):
|
|||||||
generator: Generator,
|
generator: Generator,
|
||||||
var: Var,
|
var: Var,
|
||||||
value: bytes,
|
value: bytes,
|
||||||
|
machine: str,
|
||||||
) -> Path | None:
|
) -> Path | None:
|
||||||
machine = self.get_machine(generator)
|
|
||||||
self.ensure_machine_key(machine)
|
self.ensure_machine_key(machine)
|
||||||
secret_folder = self.secret_path(generator, var.name)
|
secret_folder = self.secret_path(generator, var.name)
|
||||||
# create directory if it doesn't exist
|
# create directory if it doesn't exist
|
||||||
@@ -277,9 +278,10 @@ class SecretStore(StoreBase):
|
|||||||
secret_folder = self.secret_path(generator, name)
|
secret_folder = self.secret_path(generator, name)
|
||||||
return (secret_folder / "secret").exists()
|
return (secret_folder / "secret").exists()
|
||||||
|
|
||||||
def ensure_machine_has_access(self, generator: Generator, name: str) -> None:
|
def ensure_machine_has_access(
|
||||||
machine = self.get_machine(generator)
|
self, generator: Generator, name: str, machine: str
|
||||||
if self.machine_has_access(generator, name):
|
) -> None:
|
||||||
|
if self.machine_has_access(generator, name, machine):
|
||||||
return
|
return
|
||||||
secret_folder = self.secret_path(generator, name)
|
secret_folder = self.secret_path(generator, name)
|
||||||
add_secret(
|
add_secret(
|
||||||
@@ -313,8 +315,9 @@ class SecretStore(StoreBase):
|
|||||||
|
|
||||||
return keys
|
return keys
|
||||||
|
|
||||||
def needs_fix(self, generator: Generator, name: str) -> tuple[bool, str | None]:
|
def needs_fix(
|
||||||
machine = self.get_machine(generator)
|
self, generator: Generator, name: str, machine: str
|
||||||
|
) -> tuple[bool, str | None]:
|
||||||
secret_path = self.secret_path(generator, name)
|
secret_path = self.secret_path(generator, name)
|
||||||
current_recipients = sops.get_recipients(secret_path)
|
current_recipients = sops.get_recipients(secret_path)
|
||||||
wanted_recipients = self.collect_keys_for_secret(machine, secret_path)
|
wanted_recipients = self.collect_keys_for_secret(machine, secret_path)
|
||||||
@@ -373,9 +376,8 @@ class SecretStore(StoreBase):
|
|||||||
|
|
||||||
age_plugins = load_age_plugins(self.flake)
|
age_plugins = load_age_plugins(self.flake)
|
||||||
|
|
||||||
gen_machine = self.get_machine(generator)
|
|
||||||
for group in self.flake.select_machine(
|
for group in self.flake.select_machine(
|
||||||
gen_machine,
|
machine,
|
||||||
"config.clan.core.sops.defaultGroups",
|
"config.clan.core.sops.defaultGroups",
|
||||||
):
|
):
|
||||||
allow_member(
|
allow_member(
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ class SecretStore(StoreBase):
|
|||||||
generator: Generator,
|
generator: Generator,
|
||||||
var: Var,
|
var: Var,
|
||||||
value: bytes,
|
value: bytes,
|
||||||
|
machine: str,
|
||||||
) -> Path | None:
|
) -> Path | None:
|
||||||
machine = self.get_machine(generator)
|
|
||||||
secret_file = self.get_dir(machine) / generator.name / var.name
|
secret_file = self.get_dir(machine) / generator.name / var.name
|
||||||
secret_file.parent.mkdir(parents=True, exist_ok=True)
|
secret_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
secret_file.write_bytes(value)
|
secret_file.write_bytes(value)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ def set_var(machine: str | Machine, var: str | Var, value: bytes, flake: Flake)
|
|||||||
else:
|
else:
|
||||||
_machine = machine
|
_machine = machine
|
||||||
_var = get_machine_var(_machine, var) if isinstance(var, str) else var
|
_var = get_machine_var(_machine, var) if isinstance(var, str) else var
|
||||||
paths = _var.set(value)
|
paths = _var.set(value, _machine.name)
|
||||||
if paths:
|
if paths:
|
||||||
commit_files(
|
commit_files(
|
||||||
paths,
|
paths,
|
||||||
|
|||||||
@@ -52,14 +52,14 @@ class Var:
|
|||||||
except UnicodeDecodeError:
|
except UnicodeDecodeError:
|
||||||
return "<binary blob>"
|
return "<binary blob>"
|
||||||
|
|
||||||
def set(self, value: bytes) -> list[Path]:
|
def set(self, value: bytes, machine: str) -> list[Path]:
|
||||||
if self._store is None:
|
if self._store is None:
|
||||||
msg = "Store cannot be None"
|
msg = "Store cannot be None"
|
||||||
raise ClanError(msg)
|
raise ClanError(msg)
|
||||||
if self._generator is None:
|
if self._generator is None:
|
||||||
msg = "Generator cannot be None"
|
msg = "Generator cannot be None"
|
||||||
raise ClanError(msg)
|
raise ClanError(msg)
|
||||||
return self._store.set(self._generator, self, value)
|
return self._store.set(self._generator, self, value, machine)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def exists(self) -> bool:
|
def exists(self) -> bool:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import importlib
|
import importlib
|
||||||
import logging
|
import logging
|
||||||
import pkgutil
|
import pkgutil
|
||||||
|
import sys
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
@@ -214,6 +215,8 @@ API.register(get_system_file)
|
|||||||
for name, func in self._registry.items():
|
for name, func in self._registry.items():
|
||||||
hints = get_type_hints(func)
|
hints = get_type_hints(func)
|
||||||
|
|
||||||
|
print("Generating schema for function:", name, file=sys.stderr)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
serialized_hints = {
|
serialized_hints = {
|
||||||
key: type_to_dict(
|
key: type_to_dict(
|
||||||
@@ -236,6 +239,15 @@ API.register(get_system_file)
|
|||||||
if ("error" in t["properties"]["status"]["enum"])
|
if ("error" in t["properties"]["status"]["enum"])
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# TODO: improve error handling in this function
|
||||||
|
if "oneOf" not in return_type:
|
||||||
|
msg = (
|
||||||
|
f"Return type of function '{name}' is not a union type. Expected a union of Success and Error types."
|
||||||
|
# @DavHau: no idea wy exactly this leads to the "oneOf" ot being present, but this should help
|
||||||
|
"Hint: When using dataclasses as return types, ensure they don't contain public fields with non-serializable types"
|
||||||
|
)
|
||||||
|
raise JSchemaTypeError(msg)
|
||||||
|
|
||||||
return_type["oneOf"][1] = {"$ref": "#/$defs/error"}
|
return_type["oneOf"][1] = {"$ref": "#/$defs/error"}
|
||||||
|
|
||||||
sig = signature(func)
|
sig = signature(func)
|
||||||
|
|||||||
@@ -95,9 +95,14 @@ class Machine:
|
|||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def secret_vars_store(self) -> StoreBase:
|
def secret_vars_store(self) -> StoreBase:
|
||||||
|
from clan_cli.vars.secret_modules import password_store # noqa: PLC0415
|
||||||
|
|
||||||
secret_module = self.select("config.clan.core.vars.settings.secretModule")
|
secret_module = self.select("config.clan.core.vars.settings.secretModule")
|
||||||
module = importlib.import_module(secret_module)
|
module = importlib.import_module(secret_module)
|
||||||
return module.SecretStore(flake=self.flake)
|
store = module.SecretStore(flake=self.flake)
|
||||||
|
if isinstance(store, password_store.SecretStore):
|
||||||
|
store.init_pass_command(machine=self.name)
|
||||||
|
return store
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def public_vars_store(self) -> StoreBase:
|
def public_vars_store(self) -> StoreBase:
|
||||||
|
|||||||
@@ -8,10 +8,13 @@ from clan_cli.vars.migration import check_can_migrate, migrate_files
|
|||||||
|
|
||||||
from clan_lib.api import API
|
from clan_lib.api import API
|
||||||
from clan_lib.errors import ClanError
|
from clan_lib.errors import ClanError
|
||||||
|
from clan_lib.machines.actions import list_machines
|
||||||
from clan_lib.machines.machines import Machine
|
from clan_lib.machines.machines import Machine
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
debug_condition = False
|
||||||
|
|
||||||
|
|
||||||
@API.register
|
@API.register
|
||||||
def get_generators(
|
def get_generators(
|
||||||
@@ -32,27 +35,46 @@ def get_generators(
|
|||||||
List of generators based on the specified selection and closure mode.
|
List of generators based on the specified selection and closure mode.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
machine_names = [machine.name for machine in machines]
|
if not machines:
|
||||||
vars_generators = Generator.get_machine_generators(
|
msg = "At least one machine must be provided"
|
||||||
machine_names,
|
raise ClanError(msg)
|
||||||
|
|
||||||
|
all_machines = list_machines(machines[0].flake).keys()
|
||||||
|
requested_machines = [machine.name for machine in machines]
|
||||||
|
|
||||||
|
all_generators_list = Generator.get_machine_generators(
|
||||||
|
all_machines,
|
||||||
machines[0].flake,
|
machines[0].flake,
|
||||||
include_previous_values=include_previous_values,
|
include_previous_values=include_previous_values,
|
||||||
)
|
)
|
||||||
generators = {generator.key: generator for generator in vars_generators}
|
requested_generators_list = Generator.get_machine_generators(
|
||||||
|
requested_machines,
|
||||||
|
machines[0].flake,
|
||||||
|
include_previous_values=include_previous_values,
|
||||||
|
)
|
||||||
|
|
||||||
|
all_generators = {generator.key: generator for generator in all_generators_list}
|
||||||
|
requested_generators = {
|
||||||
|
generator.key: generator for generator in requested_generators_list
|
||||||
|
}
|
||||||
|
|
||||||
result_closure = []
|
result_closure = []
|
||||||
if generator_name is None: # all generators selected
|
if generator_name is None: # all generators selected
|
||||||
if full_closure:
|
if full_closure:
|
||||||
result_closure = graph.requested_closure(generators.keys(), generators)
|
result_closure = graph.requested_closure(
|
||||||
|
requested_generators.keys(), all_generators
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
result_closure = graph.all_missing_closure(generators.keys(), generators)
|
result_closure = graph.all_missing_closure(
|
||||||
|
requested_generators.keys(), all_generators
|
||||||
|
)
|
||||||
# specific generator selected
|
# specific generator selected
|
||||||
elif full_closure:
|
elif full_closure:
|
||||||
roots = [key for key in generators if key.name == generator_name]
|
roots = [key for key in requested_generators if key.name == generator_name]
|
||||||
result_closure = requested_closure(roots, generators)
|
result_closure = requested_closure(roots, all_generators)
|
||||||
else:
|
else:
|
||||||
roots = [key for key in generators if key.name == generator_name]
|
roots = [key for key in requested_generators if key.name == generator_name]
|
||||||
result_closure = graph.all_missing_closure(roots, generators)
|
result_closure = graph.all_missing_closure(roots, all_generators)
|
||||||
|
|
||||||
return result_closure
|
return result_closure
|
||||||
|
|
||||||
@@ -123,6 +145,9 @@ def run_generators(
|
|||||||
executing the generator.
|
executing the generator.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
if not machines:
|
||||||
|
msg = "At least one machine must be provided"
|
||||||
|
raise ClanError(msg)
|
||||||
if isinstance(generators, list):
|
if isinstance(generators, list):
|
||||||
# List of generator names - use them exactly as provided
|
# List of generator names - use them exactly as provided
|
||||||
if len(generators) == 0:
|
if len(generators) == 0:
|
||||||
@@ -143,23 +168,23 @@ def run_generators(
|
|||||||
prompt_values = {
|
prompt_values = {
|
||||||
generator.name: prompt_values(generator) for generator in generator_objects
|
generator.name: prompt_values(generator) for generator in generator_objects
|
||||||
}
|
}
|
||||||
|
|
||||||
# execute health check
|
# execute health check
|
||||||
for machine in machines:
|
for machine in machines:
|
||||||
_ensure_healthy(machine=machine)
|
_ensure_healthy(machine=machine)
|
||||||
|
|
||||||
# execute generators
|
# execute generators
|
||||||
for generator in generator_objects:
|
for generator in generator_objects:
|
||||||
generator_machines = (
|
machine = (
|
||||||
machines
|
machines[0]
|
||||||
if generator.machine is None
|
if generator.machine is None
|
||||||
else [Machine(name=generator.machine, flake=machines[0].flake)]
|
else Machine(name=generator.machine, flake=machines[0].flake)
|
||||||
)
|
)
|
||||||
for machine in generator_machines:
|
if check_can_migrate(machine, generator):
|
||||||
if check_can_migrate(machine, generator):
|
migrate_files(machine, generator)
|
||||||
migrate_files(machine, generator)
|
else:
|
||||||
else:
|
generator.execute(
|
||||||
generator.execute(
|
machine=machine,
|
||||||
machine=machine,
|
prompt_values=prompt_values.get(generator.name, {}),
|
||||||
prompt_values=prompt_values.get(generator.name, {}),
|
no_sandbox=no_sandbox,
|
||||||
no_sandbox=no_sandbox,
|
)
|
||||||
)
|
|
||||||
|
|||||||
@@ -24,6 +24,9 @@
|
|||||||
let
|
let
|
||||||
pyDeps = ps: [
|
pyDeps = ps: [
|
||||||
ps.argcomplete # Enables shell completions
|
ps.argcomplete # Enables shell completions
|
||||||
|
|
||||||
|
# uncomment web-pdb for debugging:
|
||||||
|
# (pkgs.callPackage ./python-deps.nix {}).web-pdb
|
||||||
];
|
];
|
||||||
devDeps = ps: [
|
devDeps = ps: [
|
||||||
ps.ipython
|
ps.ipython
|
||||||
|
|||||||
Reference in New Issue
Block a user