feat(fleet): run one command, or a package upgrade, across many servers - #16
Merged
Conversation
DiskPush moves bytes to a server. This adds the other thing you do with a
list of servers: run work on all of them.
diskpush fleet check --on tag:production # what does each one need
diskpush fleet upgrade --on tag:production --sudo
diskpush fleet run "systemctl reload nginx" --on 'web-*' --sudo
diskpush fleet script ./deploy.sh --on all '!db-01'
In the desktop app it is the Fleet button: tick servers, pick a recipe or
type a command, watch each host report on its own.
New package `packages/fleet-core`, with no Electron in it, the same way
`rsync-core` has none. It takes connections and a script and produces a
stream of per-host events; it opens sessions only through a `connect`
function the caller supplies, so the CLI opens one connection per host and
closes it while the desktop hands it a pooled session it keeps — and so the
runner is testable without a network.
Upgrades detect the package manager on each host, inside the script, so one
command covers a fleet mixing Debian, Rocky and Alpine (apt, dnf, yum,
zypper, pacman, apk, brew, pkg). Every invocation is non-interactive and
keeps the installed config file where the question comes up. Nothing is
removed: upgrade, never dist-upgrade or autoremove. Rebooting is off by
default and the run reports which servers need one.
The decisions worth knowing about:
- Script text is never interpolated into a command line. It goes to the
remote interpreter on stdin; the command line only names the interpreter.
A quote, a backtick or a newline in a script cannot become a different
command.
- A selector term matching nothing is an error, not a smaller fleet. `--on
web-O3` with a letter O fails rather than quietly patching eleven of
twelve servers. Same for an exclusion, which was meant to protect a host.
- `unreachable` is not `failed`. A server that was switched off did not run
the command, and collapsing those two is how a fleet tool reports a
powered-down box as a failed deploy.
- A script matching a known way to lose a machine needs confirming first,
the same bargain Mirror makes on the transfer side. Re-checked in the
Electron main process, so a renderer that skipped the dialog cannot skip
the check with it. It is a tripwire against the accident, not a sandbox.
- `--sudo` uses `sudo -n`, which fails rather than hanging on a prompt
nobody can see. `--sudo-password` asks once without echo and feeds
`sudo -S` on stdin: never stored, never logged, never on a command line
where `ps` on the server would show it.
- Unknown host keys fail during a fan-out instead of prompting one at a
time; `--accept-new` is opt-in per run. A changed key is still never
accepted.
- A run records the script it ran and the server names it ran on, not
pointers to them, so editing a saved command cannot rewrite the history of
what was executed on production last Tuesday.
- Exit 71 when any server did not succeed. One code rather than a failing
host's own status: across twelve servers there may be several, and picking
one would mean inventing a winner. `--json` carries them all.
Also adds `SshSession.execStream`, since `exec` buffers to completion and a
fifteen-minute upgrade is only watchable if the output arrives while it
happens.
Verified against real SSH: streaming interleaves across hosts, per-host exit
codes and timeouts are reported correctly, script bytes round-trip
unmangled, and an unreachable host stays distinct from a failing one. 36 new
tests; suite is 452 across 38 files.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UeSWg1Czsb2Lwxj8vHUnA4
`--sudo-password` used the widely-copied readline recipe for hidden input:
create the interface with `terminal: true` and override `_writeToOutput` to
swallow everything but the prompt. On current Node that override is not
reached — the interface writes its refreshed line straight to the output, so
the password appeared on screen as it was typed and stayed in the terminal's
scrollback afterwards.
Found by driving the prompt under a pty, which is the only way to see it:
piping into it without a tty takes the `isTTY` guard instead, and the code
looks right by reading.
$ printf 'hunter2\n' | script -qc "node probe.mjs" /dev/null | cat -A
^[[1G^[[0Jsudo password: ^[[16Ghunter2^M$
^^^^^^^ echoed
Replaced with raw mode and a manual read, so the tty does no echoing of its
own: nothing leaks by default, rather than by our getting the interception
right. Ctrl-C is honoured explicitly, since raw mode swallows the signal, and
anything typed after the newline in the same chunk is pushed back with
`unshift` rather than swallowed — it belongs to whoever reads stdin next.
Verified under a pty: the prompt now shows `sudo password: ` with nothing
after it, the value still arrives intact, and an ordinary readline prompt
afterwards works, which is what proves the terminal was restored.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UeSWg1Czsb2Lwxj8vHUnA4
The rule-by-rule tests assert that each pattern matches what it is for. They say nothing about the property that decides whether the guard is worth having: that it stays quiet during ordinary work. That is not hypothetical. An earlier `\breboot\b` rule flagged `reboot=no`, `/var/run/reboot-required` and `echo "reboot required"`, so DiskPush's own read-only status recipe demanded confirmation before *reading a file*. A tripwire that fires on routine work is one people learn to click through, and then it protects nothing on the day it is right. So: ten real scripts on each side, asserted whole rather than line by line. Ordinary — a node deploy, an nginx reload, docker cleanup, log rotation, a pg backup, cert renewal, a scoped chown, a reboot-required check, read-only firewall status, a disk check. Hazardous — the ten ways a fleet loses machines, one per rule. Currently 0 false positives and 0 misses. Either number moving is the signal. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UeSWg1Czsb2Lwxj8vHUnA4
Both were invisible to a reading of the code and obvious the moment a recipe
was actually executed under the shell a fleet run uses. Neither had a test,
because the existing ones assert what the recipes *contain*, not what they
*do*.
check-updates is a probe, and its commands fail as part of doing their job:
`grep -c` exits 1 when it counts zero updates. Under `sh -e` that ended the
script, so a fully patched server reported as unreachable. `checkFleet` had
worked around it by passing `failFast: false`, which fixed exactly one of the
five ways to run that script — `fleet run --command check-updates`, `fleet
script` on a copy, and the desktop's recipe chip all still broke, and the
copy someone edits would break too. The script now opens with `set +e`, so
the property travels with the text; the caller-side override is gone, leaving
one mechanism rather than two.
reboot-required ended its first branch with
[ -f /var/run/reboot-required.pkgs ] && cat /var/run/reboot-required.pkgs
whose status is the `if`'s status, which is the script's. So a server that
genuinely needed a reboot but had no package list — the branch that exists to
say "yes, reboot this one" — exited 1 and reported as failed. Verified
directly: the old form exits 1 on that path, the new `cat ... || true` form
exits 0 and still prints the list when there is one.
Adds `recipes.test.ts`, which runs every read-only recipe for real under
`/bin/sh -es` and asserts exit 0, plus the specific no-package-list case.
`upgrade` is excluded there because it installs packages.
While writing that: `execFile` has no `input` option, so a probe that passes
one hands the shell nothing and waits on stdin until the timeout. The helper
writes to `child.stdin` and closes it, with a comment, because that failure
looks exactly like a hung command.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UeSWg1Czsb2Lwxj8vHUnA4
The doc said an unreportable count shows as an em dash; the CLI table renders a hyphen. A reference that describes output the reader can see in front of them has to match it exactly, or it teaches them not to trust the rest. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UeSWg1Czsb2Lwxj8vHUnA4
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
DiskPush moves bytes to a server. This adds the other thing you do with a list of servers: run work on all of them.
In the desktop app it is the Fleet button in the header: tick servers on the left, pick a recipe or type a command, watch each host report on its own.
What's here
packages/fleet-core— new package, no Electron in it, the same wayrsync-corehas none. Takes connections and a script, produces a stream of per-host events. It opens sessions only through aconnectfunction the caller supplies, so the CLI opens one connection per host and closes it while the desktop hands it a pooled session it keeps — and so the runner is testable without a network.diskpush fleet—run,script,upgrade,check,servers,commands,runs,show.check-updates,upgrade,reboot-required,disk,uptime,who. They can be copied but not edited, so upgrading DiskPush never silently changes a command someone relies on.002-fleet— saved commands, runs, and per-host results.SshSession.execStream—execbuffers to completion, and a fifteen-minute upgrade is only watchable if the output arrives while it happens.docs/fleet.md, plus updates to the README, CLI reference, desktop, security and architecture docs.Selecting servers
--ontakes names, globs,tag:NAME,host:GLOB,all, and!TERMto exclude. Includes are unioned then exclusions subtracted, so order does not matter. Saved connections and~/.ssh/confighosts are both selectable.Upgrades on a fleet that is not all the same
The package manager is detected on each host, inside the script, so one command covers Debian, Rocky and Alpine together —
apt,dnf,yum,zypper,pacman,apk,brew,pkg. Every invocation is non-interactive and answers "keep the installed config file" where the question comes up, because a fleet upgrade that stops on a conffile prompt on host four has already failed. Nothing is removed:upgrade, neverdist-upgradeorautoremove. Rebooting is off by default; the run reports which servers need one.The decisions worth reviewing
--on web-O3with a letter O fails rather than quietly patching eleven of twelve servers. Same for an exclusion, which was meant to protect a host.unreachableis notfailed. A server that was switched off never ran the command. Collapsing those two is how a fleet tool reports a powered-down box as a failed deploy.--sudousessudo -n, which fails rather than hanging on a prompt nobody can see.--sudo-passwordasks once without echo and feedssudo -Son stdin: never stored, never logged, never on a command line wherepson the server would show it.--accept-newis opt-in per run. A changed key is still never accepted.--jsoncarries them all.Verification
Tests: 486 across 40 files (was 416/35). 70 new, covering selection, the hazard guard, command building and quoting, upgrade-script generation and output parsing, the runner (concurrency bounds, abort, failed/unreachable/timeout), fleet persistence, the IPC contract, and the renderer's event reducer.
Two of those test files exist because reading the code was not enough:
recipes.test.tsruns every read-only recipe for real under/bin/sh -esand asserts exit 0.upgradeis excluded there because it installs packages.guard-calibration.test.tsruns ten realistic scripts (a node deploy, an nginx reload, docker cleanup, log rotation, a pg backup, cert renewal, a scoped chown, a reboot-required check, read-only firewall status, a disk check) and asserts the guard stays silent, plus ten genuinely dangerous ones and asserts it fires. Currently 0 false positives, 0 misses — either number moving is the signal.Bugs found while verifying
Eight, six of which were only visible by running the thing rather than reading it:
--sudo-passwordprompt echoed the password. The widely-copied readline_writeToOutputrecipe for hidden input silently does not work on current Node. Only visible under a pty; piping into it takes theisTTYguard instead. Rewritten with raw mode.sh -emade the status probe report healthy servers asunreachable—grep -cexits 1 when it counts zero updates. The workaround only fixed one of the five ways to run that script, so the fix moved into the script itself asset +e.reboot-requiredfailed on the branch that exists to say "yes, reboot this one" —[ -f pkgs ] && cat pkgsas the last statement of thethenblock makes its status theif's status, so a server needing a reboot with no package list reported as failed.reboot=noandecho "reboot required"— DiskPush's own read-only recipe demanded confirmation to read a file.--print-commandpolluted stdout with a three-line preamble, and--reboot --print-commandstopped to ask a question nobody was there to answer.70(internal) instead of65, and bypassed JSON error output under--json.Also verified
Live against a real SSH server: streaming interleaves across hosts as it happens, per-host exit codes and timeouts are reported correctly, script bytes round-trip unmangled through quotes / backticks /
$VAR/ pipes / redirects, an unreachable host stays distinct from a failing one,--stop-on-errorleaves queued hosts untouched, and every shipped recipe runs clean with no flags.The desktop smoke test passes with the Electron main process actually loading the new ESM dependency — the failure mode that script was written for.
🤖 Generated with Claude Code
https://claude.ai/code/session_01UeSWg1Czsb2Lwxj8vHUnA4