#!/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 &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" </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 </dev/null; then cat >>/etc/bashrc </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' Label limit.maxfiles ProgramArguments launchctl limit maxfiles 65536 200000 RunAtLoad ServiceIPC 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 </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 --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"