Merge nucleic/mellow-dewy-falcon-rjhr into main
This commit is contained in:
@@ -0,0 +1,597 @@
|
||||
import Foundation
|
||||
import RunnerCore
|
||||
import Virtualization
|
||||
|
||||
/// The outcome of one preflight check.
|
||||
public struct DoctorCheck: Sendable, Equatable {
|
||||
/// How a check turned out.
|
||||
public enum Result: Sendable, Equatable {
|
||||
/// Requirement satisfied.
|
||||
case pass
|
||||
/// Requirement not satisfied; the daemon will not work.
|
||||
case fail
|
||||
/// Not a hard requirement, but worth knowing about.
|
||||
case warn
|
||||
/// Informational only.
|
||||
case info
|
||||
}
|
||||
|
||||
/// Short check name, e.g. `virtualization entitlement`.
|
||||
public let name: String
|
||||
/// Outcome.
|
||||
public let result: Result
|
||||
/// What was observed.
|
||||
public let detail: String
|
||||
/// What to do about it, when the outcome is not ``Result/pass``.
|
||||
public let remediation: String?
|
||||
|
||||
public init(name: String, result: Result, detail: String, remediation: String? = nil) {
|
||||
self.name = name
|
||||
self.result = result
|
||||
self.detail = detail
|
||||
self.remediation = remediation
|
||||
}
|
||||
|
||||
/// Whether this check blocks the daemon from working.
|
||||
public var isBlocking: Bool { result == .fail }
|
||||
}
|
||||
|
||||
extension DoctorCheck.Result {
|
||||
/// Lowercase name, for JSON output and log lines.
|
||||
public var label: String {
|
||||
switch self {
|
||||
case .pass: return "pass"
|
||||
case .fail: return "fail"
|
||||
case .warn: return "warn"
|
||||
case .info: return "info"
|
||||
}
|
||||
}
|
||||
|
||||
/// Single-character marker used by ``Doctor/format(_:)``.
|
||||
public var symbol: String {
|
||||
switch self {
|
||||
case .pass: return "✓"
|
||||
case .fail: return "✗"
|
||||
case .warn: return "!"
|
||||
case .info: return "·"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Preflight checks for a host that is supposed to run macOS guests.
|
||||
///
|
||||
/// Each of these corresponds to a failure mode that is otherwise diagnosed only
|
||||
/// by a confusing runtime error deep inside the boot path, so `doctor` exists to
|
||||
/// surface them all at once, before anything is installed.
|
||||
public enum Doctor {
|
||||
|
||||
/// Runs every check.
|
||||
///
|
||||
/// Checks performed:
|
||||
///
|
||||
/// 1. **Architecture is arm64.** Virtualization cannot run macOS guests on
|
||||
/// Intel at all.
|
||||
/// 2. **Host macOS ≥ 26.** Required for ASIF disks; the guest-provisioning
|
||||
/// automation additionally wants 27.
|
||||
/// 3. **`VZVirtualMachine.isSupported`.** The framework's own verdict.
|
||||
/// 4. **`com.apple.security.virtualization` entitlement present** on the
|
||||
/// running binary, read with `codesign -d --entitlements - <path>`.
|
||||
/// Running from `.build/` instead of the signed `.app` is the single most
|
||||
/// common setup mistake, and this is what catches it.
|
||||
/// 5. **Free disk ≥ `storage.minFreeDiskGB`.** CoW clones grow as guests
|
||||
/// write.
|
||||
/// 6. **`login.keychain` unlocked**, via `security show-keychain-info
|
||||
/// login.keychain`. macOS 15+ refuses to start a VM otherwise — the
|
||||
/// reason the daemon must be a LaunchAgent in a logged-in session.
|
||||
/// 7. **Gitea reachable and the token has admin scope**, probed with
|
||||
/// ``GiteaClient/listRunners()``. A non-admin token fails here rather
|
||||
/// than at the first poll.
|
||||
/// 8. **Registration token resolvable** from file, inline value, or (if
|
||||
/// enabled) the API.
|
||||
/// 9. **Runner download URL is live**, via a `HEAD` expecting 200. Catches a
|
||||
/// version bump that no longer has a darwin-arm64 asset.
|
||||
/// 10. **Local Network privacy note** (informational). On macOS 15+ the
|
||||
/// first attempt to reach a guest over the NAT link can be blocked by
|
||||
/// the Local Network permission prompt, which a background agent cannot
|
||||
/// answer; the operator must approve the app once.
|
||||
///
|
||||
/// - Parameter config: Validated configuration. Gitea-dependent checks are
|
||||
/// skipped with a ``DoctorCheck/Result/warn`` when no admin token is set.
|
||||
/// - Returns: Checks in the order above.
|
||||
public static func runChecks(config: RunnerConfig) async -> [DoctorCheck] {
|
||||
var checks = hostChecks()
|
||||
checks.append(checkDiskSpace(config: config))
|
||||
checks.append(checkLoginKeychain())
|
||||
checks.append(contentsOf: await checkGitea(config: config))
|
||||
checks.append(await checkRunnerDownloadURL(config: config))
|
||||
checks.append(localNetworkNote())
|
||||
return checks
|
||||
}
|
||||
|
||||
/// Runs every check, loading configuration from `path` first.
|
||||
///
|
||||
/// The host checks still run when the configuration is missing or invalid,
|
||||
/// which is the state a first-time operator is actually in.
|
||||
///
|
||||
/// - Parameter configPath: Path to the configuration file; tilde-expanded.
|
||||
/// - Returns: Checks, with configuration loading itself reported as a check.
|
||||
public static func runChecks(configPath: String) async -> [DoctorCheck] {
|
||||
var checks = hostChecks()
|
||||
|
||||
let loaded: RunnerConfig
|
||||
do {
|
||||
loaded = try RunnerConfig.load(from: configPath).validated()
|
||||
checks.append(
|
||||
DoctorCheck(
|
||||
name: "configuration",
|
||||
result: .pass,
|
||||
detail: "loaded and validated \(RunnerConfig.expandTilde(configPath))"
|
||||
)
|
||||
)
|
||||
} catch {
|
||||
checks.append(
|
||||
DoctorCheck(
|
||||
name: "configuration",
|
||||
result: .fail,
|
||||
detail: "\(error)",
|
||||
remediation: "run `gitea-macos-runner config init` and edit \(RunnerConfig.expandTilde(configPath))"
|
||||
)
|
||||
)
|
||||
checks.append(localNetworkNote())
|
||||
return checks
|
||||
}
|
||||
|
||||
checks.append(checkDiskSpace(config: loaded))
|
||||
checks.append(checkLoginKeychain())
|
||||
checks.append(contentsOf: await checkGitea(config: loaded))
|
||||
checks.append(await checkRunnerDownloadURL(config: loaded))
|
||||
checks.append(contentsOf: checkTokenFilePermissions(config: loaded))
|
||||
checks.append(localNetworkNote())
|
||||
return checks
|
||||
}
|
||||
|
||||
/// The configuration-independent host checks: architecture, OS version,
|
||||
/// framework support, entitlement.
|
||||
public static func hostChecks() -> [DoctorCheck] {
|
||||
[
|
||||
checkHostCapability(),
|
||||
checkVirtualizationSupported(),
|
||||
checkVirtualizationEntitlement(),
|
||||
]
|
||||
}
|
||||
|
||||
/// Whether the running binary carries `com.apple.security.virtualization`.
|
||||
///
|
||||
/// - Parameter binaryPath: Defaults to the current executable.
|
||||
/// - Returns: The check result.
|
||||
public static func checkVirtualizationEntitlement(
|
||||
binaryPath: String = CommandLine.arguments.first ?? ""
|
||||
) -> DoctorCheck {
|
||||
let name = "virtualization entitlement"
|
||||
let remediation = """
|
||||
build and install the signed bundle: `make install`, then run \
|
||||
~/Applications/GiteaMacosRunner.app/Contents/MacOS/gitea-macos-runner
|
||||
"""
|
||||
|
||||
guard let executable = resolveExecutablePath(binaryPath) else {
|
||||
return DoctorCheck(
|
||||
name: name,
|
||||
result: .warn,
|
||||
detail: "could not locate the running executable to inspect",
|
||||
remediation: remediation
|
||||
)
|
||||
}
|
||||
|
||||
// The entitlement lives on the signature, so a bare binary copied out of
|
||||
// the bundle loses it. Report where we are as well as what we found.
|
||||
let inAppBundle = executable.contains(".app/Contents/MacOS/")
|
||||
let signedTarget = inAppBundle
|
||||
? String(executable.prefix(upTo: executable.range(of: ".app/Contents/MacOS/")!.upperBound)
|
||||
.dropLast("/Contents/MacOS/".count))
|
||||
: executable
|
||||
|
||||
let result = DoctorShell.run(
|
||||
"/usr/bin/codesign",
|
||||
["-d", "--entitlements", "-", "--xml", signedTarget]
|
||||
)
|
||||
let hasEntitlement = result.output.contains("com.apple.security.virtualization")
|
||||
|
||||
if hasEntitlement {
|
||||
return DoctorCheck(
|
||||
name: name,
|
||||
result: .pass,
|
||||
detail: "present on \(signedTarget)"
|
||||
)
|
||||
}
|
||||
|
||||
if inAppBundle {
|
||||
return DoctorCheck(
|
||||
name: name,
|
||||
result: .fail,
|
||||
detail: "\(signedTarget) is not signed with com.apple.security.virtualization",
|
||||
remediation: "re-sign the bundle: `make sign` (or `make install`)"
|
||||
)
|
||||
}
|
||||
|
||||
// Running the plain SwiftPM binary is normal for `doctor`, `config`, and
|
||||
// `service`; it only becomes fatal when a VM is actually started.
|
||||
return DoctorCheck(
|
||||
name: name,
|
||||
result: .warn,
|
||||
detail: "running an unsigned binary at \(executable); VM starts will fail",
|
||||
remediation: remediation
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether `login.keychain` is currently unlocked.
|
||||
public static func checkLoginKeychain() -> DoctorCheck {
|
||||
let name = "login.keychain unlocked"
|
||||
let result = DoctorShell.run("/usr/bin/security", ["show-keychain-info", "login.keychain"])
|
||||
|
||||
if result.exitCode == 0 {
|
||||
return DoctorCheck(name: name, result: .pass, detail: "unlocked")
|
||||
}
|
||||
|
||||
return DoctorCheck(
|
||||
name: name,
|
||||
result: .warn,
|
||||
detail: "locked or unavailable (security exited \(result.exitCode))",
|
||||
remediation: """
|
||||
macOS 15+ refuses to start a VM while login.keychain is locked. Configure \
|
||||
automatic login, and do not lock the session — the daemon runs as a \
|
||||
LaunchAgent inside it.
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether the host architecture and macOS version can run macOS guests.
|
||||
public static func checkHostCapability() -> DoctorCheck {
|
||||
let name = "host capability"
|
||||
let version = ProcessInfo.processInfo.operatingSystemVersion
|
||||
let versionString = "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)"
|
||||
|
||||
// Emitted straight from the preprocessor branch rather than via a `let
|
||||
// isAppleSilicon` flag: with the flag, one arm is a compile-time
|
||||
// constant and the compiler warns that the other is dead code.
|
||||
#if !arch(arm64)
|
||||
return DoctorCheck(
|
||||
name: name,
|
||||
result: .fail,
|
||||
detail: "not an Apple silicon host; macOS guests require arm64",
|
||||
remediation: "run this daemon on an Apple silicon Mac"
|
||||
)
|
||||
#else
|
||||
|
||||
guard version.majorVersion >= 26 else {
|
||||
return DoctorCheck(
|
||||
name: name,
|
||||
result: .fail,
|
||||
detail: "macOS \(versionString); this daemon requires macOS 26 or newer",
|
||||
remediation: "upgrade the host: ASIF sparse disks need macOS 26+"
|
||||
)
|
||||
}
|
||||
|
||||
if version.majorVersion < 27 {
|
||||
return DoctorCheck(
|
||||
name: name,
|
||||
result: .warn,
|
||||
detail: "arm64, macOS \(versionString)",
|
||||
remediation: """
|
||||
automated guest provisioning (VZMacGuestProvisioningOptions) needs macOS 27 \
|
||||
on both host and guest; on 26 the first boot's Setup Assistant must be \
|
||||
completed by hand once per image
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
return DoctorCheck(name: name, result: .pass, detail: "arm64, macOS \(versionString)")
|
||||
#endif
|
||||
}
|
||||
|
||||
/// The framework's own verdict on this host.
|
||||
public static func checkVirtualizationSupported() -> DoctorCheck {
|
||||
let name = "Virtualization.framework"
|
||||
if VZVirtualMachine.isSupported {
|
||||
return DoctorCheck(name: name, result: .pass, detail: "VZVirtualMachine.isSupported == true")
|
||||
}
|
||||
return DoctorCheck(
|
||||
name: name,
|
||||
result: .fail,
|
||||
detail: "VZVirtualMachine.isSupported == false",
|
||||
remediation: "this host cannot run virtual machines"
|
||||
)
|
||||
}
|
||||
|
||||
/// Free space on the store volume against `storage.minFreeDiskGB`.
|
||||
public static func checkDiskSpace(config: RunnerConfig) -> DoctorCheck {
|
||||
let name = "free disk space"
|
||||
let store = VMStore(config: config)
|
||||
do {
|
||||
let free = try store.freeDiskSpace()
|
||||
let freeGB = Double(free) / 1_073_741_824
|
||||
let detail = String(
|
||||
format: "%.1f GB free at %@ (minimum %d GB)",
|
||||
freeGB, config.storeDirectoryURL.path, config.storage.minFreeDiskGB
|
||||
)
|
||||
if freeGB < Double(config.storage.minFreeDiskGB) {
|
||||
return DoctorCheck(
|
||||
name: name,
|
||||
result: .fail,
|
||||
detail: detail,
|
||||
remediation: "free space, or lower storage.minFreeDiskGB"
|
||||
)
|
||||
}
|
||||
return DoctorCheck(name: name, result: .pass, detail: detail)
|
||||
} catch {
|
||||
return DoctorCheck(
|
||||
name: name,
|
||||
result: .warn,
|
||||
detail: "could not measure free space: \(error)",
|
||||
remediation: "check that \(config.storeDirectoryURL.path) exists and is readable"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Reachability, admin scope, and registration-token availability.
|
||||
public static func checkGitea(config: RunnerConfig) async -> [DoctorCheck] {
|
||||
var checks: [DoctorCheck] = []
|
||||
|
||||
let adminToken: String?
|
||||
do {
|
||||
adminToken = try config.resolveAdminToken()
|
||||
} catch {
|
||||
checks.append(
|
||||
DoctorCheck(
|
||||
name: "gitea admin token",
|
||||
result: .fail,
|
||||
detail: "\(error)",
|
||||
remediation: "check gitea.adminTokenFile / gitea.adminToken"
|
||||
)
|
||||
)
|
||||
return checks
|
||||
}
|
||||
|
||||
guard let adminToken, !adminToken.isEmpty else {
|
||||
checks.append(
|
||||
DoctorCheck(
|
||||
name: "gitea admin api",
|
||||
result: .warn,
|
||||
detail: "no admin token configured; skipped",
|
||||
remediation: "set gitea.adminTokenFile to a file holding an ADMIN user's API token"
|
||||
)
|
||||
)
|
||||
return checks
|
||||
}
|
||||
|
||||
let client = GiteaClient(baseURL: config.gitea.instanceURL, token: adminToken)
|
||||
do {
|
||||
let runners = try await client.listRunners()
|
||||
checks.append(
|
||||
DoctorCheck(
|
||||
name: "gitea admin api",
|
||||
result: .pass,
|
||||
detail: "\(config.gitea.instanceURL.absoluteString) reachable; \(runners.count) runner(s) registered"
|
||||
)
|
||||
)
|
||||
} catch {
|
||||
checks.append(
|
||||
DoctorCheck(
|
||||
name: "gitea admin api",
|
||||
result: .fail,
|
||||
detail: "\(error)",
|
||||
remediation: """
|
||||
every endpoint used lives under /api/v1/admin/actions/ — the token must \
|
||||
belong to a Gitea administrator, and the instance must be reachable
|
||||
"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
checks.append(await checkRegistrationToken(config: config, client: client))
|
||||
return checks
|
||||
}
|
||||
|
||||
/// Whether a registration token can be obtained at all.
|
||||
private static func checkRegistrationToken(config: RunnerConfig, client: GiteaClient) async -> DoctorCheck {
|
||||
let name = "registration token"
|
||||
do {
|
||||
if let staticToken = try config.resolveStaticRegistrationToken(), !staticToken.isEmpty {
|
||||
return DoctorCheck(name: name, result: .pass, detail: "resolved from configuration")
|
||||
}
|
||||
} catch {
|
||||
return DoctorCheck(
|
||||
name: name,
|
||||
result: .fail,
|
||||
detail: "\(error)",
|
||||
remediation: "check gitea.registrationTokenFile"
|
||||
)
|
||||
}
|
||||
|
||||
guard config.gitea.fetchRegistrationTokenViaAPI else {
|
||||
return DoctorCheck(
|
||||
name: name,
|
||||
result: .fail,
|
||||
detail: "no static token configured and gitea.fetchRegistrationTokenViaAPI is off",
|
||||
remediation: """
|
||||
seed a fixed token server-side (GITEA_RUNNER_REGISTRATION_TOKEN) and point \
|
||||
gitea.registrationTokenFile at a copy of it
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
do {
|
||||
let token = try await client.getRegistrationToken()
|
||||
guard !token.isEmpty else {
|
||||
return DoctorCheck(
|
||||
name: name,
|
||||
result: .fail,
|
||||
detail: "the API returned an empty token",
|
||||
remediation: "configure gitea.registrationTokenFile instead"
|
||||
)
|
||||
}
|
||||
return DoctorCheck(name: name, result: .pass, detail: "fetched from the admin API")
|
||||
} catch {
|
||||
return DoctorCheck(
|
||||
name: name,
|
||||
result: .fail,
|
||||
detail: "\(error)",
|
||||
remediation: "configure gitea.registrationTokenFile instead"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the configured `gitea-runner` asset still exists.
|
||||
public static func checkRunnerDownloadURL(config: RunnerConfig) async -> DoctorCheck {
|
||||
let name = "runner download url"
|
||||
let url: URL
|
||||
do {
|
||||
url = try config.runner.resolvedDownloadURL
|
||||
} catch {
|
||||
return DoctorCheck(
|
||||
name: name,
|
||||
result: .fail,
|
||||
detail: "\(error)",
|
||||
remediation: "check runner.runnerDownloadURL and runner.version"
|
||||
)
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "HEAD"
|
||||
request.timeoutInterval = 15
|
||||
|
||||
do {
|
||||
let (_, response) = try await URLSession.shared.data(for: request)
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
if status <= 399 {
|
||||
return DoctorCheck(name: name, result: .pass, detail: "\(url.absoluteString) → \(status)")
|
||||
}
|
||||
return DoctorCheck(
|
||||
name: name,
|
||||
result: .warn,
|
||||
detail: "\(url.absoluteString) → \(status)",
|
||||
remediation: "check runner.version and runner.runnerDownloadURL for a darwin-arm64 asset"
|
||||
)
|
||||
} catch {
|
||||
// Best effort: a proxy or offline build host is not a reason to
|
||||
// block the daemon.
|
||||
return DoctorCheck(
|
||||
name: name,
|
||||
result: .warn,
|
||||
detail: "could not reach \(url.absoluteString): \(error.localizedDescription)",
|
||||
remediation: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Warns about token files readable by other users on this Mac.
|
||||
public static func checkTokenFilePermissions(config: RunnerConfig) -> [DoctorCheck] {
|
||||
let insecure = config.insecureTokenFilePaths
|
||||
guard !insecure.isEmpty else { return [] }
|
||||
return [
|
||||
DoctorCheck(
|
||||
name: "token file permissions",
|
||||
result: .warn,
|
||||
detail: "group/world readable: \(insecure.joined(separator: ", "))",
|
||||
remediation: "chmod 600 \(insecure.joined(separator: " "))"
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
/// The macOS 15+ Local Network permission note.
|
||||
public static func localNetworkNote() -> DoctorCheck {
|
||||
DoctorCheck(
|
||||
name: "local network access",
|
||||
result: .info,
|
||||
detail: "guests are reached over the host-private NAT link",
|
||||
remediation: """
|
||||
on macOS 15+ the first connection to a guest can be blocked by the Local Network \
|
||||
privacy prompt, which a background LaunchAgent cannot answer. Approve the app once \
|
||||
under System Settings → Privacy & Security → Local Network.
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
/// Renders checks as aligned, human-readable lines for the CLI.
|
||||
public static func format(_ checks: [DoctorCheck]) -> String {
|
||||
let width = checks.map(\.name.count).max() ?? 0
|
||||
var lines: [String] = []
|
||||
|
||||
for check in checks {
|
||||
let padded = check.name.padding(toLength: max(width, check.name.count), withPad: " ", startingAt: 0)
|
||||
lines.append("\(check.result.symbol) \(padded) \(check.detail)")
|
||||
if check.result != .pass, let remediation = check.remediation {
|
||||
for (index, wrapped) in wrap(remediation, width: 76).enumerated() {
|
||||
let prefix = index == 0 ? "→ " : " "
|
||||
lines.append(String(repeating: " ", count: width + 4) + prefix + wrapped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let failures = checks.filter(\.isBlocking).count
|
||||
let warnings = checks.filter { $0.result == .warn }.count
|
||||
lines.append("")
|
||||
lines.append("\(checks.count) checks, \(failures) failed, \(warnings) warned")
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
/// Greedy word wrap for remediation text.
|
||||
private static func wrap(_ text: String, width: Int) -> [String] {
|
||||
var lines: [String] = []
|
||||
var current = ""
|
||||
for word in text.split(whereSeparator: { $0 == " " || $0 == "\n" }) {
|
||||
if current.isEmpty {
|
||||
current = String(word)
|
||||
} else if current.count + 1 + word.count <= width {
|
||||
current += " " + word
|
||||
} else {
|
||||
lines.append(current)
|
||||
current = String(word)
|
||||
}
|
||||
}
|
||||
if !current.isEmpty { lines.append(current) }
|
||||
return lines
|
||||
}
|
||||
|
||||
/// Resolves the running executable, preferring the bundle's own record of it
|
||||
/// over `argv[0]`, which may be a symlink or a bare command name.
|
||||
private static func resolveExecutablePath(_ candidate: String) -> String? {
|
||||
let fm = FileManager.default
|
||||
if !candidate.isEmpty, candidate.hasPrefix("/"), fm.fileExists(atPath: candidate) {
|
||||
return URL(fileURLWithPath: candidate).resolvingSymlinksInPath().path
|
||||
}
|
||||
if let executableURL = Bundle.main.executableURL {
|
||||
return executableURL.resolvingSymlinksInPath().path
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal synchronous process runner for the tools `doctor` shells out to.
|
||||
private enum DoctorShell {
|
||||
struct Output {
|
||||
let exitCode: Int32
|
||||
let output: String
|
||||
}
|
||||
|
||||
static func run(_ launchPath: String, _ arguments: [String]) -> Output {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: launchPath)
|
||||
process.arguments = arguments
|
||||
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
// codesign and security both report on stderr; merge so callers can grep
|
||||
// one stream.
|
||||
process.standardError = pipe
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
return Output(exitCode: 127, output: "\(error)")
|
||||
}
|
||||
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
process.waitUntilExit()
|
||||
return Output(exitCode: process.terminationStatus, output: String(data: data, encoding: .utf8) ?? "")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import Foundation
|
||||
import RunnerCore
|
||||
import Virtualization
|
||||
|
||||
/// Locates, downloads, and opens macOS restore images (IPSWs).
|
||||
///
|
||||
/// Two distinct notions of "restore image" get conflated easily, so this type
|
||||
/// keeps them apart:
|
||||
///
|
||||
/// * `VZMacOSRestoreImage.latestSupported` returns an image whose `url` is a
|
||||
/// **network** URL on Apple's CDN. It cannot be handed to `VZMacOSInstaller`.
|
||||
/// * `VZMacOSRestoreImage.image(from:)` (or `load(from:)`) opens a **local
|
||||
/// file** URL. That is what the installer needs.
|
||||
///
|
||||
/// So the pipeline is always: discover → download → load.
|
||||
public struct IPSWProvider: Sendable {
|
||||
/// Where downloads are written, typically `<storeDir>/ipsw`.
|
||||
public let downloadDirectory: URL
|
||||
|
||||
/// Creates a provider.
|
||||
///
|
||||
/// - Parameter downloadDirectory: Destination directory for downloads.
|
||||
public init(downloadDirectory: URL) {
|
||||
self.downloadDirectory = downloadDirectory
|
||||
}
|
||||
|
||||
/// Asks Apple for the newest restore image this host can run.
|
||||
///
|
||||
/// - Returns: The CDN URL to download and the image's build version (e.g.
|
||||
/// `25A354`), recorded into ``VMBundleConfig/macOSVersion``.
|
||||
/// - Throws: ``CoreError/notFound(_:)`` when Apple reports no supported
|
||||
/// image (which also happens with no network).
|
||||
public func latestSupported() async throws -> (url: URL, buildVersion: String) {
|
||||
let image: VZMacOSRestoreImage
|
||||
do {
|
||||
image = try await VZMacOSRestoreImage.latestSupported
|
||||
} catch {
|
||||
// The framework reports "no supported image" and "could not reach
|
||||
// the CDN" identically, so the message has to cover both.
|
||||
throw CoreError.notFound(
|
||||
"no supported macOS restore image available: \(error.localizedDescription) "
|
||||
+ "(check network connectivity, or pass --ipsw with a local file)"
|
||||
)
|
||||
}
|
||||
return (image.url, image.buildVersion)
|
||||
}
|
||||
|
||||
/// Downloads a restore image to ``downloadDirectory``.
|
||||
///
|
||||
/// IPSWs are ~15 GB, so this reports progress and resumes nothing — a failed
|
||||
/// download is retried from scratch. The file is written to a `.partial`
|
||||
/// name and renamed on completion so an interrupted run never leaves a
|
||||
/// truncated file that looks valid.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - remoteURL: The CDN URL from ``latestSupported()``.
|
||||
/// - progress: Called with a fraction in `0...1`. May be called from an
|
||||
/// arbitrary thread.
|
||||
/// - Returns: The local file URL.
|
||||
public func download(
|
||||
from remoteURL: URL,
|
||||
progress: (@Sendable (Double) -> Void)? = nil
|
||||
) async throws -> URL {
|
||||
// A file URL is already local; nothing to do.
|
||||
if remoteURL.isFileURL {
|
||||
progress?(1.0)
|
||||
return remoteURL
|
||||
}
|
||||
|
||||
let fileManager = FileManager.default
|
||||
try fileManager.createDirectory(at: downloadDirectory, withIntermediateDirectories: true)
|
||||
|
||||
let fileName = IPSWProvider.localFileName(for: remoteURL)
|
||||
let finalURL = downloadDirectory.appendingPathComponent(fileName)
|
||||
|
||||
// A previously completed download is reused: only fully-written files
|
||||
// ever get the final name.
|
||||
if fileManager.fileExists(atPath: finalURL.path) {
|
||||
progress?(1.0)
|
||||
return finalURL
|
||||
}
|
||||
|
||||
let partialURL = downloadDirectory.appendingPathComponent(fileName + ".partial")
|
||||
try? fileManager.removeItem(at: partialURL)
|
||||
|
||||
let configuration = URLSessionConfiguration.default
|
||||
// The default 7-day resource timeout is useless as a failure signal and
|
||||
// the default 60 s request timeout only bounds the *response start*.
|
||||
// Six hours is generous for 15 GB on a slow link and still finite.
|
||||
configuration.timeoutIntervalForRequest = 120
|
||||
configuration.timeoutIntervalForResource = 6 * 60 * 60
|
||||
configuration.waitsForConnectivity = true
|
||||
|
||||
let delegate = IPSWDownloadProgressDelegate(onProgress: progress)
|
||||
let session = URLSession(configuration: configuration)
|
||||
defer { session.finishTasksAndInvalidate() }
|
||||
|
||||
let temporaryURL: URL
|
||||
let response: URLResponse
|
||||
do {
|
||||
(temporaryURL, response) = try await session.download(from: remoteURL, delegate: delegate)
|
||||
} catch {
|
||||
throw CoreError.notFound(
|
||||
"restore image download failed for \(remoteURL.absoluteString): \(error.localizedDescription)"
|
||||
)
|
||||
}
|
||||
|
||||
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
|
||||
try? fileManager.removeItem(at: temporaryURL)
|
||||
throw CoreError.notFound(
|
||||
"restore image download failed: HTTP \(http.statusCode) for \(remoteURL.absoluteString)"
|
||||
)
|
||||
}
|
||||
|
||||
// Move into `.partial` first, then rename: the final name is the
|
||||
// "this file is complete" marker that the reuse check above trusts.
|
||||
do {
|
||||
try fileManager.moveItem(at: temporaryURL, to: partialURL)
|
||||
try fileManager.moveItem(at: partialURL, to: finalURL)
|
||||
} catch {
|
||||
try? fileManager.removeItem(at: temporaryURL)
|
||||
try? fileManager.removeItem(at: partialURL)
|
||||
throw CoreError.provisioningFailed(
|
||||
"could not store the downloaded restore image at \(finalURL.path): \(error.localizedDescription)"
|
||||
)
|
||||
}
|
||||
|
||||
progress?(1.0)
|
||||
return finalURL
|
||||
}
|
||||
|
||||
/// Opens a local IPSW.
|
||||
///
|
||||
/// Symlinks are resolved first: `VZMacOSRestoreImage` rejects a symlinked
|
||||
/// path, and `~/Downloads` paths handed in by users are frequently symlinked
|
||||
/// through `/Users` → `/System/Volumes/Data/Users`.
|
||||
///
|
||||
/// - Parameter localPath: Path to an `.ipsw` file.
|
||||
/// - Returns: The loaded restore image.
|
||||
/// - Throws: ``CoreError/notFound(_:)`` when the path does not exist, or
|
||||
/// ``CoreError/configInvalid(_:)`` when it is not a local file URL.
|
||||
public func load(localPath: String) async throws -> VZMacOSRestoreImage {
|
||||
let expanded = (localPath as NSString).expandingTildeInPath
|
||||
var url = URL(fileURLWithPath: expanded)
|
||||
// Must happen before the framework ever sees the URL.
|
||||
url.resolveSymlinksInPath()
|
||||
|
||||
guard url.isFileURL else {
|
||||
throw CoreError.configInvalid(
|
||||
"restore image path must be a local file, got \(url.absoluteString)"
|
||||
)
|
||||
}
|
||||
|
||||
// `VZMacOSRestoreImage.image(from:)` raises an Objective-C exception —
|
||||
// not a Swift error — when handed a non-file or missing path, and an
|
||||
// ObjC exception cannot be caught here. So the existence check is not
|
||||
// politeness; it is the only thing standing between a typo and a crash.
|
||||
var isDirectory: ObjCBool = false
|
||||
guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory),
|
||||
!isDirectory.boolValue
|
||||
else {
|
||||
throw CoreError.notFound("restore image not found at \(url.path)")
|
||||
}
|
||||
|
||||
do {
|
||||
return try await VZMacOSRestoreImage.image(from: url)
|
||||
} catch {
|
||||
throw CoreError.provisioningFailed(
|
||||
"could not read restore image at \(url.path): \(error.localizedDescription)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience: discover, download if not already present, and load.
|
||||
///
|
||||
/// - Parameter progress: Download progress callback.
|
||||
/// - Returns: The loaded image and the local file it came from.
|
||||
public func fetchLatest(
|
||||
progress: (@Sendable (Double) -> Void)? = nil
|
||||
) async throws -> (image: VZMacOSRestoreImage, localURL: URL) {
|
||||
let (remoteURL, _) = try await latestSupported()
|
||||
let localURL = try await download(from: remoteURL, progress: progress)
|
||||
// Deliberately reloaded from the local file: the image returned by
|
||||
// `latestSupported` carries a network URL, and the installer needs one
|
||||
// whose `url` is on disk.
|
||||
let image = try await load(localPath: localURL.path)
|
||||
return (image, localURL)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// The on-disk name for a remote restore image.
|
||||
///
|
||||
/// Apple's CDN names are already unique (`UniversalMac_15.2_24C101_Restore.ipsw`);
|
||||
/// anything else falls back to a name derived from the URL so two different
|
||||
/// sources cannot collide.
|
||||
static func localFileName(for remoteURL: URL) -> String {
|
||||
let candidate = remoteURL.lastPathComponent
|
||||
if candidate.lowercased().hasSuffix(".ipsw"), candidate.count > ".ipsw".count {
|
||||
return candidate
|
||||
}
|
||||
let digest = abs(remoteURL.absoluteString.hashValue)
|
||||
return "restore-\(String(digest, radix: 16)).ipsw"
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports `URLSession` download progress as a fraction.
|
||||
///
|
||||
/// A task-scoped delegate is the only way to observe byte progress from the
|
||||
/// `async` download API; the `didFinishDownloadingTo` callback is deliberately
|
||||
/// *not* implemented, because the `async` variant owns the temporary file.
|
||||
private final class IPSWDownloadProgressDelegate: NSObject, URLSessionDownloadDelegate, @unchecked Sendable {
|
||||
private let onProgress: (@Sendable (Double) -> Void)?
|
||||
|
||||
init(onProgress: (@Sendable (Double) -> Void)?) {
|
||||
self.onProgress = onProgress
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
downloadTask: URLSessionDownloadTask,
|
||||
didWriteData bytesWritten: Int64,
|
||||
totalBytesWritten: Int64,
|
||||
totalBytesExpectedToWrite: Int64
|
||||
) {
|
||||
// A chunked response reports -1 for the expected length; report nothing
|
||||
// rather than a nonsense fraction.
|
||||
guard totalBytesExpectedToWrite > 0 else { return }
|
||||
let fraction = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
|
||||
onProgress?(min(max(fraction, 0), 1))
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
downloadTask: URLSessionDownloadTask,
|
||||
didFinishDownloadingTo location: URL
|
||||
) {
|
||||
// Intentionally empty. `URLSession.download(from:delegate:)` moves the
|
||||
// file itself; doing anything here would race with it.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,706 @@
|
||||
import Foundation
|
||||
import RunnerCore
|
||||
import Virtualization
|
||||
|
||||
/// A coarse progress report from ``ImageBuilder``.
|
||||
public enum ImageBuildStage: Sendable, Equatable {
|
||||
/// Downloading the IPSW.
|
||||
case downloadingIPSW(fraction: Double)
|
||||
/// Reading the restore image and deriving a hardware configuration.
|
||||
case preparing
|
||||
/// Creating the disk, NVRAM, and bundle metadata.
|
||||
case creatingBundle
|
||||
/// `VZMacOSInstaller` is writing macOS onto the disk.
|
||||
case installing(fraction: Double)
|
||||
/// First boot; waiting for Setup Assistant, a DHCP lease, and SSH.
|
||||
case firstBoot
|
||||
/// Running guest provisioning over SSH.
|
||||
case provisioning(step: String)
|
||||
/// Shutting the guest down cleanly and sealing the bundle.
|
||||
case finalizing
|
||||
/// Done.
|
||||
case done
|
||||
}
|
||||
|
||||
/// Builds a base macOS image from an IPSW, end to end.
|
||||
///
|
||||
/// ## Pipeline
|
||||
///
|
||||
/// 1. **Load restore image** — `VZMacOSRestoreImage` from a local `.ipsw`
|
||||
/// (downloaded first if the caller did not supply one).
|
||||
/// 2. **Derive hardware** — `mostFeaturefulSupportedConfiguration`. A `nil`
|
||||
/// here means this host cannot run this image at all; fail loudly rather
|
||||
/// than trying to guess a configuration.
|
||||
/// 3. **Create the bundle** — persist `hardwareModel.dataRepresentation` and a
|
||||
/// fresh `VZMacMachineIdentifier`; create NVRAM with
|
||||
/// `VZMacAuxiliaryStorage(creatingStorageAt:hardwareModel:)`; create the
|
||||
/// disk, preferring sparse ASIF via
|
||||
/// `/usr/sbin/diskutil image create blank --fs none --format ASIF --size <N>G <path>`
|
||||
/// (macOS 26+) and falling back to a `truncate`-style sparse RAW file.
|
||||
/// 4. **Install** — `VZMacOSInstaller` against a *stopped* VM built from that
|
||||
/// configuration, observing its `Progress` via KVO.
|
||||
/// 5. **First boot with Setup Assistant automation** — see
|
||||
/// ``firstBootAndProvision(bundle:config:progress:)``.
|
||||
/// 6. **Provision** — ``GuestProvisioner`` over SSH.
|
||||
/// 7. **Finalize** — clean guest shutdown, then set
|
||||
/// ``VMBundleConfig/provisioned`` to `true`. Only then is the image clonable.
|
||||
public struct ImageBuilder: Sendable {
|
||||
/// The store this image is built into.
|
||||
public let store: VMStore
|
||||
|
||||
/// Creates a builder.
|
||||
public init(store: VMStore) {
|
||||
self.store = store
|
||||
}
|
||||
|
||||
/// Runs the whole pipeline.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - name: Image name under `<storeDir>/images/`, e.g. `default`.
|
||||
/// - ipswPath: A local `.ipsw`. When `nil`, the latest supported image is
|
||||
/// discovered and downloaded.
|
||||
/// - config: Supplies guest shape (CPU/RAM/disk), credentials, and the
|
||||
/// `gitea-runner` download URL.
|
||||
/// - progress: Stage callback. May be invoked from arbitrary threads.
|
||||
/// - Throws: ``CoreError/provisioningFailed(_:)`` naming the failed stage.
|
||||
public func build(
|
||||
name: String,
|
||||
ipswPath: String?,
|
||||
config: RunnerConfig,
|
||||
progress: (@Sendable (ImageBuildStage) -> Void)? = nil
|
||||
) async throws {
|
||||
try store.ensureLayout()
|
||||
|
||||
// Checked against the filesystem rather than `store.image(named:)`: a
|
||||
// half-built bundle from a previous failed run is exactly the thing this
|
||||
// needs to catch, and it would not read back as a valid image.
|
||||
let bundleURL = store.imagesDir.appendingPathComponent(name, isDirectory: true)
|
||||
if FileManager.default.fileExists(atPath: bundleURL.path) {
|
||||
throw CoreError.configInvalid(
|
||||
"image '\(name)' already exists at \(bundleURL.path). "
|
||||
+ "Delete it first (`image delete \(name)`), or build under a different --name."
|
||||
)
|
||||
}
|
||||
|
||||
// The IPSW alone is ~15 GB and the installed disk grows to tens more.
|
||||
try store.ensureFreeSpace(minGB: ipswPath == nil ? 60 : 45)
|
||||
|
||||
// 1. Restore image.
|
||||
let provider = IPSWProvider(downloadDirectory: store.ipswDir)
|
||||
let restoreImage: VZMacOSRestoreImage
|
||||
if let ipswPath {
|
||||
progress?(.downloadingIPSW(fraction: 1.0))
|
||||
restoreImage = try await provider.load(localPath: ipswPath)
|
||||
} else {
|
||||
progress?(.downloadingIPSW(fraction: 0))
|
||||
let (image, _) = try await provider.fetchLatest { fraction in
|
||||
progress?(.downloadingIPSW(fraction: fraction))
|
||||
}
|
||||
restoreImage = image
|
||||
}
|
||||
|
||||
// 2/3. Hardware model and bundle.
|
||||
progress?(.preparing)
|
||||
progress?(.creatingBundle)
|
||||
let bundle = try await createBundle(name: name, restoreImage: restoreImage, config: config)
|
||||
|
||||
// 4. Install.
|
||||
progress?(.installing(fraction: 0))
|
||||
try await install(bundle: bundle, restoreImage: restoreImage) { fraction in
|
||||
progress?(.installing(fraction: fraction))
|
||||
}
|
||||
|
||||
// 5/6/7. First boot, provisioning, seal.
|
||||
try await firstBootAndProvision(bundle: bundle, config: config, progress: progress)
|
||||
|
||||
progress?(.done)
|
||||
}
|
||||
|
||||
/// Creates the bundle directory, disk, NVRAM, and `config.json`.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - name: Image name.
|
||||
/// - restoreImage: The loaded IPSW, used for its
|
||||
/// `mostFeaturefulSupportedConfiguration` and `buildVersion`.
|
||||
/// - config: Guest shape and credentials.
|
||||
/// - Returns: The new, uninstalled bundle.
|
||||
public func createBundle(
|
||||
name: String,
|
||||
restoreImage: VZMacOSRestoreImage,
|
||||
config: RunnerConfig
|
||||
) async throws -> VMBundle {
|
||||
// `mostFeaturefulSupportedConfiguration` is nil when this host cannot run
|
||||
// this image at all — an Intel host, or a restore image newer than the
|
||||
// host's Virtualization stack. There is nothing to fall back to, and
|
||||
// guessing a hardware model produces a VM that fails to boot much later
|
||||
// with a far less useful message.
|
||||
guard let requirements = restoreImage.mostFeaturefulSupportedConfiguration else {
|
||||
let version = restoreImage.operatingSystemVersion
|
||||
throw CoreError.hostUnsupported(
|
||||
"this host cannot virtualize macOS \(version.majorVersion).\(version.minorVersion) "
|
||||
+ "(build \(restoreImage.buildVersion)). The restore image reports no supported "
|
||||
+ "configuration — the host is either not Apple silicon or is older than the guest."
|
||||
)
|
||||
}
|
||||
|
||||
let hardwareModel = requirements.hardwareModel
|
||||
guard hardwareModel.isSupported else {
|
||||
throw CoreError.hostUnsupported(
|
||||
"the hardware model required by build \(restoreImage.buildVersion) is not supported on this host"
|
||||
)
|
||||
}
|
||||
|
||||
// The image's own minimums win over the configured shape: a guest below
|
||||
// them will not boot, and silently honouring a too-small config would
|
||||
// produce that failure at first boot instead of here.
|
||||
let cpuCount = max(config.guest.cpuCount, requirements.minimumSupportedCPUCount)
|
||||
let minimumMemoryGB = Int(
|
||||
(requirements.minimumSupportedMemorySize + (1 << 30) - 1) / (1 << 30)
|
||||
)
|
||||
let memoryGB = max(config.guest.memoryGB, minimumMemoryGB)
|
||||
|
||||
let bundle = VMBundle(rootURL: store.imagesDir.appendingPathComponent(name, isDirectory: true))
|
||||
try bundle.createDirectory()
|
||||
|
||||
do {
|
||||
let diskFormat = try createDisk(bundle: bundle, sizeGB: config.guest.diskGB)
|
||||
|
||||
// NVRAM must be created against the *same* hardware model that goes
|
||||
// into config.json and into VZMacPlatformConfiguration. A mismatch is
|
||||
// undefined behaviour in the framework, not a validation error.
|
||||
_ = try VZMacAuxiliaryStorage(
|
||||
creatingStorageAt: bundle.auxiliaryStorageURL,
|
||||
hardwareModel: hardwareModel,
|
||||
options: []
|
||||
)
|
||||
|
||||
let osVersion = restoreImage.operatingSystemVersion
|
||||
let bundleConfig = VMBundleConfig(
|
||||
hardwareModelData: hardwareModel.dataRepresentation,
|
||||
machineIdentifierData: VZMacMachineIdentifier().dataRepresentation,
|
||||
// A placeholder: the base image is never booted on a slot. Every
|
||||
// clone rewrites this with its slot's persistent MAC, which is
|
||||
// what DHCP lease discovery keys on. It still has to be a valid
|
||||
// locally-administered address, because the base image *is*
|
||||
// booted once, here, for provisioning.
|
||||
macAddress: VZMACAddress.randomLocallyAdministered().string,
|
||||
diskFormat: diskFormat,
|
||||
cpuCount: cpuCount,
|
||||
memoryGB: memoryGB,
|
||||
guestUsername: config.guest.username,
|
||||
macOSVersion:
|
||||
"\(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion) "
|
||||
+ "(\(restoreImage.buildVersion))",
|
||||
provisioned: false
|
||||
)
|
||||
try bundle.saveConfig(bundleConfig)
|
||||
} catch {
|
||||
// A bundle that got partway through creation is not something a later
|
||||
// run can recover from, and leaving it behind would make `build` with
|
||||
// the same name fail on the "already exists" check for the wrong
|
||||
// reason.
|
||||
try? bundle.destroy()
|
||||
throw error
|
||||
}
|
||||
|
||||
return bundle
|
||||
}
|
||||
|
||||
/// Creates the backing disk, preferring sparse ASIF.
|
||||
///
|
||||
/// ASIF (`diskutil image create blank --fs none --format ASIF`) is available
|
||||
/// from macOS 26 and is the right choice here: it is sparse, so a 64 GB
|
||||
/// nominal disk costs what the guest actually writes, and it CoW-clones
|
||||
/// cleanly on APFS. If `diskutil` fails for any reason, a sparse RAW file is
|
||||
/// created instead and the format recorded in the bundle config so
|
||||
/// ``VZConfigFactory`` attaches the right file.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - bundle: Destination bundle.
|
||||
/// - sizeGB: Nominal disk size.
|
||||
/// - Returns: The format that was actually used.
|
||||
public func createDisk(bundle: VMBundle, sizeGB: Int) throws -> VMBundleConfig.DiskFormat {
|
||||
guard sizeGB > 0 else {
|
||||
throw CoreError.configInvalid("guest.diskGB must be positive, got \(sizeGB)")
|
||||
}
|
||||
|
||||
let asifURL = bundle.asifDiskURL
|
||||
try? FileManager.default.removeItem(at: asifURL)
|
||||
|
||||
// No `#available` guard: the package's deployment target is already
|
||||
// macOS 26, so the compiler would reject the check as redundant. The
|
||||
// runtime feature check that matters is whether *this* diskutil
|
||||
// understands `--format ASIF`, which the exit status answers directly —
|
||||
// that also covers early 26 builds where the format was still landing.
|
||||
do {
|
||||
try ImageBuilder.runProcess(
|
||||
"/usr/sbin/diskutil",
|
||||
[
|
||||
"image", "create", "blank",
|
||||
"--fs", "none",
|
||||
"--format", "ASIF",
|
||||
"--size", "\(sizeGB)G",
|
||||
asifURL.path,
|
||||
]
|
||||
)
|
||||
|
||||
// diskutil occasionally appends its own extension; accept either
|
||||
// spelling rather than failing on a cosmetic difference.
|
||||
if !FileManager.default.fileExists(atPath: asifURL.path) {
|
||||
let suffixed = URL(fileURLWithPath: asifURL.path + ".asif")
|
||||
if FileManager.default.fileExists(atPath: suffixed.path) {
|
||||
try FileManager.default.moveItem(at: suffixed, to: asifURL)
|
||||
}
|
||||
}
|
||||
|
||||
if FileManager.default.fileExists(atPath: asifURL.path) {
|
||||
return .asif
|
||||
}
|
||||
} catch {
|
||||
// Fall through to RAW.
|
||||
}
|
||||
|
||||
try? FileManager.default.removeItem(at: asifURL)
|
||||
|
||||
// RAW fallback: an empty file extended to the nominal size. APFS keeps it
|
||||
// sparse, so this costs nothing until the guest writes. Sizes are decimal
|
||||
// GB (1000³) to match what `diskutil … --size NG` produces, so switching
|
||||
// formats does not silently change the guest's disk size.
|
||||
let rawURL = bundle.rawDiskURL
|
||||
try? FileManager.default.removeItem(at: rawURL)
|
||||
guard FileManager.default.createFile(atPath: rawURL.path, contents: nil) else {
|
||||
throw CoreError.provisioningFailed("could not create the disk image at \(rawURL.path)")
|
||||
}
|
||||
let handle = try FileHandle(forWritingTo: rawURL)
|
||||
defer { try? handle.close() }
|
||||
do {
|
||||
try handle.truncate(atOffset: UInt64(sizeGB) * 1_000_000_000)
|
||||
} catch {
|
||||
throw CoreError.provisioningFailed(
|
||||
"could not size the disk image at \(rawURL.path) to \(sizeGB) GB: \(error.localizedDescription)"
|
||||
)
|
||||
}
|
||||
return .raw
|
||||
}
|
||||
|
||||
/// Runs `VZMacOSInstaller` to completion.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - bundle: The bundle to install into.
|
||||
/// - restoreImage: The loaded IPSW.
|
||||
/// - progress: Called with the installer's completed fraction.
|
||||
public func install(
|
||||
bundle: VMBundle,
|
||||
restoreImage: VZMacOSRestoreImage,
|
||||
progress: (@Sendable (Double) -> Void)? = nil
|
||||
) async throws {
|
||||
let imageURL = restoreImage.url
|
||||
guard imageURL.isFileURL else {
|
||||
// The image returned by `VZMacOSRestoreImage.latestSupported` carries
|
||||
// a CDN URL. Handing that to the installer fails deep inside the
|
||||
// framework; catching it here names the actual mistake.
|
||||
throw CoreError.configInvalid(
|
||||
"VZMacOSInstaller needs a local restore image, but this one points at "
|
||||
+ "\(imageURL.absoluteString). Download it first with IPSWProvider.download."
|
||||
)
|
||||
}
|
||||
|
||||
let configuration = try VZConfigFactory.makeConfiguration(bundle: bundle, headless: true)
|
||||
|
||||
// Everything about VZMacOSInstaller is queue-bound: the VM must be
|
||||
// created on a queue, the installer must be *constructed* on that same
|
||||
// queue with the VM stopped, and `install` must be *called* on it too.
|
||||
// The VM is also created here rather than through VMInstance because the
|
||||
// installer needs the VZVirtualMachine object itself, and because the VM
|
||||
// must never be started, paused, or stopped while installing — behaviour
|
||||
// VMInstance exists to provide and which would be actively harmful here.
|
||||
let queue = DispatchQueue(label: "gitea-macos-runner.install.\(bundle.name)")
|
||||
let session = InstallSession()
|
||||
let boxedConfiguration = UncheckedBox(configuration)
|
||||
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
|
||||
queue.async {
|
||||
let virtualMachine = VZVirtualMachine(
|
||||
configuration: boxedConfiguration.value,
|
||||
queue: queue
|
||||
)
|
||||
let installer = VZMacOSInstaller(
|
||||
virtualMachine: virtualMachine,
|
||||
restoringFromImageAt: imageURL
|
||||
)
|
||||
// Held for the duration: the completion handler is the only other
|
||||
// strong reference, and dropping the VM mid-install would be a
|
||||
// use-after-free rather than a cancellation.
|
||||
session.virtualMachine = virtualMachine
|
||||
session.installer = installer
|
||||
|
||||
if let progress {
|
||||
session.observation = installer.progress.observe(
|
||||
\.fractionCompleted,
|
||||
options: [.initial, .new]
|
||||
) { observed, _ in
|
||||
progress(observed.fractionCompleted)
|
||||
}
|
||||
}
|
||||
|
||||
installer.install { result in
|
||||
session.observation = nil
|
||||
session.installer = nil
|
||||
session.virtualMachine = nil
|
||||
switch result {
|
||||
case .success:
|
||||
progress?(1.0)
|
||||
continuation.resume()
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: VMInstance.mapVZError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Boots the freshly installed guest, gets it onto the network, and hands it
|
||||
/// to ``GuestProvisioner``.
|
||||
///
|
||||
/// ## Setup Assistant
|
||||
///
|
||||
/// A newly installed macOS sits at Setup Assistant with no account and no
|
||||
/// SSH. On a **macOS 27+ host with a macOS 27+ guest**, Virtualization can
|
||||
/// automate that: build a `VZMacGuestProvisioningOptions` carrying the
|
||||
/// configured username, password, and full name, with
|
||||
/// `logsInAutomatically = true` and `enablesRemoteLogin = true`, and attach
|
||||
/// it to the start options via
|
||||
/// `VZMacOSVirtualMachineStartOptions.setGuestProvisioning(_:)` — the ObjC
|
||||
/// selector is `setGuestProvisioningOptions:error:`, but Swift imports it
|
||||
/// under the shorter name. The guest then creates the account and enables
|
||||
/// SSH unattended.
|
||||
///
|
||||
/// - Important: An **older guest silently ignores** these options — no
|
||||
/// error, no account, no SSH, and this method will simply time out waiting
|
||||
/// for a lease or a login. When that happens the only recovery is a
|
||||
/// manual, GUI-driven first boot, which is deliberately **out of v1
|
||||
/// scope**: this method fails with an explanatory
|
||||
/// ``CoreError/provisioningFailed(_:)`` telling the operator that a
|
||||
/// `--manual-setup` flow is not implemented and that the IPSW must be
|
||||
/// macOS 27 or newer.
|
||||
///
|
||||
/// The API itself is gated at `#available(macOS 27.0, *)`, so a macOS 26
|
||||
/// host takes the same explanatory failure path.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - bundle: The installed bundle.
|
||||
/// - config: Guest credentials and timeouts.
|
||||
/// - progress: Stage callback.
|
||||
public func firstBootAndProvision(
|
||||
bundle: VMBundle,
|
||||
config: RunnerConfig,
|
||||
progress: (@Sendable (ImageBuildStage) -> Void)? = nil
|
||||
) async throws {
|
||||
guard #available(macOS 27.0, *) else {
|
||||
throw CoreError.hostUnsupported(
|
||||
"""
|
||||
automating Setup Assistant requires macOS 27 or newer on the host; this host is older. \
|
||||
Without it the freshly installed guest sits at the setup screen forever, with no \
|
||||
account and no SSH. A manual, GUI-driven first boot is not implemented in v1.
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
let provisioningOptions = VZMacGuestProvisioningOptions()
|
||||
provisioningOptions.username = config.guest.username
|
||||
provisioningOptions.password = config.guest.password
|
||||
provisioningOptions.fullName = ImageBuilder.guestAccountFullName
|
||||
// Auto-login keeps a GUI session alive, which codesign against the login
|
||||
// keychain and the simulators both need.
|
||||
provisioningOptions.logsInAutomatically = true
|
||||
// This is what turns on sshd — the only channel provisioning has.
|
||||
provisioningOptions.enablesRemoteLogin = true
|
||||
|
||||
let startOptions = VZMacOSVirtualMachineStartOptions()
|
||||
do {
|
||||
// The validating setter: it rejects, for instance, a password that
|
||||
// the guest's account policy will not accept, here rather than by
|
||||
// quietly producing a guest with no usable account.
|
||||
try startOptions.setGuestProvisioning(provisioningOptions)
|
||||
} catch {
|
||||
throw CoreError.configInvalid(
|
||||
"guest provisioning options were rejected (check guest.username / guest.password): "
|
||||
+ error.localizedDescription
|
||||
)
|
||||
}
|
||||
|
||||
try await bootProvisionAndSeal(
|
||||
bundle: bundle,
|
||||
config: config,
|
||||
startOptions: startOptions,
|
||||
isFirstBoot: true,
|
||||
xcodeXIPPath: nil,
|
||||
progress: progress
|
||||
)
|
||||
}
|
||||
|
||||
/// Re-runs guest provisioning against an already-installed image.
|
||||
///
|
||||
/// Backs `image provision NAME`, which exists so that bumping the
|
||||
/// `gitea-runner` version or adding Xcode does not require a 15 GB
|
||||
/// reinstall.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - name: Image name.
|
||||
/// - config: Guest credentials and download URLs.
|
||||
/// - xcodeXIPPath: Optional Xcode `.xip` to install as well.
|
||||
/// - progress: Stage callback.
|
||||
public func reprovision(
|
||||
name: String,
|
||||
config: RunnerConfig,
|
||||
xcodeXIPPath: String? = nil,
|
||||
progress: (@Sendable (ImageBuildStage) -> Void)? = nil
|
||||
) async throws {
|
||||
let bundleURL = store.imagesDir.appendingPathComponent(name, isDirectory: true)
|
||||
guard FileManager.default.fileExists(atPath: bundleURL.path) else {
|
||||
throw CoreError.notFound("image '\(name)' at \(bundleURL.path)")
|
||||
}
|
||||
let bundle = VMBundle(rootURL: bundleURL)
|
||||
|
||||
if let xcodeXIPPath {
|
||||
let expanded = (xcodeXIPPath as NSString).expandingTildeInPath
|
||||
guard FileManager.default.fileExists(atPath: expanded) else {
|
||||
throw CoreError.notFound("Xcode .xip at \(expanded)")
|
||||
}
|
||||
}
|
||||
|
||||
// No start options: the account already exists, so there is nothing for
|
||||
// Setup Assistant automation to do, and re-applying it on a guest that is
|
||||
// already past first boot has no effect anyway (macOS only evaluates
|
||||
// guest provisioning on the first boot after a restore).
|
||||
try await bootProvisionAndSeal(
|
||||
bundle: bundle,
|
||||
config: config,
|
||||
startOptions: nil,
|
||||
isFirstBoot: false,
|
||||
xcodeXIPPath: xcodeXIPPath,
|
||||
progress: progress
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Boot, provision, seal
|
||||
|
||||
/// The shared tail of ``firstBootAndProvision(bundle:config:progress:)`` and
|
||||
/// ``reprovision(name:config:xcodeXIPPath:progress:)``: boot, find the guest
|
||||
/// on the network, provision it, shut it down cleanly, mark it provisioned.
|
||||
private func bootProvisionAndSeal(
|
||||
bundle: VMBundle,
|
||||
config: RunnerConfig,
|
||||
startOptions: VZMacOSVirtualMachineStartOptions?,
|
||||
isFirstBoot: Bool,
|
||||
xcodeXIPPath: String?,
|
||||
progress: (@Sendable (ImageBuildStage) -> Void)?
|
||||
) async throws {
|
||||
var bundleConfig = try bundle.loadConfig()
|
||||
let macAddress = bundleConfig.macAddress
|
||||
let bootTimeout = Duration.seconds(max(60, config.scheduler.bootTimeoutSeconds))
|
||||
|
||||
progress?(.firstBoot)
|
||||
let instance = try VMInstance(bundle: bundle, label: "image:\(bundle.name)", headless: true)
|
||||
|
||||
do {
|
||||
try await instance.start(options: startOptions)
|
||||
} catch {
|
||||
throw CoreError.provisioningFailed(
|
||||
"could not boot image '\(bundle.name)': \(error)"
|
||||
)
|
||||
}
|
||||
|
||||
let address: String
|
||||
do {
|
||||
// A DHCP lease is the first observable sign of life: the guest has
|
||||
// booted far enough to bring up its NIC. SSH comes tens of seconds
|
||||
// later, once launchd has started sshd.
|
||||
address = try await ImageBuilder.waitForDHCPLease(macAddress: macAddress, timeout: bootTimeout)
|
||||
try await waitForSSH(
|
||||
host: address,
|
||||
username: config.guest.username,
|
||||
password: config.guest.password,
|
||||
timeout: bootTimeout
|
||||
)
|
||||
} catch {
|
||||
_ = await instance.requestStopThenForce()
|
||||
if isFirstBoot {
|
||||
// The most likely cause by far, and the one with no diagnostic of
|
||||
// its own: a pre-27 guest accepts the provisioning options and
|
||||
// ignores them, so it sits at Setup Assistant with no account and
|
||||
// no sshd while we wait for a login that will never be possible.
|
||||
throw CoreError.provisioningFailed(
|
||||
"""
|
||||
the guest never became reachable over SSH within \(config.scheduler.bootTimeoutSeconds)s.
|
||||
|
||||
The usual cause is a guest older than macOS 27: earlier versions do not implement \
|
||||
the automated setup protocol and silently ignore the provisioning options, leaving \
|
||||
the VM parked at Setup Assistant with no account and no Remote Login. Rebuild with \
|
||||
a macOS 27 or newer restore image.
|
||||
|
||||
A manual, GUI-driven first boot is not implemented in v1.
|
||||
|
||||
Underlying error: \(error)
|
||||
"""
|
||||
)
|
||||
}
|
||||
throw CoreError.provisioningFailed(
|
||||
"image '\(bundle.name)' booted but never became reachable over SSH: \(error)"
|
||||
)
|
||||
}
|
||||
|
||||
let executor = SSHExecutor(
|
||||
host: address,
|
||||
username: config.guest.username,
|
||||
password: config.guest.password
|
||||
)
|
||||
|
||||
do {
|
||||
let provisioner = GuestProvisioner()
|
||||
try await provisioner.provision(executor: executor, config: config) { step in
|
||||
progress?(.provisioning(step: step))
|
||||
}
|
||||
|
||||
if let xcodeXIPPath {
|
||||
progress?(.provisioning(step: "Xcode"))
|
||||
try await provisioner.installXcode(
|
||||
executor: executor,
|
||||
xipPath: (xcodeXIPPath as NSString).expandingTildeInPath
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
await executor.close()
|
||||
_ = await instance.requestStopThenForce()
|
||||
throw error
|
||||
}
|
||||
|
||||
// Shut down from inside. A forced stop is a power cut: it leaves the
|
||||
// guest's filesystem in whatever state it was in, and every clone would
|
||||
// inherit that state, so the graceful path is worth waiting for.
|
||||
progress?(.finalizing)
|
||||
_ = try? await executor.run("sudo -n /sbin/shutdown -h now", timeout: .seconds(30))
|
||||
await executor.close()
|
||||
|
||||
let stopped = await ImageBuilder.withTimeout(.seconds(180)) {
|
||||
await instance.waitUntilStopped()
|
||||
}
|
||||
if stopped == nil {
|
||||
_ = await instance.requestStopThenForce()
|
||||
}
|
||||
|
||||
bundleConfig.provisioned = true
|
||||
try bundle.saveConfig(bundleConfig)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Full name for the account Setup Assistant automation creates.
|
||||
static let guestAccountFullName = "Gitea Runner"
|
||||
|
||||
/// Polls `/var/db/dhcpd_leases` until the guest's MAC appears.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - macAddress: The bundle's MAC, in any common formatting.
|
||||
/// - timeout: Overall ceiling.
|
||||
/// - pollInterval: Delay between reads. Defaults to 2 s.
|
||||
/// - Returns: The leased IP address.
|
||||
/// - Throws: ``CoreError/timeout(_:)`` if no lease appears in time.
|
||||
static func waitForDHCPLease(
|
||||
macAddress: String,
|
||||
timeout: Duration,
|
||||
pollInterval: Duration = .seconds(2)
|
||||
) async throws -> String {
|
||||
let started = ContinuousClock.now
|
||||
while true {
|
||||
let leases = DHCPLeaseParser.parseFile()
|
||||
if let address = DHCPLeaseParser.ipAddress(forMAC: macAddress, in: leases) {
|
||||
return address
|
||||
}
|
||||
guard ContinuousClock.now - started < timeout else { break }
|
||||
try await Task.sleep(for: pollInterval)
|
||||
guard ContinuousClock.now - started < timeout else { break }
|
||||
}
|
||||
throw CoreError.timeout("no DHCP lease for \(macAddress) in /var/db/dhcpd_leases")
|
||||
}
|
||||
|
||||
/// Runs an async operation with a ceiling, returning `nil` if it elapses.
|
||||
///
|
||||
/// Used for the graceful-shutdown wait, which otherwise has no bound:
|
||||
/// `waitUntilStopped()` waits forever, and a guest that hangs on shutdown
|
||||
/// would hang the build with it.
|
||||
static func withTimeout<T: Sendable>(
|
||||
_ duration: Duration,
|
||||
operation: @escaping @Sendable () async -> T
|
||||
) async -> T? {
|
||||
await withTaskGroup(of: Optional<T>.self) { group in
|
||||
group.addTask { await operation() }
|
||||
group.addTask {
|
||||
try? await Task.sleep(for: duration)
|
||||
return nil
|
||||
}
|
||||
let first = await group.next() ?? nil
|
||||
group.cancelAll()
|
||||
return first
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a host process and throws with its output if it exits non-zero.
|
||||
@discardableResult
|
||||
static func runProcess(_ executablePath: String, _ arguments: [String]) throws -> String {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: executablePath)
|
||||
process.arguments = arguments
|
||||
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
throw CoreError.processFailed(
|
||||
command: "\(executablePath) \(arguments.joined(separator: " "))",
|
||||
exitCode: -1,
|
||||
output: error.localizedDescription
|
||||
)
|
||||
}
|
||||
|
||||
// Drained before waiting: a command that outfills the pipe buffer would
|
||||
// block forever otherwise.
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
process.waitUntilExit()
|
||||
|
||||
let output = String(decoding: data, as: UTF8.self)
|
||||
guard process.terminationStatus == 0 else {
|
||||
throw CoreError.processFailed(
|
||||
command: "\(executablePath) \(arguments.joined(separator: " "))",
|
||||
exitCode: process.terminationStatus,
|
||||
output: output
|
||||
)
|
||||
}
|
||||
return output
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Install plumbing
|
||||
|
||||
/// Carries a non-`Sendable` Virtualization object onto the VM's serial queue.
|
||||
///
|
||||
/// The framework's configuration objects are not `Sendable` and never will be,
|
||||
/// but handing one to the queue that will own the VM is exactly the transfer the
|
||||
/// framework itself prescribes.
|
||||
private final class UncheckedBox<T>: @unchecked Sendable {
|
||||
let value: T
|
||||
init(_ value: T) { self.value = value }
|
||||
}
|
||||
|
||||
/// Owns the VM, installer, and KVO observation for one install.
|
||||
///
|
||||
/// Every field is read and written only on the install queue, which is what
|
||||
/// makes the unchecked conformance sound.
|
||||
private final class InstallSession: @unchecked Sendable {
|
||||
var virtualMachine: VZVirtualMachine?
|
||||
var installer: VZMacOSInstaller?
|
||||
var observation: NSKeyValueObservation?
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import Foundation
|
||||
import RunnerCore
|
||||
|
||||
/// Whether the LaunchAgent is installed and running.
|
||||
public struct ServiceStatus: Sendable, Equatable {
|
||||
/// Whether the plist exists at ``LaunchdService/agentPlistURL``.
|
||||
public let installed: Bool
|
||||
/// Whether `launchctl` reports the label as loaded.
|
||||
public let loaded: Bool
|
||||
/// The running PID, when loaded and alive.
|
||||
public let pid: Int?
|
||||
/// The last exit status `launchctl` reported, when not running.
|
||||
public let lastExitStatus: Int?
|
||||
/// Path to the plist, whether or not it exists.
|
||||
public let plistPath: String
|
||||
|
||||
public init(
|
||||
installed: Bool,
|
||||
loaded: Bool,
|
||||
pid: Int? = nil,
|
||||
lastExitStatus: Int? = nil,
|
||||
plistPath: String
|
||||
) {
|
||||
self.installed = installed
|
||||
self.loaded = loaded
|
||||
self.pid = pid
|
||||
self.lastExitStatus = lastExitStatus
|
||||
self.plistPath = plistPath
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs, removes, and inspects the daemon's `launchd` job.
|
||||
///
|
||||
/// ## LaunchAgent, never LaunchDaemon
|
||||
///
|
||||
/// This is not a stylistic choice. Two hard constraints force it:
|
||||
///
|
||||
/// * Virtualization.framework needs a **GUI login session**. A LaunchDaemon runs
|
||||
/// in the system context with no session, and VM startup fails there.
|
||||
/// * From macOS 15, starting a VM requires an **unlocked `login.keychain`**.
|
||||
/// That keychain unlocks when a user logs in graphically; a LaunchDaemon never
|
||||
/// sees it.
|
||||
///
|
||||
/// So the daemon runs as a LaunchAgent in the logged-in user's session, and the
|
||||
/// host must be configured for automatic login with the screen allowed to sleep
|
||||
/// but the session never locked. `doctor` checks the keychain state precisely
|
||||
/// because this is the failure people hit first.
|
||||
public enum LaunchdService {
|
||||
/// The `launchd` label, matching `CFBundleIdentifier`.
|
||||
public static let label = "xyz.blakeslee.gitea-macos-runner"
|
||||
|
||||
/// `~/Library/LaunchAgents/xyz.blakeslee.gitea-macos-runner.plist`.
|
||||
public static var agentPlistURL: URL {
|
||||
URL(fileURLWithPath: RunnerConfig.expandTilde("~/Library/LaunchAgents/\(label).plist"))
|
||||
}
|
||||
|
||||
/// The default install location of the signed app's executable.
|
||||
///
|
||||
/// `make install` puts the bundle here; the entitlement only exists on the
|
||||
/// signed bundle, so this — not a bare binary — is what `launchd` must run.
|
||||
public static let defaultExecutablePath =
|
||||
"~/Applications/GiteaMacosRunner.app/Contents/MacOS/gitea-macos-runner"
|
||||
|
||||
/// The GUI domain target for this user, e.g. `gui/501`.
|
||||
public static var domainTarget: String { "gui/\(getuid())" }
|
||||
|
||||
/// The service target for this user's agent, e.g. `gui/501/xyz.blakeslee…`.
|
||||
public static var serviceTarget: String { "\(domainTarget)/\(label)" }
|
||||
|
||||
/// Writes the plist and loads the job.
|
||||
///
|
||||
/// `ProgramArguments` is the **installed app bundle's** executable followed
|
||||
/// by `daemon` — not `.build/…` and not a bare binary, because the
|
||||
/// entitlement only exists on the signed bundle. `RunAtLoad` and `KeepAlive`
|
||||
/// are both set so the daemon survives crashes and logins.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - executablePath: Absolute path to the installed binary, e.g.
|
||||
/// `~/Applications/GiteaMacosRunner.app/Contents/MacOS/gitea-macos-runner`.
|
||||
/// - configPath: Optional `--config` argument for a non-default location.
|
||||
/// - Throws: ``CoreError/notFound(_:)`` when the executable or the template
|
||||
/// is missing, ``CoreError/processFailed(command:exitCode:output:)`` when
|
||||
/// `launchctl` refuses the job.
|
||||
public static func install(executablePath: String, configPath: String? = nil) throws {
|
||||
let executable = RunnerConfig.expandTilde(executablePath)
|
||||
guard FileManager.default.isExecutableFile(atPath: executable) else {
|
||||
throw CoreError.notFound(
|
||||
"""
|
||||
no executable at \(executable) — run `make install` to build, sign, \
|
||||
and install the app bundle first
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
var arguments = ["daemon"]
|
||||
if let configPath {
|
||||
arguments += ["--config", RunnerConfig.expandTilde(configPath)]
|
||||
}
|
||||
|
||||
let xml = try renderPlist(executablePath: executable, arguments: arguments)
|
||||
|
||||
let fm = FileManager.default
|
||||
try fm.createDirectory(at: logDirectoryURL, withIntermediateDirectories: true)
|
||||
try fm.createDirectory(
|
||||
at: agentPlistURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
|
||||
// A reinstall over a loaded job is the common case (upgrade, config
|
||||
// change), so unload before rewriting rather than failing on "already
|
||||
// bootstrapped".
|
||||
if fm.fileExists(atPath: agentPlistURL.path) {
|
||||
_ = try? uninstallJobOnly()
|
||||
}
|
||||
|
||||
do {
|
||||
try Data(xml.utf8).write(to: agentPlistURL, options: .atomic)
|
||||
} catch {
|
||||
throw CoreError.processFailed(
|
||||
command: "write \(agentPlistURL.path)",
|
||||
exitCode: 1,
|
||||
output: error.localizedDescription
|
||||
)
|
||||
}
|
||||
|
||||
let bootstrap = LaunchdShell.run("/bin/launchctl", ["bootstrap", domainTarget, agentPlistURL.path])
|
||||
if bootstrap.exitCode != 0 {
|
||||
// `bootstrap` is the modern verb but is unavailable in some session
|
||||
// contexts (and returns 5 for "input/output error" on odd domains);
|
||||
// the legacy loader still works there.
|
||||
let legacy = LaunchdShell.run("/bin/launchctl", ["load", "-w", agentPlistURL.path])
|
||||
if legacy.exitCode != 0 {
|
||||
throw CoreError.processFailed(
|
||||
command: "launchctl bootstrap \(domainTarget) \(agentPlistURL.path)",
|
||||
exitCode: bootstrap.exitCode,
|
||||
output: (bootstrap.output + "\n" + legacy.output).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Unloads the job and removes the plist. Safe when not installed.
|
||||
public static func uninstall() throws {
|
||||
_ = try? uninstallJobOnly()
|
||||
if FileManager.default.fileExists(atPath: agentPlistURL.path) {
|
||||
try FileManager.default.removeItem(at: agentPlistURL)
|
||||
}
|
||||
}
|
||||
|
||||
/// Unloads the job but leaves the plist on disk.
|
||||
private static func uninstallJobOnly() throws {
|
||||
let bootout = LaunchdShell.run("/bin/launchctl", ["bootout", serviceTarget])
|
||||
if bootout.exitCode != 0 {
|
||||
_ = LaunchdShell.run("/bin/launchctl", ["unload", "-w", agentPlistURL.path])
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports installation and run state.
|
||||
public static func status() throws -> ServiceStatus {
|
||||
let installed = FileManager.default.fileExists(atPath: agentPlistURL.path)
|
||||
let printed = LaunchdShell.run("/bin/launchctl", ["print", serviceTarget])
|
||||
|
||||
guard printed.exitCode == 0 else {
|
||||
// 113 (EAGAIN-ish "Could not find service") and 36 are both "not
|
||||
// loaded"; anything else is still, for our purposes, not loaded.
|
||||
return ServiceStatus(installed: installed, loaded: false, plistPath: agentPlistURL.path)
|
||||
}
|
||||
|
||||
return ServiceStatus(
|
||||
installed: installed,
|
||||
loaded: true,
|
||||
pid: firstInteger(in: printed.output, key: "pid"),
|
||||
lastExitStatus: firstInteger(in: printed.output, key: "last exit code"),
|
||||
plistPath: agentPlistURL.path
|
||||
)
|
||||
}
|
||||
|
||||
/// Extracts `key = <integer>` from `launchctl print` output.
|
||||
private static func firstInteger(in output: String, key: String) -> Int? {
|
||||
for line in output.split(separator: "\n") {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
guard trimmed.hasPrefix(key) else { continue }
|
||||
guard let equals = trimmed.firstIndex(of: "=") else { continue }
|
||||
let value = trimmed[trimmed.index(after: equals)...].trimmingCharacters(in: .whitespaces)
|
||||
return Int(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Renders `Resources/launchd.plist.template` with the given substitutions.
|
||||
///
|
||||
/// Placeholders: `{{LABEL}}`, `{{PROGRAM}}`, `{{ARGUMENTS}}`,
|
||||
/// `{{STDOUT_PATH}}`, `{{STDERR_PATH}}`.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - executablePath: Absolute path to the installed binary.
|
||||
/// - arguments: Arguments after the executable, e.g. `["daemon"]`.
|
||||
/// - Returns: The plist XML.
|
||||
public static func renderPlist(executablePath: String, arguments: [String]) throws -> String {
|
||||
let template = try loadTemplate()
|
||||
|
||||
let argumentXML = arguments
|
||||
.map { "\t\t<string>\(xmlEscape($0))</string>" }
|
||||
.joined(separator: "\n")
|
||||
|
||||
return template
|
||||
.replacingOccurrences(of: "{{LABEL}}", with: xmlEscape(label))
|
||||
.replacingOccurrences(of: "{{PROGRAM}}", with: xmlEscape(executablePath))
|
||||
.replacingOccurrences(of: "{{ARGUMENTS}}", with: argumentXML)
|
||||
.replacingOccurrences(
|
||||
of: "{{STDOUT_PATH}}",
|
||||
with: xmlEscape(logDirectoryURL.appendingPathComponent("daemon.out.log").path)
|
||||
)
|
||||
.replacingOccurrences(
|
||||
of: "{{STDERR_PATH}}",
|
||||
with: xmlEscape(logDirectoryURL.appendingPathComponent("daemon.err.log").path)
|
||||
)
|
||||
}
|
||||
|
||||
/// Locates the plist template.
|
||||
///
|
||||
/// The template is not an SPM resource bundle and `make bundle` copies only
|
||||
/// `Info.plist` into the app, so there is no single reliable location: this
|
||||
/// walks the plausible ones and falls back to a built-in copy so
|
||||
/// `service install` works from the installed app, from `swift run`, and from
|
||||
/// a checkout.
|
||||
private static func loadTemplate() throws -> String {
|
||||
var candidates: [URL] = []
|
||||
|
||||
if let resourceURL = Bundle.main.url(forResource: "launchd.plist", withExtension: "template") {
|
||||
candidates.append(resourceURL)
|
||||
}
|
||||
candidates.append(
|
||||
Bundle.main.bundleURL
|
||||
.appendingPathComponent("Contents/Resources/launchd.plist.template")
|
||||
)
|
||||
if let executableURL = Bundle.main.executableURL?.resolvingSymlinksInPath() {
|
||||
let directory = executableURL.deletingLastPathComponent()
|
||||
candidates.append(directory.appendingPathComponent("Resources/launchd.plist.template"))
|
||||
candidates.append(
|
||||
directory.deletingLastPathComponent()
|
||||
.appendingPathComponent("Resources/launchd.plist.template")
|
||||
)
|
||||
}
|
||||
// Sources/RunnerHost/LaunchdService.swift → repository root.
|
||||
let repositoryRoot = URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
candidates.append(repositoryRoot.appendingPathComponent("Resources/launchd.plist.template"))
|
||||
candidates.append(
|
||||
URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
|
||||
.appendingPathComponent("Resources/launchd.plist.template")
|
||||
)
|
||||
|
||||
for candidate in candidates {
|
||||
if let contents = try? String(contentsOf: candidate, encoding: .utf8) {
|
||||
return contents
|
||||
}
|
||||
}
|
||||
return embeddedTemplate
|
||||
}
|
||||
|
||||
/// Escapes a string for an XML text node.
|
||||
private static func xmlEscape(_ value: String) -> String {
|
||||
value
|
||||
.replacingOccurrences(of: "&", with: "&")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
}
|
||||
|
||||
/// Directory for the agent's stdout/stderr logs,
|
||||
/// `~/Library/Logs/gitea-macos-runner`.
|
||||
public static var logDirectoryURL: URL {
|
||||
URL(fileURLWithPath: RunnerConfig.expandTilde("~/Library/Logs/gitea-macos-runner"), isDirectory: true)
|
||||
}
|
||||
|
||||
/// Byte-for-byte fallback copy of `Resources/launchd.plist.template`, used
|
||||
/// when the file cannot be found next to the running binary.
|
||||
private static let embeddedTemplate = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
\t<key>Label</key>
|
||||
\t<string>{{LABEL}}</string>
|
||||
|
||||
\t<key>ProgramArguments</key>
|
||||
\t<array>
|
||||
\t\t<string>{{PROGRAM}}</string>
|
||||
{{ARGUMENTS}}
|
||||
\t</array>
|
||||
|
||||
\t<key>RunAtLoad</key>
|
||||
\t<true/>
|
||||
|
||||
\t<key>KeepAlive</key>
|
||||
\t<dict>
|
||||
\t\t<key>SuccessfulExit</key>
|
||||
\t\t<false/>
|
||||
\t</dict>
|
||||
|
||||
\t<key>ThrottleInterval</key>
|
||||
\t<integer>30</integer>
|
||||
|
||||
\t<key>ProcessType</key>
|
||||
\t<string>Interactive</string>
|
||||
|
||||
\t<key>StandardOutPath</key>
|
||||
\t<string>{{STDOUT_PATH}}</string>
|
||||
|
||||
\t<key>StandardErrorPath</key>
|
||||
\t<string>{{STDERR_PATH}}</string>
|
||||
|
||||
\t<key>EnvironmentVariables</key>
|
||||
\t<dict>
|
||||
\t\t<key>PATH</key>
|
||||
\t\t<string>/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
|
||||
\t</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
"""
|
||||
}
|
||||
|
||||
/// Minimal synchronous process runner for `launchctl`.
|
||||
private enum LaunchdShell {
|
||||
struct Output {
|
||||
let exitCode: Int32
|
||||
let output: String
|
||||
}
|
||||
|
||||
static func run(_ launchPath: String, _ arguments: [String]) -> Output {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: launchPath)
|
||||
process.arguments = arguments
|
||||
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
return Output(exitCode: 127, output: "\(error)")
|
||||
}
|
||||
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
process.waitUntilExit()
|
||||
return Output(
|
||||
exitCode: process.terminationStatus,
|
||||
output: String(data: data, encoding: .utf8) ?? ""
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,821 @@
|
||||
import Foundation
|
||||
import Logging
|
||||
import RunnerCore
|
||||
import Virtualization
|
||||
|
||||
/// Everything the orchestrator tracks about one live slot.
|
||||
public struct LiveVM: Sendable {
|
||||
/// Slot index.
|
||||
public let slot: Int
|
||||
/// The ephemeral clone backing it.
|
||||
public let bundle: VMBundle
|
||||
/// The runner name registered with Gitea. Globally unique, prefixed with
|
||||
/// ``RunnerConfig/RunnerSection/namePrefix`` — this is what lets the
|
||||
/// reconcile loop tell a stale row apart from a live one.
|
||||
public let runnerName: String
|
||||
/// The guest's IP, once its DHCP lease appears.
|
||||
public var ipAddress: String?
|
||||
/// When the boot started.
|
||||
public let startedAt: Date
|
||||
|
||||
public init(
|
||||
slot: Int,
|
||||
bundle: VMBundle,
|
||||
runnerName: String,
|
||||
ipAddress: String? = nil,
|
||||
startedAt: Date
|
||||
) {
|
||||
self.slot = slot
|
||||
self.bundle = bundle
|
||||
self.runnerName = runnerName
|
||||
self.ipAddress = ipAddress
|
||||
self.startedAt = startedAt
|
||||
}
|
||||
}
|
||||
|
||||
/// The daemon: watches Gitea, boots ephemeral macOS VMs, and cleans up after
|
||||
/// them.
|
||||
///
|
||||
/// ## Loop
|
||||
///
|
||||
/// Every `pollIntervalSeconds`:
|
||||
/// 1. Fetch queued jobs (`status=queued` only — `waiting` means *blocked*).
|
||||
/// 2. Ask ``SchedulerCore/plan(state:queuedJobs:labels:maxVMs:now:jobTimeout:bootTimeout:)``
|
||||
/// what to do. The planner is pure; all I/O happens here.
|
||||
/// 3. Execute the returned actions.
|
||||
///
|
||||
/// Every `reconcileIntervalSeconds`, additionally run ``reconcileOnce()``.
|
||||
///
|
||||
/// ## Booting a slot
|
||||
///
|
||||
/// `ensureFreeSpace` → `cloneImage(named:slotMAC:)` → ``VMInstance/start(options:)``
|
||||
/// → poll `/var/db/dhcpd_leases` for the slot MAC until `bootTimeout` →
|
||||
/// ``waitForSSH(host:port:username:password:timeout:pollInterval:)`` → write the
|
||||
/// registration token into a guest file with mode `0600` → over SSH:
|
||||
///
|
||||
/// ```sh
|
||||
/// gitea-runner register --no-interactive \
|
||||
/// --instance <url> --token-file <f> \
|
||||
/// --name <prefix><uuid> --labels "macos-arm64:host" --ephemeral \
|
||||
/// && rm -f <f> \
|
||||
/// && gitea-runner daemon
|
||||
/// ```
|
||||
///
|
||||
/// The token goes through a file rather than `--token` because arguments are
|
||||
/// visible to every process on the guest, and it is deleted the instant
|
||||
/// registration returns. `--ephemeral` is **server-enforced** (Gitea 1.24+): the
|
||||
/// server hands this runner exactly one task and then deregisters it. The weaker
|
||||
/// `--once` is runner-side only and is not used.
|
||||
///
|
||||
/// When the `gitea-runner daemon` SSH command returns — which it does after the
|
||||
/// single job completes — or when `jobTimeout` elapses, the slot is torn down:
|
||||
/// force-stop the VM, delete the clone, mark the slot idle.
|
||||
///
|
||||
/// ## Reconcile
|
||||
///
|
||||
/// A VM that dies uncleanly leaves a runner row behind, and Gitea only sweeps
|
||||
/// rows at midnight — and *never* sweeps a runner that never claimed a task. So
|
||||
/// every reconcile pass lists runners and deletes any that are `ephemeral`, not
|
||||
/// `busy`, carry our name prefix, and have no live VM. The associated Running
|
||||
/// task is reaped separately by Gitea's zombie sweep (~10–15 minutes); that part
|
||||
/// is not ours to fix.
|
||||
public actor Orchestrator {
|
||||
|
||||
/// Effective configuration.
|
||||
public let config: RunnerConfig
|
||||
/// Gitea admin API client.
|
||||
public let client: GiteaClient
|
||||
/// On-disk store.
|
||||
public let store: VMStore
|
||||
/// Base image name to clone for each job.
|
||||
public let imageName: String
|
||||
|
||||
/// Structured logger.
|
||||
private let logger: Logger
|
||||
|
||||
/// The pure scheduler's state. Every mutation goes through `SchedulerCore`.
|
||||
private var state: SchedulerState
|
||||
|
||||
/// Slots that currently hold a VM, keyed by slot index.
|
||||
private var live: [Int: LiveVM] = [:]
|
||||
|
||||
/// The `VZVirtualMachine` wrapper for each live slot.
|
||||
private var instances: [Int: VMInstance] = [:]
|
||||
|
||||
/// The supervising task per slot: clone → boot → register → run → teardown.
|
||||
private var slotTasks: [Int: Task<Void, Never>] = [:]
|
||||
|
||||
/// The "the guest stopped on its own" watcher per slot.
|
||||
private var deathWatchTasks: [Int: Task<Void, Never>] = [:]
|
||||
|
||||
/// Bumped every time a slot starts a new VM, so a stale watcher from a
|
||||
/// previous occupant of the same slot cannot trigger a teardown of the
|
||||
/// current one.
|
||||
private var slotGeneration: [Int: Int] = [:]
|
||||
|
||||
/// Slots whose teardown is in flight. Guards against the SSH command
|
||||
/// returning, the death watcher firing, and the scheduler's timeout all
|
||||
/// racing to tear the same slot down.
|
||||
private var tearingDown: Set<Int> = []
|
||||
|
||||
/// Runner names minted but not yet visible in ``live`` (the window between
|
||||
/// deciding to boot and the clone finishing). The reconcile loop must not
|
||||
/// delete a row that one of these is about to create.
|
||||
private var reservedRunnerNames: Set<String> = []
|
||||
|
||||
/// Runner names whose `gitea-runner daemon` exited cleanly, meaning Gitea
|
||||
/// already deregistered them (`--ephemeral`). Teardown skips the belt-and-
|
||||
/// braces row deletion for these.
|
||||
private var completedRunnerNames: Set<String> = []
|
||||
|
||||
/// The shared registration token, resolved once and cached for the process
|
||||
/// lifetime. Never minted per VM — see ``registrationToken()``.
|
||||
private var cachedRegistrationToken: String?
|
||||
|
||||
/// The in-flight fetch of ``cachedRegistrationToken``, if any.
|
||||
///
|
||||
/// Caching the *value* alone is not enough: `Orchestrator` is an actor, so a
|
||||
/// second slot booting during the `await` on the API call would see an empty
|
||||
/// cache and mint a second token — and minting invalidates every prior token
|
||||
/// for the scope, including the one the first VM is about to use. Memoizing
|
||||
/// the task instead makes concurrent callers share one request.
|
||||
private var registrationTokenTask: Task<String, Error>?
|
||||
|
||||
/// Set once ``shutdown()`` has begun; stops new work being accepted.
|
||||
private var isShuttingDown = false
|
||||
|
||||
/// Creates an orchestrator.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - config: Validated configuration.
|
||||
/// - client: Admin-scoped Gitea client.
|
||||
/// - store: The VM store.
|
||||
/// - imageName: Base image to clone. Defaults to `default`.
|
||||
/// - logger: Structured logger.
|
||||
public init(
|
||||
config: RunnerConfig,
|
||||
client: GiteaClient,
|
||||
store: VMStore,
|
||||
imageName: String = "default",
|
||||
logger: Logger = Logger(label: "orchestrator")
|
||||
) {
|
||||
self.config = config
|
||||
self.client = client
|
||||
self.store = store
|
||||
self.imageName = imageName
|
||||
self.logger = logger
|
||||
self.state = SchedulerState(slotCount: Orchestrator.slotCount(for: config))
|
||||
}
|
||||
|
||||
/// The fixed slot count: the configured concurrency, hard-clamped to the
|
||||
/// kernel's two-guest limit.
|
||||
private static func slotCount(for config: RunnerConfig) -> Int {
|
||||
min(
|
||||
max(1, config.scheduler.maxConcurrentVMs),
|
||||
RunnerConfig.SchedulerSection.hardMaxConcurrentVMs
|
||||
)
|
||||
}
|
||||
|
||||
/// This orchestrator's slot count.
|
||||
public var slotCount: Int { state.slots.count }
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
/// Runs the poll/reconcile loop until the task is cancelled.
|
||||
///
|
||||
/// On entry it purges clones orphaned by a previous crash and runs one
|
||||
/// reconcile pass, so a restart converges before it schedules anything new.
|
||||
///
|
||||
/// On cancellation it stops accepting work and calls ``shutdown()``, so
|
||||
/// `SIGTERM` from `launchd` results in guests being asked to stop rather
|
||||
/// than being killed with their filesystems dirty.
|
||||
///
|
||||
/// - Throws: Only unrecoverable errors; transient Gitea or VM failures are
|
||||
/// logged and retried on the next tick.
|
||||
public func runForever() async throws {
|
||||
try store.ensureLayout()
|
||||
|
||||
// Clones left behind by a crash are garbage: their guests are gone and
|
||||
// their runner rows, if any, are handled by the reconcile pass below.
|
||||
do {
|
||||
try store.purgeClones()
|
||||
} catch {
|
||||
logger.warning("could not purge orphaned clones", metadata: ["error": "\(error)"])
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"orchestrator starting",
|
||||
metadata: [
|
||||
"image": .string(imageName),
|
||||
"slots": .stringConvertible(slotCount),
|
||||
"labels": .string(config.runner.labels.joined(separator: ",")),
|
||||
"instance": .string(config.gitea.instanceURL.absoluteString),
|
||||
]
|
||||
)
|
||||
|
||||
await reconcileOnce()
|
||||
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
group.addTask { [pollInterval = config.scheduler.pollIntervalSeconds] in
|
||||
while !Task.isCancelled {
|
||||
await self.tick()
|
||||
do {
|
||||
try await Task.sleep(for: .seconds(max(1, pollInterval)))
|
||||
} catch {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
group.addTask { [reconcileInterval = config.scheduler.reconcileIntervalSeconds] in
|
||||
while !Task.isCancelled {
|
||||
do {
|
||||
try await Task.sleep(for: .seconds(max(1, reconcileInterval)))
|
||||
} catch {
|
||||
break
|
||||
}
|
||||
await self.reconcileOnce()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await shutdown()
|
||||
logger.info("orchestrator stopped")
|
||||
}
|
||||
|
||||
/// Tears down every live VM and deletes their clones. Idempotent.
|
||||
public func shutdown() async {
|
||||
guard !isShuttingDown else { return }
|
||||
isShuttingDown = true
|
||||
logger.info("shutting down", metadata: ["liveVMs": .stringConvertible(live.count)])
|
||||
|
||||
// Cancel the supervising tasks first so they stop waiting on SSH, then
|
||||
// let them run their own teardown; whatever they miss we clean up below.
|
||||
let tasks = slotTasks
|
||||
slotTasks.removeAll()
|
||||
for (_, task) in tasks { task.cancel() }
|
||||
for (_, task) in tasks { await task.value }
|
||||
|
||||
for slot in live.keys.sorted() {
|
||||
await teardownSlot(slot, reason: "daemon shutdown")
|
||||
}
|
||||
}
|
||||
|
||||
/// One iteration of the poll loop: fetch, plan, execute.
|
||||
///
|
||||
/// Exposed separately so tests and `vm boot` can drive a single tick.
|
||||
///
|
||||
/// - Parameter now: Reference time, injected for testability.
|
||||
public func tick(now: Date = Date()) async {
|
||||
guard !isShuttingDown else { return }
|
||||
|
||||
let jobs: [WorkflowJob]
|
||||
do {
|
||||
jobs = try await client.listQueuedJobs()
|
||||
} catch {
|
||||
// A Gitea outage must never take the daemon down: queued jobs wait
|
||||
// up to ABANDONED_JOB_TIMEOUT (24 h), so a missed tick costs nothing.
|
||||
logger.warning("listQueuedJobs failed", metadata: ["error": .string("\(error)")])
|
||||
return
|
||||
}
|
||||
|
||||
// `waiting` means *blocked on a dependency* in Gitea's external
|
||||
// vocabulary and must never be scheduled; only `queued` is schedulable.
|
||||
let queued = jobs.filter { $0.isQueued }
|
||||
|
||||
let (newState, actions) = SchedulerCore.plan(
|
||||
state: state,
|
||||
queuedJobs: queued,
|
||||
labels: config.labelSet,
|
||||
maxVMs: slotCount,
|
||||
now: now,
|
||||
jobTimeout: TimeInterval(config.scheduler.jobTimeoutMinutes * 60),
|
||||
bootTimeout: TimeInterval(config.scheduler.bootTimeoutSeconds)
|
||||
)
|
||||
state = newState
|
||||
|
||||
for action in actions {
|
||||
switch action {
|
||||
case .none:
|
||||
continue
|
||||
|
||||
case .teardownVM(let slot, let reason):
|
||||
// Retire the slot's lifecycle task *before* tearing down, and
|
||||
// wait for it: the planner deliberately emits teardowns before
|
||||
// boots so a timed-out slot can be recycled in this same pass,
|
||||
// and `bootSlot` refuses a slot whose `slotTasks` entry is still
|
||||
// populated. The task is parked in `executor.run` on a channel we
|
||||
// are about to kill, so it would otherwise clear that entry only
|
||||
// after the boot had already been refused.
|
||||
if let task = slotTasks.removeValue(forKey: slot) {
|
||||
task.cancel()
|
||||
// Its own teardown runs to completion here, which also means
|
||||
// it cannot race a successor booted later in this pass.
|
||||
await task.value
|
||||
}
|
||||
await teardownSlot(slot, reason: reason)
|
||||
|
||||
case .bootVM(let slot, let jobHint):
|
||||
do {
|
||||
try await bootSlot(slot, jobHint: jobHint)
|
||||
} catch {
|
||||
logger.error(
|
||||
"boot refused",
|
||||
metadata: [
|
||||
"slot": .stringConvertible(slot),
|
||||
"job": .stringConvertible(jobHint),
|
||||
"error": .string("\(error)"),
|
||||
]
|
||||
)
|
||||
state = SchedulerCore.markIdle(state: state, slot: slot)
|
||||
// The job is still queued, so the ledger would never expire
|
||||
// its entry on its own and the job would never boot again.
|
||||
state = SchedulerCore.releaseJob(state: state, jobID: jobHint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One reconcile pass over Gitea's runner rows.
|
||||
///
|
||||
/// Deletes runners that are ephemeral, idle, ours by name prefix, and not
|
||||
/// backed by a live VM. Conservative by construction: a row we are unsure
|
||||
/// about is left alone, because deleting a live runner would fail a job.
|
||||
public func reconcileOnce() async {
|
||||
let runners: [ActionRunner]
|
||||
do {
|
||||
runners = try await client.listRunners()
|
||||
} catch {
|
||||
logger.warning("listRunners failed", metadata: ["error": .string("\(error)")])
|
||||
return
|
||||
}
|
||||
|
||||
let ours = Set(live.values.map(\.runnerName)).union(reservedRunnerNames)
|
||||
|
||||
for runner in runners {
|
||||
guard runner.isEphemeral else { continue }
|
||||
guard !runner.isBusy else { continue }
|
||||
guard RunnerNaming.hasPrefix(runner.name, prefix: config.runner.namePrefix) else { continue }
|
||||
guard !ours.contains(runner.name) else { continue }
|
||||
|
||||
do {
|
||||
try await client.deleteRunner(id: runner.id)
|
||||
logger.info(
|
||||
"reconcile: deleted orphaned runner",
|
||||
metadata: ["name": .string(runner.name), "id": .stringConvertible(runner.id)]
|
||||
)
|
||||
} catch {
|
||||
logger.warning(
|
||||
"reconcile: could not delete runner",
|
||||
metadata: ["name": .string(runner.name), "error": .string("\(error)")]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Slot operations
|
||||
|
||||
/// Boots, provisions, registers, and then supervises one slot.
|
||||
///
|
||||
/// Returns once the slot has been handed off to its supervising task; the
|
||||
/// job itself runs asynchronously and teardown is triggered by the SSH
|
||||
/// command returning or by `jobTimeout`.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - slot: Slot index.
|
||||
/// - jobHint: The queued job that motivated this boot — a **hint** only;
|
||||
/// the server chooses which job the runner actually claims.
|
||||
public func bootSlot(_ slot: Int, jobHint: Int64) async throws {
|
||||
guard !isShuttingDown else {
|
||||
throw CoreError.provisioningFailed("shutting down; refusing to boot slot \(slot)")
|
||||
}
|
||||
guard slot >= 0, slot < slotCount else {
|
||||
throw CoreError.provisioningFailed("slot \(slot) out of range")
|
||||
}
|
||||
guard slotTasks[slot] == nil, live[slot] == nil else {
|
||||
throw CoreError.provisioningFailed("slot \(slot) is already occupied")
|
||||
}
|
||||
|
||||
// Fail fast, on the caller's turn, for the conditions that make a boot
|
||||
// pointless: no space, no image, no token source.
|
||||
try store.ensureFreeSpace(minGB: config.storage.minFreeDiskGB)
|
||||
|
||||
let runnerName = RunnerNaming.makeRunnerName(prefix: config.runner.namePrefix)
|
||||
reservedRunnerNames.insert(runnerName)
|
||||
|
||||
let generation = (slotGeneration[slot] ?? 0) + 1
|
||||
slotGeneration[slot] = generation
|
||||
state = SchedulerCore.markProvisioning(state: state, slot: slot, jobHint: jobHint, now: Date())
|
||||
|
||||
logger.info(
|
||||
"booting VM",
|
||||
metadata: [
|
||||
"slot": .stringConvertible(slot),
|
||||
"job": .stringConvertible(jobHint),
|
||||
"runner": .string(runnerName),
|
||||
]
|
||||
)
|
||||
|
||||
slotTasks[slot] = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.runSlotLifecycle(slot: slot, jobHint: jobHint, runnerName: runnerName, generation: generation)
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole life of one slot, from clone to teardown.
|
||||
///
|
||||
/// Every failure path funnels into the same teardown, because a slot that is
|
||||
/// neither live nor idle is a slot leaked for the process's lifetime.
|
||||
private func runSlotLifecycle(slot: Int, jobHint: Int64, runnerName: String, generation: Int) async {
|
||||
let bootTimeout = Duration.seconds(max(30, config.scheduler.bootTimeoutSeconds))
|
||||
let jobTimeout = Duration.seconds(max(60, config.scheduler.jobTimeoutMinutes * 60))
|
||||
var teardownReason = "job finished"
|
||||
|
||||
do {
|
||||
let mac = try store.macAddress(forSlot: slot, slotCount: slotCount)
|
||||
// Whatever lease this MAC already holds belongs to the *previous*
|
||||
// guest on this slot — the MACs are persistent and macOS leases last
|
||||
// 24 h. `waitForLease` must not hand that address back before the new
|
||||
// guest has even brought its NIC up.
|
||||
let priorLease = DHCPLeaseParser.lease(
|
||||
forMAC: mac,
|
||||
in: DHCPLeaseParser.parseFile()
|
||||
)
|
||||
let bundle = try store.cloneImage(named: imageName, slotMAC: mac)
|
||||
live[slot] = LiveVM(slot: slot, bundle: bundle, runnerName: runnerName, startedAt: Date())
|
||||
|
||||
let instance = try VMInstance(bundle: bundle, label: "slot-\(slot)")
|
||||
instances[slot] = instance
|
||||
try await instance.start()
|
||||
|
||||
// A guest that panics, or is shut down from inside the job, must
|
||||
// land in the same teardown path as a clean finish.
|
||||
deathWatchTasks[slot] = Task { [weak self] in
|
||||
let reason = await instance.waitUntilStopped()
|
||||
await self?.vmStoppedUnexpectedly(slot: slot, generation: generation, reason: reason)
|
||||
}
|
||||
|
||||
let ip = try await waitForLease(mac: mac, timeout: bootTimeout, replacing: priorLease)
|
||||
live[slot]?.ipAddress = ip
|
||||
logger.info("guest leased address", metadata: ["slot": .stringConvertible(slot), "ip": .string(ip)])
|
||||
|
||||
try await waitForSSH(
|
||||
host: ip,
|
||||
username: config.guest.username,
|
||||
password: config.guest.password,
|
||||
timeout: bootTimeout
|
||||
)
|
||||
|
||||
let token = try await registrationToken()
|
||||
let executor = SSHExecutor(
|
||||
host: ip,
|
||||
username: config.guest.username,
|
||||
password: config.guest.password
|
||||
)
|
||||
|
||||
// With the hint: the slot is `.provisioning` right now, which carries
|
||||
// no hint to inherit, so the hintless overload would drop it — and
|
||||
// with it both the job-timeout ledger release and every log line
|
||||
// naming which job a running slot is serving.
|
||||
state = SchedulerCore.markRunning(state: state, slot: slot, jobHint: jobHint, now: Date())
|
||||
logger.info(
|
||||
"registering ephemeral runner",
|
||||
metadata: [
|
||||
"slot": .stringConvertible(slot),
|
||||
"runner": .string(runnerName),
|
||||
"job": .stringConvertible(jobHint),
|
||||
]
|
||||
)
|
||||
|
||||
let result = try await registerAndRun(
|
||||
executor: executor,
|
||||
runnerName: runnerName,
|
||||
token: token,
|
||||
timeout: jobTimeout
|
||||
)
|
||||
await executor.close()
|
||||
|
||||
if result.succeeded {
|
||||
// A clean exit means the ephemeral runner claimed its one task,
|
||||
// finished it, and was deregistered by the server.
|
||||
completedRunnerNames.insert(runnerName)
|
||||
logger.info("job finished", metadata: ["slot": .stringConvertible(slot), "runner": .string(runnerName)])
|
||||
} else {
|
||||
teardownReason = "runner exited \(result.exitCode)"
|
||||
logger.warning(
|
||||
"runner exited non-zero",
|
||||
metadata: [
|
||||
"slot": .stringConvertible(slot),
|
||||
"exit": .stringConvertible(result.exitCode),
|
||||
"stderr": .string(String(result.stderr.suffix(500))),
|
||||
]
|
||||
)
|
||||
}
|
||||
} catch is CancellationError {
|
||||
teardownReason = "cancelled"
|
||||
} catch {
|
||||
teardownReason = "\(error)"
|
||||
logger.error(
|
||||
"slot failed",
|
||||
metadata: [
|
||||
"slot": .stringConvertible(slot),
|
||||
"runner": .string(runnerName),
|
||||
"error": .string("\(error)"),
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
await teardownSlot(slot, reason: teardownReason)
|
||||
|
||||
// The clone may never have been adopted into `live` (a MAC or clone
|
||||
// failure throws before that), in which case teardown's own removal —
|
||||
// which lives inside `if let info` — never ran. Left behind, the name
|
||||
// would sit in the reconcile loop's "ours" set for the process lifetime.
|
||||
reservedRunnerNames.remove(runnerName)
|
||||
|
||||
// A slot that did not finish a job leaves its motivating job queued, and
|
||||
// the ledger expires entries only when a job *stops* being queued — so
|
||||
// without this the job is never booted for again.
|
||||
if teardownReason != "job finished" {
|
||||
state = SchedulerCore.releaseJob(state: state, jobID: jobHint)
|
||||
}
|
||||
|
||||
slotTasks[slot] = nil
|
||||
}
|
||||
|
||||
/// Called by the death watcher when a guest stops without us asking.
|
||||
private func vmStoppedUnexpectedly(slot: Int, generation: Int, reason: VMStopReason) async {
|
||||
guard slotGeneration[slot] == generation else { return }
|
||||
guard !tearingDown.contains(slot), live[slot] != nil else { return }
|
||||
|
||||
logger.warning(
|
||||
"guest stopped unexpectedly",
|
||||
metadata: ["slot": .stringConvertible(slot), "reason": .string("\(reason)")]
|
||||
)
|
||||
slotTasks[slot]?.cancel()
|
||||
await teardownSlot(slot, reason: "guest stopped: \(reason)")
|
||||
}
|
||||
|
||||
/// Stops the VM in a slot, deletes its clone, and marks the slot idle.
|
||||
///
|
||||
/// Best-effort and never throws: teardown that could fail would leak a slot.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - slot: Slot index.
|
||||
/// - reason: Logged cause.
|
||||
public func teardownSlot(_ slot: Int, reason: String) async {
|
||||
guard !tearingDown.contains(slot) else { return }
|
||||
|
||||
let vm = instances.removeValue(forKey: slot)
|
||||
let info = live.removeValue(forKey: slot)
|
||||
guard vm != nil || info != nil else {
|
||||
state = SchedulerCore.markIdle(state: state, slot: slot)
|
||||
return
|
||||
}
|
||||
|
||||
tearingDown.insert(slot)
|
||||
slotGeneration[slot] = (slotGeneration[slot] ?? 0) + 1
|
||||
deathWatchTasks.removeValue(forKey: slot)?.cancel()
|
||||
|
||||
logger.info("tearing down slot", metadata: ["slot": .stringConvertible(slot), "reason": .string(reason)])
|
||||
|
||||
if let vm {
|
||||
// requestStop first: the guest gets a power-button press and a
|
||||
// chance to flush before we pull the plug.
|
||||
_ = await vm.requestStopThenForce(gracePeriod: .seconds(30))
|
||||
}
|
||||
|
||||
if let info {
|
||||
do {
|
||||
try store.deleteClone(info.bundle)
|
||||
} catch {
|
||||
logger.warning(
|
||||
"could not delete clone",
|
||||
metadata: ["path": .string(info.bundle.rootURL.path), "error": .string("\(error)")]
|
||||
)
|
||||
}
|
||||
|
||||
reservedRunnerNames.remove(info.runnerName)
|
||||
|
||||
// If the runner never completed a job, Gitea will keep its row
|
||||
// forever: rows are swept at midnight, and never at all for a
|
||||
// runner that claimed no task. Delete it ourselves.
|
||||
if !completedRunnerNames.contains(info.runnerName) {
|
||||
await deleteRunnerRow(named: info.runnerName)
|
||||
}
|
||||
completedRunnerNames.remove(info.runnerName)
|
||||
}
|
||||
|
||||
state = SchedulerCore.markIdle(state: state, slot: slot)
|
||||
tearingDown.remove(slot)
|
||||
}
|
||||
|
||||
/// Best-effort deletion of a runner row by name.
|
||||
private func deleteRunnerRow(named name: String) async {
|
||||
do {
|
||||
let runners = try await client.listRunners()
|
||||
guard let row = runners.first(where: { $0.name == name }) else { return }
|
||||
guard !row.isBusy else {
|
||||
// Deleting a busy runner would fail whatever job it is running;
|
||||
// leave it for the next reconcile pass.
|
||||
logger.info("runner still busy; leaving row for reconcile", metadata: ["name": .string(name)])
|
||||
return
|
||||
}
|
||||
try await client.deleteRunner(id: row.id)
|
||||
logger.info("deleted runner row", metadata: ["name": .string(name)])
|
||||
} catch {
|
||||
logger.warning(
|
||||
"could not delete runner row",
|
||||
metadata: ["name": .string(name), "error": .string("\(error)")]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Polls `/var/db/dhcpd_leases` until the slot's MAC has an address.
|
||||
///
|
||||
/// Slot MACs are persistent and macOS leases last 24 h, so the previous
|
||||
/// guest's entry for this MAC is normally still in the file when a new clone
|
||||
/// boots. `replacing` is that entry, sampled before the guest was started;
|
||||
/// the poll holds out for a lease `bootpd` wrote afterwards rather than
|
||||
/// returning an address that belongs to a VM that no longer exists.
|
||||
///
|
||||
/// The gate is deliberately soft: if no newer lease appears within half the
|
||||
/// timeout but a stale one is present, that address is used with a warning.
|
||||
/// `bootpd` overwhelmingly reissues the same address to the same MAC, and
|
||||
/// failing a boot outright over a lease record that was merely not rewritten
|
||||
/// would be worse than the stale read this guards against.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - mac: The slot's persistent MAC.
|
||||
/// - timeout: Ceiling, from ``RunnerConfig/SchedulerSection/bootTimeoutSeconds``.
|
||||
/// - replacing: The lease seen for `mac` before the guest was started.
|
||||
/// - Returns: The guest's IPv4 address.
|
||||
/// - Throws: ``CoreError/timeout(_:)``.
|
||||
public func waitForLease(
|
||||
mac: String,
|
||||
timeout: Duration,
|
||||
replacing previous: DHCPLease? = nil
|
||||
) async throws -> String {
|
||||
let start = Date()
|
||||
let deadline = start.addingTimeInterval(timeout.seconds)
|
||||
let staleFallbackAfter = start.addingTimeInterval(timeout.seconds / 2)
|
||||
|
||||
while Date() < deadline {
|
||||
try Task.checkCancellation()
|
||||
if let lease = DHCPLeaseParser.lease(forMAC: mac, in: DHCPLeaseParser.parseFile()) {
|
||||
if DHCPLeaseParser.isNewer(lease, than: previous) {
|
||||
return lease.ipAddress
|
||||
}
|
||||
if Date() >= staleFallbackAfter {
|
||||
logger.warning(
|
||||
"no fresh dhcp lease; using the previous one for this MAC",
|
||||
metadata: ["mac": .string(mac), "ip": .string(lease.ipAddress)]
|
||||
)
|
||||
return lease.ipAddress
|
||||
}
|
||||
}
|
||||
try await Task.sleep(for: .seconds(2))
|
||||
}
|
||||
throw CoreError.timeout("dhcp lease for \(mac)")
|
||||
}
|
||||
|
||||
/// Registers an ephemeral runner in the guest and starts its daemon.
|
||||
///
|
||||
/// Blocks until the daemon exits, which — because the runner is ephemeral —
|
||||
/// happens after exactly one job.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - executor: A connected guest executor.
|
||||
/// - runnerName: The unique name to register under.
|
||||
/// - token: The shared registration token.
|
||||
/// - Returns: The daemon's exit result.
|
||||
public func registerAndRun(
|
||||
executor: any GuestExecutor,
|
||||
runnerName: String,
|
||||
token: String
|
||||
) async throws -> SSHCommandResult {
|
||||
try await registerAndRun(
|
||||
executor: executor,
|
||||
runnerName: runnerName,
|
||||
token: token,
|
||||
timeout: .seconds(max(60, config.scheduler.jobTimeoutMinutes * 60))
|
||||
)
|
||||
}
|
||||
|
||||
/// ``registerAndRun(executor:runnerName:token:)`` with an explicit ceiling on
|
||||
/// how long the runner daemon may live.
|
||||
public func registerAndRun(
|
||||
executor: any GuestExecutor,
|
||||
runnerName: String,
|
||||
token: String,
|
||||
timeout: Duration
|
||||
) async throws -> SSHCommandResult {
|
||||
// Mode 0600, and removed in the same && chain below. A registration
|
||||
// token is fleet-wide; passing it as --token would publish it to every
|
||||
// process on a guest that is about to run arbitrary repository code.
|
||||
try await executor.uploadData(Data(token.utf8), remotePath: Orchestrator.tokenPath, mode: "0600")
|
||||
|
||||
// Only bare names are stored server-side; `:host` is a register-time
|
||||
// execution hint. The guest must ship no config.yaml `runner.labels`,
|
||||
// which would silently override this.
|
||||
let labels = config.labelSet.registrationArgument(schema: "host")
|
||||
let instance = config.gitea.instanceURL.absoluteString.hasSuffix("/")
|
||||
? String(config.gitea.instanceURL.absoluteString.dropLast())
|
||||
: config.gitea.instanceURL.absoluteString
|
||||
|
||||
let command = """
|
||||
export PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin; \
|
||||
gitea-runner register --no-interactive \
|
||||
--instance \(Orchestrator.shellQuote(instance)) \
|
||||
--token-file \(Orchestrator.tokenPath) \
|
||||
--name \(Orchestrator.shellQuote(runnerName)) \
|
||||
--labels \(Orchestrator.shellQuote(labels)) \
|
||||
--ephemeral \
|
||||
&& rm -f \(Orchestrator.tokenPath) \
|
||||
&& gitea-runner daemon
|
||||
"""
|
||||
|
||||
return try await executor.run(command, timeout: timeout)
|
||||
}
|
||||
|
||||
/// Where the registration token is staged inside the guest.
|
||||
private static let tokenPath = "/tmp/.reg-token"
|
||||
|
||||
/// Single-quotes a value for `/bin/sh`.
|
||||
private static func shellQuote(_ value: String) -> String {
|
||||
"'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
|
||||
}
|
||||
|
||||
// MARK: - Tokens
|
||||
|
||||
/// Resolves the registration token, caching it for the process lifetime.
|
||||
///
|
||||
/// Order: `registrationTokenFile`, then `registrationToken`, then — only if
|
||||
/// ``RunnerConfig/GiteaSection/fetchRegistrationTokenViaAPI`` is set —
|
||||
/// ``GiteaClient/getRegistrationToken()``.
|
||||
///
|
||||
/// - Important: Never called per VM as a way of minting a throwaway secret.
|
||||
/// Registration tokens are reusable and scope-wide, and minting a new one
|
||||
/// invalidates every prior token for that scope — including tokens held by
|
||||
/// runners registered from other hosts. One token, cached, shared.
|
||||
/// - Throws: ``CoreError/configInvalid(_:)`` when no source is available.
|
||||
public func registrationToken() async throws -> String {
|
||||
if let cachedRegistrationToken { return cachedRegistrationToken }
|
||||
|
||||
if let staticToken = try config.resolveStaticRegistrationToken(),
|
||||
!staticToken.isEmpty {
|
||||
cachedRegistrationToken = staticToken
|
||||
return staticToken
|
||||
}
|
||||
|
||||
guard config.gitea.fetchRegistrationTokenViaAPI else {
|
||||
throw CoreError.configInvalid(
|
||||
"""
|
||||
no registration token available: set gitea.registrationTokenFile or \
|
||||
gitea.registrationToken, or enable gitea.fetchRegistrationTokenViaAPI
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
// Share one in-flight mint between concurrent boots. Assigned before the
|
||||
// first suspension point, so the second caller cannot miss it.
|
||||
if let inFlight = registrationTokenTask {
|
||||
return try await inFlight.value
|
||||
}
|
||||
let task = Task { [client] () -> String in
|
||||
let fetched = try await client.getRegistrationToken()
|
||||
guard !fetched.isEmpty else {
|
||||
throw CoreError.configInvalid("Gitea returned an empty registration token")
|
||||
}
|
||||
return fetched
|
||||
}
|
||||
registrationTokenTask = task
|
||||
|
||||
do {
|
||||
let fetched = try await task.value
|
||||
cachedRegistrationToken = fetched
|
||||
return fetched
|
||||
} catch {
|
||||
// A failed mint must not poison every later boot.
|
||||
registrationTokenTask = nil
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// A snapshot of live slot state, for `vm list` and diagnostics.
|
||||
public func liveVMs() -> [LiveVM] {
|
||||
live.keys.sorted().compactMap { live[$0] }
|
||||
}
|
||||
|
||||
/// A snapshot of the scheduler's slot table.
|
||||
public func slotStates() -> [VMSlot] {
|
||||
state.slots
|
||||
}
|
||||
}
|
||||
|
||||
extension Duration {
|
||||
/// This duration as a floating-point number of seconds.
|
||||
var seconds: TimeInterval {
|
||||
let components = self.components
|
||||
return TimeInterval(components.seconds) + TimeInterval(components.attoseconds) / 1e18
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import Foundation
|
||||
import RunnerCore
|
||||
import Virtualization
|
||||
|
||||
/// The persisted description of a VM, stored alongside its disk in a bundle
|
||||
/// directory.
|
||||
///
|
||||
/// Virtualization.framework requires that a macOS guest be recreated with
|
||||
/// *exactly* the hardware model and machine identifier it was installed with —
|
||||
/// change either and the guest will not boot. Both are opaque blobs the
|
||||
/// framework hands us at install time, so they are stored verbatim here.
|
||||
/// `Data` encodes to base64 in JSON, which keeps `config.json` human-inspectable.
|
||||
public struct VMBundleConfig: Codable, Sendable, Equatable {
|
||||
/// How the backing disk was created.
|
||||
public enum DiskFormat: String, Codable, Sendable {
|
||||
/// Sparse Apple System Image Format, via `diskutil image create`
|
||||
/// (macOS 26+). Preferred: clones and grows lazily.
|
||||
case asif
|
||||
/// A plain sparse file created with `truncate`. Fallback.
|
||||
case raw
|
||||
}
|
||||
|
||||
/// `VZMacHardwareModel.dataRepresentation` from the restore image's
|
||||
/// `mostFeaturefulSupportedConfiguration`.
|
||||
public var hardwareModelData: Data
|
||||
|
||||
/// `VZMacMachineIdentifier.dataRepresentation`. Uniquely identifies the
|
||||
/// "machine"; the guest's Setup Assistant state is tied to it.
|
||||
public var machineIdentifierData: Data
|
||||
|
||||
/// The NIC MAC, e.g. `aa:bb:0c:dd:ee:ff`.
|
||||
///
|
||||
/// For a **clone** this is one of the two persistent per-slot MACs, not a
|
||||
/// fresh random address — see ``VMStore`` and docs/DESIGN.md, Verified
|
||||
/// Fact 12.
|
||||
public var macAddress: String
|
||||
|
||||
/// Backing disk format.
|
||||
public var diskFormat: DiskFormat
|
||||
|
||||
/// Virtual CPU count.
|
||||
public var cpuCount: Int
|
||||
|
||||
/// RAM in gibibytes.
|
||||
public var memoryGB: Int
|
||||
|
||||
/// The guest admin account created during installation.
|
||||
public var guestUsername: String
|
||||
|
||||
/// Bundle creation timestamp.
|
||||
public var createdAt: Date
|
||||
|
||||
/// The installed macOS version/build, when known (from
|
||||
/// `VZMacOSRestoreImage.buildVersion`).
|
||||
public var macOSVersion: String?
|
||||
|
||||
/// Whether guest provisioning (Node.js, `gitea-runner`, sudoers, power
|
||||
/// settings) has completed. A base image is only clonable once this is true.
|
||||
public var provisioned: Bool
|
||||
|
||||
public init(
|
||||
hardwareModelData: Data,
|
||||
machineIdentifierData: Data,
|
||||
macAddress: String,
|
||||
diskFormat: DiskFormat,
|
||||
cpuCount: Int,
|
||||
memoryGB: Int,
|
||||
guestUsername: String,
|
||||
createdAt: Date = Date(),
|
||||
macOSVersion: String? = nil,
|
||||
provisioned: Bool = false
|
||||
) {
|
||||
self.hardwareModelData = hardwareModelData
|
||||
self.machineIdentifierData = machineIdentifierData
|
||||
self.macAddress = macAddress
|
||||
self.diskFormat = diskFormat
|
||||
self.cpuCount = cpuCount
|
||||
self.memoryGB = memoryGB
|
||||
self.guestUsername = guestUsername
|
||||
self.createdAt = createdAt
|
||||
self.macOSVersion = macOSVersion
|
||||
self.provisioned = provisioned
|
||||
}
|
||||
}
|
||||
|
||||
/// A directory holding everything needed to boot one VM.
|
||||
///
|
||||
/// ```
|
||||
/// <bundle>/
|
||||
/// disk.asif (or disk.img for the RAW fallback)
|
||||
/// nvram.bin VZMacAuxiliaryStorage — the guest's NVRAM
|
||||
/// config.json VMBundleConfig
|
||||
/// ```
|
||||
///
|
||||
/// Base images live under `<storeDir>/images/<name>/`; ephemeral clones under
|
||||
/// `<storeDir>/vms/<uuid>/`. A clone is byte-identical except for `config.json`,
|
||||
/// which is rewritten with the slot's MAC.
|
||||
public struct VMBundle: Sendable, Equatable {
|
||||
/// The bundle directory.
|
||||
public let rootURL: URL
|
||||
|
||||
/// Wraps an existing directory path. Does not touch the filesystem.
|
||||
public init(rootURL: URL) {
|
||||
self.rootURL = rootURL
|
||||
}
|
||||
|
||||
// MARK: - Paths
|
||||
|
||||
/// Path to `config.json`.
|
||||
public var configURL: URL { rootURL.appendingPathComponent("config.json") }
|
||||
|
||||
/// Path to `nvram.bin`, the `VZMacAuxiliaryStorage` backing file.
|
||||
public var auxiliaryStorageURL: URL { rootURL.appendingPathComponent("nvram.bin") }
|
||||
|
||||
/// Path to the ASIF disk, used when ``VMBundleConfig/DiskFormat/asif``.
|
||||
public var asifDiskURL: URL { rootURL.appendingPathComponent("disk.asif") }
|
||||
|
||||
/// Path to the RAW disk, used when ``VMBundleConfig/DiskFormat/raw``.
|
||||
public var rawDiskURL: URL { rootURL.appendingPathComponent("disk.img") }
|
||||
|
||||
/// The disk file for a given format.
|
||||
public func diskURL(format: VMBundleConfig.DiskFormat) -> URL {
|
||||
switch format {
|
||||
case .asif: return asifDiskURL
|
||||
case .raw: return rawDiskURL
|
||||
}
|
||||
}
|
||||
|
||||
/// The bundle's directory name — the image name, or the clone's UUID.
|
||||
public var name: String { rootURL.lastPathComponent }
|
||||
|
||||
/// The disk file this bundle actually uses, per its recorded
|
||||
/// ``VMBundleConfig/diskFormat``.
|
||||
///
|
||||
/// Reads `config.json`, so the format is never re-probed from the
|
||||
/// filesystem — the builder recorded which of ASIF/RAW it managed to create
|
||||
/// and that record is authoritative.
|
||||
public func diskURL() throws -> URL {
|
||||
diskURL(format: try loadConfig().diskFormat)
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
/// Creates the bundle directory, failing if it already exists.
|
||||
///
|
||||
/// - Throws: ``CoreError/bundleCorrupt(_:)`` if the path exists as a file.
|
||||
public func createDirectory() throws {
|
||||
let fm = FileManager.default
|
||||
var isDir: ObjCBool = false
|
||||
if fm.fileExists(atPath: rootURL.path, isDirectory: &isDir) {
|
||||
if isDir.boolValue {
|
||||
throw CoreError.bundleCorrupt("bundle directory already exists: \(rootURL.path)")
|
||||
}
|
||||
throw CoreError.bundleCorrupt("bundle path exists but is a file: \(rootURL.path)")
|
||||
}
|
||||
do {
|
||||
try fm.createDirectory(at: rootURL, withIntermediateDirectories: true)
|
||||
} catch {
|
||||
throw CoreError.bundleCorrupt(
|
||||
"could not create bundle directory \(rootURL.path): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads and decodes `config.json`.
|
||||
///
|
||||
/// - Throws: ``CoreError/bundleCorrupt(_:)`` when absent or undecodable.
|
||||
public func loadConfig() throws -> VMBundleConfig {
|
||||
let data: Data
|
||||
do {
|
||||
data = try Data(contentsOf: configURL)
|
||||
} catch {
|
||||
throw CoreError.bundleCorrupt(
|
||||
"cannot read \(configURL.path): \(error.localizedDescription)")
|
||||
}
|
||||
do {
|
||||
return try Self.decoder.decode(VMBundleConfig.self, from: data)
|
||||
} catch {
|
||||
throw CoreError.bundleCorrupt(
|
||||
"cannot decode \(configURL.path): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Encodes and atomically writes `config.json`.
|
||||
public func saveConfig(_ config: VMBundleConfig) throws {
|
||||
let data: Data
|
||||
do {
|
||||
data = try Self.encoder.encode(config)
|
||||
} catch {
|
||||
throw CoreError.bundleCorrupt(
|
||||
"cannot encode config for \(rootURL.path): \(error.localizedDescription)")
|
||||
}
|
||||
do {
|
||||
// .atomic writes to a temporary sibling and renames, so a crash
|
||||
// mid-write can never leave a half-written config behind.
|
||||
try data.write(to: configURL, options: .atomic)
|
||||
} catch {
|
||||
throw CoreError.bundleCorrupt(
|
||||
"cannot write \(configURL.path): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `config.json`, `nvram.bin`, and the disk all exist.
|
||||
public func isComplete() -> Bool {
|
||||
let fm = FileManager.default
|
||||
guard fm.fileExists(atPath: configURL.path),
|
||||
fm.fileExists(atPath: auxiliaryStorageURL.path),
|
||||
let config = try? loadConfig()
|
||||
else {
|
||||
return false
|
||||
}
|
||||
return fm.fileExists(atPath: diskURL(format: config.diskFormat).path)
|
||||
}
|
||||
|
||||
/// Total on-disk size of the bundle in bytes, following sparse allocation
|
||||
/// (i.e. blocks actually used, not the disk's nominal size).
|
||||
public func diskUsageBytes() throws -> Int64 {
|
||||
let fm = FileManager.default
|
||||
let keys: [URLResourceKey] = [.isRegularFileKey, .totalFileAllocatedSizeKey, .fileAllocatedSizeKey]
|
||||
guard
|
||||
let enumerator = fm.enumerator(
|
||||
at: rootURL,
|
||||
includingPropertiesForKeys: keys,
|
||||
options: [],
|
||||
errorHandler: nil
|
||||
)
|
||||
else {
|
||||
throw CoreError.bundleCorrupt("cannot enumerate \(rootURL.path)")
|
||||
}
|
||||
var total: Int64 = 0
|
||||
for case let url as URL in enumerator {
|
||||
guard let values = try? url.resourceValues(forKeys: Set(keys)),
|
||||
values.isRegularFile == true
|
||||
else { continue }
|
||||
// totalFileAllocatedSize is the blocks actually committed, which for
|
||||
// a sparse ASIF/RAW disk is far below its nominal size.
|
||||
if let allocated = values.totalFileAllocatedSize ?? values.fileAllocatedSize {
|
||||
total += Int64(allocated)
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
/// Recursively removes the bundle directory.
|
||||
public func destroy() throws {
|
||||
let fm = FileManager.default
|
||||
guard fm.fileExists(atPath: rootURL.path) else { return }
|
||||
do {
|
||||
try fm.removeItem(at: rootURL)
|
||||
} catch {
|
||||
throw CoreError.bundleCorrupt(
|
||||
"cannot remove \(rootURL.path): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Coding
|
||||
|
||||
/// Shared coders. ISO-8601 dates keep `config.json` readable by humans and
|
||||
/// by `jq`; `Data` still encodes as base64, which is what the two opaque
|
||||
/// Virtualization blobs need.
|
||||
private static let decoder: JSONDecoder = {
|
||||
let d = JSONDecoder()
|
||||
d.dateDecodingStrategy = .iso8601
|
||||
return d
|
||||
}()
|
||||
|
||||
private static let encoder: JSONEncoder = {
|
||||
let e = JSONEncoder()
|
||||
e.dateEncodingStrategy = .iso8601
|
||||
e.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
return e
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
import Foundation
|
||||
import RunnerCore
|
||||
import Virtualization
|
||||
|
||||
/// Why a VM stopped.
|
||||
public enum VMStopReason: Sendable, Equatable {
|
||||
/// The guest shut itself down (our normal path: the SSH session runs
|
||||
/// `shutdown`, or `gitea-runner daemon` exits and provisioning halts it).
|
||||
case guestInitiated
|
||||
/// We asked it to stop and it complied.
|
||||
case requested
|
||||
/// The framework reported an error.
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
/// Owns one live `VZVirtualMachine` and exposes it as an `async` API.
|
||||
///
|
||||
/// ## Threading
|
||||
///
|
||||
/// `VZVirtualMachine` is not thread-safe and must be used only from the queue it
|
||||
/// was created with. This class creates it with
|
||||
/// `VZVirtualMachine(configuration:queue:)` on a **private serial queue** and
|
||||
/// funnels every call through that queue, bridging the framework's
|
||||
/// completion-handler API to `async` with continuations. That is why the daemon
|
||||
/// can drive two VMs from an actor without ever touching the main queue for VM
|
||||
/// control — though the process still needs a running `NSApplication` main loop
|
||||
/// for the framework itself (see ``CommandDaemon``).
|
||||
public final class VMInstance: @unchecked Sendable {
|
||||
|
||||
/// The bundle this instance was created from.
|
||||
public let bundle: VMBundle
|
||||
|
||||
/// A caller-supplied label used in log messages, typically `slot-0`.
|
||||
public let label: String
|
||||
|
||||
/// The private serial queue every `VZVirtualMachine` call and every delegate
|
||||
/// callback runs on. `VZVirtualMachine` is not thread-safe; this queue *is*
|
||||
/// its thread-safety.
|
||||
private let queue: DispatchQueue
|
||||
|
||||
/// Only ever touched on ``queue``.
|
||||
private let vm: VZVirtualMachine
|
||||
|
||||
/// Retained explicitly: `VZVirtualMachine.delegate` is a weak reference.
|
||||
private let vmDelegate: VMInstanceDelegate
|
||||
|
||||
/// Guards ``stopReason`` and ``activityToken``. A plain lock rather than an
|
||||
/// actor so the delegate callback — which arrives on ``queue`` and must not
|
||||
/// block on an await — can publish the stop synchronously.
|
||||
private let lock = NSLock()
|
||||
private var stopReason: VMStopReason?
|
||||
private var activityToken: (any NSObjectProtocol)?
|
||||
|
||||
/// Wraps a non-`Sendable` value so it can cross into a `@Sendable` closure
|
||||
/// that immediately hops onto ``queue``, which is the only place it is used.
|
||||
private struct Unchecked<T>: @unchecked Sendable {
|
||||
let value: T
|
||||
}
|
||||
|
||||
/// Creates an instance and its underlying `VZVirtualMachine`.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - bundle: The VM bundle to boot. Usually an ephemeral clone.
|
||||
/// - label: Log label.
|
||||
/// - headless: Passed through to ``VZConfigFactory``.
|
||||
/// - Throws: Configuration or validation failures.
|
||||
public init(bundle: VMBundle, label: String, headless: Bool = true) throws {
|
||||
self.bundle = bundle
|
||||
self.label = label
|
||||
let vmQueue = DispatchQueue(label: "vm.\(label).\(bundle.name)", qos: .userInitiated)
|
||||
self.queue = vmQueue
|
||||
|
||||
let configuration = try VZConfigFactory.makeConfiguration(bundle: bundle, headless: headless)
|
||||
let delegate = VMInstanceDelegate()
|
||||
self.vmDelegate = delegate
|
||||
|
||||
// Constructed on the queue it will be driven from, so no VZ object is
|
||||
// ever created on one thread and used from another.
|
||||
self.vm = vmQueue.sync {
|
||||
let machine = VZVirtualMachine(configuration: configuration, queue: vmQueue)
|
||||
machine.delegate = delegate
|
||||
return machine
|
||||
}
|
||||
|
||||
delegate.onStop = { [weak self] reason in
|
||||
self?.finishStop(reason)
|
||||
}
|
||||
}
|
||||
|
||||
/// The framework's current state, read on the VM queue.
|
||||
public var state: VZVirtualMachine.State {
|
||||
get async {
|
||||
// The raw value crosses the concurrency boundary rather than the
|
||||
// enum, so no assumption is made about the imported type's Sendable
|
||||
// conformance.
|
||||
let raw: Int = await withCheckedContinuation { continuation in
|
||||
queue.async {
|
||||
continuation.resume(returning: self.vm.state.rawValue)
|
||||
}
|
||||
}
|
||||
return VZVirtualMachine.State(rawValue: raw) ?? .stopped
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the VM is running or in a transitional state.
|
||||
public var isActive: Bool {
|
||||
get async {
|
||||
switch await state {
|
||||
case .stopped, .error:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts the VM.
|
||||
///
|
||||
/// - Parameter options: Optional start options. The install/provision path
|
||||
/// passes a `VZMacOSVirtualMachineStartOptions` — on macOS 27+ hosts that
|
||||
/// is also where Setup Assistant automation is attached (see
|
||||
/// ``GuestProvisioner`` and docs/DESIGN.md, Verified Fact 9). Pass `nil`
|
||||
/// for a normal boot of an already-provisioned clone.
|
||||
/// - Throws: ``CoreError/vmLimitExceeded`` when Apple's kernel-enforced cap
|
||||
/// of **two** concurrent macOS guests is hit — the framework raises
|
||||
/// `VZError.virtualMachineLimitExceeded` from `start()`, and that case is
|
||||
/// translated here rather than propagated, because the scheduler treats it
|
||||
/// as transient back-pressure rather than a failure.
|
||||
public func start(options: VZMacOSVirtualMachineStartOptions? = nil) async throws {
|
||||
clearStopReason()
|
||||
|
||||
let boxed = Unchecked(value: options)
|
||||
do {
|
||||
try await withCheckedThrowingContinuation {
|
||||
(continuation: CheckedContinuation<Void, any Error>) in
|
||||
queue.async {
|
||||
if let options = boxed.value {
|
||||
// The install/provision path: on a macOS 27+ host these
|
||||
// options carry the Setup Assistant automation. Note the
|
||||
// options-taking overload reports failure as an optional
|
||||
// Error, not a Result.
|
||||
self.vm.start(options: options) { error in
|
||||
if let error {
|
||||
continuation.resume(throwing: error)
|
||||
} else {
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.vm.start { result in
|
||||
switch result {
|
||||
case .success:
|
||||
continuation.resume()
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
throw Self.mapVZError(error)
|
||||
}
|
||||
|
||||
// Hold a power assertion for the VM's lifetime: a CI guest that is
|
||||
// building for twenty minutes over SSH looks completely idle to the host,
|
||||
// and letting the Mac sleep underneath it would stall the job.
|
||||
beginActivityAssertion()
|
||||
}
|
||||
|
||||
/// NSLock's `lock`/`unlock` are unavailable from an async context, so every
|
||||
/// critical section lives in a synchronous helper.
|
||||
private func clearStopReason() {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
stopReason = nil
|
||||
}
|
||||
|
||||
/// Takes the power assertion, unless the VM already stopped in the meantime.
|
||||
private func beginActivityAssertion() {
|
||||
let token = ProcessInfo.processInfo.beginActivity(
|
||||
options: [.userInitiated, .idleSystemSleepDisabled],
|
||||
reason: "running macOS CI guest \(label) (\(bundle.name))"
|
||||
)
|
||||
lock.lock()
|
||||
let alreadyStopped = stopReason != nil
|
||||
if !alreadyStopped {
|
||||
activityToken = token
|
||||
}
|
||||
lock.unlock()
|
||||
if alreadyStopped {
|
||||
// Raced with an immediate stop; don't strand the assertion.
|
||||
ProcessInfo.processInfo.endActivity(token)
|
||||
}
|
||||
}
|
||||
|
||||
/// Publishes a terminal stop and releases the power assertion. Idempotent:
|
||||
/// the first reason wins, so a `didStopWithError` following a `requestStop`
|
||||
/// cannot overwrite an already-recorded outcome.
|
||||
private func finishStop(_ reason: VMStopReason) {
|
||||
lock.lock()
|
||||
if stopReason == nil {
|
||||
stopReason = reason
|
||||
}
|
||||
let token = activityToken
|
||||
activityToken = nil
|
||||
lock.unlock()
|
||||
if let token {
|
||||
ProcessInfo.processInfo.endActivity(token)
|
||||
}
|
||||
}
|
||||
|
||||
/// The recorded stop reason, if the VM has already stopped.
|
||||
private var recordedStopReason: VMStopReason? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return stopReason
|
||||
}
|
||||
|
||||
/// Asks the guest to shut down, then force-stops if it does not.
|
||||
///
|
||||
/// Tries `requestStop()` first — that delivers an ACPI-equivalent power
|
||||
/// button press, giving the guest a chance to flush its filesystem — and
|
||||
/// falls back to `stop()` after `gracePeriod`. Never throws: teardown must
|
||||
/// always complete so the slot can be recycled.
|
||||
///
|
||||
/// - Parameter gracePeriod: How long to wait for a graceful stop.
|
||||
/// - Returns: Why the VM ended up stopped.
|
||||
@discardableResult
|
||||
public func requestStopThenForce(gracePeriod: Duration = .seconds(30)) async -> VMStopReason {
|
||||
if let reason = recordedStopReason { return reason }
|
||||
if await !isActive {
|
||||
// Stopped without a delegate callback ever landing (for example a
|
||||
// start() that failed outright). Record it so waiters unblock.
|
||||
finishStop(.requested)
|
||||
return recordedStopReason ?? .requested
|
||||
}
|
||||
|
||||
// Guest-cooperative first: requestStop() is the equivalent of a power
|
||||
// button press, which lets the guest flush its filesystem.
|
||||
_ = await withCheckedContinuation { (continuation: CheckedContinuation<Bool, Never>) in
|
||||
queue.async {
|
||||
guard self.vm.canRequestStop else {
|
||||
continuation.resume(returning: false)
|
||||
return
|
||||
}
|
||||
do {
|
||||
try self.vm.requestStop()
|
||||
continuation.resume(returning: true)
|
||||
} catch {
|
||||
// "not running", or the guest refused. Force is next either way.
|
||||
continuation.resume(returning: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let reason = await waitForStop(within: gracePeriod) {
|
||||
return reason
|
||||
}
|
||||
|
||||
// Grace elapsed — pull the plug. Teardown must always complete so the
|
||||
// slot can be recycled, so every failure here is swallowed.
|
||||
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
||||
queue.async {
|
||||
guard self.vm.canStop else {
|
||||
continuation.resume()
|
||||
return
|
||||
}
|
||||
self.vm.stop { _ in
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let reason = await waitForStop(within: .seconds(10)) {
|
||||
return reason
|
||||
}
|
||||
// The framework never told us; treat it as stopped regardless rather
|
||||
// than leaving the caller blocked on a dead slot.
|
||||
finishStop(.requested)
|
||||
return recordedStopReason ?? .requested
|
||||
}
|
||||
|
||||
/// Polls for a recorded stop for at most `limit`. Returns `nil` on timeout.
|
||||
///
|
||||
/// Polling rather than a parked continuation keeps this cancellable and
|
||||
/// leak-free: a continuation registered for a VM that never stops would be
|
||||
/// stranded forever.
|
||||
private func waitForStop(within limit: Duration) async -> VMStopReason? {
|
||||
let deadline = ContinuousClock.now.advanced(by: limit)
|
||||
while true {
|
||||
if let reason = recordedStopReason { return reason }
|
||||
if ContinuousClock.now >= deadline { return nil }
|
||||
do {
|
||||
try await Task.sleep(for: .milliseconds(200))
|
||||
} catch {
|
||||
return recordedStopReason
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Suspends until the VM stops for any reason.
|
||||
///
|
||||
/// - Returns: Why it stopped.
|
||||
public func waitUntilStopped() async -> VMStopReason {
|
||||
while true {
|
||||
if let reason = recordedStopReason { return reason }
|
||||
// A VM that reaches .stopped or .error without a delegate callback
|
||||
// (an unusual but observed path) must not hang the caller.
|
||||
if await !isActive {
|
||||
finishStop(.guestInitiated)
|
||||
return recordedStopReason ?? .guestInitiated
|
||||
}
|
||||
do {
|
||||
try await Task.sleep(for: .milliseconds(500))
|
||||
} catch {
|
||||
return recordedStopReason ?? .requested
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Translates a Virtualization error into a ``CoreError``.
|
||||
///
|
||||
/// `VZError.Code.virtualMachineLimitExceeded` becomes
|
||||
/// ``CoreError/vmLimitExceeded``; everything else becomes
|
||||
/// ``CoreError/provisioningFailed(_:)`` carrying the framework's message.
|
||||
public static func mapVZError(_ error: any Error) -> CoreError {
|
||||
if let coreError = error as? CoreError { return coreError }
|
||||
|
||||
// Apple's kernel-enforced cap of two concurrent macOS guests
|
||||
// (docs/DESIGN.md, Verified Fact 8). The scheduler treats this as
|
||||
// transient back-pressure, so it must stay distinguishable.
|
||||
if let vzError = error as? VZError, vzError.code == .virtualMachineLimitExceeded {
|
||||
return .vmLimitExceeded
|
||||
}
|
||||
let nsError = error as NSError
|
||||
if nsError.domain == VZErrorDomain,
|
||||
nsError.code == VZError.Code.virtualMachineLimitExceeded.rawValue
|
||||
{
|
||||
return .vmLimitExceeded
|
||||
}
|
||||
return .provisioningFailed(nsError.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// Bridges `VZVirtualMachineDelegate` callbacks back into ``VMInstance``.
|
||||
///
|
||||
/// Kept as a separate object so ``VMInstance`` need not inherit `NSObject`, and
|
||||
/// so the delegate's lifetime is explicitly owned rather than accidentally
|
||||
/// retained by the framework.
|
||||
final class VMInstanceDelegate: NSObject, VZVirtualMachineDelegate {
|
||||
/// Invoked on the VM queue whenever the machine stops.
|
||||
var onStop: (@Sendable (VMStopReason) -> Void)?
|
||||
|
||||
func guestDidStop(_ virtualMachine: VZVirtualMachine) {
|
||||
onStop?(.guestInitiated)
|
||||
}
|
||||
|
||||
func virtualMachine(_ virtualMachine: VZVirtualMachine, didStopWithError error: any Error) {
|
||||
onStop?(.failed((error as NSError).localizedDescription))
|
||||
}
|
||||
|
||||
func virtualMachine(
|
||||
_ virtualMachine: VZVirtualMachine,
|
||||
networkDevice: VZNetworkDevice,
|
||||
attachmentWasDisconnectedWithError error: any Error
|
||||
) {
|
||||
// NAT attachments do drop transiently. The VM keeps running and the
|
||||
// guest's DHCP client recovers, so this is deliberately not treated as a
|
||||
// stop — the boot/job timeouts are what catch a guest that never comes
|
||||
// back onto the network.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
import Foundation
|
||||
import RunnerCore
|
||||
import Virtualization
|
||||
|
||||
/// Host-level state persisted across daemon restarts.
|
||||
///
|
||||
/// The only thing in it today is the pair of per-slot MAC addresses, but it is
|
||||
/// versioned so future fields (saved-state handles, image pins) can be added.
|
||||
public struct HostState: Codable, Sendable, Equatable {
|
||||
/// Schema version of this file.
|
||||
public var version: Int
|
||||
|
||||
/// One MAC per VM slot, generated once with
|
||||
/// `VZMACAddress.randomLocallyAdministered()` and then **never changed**.
|
||||
///
|
||||
/// Reusing a small fixed set of MACs is deliberate. macOS's `bootpd` hands
|
||||
/// out 24-hour leases and records each in `/var/db/dhcpd_leases`; a fleet
|
||||
/// that randomized a MAC per ephemeral VM would leave a day's worth of dead
|
||||
/// leases behind and eventually exhaust the NAT subnet. Two persistent MACs
|
||||
/// mean each slot simply renews the same lease forever.
|
||||
public var slotMACAddresses: [String]
|
||||
|
||||
public init(version: Int = 1, slotMACAddresses: [String] = []) {
|
||||
self.version = version
|
||||
self.slotMACAddresses = slotMACAddresses
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns the on-disk layout of images, ephemeral clones, IPSWs, and host state.
|
||||
///
|
||||
/// ```
|
||||
/// <storeDir>/
|
||||
/// images/<name>/ base VM bundles (installed + provisioned)
|
||||
/// vms/<uuid>/ ephemeral clones, destroyed after each job
|
||||
/// ipsw/ downloaded restore images
|
||||
/// state.json HostState
|
||||
/// ```
|
||||
public struct VMStore: Sendable {
|
||||
/// Root directory, tilde-expanded by the caller.
|
||||
public let storeDir: URL
|
||||
|
||||
/// Creates a store rooted at `storeDir`. Does not touch the filesystem;
|
||||
/// call ``ensureLayout()`` first.
|
||||
public init(storeDir: URL) {
|
||||
self.storeDir = storeDir
|
||||
}
|
||||
|
||||
/// Convenience initializer reading ``RunnerConfig/storeDirectoryURL``.
|
||||
public init(config: RunnerConfig) {
|
||||
self.init(storeDir: config.storeDirectoryURL)
|
||||
}
|
||||
|
||||
// MARK: - Paths
|
||||
|
||||
/// `<storeDir>/images`.
|
||||
public var imagesDir: URL { storeDir.appendingPathComponent("images", isDirectory: true) }
|
||||
/// `<storeDir>/vms`.
|
||||
public var clonesDir: URL { storeDir.appendingPathComponent("vms", isDirectory: true) }
|
||||
/// `<storeDir>/ipsw`.
|
||||
public var ipswDir: URL { storeDir.appendingPathComponent("ipsw", isDirectory: true) }
|
||||
/// `<storeDir>/state.json`.
|
||||
public var stateURL: URL { storeDir.appendingPathComponent("state.json") }
|
||||
|
||||
/// Creates every directory in the layout if missing.
|
||||
public func ensureLayout() throws {
|
||||
let fm = FileManager.default
|
||||
for dir in [storeDir, imagesDir, clonesDir, ipswDir] {
|
||||
do {
|
||||
try fm.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
} catch {
|
||||
throw CoreError.bundleCorrupt(
|
||||
"cannot create \(dir.path): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Images
|
||||
|
||||
/// Names of every base image, sorted.
|
||||
public func listImages() throws -> [String] {
|
||||
let fm = FileManager.default
|
||||
guard fm.fileExists(atPath: imagesDir.path) else { return [] }
|
||||
let entries: [URL]
|
||||
do {
|
||||
entries = try fm.contentsOfDirectory(
|
||||
at: imagesDir,
|
||||
includingPropertiesForKeys: [.isDirectoryKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
)
|
||||
} catch {
|
||||
throw CoreError.bundleCorrupt(
|
||||
"cannot list \(imagesDir.path): \(error.localizedDescription)")
|
||||
}
|
||||
return
|
||||
entries
|
||||
.filter { (try? $0.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true }
|
||||
// A directory without a decodable config.json is not an image — it is
|
||||
// a half-finished build or somebody's scratch folder. Skip silently.
|
||||
.filter { (try? VMBundle(rootURL: $0).loadConfig()) != nil }
|
||||
.map { $0.lastPathComponent }
|
||||
.sorted()
|
||||
}
|
||||
|
||||
/// The bundle for a named base image.
|
||||
///
|
||||
/// - Parameter name: Image name, e.g. `default`.
|
||||
/// - Returns: The bundle, or `nil` when no such directory exists.
|
||||
public func image(named name: String) throws -> VMBundle? {
|
||||
let url = imagesDir.appendingPathComponent(name, isDirectory: true)
|
||||
var isDir: ObjCBool = false
|
||||
guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir),
|
||||
isDir.boolValue
|
||||
else { return nil }
|
||||
return VMBundle(rootURL: url)
|
||||
}
|
||||
|
||||
/// Deletes a base image and everything in it.
|
||||
public func deleteImage(named name: String) throws {
|
||||
guard let bundle = try image(named: name) else {
|
||||
throw CoreError.notFound("image '\(name)'")
|
||||
}
|
||||
try bundle.destroy()
|
||||
}
|
||||
|
||||
// MARK: - Clones
|
||||
|
||||
/// Copy-on-write clones a base image into a fresh ephemeral bundle.
|
||||
///
|
||||
/// Cloning is done with `FileManager.copyItem` **per file**, which on APFS
|
||||
/// performs a copy-on-write clone: the new disk costs almost nothing until
|
||||
/// the guest writes to it. Two constraints follow, and both are enforced
|
||||
/// here:
|
||||
///
|
||||
/// * Source and destination must be on the **same APFS volume**, so images
|
||||
/// and clones both live under `storeDir`.
|
||||
/// * A CoW clone's *apparent* size is the full disk size while its real cost
|
||||
/// grows with guest writes, so ``ensureFreeSpace(minGB:)`` must be called
|
||||
/// before cloning and the floor kept generous.
|
||||
///
|
||||
/// The clone's `config.json` is rewritten with `slotMAC` so the VM comes up
|
||||
/// on its slot's persistent address; everything else is inherited.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - name: Base image name. Must be ``VMBundleConfig/provisioned``.
|
||||
/// - slotMAC: The persistent MAC for the slot this clone will occupy.
|
||||
/// - Returns: The new clone bundle under `<storeDir>/vms/<uuid>/`.
|
||||
/// - Throws: ``CoreError/notFound(_:)`` if the image is missing,
|
||||
/// ``CoreError/bundleCorrupt(_:)`` if it is unprovisioned or incomplete.
|
||||
public func cloneImage(named name: String, slotMAC: String) throws -> VMBundle {
|
||||
guard let source = try image(named: name) else {
|
||||
throw CoreError.notFound("base image '\(name)' under \(imagesDir.path)")
|
||||
}
|
||||
let sourceConfig = try source.loadConfig()
|
||||
guard sourceConfig.provisioned else {
|
||||
throw CoreError.bundleCorrupt(
|
||||
"base image '\(name)' is not provisioned; run `image build` to completion first")
|
||||
}
|
||||
guard source.isComplete() else {
|
||||
throw CoreError.bundleCorrupt(
|
||||
"base image '\(name)' is missing its disk, nvram.bin, or config.json")
|
||||
}
|
||||
|
||||
try ensureLayout()
|
||||
|
||||
let fm = FileManager.default
|
||||
let destination = VMBundle(
|
||||
rootURL: clonesDir.appendingPathComponent(UUID().uuidString, isDirectory: true))
|
||||
try destination.createDirectory()
|
||||
|
||||
// Anything that fails past this point leaves a partial clone behind, and
|
||||
// a partial clone is worse than none: it would be counted by
|
||||
// `listClones` and booted by nobody.
|
||||
func abort(_ error: any Error) -> any Error {
|
||||
try? destination.destroy()
|
||||
return error
|
||||
}
|
||||
|
||||
do {
|
||||
// Per-file `copyItem`, NOT a directory copy: APFS performs a
|
||||
// copy-on-write clone for a regular file copied within the same
|
||||
// volume, so this is effectively instantaneous and costs no space
|
||||
// until the guest writes. It is *only* copy-on-write when source and
|
||||
// destination share a volume — which is why images/ and vms/ both
|
||||
// live under storeDir (docs/DESIGN.md, Verified Fact 13). Cloning
|
||||
// across volumes silently degrades to a full byte copy of a
|
||||
// multi-gigabyte disk.
|
||||
let diskName = source.diskURL(format: sourceConfig.diskFormat)
|
||||
try fm.copyItem(
|
||||
at: diskName,
|
||||
to: destination.diskURL(format: sourceConfig.diskFormat))
|
||||
try fm.copyItem(at: source.auxiliaryStorageURL, to: destination.auxiliaryStorageURL)
|
||||
try fm.copyItem(at: source.configURL, to: destination.configURL)
|
||||
} catch {
|
||||
throw abort(
|
||||
CoreError.bundleCorrupt(
|
||||
"cannot clone image '\(name)': \(error.localizedDescription)"))
|
||||
}
|
||||
|
||||
do {
|
||||
// Rewrite only the MAC. The machine identifier is deliberately
|
||||
// SHARED with the base image: the guest's Setup Assistant state and
|
||||
// its installed system are tied to it, regenerating it would present
|
||||
// the guest with new hardware, and a future save/restore path
|
||||
// (docs/DESIGN.md §9) forbids changing the ECID anyway.
|
||||
var cloneConfig = sourceConfig
|
||||
cloneConfig.macAddress = slotMAC
|
||||
cloneConfig.provisioned = true
|
||||
try destination.saveConfig(cloneConfig)
|
||||
} catch {
|
||||
throw abort(error)
|
||||
}
|
||||
|
||||
return destination
|
||||
}
|
||||
|
||||
/// Removes an ephemeral clone. Safe to call twice.
|
||||
///
|
||||
/// - Parameter bundle: A bundle previously returned by
|
||||
/// ``cloneImage(named:slotMAC:)``. Refuses to delete anything outside
|
||||
/// ``clonesDir``.
|
||||
public func deleteClone(_ bundle: VMBundle) throws {
|
||||
// `rm -rf` driven by a path that came from elsewhere deserves a guard.
|
||||
let root = clonesDir.standardizedFileURL.resolvingSymlinksInPath().path
|
||||
let target = bundle.rootURL.standardizedFileURL.resolvingSymlinksInPath().path
|
||||
guard target.hasPrefix(root.hasSuffix("/") ? root : root + "/"), target != root else {
|
||||
throw CoreError.bundleCorrupt(
|
||||
"refusing to delete \(bundle.rootURL.path): not inside \(clonesDir.path)")
|
||||
}
|
||||
try bundle.destroy()
|
||||
}
|
||||
|
||||
/// Every ephemeral clone currently on disk.
|
||||
///
|
||||
/// Used at startup to garbage-collect clones orphaned by a crash.
|
||||
public func listClones() throws -> [VMBundle] {
|
||||
let fm = FileManager.default
|
||||
guard fm.fileExists(atPath: clonesDir.path) else { return [] }
|
||||
let entries: [URL]
|
||||
do {
|
||||
entries = try fm.contentsOfDirectory(
|
||||
at: clonesDir,
|
||||
includingPropertiesForKeys: [.isDirectoryKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
)
|
||||
} catch {
|
||||
throw CoreError.bundleCorrupt(
|
||||
"cannot list \(clonesDir.path): \(error.localizedDescription)")
|
||||
}
|
||||
return
|
||||
entries
|
||||
.filter { (try? $0.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true }
|
||||
.sorted { $0.lastPathComponent < $1.lastPathComponent }
|
||||
.map { VMBundle(rootURL: $0) }
|
||||
}
|
||||
|
||||
/// Deletes every clone. Called on daemon startup, before any VM is booted.
|
||||
public func purgeClones() throws {
|
||||
// Best-effort per clone: one undeletable directory must not stop the
|
||||
// daemon from starting, so the first failure is remembered and rethrown
|
||||
// only after every other clone has been tried.
|
||||
var firstError: (any Error)?
|
||||
for clone in try listClones() {
|
||||
do {
|
||||
try deleteClone(clone)
|
||||
} catch {
|
||||
if firstError == nil { firstError = error }
|
||||
}
|
||||
}
|
||||
if let firstError { throw firstError }
|
||||
}
|
||||
|
||||
// MARK: - Host state
|
||||
|
||||
/// Reads `state.json`, returning a fresh ``HostState`` when absent.
|
||||
public func loadState() throws -> HostState {
|
||||
guard FileManager.default.fileExists(atPath: stateURL.path) else {
|
||||
return HostState()
|
||||
}
|
||||
do {
|
||||
let data = try Data(contentsOf: stateURL)
|
||||
return try JSONDecoder().decode(HostState.self, from: data)
|
||||
} catch {
|
||||
throw CoreError.bundleCorrupt(
|
||||
"cannot read \(stateURL.path): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Atomically writes `state.json`.
|
||||
public func saveState(_ state: HostState) throws {
|
||||
try ensureLayout()
|
||||
do {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
try encoder.encode(state).write(to: stateURL, options: .atomic)
|
||||
} catch {
|
||||
throw CoreError.bundleCorrupt(
|
||||
"cannot write \(stateURL.path): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the persistent MAC for a slot, generating and persisting the
|
||||
/// whole table the first time.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - slot: Slot index.
|
||||
/// - slotCount: How many slots to provision addresses for.
|
||||
/// - Returns: A MAC string such as `aa:bb:0c:dd:ee:ff`.
|
||||
public func macAddress(forSlot slot: Int, slotCount: Int) throws -> String {
|
||||
guard slot >= 0, slot < slotCount else {
|
||||
throw CoreError.configInvalid(
|
||||
"slot \(slot) is out of range for \(slotCount) slot(s)")
|
||||
}
|
||||
var state = try loadState()
|
||||
if state.slotMACAddresses.count < slotCount {
|
||||
// Generated exactly once and then persisted forever. See HostState's
|
||||
// doc comment and docs/DESIGN.md, Verified Fact 12: randomizing a MAC
|
||||
// per ephemeral clone would strand a 24-hour bootpd lease per boot
|
||||
// and eventually exhaust the NAT subnet.
|
||||
while state.slotMACAddresses.count < slotCount {
|
||||
state.slotMACAddresses.append(
|
||||
VZMACAddress.randomLocallyAdministered().string)
|
||||
}
|
||||
try saveState(state)
|
||||
}
|
||||
return state.slotMACAddresses[slot]
|
||||
}
|
||||
|
||||
// MARK: - Disk space
|
||||
|
||||
/// Free space on the store's volume, in bytes.
|
||||
///
|
||||
/// Uses the *important usage* resource key so the number matches what Finder
|
||||
/// reports and accounts for purgeable space.
|
||||
public func freeDiskSpace() throws -> Int64 {
|
||||
// The volume keys only resolve for a path that exists, and the daemon may
|
||||
// call this before anything has been created.
|
||||
try ensureLayout()
|
||||
do {
|
||||
let values = try storeDir.resourceValues(forKeys: [
|
||||
.volumeAvailableCapacityForImportantUsageKey
|
||||
])
|
||||
guard let available = values.volumeAvailableCapacityForImportantUsage else {
|
||||
throw CoreError.notFound(
|
||||
"free-space information for the volume holding \(storeDir.path)")
|
||||
}
|
||||
return available
|
||||
} catch let error as CoreError {
|
||||
throw error
|
||||
} catch {
|
||||
throw CoreError.notFound(
|
||||
"free space for \(storeDir.path): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Throws unless the store volume has at least `minGB` free.
|
||||
///
|
||||
/// - Throws: ``CoreError/insufficientDiskSpace(requiredGB:availableGB:)``.
|
||||
public func ensureFreeSpace(minGB: Int) throws {
|
||||
guard minGB > 0 else { return }
|
||||
let availableBytes = try freeDiskSpace()
|
||||
let availableGB = Int(availableBytes / 1_073_741_824)
|
||||
guard availableGB >= minGB else {
|
||||
throw CoreError.insufficientDiskSpace(requiredGB: minGB, availableGB: availableGB)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import Foundation
|
||||
import RunnerCore
|
||||
import Virtualization
|
||||
|
||||
/// Builds a `VZVirtualMachineConfiguration` from a ``VMBundle``.
|
||||
///
|
||||
/// The configuration is assembled the same way for base-image installs and for
|
||||
/// ephemeral clones; only the bundle differs. Devices are chosen for the minimum
|
||||
/// that a headless CI guest needs while still satisfying macOS's own
|
||||
/// requirements.
|
||||
public enum VZConfigFactory {
|
||||
|
||||
/// Assembles and validates a configuration.
|
||||
///
|
||||
/// Composition:
|
||||
///
|
||||
/// * **Platform** — `VZMacPlatformConfiguration` with `hardwareModel` and
|
||||
/// `machineIdentifier` restored from the bundle's stored blobs, and
|
||||
/// `auxiliaryStorage` opened from `nvram.bin`. These three must match the
|
||||
/// install exactly or the guest will not boot.
|
||||
/// * **Boot loader** — `VZMacOSBootLoader`.
|
||||
/// * **CPU / memory** — `max(4, config.cpuCount)` clamped into the
|
||||
/// framework's supported range; memory likewise clamped.
|
||||
/// * **Storage** — `VZVirtioBlockDeviceConfiguration` over a
|
||||
/// `VZDiskImageStorageDeviceAttachment` on the bundle's disk.
|
||||
/// * **Network** — `VZVirtioNetworkDeviceConfiguration` with a
|
||||
/// `VZNATNetworkDeviceAttachment` and the bundle's MAC. NAT, not bridged:
|
||||
/// bridged networking requires the restricted
|
||||
/// `com.apple.vm.networking` entitlement, which Apple does not grant for
|
||||
/// ad-hoc signing, whereas NAT needs nothing beyond
|
||||
/// `com.apple.security.virtualization`. NAT is also what puts the guest in
|
||||
/// `/var/db/dhcpd_leases`, which is how we discover its IP.
|
||||
/// * **Graphics** — a `VZMacGraphicsDeviceConfiguration` with a single
|
||||
/// 1920×1200 @ 72 ppi display, configured **always**, even headless. macOS
|
||||
/// guests misbehave without a display device; we simply never attach a
|
||||
/// `VZVirtualMachineView` to it.
|
||||
/// * **Input** — `VZMacKeyboardConfiguration` and a pointing device, needed
|
||||
/// for Setup Assistant automation to have something to talk to.
|
||||
/// * **Entropy** — `VZVirtioEntropyDeviceConfiguration`, so the guest's RNG
|
||||
/// seeds promptly instead of blocking early boot.
|
||||
/// * **Socket** — `VZVirtioSocketDeviceConfiguration`, reserved for a future
|
||||
/// vsock control channel that would replace SSH.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - bundle: The VM to configure.
|
||||
/// - headless: When `true`, no view will be attached. Retained as a
|
||||
/// parameter because `vm boot` may later want a window; it does **not**
|
||||
/// change whether the graphics device is present.
|
||||
/// - Returns: A configuration that has passed `validate()`.
|
||||
/// - Throws: ``CoreError/bundleCorrupt(_:)`` when the bundle's blobs cannot
|
||||
/// be restored, or the framework's own validation error.
|
||||
public static func makeConfiguration(
|
||||
bundle: VMBundle,
|
||||
headless: Bool = true
|
||||
) throws -> VZVirtualMachineConfiguration {
|
||||
let bundleConfig = try bundle.loadConfig()
|
||||
|
||||
let configuration = VZVirtualMachineConfiguration()
|
||||
configuration.platform = try makePlatform(bundle: bundle)
|
||||
configuration.bootLoader = VZMacOSBootLoader()
|
||||
configuration.cpuCount = clampedCPUCount(bundleConfig.cpuCount)
|
||||
configuration.memorySize = clampedMemorySize(gigabytes: bundleConfig.memoryGB)
|
||||
|
||||
// Storage. The bundle records which of ASIF/RAW the builder produced, so
|
||||
// the right file is attached without probing the filesystem.
|
||||
let diskURL = bundle.diskURL(format: bundleConfig.diskFormat)
|
||||
guard FileManager.default.fileExists(atPath: diskURL.path) else {
|
||||
throw CoreError.bundleCorrupt("missing disk image at \(diskURL.path)")
|
||||
}
|
||||
let attachment: VZDiskImageStorageDeviceAttachment
|
||||
do {
|
||||
attachment = try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false)
|
||||
} catch {
|
||||
throw CoreError.bundleCorrupt(
|
||||
"cannot attach disk \(diskURL.path): \(error.localizedDescription)")
|
||||
}
|
||||
configuration.storageDevices = [VZVirtioBlockDeviceConfiguration(attachment: attachment)]
|
||||
|
||||
// Network: NAT, with the bundle's MAC. NAT is what puts the guest into
|
||||
// /var/db/dhcpd_leases, which is the only way we learn its IP.
|
||||
guard let mac = VZMACAddress(string: bundleConfig.macAddress) else {
|
||||
throw CoreError.bundleCorrupt(
|
||||
"malformed MAC address '\(bundleConfig.macAddress)' in \(bundle.configURL.path)")
|
||||
}
|
||||
let network = VZVirtioNetworkDeviceConfiguration()
|
||||
network.attachment = VZNATNetworkDeviceAttachment()
|
||||
network.macAddress = mac
|
||||
configuration.networkDevices = [network]
|
||||
|
||||
// Graphics: always present, even headless, and never sized from
|
||||
// NSScreen — the daemon runs as a LaunchAgent that may have no attached
|
||||
// display at all, and a nil main screen there would be fatal. `headless`
|
||||
// only decides whether a VZVirtualMachineView is ever bound to this
|
||||
// device; the device itself is unconditional because macOS guests
|
||||
// misbehave without one.
|
||||
_ = headless
|
||||
let graphics = VZMacGraphicsDeviceConfiguration()
|
||||
graphics.displays = [
|
||||
VZMacGraphicsDisplayConfiguration(
|
||||
widthInPixels: 1920,
|
||||
heightInPixels: 1200,
|
||||
pixelsPerInch: 72
|
||||
)
|
||||
]
|
||||
configuration.graphicsDevices = [graphics]
|
||||
|
||||
// Input: Setup Assistant automation needs something to talk to.
|
||||
configuration.keyboards = [VZMacKeyboardConfiguration()]
|
||||
configuration.pointingDevices = [VZMacTrackpadConfiguration()]
|
||||
|
||||
// Entropy, so the guest's RNG seeds promptly rather than blocking early boot.
|
||||
configuration.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]
|
||||
|
||||
// Exactly one socket device — the framework permits no more. Reserved for
|
||||
// the vsock control channel that would eventually replace SSH.
|
||||
configuration.socketDevices = [VZVirtioSocketDeviceConfiguration()]
|
||||
|
||||
try configuration.validate()
|
||||
return configuration
|
||||
}
|
||||
|
||||
/// Builds only the platform configuration, so the installer path can share it.
|
||||
///
|
||||
/// - Parameter bundle: The VM whose hardware model, machine identifier, and
|
||||
/// auxiliary storage should be restored.
|
||||
public static func makePlatform(bundle: VMBundle) throws -> VZMacPlatformConfiguration {
|
||||
let bundleConfig = try bundle.loadConfig()
|
||||
let platform = VZMacPlatformConfiguration()
|
||||
|
||||
guard
|
||||
let hardwareModel = VZMacHardwareModel(
|
||||
dataRepresentation: bundleConfig.hardwareModelData)
|
||||
else {
|
||||
throw CoreError.bundleCorrupt(
|
||||
"hardwareModelData in \(bundle.configURL.path) is not a valid VZMacHardwareModel")
|
||||
}
|
||||
guard hardwareModel.isSupported else {
|
||||
throw CoreError.hostUnsupported(
|
||||
"this host does not support the hardware model recorded in \(bundle.configURL.path)"
|
||||
)
|
||||
}
|
||||
guard
|
||||
let machineIdentifier = VZMacMachineIdentifier(
|
||||
dataRepresentation: bundleConfig.machineIdentifierData)
|
||||
else {
|
||||
throw CoreError.bundleCorrupt(
|
||||
"machineIdentifierData in \(bundle.configURL.path) is not a valid VZMacMachineIdentifier"
|
||||
)
|
||||
}
|
||||
|
||||
// The *existing*-storage initializer. Using
|
||||
// VZMacAuxiliaryStorage(creatingStorageAt:hardwareModel:) here would
|
||||
// blank the guest's NVRAM and it would no longer boot.
|
||||
guard FileManager.default.fileExists(atPath: bundle.auxiliaryStorageURL.path) else {
|
||||
throw CoreError.bundleCorrupt("missing nvram.bin at \(bundle.auxiliaryStorageURL.path)")
|
||||
}
|
||||
platform.auxiliaryStorage = VZMacAuxiliaryStorage(url: bundle.auxiliaryStorageURL)
|
||||
platform.hardwareModel = hardwareModel
|
||||
platform.machineIdentifier = machineIdentifier
|
||||
return platform
|
||||
}
|
||||
|
||||
/// Clamps a requested CPU count into the framework's supported range, with a
|
||||
/// floor of 4 — Xcode builds are miserable below that.
|
||||
public static func clampedCPUCount(_ requested: Int) -> Int {
|
||||
let lowerBound = max(VZVirtualMachineConfiguration.minimumAllowedCPUCount, 4)
|
||||
let upperBound = VZVirtualMachineConfiguration.maximumAllowedCPUCount
|
||||
// On a host whose maximum is below our floor, the maximum wins.
|
||||
guard lowerBound <= upperBound else { return upperBound }
|
||||
return min(max(requested, lowerBound), upperBound)
|
||||
}
|
||||
|
||||
/// Clamps a requested memory size (in gibibytes) into the framework's
|
||||
/// supported range, returning bytes.
|
||||
public static func clampedMemorySize(gigabytes: Int) -> UInt64 {
|
||||
let lowerBound = VZVirtualMachineConfiguration.minimumAllowedMemorySize
|
||||
let upperBound = VZVirtualMachineConfiguration.maximumAllowedMemorySize
|
||||
let requested = UInt64(max(gigabytes, 0)) * 1_073_741_824
|
||||
return min(max(requested, lowerBound), upperBound)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user