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 - `. /// 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) ?? "") } }