Nucleic: Gitea Runner macOS VM Support

This commit is contained in:
2026-08-07 00:44:36 -07:00
parent 749f0be4fb
commit 33f299396a
47 changed files with 13159 additions and 0 deletions
+599
View File
@@ -0,0 +1,599 @@
import Foundation
import RunnerCore
/// Turns a bare macOS guest into something that can execute Gitea Actions jobs.
///
/// ## What a guest actually needs
///
/// Gitea Actions in `host` schema does not containerize anything: it shells out
/// on the guest. The hard requirements are therefore small but non-negotiable:
///
/// * **`gitea-runner`** — the runner binary itself (v3.x; renamed from
/// `act_runner`, now published from `gitea.com/gitea/runner`).
/// * **`node`** — not optional. JavaScript actions such as `actions/checkout`
/// are executed by spawning `node` directly; without it, essentially every
/// real workflow fails at its first step. Installed from Apple's official
/// arm64 `.pkg` via `installer -pkg`.
/// * **`git`** and **`bash`** — present on stock macOS, but `git` only after the
/// Command Line Tools are materialized, so presence is verified rather than
/// assumed.
/// * **A writable `$HOME`** — the runner writes its registration and workspace
/// under the guest account's home directory.
///
/// ## What else provisioning does
///
/// Everything in `Resources/provision.sh`: a passwordless-sudo drop-in
/// installed through `visudo -cf` (validated before it is moved into place, so a
/// syntax error cannot lock the account out), disabling sleep/screensaver so a
/// long job is not interrupted, disabling Spotlight indexing of build
/// directories, raising `maxfiles` (Xcode and npm both exhaust the stock 256),
/// and pre-seeding `github.com` into `known_hosts` so a checkout does not stall
/// on host-key confirmation.
///
/// - Note: The guest must **never** ship a `gitea-runner` `config.yaml` that
/// sets `runner.labels`: that key silently overrides the `--labels` passed at
/// registration, so the runner would advertise the wrong labels and never be
/// matched.
public struct GuestProvisioner: Sendable {
/// Creates a provisioner.
public init() {}
/// Runs the full provisioning sequence against a booted guest.
///
/// Steps, in order:
/// 1. Upload `Resources/provision.sh` to `/tmp/provision.sh`, `chmod +x`,
/// and run it under `sudo` with the guest username and password passed
/// via the environment (never as arguments, which are world-visible in
/// `ps`).
/// 2. Download the Node.js arm64 `.pkg` on the **host**, upload it, and
/// `installer -pkg … -target /`. Downloading host-side keeps the guest
/// off the public internet for this step and makes the version pinnable.
/// 3. Verify `git`, `bash`, and `node` all resolve.
/// 4. Download the `gitea-runner` darwin-arm64 release asset on the host
/// (URL from ``RunnerConfig/RunnerSection/resolvedDownloadURL``), upload
/// it to `/usr/local/bin/gitea-runner`, and `chmod +x`.
/// 5. Verify `gitea-runner --version` runs.
///
/// - Parameters:
/// - executor: A connected guest executor.
/// - config: Supplies guest credentials and the runner download URL.
/// - progress: Optional per-step callback, for `image build` output.
/// - Throws: ``CoreError/provisioningFailed(_:)`` naming the failed step.
public func provision(
executor: any GuestExecutor,
config: RunnerConfig,
progress: (@Sendable (String) -> Void)? = nil
) async throws {
progress?("system configuration (provision.sh)")
try await runProvisionScript(executor: executor, config: config)
progress?("Node.js \(Self.defaultNodeVersion)")
try await installNode(executor: executor)
progress?("verifying toolchain")
try await verifyToolchain(executor: executor)
progress?("gitea-runner \(config.runner.version)")
try await installGiteaRunner(executor: executor, config: config)
}
/// Uploads and executes `Resources/provision.sh`.
///
/// - Parameters:
/// - executor: A connected guest executor.
/// - config: Guest credentials.
public func runProvisionScript(
executor: any GuestExecutor,
config: RunnerConfig
) async throws {
let scriptURL = try Self.provisionScriptURL()
do {
try await executor.upload(localPath: scriptURL.path, remotePath: "/tmp/provision.sh")
} catch {
throw CoreError.provisioningFailed(
"could not upload provision.sh from \(scriptURL.path): \(error)"
)
}
// The account password has to reach `sudo -S` somehow, and every obvious
// route leaks it: as an argument it is visible in `ps` to any process on
// the guest, and `echo pw | sudo -S` puts it in the shell's own argv,
// which is exactly the same exposure. A mode-0600 file read via stdin
// redirection is the one form that never appears in an argument list; it
// is removed in the same command, so it does not outlive the call even if
// the script fails.
try await executor.uploadData(
Data((config.guest.password + "\n").utf8),
remotePath: "/tmp/.gmr-auth",
mode: "0600"
)
let giteaHost = config.gitea.instanceURL.host ?? ""
// `sudo VAR=value cmd` is how variables survive sudo's env_reset; a
// plain `VAR=value sudo cmd` would be stripped. Neither the username nor
// the Gitea hostname is secret, so argv exposure is fine for these.
let command = """
sudo -S -p '' \
GUEST_USER=\(Self.shellQuote(config.guest.username)) \
GITEA_HOST=\(Self.shellQuote(giteaHost)) \
/bin/bash /tmp/provision.sh < /tmp/.gmr-auth; \
rc=$?; rm -f /tmp/.gmr-auth /tmp/provision.sh; exit $rc
"""
// Generous: the Command Line Tools download inside the script is the
// long pole and is itself bounded at 45 minutes.
let result = try await executor.run(command, timeout: .seconds(3600))
guard result.succeeded else {
throw CoreError.provisioningFailed(
"provision.sh failed (exit \(result.exitCode))\n"
+ Self.tail(result.stderr.isEmpty ? result.stdout : result.stderr)
)
}
// The script warns rather than aborts on best-effort steps, so a zero
// exit alone does not prove it ran to the end — a truncated SSH channel
// would also look like success. The marker is the actual proof.
guard result.stdout.contains("PROVISION_OK") else {
throw CoreError.provisioningFailed(
"provision.sh exited 0 but never printed PROVISION_OK; it did not run to completion\n"
+ Self.tail(result.stdout)
)
}
}
/// Installs Node.js from the official arm64 package.
///
/// - Parameters:
/// - executor: A connected guest executor.
/// - version: Node major/minor/patch, e.g. `22.11.0`.
/// - packageURL: Overrides the derived download URL entirely. Used by
/// ``resolveLatestLTSNodeVersion()`` callers and by air-gapped setups
/// pointing at an internal mirror.
public func installNode(
executor: any GuestExecutor,
version: String = GuestProvisioner.defaultNodeVersion,
packageURL: URL? = nil
) async throws {
guard let url = packageURL ?? Self.nodePackageURL(version: version) else {
throw CoreError.configInvalid("cannot form a Node.js package URL for version \(version)")
}
// Downloaded host-side rather than by the guest: the version is then
// pinned by the host's config, the guest needs no outbound access for
// this step, and a rebuild of ten images hits the host's cache instead of
// nodejs.org ten times.
let local = try await Self.downloadToTemporaryFile(url: url, suggestedName: "node.pkg")
defer { try? FileManager.default.removeItem(at: local) }
do {
try await executor.upload(localPath: local.path, remotePath: "/tmp/node.pkg")
} catch {
throw CoreError.provisioningFailed("could not upload the Node.js package: \(error)")
}
let install = try await executor.run(
"sudo -n /usr/sbin/installer -pkg /tmp/node.pkg -target /; rc=$?; rm -f /tmp/node.pkg; exit $rc",
timeout: .seconds(900)
)
guard install.succeeded else {
throw CoreError.provisioningFailed(
"installing Node.js failed (exit \(install.exitCode))\n"
+ Self.tail(install.stderr.isEmpty ? install.stdout : install.stderr)
)
}
let check = try await executor.run(Self.withGuestPath("node --version"), timeout: .seconds(120))
guard check.succeeded else {
throw CoreError.provisioningFailed(
"Node.js installed but `node --version` failed (exit \(check.exitCode)). "
+ "Gitea's JavaScript actions spawn `node` directly, so this image would fail "
+ "every workflow that uses actions/checkout.\n"
+ Self.tail(check.stderr.isEmpty ? check.stdout : check.stderr)
)
}
}
/// Verifies that `git`, `bash`, and `node` are all present and executable.
///
/// - Throws: ``CoreError/provisioningFailed(_:)`` naming what is missing.
public func verifyToolchain(executor: any GuestExecutor) async throws {
// Order matters. On a vanilla guest `/usr/bin/git` is a shim that pops a
// GUI "install command line developer tools" dialog and blocks until
// someone clicks it — which, headless, is never. So the presence of a
// real git is established from the *package receipt* first, and `git`
// itself is only invoked once that check passes.
let hasTools = try await executor.run(
"pkgutil --pkg-info=com.apple.pkg.CLTools_Executables >/dev/null 2>&1 "
+ "|| [ -x /Applications/Xcode.app/Contents/Developer/usr/bin/git ]",
timeout: .seconds(120)
)
guard hasTools.succeeded else {
throw CoreError.provisioningFailed(
"""
the guest has no Command Line Tools, so `git` is only a stub that blocks on a \
GUI installer dialog. provision.sh attempted a non-interactive install and it \
did not take. Install a real toolchain instead:
gitea-macos-runner image provision <NAME> --xcode-xip /path/to/Xcode.xip
(Shipping the image without git would fail every checkout at job time rather \
than here, so the build stops now.)
"""
)
}
for (tool, command) in [
("git", "git --version"),
("bash", "bash --version"),
("node", "node --version"),
] {
let result = try await executor.run(Self.withGuestPath(command), timeout: .seconds(120))
guard result.succeeded else {
throw CoreError.provisioningFailed(
"required tool `\(tool)` is not usable in the guest (`\(command)` exited \(result.exitCode))\n"
+ Self.tail(result.stderr.isEmpty ? result.stdout : result.stderr)
)
}
}
}
/// Downloads the `gitea-runner` release asset on the host and installs it
/// into the guest at `/usr/local/bin/gitea-runner`.
///
/// - Parameters:
/// - executor: A connected guest executor.
/// - config: Supplies the download URL template and version.
public func installGiteaRunner(
executor: any GuestExecutor,
config: RunnerConfig
) async throws {
let url = try config.runner.resolvedDownloadURL
let local = try await Self.downloadToTemporaryFile(url: url, suggestedName: "gitea-runner")
defer { try? FileManager.default.removeItem(at: local) }
do {
try await executor.upload(localPath: local.path, remotePath: "/tmp/gitea-runner")
} catch {
throw CoreError.provisioningFailed("could not upload the gitea-runner binary: \(error)")
}
try await executor.runChecked(
"sudo -n /usr/bin/install -o root -g wheel -m 755 /tmp/gitea-runner /usr/local/bin/gitea-runner "
+ "&& rm -f /tmp/gitea-runner",
timeout: .seconds(300)
)
// arm64 macOS refuses to exec a binary with no code signature at all
// (SIGKILL, no diagnostic). Release tarballs are usually ad-hoc signed
// already, in which case re-signing is a no-op; when they are not, this
// is what keeps the runner from being killed on its first invocation.
// Best-effort: `codesign` needs the Command Line Tools, and a signature
// that was already valid does not need replacing.
_ = try? await executor.run(
"sudo -n /usr/bin/codesign --force --sign - /usr/local/bin/gitea-runner",
timeout: .seconds(300)
)
let check = try await executor.run(
Self.withGuestPath("gitea-runner --version"),
timeout: .seconds(120)
)
guard check.succeeded else {
throw CoreError.provisioningFailed(
"gitea-runner installed from \(url.absoluteString) but `gitea-runner --version` "
+ "failed (exit \(check.exitCode))\n"
+ Self.tail(check.stderr.isEmpty ? check.stdout : check.stderr)
)
}
}
/// Installs Xcode from a `.xip` into the guest — the optional heavy step.
///
/// Xcode is not installed by default: the `.xip` is ~8 GB and expanding it
/// roughly triples the base image, which is a poor default for a workflow
/// that only needs `swift build`. Operators who need it run
/// `image provision NAME --xcode-xip PATH` once, after which every clone
/// inherits it.
///
/// Expansion uses `xip --expand` inside the guest, followed by
/// `xcode-select -s` and `xcodebuild -license accept`, and finishes by
/// running `xcodebuild -runFirstLaunch` so the first job does not pay for
/// component installation.
///
/// - Parameters:
/// - executor: A connected guest executor.
/// - xipPath: Path to the `.xip` **on the host**; it is uploaded.
public func installXcode(executor: any GuestExecutor, xipPath: String) async throws {
let localURL = URL(fileURLWithPath: (xipPath as NSString).expandingTildeInPath)
guard FileManager.default.fileExists(atPath: localURL.path) else {
throw CoreError.notFound("Xcode .xip not found at \(localURL.path)")
}
let remoteXIP = "/tmp/Xcode.xip"
// Uploads go over an SSH exec channel with the payload as stdin, and
// `GuestExecutor.upload` reads the whole local file into memory first —
// fine for a 90 MB pkg, ruinous for a 12 GB xip. So this streams the file
// in bounded chunks and appends them guest-side instead. It is still slow
// (an exec channel is not SCP), but it is functional and its host memory
// use is capped at one chunk.
try await executor.runChecked("rm -f \(Self.shellQuote(remoteXIP))", timeout: .seconds(120))
try await Self.uploadLargeFile(executor: executor, localURL: localURL, remotePath: remoteXIP)
// Free the disk the old copy occupies before expanding into ~40 GB more.
_ = try? await executor.run("sudo -n rm -rf /Applications/Xcode.app", timeout: .seconds(600))
let staging = "/tmp/xcode-expand"
// `xip --expand` writes into the current directory and needs no sudo, but
// /tmp is small on some layouts; staging under /tmp keeps it beside the
// archive so the later move is a rename within one volume where possible.
try await executor.runChecked(
"rm -rf \(Self.shellQuote(staging)) && mkdir -p \(Self.shellQuote(staging))",
timeout: .seconds(300)
)
// Expansion of a full Xcode takes 20–45 minutes on VM-backed storage.
try await executor.runChecked(
"cd \(Self.shellQuote(staging)) && sudo -n /usr/bin/xip --expand \(Self.shellQuote(remoteXIP))",
timeout: .seconds(5400)
)
// Writing into /Applications needs root.
try await executor.runChecked(
"sudo -n mv \(Self.shellQuote(staging + "/Xcode.app")) /Applications/Xcode.app "
+ "&& sudo -n rm -rf \(Self.shellQuote(staging)) \(Self.shellQuote(remoteXIP))",
timeout: .seconds(1800)
)
// xcode-select writes /var/db/xcode_select_link — root only.
try await executor.runChecked(
"sudo -n /usr/bin/xcode-select -s /Applications/Xcode.app/Contents/Developer",
timeout: .seconds(300)
)
// Both of these write under /Library and must run as root; -runFirstLaunch
// installs the bundled packages (simulators, device support) that would
// otherwise be installed lazily during the first job.
try await executor.runChecked(
"sudo -n /usr/bin/xcodebuild -license accept",
timeout: .seconds(600)
)
try await executor.runChecked(
"sudo -n /usr/bin/xcodebuild -runFirstLaunch",
timeout: .seconds(3600)
)
let check = try await executor.run("/usr/bin/xcodebuild -version", timeout: .seconds(300))
guard check.succeeded else {
throw CoreError.provisioningFailed(
"Xcode installed but `xcodebuild -version` failed (exit \(check.exitCode))\n"
+ Self.tail(check.stderr.isEmpty ? check.stdout : check.stderr)
)
}
}
/// The Node.js version installed when none is specified.
///
/// Pinned rather than resolved at build time so that two images built weeks
/// apart are identical unless someone changes this line. Use
/// ``resolveLatestLTSNodeVersion()`` to look up a newer LTS deliberately.
public static let defaultNodeVersion = "24.19.0"
/// The official Node.js macOS arm64 package URL for a version.
public static func nodePackageURL(version: String) -> URL? {
URL(string: "https://nodejs.org/dist/v\(version)/node-v\(version).pkg")
}
/// Looks up the current Node.js LTS version from nodejs.org.
///
/// Best-effort and deliberately not called by ``provision(executor:config:progress:)``:
/// an image build that silently picks up a different Node depending on the
/// day it ran is not reproducible. Callers that want the newest LTS pass the
/// result to ``installNode(executor:version:packageURL:)`` explicitly.
///
/// - Returns: The version string without the leading `v`, or `nil` if the
/// index could not be read.
public static func resolveLatestLTSNodeVersion() async -> String? {
guard let indexURL = URL(string: "https://nodejs.org/dist/index.json") else { return nil }
var request = URLRequest(url: indexURL)
request.timeoutInterval = 30
guard let (data, response) = try? await URLSession.shared.data(for: request),
let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode),
let entries = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]]
else { return nil }
// The index is newest-first, and `lts` is `false` for non-LTS releases
// and the codename string ("Krypton") for LTS ones.
for entry in entries {
guard let version = entry["version"] as? String else { continue }
if entry["lts"] is String {
return String(version.dropFirst()) // "v24.19.0" -> "24.19.0"
}
}
return nil
}
// MARK: - Locating provision.sh
/// Finds `Resources/provision.sh`.
///
/// The package declares no SwiftPM `resources:`, so `Bundle.module` does not
/// exist and the script has to be located by hand. Three deployments matter:
/// the signed `.app` the daemon actually runs from (`Contents/Resources`), a
/// bare `swift build` binary in `.build/debug`, and a `swift run` from the
/// checkout. Each is tried in turn, and the error names every path searched
/// so a packaging mistake is diagnosable from the message alone.
///
/// - Returns: URL of the script.
/// - Throws: ``CoreError/notFound(_:)`` listing the searched paths.
public static func provisionScriptURL() throws -> URL {
let fileManager = FileManager.default
var searched: [URL] = []
func check(_ url: URL) -> URL? {
searched.append(url)
return fileManager.isReadableFile(atPath: url.path) ? url : nil
}
// 1. The .app's own resources, via the bundle API and by hand (the API
// returns nil for a bare executable with no Info.plist).
if let url = Bundle.main.url(forResource: "provision", withExtension: "sh") {
searched.append(url)
if fileManager.isReadableFile(atPath: url.path) { return url }
}
var roots: [URL] = [Bundle.main.bundleURL]
if let executableDirectory = Bundle.main.executableURL?
.resolvingSymlinksInPath()
.deletingLastPathComponent()
{
roots.append(executableDirectory)
}
roots.append(URL(fileURLWithPath: fileManager.currentDirectoryPath))
// The checkout this file was compiled from: Sources/RunnerHost/<file> →
// three levels up is the package root. Only useful for `swift run` during
// development, hence last.
roots.append(
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
)
for root in roots {
var candidate = root.resolvingSymlinksInPath()
// Walk upward: `.build/debug/gitea-macos-runner` is four levels below
// the checkout root, and an .app nested in a staging directory is
// similar.
for _ in 0..<6 {
if let found = check(candidate.appendingPathComponent("Contents/Resources/provision.sh")) {
return found
}
if let found = check(candidate.appendingPathComponent("Resources/provision.sh")) {
return found
}
let parent = candidate.deletingLastPathComponent()
if parent.path == candidate.path { break }
candidate = parent
}
}
let list = searched.map { " \($0.path)" }.joined(separator: "\n")
throw CoreError.notFound(
"provision.sh could not be located. Searched:\n\(list)\n"
+ "When running from a bundled .app, Resources/provision.sh must be copied into "
+ "Contents/Resources/ by the build."
)
}
// MARK: - Helpers
/// A PATH that includes `/usr/local/bin`.
///
/// `ssh host command` runs a non-login, non-interactive shell, which never
/// sources the file where `path_helper` adds `/usr/local/bin`. Both `node`
/// and `gitea-runner` install there, so every command that names one is
/// wrapped in this. (`provision.sh` also writes `/etc/zshenv` to fix this for
/// everything else that talks to the guest.)
static func withGuestPath(_ command: String) -> String {
"export PATH=/usr/local/bin:/opt/homebrew/bin:$PATH; " + command
}
/// Wraps a value so `/bin/sh` sees it literally.
static func shellQuote(_ value: String) -> String {
"'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
/// Trims captured output to something a terminal error can carry.
static func tail(_ output: String, lines: Int = 30) -> String {
let all = output.split(separator: "\n", omittingEmptySubsequences: false)
return all.suffix(lines).joined(separator: "\n")
}
/// Downloads a URL to a unique temporary file.
///
/// - Parameters:
/// - url: Source.
/// - suggestedName: File name within the temporary directory.
/// - Returns: The local file, which the caller owns and must delete.
static func downloadToTemporaryFile(url: URL, suggestedName: String) async throws -> URL {
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = 60
configuration.timeoutIntervalForResource = 60 * 60
let session = URLSession(configuration: configuration)
defer { session.finishTasksAndInvalidate() }
let temporary: URL
let response: URLResponse
do {
(temporary, response) = try await session.download(from: url)
} catch {
throw CoreError.provisioningFailed(
"download failed for \(url.absoluteString): \(error.localizedDescription)"
)
}
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
try? FileManager.default.removeItem(at: temporary)
throw CoreError.provisioningFailed(
"download failed: HTTP \(http.statusCode) for \(url.absoluteString)"
)
}
let destination = FileManager.default.temporaryDirectory
.appendingPathComponent("gmr-\(UUID().uuidString)-\(suggestedName)")
do {
try FileManager.default.moveItem(at: temporary, to: destination)
} catch {
try? FileManager.default.removeItem(at: temporary)
throw CoreError.provisioningFailed(
"could not stage the download from \(url.absoluteString): \(error.localizedDescription)"
)
}
return destination
}
/// Uploads a file too large to hold in memory, one chunk at a time.
///
/// ``GuestExecutor/upload(localPath:remotePath:)`` slurps the whole file, so
/// a multi-gigabyte Xcode archive would exhaust host memory before a byte
/// moved. Each chunk is written to a scratch path and appended guest-side,
/// which keeps both ends bounded.
static func uploadLargeFile(
executor: any GuestExecutor,
localURL: URL,
remotePath: String,
chunkBytes: Int = 128 * 1024 * 1024,
progress: (@Sendable (Double) -> Void)? = nil
) async throws {
let handle = try FileHandle(forReadingFrom: localURL)
defer { try? handle.close() }
let attributes = try? FileManager.default.attributesOfItem(atPath: localURL.path)
let totalBytes = attributes?[.size] as? Int
let quotedRemote = shellQuote(remotePath)
let scratch = remotePath + ".part"
let quotedScratch = shellQuote(scratch)
var sent = 0
while true {
let chunk = try handle.read(upToCount: chunkBytes) ?? Data()
if chunk.isEmpty { break }
try await executor.uploadData(chunk, remotePath: scratch, mode: "0644")
try await executor.runChecked(
"cat \(quotedScratch) >> \(quotedRemote) && rm -f \(quotedScratch)",
timeout: .seconds(600)
)
sent += chunk.count
if let totalBytes, totalBytes > 0 {
progress?(min(Double(sent) / Double(totalBytes), 1))
}
}
}
}