diff --git a/flake.nix b/flake.nix index e75e7bd..43d023e 100644 --- a/flake.nix +++ b/flake.nix @@ -54,7 +54,7 @@ { lib = { inherit forAllSystems; - deploy = import lib/deploy.nix; + gen-ssh-config = import lib/gen-ssh-config.nix lib; }; nixosModules = mods // { default.imports = builtins.attrValues mods; }; diff --git a/lib/deploy.nix b/lib/deploy.nix deleted file mode 100644 index 501f05e..0000000 --- a/lib/deploy.nix +++ /dev/null @@ -1,90 +0,0 @@ -{ self -, hosts -, modules ? [ ] -}: - -let - inherit (self.inputs) nixpkgs; - inherit (self) nixosConfigurations; - - helpers = system: - let - inherit (nixpkgs.lib) concatMapStrings; - inherit (nixpkgs.legacyPackages.${system}) pkgs; - - sshKnownHostsTxt = pkgs.writeText "known_hosts" (concatMapStrings - (hostName: - let m = nixosConfigurations.${hostName}.config.sconfig; - in concatMapStrings (key: "${m.deployment.targetHost} ${key}\n") m.sshPublicKeys - ) - (builtins.attrNames nixosConfigurations) - ); - - hostSshConfigs = concatMapStrings - (hostName: '' - Host ${hostName} - HostName ${nixosConfigurations.${hostName}.config.sconfig.deployment.targetHost} - '') - (builtins.attrNames nixosConfigurations); - - sshConfig = pkgs.writeText "ssh_config" '' - StrictHostKeyChecking yes - GlobalKnownHostsFile ${sshKnownHostsTxt} - ${hostSshConfigs} - ''; - - livecd-deploy = pkgs.writeShellScript "livecd-deploy" '' - set -eux - config=".#nixosConfigurations.\"$1\".config" - ip="$(nix eval --raw "$config.sconfig.deployment.targetHost")" - ssh-copy-id root@$ip - sys="$(nix eval --raw "$config.system.build.toplevel")" - nix build "$config.system.build.toplevel" --out-link "$(mktemp -d)/result" - nix copy --to ssh://root@$ip?remote-store=local?root=/mnt "$sys" - ssh root@$ip nix-env --store /mnt -p /mnt/nix/var/nix/profiles/system --set "$sys" - ssh root@$ip mkdir -p /mnt/etc - ssh root@$ip touch /mnt/etc/NIXOS - ssh root@$ip ln -sfn /proc/mounts /mnt/etc/mtab - ssh root@$ip NIXOS_INSTALL_BOOTLOADER=1 nixos-enter \ - --root /mnt -- /run/current-system/bin/switch-to-configuration boot - ''; - - check-updates = pkgs.writeShellScript "check-updates" '' - set -eu - c="${pkgs.colmena}/bin/colmena" - j="$($c eval -E '{nodes,...}: builtins.mapAttrs (n: v: v.config.system.build.toplevel) nodes')" - $c exec -- '[ "$(echo '"'$j'"' | jq -r .\"$(hostname)\")" = "$(readlink /run/current-system)" ]' - ''; - - check-reboots = pkgs.writeShellScript "check-reboots" '' - set -eu - c="${pkgs.colmena}/bin/colmena" - $c exec -- '[ "$(readlink /run/booted-system/kernel)" = "$(readlink /run/current-system/kernel)" ]' - ''; - - in - { inherit check-updates check-reboots livecd-deploy pkgs sshConfig; }; - -in -{ - defaultPackage = system: with helpers system; - pkgs.writeShellScript "deploy-init" '' - export SSH_CONFIG_FILE=${sshConfig} - alias ssh='ssh -F${sshConfig}' - alias check-updates=${check-updates} - alias check-reboots=${check-reboots} - alias livecd-deploy=${livecd-deploy} - alias c=${pkgs.colmena}/bin/colmena - ''; - - colmena = - # colmena evaluates in impure mode, so currentSystem is OK for now - { meta.nixpkgs = nixpkgs.legacyPackages.${builtins.currentSystem}; } // - builtins.mapAttrs - (name: value: { - imports = value.modules ++ [ - ({ config, ... }: { inherit (config.sconfig) deployment; }) - ]; - }) - (hosts); -} diff --git a/lib/gen-ssh-config.nix b/lib/gen-ssh-config.nix new file mode 100644 index 0000000..495ea7b --- /dev/null +++ b/lib/gen-ssh-config.nix @@ -0,0 +1,23 @@ +lib: +nixosConfigurations: + +let + sshKnownHostsTxt = builtins.toFile "known_hosts" (lib.concatMapStrings + (hostName: + let d = nixosConfigurations.${hostName}.config.deploy; + in lib.concatMapStrings (key: "${d.targetHost} ${key}\n") d.sshPublicKeys + ) + (builtins.attrNames nixosConfigurations) + ); + +in +builtins.toFile "ssh-config" ('' + StrictHostKeyChecking yes + GlobalKnownHostsFile ${sshKnownHostsTxt} +'' + +lib.concatMapStrings + (host: '' + Host ${host} + HostName ${nixosConfigurations.${host}.config.deploy.targetHost} + '') + (builtins.attrNames nixosConfigurations)) diff --git a/modules/deploy.nix b/modules/deploy.nix index fdf729b..e2a59ea 100644 --- a/modules/deploy.nix +++ b/modules/deploy.nix @@ -1,17 +1,15 @@ { lib, ... }: -with lib.types; { - options.sconfig = { - + options.deploy = { sshPublicKeys = lib.mkOption { - type = listOf str; + type = lib.types.listOf lib.types.str; + }; + targetHost = lib.mkOption { + type = lib.types.str; + }; + tags = lib.mkOption { + type = lib.types.listOf lib.types.str; default = [ ]; }; - - deployment = lib.mkOption { - type = attrs; - default = { }; - }; - }; } diff --git a/pkgs/deploy/default.nix b/pkgs/deploy/default.nix new file mode 100644 index 0000000..77b9c89 --- /dev/null +++ b/pkgs/deploy/default.nix @@ -0,0 +1,5 @@ +{ writeShellScriptBin, python3 }: + +writeShellScriptBin "deploy" '' + exec ${python3}/bin/python ${./deploy.py} "$@" +'' diff --git a/pkgs/deploy/deploy.py b/pkgs/deploy/deploy.py new file mode 100755 index 0000000..eb9bca6 --- /dev/null +++ b/pkgs/deploy/deploy.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 + +from json import loads +from os import readlink +from string import ascii_letters, digits +from subprocess import run, PIPE, STDOUT +from sys import argv + + +def get_deployment(): + return loads( + run( + [ + "nix", + "eval", + "--json", + "--apply", + "builtins.mapAttrs (n: v: v.config.deploy)", + ".#nixosConfigurations", + ], + stdout=PIPE, + check=True, + ).stdout + ) + + +def expand(ln): + hosts = set() + for item in ln.split(","): + if item == "@all": + hosts.update(depl) + elif item[0] == "@": + hosts.update(x for x in depl if item[1:] in depl[x]["tags"]) + else: + hosts.add(item) + for host in hosts: + for c in host: + if not c in (ascii_letters + digits + "-"): + raise RuntimeError(f"Invalid hostname: {host}") + return sorted(hosts) + + +def build_system(name): + return loads( + run( + [ + "nix", + "build", + "--no-link", + "--json", + f".#nixosConfigurations.{name}.config.system.build.toplevel", + ], + stdout=PIPE, + check=True, + ).stdout + )[0]["outputs"]["out"] + + +def build_all(hosts): + return dict([x, build_system(x)] for x in hosts) + + +def apply(goal, hosts): + for host in hosts: + print(f"\n\n{goal} on {host}...\n") + run( + [ + "nixos-rebuild", + goal, + "--use-substitutes", + "--use-remote-sudo", + "--target-host", + host, + "--flake", + ".#" + host, + ], + check=True, + ) + + +def check(hosts): + hostwidth = max(map(len, hosts)) + new_sys = build_all(hosts) + print("#" * 64) + + for host in hosts: + current_sys, cur_kernel, boot_kernel = ( + run( + [ + "ssh", + host, + "readlink", + "/run/current-system", + "/run/current-system/kernel", + "/run/booted-system/kernel", + ], + stdout=PIPE, + check=True, + ) + .stdout.decode("ascii") + .strip() + .split("\n") + ) + + reboot_needed = cur_kernel != boot_kernel + update_needed = current_sys != new_sys[host] + + print(host.rjust(hostwidth + 1), end=" ") + + if not (reboot_needed or update_needed): + print(icon_good, "[OK]") + continue + + print(icon_bad, end=" ") + if update_needed: + print("[UPDATE REQUIRED]", end=" ") + if readlink(new_sys[host] + "/kernel") != boot_kernel: + print("[UPDATE REQUIRES REBOOT]", end=" ") + if reboot_needed: + print("[REBOOT REQUIRED]", end=" ") + print() + + print("#" * 64) + + +def push(hosts): + new_sys = build_all(hosts) + for host in hosts: + print(f"Pushing to {host}...") + run( + [ + "nix", + "copy", + new_sys[host], + "--to", + "ssh://" + host, + ], + check=True, + ) + + +def rexec(hosts, cmd): + hostwidth = max(map(len, hosts)) + for host in hosts: + r = run(["ssh", host, "--"] + cmd, stdout=PIPE, stderr=STDOUT) + lines = r.stdout.decode("utf8").strip().splitlines() + print(host.rjust(hostwidth), end=" ") + print(icon_bad if r.returncode else icon_good, end=" ") + if len(lines) == 1: + print(lines[0]) + else: + print() + for ln in lines: + print(" " * hostwidth, ln) + + +def main(): + op = argv[1] + args = argv[2:] + + if op in ["boot", "switch", "test"]: + apply(op, expand(args[0])) + + elif op == "check": + check(expand(args[0])) + + elif op == "push": + push(expand(args[0])) + + elif op == "exec": + rexec(expand(args[0]), args[1:]) + + else: + print("Invalid op:", op) + + +icon_bad = "\u274c" +icon_good = "\u2705" +depl = get_deployment() +if __name__ == "__main__": + main()