From 82bf417e17a1cb5ffbf18421d63cfd0bc501cfd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Thu, 28 Sep 2023 14:13:23 +0200 Subject: [PATCH] add toplevel machines-json that can deploy all hosts --- lib/build-clan/default.nix | 27 +++--- pkgs/clan-cli/clan_cli/machines/update.py | 91 +++++++++++++------ pkgs/clan-cli/clan_cli/secrets/generate.py | 28 ++++-- .../clan_cli/secrets/sops_generate.py | 4 +- pkgs/clan-cli/clan_cli/secrets/upload.py | 56 +++++------- pkgs/clan-cli/clan_cli/ssh/__init__.py | 8 +- 6 files changed, 132 insertions(+), 82 deletions(-) diff --git a/lib/build-clan/default.nix b/lib/build-clan/default.nix index 4b044edbd..e50299165 100644 --- a/lib/build-clan/default.nix +++ b/lib/build-clan/default.nix @@ -11,6 +11,7 @@ let (builtins.fromJSON (builtins.readFile (directory + /machines/${machineName}/settings.json))); + # TODO: remove default system once we have a hardware-config mechanism nixosConfiguration = { system ? "x86_64-linux", name }: nixpkgs.lib.nixosSystem { modules = [ self.nixosModules.clanCore @@ -19,8 +20,7 @@ let { clanCore.machineName = name; clanCore.clanDir = directory; - # TODO: remove this once we have a hardware-config mechanism - nixpkgs.hostPlatform = lib.mkDefault system; + nixpkgs.hostPlatform = lib.mkForce system; } ]; inherit specialArgs; @@ -41,27 +41,32 @@ let # This instantiates nixos for each system that we support: # configPerSystem = ..nixosConfiguration # We need this to build nixos secret generators for each system - configPerSystem = builtins.listToAttrs + configsPerSystem = builtins.listToAttrs (builtins.map (system: lib.nameValuePair system (lib.mapAttrs (name: _: nixosConfiguration { inherit name system; }) allMachines)) supportedSystems); - machinesPerSystem = lib.mapAttrs (_: machine: + getMachine = machine: { + inherit (machine.config.system.clan) uploadSecrets generateSecrets; + inherit (machine.config.clan.networking) deploymentAddress; + }; + + machinesPerSystem = lib.mapAttrs (_: machine: getMachine machine); + + machinesPerSystemWithJson = lib.mapAttrs (_: machine: let - config = { - inherit (machine.config.system.clan) uploadSecrets generateSecrets; - inherit (machine.config.clan.networking) deploymentAddress; - }; + m = getMachine machine; in - config // { - json = machine.pkgs.writeText "config.json" (builtins.toJSON config); + m // { + json = machine.pkgs.writers.writeJSON "machine.json" m; }); in { inherit nixosConfigurations; clanInternals = { - machines = lib.mapAttrs (_: machinesPerSystem) configPerSystem; + machines = lib.mapAttrs (_: configs: machinesPerSystemWithJson configs) configsPerSystem; + machines-json = lib.mapAttrs (system: configs: nixpkgs.legacyPackages.${system}.writers.writeJSON "machines.json" (machinesPerSystem configs)) configsPerSystem; }; } diff --git a/pkgs/clan-cli/clan_cli/machines/update.py b/pkgs/clan-cli/clan_cli/machines/update.py index bcac4b67a..592256832 100644 --- a/pkgs/clan-cli/clan_cli/machines/update.py +++ b/pkgs/clan-cli/clan_cli/machines/update.py @@ -2,15 +2,17 @@ import argparse import json import os import subprocess +from pathlib import Path +from typing import Any from ..dirs import get_clan_flake_toplevel -from ..nix import nix_command, nix_config, nix_eval -from ..secrets.generate import generate_secrets -from ..secrets.upload import upload_secrets +from ..nix import nix_build, nix_command, nix_config +from ..secrets.generate import run_generate_secrets +from ..secrets.upload import run_upload_secrets from ..ssh import Host, HostGroup, HostKeyCheck, parse_deployment_address -def deploy_nixos(hosts: HostGroup) -> None: +def deploy_nixos(hosts: HostGroup, clan_dir: Path) -> None: """ Deploy to all hosts in parallel """ @@ -38,8 +40,11 @@ def deploy_nixos(hosts: HostGroup) -> None: flake_attr = h.meta.get("flake_attr", "") - generate_secrets(flake_attr) - upload_secrets(flake_attr) + if generate_secrets_script := h.meta.get("generate_secrets"): + run_generate_secrets(generate_secrets_script, clan_dir) + + if upload_secrets_script := h.meta.get("upload_secrets"): + run_upload_secrets(upload_secrets_script, clan_dir) target_host = h.meta.get("target_host") if target_host: @@ -74,31 +79,65 @@ def deploy_nixos(hosts: HostGroup) -> None: hosts.run_function(deploy) -# FIXME: we want some kind of inventory here. -def update(args: argparse.Namespace) -> None: - clan_dir = get_clan_flake_toplevel().as_posix() - machine = args.machine +def build_json(targets: list[str]) -> list[dict[str, Any]]: + outpaths = subprocess.run( + nix_build(targets), + stdout=subprocess.PIPE, + check=True, + text=True, + ).stdout + parsed = [] + for outpath in outpaths.splitlines(): + parsed.append(json.loads(Path(outpath).read_text())) + return parsed + +def get_all_machines(clan_dir: Path) -> HostGroup: config = nix_config() system = config["system"] + what = f'{clan_dir}#clanInternals.machines-json."{system}"' + machines = build_json([what])[0] - address = json.loads( - subprocess.run( - nix_eval( - [ - f'{clan_dir}#clanInternals.machines."{system}"."{machine}".deploymentAddress' - ] - ), - stdout=subprocess.PIPE, - check=True, - text=True, - ).stdout - ) - host = parse_deployment_address(machine, address) - print(f"deploying {machine}") - deploy_nixos(HostGroup([host])) + hosts = [] + for name, machine in machines.items(): + host = parse_deployment_address( + name, machine["deploymentAddress"], meta=machine + ) + hosts.append(host) + return HostGroup(hosts) + + +def get_selected_machines(machine_names: list[str], clan_dir: Path) -> HostGroup: + config = nix_config() + system = config["system"] + what = [] + for name in machine_names: + what.append(f'{clan_dir}#clanInternals.machines."{system}"."{name}".json') + machines = build_json(what) + hosts = [] + for i, machine in enumerate(machines): + host = parse_deployment_address(machine_names[i], machine["deploymentAddress"]) + hosts.append(host) + return HostGroup(hosts) + + +# FIXME: we want some kind of inventory here. +def update(args: argparse.Namespace) -> None: + clan_dir = get_clan_flake_toplevel() + if len(args.machines) == 0: + machines = get_all_machines(clan_dir) + else: + machines = get_selected_machines(args.machines, clan_dir) + + deploy_nixos(machines, clan_dir) def register_update_parser(parser: argparse.ArgumentParser) -> None: - parser.add_argument("machine", type=str) + parser.add_argument( + "machines", + type=str, + help="machine to update. if empty, update all machines", + nargs="*", + default=[], + ) parser.set_defaults(func=update) diff --git a/pkgs/clan-cli/clan_cli/secrets/generate.py b/pkgs/clan-cli/clan_cli/secrets/generate.py index 30643f336..56c1f7810 100644 --- a/pkgs/clan-cli/clan_cli/secrets/generate.py +++ b/pkgs/clan-cli/clan_cli/secrets/generate.py @@ -2,6 +2,7 @@ import argparse import os import shlex import subprocess +from pathlib import Path from clan_cli.errors import ClanError @@ -9,11 +10,7 @@ from ..dirs import get_clan_flake_toplevel, module_root from ..nix import nix_build, nix_config -def generate_secrets(machine: str) -> None: - clan_dir = get_clan_flake_toplevel().as_posix().strip() - env = os.environ.copy() - env["CLAN_DIR"] = clan_dir - env["PYTHONPATH"] = str(module_root().parent) # TODO do this in the clanCore module +def build_generate_script(machine: str, clan_dir: Path) -> str: config = nix_config() system = config["system"] @@ -28,21 +25,32 @@ def generate_secrets(machine: str) -> None: f"failed to generate secrets:\n{shlex.join(cmd)}\nexited with {proc.returncode}" ) - secret_generator_script = proc.stdout.strip() - print(secret_generator_script) - secret_generator = subprocess.run( + return proc.stdout.strip() + + +def run_generate_secrets(secret_generator_script: str, clan_dir: Path) -> None: + env = os.environ.copy() + env["CLAN_DIR"] = str(clan_dir) + env["PYTHONPATH"] = str(module_root().parent) # TODO do this in the clanCore module + print(f"generating secrets... {secret_generator_script}") + proc = subprocess.run( [secret_generator_script], env=env, ) - if secret_generator.returncode != 0: + if proc.returncode != 0: raise ClanError("failed to generate secrets") else: print("successfully generated secrets") +def generate(machine: str) -> None: + clan_dir = get_clan_flake_toplevel() + run_generate_secrets(build_generate_script(machine, clan_dir), clan_dir) + + def generate_command(args: argparse.Namespace) -> None: - generate_secrets(args.machine) + generate(args.machine) def register_generate_parser(parser: argparse.ArgumentParser) -> None: diff --git a/pkgs/clan-cli/clan_cli/secrets/sops_generate.py b/pkgs/clan-cli/clan_cli/secrets/sops_generate.py index 0be562ce3..f93c6a884 100644 --- a/pkgs/clan-cli/clan_cli/secrets/sops_generate.py +++ b/pkgs/clan-cli/clan_cli/secrets/sops_generate.py @@ -44,8 +44,8 @@ def generate_secrets_group( text = f"""\ set -euo pipefail -facts={shlex.quote(str(facts_dir))} -secrets={shlex.quote(str(secrets_dir))} +export facts={shlex.quote(str(facts_dir))} +export secrets={shlex.quote(str(secrets_dir))} {generator} """ try: diff --git a/pkgs/clan-cli/clan_cli/secrets/upload.py b/pkgs/clan-cli/clan_cli/secrets/upload.py index 19fb7571e..719ab2f28 100644 --- a/pkgs/clan-cli/clan_cli/secrets/upload.py +++ b/pkgs/clan-cli/clan_cli/secrets/upload.py @@ -1,57 +1,51 @@ import argparse -import json import os +import shlex import subprocess +from pathlib import Path from ..dirs import get_clan_flake_toplevel, module_root from ..errors import ClanError -from ..nix import nix_build, nix_config, nix_eval +from ..nix import nix_build, nix_config -def upload_secrets(machine: str) -> None: - clan_dir = get_clan_flake_toplevel().as_posix() +def build_upload_script(machine: str, clan_dir: Path) -> str: config = nix_config() system = config["system"] - proc = subprocess.run( - nix_build( - [f'{clan_dir}#clanInternals.machines."{system}"."{machine}".uploadSecrets'] - ), - stdout=subprocess.PIPE, - text=True, - check=True, + cmd = nix_build( + [f'{clan_dir}#clanInternals.machines."{system}"."{machine}".uploadSecrets'] ) + proc = subprocess.run(cmd, stdout=subprocess.PIPE, text=True) + if proc.returncode != 0: + raise ClanError( + f"failed to upload secrets:\n{shlex.join(cmd)}\nexited with {proc.returncode}" + ) + return proc.stdout.strip() + + +def run_upload_secrets(flake_attr: str, clan_dir: Path) -> None: env = os.environ.copy() + env["CLAN_DIR"] = str(clan_dir) env["PYTHONPATH"] = str(module_root().parent) # TODO do this in the clanCore module - host = json.loads( - subprocess.run( - nix_eval( - [ - f'{clan_dir}#clanInternals.machines."{system}"."{machine}".deploymentAddress' - ] - ), - stdout=subprocess.PIPE, - text=True, - check=True, - ).stdout - ) - - secret_upload_script = proc.stdout.strip() - secret_upload = subprocess.run( - [ - secret_upload_script, - host, - ], + print(f"uploading secrets... {flake_attr}") + proc = subprocess.run( + [flake_attr], env=env, ) - if secret_upload.returncode != 0: + if proc.returncode != 0: raise ClanError("failed to upload secrets") else: print("successfully uploaded secrets") +def upload_secrets(machine: str) -> None: + clan_dir = get_clan_flake_toplevel() + run_upload_secrets(build_upload_script(machine, clan_dir), clan_dir) + + def upload_command(args: argparse.Namespace) -> None: upload_secrets(args.machine) diff --git a/pkgs/clan-cli/clan_cli/ssh/__init__.py b/pkgs/clan-cli/clan_cli/ssh/__init__.py index 735db17ff..8e92bbad8 100644 --- a/pkgs/clan-cli/clan_cli/ssh/__init__.py +++ b/pkgs/clan-cli/clan_cli/ssh/__init__.py @@ -756,7 +756,9 @@ class HostGroup: return HostGroup(list(filter(pred, self.hosts))) -def parse_deployment_address(machine_name: str, host: str) -> Host: +def parse_deployment_address( + machine_name: str, host: str, meta: dict[str, str] = {} +) -> Host: parts = host.split("@") user: Optional[str] = None if len(parts) > 1: @@ -776,12 +778,14 @@ def parse_deployment_address(machine_name: str, host: str) -> Host: if len(maybe_port) > 1: hostname = maybe_port[0] port = int(maybe_port[1]) + meta = meta.copy() + meta["flake_attr"] = machine_name return Host( hostname, user=user, port=port, command_prefix=machine_name, - meta=dict(flake_attr=machine_name), + meta=meta, ssh_options=options, )