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,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"
}
}
}