Files
2026-08-07 04:14:34 -07:00

355 lines
15 KiB
Swift

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.
///
/// Resolved through ``PathResolution`` so it works whether or not the
/// shell got to the glob first: `--ipsw '~/Downloads/UniversalMac_27*.ipsw'`
/// and the unquoted form both land on the same file.
@Option(
name: .long,
help: "Path to a local .ipsw; may be a glob (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)
// Before anything else touches the disk: an unresolvable --ipsw is
// an argument error, and an argument error should not first make the
// operator wait on a config load and a free-space check.
let resolvedIPSW = try ipsw.map { try PathResolution.resolve($0, label: "--ipsw") }
var config = try options.loadConfig()
if let diskGB {
config.guest.diskGB = diskGB
}
let store = VMStore(config: config)
try store.ensureLayout()
// Only a *finished* image blocks a rebuild. An installed but
// unprovisioned bundle is an hour of work that `ImageBuilder.build`
// knows how to resume, so it must get the chance to say so.
if let existing = try store.image(named: name),
(try? existing.loadConfig())?.provisioned == true
{
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 = resolvedIPSW
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 ImageCommand.report(stage, to: printer) }
)
} catch {
printer.finish()
CLI.error("\(error)")
// Not `MainActor.run`: the main actor is parked inside
// `app.run()` for the life of the process, so hopping onto
// it to exit is its own deadlock. See `VZAppRuntime.run`.
VZAppRuntime.flushAndExit(1)
}
// Just seals the line: the builder's own `.done` stage has
// already printed it, and saying it twice down a pipe reads
// like something ran twice.
printer.finish()
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; may be a glob.")
var xcodeXIP: String?
func run() async throws {
CLI.bootstrapLogging(verbose: options.verbose)
// Same treatment as `image build --ipsw`, and for the same reason:
// this is a long path to a big file that people reach for with a
// glob. Resolved first so a bad one costs nothing.
let resolvedXIP = try xcodeXIP.map { try PathResolution.resolve($0, label: "--xcode-xip") }
if let resolvedXIP, !FileManager.default.fileExists(atPath: resolvedXIP) {
throw ValidationError("no file at \(resolvedXIP)")
}
let config = try options.loadConfig()
let store = VMStore(config: config)
guard try store.image(named: name) != nil else {
throw ValidationError("no image named '\(name)'")
}
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 = resolvedXIP
// 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 ImageCommand.report(stage, to: printer) }
)
} catch {
printer.finish()
CLI.error("\(error)")
// Not `MainActor.run`: the main actor is parked inside
// `app.run()` for the life of the process, so hopping onto
// it to exit is its own deadlock. See `VZAppRuntime.run`.
VZAppRuntime.flushAndExit(1)
}
printer.finish("done")
print("provisioned image '\(imageName)'")
}
)
}
}
/// Routes a stage to the progress printer.
///
/// Notes get a line of their own: they are the reason the operator is still
/// watching, and a status line that is about to be overwritten is no place
/// to put "this may be a truncated download".
static func report(_ stage: ImageBuildStage, to printer: ProgressPrinter) {
if case .note(let text) = stage {
printer.line(text)
} else {
printer.update(describe(stage), group: group(of: stage))
}
}
/// The stage a status line belongs to, ignoring its varying payload.
///
/// Two lines share a group exactly when one is meant to overwrite the
/// other. Crossing a group boundary seals the previous line instead, which
/// is why `installing macOS … 100%` survives into scrollback rather than
/// being replaced by `first boot + guest provisioning…`.
static func group(of stage: ImageBuildStage) -> String {
switch stage {
case .downloadingIPSW: return "download"
case .preparing: return "preparing"
case .loadingRestoreImage: return "loading"
case .creatingBundle: return "bundle"
case .note: return "note"
case .installing: return "install"
case .firstBoot: return "firstBoot"
// Each provisioning step is its own headline — "installing Node.js"
// should not erase "downloading gitea-runner" — but a step that carries
// a live percentage keeps rewriting one line rather than scrolling a
// hundred of them, so only the part before the payload identifies it.
case .provisioning(let step): return "provisioning:\(Self.stableHead(of: step))"
case .finalizing: return "finalizing"
case .done: return "done"
}
}
/// The fixed part of a status line: everything before the two-space run that
/// separates a headline from its payload, following the same convention as
/// `installing macOS [====]`. A step with no payload is its own head.
static func stableHead(of step: String) -> String {
guard let separator = step.range(of: " ") else { return step }
return String(step[step.startIndex..<separator.lowerBound])
}
/// 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 "resolving restore image…"
case .loadingRestoreImage:
return "loading restore image metadata…"
case .creatingBundle(let diskGB):
return "creating VM bundle (disk \(diskGB) GB)…"
case .note(let text):
return text
case .installing(let fraction):
return "installing macOS " + CLI.progressBar(fraction)
case .firstBoot:
return "first boot + guest provisioning…"
case .provisioning(let step):
return "provisioning: \(step)"
case .finalizing:
return "finalizing"
case .done:
return "done"
}
}
}