398 lines
16 KiB
Bash
Executable File
398 lines
16 KiB
Bash
Executable File
#!/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"
|