314 lines
12 KiB
Swift
314 lines
12 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()
|
|
|
|
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 = 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)
|
|
}
|
|
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; 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))
|
|
}
|
|
}
|
|
|
|
/// 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"
|
|
}
|
|
}
|
|
}
|