179 lines
6.8 KiB
Swift
179 lines
6.8 KiB
Swift
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)
|
|
}
|
|
}
|