devshell: Cache SSH connection settings

Cache all deploy node connection settings and invalidate them when
HEAD or tracked working-tree contents change. Preserve SSH argument
boundaries and support explicit refreshes.
This commit is contained in:
2026-11-15 13:52:52 +00:00
parent 99f105ed5f
commit 93ac20e053
6 changed files with 98 additions and 17 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
watch_file devshell/{default,commands,install,vm-tasks}.nix watch_file devshell/*
# --accept-flake-config trusts the flake's nixConfig (our Harmonia cache) non-interactively, so # --accept-flake-config trusts the flake's nixConfig (our Harmonia cache) non-interactively, so
# direnv doesn't stall on the trust prompt. # direnv doesn't stall on the trust prompt.
use flake . --accept-flake-config use flake . --accept-flake-config
+1
View File
@@ -1,4 +1,5 @@
.direnv .direnv
/.cache
result* result*
/.vms/* /.vms/*
!/.vms/.gitkeep !/.vms/.gitkeep
+6 -1
View File
@@ -55,7 +55,10 @@ Common ones:
reboot to cut over. reboot to cut over.
- `ssh-machine <name> [cmd]` — SSH to a NixOS system or home-manager config by name. Resolves the - `ssh-machine <name> [cmd]` — SSH to a NixOS system or home-manager config by name. Resolves the
target and ssh options (identity, port) from its deploy-rs node, so it needs `my.deploy.enable` target and ssh options (identity, port) from its deploy-rs node, so it needs `my.deploy.enable`
(same gate as `deploy`). Boxes default to the `fish` login shell, so pipe multi-statement remote (same gate as `deploy`). Connection settings for all nodes are cached together and invalidated
by tracked Git changes; use `ssh-machine --refresh` to force a refresh
(see `docs/deployment.md#ssh-machine`).
Boxes default to the `fish` login shell, so pipe multi-statement remote
scripts through `bash` (e.g. `ssh-machine <name> bash -s < script.sh`) rather than `&&`/`for`. scripts through `bash` (e.g. `ssh-machine <name> bash -s < script.sh`) rather than `&&`/`for`.
If outbound SSH hangs at the publickey step (flaky `ssh-agent`), disable the agent for the call: If outbound SSH hangs at the publickey step (flaky `ssh-agent`), disable the agent for the call:
`SSH_AUTH_SOCK= ssh-machine …` (or add `-o IdentityAgent=none` to a raw `ssh`). `SSH_AUTH_SOCK= ssh-machine …` (or add `-o IdentityAgent=none` to a raw `ssh`).
@@ -207,6 +210,8 @@ in churn.
- Comment where it genuinely aids understanding, but not for trivial/obvious code — match the file's - Comment where it genuinely aids understanding, but not for trivial/obvious code — match the file's
existing (fairly sparse) comment density. When adding something general, comment its general existing (fairly sparse) comment density. When adding something general, comment its general
purpose, not the specific change or one-off reason it was introduced for. purpose, not the specific change or one-off reason it was introduced for.
- Separate logical steps in code with blank lines. Keep closely related statements together, and leave a
blank line before a comment that introduces the next step.
- Prefer `lib.my` helpers (`mkOpt'`, `mkBoolOpt'`, `mkDefault'`) and `lib.my.c` constants over - Prefer `lib.my` helpers (`mkOpt'`, `mkBoolOpt'`, `mkDefault'`) and `lib.my.c` constants over
reimplementing. reimplementing.
- New shared functionality → a module in `*/modules/` + entry in `_list.nix`, options under `my.*`. - New shared functionality → a module in `*/modules/` + entry in `_list.nix`, options under `my.*`.
+6 -14
View File
@@ -2,6 +2,11 @@
let let
homeFlake = "$HOME/.config/home-manager/flake.nix"; homeFlake = "$HOME/.config/home-manager/flake.nix";
devKey = ".keys/dev.key"; devKey = ".keys/dev.key";
sshMachine = pkgs.writeShellApplication {
name = "ssh-machine";
runtimeInputs = with pkgs; [ coreutils git jq openssh ];
text = builtins.readFile ./ssh-machine.sh;
};
in in
{ {
commands = [ commands = [
@@ -52,20 +57,7 @@ in
name = "ssh-machine"; name = "ssh-machine";
category = "utilities"; category = "utilities";
help = "SSH to a machine by NixOS system or home-manager config name"; help = "SSH to a machine by NixOS system or home-manager config name";
command = '' command = ''exec ${sshMachine}/bin/ssh-machine "$@"'';
name="$1"
shift
# Run from the project root so deploy's relative identity path (.keys/deploy.key) resolves
cd "$PRJ_ROOT"
# deploy-rs node names are the system/home name, with `@` mangled to `-at-`
node="''${name//@/-at-}"
# Single eval: resolve `user@host` plus merged (global + node) deploy-rs ssh options
info="$(nix eval --raw .#deploy --apply 'd: let n = d.nodes."'"$node"'"; in "''${n.sshUser}@''${n.hostname} ''${builtins.concatStringsSep " " (d.sshOpts ++ n.sshOpts)}"')"
target="''${info%% *}"
opts="''${info#* }"
# shellcheck disable=SC2086
exec ${pkgs.openssh}/bin/ssh $opts "$target" "$@"
'';
} }
{ {
name = "json2nix"; name = "json2nix";
+74
View File
@@ -0,0 +1,74 @@
set -euo pipefail
refresh=false
if [[ ${1:-} == --refresh ]]; then
refresh=true
shift
fi
if [[ $# == 0 && $refresh == false ]] || [[ ${1:-} == --help ]]; then
echo 'Usage: ssh-machine [--refresh] <name> [cmd ...]'
echo ' ssh-machine --refresh'
exit 0
fi
# Resolve deploy's relative identity path from the checkout root.
cd "$PRJ_ROOT"
cache_dir=.cache/ssh-machine
cache=$cache_dir/connections.json
fingerprint() {
{
git rev-parse HEAD &&
git diff --no-ext-diff --no-textconv --binary HEAD -- &&
git diff --no-ext-diff --no-textconv --binary --cached --
} | sha256sum | cut -d ' ' -f 1
}
key=$(fingerprint)
if [[ $refresh == true ]] || ! jq -e --arg key "$key" \
'.fingerprint == $key and (.nodes | type == "object")' "$cache" >/dev/null 2>&1; then
mkdir -p "$cache_dir"
tmp=$(mktemp "$cache_dir/.connections.XXXXXXXX")
trap 'rm -f "$tmp"' EXIT
echo 'Refreshing ssh-machine connection cache…' >&2
# Interpolation here belongs to Nix, not the shell.
# shellcheck disable=SC2016
nix eval --json .#deploy --apply '
d: builtins.mapAttrs (_: n:
d.sshOpts ++ n.sshOpts ++ [ "${n.sshUser}@${n.hostname}" ]
) d.nodes
' > "$tmp"
if [[ $(fingerprint) != "$key" ]]; then
echo 'Checkout changed during evaluation; retry ssh-machine.' >&2
exit 1
fi
payload=$(jq -c --arg key "$key" '{fingerprint: $key, nodes: .}' "$tmp")
printf '%s\n' "$payload" > "$tmp"
mv -f "$tmp" "$cache"
trap - EXIT
fi
[[ $# -gt 0 ]] || exit 0
name=$1
shift
# deploy-rs mangles home configuration names containing @.
node=${name//@/-at-}
args=$(jq -ce --arg node "$node" '
.nodes | if has($node) then .[$node]
else error("Unknown deploy node: " + $node) end
' "$cache")
mapfile -d '' -t ssh_args < <(jq -j '.[] | ., "\u0000"' <<< "$args")
exec ssh "${ssh_args[@]}" "$@"
+10 -1
View File
@@ -58,7 +58,16 @@ Generated profiles deploy in this order:
`ssh-machine <name> [cmd]` resolves `user@host` and the merged global/node `sshOpts` from `ssh-machine <name> [cmd]` resolves `user@host` and the merged global/node `sshOpts` from
`.#deploy`, using the same `my.deploy.enable` gate and `@` → `-at-` mangling as `deploy`, then runs `.#deploy`, using the same `my.deploy.enable` gate and `@` → `-at-` mangling as `deploy`, then runs
`ssh`. Boxes default to `fish`, so pipe multi-statement remote scripts through `bash`, for example `ssh`. Connection settings for all nodes are cached together in `.cache/ssh-machine/`;
normal connections read the cache without running Nix. A fingerprint of Git HEAD and
staged/unstaged tracked changes automatically invalidates it, including newly added files
after `git add`.
Untracked and ignored files do not affect the fingerprint. Any tracked edit, even an unrelated
one, causes the next connection to refresh all nodes. Use `ssh-machine --refresh` to force a
refresh, or `ssh-machine --refresh <name> [cmd]` to refresh and connect. Refresh failures stop
the command; the previous cache is never used as a fallback.
Boxes default to `fish`, so pipe multi-statement remote scripts through `bash`, for example
`ssh-machine <name> bash -s < script.sh`. If a flaky agent stalls public-key authentication, use `ssh-machine <name> bash -s < script.sh`. If a flaky agent stalls public-key authentication, use
`SSH_AUTH_SOCK= ssh-machine …`. `SSH_AUTH_SOCK= ssh-machine …`.