9e1ab77e
test: one library for the boxes the macOS gates share
a73x 2026-09-04 12:50
Commit message
test/box_lib.sh
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,272 @@ | |||
| 1 | # shellcheck shell=sh | ||
| 2 | # test/box_lib.sh — the boxes the macOS gates take by name, sourced never run. | ||
| 3 | # | ||
| 4 | # `make mac` and `make xos` used to spell one developer's machines in their | ||
| 5 | # own source: a Mac called squirtle, and a Linux VM at a literal LAN | ||
| 6 | # address. Nobody else could run either gate, and neither gate could say | ||
| 7 | # what it was allowed to do to the machines it found — one scrubbed a box to | ||
| 8 | # bare metal, the other tiptoed around a laptop it refused to touch. Both | ||
| 9 | # gates take their boxes as ssh targets in the environment now, and the | ||
| 10 | # arrangements they share live here rather than in three copies. | ||
| 11 | # | ||
| 12 | # LINUX_BOX an x86_64 Linux VM this box can ssh to with key auth. The | ||
| 13 | # gate installs a static-musl mux at ~/.local/bin/mux there. | ||
| 14 | # MAC_BOX an Apple-silicon macOS box this box can ssh to with key | ||
| 15 | # auth, with NO toolchain on it — normally a bridged tart | ||
| 16 | # guest from test/provision-mac.sh. It only RUNS what the | ||
| 17 | # builder built, which is the stronger claim: a bare Mac and | ||
| 18 | # not the machine that compiled the binary. | ||
| 19 | # MAC_BUILDER a Mac holding a checkout with zig and deps/mac-sdk in it. | ||
| 20 | # It BUILDS and does nothing else, so no gate installs a mux | ||
| 21 | # there and none scrubs it. It defaults to $MAC_BOX, which is | ||
| 22 | # right only when the one Mac is both. | ||
| 23 | # | ||
| 24 | # LINUX_BOX and MAC_BOX are VMs the gate MAY scrub, and box_scrub is the | ||
| 25 | # whole of what that means. Neither name has a default: a gate that guessed | ||
| 26 | # a box would eventually guess somebody's laptop. | ||
| 27 | # | ||
| 28 | # Sourced after test/os_oracle.sh, whose now_ms and file/pid helpers these | ||
| 29 | # functions leave alone. | ||
| 30 | |||
| 31 | # The word a refusal opens with, so a failure line reads as its own gate's. | ||
| 32 | BOX_GATE=${BOX_GATE:-box} | ||
| 33 | |||
| 34 | # The refusals in here are the shared ones, and they end the run: a box that | ||
| 35 | # will not take a key or will not reset is not a box the gate can grade. | ||
| 36 | box_die() { | ||
| 37 | echo "$BOX_GATE FAIL: $*" | ||
| 38 | exit 1 | ||
| 39 | } | ||
| 40 | |||
| 41 | # BatchMode so a broken auth fails in seconds rather than prompting a | ||
| 42 | # harness nobody sits in front of. | ||
| 43 | BOX_SSH_BASE="-o BatchMode=yes -o ConnectTimeout=10" | ||
| 44 | |||
| 45 | # The one target whose host key is a fresh clone's, and so is a different key | ||
| 46 | # every run: a MAC_BOX that test/provision-mac.sh just made. A gate's own ssh | ||
| 47 | # to that box therefore keeps no host key at all — recording it would fill | ||
| 48 | # known_hosts with keys no later run ever sees again, and checking it would | ||
| 49 | # refuse every run after the first. LogLevel=ERROR because a throwaway | ||
| 50 | # known-hosts file makes ssh announce the new key on stderr every single | ||
| 51 | # time, and that line would be most of what a green run prints. | ||
| 52 | # | ||
| 53 | # mux's OWN ssh is a different matter: the entry dial spawns a plain | ||
| 54 | # `ssh HOST` that reads the real ~/.ssh/known_hosts and cannot be handed | ||
| 55 | # these options, which is why box_preflight forgets and re-learns that box's | ||
| 56 | # key there. A gate that dials MAC_BOX with mux sets this and does that. | ||
| 57 | BOX_EPHEMERAL=${BOX_EPHEMERAL:-} | ||
| 58 | |||
| 59 | # The ssh options for one target: loose for the ephemeral box above, | ||
| 60 | # accept-new for every other, because the first run against a real box | ||
| 61 | # should record its host key where plain ssh will find it. | ||
| 62 | box_opts_for() { | ||
| 63 | if [ -n "$BOX_EPHEMERAL" ] && [ "$1" = "$BOX_EPHEMERAL" ]; then | ||
| 64 | printf '%s' "$BOX_SSH_BASE -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR" | ||
| 65 | else | ||
| 66 | printf '%s' "$BOX_SSH_BASE -o StrictHostKeyChecking=accept-new" | ||
| 67 | fi | ||
| 68 | } | ||
| 69 | |||
| 70 | # box_ssh SECONDS TARGET — run the script on stdin on TARGET, under /bin/sh, | ||
| 71 | # with $PRELUDE in front of it. | ||
| 72 | # | ||
| 73 | # /bin/sh and not the box's login shell (zsh on a Mac): `$VAR` holding | ||
| 74 | # several words does not word-split in zsh, so an options string pasted into | ||
| 75 | # a command line there arrives as ONE argument and ssh answers "keyword | ||
| 76 | # batchmode extra arguments at end of line". | ||
| 77 | # | ||
| 78 | # The rule for a script handed to this: anything INSIDE it that reads stdin | ||
| 79 | # gets it closed — `-n` for an ssh, `</dev/null` for the rest. The remote sh | ||
| 80 | # is reading its own script off stdin, and a command that drains stdin | ||
| 81 | # swallows the lines after it, silently and with a 0 exit, so they simply | ||
| 82 | # never run. `mux d endpoint` is one of those: it announces on stdout and | ||
| 83 | # then PROXIES, and it ate the two assertions after it the first time the | ||
| 84 | # cross-OS gate ran its entry-dial leg. | ||
| 85 | # | ||
| 86 | # The quoting rule is that there is NO quoting: the script rides this | ||
| 87 | # shell's stdin all the way to the remote `sh -s` and no shell in between | ||
| 88 | # parses it as a command line. The one thing a caller owes it is a heredoc; | ||
| 89 | # called with no redirect it would read the enclosing script's stdin. | ||
| 90 | box_ssh() { | ||
| 91 | _bx_t=$1 | ||
| 92 | _bx_target=$2 | ||
| 93 | _bx_opts=$(box_opts_for "$_bx_target") | ||
| 94 | # shellcheck disable=SC2086 # the options are words on purpose | ||
| 95 | { printf '%s\n' "${PRELUDE:-}"; cat; } | | ||
| 96 | timeout "$_bx_t" ssh $_bx_opts "$_bx_target" /bin/sh -s | ||
| 97 | } | ||
| 98 | |||
| 99 | # box_scp TARGET LOCAL REMOTE — one local file onto TARGET, at a path | ||
| 100 | # relative to that box's $HOME unless it starts with a slash. | ||
| 101 | box_scp() { | ||
| 102 | _bc_target=$1 | ||
| 103 | _bc_opts=$(box_opts_for "$_bc_target") | ||
| 104 | # shellcheck disable=SC2086 # the options are words on purpose | ||
| 105 | scp -q $_bc_opts "$2" "$_bc_target:$3" | ||
| 106 | } | ||
| 107 | |||
| 108 | # box_stream FROM SRCPATH TO DESTPATH — one file from one box to another, | ||
| 109 | # through this one, mode 755 at the far end. | ||
| 110 | # | ||
| 111 | # A `cat` and not an scp, because the two boxes need no key of each other's | ||
| 112 | # for this: the builder holds a binary and the box under test must run it, | ||
| 113 | # and the only machine that can reach both is the one driving the gate. | ||
| 114 | # `cat` carries no mode, so the chmod is explicit — and a caller who cares | ||
| 115 | # that the bytes arrived intact compares the two hashes rather than trusting | ||
| 116 | # this. | ||
| 117 | box_stream() { | ||
| 118 | _bm_from=$1 | ||
| 119 | _bm_src=$2 | ||
| 120 | _bm_to=$3 | ||
| 121 | _bm_dest=$4 | ||
| 122 | _bm_fopts=$(box_opts_for "$_bm_from") | ||
| 123 | _bm_topts=$(box_opts_for "$_bm_to") | ||
| 124 | # shellcheck disable=SC2086 # both option strings are words on purpose | ||
| 125 | ssh -n $_bm_fopts "$_bm_from" "cat $_bm_src" | | ||
| 126 | ssh $_bm_topts "$_bm_to" "cat > $_bm_dest.part && chmod 755 $_bm_dest.part && mv $_bm_dest.part $_bm_dest" | ||
| 127 | } | ||
| 128 | |||
| 129 | # box_preflight NAME TARGET WANT_ARCH [HINT] — the box answers, and it is the | ||
| 130 | # machine this gate is for. Runs for every box BEFORE the gate writes | ||
| 131 | # anything anywhere, so a run that cannot finish has not started either. | ||
| 132 | # | ||
| 133 | # `-n` is load-bearing. `timeout` runs its child in a background process | ||
| 134 | # group and an ssh with no -n reads its stdin to forward it; run from a | ||
| 135 | # terminal that is a background read of the tty, so the kernel stops ssh | ||
| 136 | # with SIGTTIN and the SIGTERM at 20 s cannot land on a stopped process. The | ||
| 137 | # gate sat for 17 minutes on `uname -m` before this was fixed (2026-09-04). | ||
| 138 | # | ||
| 139 | # `uname -m` on macOS prints `arm64` and zig's word for that machine is | ||
| 140 | # `aarch64`, the same one-way normalisation src/cli/main.zig's archMatches | ||
| 141 | # does for `mux d upgrade`'s preflight. Callers therefore spell WANT_ARCH | ||
| 142 | # the zig way for both machines. | ||
| 143 | box_preflight() { | ||
| 144 | _pf_name=$1 | ||
| 145 | _pf_target=$2 | ||
| 146 | _pf_want=$3 | ||
| 147 | _pf_hint=${4:-} | ||
| 148 | if [ -z "$_pf_target" ]; then | ||
| 149 | echo "$BOX_GATE FAIL: $_pf_name is unset, and this gate has no default for it." | ||
| 150 | echo " A box named by hand is a box somebody chose; a guessed one is" | ||
| 151 | echo " eventually somebody's laptop." | ||
| 152 | [ -n "$_pf_hint" ] && printf ' %s\n' "$_pf_hint" | ||
| 153 | exit 2 | ||
| 154 | fi | ||
| 155 | _pf_opts=$(box_opts_for "$_pf_target") | ||
| 156 | # shellcheck disable=SC2086 # the options are words on purpose | ||
| 157 | _pf_arch=$(timeout 20 ssh -n $_pf_opts "$_pf_target" 'uname -m' 2>"${BOX_ERRFILE:-/tmp/box_preflight.err}") || { | ||
| 158 | echo "$BOX_GATE FAIL: cannot ssh to $_pf_name ($_pf_target). It said:" | ||
| 159 | sed 's/^/ /' "${BOX_ERRFILE:-/tmp/box_preflight.err}" 2>/dev/null || true | ||
| 160 | [ -n "$_pf_hint" ] && printf ' %s\n' "$_pf_hint" | ||
| 161 | exit 2 | ||
| 162 | } | ||
| 163 | [ "$_pf_arch" = "arm64" ] && _pf_arch=aarch64 | ||
| 164 | [ "$_pf_arch" = "$_pf_want" ] || { | ||
| 165 | echo "$BOX_GATE FAIL: $_pf_name ($_pf_target) is $_pf_arch; this gate needs $_pf_want there." | ||
| 166 | exit 2 | ||
| 167 | } | ||
| 168 | echo "preflight $_pf_name $_pf_target $_pf_arch" | ||
| 169 | } | ||
| 170 | |||
| 171 | # box_pair A B — A can ssh to B unattended, and can still after a re-run. | ||
| 172 | # | ||
| 173 | # Both directions of the cross-OS gate dial the other box FROM inside a box: | ||
| 174 | # a macOS client's entry dial spawns ssh at the Linux daemon, and a `mux d | ||
| 175 | # upgrade` in the other direction spawns ssh back. Neither box has ever seen | ||
| 176 | # the other on a first run, and a tart guest has never seen anything at all. | ||
| 177 | # | ||
| 178 | # Three steps, each idempotent: A gets a key if it has none, B accepts that | ||
| 179 | # key, and then A actually dials B — which both proves the pairing and | ||
| 180 | # records B's host key on A, where the plain ssh mux spawns will read it. | ||
| 181 | # Writing another machine's authorized_keys is in scope precisely because | ||
| 182 | # both boxes are VMs the gate may scrub. | ||
| 183 | box_pair() { | ||
| 184 | _pr_a=$1 | ||
| 185 | _pr_b=$2 | ||
| 186 | _pr_pub=$(box_ssh 60 "$_pr_a" <<'A' | grep '^ssh-' | head -1 | ||
| 187 | set -eu | ||
| 188 | mkdir -p "$HOME/.ssh" | ||
| 189 | chmod 700 "$HOME/.ssh" | ||
| 190 | [ -f "$HOME/.ssh/id_ed25519" ] || | ||
| 191 | ssh-keygen -q -t ed25519 -N "" -f "$HOME/.ssh/id_ed25519" </dev/null | ||
| 192 | cat "$HOME/.ssh/id_ed25519.pub" | ||
| 193 | A | ||
| 194 | ) | ||
| 195 | [ -n "$_pr_pub" ] || | ||
| 196 | box_die "pair: $_pr_a would not hand over an ssh public key" | ||
| 197 | box_ssh 60 "$_pr_b" >/dev/null <<B || box_die "pair: $_pr_b would not take $_pr_a's key" | ||
| 198 | set -eu | ||
| 199 | mkdir -p "\$HOME/.ssh" | ||
| 200 | chmod 700 "\$HOME/.ssh" | ||
| 201 | touch "\$HOME/.ssh/authorized_keys" | ||
| 202 | chmod 600 "\$HOME/.ssh/authorized_keys" | ||
| 203 | grep -qxF '$_pr_pub' "\$HOME/.ssh/authorized_keys" || | ||
| 204 | printf '%s\n' '$_pr_pub' >> "\$HOME/.ssh/authorized_keys" | ||
| 205 | B | ||
| 206 | box_ssh 60 "$_pr_a" >/dev/null <<B || box_die "pair: $_pr_a still cannot ssh to $_pr_b with its own key" | ||
| 207 | ssh -n -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new $_pr_b true | ||
| 208 | B | ||
| 209 | } | ||
| 210 | |||
| 211 | # box_scrub TARGET — the mux-less box a new user has, on a box the gate is | ||
| 212 | # allowed to scrub. Run at the START of a run and again at the END, so a | ||
| 213 | # failed run leaves nothing behind and the run after it starts from the same | ||
| 214 | # place whatever happened. | ||
| 215 | # | ||
| 216 | # It writes NO shell rc file, which is a deliberate reversal of what the | ||
| 217 | # earlier reset did. Every remote spelling the PRODUCT sends already carries | ||
| 218 | # `PATH="$PATH:$HOME/.local/bin"` in front of it — that is | ||
| 219 | # `handoff.local_bin_append`, on the entry dial's word, on the upgrade | ||
| 220 | # preflight and on the upgrade's push — so a `~/.bashrc` or `~/.zshenv` line | ||
| 221 | # put there by the harness tests nothing the product needs. Worse, it is the | ||
| 222 | # fixture configuring the machine: a bare box that finds mux only because | ||
| 223 | # the gate edited its shell rc is exactly the configured developer machine a | ||
| 224 | # pristine guest exists to rule out. The gate's OWN remote scripts therefore | ||
| 225 | # spell `$HOME/.local/bin/mux` in full, or carry a PATH in the prelude. | ||
| 226 | # | ||
| 227 | # The one arrangement that is (re)made is linger, and only on Linux: without | ||
| 228 | # it logind tears down /run/user/<uid> — the daemon's socket directory — | ||
| 229 | # when the ssh session that started the daemon ends, and reaps the detached | ||
| 230 | # daemon with it. It is separated from the rest by its own exit code, so a | ||
| 231 | # box without passwordless sudo is told what is missing rather than being | ||
| 232 | # handed "the reset was refused". | ||
| 233 | # | ||
| 234 | # Both gates' scratch directory names are removed here, not just the calling | ||
| 235 | # gate's: a box may have run either, and neither should outlive a run. | ||
| 236 | box_scrub() { | ||
| 237 | _sc_rc=0 | ||
| 238 | box_ssh 120 "$1" >/dev/null <<'S' || _sc_rc=$? | ||
| 239 | # Best-effort down to the mkdir: a box with no mux on it answers "no such | ||
| 240 | # process" to half of these, and that is the state this is aiming for. | ||
| 241 | set -u | ||
| 242 | for _m in "$HOME/.local/bin/mux" "$HOME/xos/mux" "$HOME/mux-e2e/mux"; do | ||
| 243 | [ -x "$_m" ] && "$_m" d stop >/dev/null 2>&1 | ||
| 244 | done | ||
| 245 | sleep 0.3 | ||
| 246 | pkill -x mux >/dev/null 2>&1 | ||
| 247 | sleep 0.3 | ||
| 248 | pkill -9 -x mux >/dev/null 2>&1 | ||
| 249 | rm -rf "$HOME/.local/bin/mux" "$HOME/.local/state/mux" "$HOME/.cache/mux" \ | ||
| 250 | "$HOME/.config/mux" "$HOME/xos" "$HOME/mux-e2e" | ||
| 251 | [ -n "${XDG_RUNTIME_DIR:-}" ] && rm -rf "$XDG_RUNTIME_DIR"/mux* | ||
| 252 | case "$(uname)" in | ||
| 253 | Darwin) rm -rf "/tmp/mux-$(id -u)" ;; | ||
| 254 | *) rm -rf "/run/user/$(id -u)"/mux* ;; | ||
| 255 | esac | ||
| 256 | # The install legs copy into this, so its absence is the run's problem. | ||
| 257 | mkdir -p "$HOME/.local/bin" || exit 1 | ||
| 258 | # Its own code, because "no passwordless sudo on this box" is a different | ||
| 259 | # thing for a reader to fix than "the reset was refused". | ||
| 260 | case "$(uname)" in | ||
| 261 | Linux) sudo -n loginctl enable-linger "$(id -un)" >/dev/null 2>&1 || exit 3 ;; | ||
| 262 | esac | ||
| 263 | exit 0 | ||
| 264 | S | ||
| 265 | case "$_sc_rc" in | ||
| 266 | 0) ;; | ||
| 267 | 3) box_die "scrub: $1 refused \`sudo -n loginctl enable-linger\`. Without linger, | ||
| 268 | logind takes /run/user/<uid> down with the ssh session that started | ||
| 269 | the daemon. Give that user passwordless sudo for loginctl." ;; | ||
| 270 | *) box_die "scrub: $1 refused the reset (rc $_sc_rc)" ;; | ||
| 271 | esac | ||
| 272 | } | ||