test/provision-mac.sh
Ref: Size: 8.3 KiB History
#!/bin/sh
# test/provision-mac.sh — make the macOS box the gates take by name.
#
# `make mac` and `make xos` grade a Mac they are TOLD about (MAC_BOX). This
# is what produces one: a pristine macOS virtual machine, on the LAN, that
# this box can ssh into directly. Run it from here, and eval its one line:
#
# eval "$(test/provision-mac.sh)" # export MAC_BOX=admin@192.168.0.x
# MAC_BUILDER=squirtle make xos
# test/provision-mac.sh --down # when the run is over
#
# The clone lives on a tart HOST — a Mac with tart installed, named by
# --host or by $MAC_BUILDER, since the machine that builds is normally the
# machine that has the VMs. Everything here is on the host EXCEPT the last
# step, which proves the guest answers THIS box.
#
# Bridged, not NAT. `tart run --net-bridged=en0` puts the guest on the same
# LAN as everything else (measured 2026-09-04: it took 192.168.0.170), so
# this box reaches it without a hop through the host and the Linux VM the
# cross-OS gate uses can see its port 22. Under tart's default NAT the guest
# is reachable only from the host, which is why both gates used to spell an
# ssh-through-ssh string for every command they ran there.
#
# The lifecycle is HERE and not in a gate on purpose. The pristine base
# image is a human action (test/vm.sh's rule) and so is the decision to
# spend a machine; a gate that cloned a VM for itself would be a gate that
# could not be pointed at a Mac somebody already had.
#
# The base image is made ONCE, by hand, on the host:
#
# brew trust cirruslabs/cli && brew install cirruslabs/cli/tart
# tart clone ghcr.io/cirruslabs/macos-tahoe-base:latest mux-mac-base
# tart run --no-graphics mux-mac-base & # user admin, password admin
# tart exec mux-mac-base sh -c 'mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo "PUBKEY" >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys'
# tart stop mux-mac-base
#
# PUBKEY is the HOST's own ~/.ssh/id_ed25519.pub, so the host can ssh into
# any clone without a password; `tart exec` needs the guest agent the
# cirruslabs images ship. That key is what step 4 below rides to install
# THIS box's key, and it is the only reason the host is in the picture after
# the boot.
set -eu
usage() {
echo "usage: test/provision-mac.sh [--host SSHNAME] [--name VM] [--iface en0] [--down]"
echo " --host the Mac holding tart and the base image (default: \$MAC_BUILDER)"
echo " --name the clone to make and boot (default: mux-mac)"
echo " --iface the host interface to bridge onto (default: en0)"
echo " --down stop and delete the clone, and print nothing"
}
HOST=${MAC_BUILDER:-}
NAME=mux-mac
IFACE=en0
DOWN=no
VM_BASE=mux-mac-base
GUSER="admin"
while [ $# -gt 0 ]; do
case "$1" in
--host) [ $# -ge 2 ] || { usage >&2; exit 2; }; HOST=$2; shift 2 ;;
--name) [ $# -ge 2 ] || { usage >&2; exit 2; }; NAME=$2; shift 2 ;;
--iface) [ $# -ge 2 ] || { usage >&2; exit 2; }; IFACE=$2; shift 2 ;;
--down) DOWN=yes; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "provision-mac: unknown argument '$1'" >&2; usage >&2; exit 2 ;;
esac
done
[ -n "$HOST" ] || {
echo "provision-mac: no tart host. Pass --host SSHNAME or set MAC_BUILDER." >&2
exit 2
}
# Everything this script says goes to STDERR. Stdout carries exactly one
# line, the `export MAC_BOX=...` a caller evals, so a message that wandered
# onto it would be evaluated as shell.
say() { echo "provision-mac: $*" >&2; }
SSH_OPTS="-o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new"
# The guest's host key is new on every clone, so this box neither records it
# nor checks it; LogLevel=ERROR keeps ssh from announcing the new key on
# stderr every time. The GATE re-learns that key deliberately, because mux's
# own entry dial spawns a plain ssh that reads the real known_hosts.
GUEST_OPTS="-o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR"
# hssh SECONDS — a script on stdin, on the tart host, under /bin/sh.
# /bin/sh and not the host's login zsh: an options string pasted into a
# command line there arrives as one argument, because zsh does not
# word-split an unquoted variable.
hssh() {
_ht=$1
# shellcheck disable=SC2086 # SSH_OPTS is words on purpose
timeout "$_ht" ssh $SSH_OPTS "$HOST" /bin/sh -s
}
if [ "$DOWN" = yes ]; then
hssh 180 >/dev/null 2>&1 <<H || true
export PATH=/opt/homebrew/bin:\$PATH
tart stop $NAME 2>/dev/null || true
tart delete $NAME 2>/dev/null || true
H
say "$NAME stopped and deleted on $HOST"
exit 0
fi
# ---- 1: the host has tart and the base image --------------------------
hssh 60 >/dev/null <<H || {
export PATH=/opt/homebrew/bin:\$PATH
command -v tart >/dev/null || exit 1
tart list 2>/dev/null | awk '{print \$2}' | grep -qx $VM_BASE
H
echo "provision-mac: $HOST has no tart VM called $VM_BASE (or no tart at all)." >&2
echo " Make it once by hand; the recipe is this script's header." >&2
exit 1
}
# THIS box's public key is what the guest must end up trusting. Refused
# rather than generated: a key made behind somebody's back is a key they do
# not know they are using.
[ -f "$HOME/.ssh/id_ed25519.pub" ] || {
echo "provision-mac: this box has no ~/.ssh/id_ed25519.pub to give the guest." >&2
echo " Make one with: ssh-keygen -t ed25519" >&2
exit 1
}
PUB=$(cat "$HOME/.ssh/id_ed25519.pub")
# ---- 2: a fresh clone, booted on the LAN ------------------------------
say "cloning $VM_BASE to $NAME on $HOST and booting it bridged on $IFACE"
hssh 600 >/dev/null <<H || { say "could not clone and boot $NAME on $HOST"; exit 1; }
export PATH=/opt/homebrew/bin:\$PATH
tart stop $NAME >/dev/null 2>&1 || true
tart delete $NAME >/dev/null 2>&1 || true
tart clone $VM_BASE $NAME || exit 1
nohup tart run --no-graphics --net-bridged=$IFACE $NAME >/tmp/$NAME.log 2>&1 &
sleep 1
exit 0
H
# ---- 3: its address, off the host's own arp table ---------------------
# --resolver=arp because a bridged guest takes its lease from the LAN's DHCP
# server and not from tart, so tart has no lease file to read and learns the
# address the same way anything else on the wire does. Polled on the HOST
# rather than one ssh per tick: a tick costs a whole connection setup from
# here, and the address arrived in 6 s when this was measured (2026-09-04).
IP=$(hssh 120 <<H
export PATH=/opt/homebrew/bin:\$PATH
_i=0
while [ \$_i -lt 60 ]; do
_ip=\$(tart ip $NAME --resolver=arp 2>/dev/null) && [ -n "\$_ip" ] && { echo "\$_ip"; exit 0; }
sleep 1; _i=\$((_i + 1))
done
exit 1
H
) || { say "$NAME took no address within 60 s (host log: /tmp/$NAME.log)"; exit 1; }
say "$NAME is at $IP"
# ---- 4: the guest trusts this box, through the host's own key ---------
# The host is the only machine the fresh clone already trusts, so the key
# that lets everything else in rides in over the host's. Idempotent, because
# a re-provision of a clone that survived is an ordinary thing to do.
_i=0
while :; do
if hssh 60 >/dev/null 2>&1 <<H
export PATH=/opt/homebrew/bin:\$PATH
ssh $GUEST_OPTS -i \$HOME/.ssh/id_ed25519 $GUSER@$IP /bin/sh -s <<'G'
set -eu
mkdir -p \$HOME/.ssh
chmod 700 \$HOME/.ssh
touch \$HOME/.ssh/authorized_keys
chmod 600 \$HOME/.ssh/authorized_keys
grep -qxF '$PUB' \$HOME/.ssh/authorized_keys ||
printf '%s\n' '$PUB' >> \$HOME/.ssh/authorized_keys
G
H
then break; fi
_i=$((_i + 1))
[ "$_i" -lt 40 ] || { say "$NAME at $IP never answered the host's ssh within 120 s"; exit 1; }
sleep 3
done
# ---- 5: and this box can reach it DIRECTLY ----------------------------
# The claim the gates rest on, made here where it can still be fixed rather
# than in the middle of a run. -n on the ssh: `timeout` runs its child in a
# background process group, and an ssh reading a terminal's stdin from there
# is stopped by SIGTTIN where the timeout's SIGTERM cannot reach it.
# shellcheck disable=SC2086 # GUEST_OPTS is words on purpose
GARCH=$(timeout 30 ssh -n $GUEST_OPTS "$GUSER@$IP" 'uname -m') || {
say "$GUSER@$IP does not answer ssh from this box — is the LAN bridged onto $IFACE?"
exit 1
}
[ "$GARCH" = "arm64" ] || { say "$GUSER@$IP says it is $GARCH, and the gates want an Apple-silicon Mac"; exit 1; }
say "$GUSER@$IP answers this box directly and is an $GARCH macOS"
echo "export MAC_BOX=$GUSER@$IP"