758 lines
35 KiB
Swift
758 lines
35 KiB
Swift
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))
|
||
}
|
||
}
|
||
}
|
||
}
|