Feature Specification: Deployment method ssh
Created: 2026-10-07
Status: Implemented
Input: Reconstructed from the code at 093e68b6f (core/effects/ssh/, core/setups/ssh-hosts.nix, core/setups/hosts-common.nix, core/setups/wiring/ssh-defaults.nix, core/effects/common/effect-meta.nix, core/render/hosts.nix, core/checks/deployment/ssh-hosts/, core/checks/deployment/nixos-hosts/), issue #723
The ssh method updates NixOS hosts that already exist. It reaches each
host over SSH, copies the closure and switches the host to it. It creates
no machine, no address and no state. The setup is ssh-hosts, and the
effect is core/effects/ssh/effect.nix.
The deploy body is shared. run.sh defines the SSH transport hooks and then
sources core/effects/common/deploy.sh. The phases, the verdict and its
knobs are in spec.md. The program grammar and the verbs are in
../004-deployment-programs/spec.md. The tf method (see tf.md) runs
the same run.sh for each node from OpenTofu.
User Scenarios & Testing (mandatory)
User Story 1 - Update existing hosts in order (Priority: P1)
An operator has a roster of NixOS hosts that they can reach over SSH. They
run the ssh-hosts program. Each host gets its new configuration, one host
after the other.
Why this priority: This is the only purpose of the method.
Independent Test: deployment-ssh-hosts deploys two targets from a
deployer VM. It checks that the closure's tmpfiles rules apply before the
units start, that both targets serve on :443 from the deployer, and that
TLS ends on the ssl resource. Each target takes a login only on a port
that is not 22. The deploy reaches target1 through the sshOpts of its
own SSH_NODES entry, and target2 through SSH_CONFIG_FILE.
deployment-nixos-hosts boots the same
kind of configuration as test nodes with no transport. It is the baseline
for this check.
Acceptance Scenarios:
- Given two reachable hosts in the node list, When the operator
runs the program, Then the script prints
DEPLOYING <name>for each node in list order and switches each host to its closure. - Given a node that fails, When the program runs, Then the
later nodes still deploy, the script prints
DEPLOY FAILED for <name> (exit N)for each failed node, and the program exits1at the end. - Given a successful switch, When the deploy ends, Then the host's bootloader entry points at the new generation.
User Story 2 - Reach each host with its own identity (Priority: P2)
Hosts of one roster can differ in address, user and key. The operator states one shared identity and, per node, the overrides.
Why this priority: Without overrides, all hosts must share one user, one key and the roster FQDN.
Independent Test: nix-unit-setups (test setups-ssh-defaults) checks
the layering in core/setups/wiring/ssh-defaults.nix. nix-unit-effects
(test env-config) checks that the ssh and tf readers parse SSH_USER
and SSH_NODES. nix-unit-effects (test effects-ssh-option) checks that
an sshOpts item in argv form fails at evaluation, and that the form schema
refuses it with a pattern. deployment-ssh-hosts
deploys a target that takes a login only on the port that the sshOpts of
its own SSH_NODES entry names.
Acceptance Scenarios:
- Given no overrides, When the setup builds a node's
ssh, Then it uses userroot, theSSH_PRIVATE_KEY_FILEkey and the roster host. - Given
sshNodes.defaultsandsshNodes.nodes.<name>, When the setup builds that node'sssh, Then the node entry wins over the defaults, and the defaults win over the built-in values. - Given a node override with
sshOpts, When the setup merges it, Then the list replaces the default list and does not append to it. - Given a node entry that is
null, When the setup merges it, Then the node gets no override. - Given a set node with a
nullfield, When the setup merges it, Then the field inheritssshNodes.defaults, and anullhostgives the roster host. - Given an
sshOptsitem that is not a bareKey=Value, such as-o Port=2222or-J, When the deploy is evaluated, Then the evaluation fails with a message that names the form. - Given
SSH_USER, When the setup builds a node'sssh, Then it uses that user in place ofroot, below bothsshNodeslayers. - Given the form schema of the
ssh-hostssettings, When a front-end validates ansshOptsitem, Then thepatternof the item admits the same items as the type of scenario 6.
User Story 3 - Get through a slow or broken target (Priority: P2)
A target can be slow, can hold the bootloader lock, or can lose its D-Bus during the switch. The deploy works around these faults or reports them.
Why this priority: A deploy that hangs or gives a false success hides the state of the host from the operator.
Independent Test: deployment-ssh-recovery breaks the target from
outside. It covers a blocked start job, a switch that cannot be scheduled, a
hanging systemd-run, a dead bus, a stuck logind, and the bootloader lock
retry and its budget. The deployment-verdict-* checks read the verdict on
the same transport (see spec.md).
Acceptance Scenarios:
- Given a switch that still holds the bootloader lock, When the
bootloader install gets exit
11, Then it tries again every 5 seconds, up toDEPLOY_BOOT_LOCK_ATTEMPTStimes. - Given a target in a container, When the switch runs, Then the
switch has a
RuntimeMaxSec=180limit. On bare metal it has no limit. - Given a container target, When the bootloader install fails,
Then the failure hint tells the operator to use
tf-incus.
User Story 4 - Refuse a host that shows a different key (Priority: P2)
The operator can state the public host key of a node. The deploy then logs in only to a host that shows that key.
Why this priority: The deploy logs in as a user that can switch the system. A host that is not the node must not get that login.
Independent Test: deployment-ssh-host-key drives run.sh and reads
with ssh -G the options that each connection gets, with and without
host_key, and with a host-key option in sshOpts, in SSH_CONFIG_FILE
and for a jump host. deployment-ssh-hosts gives each target a host-key
and deploys to it. Before the deploy, it shows that the key of each target
is the host-key, and that a different host_key stops remote_exec with
Host key verification failed.
Acceptance Scenarios:
- Given a node with
host-key, When the deploy connects, Then it accepts only that key, and a host that shows a different key stops the deploy of that node before the login. - Given a node with no
host-key, When the deploy connects for the first time, Then it accepts the key and saves it. A later deploy that reads the same file refuses a different key. - Given a host-key option in
sshOptsor inSSH_CONFIG_FILE, When the deploy connects, Then that option wins over the values of the deploy.
Edge Cases
- No
key_file, noSSH_PRIVATE_KEY_FILEand nossh-agent:BatchMode=yesmakes SSH fail at once and does not prompt. SSH_CONFIG_FILEsends the host to a different address: the script logs the change.- A
ProxyJump=option: directsshgets it as aProxyCommand, andnix-copy-closurekeeps the original form. - A remote command that hangs: the keepalive ends it after about
REMOTE_EXEC_TIMEOUTseconds (default60). - A target without the deployer's
PATH:remote_execadds the fallback prefix (checkdeployment-remote-path-fallback). - The
combinedenvironment: all nodes go into one configuration on the first host of the roster. - A changed host key: with
host-key, the deploy of that node stops. Without it, the key of the first connection is saved inssh-hosts-known-hosts, and a deploy that reads the same file refuses a different key. A fresh CI runner has a new$TMPDIRand no saved key, so it accepts the key again. - A node that is installed again gets a new host key: change its
host-key, or remove its line fromssh-hosts-known-hosts. - A
ProxyJumphost: it getsaccept-newwith the same file, also when the node has ahost-key. The connection to the node runs end to end through the jump host and checks the key of the node.
Requirements (mandatory)
Functional Requirements
- FR-001: The method MUST deploy the nodes of
nodesone at a time, in list order, in the foreground. - FR-002: A failed node MUST NOT stop the later nodes. The program MUST exit
1when one or more nodes failed. - FR-003: Each node MUST run
run.shwithkey_file,host_key,ssh_opts,host,usernameandnixos_confin the environment, andrun.shMUST sourcedeploy.sh. - FR-004:
nixos_confMUST come fromwriteConfigwitheffect = "ssh-hosts"andmultiNode = true. - FR-005: The key MUST come from
key_file, else fromSSH_PRIVATE_KEY_FILE, else fromssh-agent. - FR-006: Every SSH call MUST use
BatchMode=yes.SSH_CONFIG_FILE, when set, MUST reach SSH as-F. - FR-007:
remote_execMUST useConnectTimeout=10and a keepalive ofServerAliveInterval=5withREMOTE_EXEC_TIMEOUT / 5probes. - FR-008:
copy_closureMUST usenix-copy-closure --to --gzip --use-substitutes. - FR-009: In a container, the switch MUST run with
RuntimeMaxSec=180. - FR-010:
finalize_bootloaderMUST runswitch-to-configuration bootundertimeout 120, and retry exit11every 5 seconds up toDEPLOY_BOOT_LOCK_ATTEMPTS(default60) times. - FR-011:
revert_bootloaderMUST do nothing. - FR-012: The setup MUST layer a node's SSH settings as built-in defaults, then
sshNodes.defaults, thensshNodes.nodes.<name>, and fall back to the roster host. The built-in user MUST beSSH_USER, elseroot. A null or absent field MUST inherit the layer below.hostKeyMUST come only fromsshNodes.nodes.<name>. - FR-013: The method MUST read
SSH_PRIVATE_KEY_FILEat evaluation throughcore/render/hosts.nix, andSSH_USER,SSH_NODESand its other keys through thesshentry ofcore/effects/env-config.nix. - FR-014: With
host_key, every SSH call to the node MUST useStrictHostKeyChecking=yes,GlobalKnownHostsFile=/dev/nulland a temporaryknown_hoststhat holds only* <host_key>. The script MUST remove that file when it exits. - FR-015: Without
host_key, every SSH call to the node MUST useStrictHostKeyChecking=accept-newwith theknown_hostsfilessh-hosts-known-hostsin the directory of the key file, else in$TMPDIR. When neither directory is writable, SSH MUST use its default file. - FR-016: The host-key options of FR-014 and FR-015 MUST come after
ssh_opts, so that a host-key option insshOptswins. The script MUST NOT give a host-key option thatSSH_CONFIG_FILEsets. - FR-017: A
ProxyJumphost MUST getStrictHostKeyChecking=accept-newwith theknown_hostsfile of FR-015. - FR-018: Each item of
sshOptsMUST be a baresshoptionKey=Value, such asPort=2222.run.shMUST put-oin front of each item. The type ofsshOptsin thehost-sshsubmodule and in thessh-hostssettings MUST refuse an item that starts with-or has noKey=(core/effects/common/ssh-option.nix). The type MUST carry a JSON Schemapatternthat admits the same items, read as ECMA-262 and as POSIX ERE, so that the form schema of eachsshOptsitem refuses the items that the type refuses.
Key Entities
- Node:
{deployment-name, nixos-configuration, ssh}. The list order is the deploy order. - Host SSH (
host-ssh):host,username(defaultSSH_USER, elseroot, in the setup),key-file,host-key(one line of thessh_host_*_key.pubof the host, default null) andsshOpts(a list of bareKey=Valueoptions, FR-018). - SSH nodes map (
sshNodes):{defaults, nodes.<name>}, parsed from theSSH_NODESJSON. Every field is optional.hostKeyis a field ofnodes.<name>only. The effect metadatasettingsdescribe it for a front end. - Transport hooks:
remote_exec,copy_closure,switch_runtime_max_args,finalize_bootloader,revert_bootloaderandactivation_failure_hint.
Success Criteria (mandatory)
Measurable Outcomes
- SC-001: After a run, every reachable node runs its new closure, and
deployment-ssh-hostspasses. - SC-002: One unreachable node gives exit
1and aDEPLOY FAILEDline, and does not block the other nodes. - SC-003: A held bootloader lock costs at most
DEPLOY_BOOT_LOCK_ATTEMPTStimes 5 seconds, then the deploy fails. - SC-004:
deployment-ssh-recoveryand the fourdeployment-verdict-*checks pass on the SSH transport. - SC-005: A node with a
host-keythat the host does not show givesHost key verification failedand no login, anddeployment-ssh-host-keypasses. - SC-006: A deploy reaches a node on a port that only the
sshOptsof the node's ownSSH_NODESentry names, and a node on a port that onlySSH_CONFIG_FILEnames, anddeployment-ssh-hostspasses.
Assumptions
- Each host already runs NixOS and accepts SSH as a user that can switch the system.
- The deployer can build every closure, or substitute it.
- A node with no
host-keyis reached over a trusted network for its first connection, because that connection accepts any key. - A host that is a container is deployed with
tf-incus, not with this method.
Known Deviations
- The
calleroption carries a FIXME: it must also work from outside this repository.