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
@@ -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 = ""
}
}