Files
gitea-macos-vm-orchestrator/Sources/RunnerHost/GuestProvisioner.swift
T
2026-08-07 04:14:34 -07:00

758 lines
35 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
///
/// The application's name is **discovered, not assumed**. A release archive
/// expands to `Xcode.app`, a beta to `Xcode-beta.app`, and Apple has shipped
/// version-qualified names too; whatever comes out keeps its name under
/// `/Applications`, because `xcode-select -s` makes the name irrelevant to
/// anything that builds.
///
/// - Parameters:
/// - executor: A connected guest executor.
/// - xipPath: Path to the `.xip` **on the host**; it is uploaded.
/// - progress: Optional stage callback. Every phase here runs for tens of
/// minutes, so silence is indistinguishable from a hang — this is the
/// only thing that says otherwise.
public func installXcode(
executor: any GuestExecutor,
xipPath: String,
progress: (@Sendable (String) -> Void)? = nil
) 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 localBytes =
(try? FileManager.default.attributesOfItem(atPath: localURL.path))?[.size] as? Int ?? 0
let remoteXIP = "/tmp/Xcode.xip"
let staging = "/tmp/xcode-expand"
let quotedXIP = Self.shellQuote(remoteXIP)
let quotedStaging = Self.shellQuote(staging)
// Is a previous run's expansion still sitting there, complete? Then the
// upload and the expansion — between them the entire cost of this
// function — are already paid for. Opportunistic only: macOS clears /tmp
// on boot, so after the guest has been power-cycled this finds nothing,
// which is fine.
var expandedApp = try await Self.reusableExpandedApp(executor: executor, staging: staging)
if let expandedApp {
progress?("reusing expanded \((expandedApp as NSString).lastPathComponent)")
} else {
try await Self.checkGuestDisk(executor: executor, xipBytes: localBytes, progress: progress)
if try await Self.hasMatchingUpload(
executor: executor, remotePath: remoteXIP, expectedBytes: localBytes)
{
progress?("reusing uploaded xip (\(XcodeInstall.formatGB(localBytes)))")
} else {
// 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.
let headline = "uploading Xcode (\(XcodeInstall.formatGB(localBytes)))"
progress?(headline + " 0%")
try await executor.runChecked("rm -f \(quotedXIP)", timeout: .seconds(120))
try await Self.uploadLargeFile(
executor: executor,
localURL: localURL,
remotePath: remoteXIP,
progress: { fraction in
progress?(headline + " \(Int(fraction * 100))%")
}
)
progress?(headline + " 100%")
}
// A half-finished expansion from an earlier attempt would leave
// `ls *.app` ambiguous, or leave a truncated bundle to be installed.
// Clear it before, not after.
try await executor.runChecked(
"rm -rf \(quotedStaging) && mkdir -p \(quotedStaging)", timeout: .seconds(600))
// `xip --expand` writes into the current directory and needs no sudo,
// but staging beside the archive keeps the later move a rename within
// one volume. Expansion of a full Xcode takes 20–45 minutes on
// VM-backed storage.
progress?("expanding xip (takes 15-40 min)…")
try await executor.runChecked(
"cd \(quotedStaging) && sudo -n /usr/bin/xip --expand \(quotedXIP)",
timeout: .seconds(5400)
)
// Immediately, and unconditionally on success: the archive is dead
// weight from here on, and the guest is at its tightest right now
// holding both copies. Deleting it as part of a success-only `&&`
// chain at the very end — which is what this used to do — means a
// failure anywhere later strands 12 GB in /tmp.
_ = try? await executor.run("rm -f \(quotedXIP)", timeout: .seconds(300))
let listing = try await executor.run(
"ls -d \(quotedStaging)/*.app 2>/dev/null", timeout: .seconds(300))
expandedApp = try XcodeInstall.expandedAppPath(
fromListing: listing.stdout, staging: staging)
}
guard let sourceApp = expandedApp else {
throw CoreError.provisioningFailed("could not locate the expanded Xcode in \(staging)")
}
let appName = (sourceApp as NSString).lastPathComponent
let destination = "/Applications/" + appName
let quotedDestination = Self.shellQuote(destination)
// Whatever is already there loses. This is a golden image being built to
// a specification, not a user's Mac, and leaving the old copy would both
// fail the move and waste tens of gigabytes in every clone.
let existing = try await executor.run(
"test -e \(quotedDestination) && echo present", timeout: .seconds(120))
if existing.stdout.contains("present") {
progress?("replacing existing \(appName) in the guest")
try await executor.runChecked(
"sudo -n rm -rf \(quotedDestination)", timeout: .seconds(1800))
}
// Writing into /Applications needs root.
progress?("installing \(appName)…")
try await executor.runChecked(
"sudo -n mv \(Self.shellQuote(sourceApp)) \(quotedDestination)",
timeout: .seconds(1800)
)
_ = try? await executor.run("rm -rf \(quotedStaging)", timeout: .seconds(600))
// xcode-select writes /var/db/xcode_select_link — root only. Pointing it
// at the discovered path is what makes the bundle's name a non-issue:
// `xcodebuild`, `swift`, and every `xcrun` shim resolve through this.
try await executor.runChecked(
"sudo -n /usr/bin/xcode-select -s "
+ Self.shellQuote(destination + "/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)
)
progress?("running xcodebuild -runFirstLaunch (installs simulators; 10-30 min)…")
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)
)
}
// Proof, in the operator's log, that the thing they waited an hour for
// is actually there and selected — on one line, since `-version` prints
// two.
let version = check.stdout
.split(separator: "\n")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
.joined(separator: " — ")
progress?("Xcode ready: \(version) at \(destination)")
}
/// An already-expanded application left by an earlier attempt, if one is
/// there and looks complete.
///
/// "Complete" is `Contents/MacOS` existing: an expansion killed part-way
/// leaves a directory tree that `ls` is perfectly happy to list, and
/// installing that would produce an Xcode that fails at first use rather
/// than at install time. Never throws — a guest with nothing staged is the
/// normal case, and an ambiguous listing here just means "do it properly".
static func reusableExpandedApp(
executor: any GuestExecutor,
staging: String
) async throws -> String? {
let listing = try await executor.run(
"ls -d \(shellQuote(staging))/*.app 2>/dev/null", timeout: .seconds(120))
guard let app = try? XcodeInstall.expandedAppPath(fromListing: listing.stdout, staging: staging)
else { return nil }
let complete = try await executor.run(
"test -d \(shellQuote(app + "/Contents/MacOS")) && echo ok", timeout: .seconds(120))
return complete.stdout.contains("ok") ? app : nil
}
/// Whether the guest already holds a byte-for-byte-sized copy of the upload.
///
/// Size only — hashing 12 GB over an exec channel would cost more than the
/// upload it is trying to avoid. The archive is written by this code alone,
/// to a fixed path, so a size match is strong enough evidence; a partial
/// upload from an interrupted run is shorter and fails the check.
static func hasMatchingUpload(
executor: any GuestExecutor,
remotePath: String,
expectedBytes: Int
) async throws -> Bool {
guard expectedBytes > 0 else { return false }
let result = try await executor.run(
"stat -f %z \(shellQuote(remotePath)) 2>/dev/null", timeout: .seconds(120))
let reported = Int(result.stdout.trimmingCharacters(in: .whitespacesAndNewlines))
return reported == expectedBytes
}
/// Refuses the install before the upload when the guest cannot hold it.
///
/// The failure this replaces is the worst kind: `xip --expand` fills the
/// disk half an hour in, and the error names neither how much was needed nor
/// what to do about it. Unparseable `df` output is treated as "cannot check"
/// and allowed through — a pre-flight that blocks the install because it did
/// not recognise the output is worse than the problem.
static func checkGuestDisk(
executor: any GuestExecutor,
xipBytes: Int,
progress: (@Sendable (String) -> Void)?
) async throws {
guard xipBytes > 0 else { return }
let result = try await executor.run("df -Pk /", timeout: .seconds(120))
guard let available = XcodeInstall.availableBytes(dfOutput: result.stdout) else { return }
let needed = XcodeInstall.requiredFreeBytes(xipBytes: xipBytes)
guard available >= needed else {
throw CoreError.provisioningFailed(
XcodeInstall.insufficientDiskMessage(xipBytes: xipBytes, availableBytes: available)
)
}
progress?(
"guest disk: \(XcodeInstall.formatGB(available)) free, "
+ "\(XcodeInstall.formatGB(needed)) needed")
}
/// 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))
}
}
}
}