Nucleic: Gitea Runner macOS VM Support
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user