Merge nucleic/mellow-dewy-falcon-rjhr into main

This commit is contained in:
2026-08-07 00:44:36 -07:00
parent 749f0be4fb
commit bc2b6cd33b
47 changed files with 13159 additions and 0 deletions
+733
View File
@@ -0,0 +1,733 @@
import Foundation
/// The runner's on-disk configuration, loaded from
/// `~/.config/gitea-macos-runner/config.json`.
///
/// Every section has defaults, and decoding tolerates missing keys, so a minimal
/// config only needs `gitea.instanceURL` plus a way to obtain tokens. See
/// `Resources/config.example.json` for an annotated full example.
public struct RunnerConfig: Codable, Sendable, Equatable {
// MARK: - Sections
/// How to reach the Gitea instance and how to authenticate to it.
public struct GiteaSection: Codable, Sendable, Equatable {
/// Base URL of the Gitea instance, e.g. `https://gitea.example.com`.
/// Paths are appended to this, so a trailing slash is harmless.
public var instanceURL: URL
/// A Gitea admin API token, inline. Used for the admin Actions endpoints
/// (job listing, runner listing/deletion, registration-token minting).
/// Prefer ``adminTokenFile`` so the secret is not world-readable in JSON.
///
/// - Important: Exactly one of this and ``adminTokenFile`` must be set.
/// ``RunnerConfig/validated()`` rejects both-set and neither-set alike;
/// a stale inline token sitting beside a live token file is exactly the
/// ambiguity that produces a baffling 401 at 3am.
public var adminToken: String?
/// Path to a file whose (trimmed) contents are the admin API token.
/// Tilde-expanded.
///
/// - Important: Exactly one of this and ``adminToken`` must be set — see
/// that property. This one does *not* silently win over an inline
/// value; setting both is a validation error.
public var adminTokenFile: String?
/// The shared runner registration token, inline.
///
/// - Important: Registration tokens are **reusable** and **scoped**.
/// Minting a new token for a scope invalidates all prior tokens of that
/// scope, so per-VM tokens must never be pre-generated. One shared
/// token serves the whole fleet. See docs/DESIGN.md, Verified Fact 4.
public var registrationToken: String?
/// Path to a file whose (trimmed) contents are the registration token.
/// Tilde-expanded. Takes precedence over ``registrationToken``.
public var registrationTokenFile: String?
/// When no static registration token is configured, fetch one from
/// `POST /api/v1/admin/actions/runners/registration-token`.
///
/// Defaults to `false` because that endpoint effectively returns the
/// *existing* active token for the scope, and any implementation change
/// that made it mint a fresh one would invalidate tokens held by runners
/// registered elsewhere.
public var fetchRegistrationTokenViaAPI: Bool
public init(
instanceURL: URL,
adminToken: String? = nil,
adminTokenFile: String? = nil,
registrationToken: String? = nil,
registrationTokenFile: String? = nil,
fetchRegistrationTokenViaAPI: Bool = false
) {
self.instanceURL = instanceURL
self.adminToken = adminToken
self.adminTokenFile = adminTokenFile
self.registrationToken = registrationToken
self.registrationTokenFile = registrationTokenFile
self.fetchRegistrationTokenViaAPI = fetchRegistrationTokenViaAPI
}
}
/// Identity and provenance of the runners registered inside each guest.
public struct RunnerSection: Codable, Sendable, Equatable {
/// Bare label names this host serves. Matched case-sensitively against a
/// job's `labels` (i.e. its `runs-on:`). The `:host` schema suffix is
/// added only when calling `gitea-runner register`.
public var labels: [String]
/// Prefix for generated runner names. Must be distinctive enough that the
/// reconcile loop can tell our stale rows from other runners'.
public var namePrefix: String
/// Template for the `gitea-runner` release asset to install in the guest.
/// `{version}` is substituted with ``version``.
public var runnerDownloadURL: String
/// The `gitea-runner` version to install (v3.x; the binary was renamed
/// from `act_runner`, and now lives at `gitea.com/gitea/runner`).
public var version: String
public init(
labels: [String] = ["macos-arm64"],
namePrefix: String = "macos-vm-",
runnerDownloadURL: String = RunnerSection.defaultDownloadURLTemplate,
version: String = "3.0.2"
) {
self.labels = labels
self.namePrefix = namePrefix
self.runnerDownloadURL = runnerDownloadURL
self.version = version
}
/// Default release-asset URL template for the darwin/arm64 build.
public static let defaultDownloadURLTemplate =
"https://gitea.com/gitea/runner/releases/download/v{version}/gitea-runner-{version}-darwin-arm64"
/// ``runnerDownloadURL`` with `{version}` substituted.
public var resolvedDownloadURL: URL {
get throws {
let substituted = runnerDownloadURL.replacingOccurrences(of: "{version}", with: version)
guard let url = URL(string: substituted), url.scheme != nil else {
throw CoreError.configInvalid(
"runner.runnerDownloadURL does not form a valid URL: \(substituted)")
}
return url
}
}
}
/// Polling cadence, concurrency, and the timeouts that bound a stuck VM.
public struct SchedulerSection: Codable, Sendable, Equatable {
/// How many macOS guests may run at once.
///
/// - Important: Hard-clamped to 2 by ``RunnerConfig/validated()``. Apple's
/// kernel enforces a limit of two concurrent macOS VMs per host; a third
/// `start()` fails with `VZError.virtualMachineLimitExceeded`.
public var maxConcurrentVMs: Int
/// Seconds between queued-job polls.
public var pollIntervalSeconds: Int
/// Seconds between reconcile passes that sweep orphaned runner rows.
public var reconcileIntervalSeconds: Int
/// Wall-clock ceiling on a single job before its VM is torn down.
public var jobTimeoutMinutes: Int
/// Ceiling on boot + DHCP lease + SSH readiness before a slot is
/// declared dead and recycled.
public var bootTimeoutSeconds: Int
public init(
maxConcurrentVMs: Int = 2,
pollIntervalSeconds: Int = 5,
reconcileIntervalSeconds: Int = 300,
jobTimeoutMinutes: Int = 120,
bootTimeoutSeconds: Int = 300
) {
self.maxConcurrentVMs = maxConcurrentVMs
self.pollIntervalSeconds = pollIntervalSeconds
self.reconcileIntervalSeconds = reconcileIntervalSeconds
self.jobTimeoutMinutes = jobTimeoutMinutes
self.bootTimeoutSeconds = bootTimeoutSeconds
}
/// The absolute cap on concurrent macOS guests, enforced by the kernel.
public static let hardMaxConcurrentVMs = 2
}
/// Shape of each guest VM and the credentials used to reach it over SSH.
///
/// - Note: These credentials only ever exist on the NAT network between the
/// host and its own ephemeral guests. They are not secrets in any
/// meaningful sense, but they are also why the NAT attachment (rather than
/// bridged networking) is not optional.
public struct GuestSection: Codable, Sendable, Equatable {
/// The admin account created by Setup Assistant automation.
public var username: String
/// That account's password, also used for SSH password auth.
public var password: String
/// Virtual CPUs per guest.
public var cpuCount: Int
/// RAM per guest, in gibibytes.
public var memoryGB: Int
/// Backing disk size per guest, in gibibytes. Sparse (ASIF) where
/// available, so this is a ceiling rather than an allocation.
public var diskGB: Int
public init(
username: String = "admin",
password: String = "admin",
cpuCount: Int = 4,
memoryGB: Int = 8,
diskGB: Int = 64
) {
self.username = username
self.password = password
self.cpuCount = cpuCount
self.memoryGB = memoryGB
self.diskGB = diskGB
}
}
/// Where images, clones, IPSWs, and host state live on disk.
public struct StorageSection: Codable, Sendable, Equatable {
/// Root of the store. Tilde-expanded.
///
/// - Important: Clones are made with APFS copy-on-write, which requires
/// source and destination on the *same volume*. Keep base images and
/// ephemeral clones under one root.
public var storeDir: String
/// Refuse to clone a new VM when the store volume has less than this
/// much free space. CoW clones start near-free but grow as the guest
/// writes, so a floor well above one clone's nominal size is prudent.
public var minFreeDiskGB: Int
public init(
storeDir: String = "~/Library/Application Support/gitea-macos-runner",
minFreeDiskGB: Int = 20
) {
self.storeDir = storeDir
self.minFreeDiskGB = minFreeDiskGB
}
}
// MARK: - Stored properties
public var gitea: GiteaSection
public var runner: RunnerSection
public var scheduler: SchedulerSection
public var guest: GuestSection
public var storage: StorageSection
public init(
gitea: GiteaSection,
runner: RunnerSection = .init(),
scheduler: SchedulerSection = .init(),
guest: GuestSection = .init(),
storage: StorageSection = .init()
) {
self.gitea = gitea
self.runner = runner
self.scheduler = scheduler
self.guest = guest
self.storage = storage
}
// MARK: - Defaults
/// A configuration with every default applied and a placeholder instance URL.
/// Used by `config init` to seed a new file, and by tests.
public static var `default`: RunnerConfig {
RunnerConfig(gitea: GiteaSection(instanceURL: URL(string: "https://gitea.example.com")!))
}
/// The conventional config path, `~/.config/gitea-macos-runner/config.json`,
/// tilde-expanded.
public static var defaultPath: String {
expandTilde("~/.config/gitea-macos-runner/config.json")
}
// MARK: - Loading & validation
/// Loads and validates a configuration from a JSON file.
///
/// - Parameter path: Filesystem path; tilde-expanded. Defaults to
/// ``defaultPath``.
/// - Returns: A validated configuration.
/// - Throws: ``CoreError/configInvalid(_:)`` if the file is missing,
/// unparseable, or fails ``validated()``.
public static func load(from path: String = RunnerConfig.defaultPath) throws -> RunnerConfig {
let expanded = expandTilde(path)
guard FileManager.default.fileExists(atPath: expanded) else {
throw CoreError.configInvalid("no configuration file at \(expanded)")
}
let data: Data
do {
data = try Data(contentsOf: URL(fileURLWithPath: expanded))
} catch {
throw CoreError.configInvalid("cannot read \(expanded): \(error.localizedDescription)")
}
let decoded: RunnerConfig
do {
decoded = try JSONDecoder().decode(RunnerConfig.self, from: data)
} catch let error as DecodingError {
throw CoreError.configInvalid("\(expanded): \(RunnerConfig.describe(error))")
} catch {
throw CoreError.configInvalid("\(expanded): \(error.localizedDescription)")
}
return try decoded.validated()
}
/// Renders a `DecodingError` as something an operator can act on, since the
/// default description is a multi-line dump of the underlying context.
private static func describe(_ error: DecodingError) -> String {
func keyPath(_ context: DecodingError.Context) -> String {
let path = context.codingPath.map(\.stringValue).joined(separator: ".")
return path.isEmpty ? "<root>" : path
}
switch error {
case .keyNotFound(let key, let context):
let parent = keyPath(context)
return "missing required key `\(key.stringValue)`"
+ (parent == "<root>" ? "" : " under `\(parent)`")
case .typeMismatch(let type, let context):
return "key `\(keyPath(context))` has the wrong type (expected \(type))"
case .valueNotFound(let type, let context):
return "key `\(keyPath(context))` is null (expected \(type))"
case .dataCorrupted(let context):
let path = keyPath(context)
return path == "<root>"
? "not valid JSON (\(context.debugDescription))"
: "key `\(path)` is malformed (\(context.debugDescription))"
@unknown default:
return "\(error)"
}
}
/// Writes this configuration as pretty-printed JSON, creating parent
/// directories as needed.
///
/// - Parameter path: Destination; tilde-expanded.
public func save(to path: String) throws {
let expanded = RunnerConfig.expandTilde(path)
let url = URL(fileURLWithPath: expanded)
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
do {
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true)
var data = try encoder.encode(self)
data.append(0x0A) // trailing newline, so the file is diff-friendly
try data.write(to: url, options: .atomic)
} catch {
throw CoreError.configInvalid("cannot write \(expanded): \(error.localizedDescription)")
}
}
/// Writes the annotated example configuration shipped in `Resources/`, or —
/// when that resource is not reachable — this configuration serialized by
/// ``save(to:)``.
///
/// `config init` uses this so a fresh install lands an operator on the
/// commented example rather than a bare JSON dump.
///
/// - Parameters:
/// - path: Destination; tilde-expanded.
/// - exampleContents: The example document, if the caller could load it.
/// - overwrite: When `false` (the default) an existing file is left alone.
/// - Returns: `true` if a file was written, `false` if one already existed.
@discardableResult
public func writeExample(
to path: String,
exampleContents: String? = nil,
overwrite: Bool = false
) throws -> Bool {
let expanded = RunnerConfig.expandTilde(path)
if !overwrite, FileManager.default.fileExists(atPath: expanded) {
return false
}
guard let example = exampleContents else {
try save(to: expanded)
return true
}
let url = URL(fileURLWithPath: expanded)
do {
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true)
try Data(example.utf8).write(to: url, options: .atomic)
} catch {
throw CoreError.configInvalid("cannot write \(expanded): \(error.localizedDescription)")
}
return true
}
/// Returns a normalized copy, or throws describing what is wrong.
///
/// Normalization clamps ``SchedulerSection/maxConcurrentVMs`` into
/// `1...2` and expands tildes in path-bearing fields. Validation rejects a
/// non-http(s) instance URL, an empty label list, an empty name prefix,
/// non-positive intervals or timeouts, a guest with fewer than 1 CPU or less
/// than 1 GB of RAM, and a configuration with no way to obtain either token.
///
/// - Returns: The normalized configuration.
/// - Throws: ``CoreError/configInvalid(_:)``.
public func validated() throws -> RunnerConfig {
var c = self
// --- gitea.instanceURL ------------------------------------------------
let scheme = c.gitea.instanceURL.scheme?.lowercased()
guard scheme == "http" || scheme == "https" else {
throw CoreError.configInvalid(
"gitea.instanceURL must be an http:// or https:// URL, got \"\(c.gitea.instanceURL.absoluteString)\"")
}
guard let host = c.gitea.instanceURL.host, !host.isEmpty else {
throw CoreError.configInvalid(
"gitea.instanceURL has no host: \"\(c.gitea.instanceURL.absoluteString)\"")
}
// --- admin token: exactly one source ----------------------------------
//
// Both-set is rejected rather than silently preferring one, because a
// stale inline token sitting next to a live token file is precisely the
// kind of ambiguity that produces a baffling 401 at 3am.
let inlineAdmin = RunnerConfig.nonEmpty(c.gitea.adminToken)
let fileAdmin = RunnerConfig.nonEmpty(c.gitea.adminTokenFile)
switch (inlineAdmin, fileAdmin) {
case (nil, nil):
throw CoreError.configInvalid(
"no admin API token configured: set exactly one of gitea.adminToken or gitea.adminTokenFile")
case (.some, .some):
throw CoreError.configInvalid(
"gitea.adminToken and gitea.adminTokenFile are both set: use exactly one")
default:
break
}
c.gitea.adminToken = inlineAdmin
c.gitea.adminTokenFile = fileAdmin.map(RunnerConfig.expandTilde)
// --- registration token: at least one source --------------------------
//
// Unlike the admin token, a file and an inline value are not mutually
// exclusive here (the file wins); what is rejected is having no source
// at all with the API fallback switched off.
let inlineReg = RunnerConfig.nonEmpty(c.gitea.registrationToken)
let fileReg = RunnerConfig.nonEmpty(c.gitea.registrationTokenFile)
if inlineReg == nil, fileReg == nil, !c.gitea.fetchRegistrationTokenViaAPI {
throw CoreError.configInvalid(
"no runner registration token configured: set gitea.registrationTokenFile "
+ "(or gitea.registrationToken), or set gitea.fetchRegistrationTokenViaAPI to true")
}
c.gitea.registrationToken = inlineReg
c.gitea.registrationTokenFile = fileReg.map(RunnerConfig.expandTilde)
// --- runner -----------------------------------------------------------
let labels = c.runner.labels.map { $0.trimmingCharacters(in: .whitespaces) }
guard !labels.isEmpty else {
throw CoreError.configInvalid("runner.labels must not be empty")
}
if labels.contains(where: \.isEmpty) {
throw CoreError.configInvalid("runner.labels contains an empty label name")
}
// Bare names only: the `:schema` suffix belongs on the `register
// --labels` argument, never in stored config, and Gitea reports bare
// names on jobs — so a configured "macos-arm64:host" would never match.
if let schemed = labels.first(where: { $0.contains(":") }) {
throw CoreError.configInvalid(
"runner.labels must contain bare names only, but \"\(schemed)\" carries a ':schema' suffix; "
+ "the schema is appended automatically at registration time")
}
c.runner.labels = labels
let prefix = c.runner.namePrefix.trimmingCharacters(in: .whitespaces)
guard !prefix.isEmpty else {
throw CoreError.configInvalid("runner.namePrefix must not be empty")
}
c.runner.namePrefix = prefix
guard !c.runner.version.trimmingCharacters(in: .whitespaces).isEmpty else {
throw CoreError.configInvalid("runner.version must not be empty")
}
c.runner.version = c.runner.version.trimmingCharacters(in: .whitespaces)
_ = try c.runner.resolvedDownloadURL
// --- scheduler --------------------------------------------------------
//
// Clamped rather than rejected: Apple's kernel caps concurrent macOS
// guests at two, and that is not a limit a config file gets to negotiate.
c.scheduler.maxConcurrentVMs = min(
max(c.scheduler.maxConcurrentVMs, 1),
SchedulerSection.hardMaxConcurrentVMs)
guard c.scheduler.pollIntervalSeconds > 0 else {
throw CoreError.configInvalid("scheduler.pollIntervalSeconds must be greater than 0")
}
guard c.scheduler.reconcileIntervalSeconds > 0 else {
throw CoreError.configInvalid("scheduler.reconcileIntervalSeconds must be greater than 0")
}
guard c.scheduler.jobTimeoutMinutes > 0 else {
throw CoreError.configInvalid("scheduler.jobTimeoutMinutes must be greater than 0")
}
guard c.scheduler.bootTimeoutSeconds > 0 else {
throw CoreError.configInvalid("scheduler.bootTimeoutSeconds must be greater than 0")
}
// --- guest ------------------------------------------------------------
guard !c.guest.username.trimmingCharacters(in: .whitespaces).isEmpty else {
throw CoreError.configInvalid("guest.username must not be empty")
}
// SSH password auth is the only channel into the guest, and an empty
// password would leave the boot hanging at authentication with no
// diagnostic worth reading.
guard !c.guest.password.isEmpty else {
throw CoreError.configInvalid("guest.password must not be empty")
}
guard c.guest.cpuCount >= 1 else {
throw CoreError.configInvalid("guest.cpuCount must be at least 1")
}
guard c.guest.memoryGB >= 1 else {
throw CoreError.configInvalid("guest.memoryGB must be at least 1")
}
guard c.guest.diskGB >= 1 else {
throw CoreError.configInvalid("guest.diskGB must be at least 1")
}
// --- storage ----------------------------------------------------------
let storeDir = c.storage.storeDir.trimmingCharacters(in: .whitespaces)
guard !storeDir.isEmpty else {
throw CoreError.configInvalid("storage.storeDir must not be empty")
}
c.storage.storeDir = RunnerConfig.expandTilde(storeDir)
guard c.storage.minFreeDiskGB >= 0 else {
throw CoreError.configInvalid("storage.minFreeDiskGB must not be negative")
}
return c
}
/// Trims a string and maps `""` to `nil`, so an empty JSON value reads as
/// "not configured" rather than as a zero-length token.
private static func nonEmpty(_ value: String?) -> String? {
guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
!trimmed.isEmpty
else { return nil }
return trimmed
}
/// The admin API token, resolved from ``GiteaSection/adminTokenFile`` (read
/// and trimmed) or ``GiteaSection/adminToken``.
///
/// On a configuration that has been through ``validated()`` exactly one of
/// those is set, so the file-first order here never actually chooses between
/// two live values.
///
/// - Returns: The token, or `nil` when neither source is configured.
public func resolveAdminToken() throws -> String? {
if let path = RunnerConfig.nonEmpty(gitea.adminTokenFile) {
return try RunnerConfig.readTokenFile(path, describedAs: "gitea.adminTokenFile")
}
return RunnerConfig.nonEmpty(gitea.adminToken)
}
/// The registration token from static configuration only — file first, then
/// inline value. Returns `nil` when the caller must fall back to the API
/// (see ``GiteaSection/fetchRegistrationTokenViaAPI``).
public func resolveStaticRegistrationToken() throws -> String? {
if let path = RunnerConfig.nonEmpty(gitea.registrationTokenFile) {
return try RunnerConfig.readTokenFile(path, describedAs: "gitea.registrationTokenFile")
}
return RunnerConfig.nonEmpty(gitea.registrationToken)
}
/// Reads a secret from a file: tilde-expanded, trimmed of surrounding
/// whitespace and newlines (an `echo`-written token file always has one).
///
/// - Throws: ``CoreError/configInvalid(_:)`` when the file is missing,
/// unreadable, not UTF-8, or empty once trimmed.
private static func readTokenFile(_ path: String, describedAs key: String) throws -> String {
let expanded = expandTilde(path)
guard FileManager.default.fileExists(atPath: expanded) else {
throw CoreError.configInvalid("\(key): no such file: \(expanded)")
}
let data: Data
do {
data = try Data(contentsOf: URL(fileURLWithPath: expanded))
} catch {
throw CoreError.configInvalid("\(key): cannot read \(expanded): \(error.localizedDescription)")
}
guard let text = String(data: data, encoding: .utf8) else {
throw CoreError.configInvalid("\(key): \(expanded) is not valid UTF-8")
}
let token = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !token.isEmpty else {
throw CoreError.configInvalid("\(key): \(expanded) is empty")
}
return token
}
/// Whether a token file is readable by users other than its owner.
///
/// Permissions are deliberately **not** enforced — refusing to start because
/// a file is `0644` would be a poor trade on a single-user CI Mac — but
/// `doctor` surfaces this as a warning.
///
/// - Parameter path: Path to check; tilde-expanded.
/// - Returns: `true` when group or other bits are set, `false` when the file
/// is owner-only, and `nil` when the mode cannot be read.
public static func tokenFileIsGroupOrWorldReadable(_ path: String) -> Bool? {
let expanded = expandTilde(path)
guard
let attrs = try? FileManager.default.attributesOfItem(atPath: expanded),
let mode = attrs[.posixPermissions] as? NSNumber
else { return nil }
return (mode.int16Value & 0o077) != 0
}
/// Paths of configured token files whose permissions are looser than `0600`.
/// Empty when everything is owner-only or nothing is file-backed.
public var insecureTokenFilePaths: [String] {
[gitea.adminTokenFile, gitea.registrationTokenFile]
.compactMap { RunnerConfig.nonEmpty($0) }
.filter { RunnerConfig.tokenFileIsGroupOrWorldReadable($0) == true }
}
/// ``StorageSection/storeDir`` with `~` expanded, as a `URL`.
public var storeDirectoryURL: URL {
URL(fileURLWithPath: RunnerConfig.expandTilde(storage.storeDir), isDirectory: true)
}
/// The label set used for job matching.
public var labelSet: LabelSet {
LabelSet(runner.labels)
}
// MARK: - Helpers
/// Expands a leading `~` or `~/` to the current user's home directory.
///
/// `NSString.expandingTildeInPath` is used rather than `FileManager`'s
/// deprecated home lookup so the behaviour matches the shell.
///
/// - Parameter path: A possibly tilde-prefixed path.
/// - Returns: An absolute path.
public static func expandTilde(_ path: String) -> String {
(path as NSString).expandingTildeInPath
}
// MARK: - Codable
private enum CodingKeys: String, CodingKey {
case gitea, runner, scheduler, guest, storage
}
/// Decodes a configuration, substituting section defaults for absent keys.
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.gitea = try c.decode(GiteaSection.self, forKey: .gitea)
self.runner = try c.decodeIfPresent(RunnerSection.self, forKey: .runner) ?? .init()
self.scheduler = try c.decodeIfPresent(SchedulerSection.self, forKey: .scheduler) ?? .init()
self.guest = try c.decodeIfPresent(GuestSection.self, forKey: .guest) ?? .init()
self.storage = try c.decodeIfPresent(StorageSection.self, forKey: .storage) ?? .init()
}
}
// MARK: - Tolerant section decoding
extension RunnerConfig.GiteaSection {
private enum CodingKeys: String, CodingKey {
case instanceURL, adminToken, adminTokenFile
case registrationToken, registrationTokenFile, fetchRegistrationTokenViaAPI
}
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.instanceURL = try c.decode(URL.self, forKey: .instanceURL)
self.adminToken = try c.decodeIfPresent(String.self, forKey: .adminToken)
self.adminTokenFile = try c.decodeIfPresent(String.self, forKey: .adminTokenFile)
self.registrationToken = try c.decodeIfPresent(String.self, forKey: .registrationToken)
self.registrationTokenFile = try c.decodeIfPresent(String.self, forKey: .registrationTokenFile)
self.fetchRegistrationTokenViaAPI =
try c.decodeIfPresent(Bool.self, forKey: .fetchRegistrationTokenViaAPI) ?? false
}
}
extension RunnerConfig.RunnerSection {
private enum CodingKeys: String, CodingKey {
case labels, namePrefix, runnerDownloadURL, version
}
public init(from decoder: Decoder) throws {
let d = RunnerConfig.RunnerSection()
let c = try decoder.container(keyedBy: CodingKeys.self)
self.labels = try c.decodeIfPresent([String].self, forKey: .labels) ?? d.labels
self.namePrefix = try c.decodeIfPresent(String.self, forKey: .namePrefix) ?? d.namePrefix
self.runnerDownloadURL =
try c.decodeIfPresent(String.self, forKey: .runnerDownloadURL) ?? d.runnerDownloadURL
self.version = try c.decodeIfPresent(String.self, forKey: .version) ?? d.version
}
}
extension RunnerConfig.SchedulerSection {
private enum CodingKeys: String, CodingKey {
case maxConcurrentVMs, pollIntervalSeconds, reconcileIntervalSeconds
case jobTimeoutMinutes, bootTimeoutSeconds
}
public init(from decoder: Decoder) throws {
let d = RunnerConfig.SchedulerSection()
let c = try decoder.container(keyedBy: CodingKeys.self)
self.maxConcurrentVMs =
try c.decodeIfPresent(Int.self, forKey: .maxConcurrentVMs) ?? d.maxConcurrentVMs
self.pollIntervalSeconds =
try c.decodeIfPresent(Int.self, forKey: .pollIntervalSeconds) ?? d.pollIntervalSeconds
self.reconcileIntervalSeconds =
try c.decodeIfPresent(Int.self, forKey: .reconcileIntervalSeconds) ?? d.reconcileIntervalSeconds
self.jobTimeoutMinutes =
try c.decodeIfPresent(Int.self, forKey: .jobTimeoutMinutes) ?? d.jobTimeoutMinutes
self.bootTimeoutSeconds =
try c.decodeIfPresent(Int.self, forKey: .bootTimeoutSeconds) ?? d.bootTimeoutSeconds
}
}
extension RunnerConfig.GuestSection {
private enum CodingKeys: String, CodingKey {
case username, password, cpuCount, memoryGB, diskGB
}
public init(from decoder: Decoder) throws {
let d = RunnerConfig.GuestSection()
let c = try decoder.container(keyedBy: CodingKeys.self)
self.username = try c.decodeIfPresent(String.self, forKey: .username) ?? d.username
self.password = try c.decodeIfPresent(String.self, forKey: .password) ?? d.password
self.cpuCount = try c.decodeIfPresent(Int.self, forKey: .cpuCount) ?? d.cpuCount
self.memoryGB = try c.decodeIfPresent(Int.self, forKey: .memoryGB) ?? d.memoryGB
self.diskGB = try c.decodeIfPresent(Int.self, forKey: .diskGB) ?? d.diskGB
}
}
extension RunnerConfig.StorageSection {
private enum CodingKeys: String, CodingKey {
case storeDir, minFreeDiskGB
}
public init(from decoder: Decoder) throws {
let d = RunnerConfig.StorageSection()
let c = try decoder.container(keyedBy: CodingKeys.self)
self.storeDir = try c.decodeIfPresent(String.self, forKey: .storeDir) ?? d.storeDir
self.minFreeDiskGB =
try c.decodeIfPresent(Int.self, forKey: .minFreeDiskGB) ?? d.minFreeDiskGB
}
}
+110
View File
@@ -0,0 +1,110 @@
import Foundation
/// The single error domain shared by every layer of the runner.
///
/// Host-side (`RunnerHost`) code wraps Virtualization.framework's `VZError` into
/// these cases rather than propagating it, so the CLI only ever has to render one
/// error type. `unimplemented` exists so that skeleton bodies can `throw` instead
/// of trapping in code paths where a trap would take down the daemon.
public enum CoreError: Error, Sendable {
/// A code path that has not been written yet.
case unimplemented
/// The on-disk configuration is missing, malformed, or internally inconsistent.
/// The payload is a human-readable explanation suitable for printing to stderr.
case configInvalid(String)
/// The Gitea API returned a non-2xx status.
/// - Parameters:
/// - status: The HTTP status code.
/// - message: The response body (truncated) or a decoded API error message.
case gitea(status: Int, message: String)
/// An SSH session could not be established, authenticated, or the remote
/// command exited non-zero when a zero exit was required.
case sshFailed(String)
/// A bounded wait elapsed. The payload names what was being waited on
/// (for example `"dhcp lease for aa:bb:cc:dd:ee:ff"` or `"ssh on 192.168.64.7"`).
case timeout(String)
/// A required external tool or file was absent (`diskutil`, an IPSW, the
/// `gitea-runner` release asset, …).
case notFound(String)
/// The host cannot run VMs: wrong architecture, unsupported macOS, missing
/// `com.apple.security.virtualization` entitlement, or a locked login keychain.
case hostUnsupported(String)
/// Apple's kernel-enforced limit of two concurrent macOS guests was hit.
/// Surfaced distinctly because it is transient and the scheduler retries.
case vmLimitExceeded
/// A VM bundle on disk is missing files or has an unreadable `config.json`.
case bundleCorrupt(String)
/// Not enough free space on the store volume to safely clone or grow a VM.
/// - Parameters:
/// - requiredGB: The configured floor.
/// - availableGB: What the volume actually has.
case insufficientDiskSpace(requiredGB: Int, availableGB: Int)
/// A subprocess (`diskutil`, `codesign`, `security`, …) exited non-zero.
case processFailed(command: String, exitCode: Int32, output: String)
/// The image build or provisioning pipeline failed at a named stage.
case provisioningFailed(String)
}
extension CoreError: CustomStringConvertible {
/// A one-line, user-facing rendering of the error.
public var description: String {
switch self {
case .unimplemented:
return "not implemented"
case .configInvalid(let detail):
return "invalid configuration: \(detail)"
case .gitea(let status, let message):
let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty
? "gitea API error (HTTP \(status))"
: "gitea API error (HTTP \(status)): \(trimmed)"
case .sshFailed(let detail):
return "ssh failed: \(detail)"
case .timeout(let what):
return "timed out waiting for \(what)"
case .notFound(let what):
return "not found: \(what)"
case .hostUnsupported(let detail):
return "host cannot run VMs: \(detail)"
case .vmLimitExceeded:
return "macOS guest limit reached (Apple allows at most 2 concurrent VMs per host)"
case .bundleCorrupt(let detail):
return "VM bundle is corrupt: \(detail)"
case .insufficientDiskSpace(let requiredGB, let availableGB):
return "insufficient disk space: need \(requiredGB) GB free, have \(availableGB) GB"
case .processFailed(let command, let exitCode, let output):
let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty
? "`\(command)` exited \(exitCode)"
: "`\(command)` exited \(exitCode): \(trimmed)"
case .provisioningFailed(let stage):
return "provisioning failed: \(stage)"
}
}
}
extension CoreError: LocalizedError {
public var errorDescription: String? { description }
}
+246
View File
@@ -0,0 +1,246 @@
import Foundation
/// One entry from macOS's `/var/db/dhcpd_leases`.
///
/// The Virtualization NAT attachment hands guests addresses from the host's
/// built-in `bootpd`, which records each lease in that file. There is no API for
/// this, so parsing the file keyed by the guest's MAC is how we learn a VM's IP.
public struct DHCPLease: Sendable, Equatable {
/// The guest's advertised hostname (`name=` in the lease block). Often the
/// guest's local hostname, sometimes absent.
public let name: String?
/// The leased IPv4 address, e.g. `192.168.64.7`.
public let ipAddress: String
/// The hardware address, **normalized**: lowercase, colon-separated, each
/// octet zero-padded to two hex digits, with the `1,` type prefix stripped.
public let hwAddress: String
/// Lease expiry, parsed from the `lease=` hex epoch, when present.
public let leaseExpiry: Date?
public init(name: String?, ipAddress: String, hwAddress: String, leaseExpiry: Date?) {
self.name = name
self.ipAddress = ipAddress
self.hwAddress = hwAddress
self.leaseExpiry = leaseExpiry
}
}
/// Parser for `/var/db/dhcpd_leases`.
///
/// ## File format
///
/// A sequence of brace-delimited blocks of `key=value` lines:
///
/// ```
/// {
/// name=macos-guest
/// ip_address=192.168.64.7
/// hw_address=1,aa:bb:c:dd:ee:ff
/// identifier=1,aa:bb:c:dd:ee:ff
/// lease=0x67a1b2c3
/// }
/// ```
///
/// Two details bite:
///
/// 1. `hw_address` carries a leading hardware-type prefix (`1,` for Ethernet)
/// that is not part of the MAC.
/// 2. Octets are **not zero-padded** — `aa:bb:c:dd:ee:ff` is the same address
/// that `VZMACAddress.string` renders as `aa:bb:0c:dd:ee:ff`. Comparing raw
/// strings silently fails to match; both sides must be normalized.
///
/// Blocks accumulate: a MAC can appear more than once as leases are renewed or
/// reissued, so lookups take the **newest** lease (latest `leaseExpiry`, falling
/// back to last-in-file when expiry is missing).
///
/// - Note: macOS's DHCP lease time is 24 hours. That is exactly why clones must
/// reuse a small set of **persistent per-slot MACs** rather than randomizing a
/// MAC per VM: a randomized fleet would fill this file with day-long stale
/// leases and exhaust the NAT subnet.
public enum DHCPLeaseParser {
/// The canonical path of the lease database.
public static let defaultPath = "/var/db/dhcpd_leases"
/// Parses the whole file.
///
/// Malformed blocks are skipped rather than throwing — the file is written
/// by another process and may be observed mid-write.
///
/// - Parameter text: The file's contents.
/// - Returns: Leases in file order.
public static func parse(_ text: String) -> [DHCPLease] {
var leases: [DHCPLease] = []
var fields: [String: String] = [:]
var inBlock = false
for rawLine in text.split(separator: "\n", omittingEmptySubsequences: false) {
let line = rawLine.trimmingCharacters(in: .whitespaces)
if line.isEmpty { continue }
if line.hasPrefix("{") {
// A `{` while already inside a block means the previous one was
// truncated (the file is written by bootpd and can be observed
// mid-write). Drop it and start over rather than merging.
inBlock = true
fields = [:]
continue
}
if line.hasPrefix("}") {
if inBlock, let lease = makeLease(from: fields) { leases.append(lease) }
inBlock = false
fields = [:]
continue
}
guard inBlock, let separator = line.firstIndex(of: "=") else { continue }
let key = line[line.startIndex..<separator].trimmingCharacters(in: .whitespaces).lowercased()
let value = line[line.index(after: separator)...].trimmingCharacters(in: .whitespaces)
if key.isEmpty { continue }
fields[key] = value
}
return leases
}
/// Builds a lease from one block's `key=value` pairs, or `nil` when the block
/// lacks the two fields that make it useful (an address and a MAC we can
/// normalize). Never throws: a half-written block is simply not a lease.
private static func makeLease(from fields: [String: String]) -> DHCPLease? {
guard
let ip = fields["ip_address"], !ip.isEmpty,
let rawMAC = fields["hw_address"] ?? fields["identifier"],
let mac = normalizeMAC(rawMAC)
else { return nil }
let name = fields["name"].flatMap { $0.isEmpty ? nil : $0 }
return DHCPLease(
name: name,
ipAddress: ip,
hwAddress: mac,
leaseExpiry: fields["lease"].flatMap(parseLeaseTime)
)
}
/// Parses a `lease=` value. `bootpd` writes a hex epoch (`0x66b2c0de`), but
/// a plain decimal epoch has been observed too, so both are accepted.
private static func parseLeaseTime(_ raw: String) -> Date? {
let text = raw.trimmingCharacters(in: .whitespaces).lowercased()
guard !text.isEmpty else { return nil }
let seconds: UInt64?
if text.hasPrefix("0x") {
seconds = UInt64(text.dropFirst(2), radix: 16)
} else {
seconds = UInt64(text, radix: 10)
}
guard let seconds else { return nil }
return Date(timeIntervalSince1970: TimeInterval(seconds))
}
/// Reads and parses the lease database from disk.
///
/// - Parameter path: Defaults to ``defaultPath``.
/// - Returns: Leases, or `[]` when the file does not exist yet (no guest has
/// ever leased an address).
public static func parseFile(at path: String = DHCPLeaseParser.defaultPath) -> [DHCPLease] {
guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return [] }
return parse(text)
}
/// Finds the current IP for a MAC.
///
/// Both `mac` and each lease's `hwAddress` are normalized before comparison.
///
/// - Parameters:
/// - mac: The guest's MAC, in any common rendering.
/// - leases: Leases from ``parse(_:)``.
/// - Returns: The newest matching lease's IP, or `nil`.
public static func ipAddress(forMAC mac: String, in leases: [DHCPLease]) -> String? {
lease(forMAC: mac, in: leases)?.ipAddress
}
/// Finds the newest lease for a MAC.
///
/// Callers that must distinguish a *fresh* lease from the 24 h-old one the
/// slot's previous guest left behind need the whole record, not just its
/// address — see ``isNewer(_:than:)``.
///
/// - Parameters:
/// - mac: The guest's MAC, in any common rendering.
/// - leases: Leases from ``parse(_:)``.
/// - Returns: The newest matching lease, or `nil`.
public static func lease(forMAC mac: String, in leases: [DHCPLease]) -> DHCPLease? {
guard let wanted = normalizeMAC(mac) else { return nil }
var best: DHCPLease?
for lease in leases where lease.hwAddress == wanted {
guard let current = best else {
best = lease
continue
}
// Newest expiry wins; a missing expiry sorts oldest. `>=` means that
// among equally-dated (or equally-undated) duplicates the last block
// in the file wins, which is the one bootpd wrote most recently.
let candidate = lease.leaseExpiry ?? .distantPast
let incumbent = current.leaseExpiry ?? .distantPast
if candidate >= incumbent { best = lease }
}
return best
}
/// Whether `candidate` is a lease `bootpd` wrote *after* `previous`.
///
/// Slot MACs are persistent and macOS leases live 24 h, so a MAC almost
/// always still has its previous guest's entry when the next clone boots.
/// A caller that accepted the first entry it saw would hand out a stale
/// address and then spend the whole boot timeout SSHing at nothing.
///
/// `bootpd` rewrites the block — bumping `lease=` — whenever it hands the
/// address out again, so a strictly later expiry means a new lease. A
/// changed address means the same thing. With no `previous` (first boot on
/// this MAC) anything counts as new.
///
/// - Parameters:
/// - candidate: The lease just read from the file.
/// - previous: The lease observed before the guest was started.
/// - Returns: `true` when `candidate` may be used.
public static func isNewer(_ candidate: DHCPLease, than previous: DHCPLease?) -> Bool {
guard let previous else { return true }
if candidate.ipAddress != previous.ipAddress { return true }
guard let previousExpiry = previous.leaseExpiry else { return true }
guard let candidateExpiry = candidate.leaseExpiry else { return false }
return candidateExpiry > previousExpiry
}
/// Normalizes a MAC to lowercase, colon-separated, zero-padded octets.
///
/// Accepts an optional `<type>,` prefix (as written by `bootpd`), and
/// tolerates `-` separators.
///
/// - Parameter raw: For example `1,aa:bb:c:dd:ee:ff` or `AA-BB-0C-DD-EE-FF`.
/// - Returns: For example `aa:bb:0c:dd:ee:ff`, or `nil` if unparseable.
public static func normalizeMAC(_ raw: String) -> String? {
var text = raw.trimmingCharacters(in: .whitespaces)
// `bootpd` prefixes the hardware type: `1,` for Ethernet.
if let comma = text.lastIndex(of: ",") {
text = String(text[text.index(after: comma)...])
}
text = text.replacingOccurrences(of: "-", with: ":")
let octets = text.split(separator: ":", omittingEmptySubsequences: false)
guard octets.count == 6 else { return nil }
var normalized: [String] = []
normalized.reserveCapacity(6)
for octet in octets {
guard (1...2).contains(octet.count), octet.allSatisfy(\.isHexDigit) else { return nil }
normalized.append(String(repeating: "0", count: 2 - octet.count) + octet.lowercased())
}
return normalized.joined(separator: ":")
}
}
+357
View File
@@ -0,0 +1,357 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
/// The HTTP seam under ``GiteaClient``.
///
/// Everything network-facing goes through this protocol so tests can supply a
/// canned transport without a live Gitea instance.
public protocol HTTPTransport: Sendable {
/// Performs a request.
///
/// - Parameter request: A fully-formed request, including auth headers.
/// - Returns: The response body and its HTTP status code.
/// - Throws: Transport-level errors only; a non-2xx status is *not* an error
/// here — ``GiteaClient`` maps that to ``CoreError/gitea(status:message:)``.
func send(_ request: URLRequest) async throws -> (Data, Int)
}
/// The production transport, backed by `URLSession`.
public struct URLSessionTransport: HTTPTransport {
/// The underlying session.
public let session: URLSession
/// Creates a transport.
///
/// - Parameter session: Defaults to an ephemeral session with a 30 s request
/// timeout, so a hung Gitea cannot stall the poll loop.
public init(session: URLSession = URLSessionTransport.makeDefaultSession()) {
self.session = session
}
/// Builds the default ephemeral session.
public static func makeDefaultSession() -> URLSession {
let cfg = URLSessionConfiguration.ephemeral
cfg.timeoutIntervalForRequest = 30
cfg.timeoutIntervalForResource = 60
return URLSession(configuration: cfg)
}
public func send(_ request: URLRequest) async throws -> (Data, Int) {
// `dataTask` + a continuation rather than `session.data(for:)`, because
// the async URLSession API is not uniformly available in
// swift-corelibs-foundation, and RunnerCore must build on Linux.
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<(Data, Int), Error>) in
let task = session.dataTask(with: request) { data, response, error in
if let error {
continuation.resume(throwing: error)
return
}
guard let http = response as? HTTPURLResponse else {
continuation.resume(
throwing: CoreError.gitea(status: 0, message: "no HTTP response"))
return
}
continuation.resume(returning: (data ?? Data(), http.statusCode))
}
task.resume()
}
}
}
/// A thin, typed client for the subset of Gitea's admin Actions API this daemon
/// needs.
///
/// All endpoints used here are **admin**-scoped, so the token must belong to a
/// Gitea administrator. `doctor` verifies that by calling ``listRunners()``.
public struct GiteaClient: Sendable {
/// Instance base URL, e.g. `https://gitea.example.com`.
public let baseURL: URL
/// Admin API token, sent as `Authorization: token <value>`.
public let token: String
/// The HTTP seam.
public let transport: any HTTPTransport
/// Creates a client.
///
/// - Parameters:
/// - baseURL: Instance base URL; a trailing slash is tolerated.
/// - token: Admin API token.
/// - transport: Defaults to ``URLSessionTransport``.
public init(baseURL: URL, token: String, transport: any HTTPTransport = URLSessionTransport()) {
self.baseURL = baseURL
self.token = token
self.transport = transport
}
// MARK: - Endpoints
/// Lists jobs currently waiting for a runner.
///
/// `GET /api/v1/admin/actions/jobs?status=queued&limit=<limit>`
///
/// A queued job stays queued until a matching runner claims it, or until
/// Gitea's `ABANDONED_JOB_TIMEOUT` (default 24 h, swept every 6 h) expires
/// it. There is therefore no urgency risk in a 5-second poll.
///
/// - Parameter limit: Page size. The scheduler only ever needs a handful.
/// - Returns: The queued jobs, oldest-first as Gitea returns them.
/// - Throws: ``CoreError/gitea(status:message:)`` on a non-2xx response.
public func listQueuedJobs(limit: Int = 50) async throws -> [WorkflowJob] {
// `status=queued` and nothing else. Gitea's `convertToInternal` maps
// "queued" onto StatusWaiting ("ready, waiting for a runner") and maps
// "waiting" onto StatusBlocked ("blocked on a dependency") — so asking
// for "waiting" would return exactly the jobs that must not be booted.
let request = try makeRequest(
method: "GET",
path: "/api/v1/admin/actions/jobs",
query: [
URLQueryItem(name: "status", value: "queued"),
URLQueryItem(name: "limit", value: String(max(limit, 1))),
])
let response = try await send(request, as: WorkflowJobsResponse.self)
return response.items
}
/// Lists every registered runner on the instance.
///
/// `GET /api/v1/admin/actions/runners?page=<n>&limit=<limit>`
///
/// Used by the reconcile loop and by `doctor` (as an admin-scope probe).
///
/// Paginated deliberately: an unpaginated request returns only Gitea's
/// default first page, and reconcile is precisely the thing that stops an
/// instance from accumulating orphan rows. Missing rows past the page
/// boundary would let the leak accelerate — each undeleted row pushes more
/// rows out of view — and it would silently no-op the targeted cleanup that
/// looks a single runner up by name.
///
/// - Parameters:
/// - limit: Page size.
/// - maxPages: Defensive ceiling, so a server that ignores `page` cannot
/// spin this forever.
/// - Returns: Every runner across all fetched pages, in server order.
public func listRunners(limit: Int = 50, maxPages: Int = 50) async throws -> [ActionRunner] {
let pageSize = max(limit, 1)
var all: [ActionRunner] = []
for page in 1...max(maxPages, 1) {
let request = try makeRequest(
method: "GET",
path: "/api/v1/admin/actions/runners",
query: [
URLQueryItem(name: "page", value: String(page)),
URLQueryItem(name: "limit", value: String(pageSize)),
])
let response = try await send(request, as: RunnersResponse.self)
all.append(contentsOf: response.items)
// Terminate on the server's own total rather than on a short page:
// Gitea clamps `limit` to its configured maximum, so a page shorter
// than the one we asked for is not evidence that it is the last.
if response.items.isEmpty { break }
if let total = response.totalCount, all.count >= total { break }
}
return all
}
/// Deletes a runner row.
///
/// `DELETE /api/v1/admin/actions/runners/{id}`
///
/// Needed because a VM that dies uncleanly leaves its row behind: Gitea only
/// sweeps runner rows at midnight, and never sweeps a runner that claimed no
/// task. A 404 is treated as success (someone else already removed it).
///
/// - Parameter id: The runner id.
public func deleteRunner(id: Int64) async throws {
let request = try makeRequest(
method: "DELETE",
path: "/api/v1/admin/actions/runners/\(id)")
// Gitea answers 204. 200 is accepted for tolerance, and 404 counts as
// success: the reconcile loop's only goal is that the row be gone, and
// it races with Gitea's own midnight sweep and with `--ephemeral`
// auto-deregistration.
try await sendIgnoringBody(request, acceptingStatuses: [200, 202, 204, 404])
}
/// Returns the instance-scoped runner registration token.
///
/// `POST /api/v1/admin/actions/runners/registration-token`
///
/// - Warning: In current Gitea this returns the *existing* active token for
/// the scope rather than minting a new one — but the semantics of "mint"
/// are that a new token **invalidates all prior tokens of that scope**.
/// Never call this per VM as a way of getting a throwaway secret; call it
/// once and cache. Prefer seeding the token server-side via
/// `GITEA_RUNNER_REGISTRATION_TOKEN` and configuring it statically.
public func getRegistrationToken() async throws -> String {
let request = try makeRequest(
method: "POST",
path: "/api/v1/admin/actions/runners/registration-token")
let response = try await send(request, as: RegistrationTokenResponse.self)
let token = response.token.trimmingCharacters(in: .whitespacesAndNewlines)
guard !token.isEmpty else {
throw CoreError.gitea(status: 200, message: "registration-token response carried an empty token")
}
return token
}
/// Cheap reachability + auth probe used by `doctor`.
///
/// - Throws: ``CoreError/gitea(status:message:)`` when the instance is
/// reachable but rejects the token.
public func ping() async throws {
// The runners list rather than /api/v1/version: version is anonymously
// readable on most instances, so it would report "reachable" for a token
// that is expired, wrong, or simply not an admin's — which is the exact
// failure `doctor` exists to catch.
_ = try await listRunners()
}
// MARK: - Request plumbing
/// Builds an authenticated request against an API path.
///
/// - Parameters:
/// - method: HTTP method.
/// - path: API path relative to the instance root, e.g.
/// `/api/v1/admin/actions/runners`.
/// - query: Optional query items.
/// - body: Optional request body; sets `Content-Type: application/json`.
/// - Returns: A request carrying `Authorization` and `Accept` headers.
public func makeRequest(
method: String,
path: String,
query: [URLQueryItem] = [],
body: Data? = nil
) throws -> URLRequest {
// Built by string-joining rather than `URL(string:relativeTo:)`, which
// would discard any path component of `baseURL` — instances served under
// a subpath (https://example.com/gitea) are common enough to matter.
var base = baseURL.absoluteString
while base.hasSuffix("/") { base.removeLast() }
let suffix = path.hasPrefix("/") ? path : "/" + path
guard var components = URLComponents(string: base + suffix) else {
throw CoreError.configInvalid("cannot form a request URL from \(base + suffix)")
}
if !query.isEmpty {
components.queryItems = query
}
guard let url = components.url else {
throw CoreError.configInvalid("cannot form a request URL from \(base + suffix)")
}
var request = URLRequest(url: url)
request.httpMethod = method
// Gitea's PAT scheme. `Bearer` also works on recent versions, but
// `token` is the documented form and works on every 1.x.
request.setValue("token \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue(
"gitea-macos-runner/\(RunnerVersion.current)", forHTTPHeaderField: "User-Agent")
if let body {
request.httpBody = body
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
return request
}
/// Sends a request and decodes a JSON body, mapping non-2xx to
/// ``CoreError/gitea(status:message:)``.
public func send<T: Decodable>(_ request: URLRequest, as type: T.Type) async throws -> T {
let (data, status) = try await transport.send(request)
guard (200..<300).contains(status) else {
throw CoreError.gitea(status: status, message: GiteaClient.errorMessage(from: data))
}
do {
return try GiteaClient.makeDecoder().decode(T.self, from: data)
} catch {
throw CoreError.gitea(
status: status,
message: "could not decode \(T.self): \(error) — body: \(GiteaClient.excerpt(data))")
}
}
/// Sends a request that is expected to have no useful body.
public func sendIgnoringBody(_ request: URLRequest, acceptingStatuses: Set<Int>) async throws {
let (data, status) = try await transport.send(request)
guard acceptingStatuses.contains(status) || (200..<300).contains(status) else {
throw CoreError.gitea(status: status, message: GiteaClient.errorMessage(from: data))
}
}
/// Gitea's error bodies are `{"message": "...", "url": "..."}`. Prefer that
/// message; fall back to a truncated raw body so nothing is ever reported as
/// an empty error.
private static func errorMessage(from data: Data) -> String {
struct APIError: Decodable {
let message: String?
let errors: [String]?
}
if let decoded = try? JSONDecoder().decode(APIError.self, from: data) {
if let message = decoded.message?.trimmingCharacters(in: .whitespacesAndNewlines),
!message.isEmpty
{
return message
}
if let errors = decoded.errors, !errors.isEmpty {
return errors.joined(separator: "; ")
}
}
return excerpt(data)
}
/// At most `limit` characters of a response body, for error messages.
private static func excerpt(_ data: Data, limit: Int = 512) -> String {
guard !data.isEmpty else { return "<empty body>" }
let text = String(decoding: data, as: UTF8.self)
.trimmingCharacters(in: .whitespacesAndNewlines)
guard text.count > limit else { return text }
return String(text.prefix(limit)) + "… (\(data.count) bytes)"
}
/// A `JSONDecoder` configured for Gitea's timestamps (RFC 3339 / ISO 8601
/// with an offset).
///
/// Go's `time.Time` marshals as RFC 3339 **Nano**: the fractional-seconds
/// part is present only when non-zero, so a single strict formatter fails
/// intermittently on real traffic. Both spellings are tried, plus a plain
/// `YYYY-MM-DD` for good measure.
public static func makeDecoder() -> JSONDecoder {
let d = JSONDecoder()
d.dateDecodingStrategy = .custom { decoder in
let raw = try decoder.singleValueContainer().decode(String.self)
if let date = parseTimestamp(raw) { return date }
throw DecodingError.dataCorrupted(
DecodingError.Context(
codingPath: decoder.codingPath,
debugDescription: "not an RFC 3339 timestamp: \"\(raw)\""))
}
return d
}
/// Parses an RFC 3339 timestamp with or without fractional seconds.
///
/// - Parameter raw: The timestamp string.
/// - Returns: The instant, or `nil` if it is in no recognized form.
public static func parseTimestamp(_ raw: String) -> Date? {
// Formatters are built per call rather than cached in a `static let`:
// `ISO8601DateFormatter` is a non-Sendable reference type, and this is
// called a handful of times per poll — not a hot path.
let withFractional = ISO8601DateFormatter()
withFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = withFractional.date(from: raw) { return date }
let plain = ISO8601DateFormatter()
plain.formatOptions = [.withInternetDateTime]
if let date = plain.date(from: raw) { return date }
let dateOnly = ISO8601DateFormatter()
dateOnly.formatOptions = [.withFullDate]
return dateOnly.date(from: raw)
}
}
+330
View File
@@ -0,0 +1,330 @@
import Foundation
/// A single job within a workflow run, as reported by
/// `GET /api/v1/admin/actions/jobs` (Gitea 1.25+).
///
/// - Note: `status` values are the *external* strings. `queued` is the one we
/// act on: it maps to Gitea's internal `StatusWaiting`, meaning "ready and
/// waiting for a matching runner". The string `waiting` means something quite
/// different — the job is **blocked** on a dependency — and must never be
/// treated as schedulable.
public struct WorkflowJob: Codable, Sendable, Equatable, Identifiable {
/// Job id. Unique across the instance; the scheduler dedups on this.
public let id: Int64
/// The workflow run this job belongs to.
public let runID: Int64
/// The job's display name.
public let name: String
/// One of `queued`, `waiting`, `running`, `success`, `failure`, `cancelled`,
/// `skipped`, `blocked`.
public let status: String
/// The job's `runs-on:` values, as **bare** label names.
public let labels: [String]
/// The runner that claimed the job, if any.
public let runnerID: Int64?
/// That runner's name, if any. Lets the reconcile loop tie a Gitea runner
/// row back to one of our VMs.
public let runnerName: String?
/// When the job was created.
public let createdAt: Date?
/// When a runner picked it up.
public let startedAt: Date?
/// When it finished.
public let completedAt: Date?
public init(
id: Int64,
runID: Int64,
name: String,
status: String,
labels: [String],
runnerID: Int64? = nil,
runnerName: String? = nil,
createdAt: Date? = nil,
startedAt: Date? = nil,
completedAt: Date? = nil
) {
self.id = id
self.runID = runID
self.name = name
self.status = status
self.labels = labels
self.runnerID = runnerID
self.runnerName = runnerName
self.createdAt = createdAt
self.startedAt = startedAt
self.completedAt = completedAt
}
private enum CodingKeys: String, CodingKey {
case id
case runID = "run_id"
case name
case status
case labels
case runnerID = "runner_id"
case runnerName = "runner_name"
case createdAt = "created_at"
case startedAt = "started_at"
case completedAt = "completed_at"
}
/// Whether this job is waiting for a runner right now.
public var isQueued: Bool { status == "queued" }
/// ``status`` as a case, with an ``JobStatus/unknown(_:)`` catch-all.
public var jobStatus: JobStatus { JobStatus(rawValue: status) }
/// Decodes tolerantly: `runner_id` / `runner_name` carry `omitempty` in
/// Gitea, and the timestamps are Go `time.Time` values that serialize as
/// `0001-01-01T00:00:00Z` when unset (a queued job has no `started_at`).
/// Those zero instants are surfaced as `nil` rather than as a year-1 date.
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.id = try c.decode(Int64.self, forKey: .id)
self.runID = try c.decodeIfPresent(Int64.self, forKey: .runID) ?? 0
self.name = try c.decodeIfPresent(String.self, forKey: .name) ?? ""
self.status = try c.decodeIfPresent(String.self, forKey: .status) ?? ""
self.labels = try c.decodeIfPresent([String].self, forKey: .labels) ?? []
self.runnerID = try c.decodeIfPresent(Int64.self, forKey: .runnerID)
self.runnerName = try c.decodeIfPresent(String.self, forKey: .runnerName)
self.createdAt = WorkflowJob.nonZero(try c.decodeIfPresent(Date.self, forKey: .createdAt))
self.startedAt = WorkflowJob.nonZero(try c.decodeIfPresent(Date.self, forKey: .startedAt))
self.completedAt = WorkflowJob.nonZero(try c.decodeIfPresent(Date.self, forKey: .completedAt))
}
/// Maps Go's zero `time.Time` (year 1) to `nil`.
private static func nonZero(_ date: Date?) -> Date? {
guard let date else { return nil }
// 0001-01-01T00:00:00Z is ~62.1e9 seconds before the reference date.
return date.timeIntervalSinceReferenceDate <= -62_135_596_800 ? nil : date
}
}
/// The *external* status strings Gitea reports for a workflow job.
///
/// Gitea maps its internal statuses onto GitHub's vocabulary in
/// `convert.ToActionsStatus`: `StatusWaiting → "queued"`,
/// `StatusBlocked → "waiting"`, `StatusRunning → "in_progress"`, and every
/// terminal status → `"completed"` (the detail moves to a separate `conclusion`
/// field). The `unknown` case exists because that mapping is Gitea's to change:
/// a closed enum that threw on an unrecognized string would turn a new server
/// version into a decode failure and stop the poll loop dead.
public enum JobStatus: RawRepresentable, Sendable, Equatable, Hashable {
/// Ready and waiting for a matching runner — Gitea's internal `StatusWaiting`.
/// This is the only status that is schedulable.
case queued
/// **Blocked** on a dependency — Gitea's internal `StatusBlocked`. Despite
/// the name, this is *not* a job waiting for a runner.
case waiting
/// Claimed by a runner and executing.
case inProgress
/// Terminal, in any of success / failure / cancelled / skipped.
case completed
/// A status string this build does not know about.
case unknown(String)
public init(rawValue: String) {
switch rawValue {
case "queued": self = .queued
case "waiting": self = .waiting
case "in_progress": self = .inProgress
case "completed": self = .completed
default: self = .unknown(rawValue)
}
}
public var rawValue: String {
switch self {
case .queued: return "queued"
case .waiting: return "waiting"
case .inProgress: return "in_progress"
case .completed: return "completed"
case .unknown(let raw): return raw
}
}
}
/// Envelope returned by `GET /api/v1/admin/actions/jobs`.
///
/// - Important: The array key has been observed as `jobs`, which is what is
/// decoded here; some Gitea builds/OpenAPI revisions have used `workflow_jobs`
/// for the equivalent repo-scoped endpoint. ``CodingKeys`` is written out
/// explicitly so that adding a fallback is a one-line change, and
/// ``jobs`` is optional so an empty response body decodes rather than throwing.
public struct WorkflowJobsResponse: Codable, Sendable, Equatable {
/// Total matching jobs server-side, ignoring `limit`.
public let totalCount: Int?
/// The page of jobs. `nil` and `[]` both mean "nothing queued".
public let jobs: [WorkflowJob]?
public init(totalCount: Int?, jobs: [WorkflowJob]?) {
self.totalCount = totalCount
self.jobs = jobs
}
private enum CodingKeys: String, CodingKey {
case totalCount = "total_count"
case jobs
case workflowJobs = "workflow_jobs"
case entries
}
/// The jobs, never `nil`.
public var items: [WorkflowJob] { jobs ?? [] }
/// Decodes the array under `jobs`, falling back to `workflow_jobs` and
/// `entries`.
///
/// Gitea 1.25's `ActionWorkflowJobsResponse` tags its slice `json:"jobs"`
/// (the Go field is named `Entries`), which is what the fallbacks guard
/// against: a future rename of the tag, or a proxy that reserializes from
/// the Go field name.
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.totalCount = try c.decodeIfPresent(Int.self, forKey: .totalCount)
if let jobs = try c.decodeIfPresent([WorkflowJob].self, forKey: .jobs) {
self.jobs = jobs
} else if let jobs = try c.decodeIfPresent([WorkflowJob].self, forKey: .workflowJobs) {
self.jobs = jobs
} else {
self.jobs = try c.decodeIfPresent([WorkflowJob].self, forKey: .entries)
}
}
public func encode(to encoder: Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encodeIfPresent(totalCount, forKey: .totalCount)
try c.encodeIfPresent(jobs, forKey: .jobs)
}
}
/// A registered Actions runner, from `GET /api/v1/admin/actions/runners`.
public struct ActionRunner: Codable, Sendable, Equatable, Identifiable {
/// Runner id, used for `DELETE /api/v1/admin/actions/runners/{id}`.
public let id: Int64
/// Runner name. Ours always start with the configured `namePrefix`.
public let name: String
/// Bare label names the runner advertises.
public let labels: [String]
/// Server-side liveness, e.g. `online` / `offline`.
public let status: String?
/// Whether the runner is currently executing a task.
public let busy: Bool?
/// Whether the runner registered with `--ephemeral`, i.e. the server will
/// hand it exactly one task and then auto-deregister it (Gitea 1.24+).
public let ephemeral: Bool?
public init(
id: Int64,
name: String,
labels: [String],
status: String? = nil,
busy: Bool? = nil,
ephemeral: Bool? = nil
) {
self.id = id
self.name = name
self.labels = labels
self.status = status
self.busy = busy
self.ephemeral = ephemeral
}
private enum CodingKeys: String, CodingKey {
case id, name, labels, status, busy, ephemeral
}
/// A single entry of `ActionRunner.labels` as Gitea actually serializes it:
/// an object, not a string.
///
/// Verified against `modules/structs/repo_actions.go` at tag `v1.25.0`,
/// where `ActionRunner.Labels` is `[]*ActionRunnerLabel` and
/// `ActionRunnerLabel` is `{id int64, name string, type string}`. Only
/// ``name`` is of any use here.
private struct LabelObject: Decodable {
let name: String?
}
/// Decodes `labels` from either shape.
///
/// Gitea's job payload gives labels as plain strings, its runner payload
/// gives them as objects. Both are accepted so that this one model keeps
/// working if a version, a proxy, or a hand-written fixture disagrees.
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.id = try c.decode(Int64.self, forKey: .id)
self.name = try c.decodeIfPresent(String.self, forKey: .name) ?? ""
self.status = try c.decodeIfPresent(String.self, forKey: .status)
self.busy = try c.decodeIfPresent(Bool.self, forKey: .busy)
self.ephemeral = try c.decodeIfPresent(Bool.self, forKey: .ephemeral)
if let strings = try? c.decode([String].self, forKey: .labels) {
self.labels = strings
} else if let objects = try? c.decode([LabelObject].self, forKey: .labels) {
self.labels = objects.compactMap(\.name)
} else {
// Absent or explicitly null. Gitea does emit `"labels": null` for a
// runner registered without any.
self.labels = []
}
}
/// `busy` treated as `false` when the server omits it.
public var isBusy: Bool { busy ?? false }
/// `ephemeral` treated as `false` when the server omits it. The reconcile
/// loop only ever deletes rows it is sure are ephemeral.
public var isEphemeral: Bool { ephemeral ?? false }
}
/// Envelope returned by `GET /api/v1/admin/actions/runners`.
public struct RunnersResponse: Codable, Sendable, Equatable {
public let totalCount: Int?
public let runners: [ActionRunner]?
public init(totalCount: Int?, runners: [ActionRunner]?) {
self.totalCount = totalCount
self.runners = runners
}
private enum CodingKeys: String, CodingKey {
case totalCount = "total_count"
case runners
case entries
}
/// The runners, never `nil`.
public var items: [ActionRunner] { runners ?? [] }
/// Decodes the array under `runners` (Gitea 1.25's tag), falling back to
/// `entries` (the Go field name).
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.totalCount = try c.decodeIfPresent(Int.self, forKey: .totalCount)
if let runners = try c.decodeIfPresent([ActionRunner].self, forKey: .runners) {
self.runners = runners
} else {
self.runners = try c.decodeIfPresent([ActionRunner].self, forKey: .entries)
}
}
public func encode(to encoder: Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encodeIfPresent(totalCount, forKey: .totalCount)
try c.encodeIfPresent(runners, forKey: .runners)
}
}
/// Response from `POST /api/v1/admin/actions/runners/registration-token`.
///
/// - Warning: This is scope-wide and **reusable**. Treat the returned value as
/// "the current token for this scope", not as a freshly minted per-VM secret.
public struct RegistrationTokenResponse: Codable, Sendable, Equatable {
/// The registration token.
public let token: String
public init(token: String) {
self.token = token
}
}
+84
View File
@@ -0,0 +1,84 @@
import Foundation
/// The set of labels this host's runners advertise, used to decide whether a
/// queued Gitea job is ours to pick up.
///
/// ## Bare names only
///
/// Gitea's label syntax at *registration* time is `name:schema` (for example
/// `macos-arm64:host`), where the schema defaults to `host` when omitted. The
/// schema is a runner-side execution hint — it tells `gitea-runner` to run the
/// job directly on the machine instead of inside a container. **The server only
/// ever stores and reports the bare name.** A workflow's `runs-on:` value, and
/// therefore the `labels` array on a queued job, likewise contains bare names.
///
/// So: pass `macos-arm64:host` to `gitea-runner register --labels`, but match
/// against `macos-arm64` here. Matching is case-sensitive, because Gitea's own
/// comparison is.
///
/// - Warning: If the guest's `.runner`/`config.yaml` sets `runner.labels`, it
/// silently overrides whatever `--labels` was passed at registration. The
/// guest must therefore never ship a config file containing labels.
public struct LabelSet: Sendable, Equatable, Hashable {
/// The bare label names this host serves, e.g. `["macos-arm64", "macos"]`.
public let names: Set<String>
/// Creates a label set from bare names.
///
/// Any `:schema` suffix present in `names` is stripped, so it is safe to
/// hand this the same array that is written into the config file.
///
/// - Parameter names: Label names, with or without a `:schema` suffix.
public init(_ names: [String]) {
self.names = Set(
names
.map(LabelSet.bareName)
.filter { !$0.isEmpty }
)
}
/// Whether a queued job's `labels` array can be satisfied by this host.
///
/// Returns `true` if and only if `jobLabels` is non-empty *and* every entry
/// is a member of ``names``. An empty job label array is treated as "no
/// declared requirement" and is deliberately **not** matched — a job that
/// asks for nothing must not be scheduled onto a scarce macOS VM.
///
/// The job side is put through ``bareName(_:)`` too. The server normally
/// stores bare names, so this changes nothing in the common case — but a
/// workflow that writes `runs-on: [macos-arm64:host]` would otherwise never
/// match anything and its job would be skipped with no log line at all.
///
/// - Parameter jobLabels: The `labels` array from a `WorkflowJob`.
/// - Returns: `true` when this host should boot a VM for the job.
public func matches(jobLabels: [String]) -> Bool {
guard !jobLabels.isEmpty else { return false }
let wanted = Set(jobLabels.map(LabelSet.bareName).filter { !$0.isEmpty })
guard !wanted.isEmpty else { return false }
return wanted.isSubset(of: names)
}
/// The value to pass to `gitea-runner register --labels`, i.e. each bare
/// name suffixed with the given schema and joined by commas.
///
/// - Parameter schema: The execution schema; `host` for a bare-metal guest.
/// - Returns: For example `"macos-arm64:host,macos:host"`.
public func registrationArgument(schema: String = "host") -> String {
// Sorted so the argument is stable across process runs — a `Set` has no
// inherent order, and an unstable registration argument would make the
// guest command line (and its logs) needlessly non-reproducible.
names.sorted()
.map { schema.isEmpty ? $0 : "\($0):\(schema)" }
.joined(separator: ",")
}
/// Strips an optional `:schema` suffix from a single label token.
///
/// - Parameter label: A label such as `macos-arm64:host` or `macos-arm64`.
/// - Returns: The bare name.
public static func bareName(_ label: String) -> String {
let trimmed = label.trimmingCharacters(in: .whitespaces)
guard let colon = trimmed.firstIndex(of: ":") else { return trimmed }
return String(trimmed[trimmed.startIndex..<colon])
}
}
+35
View File
@@ -0,0 +1,35 @@
import Foundation
/// Naming scheme for the ephemeral runners this host registers with Gitea.
///
/// Every booted VM registers under a **globally unique** name. That uniqueness
/// is what makes the reconcile loop safe: when a VM dies uncleanly, Gitea keeps
/// the runner row forever (rows are only swept at midnight, and never at all if
/// the runner never claimed a task), so we must be able to look at a runner row
/// and decide "this name is mine and no live VM of mine owns it" without any
/// ambiguity. A shared or reused name would make that decision impossible.
public enum RunnerNaming {
/// Generates a fresh runner name.
///
/// - Parameter prefix: The configured prefix, e.g. `macos-vm-`.
/// - Returns: `prefix` followed by a lowercase UUID, e.g.
/// `macos-vm-3f1c2f8e-...`.
public static func makeRunnerName(prefix: String) -> String {
prefix + UUID().uuidString.lowercased()
}
/// Whether a runner name reported by Gitea was minted by this host.
///
/// - Parameters:
/// - name: A runner name from `GET /api/v1/admin/actions/runners`.
/// - prefix: The configured prefix.
/// - Returns: `true` when the reconcile loop may consider deleting it.
public static func hasPrefix(_ name: String, prefix: String) -> Bool {
// An empty prefix would match every runner on the instance, including
// other people's. The reconcile loop deletes what this returns true for,
// so refuse rather than match everything. `validated()` also rejects an
// empty `namePrefix`; this is the second line of defence.
guard !prefix.isEmpty else { return false }
return name.hasPrefix(prefix)
}
}
+589
View File
@@ -0,0 +1,589 @@
import Foundation
import NIOCore
import NIOPosix
import NIOSSH
/// The outcome of a command run inside a guest.
public struct SSHCommandResult: Sendable, Equatable {
/// The remote process's exit status. `0` on success.
public let exitCode: Int32
/// Captured stdout, UTF-8 decoded with lossy replacement.
public let stdout: String
/// Captured stderr, UTF-8 decoded with lossy replacement.
public let stderr: String
public init(exitCode: Int32, stdout: String, stderr: String) {
self.exitCode = exitCode
self.stdout = stdout
self.stderr = stderr
}
/// Whether the command exited zero.
public var succeeded: Bool { exitCode == 0 }
/// Throws ``CoreError/sshFailed(_:)`` unless the command exited zero.
///
/// - Parameter command: Echoed into the error message for context.
public func throwIfFailed(command: String) throws {
guard exitCode != 0 else { return }
let detail = stderr.isEmpty ? stdout : stderr
let trimmed = detail.trimmingCharacters(in: .whitespacesAndNewlines)
throw CoreError.sshFailed(
"command failed (exit \(exitCode)): \(command)" + (trimmed.isEmpty ? "" : "\n\(trimmed)")
)
}
}
/// The seam for talking to a guest.
///
/// The orchestrator and the provisioner are written against this rather than
/// against ``SSHExecutor`` so that provisioning logic can be unit-tested with a
/// recording fake, and so a future vsock-based transport could be dropped in
/// without touching callers.
public protocol GuestExecutor: Sendable {
/// Runs a shell command in the guest and waits for it to exit.
///
/// - Parameters:
/// - command: A `/bin/sh`-compatible command line.
/// - timeout: Wall-clock ceiling; exceeding it throws
/// ``CoreError/timeout(_:)`` and closes the channel.
/// - Returns: Exit status and captured output.
func run(_ command: String, timeout: Duration) async throws -> SSHCommandResult
/// Copies a local file into the guest.
///
/// - Parameters:
/// - localPath: Source path on the host.
/// - remotePath: Destination path in the guest.
func upload(localPath: String, remotePath: String) async throws
/// Writes bytes to a guest file with an explicit mode.
///
/// Used for secrets — notably the registration token, which is written with
/// mode `0600` and deleted immediately after `gitea-runner register` reads
/// it, so it never appears in a process argument list.
///
/// - Parameters:
/// - data: File contents.
/// - remotePath: Destination path in the guest.
/// - mode: Octal mode string, e.g. `"0600"`.
func uploadData(_ data: Data, remotePath: String, mode: String) async throws
}
extension GuestExecutor {
/// ``run(_:timeout:)`` with a two-minute default ceiling.
public func run(_ command: String) async throws -> SSHCommandResult {
try await run(command, timeout: .seconds(120))
}
/// Runs a command and throws unless it exits zero.
///
/// - Returns: The successful result.
@discardableResult
public func runChecked(_ command: String, timeout: Duration = .seconds(120)) async throws -> SSHCommandResult {
let result = try await run(command, timeout: timeout)
try result.throwIfFailed(command: command)
return result
}
}
/// SSH client over swift-nio-ssh using password authentication.
///
/// Password auth (rather than keys) is deliberate: the guest is a throwaway VM
/// on a host-private NAT network whose credentials come from the same config
/// that created it, and injecting a key would mean another provisioning step
/// during the window before SSH is up.
///
/// - Important: Host keys are **not** verified. The peer is a VM this process
/// just booted, on a link no other host shares; there is no trust-on-first-use
/// story that would add security here, and pinning would break on every clone.
public final class SSHExecutor: GuestExecutor, @unchecked Sendable {
/// Guest IP, as learned from ``DHCPLeaseParser``.
public let host: String
/// SSH port; `22` for a stock guest with Remote Login enabled.
public let port: Int
/// Guest account name.
public let username: String
/// Guest account password.
public let password: String
/// Creates an executor. No connection is made until the first command.
///
/// - Parameters:
/// - host: Guest IP address.
/// - port: SSH port. Defaults to `22`.
/// - username: Guest account.
/// - password: Guest password.
public init(host: String, port: Int = 22, username: String, password: String) {
self.host = host
self.port = port
self.username = username
self.password = password
}
public func run(_ command: String, timeout: Duration) async throws -> SSHCommandResult {
do {
return try await execute(command, stdin: nil, timeout: timeout)
} catch let error as SSHTransportError {
throw error.asCoreError
}
}
public func upload(localPath: String, remotePath: String) async throws {
let url = URL(fileURLWithPath: localPath)
guard let data = try? Data(contentsOf: url) else {
throw CoreError.notFound("local file for upload: \(localPath)")
}
try await uploadData(data, remotePath: remotePath, mode: "0644")
}
public func uploadData(_ data: Data, remotePath: String, mode: String) async throws {
// Deliberately not SFTP or SCP: a stock macOS guest runs an sshd whose
// subsystem set we do not control at this point in provisioning, and an
// exec channel with the payload as stdin needs nothing beyond what we
// already use for every other command.
let quotedPath = Self.shellQuote(remotePath)
guard mode.allSatisfy(\.isNumber), !mode.isEmpty else {
throw CoreError.sshFailed("invalid file mode \(mode.debugDescription) for \(remotePath)")
}
let command = """
mkdir -p "$(dirname \(quotedPath))" && cat > \(quotedPath) && chmod \(mode) \(quotedPath)
"""
let result: SSHCommandResult
do {
result = try await execute(command, stdin: data, timeout: .seconds(300))
} catch let error as SSHTransportError {
throw error.asCoreError
}
try result.throwIfFailed(command: "upload to \(remotePath)")
}
/// Releases any pooled connection and event loop resources.
///
/// Connections are not pooled — each command opens and closes its own — and
/// the event loop group is NIO's process-wide singleton, so there is nothing
/// to release. Kept so callers can be written against a lifecycle that a
/// future pooling or vsock transport may need.
public func close() async {}
// MARK: - Transport
/// Opens a connection, runs one exec channel, and tears both down.
///
/// - Parameters:
/// - command: The `/bin/sh` command line to exec.
/// - stdin: Bytes to stream as the command's standard input. Standard
/// input is closed (channel EOF) either way, so a command that would
/// otherwise read from the terminal exits instead of hanging.
/// - timeout: Wall-clock ceiling on the whole exchange.
/// - Throws: ``SSHTransportError`` for connect/auth problems (which
/// ``waitForSSH(host:port:username:password:timeout:pollInterval:)`` needs
/// to tell apart), or ``CoreError/timeout(_:)`` when the ceiling elapses.
func execute(_ command: String, stdin: Data?, timeout: Duration) async throws -> SSHCommandResult {
let group = MultiThreadedEventLoopGroup.singleton
let auth = SSHAuthOutcome()
let username = self.username
let password = self.password
let host = self.host
let port = self.port
let bootstrap = ClientBootstrap(group: group)
.channelOption(ChannelOptions.socketOption(.tcp_nodelay), value: 1)
.channelInitializer { channel in
channel.eventLoop.makeCompletedFuture {
let configuration = SSHClientConfiguration(
userAuthDelegate: PasswordOnlyAuthDelegate(
username: username,
password: password,
outcome: auth
),
serverAuthDelegate: AcceptAnyHostKeyDelegate()
)
try channel.pipeline.syncOperations.addHandler(
NIOSSHHandler(
role: .client(configuration),
allocator: channel.allocator,
inboundChildChannelInitializer: nil
)
)
}
}
let channel: Channel
do {
channel = try await bootstrap.connect(host: host, port: port).get()
} catch {
// No TCP connection at all: sshd is not listening yet (or the guest
// is unreachable). Recoverable — this is what waitForSSH retries on.
throw SSHTransportError.connectFailed(host: host, port: port, underlying: error)
}
let loop = channel.eventLoop
let resultPromise = loop.makePromise(of: SSHCommandResult.self)
let stdinBuffer = stdin.map { ByteBuffer(bytes: $0) }
let description = command
let timeoutTask = loop.scheduleTask(in: .nanoseconds(Self.nanoseconds(timeout))) {
resultPromise.fail(CoreError.timeout("ssh command on \(host): \(description)"))
channel.close(promise: nil)
}
// Completing an already-completed NIO promise is a no-op, so these
// racing completions are safe: whichever fires first wins.
channel.closeFuture.whenComplete { _ in
if auth.wasRejected {
resultPromise.fail(
SSHTransportError.authenticationFailed(host: host, username: username)
)
} else {
resultPromise.fail(
CoreError.sshFailed("ssh connection to \(host):\(port) closed before the command finished")
)
}
}
channel.pipeline.handler(type: NIOSSHHandler.self).flatMap { sshHandler -> EventLoopFuture<Channel> in
let childPromise = loop.makePromise(of: Channel.self)
sshHandler.createChannel(childPromise, channelType: .session) { child, channelType in
guard channelType == .session else {
return child.eventLoop.makeFailedFuture(
CoreError.sshFailed("unexpected SSH channel type \(channelType)")
)
}
return child.eventLoop.makeCompletedFuture {
try child.pipeline.syncOperations.addHandler(
ExecChannelHandler(
command: description,
stdin: stdinBuffer,
promise: resultPromise
)
)
}
// Without this the guest's EOF would close the channel before the
// exit-status request arrives.
.flatMap { child.setOption(ChannelOptions.allowRemoteHalfClosure, value: true) }
}
return childPromise.futureResult
}.whenFailure { error in
resultPromise.fail(error)
channel.close(promise: nil)
}
do {
let result = try await resultPromise.futureResult.get()
timeoutTask.cancel()
channel.close(promise: nil)
return result
} catch {
timeoutTask.cancel()
channel.close(promise: nil)
throw error
}
}
/// Wraps a path (or any argument) so `/bin/sh` sees it literally.
static func shellQuote(_ value: String) -> String {
"'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
private static func nanoseconds(_ duration: Duration) -> Int64 {
let components = duration.components
let seconds = components.seconds.multipliedReportingOverflow(by: 1_000_000_000)
guard !seconds.overflow else { return .max }
let sum = seconds.partialValue.addingReportingOverflow(
Int64(components.attoseconds / 1_000_000_000)
)
return sum.overflow ? .max : sum.partialValue
}
}
// MARK: - Transport failures
/// Connection-level failures, kept distinct from ``CoreError`` so that
/// ``waitForSSH(host:port:username:password:timeout:pollInterval:)`` can tell
/// "sshd is not up yet" (retry) from "the password is wrong" (give up now).
enum SSHTransportError: Error {
/// No TCP connection could be established.
case connectFailed(host: String, port: Int, underlying: Error)
/// The server rejected our credentials.
case authenticationFailed(host: String, username: String)
var asCoreError: CoreError {
switch self {
case .connectFailed(let host, let port, let underlying):
return .sshFailed("cannot connect to \(host):\(port): \(underlying)")
case .authenticationFailed(let host, let username):
return .sshFailed("authentication failed for \(username)@\(host)")
}
}
}
/// Shared, thread-safe record of whether the server rejected our password.
///
/// The auth delegate runs on the event loop; the value is read from the async
/// caller, hence the lock.
final class SSHAuthOutcome: @unchecked Sendable {
private let lock = NSLock()
private var rejected = false
var wasRejected: Bool {
lock.lock()
defer { lock.unlock() }
return rejected
}
func markRejected() {
lock.lock()
defer { lock.unlock() }
rejected = true
}
}
// MARK: - Delegates
/// Accepts every host key.
///
/// The peer is a VM this process booted seconds ago, on a host-private NAT link
/// no other machine shares, from an image that is destroyed after one job. Its
/// host key is freshly generated per clone, so there is nothing to pin:
/// trust-on-first-use would accept whatever the first connection presented —
/// exactly what this does — while a pinned key would reject every legitimate
/// guest. See docs/DESIGN.md §6.
final class AcceptAnyHostKeyDelegate: NIOSSHClientServerAuthenticationDelegate {
func validateHostKey(hostKey: NIOSSHPublicKey, validationCompletePromise: EventLoopPromise<Void>) {
validationCompletePromise.succeed(())
}
}
/// Offers the configured password once, then reports rejection.
///
/// NIOSSH asks again after a failed attempt; a second ask means the server
/// refused the first, which is a provisioning bug rather than a transient
/// condition, so it is recorded for the caller to fail fast on.
final class PasswordOnlyAuthDelegate: NIOSSHClientUserAuthenticationDelegate {
private let username: String
private let password: String
private let outcome: SSHAuthOutcome
private var offered = false
init(username: String, password: String, outcome: SSHAuthOutcome) {
self.username = username
self.password = password
self.outcome = outcome
}
func nextAuthenticationType(
availableMethods: NIOSSHAvailableUserAuthenticationMethods,
nextChallengePromise: EventLoopPromise<NIOSSHUserAuthenticationOffer?>
) {
guard !offered, availableMethods.contains(.password) else {
// Either the server refused our password, or it never offered
// password auth at all. Both mean this guest will not let us in.
outcome.markRejected()
nextChallengePromise.succeed(nil)
return
}
offered = true
nextChallengePromise.succeed(
NIOSSHUserAuthenticationOffer(
username: username,
serviceName: "",
offer: .password(.init(password: password))
)
)
}
}
// MARK: - Exec channel
/// Drives one exec channel: sends the request, streams stdin, splits stdout from
/// stderr, and captures the exit status.
final class ExecChannelHandler: ChannelInboundHandler {
typealias InboundIn = SSHChannelData
typealias OutboundOut = SSHChannelData
/// SSH channel data is framed into packets; keep writes comfortably under
/// the 128 KiB default maximum packet size.
private static let chunkSize = 32 * 1024
private let command: String
private var stdin: ByteBuffer?
private var promise: EventLoopPromise<SSHCommandResult>?
private var stdout = ByteBufferAllocator().buffer(capacity: 0)
private var stderr = ByteBufferAllocator().buffer(capacity: 0)
private var exitCode: Int32?
init(command: String, stdin: ByteBuffer?, promise: EventLoopPromise<SSHCommandResult>) {
self.command = command
self.stdin = stdin
self.promise = promise
}
func channelActive(context: ChannelHandlerContext) {
let request = SSHChannelRequestEvent.ExecRequest(command: command, wantReply: true)
let sent = context.eventLoop.makePromise(of: Void.self)
// Capture only Sendable values: the handler and its context must not
// escape onto another thread.
let resultPromise = promise
let channel = context.channel
sent.futureResult.whenFailure { error in
resultPromise?.fail(error)
channel.close(promise: nil)
}
context.triggerUserOutboundEvent(request, promise: sent)
context.fireChannelActive()
}
func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) {
switch event {
case is ChannelSuccessEvent:
sendStandardInput(context: context)
case is ChannelFailureEvent:
fail(context: context, error: CoreError.sshFailed("guest refused to exec: \(command)"))
case let status as SSHChannelRequestEvent.ExitStatus:
exitCode = Int32(truncatingIfNeeded: status.exitStatus)
case let signal as SSHChannelRequestEvent.ExitSignal:
// A signalled process has no exit status; report it the way a shell
// would, and keep the signal name in stderr so it is not lost.
exitCode = 128
var note = ByteBuffer(string: "\nterminated by SIG\(signal.signalName): \(signal.errorMessage)\n")
stderr.writeBuffer(&note)
default:
context.fireUserInboundEventTriggered(event)
}
}
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
let channelData = unwrapInboundIn(data)
guard case .byteBuffer(var bytes) = channelData.data else { return }
switch channelData.type {
case .channel: stdout.writeBuffer(&bytes)
case .stdErr: stderr.writeBuffer(&bytes)
default: break // An extended data type we did not ask for.
}
}
func channelInactive(context: ChannelHandlerContext) {
complete()
context.fireChannelInactive()
}
func handlerRemoved(context: ChannelHandlerContext) {
complete()
}
func errorCaught(context: ChannelHandlerContext, error: Error) {
fail(context: context, error: error)
}
private func sendStandardInput(context: ChannelHandlerContext) {
if var payload = stdin {
stdin = nil
while payload.readableBytes > 0 {
let slice = payload.readSlice(length: min(Self.chunkSize, payload.readableBytes))!
context.write(
wrapOutboundOut(SSHChannelData(type: .channel, data: .byteBuffer(slice))),
promise: nil
)
}
context.flush()
}
// EOF either way: `cat > file` needs it to finish, and a command that
// would otherwise block reading stdin gets an immediate end of input.
context.close(mode: .output, promise: nil)
}
private func complete() {
guard let promise else { return }
self.promise = nil
if let exitCode {
promise.succeed(
SSHCommandResult(
exitCode: exitCode,
stdout: String(buffer: stdout),
stderr: String(buffer: stderr)
)
)
} else {
promise.fail(
CoreError.sshFailed("guest closed the channel without an exit status: \(command)")
)
}
}
private func fail(context: ChannelHandlerContext, error: Error) {
if let promise {
self.promise = nil
promise.fail(error)
}
context.close(promise: nil)
}
}
/// Blocks until a guest accepts an authenticated SSH session, or the deadline
/// passes.
///
/// Called after a DHCP lease appears but before any provisioning: a fresh guest
/// answers on port 22 only once `launchd` has started `sshd`, which lags the
/// lease by tens of seconds.
///
/// - Parameters:
/// - host: Guest IP.
/// - port: SSH port. Defaults to `22`.
/// - username: Guest account.
/// - password: Guest password.
/// - timeout: Overall ceiling.
/// - pollInterval: Delay between attempts. Defaults to 2 s.
/// - Throws: ``CoreError/timeout(_:)`` if the guest never answers.
public func waitForSSH(
host: String,
port: Int = 22,
username: String,
password: String,
timeout: Duration,
pollInterval: Duration = .seconds(2)
) async throws {
let executor = SSHExecutor(host: host, port: port, username: username, password: password)
let started = ContinuousClock.now
var lastError: Error?
while true {
do {
// A real authenticated session running a trivial command, not a bare
// TCP probe: sshd binds the port before it is ready to authenticate,
// so a connect that succeeds proves very little.
_ = try await executor.execute("true", stdin: nil, timeout: .seconds(20))
return
} catch let error as SSHTransportError {
if case .authenticationFailed = error {
// Wrong credentials will not become right by waiting: the guest
// was provisioned with a different account or password, which is
// a build failure, not a boot delay.
throw error.asCoreError
}
lastError = error
} catch {
// Timeouts and mid-handshake closures are what a guest that is still
// starting `sshd` looks like. Keep waiting.
lastError = error
}
guard ContinuousClock.now - started < timeout else { break }
try await Task.sleep(for: pollInterval)
guard ContinuousClock.now - started < timeout else { break }
}
let detail = lastError.map { "; last error: \($0)" } ?? ""
throw CoreError.timeout("ssh on \(host):\(port)\(detail)")
}
+339
View File
@@ -0,0 +1,339 @@
import Foundation
/// What a VM slot is doing.
///
/// Slots are fixed in number (two, matching both the kernel's concurrent-VM cap
/// and our two persistent MAC addresses) and are recycled, never created.
public enum SlotState: Sendable, Equatable {
/// No VM. Available to boot.
case idle
/// A VM is being cloned/booted/provisioned; not yet registered with Gitea.
/// - Parameter since: When the transition happened, for boot-timeout checks.
case provisioning(since: Date)
/// A VM is up with `gitea-runner daemon` attached.
///
/// - Parameters:
/// - jobHint: The queued job whose presence motivated this boot, if known.
/// **Only a hint** — the server, not us, decides which job this runner
/// actually claims.
/// - since: When the VM went live, for job-timeout checks.
case running(jobHint: Int64?, since: Date)
/// Whether the slot currently holds a VM (booting or live).
public var isOccupied: Bool {
if case .idle = self { return false }
return true
}
/// When the slot entered its current state, or `nil` when idle.
public var since: Date? {
switch self {
case .idle: return nil
case .provisioning(let t): return t
case .running(_, let t): return t
}
}
}
/// One recyclable VM slot.
public struct VMSlot: Sendable, Equatable, Identifiable {
/// Stable index, `0..<maxVMs`. Also indexes the persistent per-slot MAC.
public let id: Int
/// Current state.
public var state: SlotState
public init(id: Int, state: SlotState = .idle) {
self.id = id
self.state = state
}
}
/// The scheduler's complete observable state.
public struct SchedulerState: Sendable, Equatable {
/// Fixed-size slot table.
public var slots: [VMSlot]
/// Job ids that have already caused a boot.
///
/// This is the dedup ledger. Without it, a job that stays queued for the
/// several seconds a VM takes to come up would trigger a second boot on the
/// next poll, and a third after that — burning the entire slot budget on one
/// job. Entries are dropped once the job stops appearing as queued.
public var dispatchedJobIDs: Set<Int64>
/// Creates a state with `count` idle slots and an empty ledger.
public init(slotCount: Int) {
self.slots = (0..<slotCount).map { VMSlot(id: $0) }
self.dispatchedJobIDs = []
}
public init(slots: [VMSlot], dispatchedJobIDs: Set<Int64> = []) {
self.slots = slots
self.dispatchedJobIDs = dispatchedJobIDs
}
/// Slots not currently holding a VM.
public var idleSlots: [VMSlot] { slots.filter { !$0.state.isOccupied } }
/// Slots holding a VM.
public var occupiedSlots: [VMSlot] { slots.filter { $0.state.isOccupied } }
}
/// A side effect the orchestrator should perform.
///
/// The planner returns these; it never performs I/O itself, which is what makes
/// the whole scheduling policy unit-testable against a fixed `now`.
public enum SchedulerAction: Sendable, Equatable {
/// Clone, boot, provision, and register a VM in the given slot.
/// - Parameters:
/// - slot: Slot id.
/// - jobHint: The queued job that motivated the boot.
case bootVM(slot: Int, jobHint: Int64)
/// Stop and delete the VM in the given slot.
/// - Parameters:
/// - slot: Slot id.
/// - reason: Human-readable cause, logged and used in tests.
case teardownVM(slot: Int, reason: String)
/// Explicit no-op. Returned so a caller can distinguish "planner ran and
/// chose to do nothing" from "planner returned an empty list".
case none
}
/// The pure scheduling state machine.
///
/// ## Capacity, not assignment
///
/// A booted VM is **capacity**, not a promise to run a specific job. We register
/// an ephemeral runner and the *server* decides which queued job it claims —
/// possibly not the one that triggered the boot. That is fine and in fact
/// desirable: it means we never have to reimplement Gitea's matching rules. The
/// `jobHint` carried through ``SchedulerAction/bootVM(slot:jobHint:)`` and
/// ``SlotState/running(jobHint:since:)`` exists purely for logs and for the
/// dedup ledger.
///
/// Because `--ephemeral` makes the server hand each runner exactly one task and
/// then deregister it, a slot's life is: boot → register → claim one job → the
/// `gitea-runner daemon` process exits → we tear down. There is no reuse, which
/// is what makes the VM genuinely disposable.
///
/// ## Rules
///
/// 1. Only jobs whose labels ``LabelSet/matches(jobLabels:)`` are considered.
/// 2. A job id already in ``SchedulerState/dispatchedJobIDs`` never boots a
/// second VM.
/// 3. At most `maxVMs` slots may be occupied (hard-clamped to 2 — the kernel
/// fails a third `start()` with `VZError.virtualMachineLimitExceeded`).
/// 4. Ledger entries for jobs no longer visible as queued are expired, so a
/// slot freed by a completed job can be re-earned by a genuinely new job.
/// 5. A slot in ``SlotState/provisioning(since:)`` longer than `bootTimeout`, or
/// ``SlotState/running(jobHint:since:)`` longer than `jobTimeout`, is torn
/// down.
public enum SchedulerCore {
/// Computes the next state and the actions to reach it.
///
/// Deterministic and side-effect free: same inputs, same outputs. `now` is
/// injected rather than read so timeout behaviour is testable.
///
/// - Parameters:
/// - state: Current state.
/// - queuedJobs: Jobs Gitea currently reports as `queued`. Callers must
/// not include `waiting` (blocked) jobs.
/// - labels: This host's label set.
/// - maxVMs: Concurrency cap; values above 2 are clamped.
/// - now: Reference time for timeout arithmetic.
/// - jobTimeout: Ceiling on ``SlotState/running(jobHint:since:)``.
/// - bootTimeout: Ceiling on ``SlotState/provisioning(since:)``.
/// - Returns: The updated state and the actions to execute, teardowns first
/// so a freed slot can be reused within the same pass.
public static func plan(
state: SchedulerState,
queuedJobs: [WorkflowJob],
labels: LabelSet,
maxVMs: Int,
now: Date,
jobTimeout: TimeInterval,
bootTimeout: TimeInterval
) -> (SchedulerState, [SchedulerAction]) {
// The kernel fails a third concurrent guest, so the config never gets to
// negotiate this. Clamped here as well as in `RunnerConfig.validated()`.
let cap = min(max(maxVMs, 0), 2)
var newState = state
var teardowns: [SchedulerAction] = []
var boots: [SchedulerAction] = []
// 1. Which of the queued jobs are ours to serve, in the order Gitea
// reported them (so the plan is a deterministic function of input).
let matching = queuedJobs.filter { labels.matches(jobLabels: $0.labels) }
let queuedIDs = Set(queuedJobs.map(\.id))
// 2. Expire the dedup ledger against reality rather than against a
// timer: an id that is no longer queued was either claimed or
// cancelled, and dedup only matters while a job is still waiting.
newState.dispatchedJobIDs.formIntersection(queuedIDs)
// 3. Timeouts, emitted before any boot so a slot freed here can be
// reused in this same pass.
for index in newState.slots.indices {
let slot = newState.slots[index]
switch slot.state {
case .idle:
continue
case .provisioning(let since):
let age = now.timeIntervalSince(since)
guard age > bootTimeout else { continue }
teardowns.append(
.teardownVM(
slot: slot.id,
reason: "boot timeout: provisioning for \(Int(age))s (limit \(Int(bootTimeout))s)"
)
)
newState.slots[index].state = .idle
// Losing a boot must not permanently strand the job that
// motivated it. `SlotState.provisioning` deliberately carries no
// jobHint (the hint is a log/dedup detail, not an assignment), so
// there is no specific id to drop here. Instead we release one
// ledger entry — the lowest still-queued dispatched id, i.e. the
// oldest such job, since Gitea's ids increase monotonically.
// That is deterministic, releases exactly the capacity we lost,
// and lets a replacement VM boot (possibly on this very tick).
if let oldest = newState.dispatchedJobIDs.min() {
newState.dispatchedJobIDs.remove(oldest)
}
case .running(let jobHint, let since):
let age = now.timeIntervalSince(since)
guard age > jobTimeout else { continue }
teardowns.append(
.teardownVM(
slot: slot.id,
reason: "job timeout: running for \(Int(age))s (limit \(Int(jobTimeout))s)"
)
)
newState.slots[index].state = .idle
// Same reasoning as above, except here we do know the hint. It is
// usually gone from the ledger already (a claimed job stops being
// queued), so this is normally a no-op.
if let jobHint { newState.dispatchedJobIDs.remove(jobHint) }
}
}
// 4. Boot capacity for jobs we have not already booted for.
//
// A booted VM is CAPACITY, not an assignment: the ephemeral runner we
// register may legally claim a DIFFERENT matching job than the one
// whose presence motivated the boot. The counting still works out —
// one queued matching job earns one VM, and whichever job that VM
// claims stops being queued and drops out of the ledger.
for job in matching {
guard !newState.dispatchedJobIDs.contains(job.id) else { continue }
guard newState.occupiedSlots.count < cap else { break }
guard let free = newState.slots.firstIndex(where: { !$0.state.isOccupied }) else { break }
boots.append(.bootVM(slot: newState.slots[free].id, jobHint: job.id))
newState.slots[free].state = .provisioning(since: now)
newState.dispatchedJobIDs.insert(job.id)
}
// Teardowns first, boots second. An empty list is the no-op; `.none` is
// never emitted, so callers never have to filter it out of a real plan.
return (newState, teardowns + boots)
}
/// Records that a slot began booting for a job.
///
/// Called by the orchestrator once it has actually started the clone/boot,
/// so that a failed `plan` execution does not leave a phantom occupied slot.
///
/// - Returns: The updated state.
public static func markProvisioning(
state: SchedulerState,
slot: Int,
jobHint: Int64,
now: Date
) -> SchedulerState {
var newState = state
guard let index = newState.slots.firstIndex(where: { $0.id == slot }) else { return newState }
newState.slots[index].state = .provisioning(since: now)
newState.dispatchedJobIDs.insert(jobHint)
return newState
}
/// Promotes a slot from provisioning to running.
public static func markRunning(
state: SchedulerState,
slot: Int,
now: Date
) -> SchedulerState {
var newState = state
guard let index = newState.slots.firstIndex(where: { $0.id == slot }) else { return newState }
// The hint, if any, is carried over purely so logs and the job-timeout
// teardown reason can name a job. It is never an assignment.
let hint: Int64?
if case .running(let existing, _) = newState.slots[index].state {
hint = existing
} else {
hint = nil
}
newState.slots[index].state = .running(jobHint: hint, since: now)
return newState
}
/// Promotes a slot to running while recording the job that motivated its
/// boot, which ``SlotState/provisioning(since:)`` does not carry.
///
/// Additive convenience over ``markRunning(state:slot:now:)``; the hint is
/// still only ever used for logging and the job-timeout reason string.
public static func markRunning(
state: SchedulerState,
slot: Int,
jobHint: Int64?,
now: Date
) -> SchedulerState {
var newState = state
guard let index = newState.slots.firstIndex(where: { $0.id == slot }) else { return newState }
newState.slots[index].state = .running(jobHint: jobHint, since: now)
return newState
}
/// Drops a job id from the dedup ledger.
///
/// The ledger's only automatic expiry is "the job stopped being queued"
/// (``plan(state:queuedJobs:labels:maxVMs:now:jobTimeout:bootTimeout:)``,
/// step 2), which is exactly wrong for a boot that never happened: the job
/// is *still* queued, so its entry is retained and no further VM is ever
/// booted for it. Every failure path — a refused boot, a clone error, a lost
/// lease, a dead SSH channel — must call this, or the job waits out Gitea's
/// 24 h `ABANDONED_JOB_TIMEOUT` for nothing.
///
/// Safe to call for an id that was never dispatched, or twice.
///
/// - Parameters:
/// - state: Current state.
/// - jobID: The job to release.
/// - Returns: The updated state.
public static func releaseJob(
state: SchedulerState,
jobID: Int64
) -> SchedulerState {
var newState = state
newState.dispatchedJobIDs.remove(jobID)
return newState
}
/// Returns a slot to ``SlotState/idle`` after teardown.
public static func markIdle(
state: SchedulerState,
slot: Int
) -> SchedulerState {
var newState = state
guard let index = newState.slots.firstIndex(where: { $0.id == slot }) else { return newState }
newState.slots[index].state = .idle
return newState
}
}
+11
View File
@@ -0,0 +1,11 @@
import Foundation
/// Version stamp for the runner host tool itself.
///
/// This is *not* the version of the `gitea-runner` binary installed into the
/// guest — that one lives in ``RunnerConfig/RunnerSection/version``.
public enum RunnerVersion {
/// The semantic version of this build, reported by `--version` and by
/// `doctor`.
public static let current = "0.1.0"
}
+597
View File
@@ -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) ?? "")
}
}
+599
View File
@@ -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))
}
}
}
}
+241
View File
@@ -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.
}
}
+706
View File
@@ -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?
}
+354
View File
@@ -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: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
}
/// 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) ?? ""
)
}
}
+821
View File
@@ -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
}
}
+272
View File
@@ -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
}()
}
+373
View File
@@ -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.
}
}
+366
View File
@@ -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)
}
}
}
+181
View File
@@ -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)
}
}
@@ -0,0 +1,205 @@
import ArgumentParser
import Foundation
import RunnerCore
/// `gitea-macos-runner config …` — create and inspect configuration.
struct ConfigCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "config",
abstract: "Create and inspect the runner configuration.",
subcommands: [Init.self, Show.self, Path.self]
)
/// `config init` — write a commented example config.
struct Init: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "init",
abstract: "Write an example config.json, creating parent directories.",
discussion: """
Writes to ~/.config/gitea-macos-runner/config.json unless --config says \
otherwise. Refuses to overwrite an existing file without --force. The \
written file carries "_comment" keys explaining each section; they are \
ignored when the config is read back.
"""
)
@OptionGroup var options: GlobalOptions
/// Overwrite an existing file.
@Flag(name: .shortAndLong, help: "Overwrite an existing config file.")
var force: Bool = false
/// Seed `gitea.instanceURL` instead of the placeholder.
@Option(name: .long, help: "Gitea instance URL to seed into the config.")
var instanceURL: String?
func run() async throws {
var config = RunnerConfig.default
if let instanceURL {
guard let url = URL(string: instanceURL), url.scheme != nil, url.host != nil else {
throw ValidationError("not a valid absolute URL: \(instanceURL)")
}
config.gitea.instanceURL = url
}
var example = ConfigCommand.loadExampleDocument()
if let instanceURL, example != nil {
example = example?.replacingOccurrences(
of: "https://gitea.example.com",
with: instanceURL
)
}
let path = RunnerConfig.expandTilde(options.configPath)
let written = try config.writeExample(to: path, exampleContents: example, overwrite: force)
guard written else {
CLI.error("\(path) already exists; pass --force to overwrite")
throw ExitCode(1)
}
print("wrote \(path)")
print("")
if let contents = try? String(contentsOfFile: path, encoding: .utf8) {
print(contents)
}
print("edit it, then run: gitea-macos-runner doctor")
}
}
/// `config show` — print the effective, validated configuration.
///
/// Token values are redacted; token *sources* are shown, which is what you
/// actually need when debugging "why does it say no registration token".
struct Show: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "show",
abstract: "Print the effective configuration with secrets redacted."
)
@OptionGroup var options: GlobalOptions
func run() async throws {
let config = try options.loadConfig()
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let encoded = try encoder.encode(config)
var object = (try JSONSerialization.jsonObject(with: encoded)) as? [String: Any] ?? [:]
if var gitea = object["gitea"] as? [String: Any] {
if gitea["adminToken"] != nil { gitea["adminToken"] = "<redacted>" }
if gitea["registrationToken"] != nil { gitea["registrationToken"] = "<redacted>" }
object["gitea"] = gitea
}
let redacted = try JSONSerialization.data(
withJSONObject: object,
options: [.prettyPrinted, .sortedKeys]
)
print(String(data: redacted, encoding: .utf8) ?? "{}")
// The sources matter more than the values: "no registration token"
// is almost always a path problem, not a secret problem.
print("")
print("config path: \(RunnerConfig.expandTilde(options.configPath))")
print("store directory: \(config.storeDirectoryURL.path)")
print("labels: \(config.runner.labels.joined(separator: ", "))")
print("register --labels: \(config.labelSet.registrationArgument())")
let downloadURL = (try? config.runner.resolvedDownloadURL)?.absoluteString ?? "<invalid>"
print("runner download: \(downloadURL)")
let adminSource = ConfigCommand.describeSource(
inline: config.gitea.adminToken,
file: config.gitea.adminTokenFile,
resolved: (try? config.resolveAdminToken()) ?? nil
)
let registrationSource = ConfigCommand.describeSource(
inline: config.gitea.registrationToken,
file: config.gitea.registrationTokenFile,
resolved: (try? config.resolveStaticRegistrationToken()) ?? nil,
fallback: config.gitea.fetchRegistrationTokenViaAPI
? "admin API (fetchRegistrationTokenViaAPI)"
: nil
)
print("admin token: \(adminSource)")
print("registration token: \(registrationSource)")
let insecure = config.insecureTokenFilePaths
if !insecure.isEmpty {
print("")
CLI.note("warning: group/world readable token files: \(insecure.joined(separator: ", "))")
}
}
}
/// `config path` — print the config path being used.
struct Path: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "path",
abstract: "Print the configuration file path."
)
@OptionGroup var options: GlobalOptions
func run() async throws {
print(RunnerConfig.expandTilde(options.configPath))
}
}
/// Describes where a secret comes from, without printing it.
static func describeSource(
inline: String?,
file: String?,
resolved: String?,
fallback: String? = nil
) -> String {
let value = resolved
if let file, !file.isEmpty {
let expanded = RunnerConfig.expandTilde(file)
let readable = (value?.isEmpty == false)
return "\(expanded) (\(readable ? "readable" : "MISSING or empty"))"
}
if let inline, !inline.isEmpty {
return "inline value in config.json (prefer a file)"
}
return fallback ?? "not configured"
}
/// Finds `Resources/config.example.json` next to the binary or in a checkout.
///
/// The example is not an SPM resource bundle and `make bundle` does not copy
/// it into the app, so several plausible locations are tried; `writeExample`
/// falls back to a plain serialization when none is found.
static func loadExampleDocument() -> String? {
var candidates: [URL] = []
if let resource = Bundle.main.url(forResource: "config.example", withExtension: "json") {
candidates.append(resource)
}
candidates.append(Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/config.example.json"))
if let executableURL = Bundle.main.executableURL?.resolvingSymlinksInPath() {
let directory = executableURL.deletingLastPathComponent()
candidates.append(directory.appendingPathComponent("Resources/config.example.json"))
candidates.append(
directory.deletingLastPathComponent().appendingPathComponent("Resources/config.example.json")
)
}
// Sources/gitea-macos-runner/CommandConfig.swift → repository root.
let repositoryRoot = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
candidates.append(repositoryRoot.appendingPathComponent("Resources/config.example.json"))
candidates.append(
URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
.appendingPathComponent("Resources/config.example.json")
)
for candidate in candidates {
if let contents = try? String(contentsOf: candidate, encoding: .utf8) {
return contents
}
}
return nil
}
}
@@ -0,0 +1,178 @@
import AppKit
import ArgumentParser
import Foundation
import Logging
import RunnerCore
import RunnerHost
/// `gitea-macos-runner daemon` — the long-running service.
///
/// ## Why there is an `NSApplication` here
///
/// Virtualization.framework requires a running main run loop in an application
/// context; a plain command-line process that blocks in `await` never services
/// it, and VM startup either hangs or fails. The fix is to start a real
/// `NSApplication` but suppress every trace of a GUI:
///
/// ```swift
/// NSApplication.shared.setActivationPolicy(.prohibited) // no Dock icon, no menu bar
/// // spawn the orchestrator Task
/// NSApplication.shared.run() // never returns
/// ```
///
/// `.prohibited` (mirrored by `LSUIElement` in `Info.plist`) is what makes this
/// invisible. The orchestrator runs in a detached `Task`; `run()` owns the main
/// thread from then on.
///
/// `SIGTERM` and `SIGINT` are trapped with `DispatchSourceSignal` — not
/// `signal(2)` handlers, which cannot safely touch Swift concurrency — and
/// trigger ``Orchestrator/shutdown()`` before the process leaves, so guests get
/// a chance to stop cleanly instead of having their disks yanked.
struct DaemonCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "daemon",
abstract: "Watch Gitea for queued macOS jobs and run each in a fresh VM."
)
@OptionGroup var options: GlobalOptions
/// Base image to clone for each job.
@Option(name: .long, help: "Base image to clone for each job.")
var image: String = "default"
/// Run one poll/reconcile tick and exit. Useful for debugging without
/// installing the service.
@Flag(name: .long, help: "Run a single scheduling tick, then exit.")
var once: Bool = false
func run() async throws {
CLI.bootstrapLogging(verbose: options.verbose)
let logger = Logger(label: "daemon")
let config = try options.loadConfig()
guard let adminToken = try config.resolveAdminToken(), !adminToken.isEmpty else {
throw ValidationError(
"""
no Gitea admin token: set gitea.adminTokenFile (preferred) or gitea.adminToken \
in \(RunnerConfig.expandTilde(options.configPath))
"""
)
}
for path in config.insecureTokenFilePaths {
logger.warning("token file is group/world readable", metadata: ["path": .string(path)])
}
let store = VMStore(config: config)
try store.ensureLayout()
guard try store.image(named: image) != nil else {
throw ValidationError(
"no base image named '\(image)' — build one with `gitea-macos-runner image build --name \(image)`"
)
}
let client = GiteaClient(baseURL: config.gitea.instanceURL, token: adminToken)
let orchestrator = Orchestrator(
config: config,
client: client,
store: store,
imageName: image,
logger: Logger(label: "orchestrator")
)
let singleTick = once
let jobTimeout = TimeInterval(config.scheduler.jobTimeoutMinutes * 60)
// Even a single tick can start a VM, and a VM needs the run loop — so
// both modes go through NSApplication.
await VZAppRuntime.run(
onSignal: { await orchestrator.shutdown() },
body: {
do {
if singleTick {
await orchestrator.reconcileOnce()
await orchestrator.tick()
// Let whatever the tick started run to completion rather
// than tearing a just-booted guest down mid-boot.
let deadline = Date().addingTimeInterval(jobTimeout)
var pending = await orchestrator.liveVMs().count
while pending > 0, Date() < deadline {
try? await Task.sleep(for: .seconds(5))
pending = await orchestrator.liveVMs().count
}
await orchestrator.shutdown()
} else {
try await orchestrator.runForever()
}
} catch is CancellationError {
// Expected on shutdown.
} catch {
logger.critical("daemon stopped", metadata: ["error": .string("\(error)")])
// Fully qualified: inside a ParsableCommand a bare `exit`
// resolves to ParsableCommand.exit(withError:).
await MainActor.run { Foundation.exit(1) }
}
}
)
}
}
/// Hosts an `NSApplication` run loop so Virtualization.framework has the main
/// run loop it requires, while the real work runs in a `Task`.
///
/// Shared by `daemon` and `vm boot`: any command that starts a VM needs this.
@MainActor
enum VZAppRuntime {
/// Signal sources have to outlive the call that creates them or they are
/// cancelled on deinit and the signals go nowhere.
private static var signalSources: [DispatchSourceSignal] = []
private static var isTerminating = false
/// Starts the run loop and runs `body` alongside it. Never returns.
///
/// - Parameters:
/// - onSignal: Cleanup to perform on `SIGINT`/`SIGTERM` before exiting.
/// - body: The work to run. When it returns, the process exits zero.
static func run(
onSignal: @escaping @Sendable () async -> Void,
body: @escaping @Sendable () async -> Void
) -> Never {
let app = NSApplication.shared
// No Dock icon, no menu bar, no activation: this is a background agent
// that merely needs to be an application as far as the kernel is
// concerned.
app.setActivationPolicy(.prohibited)
for signalNumber in [SIGINT, SIGTERM] {
// DispatchSourceSignal only observes; the default disposition still
// kills the process unless it is ignored first.
signal(signalNumber, SIG_IGN)
let source = DispatchSource.makeSignalSource(signal: signalNumber, queue: .main)
source.setEventHandler {
Task { @MainActor in
guard !isTerminating else { return }
isTerminating = true
CLI.note("received signal; shutting down…")
await onSignal()
NSApp.terminate(nil)
exit(0)
}
}
source.resume()
signalSources.append(source)
}
Task {
await body()
await MainActor.run {
NSApp.terminate(nil)
exit(0)
}
}
app.run()
exit(0)
}
}
@@ -0,0 +1,61 @@
import ArgumentParser
import Foundation
import RunnerCore
import RunnerHost
/// `gitea-macos-runner doctor` — verify the host before anything else.
///
/// Every check corresponds to a failure that would otherwise show up as an
/// opaque error deep inside a VM boot: wrong architecture, unsigned binary,
/// locked keychain, non-admin Gitea token, dead download URL. Run this first,
/// and again after `service install`.
struct DoctorCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "doctor",
abstract: "Check that this host can build and run macOS guests."
)
@OptionGroup var options: GlobalOptions
/// Emit machine-readable JSON instead of aligned text.
@Flag(name: .long, help: "Emit results as JSON.")
var json: Bool = false
/// By default `doctor` exits non-zero when any check fails, so it can gate a
/// setup script. This makes it always exit zero.
@Flag(name: .customLong("no-fail"), help: "Exit zero even when checks fail.")
var noFail: Bool = false
func run() async throws {
// Deliberately does not use options.loadConfig(): a broken or missing
// config is exactly the state doctor exists to diagnose, so it is
// reported as a check rather than thrown as an error.
let checks = await Doctor.runChecks(configPath: options.configPath)
if json {
let payload: [[String: Any]] = checks.map { check in
var entry: [String: Any] = [
"name": check.name,
"result": check.result.label,
"detail": check.detail,
"blocking": check.isBlocking,
]
if let remediation = check.remediation {
entry["remediation"] = remediation
}
return entry
}
let data = try JSONSerialization.data(
withJSONObject: payload,
options: [.prettyPrinted, .sortedKeys]
)
print(String(data: data, encoding: .utf8) ?? "[]")
} else {
print(Doctor.format(checks))
}
if !noFail, checks.contains(where: \.isBlocking) {
throw ExitCode(1)
}
}
}
@@ -0,0 +1,274 @@
import ArgumentParser
import Foundation
import RunnerCore
import RunnerHost
/// `gitea-macos-runner image …` — manage base VM images.
///
/// A base image is installed and provisioned once and then cloned per job.
/// Building one takes the better part of an hour, most of it downloading a
/// ~15 GB IPSW; cloning one takes milliseconds.
struct ImageCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "image",
abstract: "Build, list, provision, and delete base VM images.",
subcommands: [Build.self, List.self, Delete.self, Provision.self]
)
/// `image build` — install macOS from an IPSW and provision it.
struct Build: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "build",
abstract: "Install macOS into a new base image and provision it.",
discussion: """
Downloads the latest supported restore image unless --ipsw is given, \
installs it, automates Setup Assistant, then installs Node.js and the \
gitea-runner binary over SSH. The guest must be macOS 27 or newer for \
unattended Setup Assistant automation to work.
"""
)
@OptionGroup var options: GlobalOptions
/// Image name under `<storeDir>/images/`.
@Option(name: .long, help: "Image name.")
var name: String = "default"
/// A local `.ipsw`; omit to download the latest supported image.
@Option(name: .long, help: "Path to a local .ipsw (default: download the latest supported).")
var ipsw: String?
/// Nominal guest disk size, overriding `guest.diskGB`.
@Option(name: .customLong("disk-gb"), help: "Guest disk size in GB (overrides config).")
var diskGB: Int?
func run() async throws {
CLI.bootstrapLogging(verbose: options.verbose)
var config = try options.loadConfig()
if let diskGB {
config.guest.diskGB = diskGB
}
let store = VMStore(config: config)
try store.ensureLayout()
if try store.image(named: name) != nil {
throw ValidationError(
"image '\(name)' already exists — delete it first with `image delete \(name)`"
)
}
try store.ensureFreeSpace(minGB: max(config.storage.minFreeDiskGB, 40))
CLI.note("building image '\(name)' (this takes a while; the IPSW alone is ~15 GB)")
let printer = ProgressPrinter()
let builder = ImageBuilder(store: store)
let imageName = name
let ipswPath = ipsw
let frozenConfig = config
// `image build` runs `VZMacOSInstaller` and then boots the guest, so
// it needs the same `NSApplication` main run loop `daemon` and
// `vm boot` do — without it Virtualization.framework's callbacks are
// never serviced and the install hangs. See `VZAppRuntime`.
await VZAppRuntime.run(
onSignal: {},
body: {
do {
try await builder.build(
name: imageName,
ipswPath: ipswPath,
config: frozenConfig,
progress: { stage in printer.update(ImageCommand.describe(stage)) }
)
} catch {
printer.finish()
CLI.error("\(error)")
// Fully qualified: inside a ParsableCommand a bare `exit`
// resolves to ParsableCommand.exit(withError:).
await MainActor.run { Foundation.exit(1) }
}
printer.finish("done")
print("built image '\(imageName)'")
print("next: gitea-macos-runner vm boot --image \(imageName)")
}
)
}
}
/// `image list` — show base images and whether they are provisioned.
struct List: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "list",
abstract: "List base images."
)
@OptionGroup var options: GlobalOptions
func run() async throws {
let config = try options.loadConfig()
let store = VMStore(config: config)
try store.ensureLayout()
let names = try store.listImages()
guard !names.isEmpty else {
print("no images (build one with `gitea-macos-runner image build`)")
return
}
print("NAME MACOS PROVISIONED DISK SIZE")
for name in names {
guard let bundle = try store.image(named: name) else { continue }
let bundleConfig = try? bundle.loadConfig()
let size = (try? bundle.diskUsageBytes()).map(CLI.formatBytes) ?? "-"
print(
pad(name, 20)
+ pad(bundleConfig?.macOSVersion ?? "-", 12)
+ pad((bundleConfig?.provisioned ?? false) ? "yes" : "no", 13)
+ pad(bundleConfig?.diskFormat.rawValue ?? "-", 11)
+ size
)
}
}
private func pad(_ value: String, _ width: Int) -> String {
value.count >= width
? value + " "
: value + String(repeating: " ", count: width - value.count)
}
}
/// `image delete NAME` — remove a base image.
struct Delete: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "delete",
abstract: "Delete a base image and its disk."
)
@OptionGroup var options: GlobalOptions
/// Image name.
@Argument(help: "Image name.")
var name: String
/// Skip the confirmation prompt.
@Flag(name: .shortAndLong, help: "Do not prompt for confirmation.")
var force: Bool = false
func run() async throws {
let config = try options.loadConfig()
let store = VMStore(config: config)
guard let bundle = try store.image(named: name) else {
throw ValidationError("no image named '\(name)'")
}
if !force {
let size = (try? bundle.diskUsageBytes()).map(CLI.formatBytes) ?? "unknown size"
guard CLI.confirm("delete image '\(name)' (\(size))?") else {
print("cancelled")
throw ExitCode(1)
}
}
try store.deleteImage(named: name)
print("deleted image '\(name)'")
}
}
/// `image provision NAME` — re-run guest provisioning on an existing image.
///
/// Exists so that bumping the `gitea-runner` version, or adding Xcode, does
/// not require reinstalling macOS.
struct Provision: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "provision",
abstract: "Re-run guest provisioning against an existing image.",
discussion: """
Boots the BASE image bundle itself — not a clone — runs provisioning, and \
shuts it down. This deliberately mutates the golden image in place, which \
is the point: every clone made afterwards inherits the change. Nothing \
else may be using the image while this runs, so stop the daemon first.
"""
)
@OptionGroup var options: GlobalOptions
/// Image name.
@Argument(help: "Image name.")
var name: String
/// Optional Xcode `.xip` to install into the guest. Adds tens of
/// gigabytes; omitted by default.
@Option(name: .customLong("xcode-xip"), help: "Path to an Xcode .xip to install into the guest.")
var xcodeXIP: String?
func run() async throws {
CLI.bootstrapLogging(verbose: options.verbose)
let config = try options.loadConfig()
let store = VMStore(config: config)
guard try store.image(named: name) != nil else {
throw ValidationError("no image named '\(name)'")
}
if let xcodeXIP, !FileManager.default.fileExists(atPath: RunnerConfig.expandTilde(xcodeXIP)) {
throw ValidationError("no file at \(RunnerConfig.expandTilde(xcodeXIP))")
}
CLI.note("provisioning base image '\(name)' in place — stop the daemon before doing this")
let printer = ProgressPrinter()
let builder = ImageBuilder(store: store)
let imageName = name
let frozenConfig = config
let xipPath = xcodeXIP.map(RunnerConfig.expandTilde)
// Boots the image to run provision.sh in it, so it needs the run
// loop for exactly the reason `image build` does.
await VZAppRuntime.run(
onSignal: {},
body: {
do {
try await builder.reprovision(
name: imageName,
config: frozenConfig,
xcodeXIPPath: xipPath,
progress: { stage in printer.update(ImageCommand.describe(stage)) }
)
} catch {
printer.finish()
CLI.error("\(error)")
// Fully qualified: inside a ParsableCommand a bare `exit`
// resolves to ParsableCommand.exit(withError:).
await MainActor.run { Foundation.exit(1) }
}
printer.finish("done")
print("provisioned image '\(imageName)'")
}
)
}
}
/// Renders a build stage as one status line.
static func describe(_ stage: ImageBuildStage) -> String {
switch stage {
case .downloadingIPSW(let fraction):
return "downloading IPSW " + CLI.progressBar(fraction)
case .preparing:
return "preparing"
case .creatingBundle:
return "creating bundle"
case .installing(let fraction):
return "installing macOS " + CLI.progressBar(fraction)
case .firstBoot:
return "first boot (Setup Assistant)"
case .provisioning(let step):
return "provisioning: \(step)"
case .finalizing:
return "finalizing"
case .done:
return "done"
}
}
}
@@ -0,0 +1,107 @@
import ArgumentParser
import Foundation
import RunnerCore
import RunnerHost
/// `gitea-macos-runner service …` — manage the `launchd` LaunchAgent.
///
/// - Important: This installs a **LaunchAgent** in the logged-in user's session,
/// never a LaunchDaemon. Virtualization needs a GUI session, and macOS 15+
/// additionally needs an unlocked `login.keychain` to start a VM — neither of
/// which exists in the system context. The host should be set to log in
/// automatically.
struct ServiceCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "service",
abstract: "Install, remove, or inspect the launchd LaunchAgent.",
subcommands: [Install.self, Uninstall.self, Status.self]
)
/// `service install` — write the plist and load the job.
struct Install: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "install",
abstract: "Write ~/Library/LaunchAgents/xyz.blakeslee.gitea-macos-runner.plist and load it.",
discussion: """
Points the agent at the installed, signed .app bundle — not at a bare \
binary. The com.apple.security.virtualization entitlement only survives \
on the signed bundle, so a daemon started from .build/ cannot start VMs.
"""
)
@OptionGroup var options: GlobalOptions
/// Path to the installed executable inside the signed `.app`.
@Option(name: .long, help: "Path to the installed executable (default: ~/Applications/GiteaMacosRunner.app/Contents/MacOS/gitea-macos-runner).")
var executable: String?
func run() async throws {
let executablePath = executable ?? LaunchdService.defaultExecutablePath
// Only pass --config when it is not the default; a plist that
// hard-codes the default path is one more thing to keep in sync.
let configPath = options.configPath == RunnerConfig.defaultPath ? nil : options.configPath
if (try? options.loadConfig()) == nil {
CLI.note("warning: \(RunnerConfig.expandTilde(options.configPath)) is missing or invalid; the agent will fail to start until it is fixed")
}
try LaunchdService.install(executablePath: executablePath, configPath: configPath)
print("installed \(LaunchdService.agentPlistURL.path)")
print("program: \(RunnerConfig.expandTilde(executablePath)) daemon")
print("logs: \(LaunchdService.logDirectoryURL.path)")
print("")
print("check it with: gitea-macos-runner service status")
}
}
/// `service uninstall` — unload and remove the plist.
struct Uninstall: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "uninstall",
abstract: "Unload the LaunchAgent and remove its plist."
)
@OptionGroup var options: GlobalOptions
func run() async throws {
let path = LaunchdService.agentPlistURL.path
let existed = FileManager.default.fileExists(atPath: path)
try LaunchdService.uninstall()
print(existed ? "removed \(path)" : "not installed (\(path))")
}
}
/// `service status` — report whether the agent is installed and running.
struct Status: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "status",
abstract: "Report LaunchAgent installation and run state."
)
@OptionGroup var options: GlobalOptions
func run() async throws {
let status = try LaunchdService.status()
print("label: \(LaunchdService.label)")
print("plist: \(status.plistPath)")
print("installed: \(status.installed ? "yes" : "no")")
print("loaded: \(status.loaded ? "yes" : "no")")
if let pid = status.pid {
print("pid: \(pid)")
}
if let lastExitStatus = status.lastExitStatus {
print("last exit: \(lastExitStatus)")
}
print("logs: \(LaunchdService.logDirectoryURL.path)")
if status.installed, !status.loaded {
print("")
CLI.note("installed but not loaded — reinstall with `service install`, or check the logs above")
throw ExitCode(1)
}
}
}
}
+195
View File
@@ -0,0 +1,195 @@
import ArgumentParser
import Foundation
import RunnerCore
import RunnerHost
/// `gitea-macos-runner vm …` — debugging helpers that operate on VMs directly,
/// without any Gitea involvement.
struct VMCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "vm",
abstract: "Boot and inspect VMs directly (debugging).",
subcommands: [Boot.self, List.self]
)
/// `vm boot --image NAME` — clone an image, boot it, print its IP, wait.
///
/// The fastest way to answer "is the image itself broken, or is it the
/// Gitea integration?". Clones the image onto slot 0's MAC, boots it, waits
/// for a DHCP lease, prints the address and an `ssh` line, then blocks until
/// Ctrl-C — at which point the VM is stopped and the clone deleted, exactly
/// as the daemon would.
struct Boot: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "boot",
abstract: "Clone an image, boot it, print its IP, and wait for Ctrl-C."
)
@OptionGroup var options: GlobalOptions
/// Base image to clone.
@Option(name: .long, help: "Base image to clone.")
var image: String = "default"
/// Which slot's persistent MAC to use.
@Option(name: .long, help: "Slot index whose persistent MAC the clone should use.")
var slot: Int = 0
/// Leave the clone on disk after exit, for post-mortem inspection.
@Flag(name: .long, help: "Do not delete the clone on exit.")
var keep: Bool = false
func run() async throws {
CLI.bootstrapLogging(verbose: options.verbose)
let config = try options.loadConfig()
let store = VMStore(config: config)
try store.ensureLayout()
guard try store.image(named: image) != nil else {
throw ValidationError("no image named '\(image)'")
}
try store.ensureFreeSpace(minGB: config.storage.minFreeDiskGB)
let session = BootSession(store: store, keepClone: keep)
let slotIndex = slot
let imageName = image
let bootTimeout = Duration.seconds(max(30, config.scheduler.bootTimeoutSeconds))
let username = config.guest.username
await VZAppRuntime.run(
onSignal: { await session.teardown() },
body: {
do {
let mac = try store.macAddress(
forSlot: slotIndex,
slotCount: RunnerConfig.SchedulerSection.hardMaxConcurrentVMs
)
let bundle = try store.cloneImage(named: imageName, slotMAC: mac)
let instance = try VMInstance(bundle: bundle, label: "vm-boot")
await session.adopt(bundle: bundle, instance: instance)
CLI.note("booting clone \(bundle.name) (mac \(mac))…")
try await instance.start()
let ip = try await VMCommand.waitForLease(mac: mac, timeout: bootTimeout)
print("ip: \(ip)")
print("ssh: ssh \(username)@\(ip)")
print("")
CLI.note("press Ctrl-C to stop the VM and delete the clone")
// Whichever happens first: the guest shuts itself down,
// or the operator interrupts (handled by onSignal).
let reason = await instance.waitUntilStopped()
CLI.note("guest stopped: \(reason)")
await session.teardown()
} catch {
CLI.error("\(error)")
await session.teardown()
// Fully qualified: inside a ParsableCommand a bare `exit`
// resolves to ParsableCommand.exit(withError:).
await MainActor.run { Foundation.exit(1) }
}
}
)
}
}
/// `vm list` — show ephemeral clones currently on disk.
///
/// Under normal operation this is empty between jobs; anything listed after
/// the daemon has settled is an orphan from an unclean shutdown.
struct List: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "list",
abstract: "List ephemeral VM clones on disk."
)
@OptionGroup var options: GlobalOptions
func run() async throws {
let config = try options.loadConfig()
let store = VMStore(config: config)
try store.ensureLayout()
let clones = try store.listClones()
guard !clones.isEmpty else {
print("no ephemeral clones on disk")
return
}
let leases = DHCPLeaseParser.parseFile()
print("CLONE MAC IP SIZE")
for clone in clones {
let bundleConfig = try? clone.loadConfig()
let mac = bundleConfig?.macAddress ?? "-"
let ip = bundleConfig.flatMap { DHCPLeaseParser.ipAddress(forMAC: $0.macAddress, in: leases) } ?? "-"
let size = (try? clone.diskUsageBytes()).map(CLI.formatBytes) ?? "-"
print(pad(clone.name, 31) + pad(mac, 19) + pad(ip, 17) + size)
}
print("")
CLI.note("clones left behind after the daemon has settled are orphans; `purge` happens at daemon start")
}
private func pad(_ value: String, _ width: Int) -> String {
value.count >= width
? value + " "
: value + String(repeating: " ", count: width - value.count)
}
}
/// Polls `/var/db/dhcpd_leases` for a MAC, as the orchestrator does.
static func waitForLease(mac: String, timeout: Duration) async throws -> String {
let deadline = Date().addingTimeInterval(
TimeInterval(timeout.components.seconds)
)
while Date() < deadline {
if let ip = DHCPLeaseParser.ipAddress(forMAC: mac, in: DHCPLeaseParser.parseFile()) {
return ip
}
try await Task.sleep(for: .seconds(2))
}
throw CoreError.timeout("dhcp lease for \(mac)")
}
}
/// Holds the VM and clone `vm boot` created, so the signal handler can tear them
/// down from outside the task that made them.
actor BootSession {
private let store: VMStore
private let keepClone: Bool
private var bundle: VMBundle?
private var instance: VMInstance?
private var finished = false
init(store: VMStore, keepClone: Bool) {
self.store = store
self.keepClone = keepClone
}
func adopt(bundle: VMBundle, instance: VMInstance) {
self.bundle = bundle
self.instance = instance
}
/// Stops the VM and removes the clone. Idempotent.
func teardown() async {
guard !finished else { return }
finished = true
if let instance {
_ = await instance.requestStopThenForce(gracePeriod: .seconds(30))
}
guard let bundle else { return }
if keepClone {
CLI.note("keeping clone at \(bundle.rootURL.path)")
} else {
do {
try store.deleteClone(bundle)
CLI.note("deleted clone \(bundle.name)")
} catch {
CLI.error("could not delete clone: \(error)")
}
}
}
}
+162
View File
@@ -0,0 +1,162 @@
import ArgumentParser
import Foundation
import Logging
import RunnerCore
/// Options every subcommand accepts.
struct GlobalOptions: ParsableArguments {
/// Path to `config.json`. Tilde-expanded.
@Option(name: [.customLong("config"), .customShort("c")],
help: "Path to config.json (default: ~/.config/gitea-macos-runner/config.json)")
var configPath: String = RunnerConfig.defaultPath
/// Emit debug-level logs.
@Flag(name: .long, help: "Verbose logging.")
var verbose: Bool = false
/// Loads and validates the configuration named by ``configPath``.
func loadConfig() throws -> RunnerConfig {
try RunnerConfig.load(from: configPath).validated()
}
}
/// Root command.
///
/// The tool is both the daemon and its own admin CLI: `daemon` is what
/// `launchd` starts, and everything else is operator-facing.
@main
struct GiteaMacOSRunner: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "gitea-macos-runner",
abstract: "Run Gitea Actions macOS jobs in fresh, ephemeral Virtualization.framework VMs.",
discussion: """
Each queued job that matches this host's labels gets a brand-new macOS VM \
cloned from a base image, an ephemeral runner registered with Gitea, and a \
teardown as soon as the job finishes. Nothing is reused between jobs.
Start with `doctor` to verify the host, then `config init`, then \
`image build`, then `service install`.
""",
version: RunnerVersion.current,
subcommands: [
DaemonCommand.self,
ImageCommand.self,
VMCommand.self,
ServiceCommand.self,
DoctorCommand.self,
ConfigCommand.self,
],
defaultSubcommand: nil
)
}
/// Shared helpers for command bodies.
enum CLI {
/// Prints to stderr.
static func error(_ message: String) {
FileHandle.standardError.write(Data(("error: " + message + "\n").utf8))
}
/// Prints a note to stderr, so it does not pollute pipeable stdout.
static func note(_ message: String) {
FileHandle.standardError.write(Data((message + "\n").utf8))
}
/// Prints an "unimplemented" notice and exits non-zero.
static func unimplemented(_ what: String) throws -> Never {
error("\(what): unimplemented")
throw ExitCode(1)
}
/// Routes swift-log to stderr, leaving stdout for command output.
///
/// Only the first call has any effect: `LoggingSystem.bootstrap` traps when
/// called twice, and subcommands are free to call this independently.
static func bootstrapLogging(verbose: Bool) {
loggingBootstrap.once {
let level: Logger.Level = verbose ? .debug : .info
LoggingSystem.bootstrap { label in
var handler = StreamLogHandler.standardError(label: label)
handler.logLevel = level
return handler
}
}
}
private static let loggingBootstrap = OnceFlag()
/// Asks a yes/no question on stderr. Answers `false` when stdin is not a
/// terminal, so a piped invocation never blocks forever.
static func confirm(_ question: String) -> Bool {
guard isatty(fileno(stdin)) == 1 else { return false }
FileHandle.standardError.write(Data((question + " [y/N] ").utf8))
guard let answer = readLine(strippingNewline: true)?.lowercased() else { return false }
return answer == "y" || answer == "yes"
}
/// Formats a byte count as a human-readable size.
static func formatBytes(_ bytes: Int64) -> String {
let units = ["B", "KB", "MB", "GB", "TB"]
var value = Double(bytes)
var unit = 0
while value >= 1024, unit < units.count - 1 {
value /= 1024
unit += 1
}
return unit == 0
? "\(Int(value)) \(units[unit])"
: String(format: "%.1f %@", value, units[unit])
}
/// Renders a fixed-width progress bar, e.g. `[####------] 40%`.
static func progressBar(_ fraction: Double, width: Int = 30) -> String {
let clamped = min(max(fraction, 0), 1)
let filled = Int((Double(width) * clamped).rounded())
let bar = String(repeating: "#", count: filled) + String(repeating: "-", count: width - filled)
return String(format: "[%@] %3d%%", bar, Int((clamped * 100).rounded()))
}
}
/// A thread-safe "run this exactly once" latch.
final class OnceFlag: @unchecked Sendable {
private let lock = NSLock()
private var done = false
func once(_ body: () -> Void) {
lock.lock()
defer { lock.unlock() }
guard !done else { return }
done = true
body()
}
}
/// Serializes progress output arriving from arbitrary threads and keeps it on a
/// single rewritten stderr line.
final class ProgressPrinter: @unchecked Sendable {
private let lock = NSLock()
private var lastLine = ""
/// Rewrites the current line.
func update(_ line: String) {
lock.lock()
defer { lock.unlock() }
guard line != lastLine else { return }
lastLine = line
let padding = String(repeating: " ", count: max(0, 78 - line.count))
FileHandle.standardError.write(Data(("\r" + line + padding).utf8))
}
/// Ends the line so subsequent output starts cleanly.
func finish(_ line: String? = nil) {
lock.lock()
defer { lock.unlock() }
if let line {
let padding = String(repeating: " ", count: max(0, 78 - line.count))
FileHandle.standardError.write(Data(("\r" + line + padding + "\n").utf8))
} else if !lastLine.isEmpty {
FileHandle.standardError.write(Data("\n".utf8))
}
lastLine = ""
}
}