From 7dbed610790ba0d41e057e87c5de0606e83e6155 Mon Sep 17 00:00:00 2001 From: DavHau Date: Wed, 10 Jul 2024 17:26:47 +0700 Subject: [PATCH] vars: implement secret generation --- nixosModules/clanCore/vars/interface.nix | 234 +++++++++--------- nixosModules/clanCore/vars/secret/sops.nix | 2 +- .../clan_cli/vars/secret_modules/sops.py | 17 +- pkgs/clan-cli/tests/age_keys.py | 16 ++ pkgs/clan-cli/tests/test_vars.py | 62 ++++- 5 files changed, 204 insertions(+), 127 deletions(-) diff --git a/nixosModules/clanCore/vars/interface.nix b/nixosModules/clanCore/vars/interface.nix index 39f0df0bb..edf80f501 100644 --- a/nixosModules/clanCore/vars/interface.nix +++ b/nixosModules/clanCore/vars/interface.nix @@ -36,128 +36,138 @@ in Each generator is expected to produce a set of files under a directory. ''; default = { }; - type = attrsOf (submodule { - imports = [ ./generator.nix ]; - options = options { - dependencies = { - description = '' - A list of other generators that this generator depends on. - The output values of these generators will be available to the generator script as files. - For example, the file 'file1' of a dependency named 'dep1' will be available via $dependencies/dep1/file1. - ''; - type = listOf str; - default = [ ]; - }; - files = { - description = '' - A set of files to generate. - The generator 'script' is expected to produce exactly these files under $out. - ''; - type = attrsOf ( - submodule (file: { - imports = [ config.settings.fileModule ]; - options = options { - name = { - type = lib.types.str; - description = '' - name of the public fact - ''; - readOnly = true; - default = file.config._module.args.name; + type = attrsOf ( + submodule (generator: { + imports = [ ./generator.nix ]; + options = options { + dependencies = { + description = '' + A list of other generators that this generator depends on. + The output values of these generators will be available to the generator script as files. + For example, the file 'file1' of a dependency named 'dep1' will be available via $dependencies/dep1/file1. + ''; + type = listOf str; + default = [ ]; + }; + files = { + description = '' + A set of files to generate. + The generator 'script' is expected to produce exactly these files under $out. + ''; + type = attrsOf ( + submodule (file: { + imports = [ config.settings.fileModule ]; + options = options { + name = { + type = lib.types.str; + description = '' + name of the public fact + ''; + readOnly = true; + default = file.config._module.args.name; + }; + generatorName = { + type = lib.types.str; + description = '' + name of the generator + ''; + readOnly = true; + default = generator.name; + }; + secret = { + description = '' + Whether the file should be treated as a secret. + ''; + type = bool; + default = true; + }; + path = { + description = '' + The path to the file containing the content of the generated value. + This will be set automatically + ''; + type = str; + readOnly = true; + }; + value = { + description = '' + The content of the generated value. + Only available if the file is not secret. + ''; + type = str; + default = throw "Cannot access value of secret file"; + defaultText = "Throws error because the value of a secret file is not accessible"; + }; }; - secret = { + }) + ); + }; + prompts = { + description = '' + A set of prompts to ask the user for values. + Prompts are available to the generator script as files. + For example, a prompt named 'prompt1' will be available via $prompts/prompt1 + ''; + type = attrsOf (submodule { + options = { + description = { description = '' - Whether the file should be treated as a secret. - ''; - type = bool; - default = true; - }; - path = { - description = '' - The path to the file containing the content of the generated value. - This will be set automatically + The description of the prompted value ''; type = str; - readOnly = true; + example = "SSH private key"; }; - value = { + type = { description = '' - The content of the generated value. - Only available if the file is not secret. + The input type of the prompt. + The following types are available: + - hidden: A hidden text (e.g. password) + - line: A single line of text + - multiline: A multiline text ''; - type = str; - default = throw "Cannot access value of secret file"; - defaultText = "Throws error because the value of a secret file is not accessible"; + type = enum [ + "hidden" + "line" + "multiline" + ]; + default = "line"; }; }; - }) - ); + }); + }; + runtimeInputs = { + description = '' + A list of packages that the generator script requires. + These packages will be available in the PATH when the script is run. + ''; + type = listOf package; + default = [ ]; + }; + script = { + description = '' + The script to run to generate the files. + The script will be run with the following environment variables: + - $dependencies: The directory containing the output values of all declared dependencies + - $out: The output directory to put the generated files + - $prompts: The directory containing the prompted values as files + The script should produce the files specified in the 'files' attribute under $out. + ''; + type = either str path; + }; + finalScript = { + description = '' + The final generator script, wrapped, so: + - all required programs are in PATH + - sandbox is set up correctly + ''; + type = lib.types.str; + readOnly = true; + internal = true; + visible = false; + }; }; - prompts = { - description = '' - A set of prompts to ask the user for values. - Prompts are available to the generator script as files. - For example, a prompt named 'prompt1' will be available via $prompts/prompt1 - ''; - type = attrsOf (submodule { - options = { - description = { - description = '' - The description of the prompted value - ''; - type = str; - example = "SSH private key"; - }; - type = { - description = '' - The input type of the prompt. - The following types are available: - - hidden: A hidden text (e.g. password) - - line: A single line of text - - multiline: A multiline text - ''; - type = enum [ - "hidden" - "line" - "multiline" - ]; - default = "line"; - }; - }; - }); - }; - runtimeInputs = { - description = '' - A list of packages that the generator script requires. - These packages will be available in the PATH when the script is run. - ''; - type = listOf package; - default = [ ]; - }; - script = { - description = '' - The script to run to generate the files. - The script will be run with the following environment variables: - - $dependencies: The directory containing the output values of all declared dependencies - - $out: The output directory to put the generated files - - $prompts: The directory containing the prompted values as files - The script should produce the files specified in the 'files' attribute under $out. - ''; - type = either str path; - }; - finalScript = { - description = '' - The final generator script, wrapped, so: - - all required programs are in PATH - - sandbox is set up correctly - ''; - type = lib.types.str; - readOnly = true; - internal = true; - visible = false; - }; - }; - }); + }) + ); }; }; } diff --git a/nixosModules/clanCore/vars/secret/sops.nix b/nixosModules/clanCore/vars/secret/sops.nix index bbf9cbc50..3e22e8628 100644 --- a/nixosModules/clanCore/vars/secret/sops.nix +++ b/nixosModules/clanCore/vars/secret/sops.nix @@ -38,7 +38,7 @@ in fileModule = file: { path = lib.mkIf file.secret - config.sops.secrets.${"${config.clan.core.machineName}-${file.config.name}"}.path + config.sops.secrets.${"vars-${config.clan.core.machineName}-${file.config.generatorName}-${file.config.name}"}.path or "/no-such-path"; }; secretModule = "clan_cli.vars.secret_modules.sops"; diff --git a/pkgs/clan-cli/clan_cli/vars/secret_modules/sops.py b/pkgs/clan-cli/clan_cli/vars/secret_modules/sops.py index 9bc6bcbc1..1f609200d 100644 --- a/pkgs/clan-cli/clan_cli/vars/secret_modules/sops.py +++ b/pkgs/clan-cli/clan_cli/vars/secret_modules/sops.py @@ -14,13 +14,16 @@ class SecretStore(SecretStoreBase): self.machine = machine # no need to generate keys if we don't manage secrets - if not hasattr(self.machine, "vars_data") or not self.machine.vars_generators: + if not self.machine.vars_generators: return + has_secrets = False for generator in self.machine.vars_generators.values(): if "files" in generator: for file in generator["files"].values(): if file["secret"]: - return + has_secrets = True + if not has_secrets: + return if has_machine(self.machine.flake_dir, self.machine.name): return @@ -38,7 +41,7 @@ class SecretStore(SecretStoreBase): ) -> Path | None: path = ( sops_secrets_folder(self.machine.flake_dir) - / f"{self.machine.name}-{generator_name}-{name}" + / f"vars-{self.machine.name}-{generator_name}-{name}" ) encrypt_secret( self.machine.flake_dir, @@ -49,15 +52,15 @@ class SecretStore(SecretStoreBase): ) return path - def get(self, service: str, name: str) -> bytes: + def get(self, generator_name: str, name: str) -> bytes: return decrypt_secret( - self.machine.flake_dir, f"{self.machine.name}-{name}" + self.machine.flake_dir, f"vars-{self.machine.name}-{generator_name}-{name}" ).encode("utf-8") - def exists(self, service: str, name: str) -> bool: + def exists(self, generator_name: str, name: str) -> bool: return has_secret( self.machine.flake_dir, - f"{self.machine.name}-{name}", + f"vars-{self.machine.name}-{generator_name}-{name}", ) def upload(self, output_dir: Path) -> None: diff --git a/pkgs/clan-cli/tests/age_keys.py b/pkgs/clan-cli/tests/age_keys.py index 5a0e038ad..02f15c02b 100644 --- a/pkgs/clan-cli/tests/age_keys.py +++ b/pkgs/clan-cli/tests/age_keys.py @@ -7,6 +7,11 @@ class KeyPair: self.privkey = privkey +class SopsSetup: + def __init__(self, keys: list[KeyPair]) -> None: + self.keys = keys + + KEYS = [ KeyPair( "age1dhwqzkah943xzc34tc3dlmfayyevcmdmxzjezdgdy33euxwf59vsp3vk3c", @@ -29,3 +34,14 @@ def age_keys() -> list[KeyPair]: Root directory of the tests """ return KEYS + + +@pytest.fixture +def sops_setup( + monkeypatch: pytest.MonkeyPatch, +) -> SopsSetup: + """ + Root directory of the tests + """ + monkeypatch.setenv("SOPS_AGE_KEY", KEYS[0].privkey) + return SopsSetup(KEYS) diff --git a/pkgs/clan-cli/tests/test_vars.py b/pkgs/clan-cli/tests/test_vars.py index e7ccaf250..0bcfa2646 100644 --- a/pkgs/clan-cli/tests/test_vars.py +++ b/pkgs/clan-cli/tests/test_vars.py @@ -1,17 +1,15 @@ +import os from pathlib import Path -from typing import TYPE_CHECKING import pytest +from age_keys import SopsSetup from fixtures_flakes import generate_flake from helpers import cli from root import CLAN_CORE -if TYPE_CHECKING: - pass - @pytest.mark.impure -def test_generate_secret( +def test_generate_public_var( monkeypatch: pytest.MonkeyPatch, temporary_home: Path, # age_keys: list["KeyPair"], @@ -41,8 +39,58 @@ def test_generate_secret( ), ) monkeypatch.chdir(flake.path) - cmd = ["vars", "generate", "--flake", str(flake.path), "my_machine"] - cli.run(cmd) + cli.run(["vars", "generate", "--flake", str(flake.path), "my_machine"]) assert ( flake.path / "machines" / "my_machine" / "vars" / "my_generator" / "my_secret" ).is_file() + + +@pytest.mark.impure +def test_generate_secret_var( + monkeypatch: pytest.MonkeyPatch, + temporary_home: Path, + sops_setup: SopsSetup, +) -> None: + flake = generate_flake( + temporary_home, + flake_template=CLAN_CORE / "templates" / "minimal", + machine_configs=dict( + my_machine=dict( + clan=dict( + core=dict( + vars=dict( + generators=dict( + my_generator=dict( + files=dict( + my_secret=dict( + secret=True, + ) + ), + script="echo hello > $out/my_secret", + ) + ) + ) + ) + ) + ) + ), + ) + monkeypatch.chdir(flake.path) + cli.run( + [ + "secrets", + "users", + "add", + "--flake", + str(flake.path), + os.environ.get("USER", "user"), + sops_setup.keys[0].pubkey, + ] + ) + cli.run(["vars", "generate", "--flake", str(flake.path), "my_machine"]) + assert not ( + flake.path / "machines" / "my_machine" / "vars" / "my_generator" / "my_secret" + ).is_file() + assert ( + flake.path / "sops" / "secrets" / "vars-my_machine-my_generator-my_secret" + ).is_dir()