From 35b6c1c4538e500c8e6778a1187fc20b5de1ffbe Mon Sep 17 00:00:00 2001 From: Kiara Grouwstra Date: Sat, 30 Aug 2025 19:24:50 +0200 Subject: [PATCH] factor out ssh deployment to make for reusable invocation --- .../check/common/data-model-options.nix | 14 +++- deployment/check/common/data-model.nix | 56 +++++++++---- deployment/check/common/sharedOptions.nix | 3 +- deployment/check/data-model-ssh/nixosTest.nix | 69 ++++------------ deployment/data-model.nix | 81 ++++++++++++++++++- deployment/run/ssh-single-host/run.sh | 49 +++++++++++ 6 files changed, 198 insertions(+), 74 deletions(-) create mode 100755 deployment/run/ssh-single-host/run.sh diff --git a/deployment/check/common/data-model-options.nix b/deployment/check/common/data-model-options.nix index 8492bee3..57dc88c5 100644 --- a/deployment/check/common/data-model-options.nix +++ b/deployment/check/common/data-model-options.nix @@ -3,13 +3,23 @@ ... }: let - inherit (lib) types; + inherit (lib) mkOption types; in { options = { - host = lib.mkOption { + host = mkOption { type = types.str; description = "name of the host to deploy to"; }; + targetSystem = mkOption { + type = types.str; + description = "name of the host to deploy to"; + }; + sshOpts = mkOption { + description = "Extra SSH options (`-o`) to use."; + type = types.listOf types.str; + default = [ ]; + example = "ConnectTimeout=60"; + }; }; } diff --git a/deployment/check/common/data-model.nix b/deployment/check/common/data-model.nix index c4922f72..91b00bff 100644 --- a/deployment/check/common/data-model.nix +++ b/deployment/check/common/data-model.nix @@ -1,17 +1,29 @@ { config, system, - inputs ? (import ../../../default.nix { }).inputs, + inputs ? (import ../../../default.nix { }).inputs, # XXX can't be serialized sources ? import ../../../npins, ... -}: +}@args: let + # having this module's location (`self`) and (serializable) `args`, we know + # enough to make it re-call itself to extract different info elsewhere later. + # we use this to make a deployment script using the desired nixos config, + # which would otherwise not be serializable, while nix also makes it hard to + # produce its derivation to pass thru without a `nix-instantiate` call, + # which in turn would need to be passed the (unserializable) nixos config. + self = "deployment/check/common/data-model.nix"; inherit (sources) nixpkgs; pkgs = import nixpkgs { inherit system; }; inherit (pkgs) lib; deployment-config = config; - inherit (deployment-config) nodeName; + inherit (deployment-config) + nodeName + pathToRoot + targetSystem + sshOpts + ; inherit (lib) mkOption types; eval = module: @@ -124,17 +136,27 @@ let { single-nixos-vm-ssh = environment: { resources."operator-environment".login-shell.username = "operator"; - implementation = requests: { - input = requests; - output.ssh-host = { - nixos-configuration = mkNixosConfiguration environment requests; - ssh = { - username = "root"; - host = nodeName; - key-file = null; + implementation = + { + required-resources, + deployment-name, + }: + { + input = required-resources; + output.ssh-host = { + nixos-configuration = mkNixosConfiguration environment required-resources; + system = targetSystem; + ssh = { + username = "root"; + host = nodeName; + key-file = null; + inherit sshOpts; + }; + module = self; + inherit args deployment-name; + root-path = pathToRoot; }; }; - }; }; single-nixos-vm-nixops4 = environment: { resources."operator-environment".login-shell.username = "operator"; @@ -175,7 +197,10 @@ let in mkOption { type = env.resource-mapping.output-type; - default = env.deployment config."example-configuration"; + default = env.deployment { + deployment-name = "ssh-deployment"; + configuration = config."example-configuration"; + }; }; "nixops4-deployment" = let @@ -183,7 +208,10 @@ let in mkOption { type = env.resource-mapping.output-type; - default = env.deployment config."example-configuration"; + default = env.deployment { + deployment-name = "nixops4-deployment"; + configuration = config."example-configuration"; + }; }; }; } diff --git a/deployment/check/common/sharedOptions.nix b/deployment/check/common/sharedOptions.nix index c0efc6cf..95736066 100644 --- a/deployment/check/common/sharedOptions.nix +++ b/deployment/check/common/sharedOptions.nix @@ -32,11 +32,10 @@ in }; pathFromRoot = mkOption { - type = types.path; + type = types.str; description = '' Path from the root of the repository to the working directory. ''; - apply = x: lib.path.removePrefix config.pathToRoot x; }; pathToCwd = mkOption { diff --git a/deployment/check/data-model-ssh/nixosTest.nix b/deployment/check/data-model-ssh/nixosTest.nix index e800ab7b..cb27deaa 100644 --- a/deployment/check/data-model-ssh/nixosTest.nix +++ b/deployment/check/data-model-ssh/nixosTest.nix @@ -1,30 +1,26 @@ { lib, - config, pkgs, - inputs, ... }: let inherit (import ./constants.nix) pathToRoot pathFromRoot; inherit (pkgs) system; - escapedJson = v: lib.replaceStrings [ "\"" ] [ "\\\\\"" ] (lib.strings.toJSON v); deployment-config = { inherit pathToRoot pathFromRoot; - inherit (config) enableAcme; - acmeNodeIP = if config.enableAcme then config.nodes.acme.networking.primaryIPAddress else null; nodeName = "ssh"; + targetSystem = system; + sshOpts = [ + "ConnectTimeout=1" + "ServerAliveInterval=1" + ]; }; - inherit - ((import ../common/data-model.nix { - inherit system inputs; + deployment = + (import ../common/data-model.nix { + inherit system; config = deployment-config; - })."ssh-deployment".ssh-host.ssh - ) - host - username - key-file - ; + # opt not to pass `inputs`, as we could only pass serializable arguments through to its self-call + })."ssh-deployment".ssh-host; in { _class = "nixosTest"; @@ -36,6 +32,10 @@ in sourceFileset = lib.fileset.unions [ ../../data-model.nix ../../function.nix + ../../nixos.nix + ../../run/ssh-single-host/run.sh + ../../../npins/default.nix + ../../../npins/sources.json ../common/data-model.nix ../common/data-model-options.nix ./constants.nix @@ -63,46 +63,7 @@ in with subtest("Run the deployment"): deployer.succeed(""" - set -euo pipefail - - # INSTANTIATE - command=(nix-instantiate --show-trace --expr ' - let - system = "${pkgs.system}"; # FIXME: what system are we deploying to? - in - import ${pathToRoot}/deployment/nixos.nix { - inherit system; - configuration = ( - import ${pathToRoot}/deployment/check/common/data-model.nix { - inherit system; - config = builtins.fromJSON "${escapedJson deployment-config}"; - } - )."ssh-deployment".ssh-host.nixos-configuration; - } - ') - # DEPLOY - host="${lib.defaultTo "root" username}@${host}" - sshOpts=( - ${if key-file == null then "" else "-i ${key-file}"} - -o StrictHostKeyChecking=no - -o "ConnectTimeout=1" - -o "ServerAliveInterval=1" - ) - # instantiate the config in /nix/store - "''${command[@]}" --show-trace -A out_path - # get the realized derivation to deploy - outPath=$(nix-store --realize "$("''${command[@]}" --show-trace --eval --strict --json | jq -r '.drv_path')") - # deploy the config by nix-copy-closure - NIX_SSHOPTS="''${sshOpts[*]}" nix-copy-closure --to "$host" "$outPath" --gzip --use-substitutes - # switch the remote host to the config - output=$(ssh "''${sshOpts[@]}" "$host" "nix-env --profile /nix/var/nix/profiles/system --set $outPath; nohup $outPath/bin/switch-to-configuration switch &" 2>&1) || echo "status code: $?" - echo "output: $output" - if [[ $output != *"Timeout, server ssh not responding"* ]]; then - echo "non-timeout error: $output" - exit 1 - else - exit 0 - fi + ${deployment.run} """) ssh.wait_for_unit("multi-user.target") ssh.succeed("su - operator -c hello 1>&2") diff --git a/deployment/data-model.nix b/deployment/data-model.nix index 6d5f745f..842b1961 100644 --- a/deployment/data-model.nix +++ b/deployment/data-model.nix @@ -18,6 +18,16 @@ let str submodule ; + toBash = + v: + lib.replaceStrings [ "\"" ] [ "\\\\\"" ] ( + if lib.isPath v || builtins.isNull v then + toString v + else if lib.isString v then + v + else + lib.strings.toJSON v + ); functionType = submodule ./function.nix; application-resources = submodule { @@ -65,18 +75,85 @@ let type = nullOr str; example = "/root/.ssh/id_ed25519"; }; + sshOpts = mkOption { + description = "Extra SSH options (`-o`) to use."; + type = types.listOf str; + default = [ ]; + example = "ConnectTimeout=60"; + }; }; }; }; deployment-type = attrTag { ssh-host = mkOption { description = "A deployment by SSH to update a single existing NixOS host."; - type = submodule { + type = submodule (ssh-host: { options = { + system = mkOption { + description = "The architecture of the system to deploy to."; + type = types.str; + }; inherit nixos-configuration; ssh = host-ssh; + module = mkOption { + description = "The module to call to obtain the NixOS configuration from."; + type = types.str; + }; + args = mkOption { + description = "The arguments with which to call the module to obtain the NixOS configuration."; + type = types.attrs; + }; + deployment-name = mkOption { + description = "The name of the deployment for which to obtain the NixOS configuration."; + type = types.str; + }; + root-path = mkOption { + description = "The path to the root of the repository."; + type = types.path; + }; + run = mkOption { + type = types.str; + # error: The option `ssh-deployment.ssh-host.run' is read-only, but it's set multiple times. + # readOnly = true; + default = + let + inherit (ssh-host.config) + system + ssh + module + args + deployment-name + root-path + ; + inherit (ssh) + host + username + key-file + sshOpts + ; + environment = { + key_file = key-file; + deployment_name = deployment-name; + root_path = root-path; + ssh_opts = sshOpts; + inherit + system + host + username + module + args + ; + deployment_type = "ssh-host"; + }; + in + '' + env ${ + toString (lib.mapAttrsToList (k: v: "${k}=\"${toBash v}\"") environment) + } bash ./deployment/run/ssh-single-host/run.sh + ''; + }; }; - }; + }); }; nixops4 = mkOption { description = "A NixOps4 NixOS deployment. For an example, see https://github.com/nixops4/nixops4-nixos/blob/main/example/deployment.nix."; diff --git a/deployment/run/ssh-single-host/run.sh b/deployment/run/ssh-single-host/run.sh new file mode 100755 index 00000000..058f8267 --- /dev/null +++ b/deployment/run/ssh-single-host/run.sh @@ -0,0 +1,49 @@ +#! /usr/bin/env bash +set -xeuo pipefail +declare username host system module args deployment_name deployment_type key_file root_path ssh_opts +IFS=" " read -r -a ssh_opts <<< "$( (echo "$ssh_opts" | jq -r '@sh') | tr -d \'\")" + +# DEPLOY +sshOpts=( + -o BatchMode=yes + -o StrictHostKeyChecking=no +) +for ssh_opt in "${ssh_opts[@]}"; do + sshOpts+=( + -o "$ssh_opt" + ) +done +if [[ -n "$key_file" ]]; then + sshOpts+=( + -i "$key_file" + ) +fi +destination="$username@$host" + +command=(nix-instantiate --show-trace --expr " + import $root_path/deployment/nixos.nix { + system = \"$system\"; + configuration = (import \"$root_path/$module\" (builtins.fromJSON ''$args'')).$deployment_name.$deployment_type.nixos-configuration; + } +") + +# INSTANTIATE +# instantiate the config in /nix/store +"${command[@]}" -A out_path + +# get the realized derivation to deploy +outPath=$(nix-store --realize "$("${command[@]}" --show-trace --eval --strict --json | jq -r '.drv_path')") +# deploy the config by nix-copy-closure +NIX_SSHOPTS="${sshOpts[*]}" nix-copy-closure --to "$destination" "$outPath" --gzip --use-substitutes +# switch the remote host to the config +# shellcheck disable=SC2029 +ssh "${sshOpts[@]}" "$destination" "nix-env --profile /nix/var/nix/profiles/system --set $outPath" +# shellcheck disable=SC2029 +output=$(ssh "${sshOpts[@]}" "$destination" "nohup $outPath/bin/switch-to-configuration switch &" 2>&1) || echo "status code: $?" +echo "output: $output" +if [[ $output != *"Timeout, server $host not responding"* ]]; then + echo "non-timeout error: $output" + exit 1 +else + exit 0 +fi