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 `/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 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; 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 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" } } }