09776b98
test: the macOS guest's lifecycle is a provisioning script
a73x 2026-09-04 12:50
Commit message
test/provision-mac.sh
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,194 @@ | |||
| 1 | #!/bin/sh | ||
| 2 | # test/provision-mac.sh — make the macOS box the gates take by name. | ||
| 3 | # | ||
| 4 | # `make mac` and `make xos` grade a Mac they are TOLD about (MAC_BOX). This | ||
| 5 | # is what produces one: a pristine macOS virtual machine, on the LAN, that | ||
| 6 | # this box can ssh into directly. Run it from here, and eval its one line: | ||
| 7 | # | ||
| 8 | # eval "$(test/provision-mac.sh)" # export MAC_BOX=admin@192.168.0.x | ||
| 9 | # MAC_BUILDER=squirtle make xos | ||
| 10 | # test/provision-mac.sh --down # when the run is over | ||
| 11 | # | ||
| 12 | # The clone lives on a tart HOST — a Mac with tart installed, named by | ||
| 13 | # --host or by $MAC_BUILDER, since the machine that builds is normally the | ||
| 14 | # machine that has the VMs. Everything here is on the host EXCEPT the last | ||
| 15 | # step, which proves the guest answers THIS box. | ||
| 16 | # | ||
| 17 | # Bridged, not NAT. `tart run --net-bridged=en0` puts the guest on the same | ||
| 18 | # LAN as everything else (measured 2026-09-04: it took 192.168.0.170), so | ||
| 19 | # this box reaches it without a hop through the host and the Linux VM the | ||
| 20 | # cross-OS gate uses can see its port 22. Under tart's default NAT the guest | ||
| 21 | # is reachable only from the host, which is why both gates used to spell an | ||
| 22 | # ssh-through-ssh string for every command they ran there. | ||
| 23 | # | ||
| 24 | # The lifecycle is HERE and not in a gate on purpose. The pristine base | ||
| 25 | # image is a human action (test/vm.sh's rule) and so is the decision to | ||
| 26 | # spend a machine; a gate that cloned a VM for itself would be a gate that | ||
| 27 | # could not be pointed at a Mac somebody already had. | ||
| 28 | # | ||
| 29 | # The base image is made ONCE, by hand, on the host: | ||
| 30 | # | ||
| 31 | # brew trust cirruslabs/cli && brew install cirruslabs/cli/tart | ||
| 32 | # tart clone ghcr.io/cirruslabs/macos-tahoe-base:latest mux-mac-base | ||
| 33 | # tart run --no-graphics mux-mac-base & # user admin, password admin | ||
| 34 | # tart exec mux-mac-base sh -c 'mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo "PUBKEY" >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys' | ||
| 35 | # tart stop mux-mac-base | ||
| 36 | # | ||
| 37 | # PUBKEY is the HOST's own ~/.ssh/id_ed25519.pub, so the host can ssh into | ||
| 38 | # any clone without a password; `tart exec` needs the guest agent the | ||
| 39 | # cirruslabs images ship. That key is what step 4 below rides to install | ||
| 40 | # THIS box's key, and it is the only reason the host is in the picture after | ||
| 41 | # the boot. | ||
| 42 | set -eu | ||
| 43 | |||
| 44 | usage() { | ||
| 45 | echo "usage: test/provision-mac.sh [--host SSHNAME] [--name VM] [--iface en0] [--down]" | ||
| 46 | echo " --host the Mac holding tart and the base image (default: \$MAC_BUILDER)" | ||
| 47 | echo " --name the clone to make and boot (default: mux-mac)" | ||
| 48 | echo " --iface the host interface to bridge onto (default: en0)" | ||
| 49 | echo " --down stop and delete the clone, and print nothing" | ||
| 50 | } | ||
| 51 | |||
| 52 | HOST=${MAC_BUILDER:-} | ||
| 53 | NAME=mux-mac | ||
| 54 | IFACE=en0 | ||
| 55 | DOWN=no | ||
| 56 | VM_BASE=mux-mac-base | ||
| 57 | GUSER="admin" | ||
| 58 | |||
| 59 | while [ $# -gt 0 ]; do | ||
| 60 | case "$1" in | ||
| 61 | --host) [ $# -ge 2 ] || { usage >&2; exit 2; }; HOST=$2; shift 2 ;; | ||
| 62 | --name) [ $# -ge 2 ] || { usage >&2; exit 2; }; NAME=$2; shift 2 ;; | ||
| 63 | --iface) [ $# -ge 2 ] || { usage >&2; exit 2; }; IFACE=$2; shift 2 ;; | ||
| 64 | --down) DOWN=yes; shift ;; | ||
| 65 | -h|--help) usage; exit 0 ;; | ||
| 66 | *) echo "provision-mac: unknown argument '$1'" >&2; usage >&2; exit 2 ;; | ||
| 67 | esac | ||
| 68 | done | ||
| 69 | |||
| 70 | [ -n "$HOST" ] || { | ||
| 71 | echo "provision-mac: no tart host. Pass --host SSHNAME or set MAC_BUILDER." >&2 | ||
| 72 | exit 2 | ||
| 73 | } | ||
| 74 | |||
| 75 | # Everything this script says goes to STDERR. Stdout carries exactly one | ||
| 76 | # line, the `export MAC_BOX=...` a caller evals, so a message that wandered | ||
| 77 | # onto it would be evaluated as shell. | ||
| 78 | say() { echo "provision-mac: $*" >&2; } | ||
| 79 | |||
| 80 | SSH_OPTS="-o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new" | ||
| 81 | # The guest's host key is new on every clone, so this box neither records it | ||
| 82 | # nor checks it; LogLevel=ERROR keeps ssh from announcing the new key on | ||
| 83 | # stderr every time. The GATE re-learns that key deliberately, because mux's | ||
| 84 | # own entry dial spawns a plain ssh that reads the real known_hosts. | ||
| 85 | GUEST_OPTS="-o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR" | ||
| 86 | |||
| 87 | # hssh SECONDS — a script on stdin, on the tart host, under /bin/sh. | ||
| 88 | # /bin/sh and not the host's login zsh: an options string pasted into a | ||
| 89 | # command line there arrives as one argument, because zsh does not | ||
| 90 | # word-split an unquoted variable. | ||
| 91 | hssh() { | ||
| 92 | _ht=$1 | ||
| 93 | # shellcheck disable=SC2086 # SSH_OPTS is words on purpose | ||
| 94 | timeout "$_ht" ssh $SSH_OPTS "$HOST" /bin/sh -s | ||
| 95 | } | ||
| 96 | |||
| 97 | if [ "$DOWN" = yes ]; then | ||
| 98 | hssh 180 >/dev/null 2>&1 <<H || true | ||
| 99 | export PATH=/opt/homebrew/bin:\$PATH | ||
| 100 | tart stop $NAME 2>/dev/null || true | ||
| 101 | tart delete $NAME 2>/dev/null || true | ||
| 102 | H | ||
| 103 | say "$NAME stopped and deleted on $HOST" | ||
| 104 | exit 0 | ||
| 105 | fi | ||
| 106 | |||
| 107 | # ---- 1: the host has tart and the base image -------------------------- | ||
| 108 | hssh 60 >/dev/null <<H || { | ||
| 109 | export PATH=/opt/homebrew/bin:\$PATH | ||
| 110 | command -v tart >/dev/null || exit 1 | ||
| 111 | tart list 2>/dev/null | awk '{print \$2}' | grep -qx $VM_BASE | ||
| 112 | H | ||
| 113 | echo "provision-mac: $HOST has no tart VM called $VM_BASE (or no tart at all)." >&2 | ||
| 114 | echo " Make it once by hand; the recipe is this script's header." >&2 | ||
| 115 | exit 1 | ||
| 116 | } | ||
| 117 | |||
| 118 | # THIS box's public key is what the guest must end up trusting. Refused | ||
| 119 | # rather than generated: a key made behind somebody's back is a key they do | ||
| 120 | # not know they are using. | ||
| 121 | [ -f "$HOME/.ssh/id_ed25519.pub" ] || { | ||
| 122 | echo "provision-mac: this box has no ~/.ssh/id_ed25519.pub to give the guest." >&2 | ||
| 123 | echo " Make one with: ssh-keygen -t ed25519" >&2 | ||
| 124 | exit 1 | ||
| 125 | } | ||
| 126 | PUB=$(cat "$HOME/.ssh/id_ed25519.pub") | ||
| 127 | |||
| 128 | # ---- 2: a fresh clone, booted on the LAN ------------------------------ | ||
| 129 | say "cloning $VM_BASE to $NAME on $HOST and booting it bridged on $IFACE" | ||
| 130 | hssh 600 >/dev/null <<H || { say "could not clone and boot $NAME on $HOST"; exit 1; } | ||
| 131 | export PATH=/opt/homebrew/bin:\$PATH | ||
| 132 | tart stop $NAME >/dev/null 2>&1 || true | ||
| 133 | tart delete $NAME >/dev/null 2>&1 || true | ||
| 134 | tart clone $VM_BASE $NAME || exit 1 | ||
| 135 | nohup tart run --no-graphics --net-bridged=$IFACE $NAME >/tmp/$NAME.log 2>&1 & | ||
| 136 | sleep 1 | ||
| 137 | exit 0 | ||
| 138 | H | ||
| 139 | |||
| 140 | # ---- 3: its address, off the host's own arp table --------------------- | ||
| 141 | # --resolver=arp because a bridged guest takes its lease from the LAN's DHCP | ||
| 142 | # server and not from tart, so tart has no lease file to read and learns the | ||
| 143 | # address the same way anything else on the wire does. Polled on the HOST | ||
| 144 | # rather than one ssh per tick: a tick costs a whole connection setup from | ||
| 145 | # here, and the address arrived in 6 s when this was measured (2026-09-04). | ||
| 146 | IP=$(hssh 120 <<H | ||
| 147 | export PATH=/opt/homebrew/bin:\$PATH | ||
| 148 | _i=0 | ||
| 149 | while [ \$_i -lt 60 ]; do | ||
| 150 | _ip=\$(tart ip $NAME --resolver=arp 2>/dev/null) && [ -n "\$_ip" ] && { echo "\$_ip"; exit 0; } | ||
| 151 | sleep 1; _i=\$((_i + 1)) | ||
| 152 | done | ||
| 153 | exit 1 | ||
| 154 | H | ||
| 155 | ) || { say "$NAME took no address within 60 s (host log: /tmp/$NAME.log)"; exit 1; } | ||
| 156 | say "$NAME is at $IP" | ||
| 157 | |||
| 158 | # ---- 4: the guest trusts this box, through the host's own key --------- | ||
| 159 | # The host is the only machine the fresh clone already trusts, so the key | ||
| 160 | # that lets everything else in rides in over the host's. Idempotent, because | ||
| 161 | # a re-provision of a clone that survived is an ordinary thing to do. | ||
| 162 | _i=0 | ||
| 163 | while :; do | ||
| 164 | if hssh 60 >/dev/null 2>&1 <<H | ||
| 165 | export PATH=/opt/homebrew/bin:\$PATH | ||
| 166 | ssh $GUEST_OPTS -i \$HOME/.ssh/id_ed25519 $GUSER@$IP /bin/sh -s <<'G' | ||
| 167 | set -eu | ||
| 168 | mkdir -p \$HOME/.ssh | ||
| 169 | chmod 700 \$HOME/.ssh | ||
| 170 | touch \$HOME/.ssh/authorized_keys | ||
| 171 | chmod 600 \$HOME/.ssh/authorized_keys | ||
| 172 | grep -qxF '$PUB' \$HOME/.ssh/authorized_keys || | ||
| 173 | printf '%s\n' '$PUB' >> \$HOME/.ssh/authorized_keys | ||
| 174 | G | ||
| 175 | H | ||
| 176 | then break; fi | ||
| 177 | _i=$((_i + 1)) | ||
| 178 | [ "$_i" -lt 40 ] || { say "$NAME at $IP never answered the host's ssh within 120 s"; exit 1; } | ||
| 179 | sleep 3 | ||
| 180 | done | ||
| 181 | |||
| 182 | # ---- 5: and this box can reach it DIRECTLY ---------------------------- | ||
| 183 | # The claim the gates rest on, made here where it can still be fixed rather | ||
| 184 | # than in the middle of a run. -n on the ssh: `timeout` runs its child in a | ||
| 185 | # background process group, and an ssh reading a terminal's stdin from there | ||
| 186 | # is stopped by SIGTTIN where the timeout's SIGTERM cannot reach it. | ||
| 187 | # shellcheck disable=SC2086 # GUEST_OPTS is words on purpose | ||
| 188 | GARCH=$(timeout 30 ssh -n $GUEST_OPTS "$GUSER@$IP" 'uname -m') || { | ||
| 189 | say "$GUSER@$IP does not answer ssh from this box — is the LAN bridged onto $IFACE?" | ||
| 190 | exit 1 | ||
| 191 | } | ||
| 192 | [ "$GARCH" = "arm64" ] || { say "$GUSER@$IP says it is $GARCH, and the gates want an Apple-silicon Mac"; exit 1; } | ||
| 193 | say "$GUSER@$IP answers this box directly and is an $GARCH macOS" | ||
| 194 | echo "export MAC_BOX=$GUSER@$IP" | ||