598 lines
23 KiB
Swift
598 lines
23 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 `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) ?? "")
|
|
}
|
|
}
|