Files
gitea-macos-vm-orchestrator/Sources/RunnerHost/Doctor.swift
T

623 lines
24 KiB
Swift

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 one-byte ranged `GET` — the
/// same verb the real download uses, because the presigned redirect
/// target is signed per method. 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"
)
}
// A ranged GET, not a HEAD. gitea.com answers an asset request with a
// 303 to a presigned object-storage URL, and the signature covers the
// *method of the request that minted it*: ask with HEAD and you get a
// HEAD-signed URL. URLSession then follows the 303 and — per RFC 7231
// §6.4.4 — rewrites the method to GET, so the signed URL is replayed
// with the one verb it was not signed for and the store answers 403
// SignatureDoesNotMatch. Probing with the same verb the real download
// uses is the only way to make the answer mean anything. `bytes=0-0`
// keeps it to one byte instead of the whole 20-plus MB asset.
do {
let status = try await probeStatus(url: url, method: "GET", range: "bytes=0-0")
if status <= 399 {
return DoctorCheck(name: name, result: .pass, detail: "\(url.absoluteString) → \(status)")
}
// A host that rejects ranges outright still deserves a second look
// before we call the asset missing.
let fallback = try await probeStatus(url: url, method: "HEAD", range: nil)
if fallback <= 399 {
return DoctorCheck(name: name, result: .pass, detail: "\(url.absoluteString) → \(fallback)")
}
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
)
}
}
/// Issues one probe request and reports its status code, or 0 if the
/// response was not HTTP.
private static func probeStatus(url: URL, method: String, range: String?) async throws -> Int {
var request = URLRequest(url: url)
request.httpMethod = method
request.timeoutInterval = 15
if let range {
request.setValue(range, forHTTPHeaderField: "Range")
}
let (_, response) = try await URLSession.shared.data(for: request)
return (response as? HTTPURLResponse)?.statusCode ?? 0
}
/// 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) ?? "")
}
}