Nucleic: Gitea Runner macOS VM Support
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
<?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>CFBundleIdentifier</key>
|
||||
<string>xyz.blakeslee.gitea-macos-runner</string>
|
||||
|
||||
<key>CFBundleName</key>
|
||||
<string>GiteaMacosRunner</string>
|
||||
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Gitea macOS Runner</string>
|
||||
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>gitea-macos-runner</string>
|
||||
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.1.0</string>
|
||||
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
|
||||
<!--
|
||||
An agent app: no Dock icon, no menu bar. The daemon still needs a real
|
||||
NSApplication run loop for Virtualization.framework, but nothing about it
|
||||
should be user-visible. Mirrored at runtime by
|
||||
NSApplication.shared.setActivationPolicy(.prohibited).
|
||||
-->
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>26.0</string>
|
||||
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string></string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"_comment": "Example configuration for gitea-macos-runner. Copy to ~/.config/gitea-macos-runner/config.json and edit. Keys beginning with an underscore are comments and are ignored by the loader.",
|
||||
|
||||
"gitea": {
|
||||
"_comment": "How to reach Gitea and how to authenticate. The token must belong to a Gitea ADMIN: every endpoint used lives under /api/v1/admin/actions/.",
|
||||
"instanceURL": "https://gitea.example.com",
|
||||
|
||||
"_comment_adminToken": "Admin API token. Set EXACTLY ONE of adminToken and adminTokenFile: setting both, or neither, is rejected at load. Prefer adminTokenFile so the secret is not sitting in a world-readable JSON file.",
|
||||
"adminTokenFile": "~/.config/gitea-macos-runner/admin-token",
|
||||
|
||||
"_comment_registrationToken": "The shared runner registration token. IMPORTANT: registration tokens are REUSABLE and scope-wide, and minting a new one INVALIDATES every prior token for that scope. Never pre-generate one per VM. The recommended setup is to seed a fixed token server-side with GITEA_RUNNER_REGISTRATION_TOKEN and point registrationTokenFile at a copy of it.",
|
||||
"registrationTokenFile": "~/.config/gitea-macos-runner/registration-token",
|
||||
|
||||
"_comment_fetchViaAPI": "When no static registration token is configured, fetch one from POST /api/v1/admin/actions/runners/registration-token. Off by default: that endpoint returns the scope's active token, and any behaviour change that made it mint a fresh one would invalidate tokens held by runners elsewhere.",
|
||||
"fetchRegistrationTokenViaAPI": false
|
||||
},
|
||||
|
||||
"runner": {
|
||||
"_comment": "Identity of the ephemeral runners registered inside each guest.",
|
||||
|
||||
"_comment_labels": "BARE label names, matched case-sensitively against a job's runs-on. The ':host' schema suffix is added only when calling `gitea-runner register --labels`; the server never stores it.",
|
||||
"labels": ["macos-arm64"],
|
||||
|
||||
"_comment_namePrefix": "Prefix for generated runner names. Each VM registers as <prefix><uuid>, globally unique, which is what lets the reconcile loop identify and delete rows orphaned by an unclean VM death.",
|
||||
"namePrefix": "macos-vm-",
|
||||
|
||||
"_comment_download": "Release asset for the gitea-runner binary installed into the guest. {version} is substituted. The binary is v3.x, renamed from act_runner and published from gitea.com/gitea/runner.",
|
||||
"runnerDownloadURL": "https://gitea.com/gitea/runner/releases/download/v{version}/gitea-runner-{version}-darwin-arm64",
|
||||
"version": "3.0.2"
|
||||
},
|
||||
|
||||
"scheduler": {
|
||||
"_comment": "Polling cadence, concurrency, and the timeouts that bound a stuck VM.",
|
||||
|
||||
"_comment_maxConcurrentVMs": "Hard-clamped to 2. Apple's kernel allows at most two concurrent macOS guests per host; a third start() fails with VZError.virtualMachineLimitExceeded.",
|
||||
"maxConcurrentVMs": 2,
|
||||
|
||||
"pollIntervalSeconds": 5,
|
||||
|
||||
"_comment_reconcile": "How often to sweep Gitea for orphaned runner rows. Gitea itself only sweeps runner rows at midnight, and never sweeps a runner that claimed no task, so this loop is not optional.",
|
||||
"reconcileIntervalSeconds": 300,
|
||||
|
||||
"_comment_jobTimeout": "Wall-clock ceiling on a single job before its VM is destroyed. Should be comfortably under Gitea's own ABANDONED_JOB_TIMEOUT (default 24h).",
|
||||
"jobTimeoutMinutes": 120,
|
||||
|
||||
"_comment_bootTimeout": "Ceiling on clone + boot + DHCP lease + SSH readiness before the slot is declared dead and recycled.",
|
||||
"bootTimeoutSeconds": 300
|
||||
},
|
||||
|
||||
"guest": {
|
||||
"_comment": "Shape of each guest VM and the credentials used to reach it over SSH. These credentials only ever traverse the host-private NAT link between this Mac and its own ephemeral guests.",
|
||||
"username": "admin",
|
||||
"password": "admin",
|
||||
"cpuCount": 4,
|
||||
"memoryGB": 8,
|
||||
|
||||
"_comment_diskGB": "Nominal disk size. With the ASIF sparse format this is a ceiling, not an allocation.",
|
||||
"diskGB": 64
|
||||
},
|
||||
|
||||
"storage": {
|
||||
"_comment": "Where images, ephemeral clones, IPSWs, and host state live. Images and clones must share one APFS volume: cloning relies on copy-on-write, which requires the same volume.",
|
||||
"storeDir": "~/Library/Application Support/gitea-macos-runner",
|
||||
|
||||
"_comment_minFree": "Refuse to clone a VM when the store volume has less than this free. CoW clones start nearly free but grow with every guest write, so keep this well above one clone's nominal size.",
|
||||
"minFreeDiskGB": 20
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?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>com.apple.security.virtualization</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,56 @@
|
||||
<?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">
|
||||
<!--
|
||||
Template rendered by LaunchdService.renderPlist(executablePath:arguments:).
|
||||
Placeholders: {{LABEL}}, {{PROGRAM}}, {{ARGUMENTS}}, {{STDOUT_PATH}}, {{STDERR_PATH}}
|
||||
|
||||
This is a LaunchAgent — it MUST be installed to ~/Library/LaunchAgents and run
|
||||
in the logged-in user's GUI session, never to /Library/LaunchDaemons.
|
||||
Virtualization.framework needs a GUI session, and macOS 15+ additionally
|
||||
refuses to start a VM unless login.keychain is unlocked, which only happens
|
||||
after a graphical login. Configure the host for automatic login.
|
||||
|
||||
{{PROGRAM}} must point at the executable inside the signed .app bundle; the
|
||||
com.apple.security.virtualization entitlement does not survive on a bare
|
||||
binary copied out of it.
|
||||
-->
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>{{LABEL}}</string>
|
||||
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>{{PROGRAM}}</string>
|
||||
{{ARGUMENTS}}
|
||||
</array>
|
||||
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
|
||||
<key>KeepAlive</key>
|
||||
<dict>
|
||||
<key>SuccessfulExit</key>
|
||||
<false/>
|
||||
</dict>
|
||||
|
||||
<!-- Back off rather than spin if the daemon exits immediately at startup. -->
|
||||
<key>ThrottleInterval</key>
|
||||
<integer>30</integer>
|
||||
|
||||
<key>ProcessType</key>
|
||||
<string>Interactive</string>
|
||||
|
||||
<key>StandardOutPath</key>
|
||||
<string>{{STDOUT_PATH}}</string>
|
||||
|
||||
<key>StandardErrorPath</key>
|
||||
<string>{{STDERR_PATH}}</string>
|
||||
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
Executable
+397
@@ -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"
|
||||
Reference in New Issue
Block a user