diff --git a/.envrc b/.envrc index 692b0fb..c385316 100644 --- a/.envrc +++ b/.envrc @@ -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 # direnv doesn't stall on the trust prompt. use flake . --accept-flake-config diff --git a/.gitignore b/.gitignore index b0efdb3..3132de3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .direnv +/.cache result* /.vms/* !/.vms/.gitkeep diff --git a/AGENTS.md b/AGENTS.md index 4336849..354ec92 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,10 @@ Common ones: reboot to cut over. - `ssh-machine [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` - (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 bash -s < script.sh`) rather than `&&`/`for`. 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`). @@ -207,6 +210,8 @@ in churn. - 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 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 reimplementing. - New shared functionality → a module in `*/modules/` + entry in `_list.nix`, options under `my.*`. diff --git a/devshell/commands.nix b/devshell/commands.nix index 523f24b..5980792 100644 --- a/devshell/commands.nix +++ b/devshell/commands.nix @@ -2,6 +2,11 @@ let homeFlake = "$HOME/.config/home-manager/flake.nix"; devKey = ".keys/dev.key"; + sshMachine = pkgs.writeShellApplication { + name = "ssh-machine"; + runtimeInputs = with pkgs; [ coreutils git jq openssh ]; + text = builtins.readFile ./ssh-machine.sh; + }; in { commands = [ @@ -52,20 +57,7 @@ in name = "ssh-machine"; category = "utilities"; help = "SSH to a machine by NixOS system or home-manager config name"; - command = '' - 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" "$@" - ''; + command = ''exec ${sshMachine}/bin/ssh-machine "$@"''; } { name = "json2nix"; diff --git a/devshell/ssh-machine.sh b/devshell/ssh-machine.sh new file mode 100644 index 0000000..fc70a9f --- /dev/null +++ b/devshell/ssh-machine.sh @@ -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] [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[@]}" "$@" diff --git a/docs/deployment.md b/docs/deployment.md index e47921f..1beb6e9 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -58,7 +58,16 @@ Generated profiles deploy in this order: `ssh-machine [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 -`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 [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 bash -s < script.sh`. If a flaky agent stalls public-key authentication, use `SSH_AUTH_SOCK= ssh-machine …`.