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 --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/ → // 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)) } } } }