Nucleic: Gitea Runner macOS VM Support

This commit is contained in:
2026-08-07 00:44:36 -07:00
parent 749f0be4fb
commit 33f299396a
47 changed files with 13159 additions and 0 deletions
@@ -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)
}
}