Merge nucleic/mellow-dewy-falcon-rjhr into main

This commit is contained in:
2026-08-07 00:44:36 -07:00
parent 749f0be4fb
commit bc2b6cd33b
47 changed files with 13159 additions and 0 deletions
+397
View File
@@ -0,0 +1,397 @@
#!/bin/bash
#
# provision.sh — run once inside a freshly installed macOS guest.
#
# Uploaded to /tmp/provision.sh by GuestProvisioner.runProvisionScript and run
# under sudo. Non-secret values arrive via the environment (GUEST_USER,
# GITEA_HOST) rather than as arguments, since arguments are visible to every
# process on the guest via ps. The account password is never passed here at all:
# it is fed to `sudo -S` on stdin from a mode-0600 file, which this script then
# detaches from (see `exec </dev/null` below).
#
# Recognised environment:
# GUEST_USER (required) the runner account to configure.
# GITEA_HOST (optional) hostname of the Gitea instance, pre-seeded into
# /etc/ssh/ssh_known_hosts alongside github.com.
# INSTALL_CLT (optional) "0" skips the Command Line Tools install.
#
# Must be idempotent: `image provision NAME` re-runs it against an existing image.
# Every step below is either a full-file overwrite of a file this script owns or
# a guarded edit, so a second run converges to the same state.
#
# The last line of stdout on success is the marker PROVISION_OK, which
# GuestProvisioner asserts on. Individual hardening steps are best-effort and
# warn rather than abort: a guest that indexes with Spotlight still runs jobs,
# whereas a guest without passwordless sudo does not, so only the load-bearing
# steps are fatal.
set -euo pipefail
# We are invoked as `sudo -S ... /bin/bash /tmp/provision.sh < /tmp/.gmr-auth`,
# and that file holds the account password for sudo's own prompt. sudo consumes
# that line only if it actually prompts — on a re-run the sudoers drop-in this
# script installs is already in place, so it does not, and the password would be
# left at the head of OUR stdin for the first command in here that reads it
# (`softwareupdate` being the realistic candidate). Detach immediately: nothing
# below this line is interactive.
exec </dev/null
GUEST_USER="${GUEST_USER:?GUEST_USER must be set}"
GITEA_HOST="${GITEA_HOST:-}"
INSTALL_CLT="${INSTALL_CLT:-1}"
if [ "$(id -u)" -ne 0 ]; then
echo "provision.sh: must run as root (invoke via sudo)" >&2
exit 1
fi
log() { echo "provision.sh: $*"; }
warn() { echo "provision.sh: WARNING: $*" >&2; }
# macOS ships no timeout(1) — it is GNU coreutils, not BSD. Several steps here
# can block forever (softwareupdate against an unreachable server, ssh-keyscan
# against a firewalled host), and a hung provision looks exactly like a hung VM
# from the host side, so they all get bounded by hand.
#
# Usage: run_with_timeout SECONDS cmd args... → 124 on timeout.
run_with_timeout() {
local secs="$1"
shift
"$@" &
local pid=$!
local waited=0
while kill -0 "$pid" 2>/dev/null; do
if [ "$waited" -ge "$secs" ]; then
kill -TERM "$pid" 2>/dev/null || true
sleep 2
kill -KILL "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
return 124
fi
sleep 1
waited=$((waited + 1))
done
wait "$pid"
}
# Run a command as the runner account, in its own login context.
as_guest_user() {
launchctl asuser "$(id -u "$GUEST_USER")" sudo -u "$GUEST_USER" "$@" 2>/dev/null \
|| sudo -u "$GUEST_USER" "$@"
}
# --------------------------------------------------------------------------
# 1. Passwordless sudo for the runner account
#
# This comes first on purpose: every later step in this script and every later
# command GuestProvisioner issues assumes `sudo -n` works. Validated with
# `visudo -cf` on a temporary file BEFORE moving it into place — a syntax error
# in sudoers locks the account out of sudo entirely, and there is no recovery in
# a headless VM.
# --------------------------------------------------------------------------
log "configuring passwordless sudo for ${GUEST_USER}"
SUDOERS_TMP="$(mktemp /tmp/gmr-sudoers.XXXXXX)"
cat >"$SUDOERS_TMP" <<EOF
# Managed by gitea-macos-runner provision.sh. Do not edit by hand.
${GUEST_USER} ALL=(ALL) NOPASSWD: ALL
Defaults:${GUEST_USER} !requiretty
EOF
if visudo -cf "$SUDOERS_TMP" >/dev/null 2>&1; then
mkdir -p /etc/sudoers.d
chmod 755 /etc/sudoers.d
install -m 0440 -o root -g wheel "$SUDOERS_TMP" /etc/sudoers.d/gitea-macos-runner
rm -f "$SUDOERS_TMP"
log "passwordless sudo installed at /etc/sudoers.d/gitea-macos-runner"
else
rm -f "$SUDOERS_TMP"
echo "provision.sh: generated sudoers drop-in failed validation; refusing to install it" >&2
exit 1
fi
# --------------------------------------------------------------------------
# 2. /usr/local/bin and a PATH that non-interactive SSH sessions actually see
#
# On a clean arm64 macOS install /usr/local does not exist at all, and
# `installer -pkg node.pkg` plus the gitea-runner binary both land there.
#
# The PATH half matters more than it looks: an `ssh host command` invocation
# runs a NON-login, NON-interactive shell, so /etc/zprofile (which is where
# path_helper injects /usr/local/bin) is never sourced. Without this the
# orchestrator's `gitea-runner …` invocation fails with "command not found" even
# though the binary is installed. /etc/zshenv is the one file zsh reads for
# every invocation, login or not.
# --------------------------------------------------------------------------
log "ensuring /usr/local/bin exists and is on PATH for non-login shells"
mkdir -p /usr/local/bin
chown root:wheel /usr/local /usr/local/bin
chmod 755 /usr/local /usr/local/bin
ZSHENV_MARKER="# gitea-macos-runner: ensure /usr/local/bin on PATH"
if [ ! -f /etc/zshenv ] || ! grep -qF "$ZSHENV_MARKER" /etc/zshenv 2>/dev/null; then
cat >>/etc/zshenv <<EOF
${ZSHENV_MARKER}
case ":\$PATH:" in
*:/usr/local/bin:*) ;;
*) export PATH="/usr/local/bin:\$PATH" ;;
esac
EOF
chmod 644 /etc/zshenv
fi
# bash only reads a startup file for non-interactive shells via BASH_ENV, so
# /etc/bashrc is not enough; anything invoking bash non-interactively gets the
# PATH from its parent. Still worth setting for interactive debugging sessions.
BASHRC_MARKER="# gitea-macos-runner: ensure /usr/local/bin on PATH"
if [ ! -f /etc/bashrc ] || ! grep -qF "$BASHRC_MARKER" /etc/bashrc 2>/dev/null; then
cat >>/etc/bashrc <<EOF
${BASHRC_MARKER}
case ":\$PATH:" in
*:/usr/local/bin:*) ;;
*) export PATH="/usr/local/bin:\$PATH" ;;
esac
EOF
fi
# --------------------------------------------------------------------------
# 3. Never sleep, never lock
#
# A guest that sleeps mid-job stops answering SSH and the job dies at jobTimeout
# with no useful diagnostic. `systemsetup` is the blunt instrument and is
# best-effort (it needs Full Disk Access in some configurations and returns
# nonzero without it); `pmset` is the one that actually has to work.
# --------------------------------------------------------------------------
log "disabling sleep, display sleep, and the screen saver"
systemsetup -setsleep Off >/dev/null 2>&1 || warn "systemsetup -setsleep failed (continuing; pmset below is authoritative)"
systemsetup -setcomputersleep Off >/dev/null 2>&1 || true
systemsetup -setdisplaysleep Off >/dev/null 2>&1 || true
systemsetup -setharddisksleep Off >/dev/null 2>&1 || true
pmset -a sleep 0 displaysleep 0 disksleep 0 >/dev/null 2>&1 || warn "pmset sleep settings failed"
# standby/autopoweroff/powernap only exist on some models; ignore failures.
pmset -a standby 0 >/dev/null 2>&1 || true
pmset -a autopoweroff 0 >/dev/null 2>&1 || true
pmset -a powernap 0 >/dev/null 2>&1 || true
pmset -a womp 0 >/dev/null 2>&1 || true
# Screen saver idle time 0 == never. -currentHost because the screensaver
# domain is per-host, and as the user because it is a per-user preference.
as_guest_user defaults -currentHost write com.apple.screensaver idleTime -int 0 >/dev/null 2>&1 \
|| warn "could not disable the screen saver idle timer"
as_guest_user defaults write com.apple.screensaver askForPassword -int 0 >/dev/null 2>&1 || true
as_guest_user defaults write com.apple.screensaver askForPasswordDelay -int 0 >/dev/null 2>&1 || true
# Auto-login keeps the guest's GUI session alive after a reboot, which some
# toolchains (simulators, codesign against the login keychain) depend on.
# VZMacGuestProvisioningOptions.logsInAutomatically already sets this on first
# boot; re-asserting it here keeps `image provision` runs consistent.
defaults write /Library/Preferences/com.apple.loginwindow autoLoginUser -string "$GUEST_USER" >/dev/null 2>&1 || true
# --------------------------------------------------------------------------
# 4. Disable Spotlight indexing
#
# Indexing a checkout and a build directory is pure waste in a VM that is
# destroyed after one job, and it competes for I/O with the build itself.
# --------------------------------------------------------------------------
log "disabling Spotlight indexing"
mdutil -a -i off >/dev/null 2>&1 || warn "mdutil -a -i off failed"
# Drop any index that the installer already built.
mdutil -a -E >/dev/null 2>&1 || true
# --------------------------------------------------------------------------
# 5. Raise file descriptor limits
#
# The stock 256 soft limit is exhausted by npm installs and by Xcode builds of
# any size, and the failure mode ("EMFILE: too many open files") reads like a
# bug in the job rather than in the image.
# --------------------------------------------------------------------------
log "raising the maxfiles limit"
cat >/Library/LaunchDaemons/limit.maxfiles.plist <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>limit.maxfiles</string>
<key>ProgramArguments</key>
<array>
<string>launchctl</string>
<string>limit</string>
<string>maxfiles</string>
<string>65536</string>
<string>200000</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>ServiceIPC</key>
<false/>
</dict>
</plist>
EOF
chown root:wheel /Library/LaunchDaemons/limit.maxfiles.plist
chmod 644 /Library/LaunchDaemons/limit.maxfiles.plist
# Already-loaded is not an error on a re-run, hence the `|| true`.
launchctl load -w /Library/LaunchDaemons/limit.maxfiles.plist >/dev/null 2>&1 || true
# Apply now too, so this boot benefits without a restart.
launchctl limit maxfiles 65536 200000 >/dev/null 2>&1 || true
# --------------------------------------------------------------------------
# 6. Pre-seed known_hosts
#
# Without this, a git+ssh checkout blocks forever on an interactive host-key
# confirmation that nothing will ever answer — and it blocks *silently*, so the
# job just sits there until jobTimeout.
#
# Seeded system-wide (/etc/ssh/ssh_known_hosts) rather than into the user's
# ~/.ssh, so it survives a job that resets the home directory.
# --------------------------------------------------------------------------
log "pre-seeding SSH host keys"
KNOWN_HOSTS=/etc/ssh/ssh_known_hosts
mkdir -p /etc/ssh
touch "$KNOWN_HOSTS"
chmod 644 "$KNOWN_HOSTS"
seed_host_key() {
local host="$1"
[ -n "$host" ] || return 0
# Already present? Nothing to do — keeps re-runs from growing the file.
if ssh-keygen -F "$host" -f "$KNOWN_HOSTS" >/dev/null 2>&1; then
log "host key for ${host} already present"
return 0
fi
local tmp
tmp="$(mktemp /tmp/gmr-keyscan.XXXXXX)"
if run_with_timeout 30 ssh-keyscan -t rsa,ecdsa,ed25519 "$host" >"$tmp" 2>/dev/null && [ -s "$tmp" ]; then
cat "$tmp" >>"$KNOWN_HOSTS"
log "seeded host key for ${host}"
else
warn "ssh-keyscan for ${host} failed or timed out; git+ssh checkouts against it may hang"
fi
rm -f "$tmp"
}
seed_host_key github.com
seed_host_key "$GITEA_HOST"
# Belt and braces: if a keyscan failed, a checkout should fail fast rather than
# block on a prompt no one can answer.
SSHCONF_MARKER="# gitea-macos-runner: never prompt for unknown host keys"
if [ ! -f /etc/ssh/ssh_config ] || ! grep -qF "$SSHCONF_MARKER" /etc/ssh/ssh_config 2>/dev/null; then
cat >>/etc/ssh/ssh_config <<EOF
${SSHCONF_MARKER}
Host *
StrictHostKeyChecking accept-new
BatchMode yes
EOF
fi
# --------------------------------------------------------------------------
# 7. Command Line Tools
#
# Vanilla macOS ships /usr/bin/git as a shim that, on first invocation, pops a
# GUI "install command line developer tools" dialog and blocks. In a headless VM
# nothing answers that dialog, so `git --version` hangs until the job times out.
#
# The touch-file below is how softwareupdate is told to surface CLT packages in
# its list; this is a widely used community technique rather than a documented
# Apple interface, so it is treated as best-effort. If it does not work, the
# fallback is `image provision NAME --xcode-xip PATH`, which installs a full
# Xcode (and with it a real git).
# --------------------------------------------------------------------------
install_command_line_tools() {
if pkgutil --pkg-info=com.apple.pkg.CLTools_Executables >/dev/null 2>&1; then
log "Command Line Tools already installed"
return 0
fi
if [ -x /Applications/Xcode.app/Contents/Developer/usr/bin/git ]; then
log "Xcode is installed; skipping Command Line Tools"
return 0
fi
log "installing Command Line Tools (this can take several minutes)"
local sentinel=/tmp/.com.apple.dt.CommandLineTools.installondemand.in-progress
touch "$sentinel"
local label
label="$(softwareupdate -l 2>/dev/null \
| sed -n 's/^.*Label: \(Command Line Tools.*\)$/\1/p' \
| tail -1 || true)"
local rc=0
if [ -n "$label" ]; then
log "found update label: ${label}"
run_with_timeout 2700 softwareupdate -i "$label" --verbose || rc=$?
else
warn "softwareupdate listed no Command Line Tools package"
rc=1
fi
rm -f "$sentinel"
if [ "$rc" -eq 124 ]; then
warn "Command Line Tools install timed out"
elif [ "$rc" -ne 0 ]; then
warn "Command Line Tools install failed (exit ${rc})"
fi
if [ -d /Library/Developer/CommandLineTools ]; then
xcode-select --switch /Library/Developer/CommandLineTools >/dev/null 2>&1 || true
fi
if pkgutil --pkg-info=com.apple.pkg.CLTools_Executables >/dev/null 2>&1; then
log "Command Line Tools installed"
return 0
fi
return 1
}
if [ "$INSTALL_CLT" != "0" ]; then
if ! install_command_line_tools; then
warn "Command Line Tools are not installed. git will not work in this guest."
warn "Re-run with: image provision <NAME> --xcode-xip /path/to/Xcode.xip"
fi
else
log "INSTALL_CLT=0; skipping Command Line Tools"
fi
# --------------------------------------------------------------------------
# 8. Sanity checks
#
# Node.js and the gitea-runner binary are installed separately by
# GuestProvisioner (host-side download, then upload), not here, so their absence
# at this point is expected and only reported.
#
# NOTE for future edits: do NOT write a gitea-runner config.yaml that sets
# runner.labels. That key silently overrides the --labels passed at
# registration, and the runner would advertise labels the server never matches.
# --------------------------------------------------------------------------
log "running sanity checks"
export PATH="/usr/local/bin:$PATH"
if ! command -v bash >/dev/null 2>&1; then
echo "provision.sh: bash is missing — this guest cannot run Gitea Actions" >&2
exit 1
fi
log "bash: $(bash --version | head -1)"
# Guarded by the CLT check so this cannot be the call that hangs on the GUI
# installer dialog.
if pkgutil --pkg-info=com.apple.pkg.CLTools_Executables >/dev/null 2>&1 \
|| [ -x /Applications/Xcode.app/Contents/Developer/usr/bin/git ]; then
if run_with_timeout 60 git --version >/dev/null 2>&1; then
log "git: $(git --version)"
else
warn "git is present but did not respond within 60s"
fi
else
warn "git is unavailable (no Command Line Tools); host-side verifyToolchain will fail the build"
fi
command -v node >/dev/null 2>&1 && log "node: $(node --version)" || log "node: not installed yet (host installs it next)"
command -v gitea-runner >/dev/null 2>&1 && log "gitea-runner: present" || log "gitea-runner: not installed yet (host installs it next)"
log "host-side steps remaining: Node.js, gitea-runner binary"
echo "PROVISION_OK"