import Foundation import RunnerCore import Virtualization /// A coarse progress report from ``ImageBuilder``. public enum ImageBuildStage: Sendable, Equatable { /// Downloading the IPSW. case downloadingIPSW(fraction: Double) /// Reading the restore image and deriving a hardware configuration. case preparing /// Creating the disk, NVRAM, and bundle metadata. case creatingBundle /// `VZMacOSInstaller` is writing macOS onto the disk. case installing(fraction: Double) /// First boot; waiting for Setup Assistant, a DHCP lease, and SSH. case firstBoot /// Running guest provisioning over SSH. case provisioning(step: String) /// Shutting the guest down cleanly and sealing the bundle. case finalizing /// Done. case done } /// Builds a base macOS image from an IPSW, end to end. /// /// ## Pipeline /// /// 1. **Load restore image** — `VZMacOSRestoreImage` from a local `.ipsw` /// (downloaded first if the caller did not supply one). /// 2. **Derive hardware** — `mostFeaturefulSupportedConfiguration`. A `nil` /// here means this host cannot run this image at all; fail loudly rather /// than trying to guess a configuration. /// 3. **Create the bundle** — persist `hardwareModel.dataRepresentation` and a /// fresh `VZMacMachineIdentifier`; create NVRAM with /// `VZMacAuxiliaryStorage(creatingStorageAt:hardwareModel:)`; create the /// disk, preferring sparse ASIF via /// `/usr/sbin/diskutil image create blank --fs none --format ASIF --size G ` /// (macOS 26+) and falling back to a `truncate`-style sparse RAW file. /// 4. **Install** — `VZMacOSInstaller` against a *stopped* VM built from that /// configuration, observing its `Progress` via KVO. /// 5. **First boot with Setup Assistant automation** — see /// ``firstBootAndProvision(bundle:config:progress:)``. /// 6. **Provision** — ``GuestProvisioner`` over SSH. /// 7. **Finalize** — clean guest shutdown, then set /// ``VMBundleConfig/provisioned`` to `true`. Only then is the image clonable. public struct ImageBuilder: Sendable { /// The store this image is built into. public let store: VMStore /// Creates a builder. public init(store: VMStore) { self.store = store } /// Runs the whole pipeline. /// /// - Parameters: /// - name: Image name under `/images/`, e.g. `default`. /// - ipswPath: A local `.ipsw`. When `nil`, the latest supported image is /// discovered and downloaded. /// - config: Supplies guest shape (CPU/RAM/disk), credentials, and the /// `gitea-runner` download URL. /// - progress: Stage callback. May be invoked from arbitrary threads. /// - Throws: ``CoreError/provisioningFailed(_:)`` naming the failed stage. public func build( name: String, ipswPath: String?, config: RunnerConfig, progress: (@Sendable (ImageBuildStage) -> Void)? = nil ) async throws { try store.ensureLayout() // Checked against the filesystem rather than `store.image(named:)`: a // half-built bundle from a previous failed run is exactly the thing this // needs to catch, and it would not read back as a valid image. let bundleURL = store.imagesDir.appendingPathComponent(name, isDirectory: true) if FileManager.default.fileExists(atPath: bundleURL.path) { throw CoreError.configInvalid( "image '\(name)' already exists at \(bundleURL.path). " + "Delete it first (`image delete \(name)`), or build under a different --name." ) } // The IPSW alone is ~15 GB and the installed disk grows to tens more. try store.ensureFreeSpace(minGB: ipswPath == nil ? 60 : 45) // 1. Restore image. let provider = IPSWProvider(downloadDirectory: store.ipswDir) let restoreImage: VZMacOSRestoreImage if let ipswPath { progress?(.downloadingIPSW(fraction: 1.0)) restoreImage = try await provider.load(localPath: ipswPath) } else { progress?(.downloadingIPSW(fraction: 0)) let (image, _) = try await provider.fetchLatest { fraction in progress?(.downloadingIPSW(fraction: fraction)) } restoreImage = image } // 2/3. Hardware model and bundle. progress?(.preparing) progress?(.creatingBundle) let bundle = try await createBundle(name: name, restoreImage: restoreImage, config: config) // 4. Install. progress?(.installing(fraction: 0)) try await install(bundle: bundle, restoreImage: restoreImage) { fraction in progress?(.installing(fraction: fraction)) } // 5/6/7. First boot, provisioning, seal. try await firstBootAndProvision(bundle: bundle, config: config, progress: progress) progress?(.done) } /// Creates the bundle directory, disk, NVRAM, and `config.json`. /// /// - Parameters: /// - name: Image name. /// - restoreImage: The loaded IPSW, used for its /// `mostFeaturefulSupportedConfiguration` and `buildVersion`. /// - config: Guest shape and credentials. /// - Returns: The new, uninstalled bundle. public func createBundle( name: String, restoreImage: VZMacOSRestoreImage, config: RunnerConfig ) async throws -> VMBundle { // `mostFeaturefulSupportedConfiguration` is nil when this host cannot run // this image at all — an Intel host, or a restore image newer than the // host's Virtualization stack. There is nothing to fall back to, and // guessing a hardware model produces a VM that fails to boot much later // with a far less useful message. guard let requirements = restoreImage.mostFeaturefulSupportedConfiguration else { let version = restoreImage.operatingSystemVersion throw CoreError.hostUnsupported( "this host cannot virtualize macOS \(version.majorVersion).\(version.minorVersion) " + "(build \(restoreImage.buildVersion)). The restore image reports no supported " + "configuration — the host is either not Apple silicon or is older than the guest." ) } let hardwareModel = requirements.hardwareModel guard hardwareModel.isSupported else { throw CoreError.hostUnsupported( "the hardware model required by build \(restoreImage.buildVersion) is not supported on this host" ) } // The image's own minimums win over the configured shape: a guest below // them will not boot, and silently honouring a too-small config would // produce that failure at first boot instead of here. let cpuCount = max(config.guest.cpuCount, requirements.minimumSupportedCPUCount) let minimumMemoryGB = Int( (requirements.minimumSupportedMemorySize + (1 << 30) - 1) / (1 << 30) ) let memoryGB = max(config.guest.memoryGB, minimumMemoryGB) let bundle = VMBundle(rootURL: store.imagesDir.appendingPathComponent(name, isDirectory: true)) try bundle.createDirectory() do { let diskFormat = try createDisk(bundle: bundle, sizeGB: config.guest.diskGB) // NVRAM must be created against the *same* hardware model that goes // into config.json and into VZMacPlatformConfiguration. A mismatch is // undefined behaviour in the framework, not a validation error. _ = try VZMacAuxiliaryStorage( creatingStorageAt: bundle.auxiliaryStorageURL, hardwareModel: hardwareModel, options: [] ) let osVersion = restoreImage.operatingSystemVersion let bundleConfig = VMBundleConfig( hardwareModelData: hardwareModel.dataRepresentation, machineIdentifierData: VZMacMachineIdentifier().dataRepresentation, // A placeholder: the base image is never booted on a slot. Every // clone rewrites this with its slot's persistent MAC, which is // what DHCP lease discovery keys on. It still has to be a valid // locally-administered address, because the base image *is* // booted once, here, for provisioning. macAddress: VZMACAddress.randomLocallyAdministered().string, diskFormat: diskFormat, cpuCount: cpuCount, memoryGB: memoryGB, guestUsername: config.guest.username, macOSVersion: "\(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion) " + "(\(restoreImage.buildVersion))", provisioned: false ) try bundle.saveConfig(bundleConfig) } catch { // A bundle that got partway through creation is not something a later // run can recover from, and leaving it behind would make `build` with // the same name fail on the "already exists" check for the wrong // reason. try? bundle.destroy() throw error } return bundle } /// Creates the backing disk, preferring sparse ASIF. /// /// ASIF (`diskutil image create blank --fs none --format ASIF`) is available /// from macOS 26 and is the right choice here: it is sparse, so a 64 GB /// nominal disk costs what the guest actually writes, and it CoW-clones /// cleanly on APFS. If `diskutil` fails for any reason, a sparse RAW file is /// created instead and the format recorded in the bundle config so /// ``VZConfigFactory`` attaches the right file. /// /// - Parameters: /// - bundle: Destination bundle. /// - sizeGB: Nominal disk size. /// - Returns: The format that was actually used. public func createDisk(bundle: VMBundle, sizeGB: Int) throws -> VMBundleConfig.DiskFormat { guard sizeGB > 0 else { throw CoreError.configInvalid("guest.diskGB must be positive, got \(sizeGB)") } let asifURL = bundle.asifDiskURL try? FileManager.default.removeItem(at: asifURL) // No `#available` guard: the package's deployment target is already // macOS 26, so the compiler would reject the check as redundant. The // runtime feature check that matters is whether *this* diskutil // understands `--format ASIF`, which the exit status answers directly — // that also covers early 26 builds where the format was still landing. do { try ImageBuilder.runProcess( "/usr/sbin/diskutil", [ "image", "create", "blank", "--fs", "none", "--format", "ASIF", "--size", "\(sizeGB)G", asifURL.path, ] ) // diskutil occasionally appends its own extension; accept either // spelling rather than failing on a cosmetic difference. if !FileManager.default.fileExists(atPath: asifURL.path) { let suffixed = URL(fileURLWithPath: asifURL.path + ".asif") if FileManager.default.fileExists(atPath: suffixed.path) { try FileManager.default.moveItem(at: suffixed, to: asifURL) } } if FileManager.default.fileExists(atPath: asifURL.path) { return .asif } } catch { // Fall through to RAW. } try? FileManager.default.removeItem(at: asifURL) // RAW fallback: an empty file extended to the nominal size. APFS keeps it // sparse, so this costs nothing until the guest writes. Sizes are decimal // GB (1000³) to match what `diskutil … --size NG` produces, so switching // formats does not silently change the guest's disk size. let rawURL = bundle.rawDiskURL try? FileManager.default.removeItem(at: rawURL) guard FileManager.default.createFile(atPath: rawURL.path, contents: nil) else { throw CoreError.provisioningFailed("could not create the disk image at \(rawURL.path)") } let handle = try FileHandle(forWritingTo: rawURL) defer { try? handle.close() } do { try handle.truncate(atOffset: UInt64(sizeGB) * 1_000_000_000) } catch { throw CoreError.provisioningFailed( "could not size the disk image at \(rawURL.path) to \(sizeGB) GB: \(error.localizedDescription)" ) } return .raw } /// Runs `VZMacOSInstaller` to completion. /// /// - Parameters: /// - bundle: The bundle to install into. /// - restoreImage: The loaded IPSW. /// - progress: Called with the installer's completed fraction. public func install( bundle: VMBundle, restoreImage: VZMacOSRestoreImage, progress: (@Sendable (Double) -> Void)? = nil ) async throws { let imageURL = restoreImage.url guard imageURL.isFileURL else { // The image returned by `VZMacOSRestoreImage.latestSupported` carries // a CDN URL. Handing that to the installer fails deep inside the // framework; catching it here names the actual mistake. throw CoreError.configInvalid( "VZMacOSInstaller needs a local restore image, but this one points at " + "\(imageURL.absoluteString). Download it first with IPSWProvider.download." ) } let configuration = try VZConfigFactory.makeConfiguration(bundle: bundle, headless: true) // Everything about VZMacOSInstaller is queue-bound: the VM must be // created on a queue, the installer must be *constructed* on that same // queue with the VM stopped, and `install` must be *called* on it too. // The VM is also created here rather than through VMInstance because the // installer needs the VZVirtualMachine object itself, and because the VM // must never be started, paused, or stopped while installing — behaviour // VMInstance exists to provide and which would be actively harmful here. let queue = DispatchQueue(label: "gitea-macos-runner.install.\(bundle.name)") let session = InstallSession() let boxedConfiguration = UncheckedBox(configuration) try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in queue.async { let virtualMachine = VZVirtualMachine( configuration: boxedConfiguration.value, queue: queue ) let installer = VZMacOSInstaller( virtualMachine: virtualMachine, restoringFromImageAt: imageURL ) // Held for the duration: the completion handler is the only other // strong reference, and dropping the VM mid-install would be a // use-after-free rather than a cancellation. session.virtualMachine = virtualMachine session.installer = installer if let progress { session.observation = installer.progress.observe( \.fractionCompleted, options: [.initial, .new] ) { observed, _ in progress(observed.fractionCompleted) } } installer.install { result in session.observation = nil session.installer = nil session.virtualMachine = nil switch result { case .success: progress?(1.0) continuation.resume() case .failure(let error): continuation.resume(throwing: VMInstance.mapVZError(error)) } } } } } /// Boots the freshly installed guest, gets it onto the network, and hands it /// to ``GuestProvisioner``. /// /// ## Setup Assistant /// /// A newly installed macOS sits at Setup Assistant with no account and no /// SSH. On a **macOS 27+ host with a macOS 27+ guest**, Virtualization can /// automate that: build a `VZMacGuestProvisioningOptions` carrying the /// configured username, password, and full name, with /// `logsInAutomatically = true` and `enablesRemoteLogin = true`, and attach /// it to the start options via /// `VZMacOSVirtualMachineStartOptions.setGuestProvisioning(_:)` — the ObjC /// selector is `setGuestProvisioningOptions:error:`, but Swift imports it /// under the shorter name. The guest then creates the account and enables /// SSH unattended. /// /// - Important: An **older guest silently ignores** these options — no /// error, no account, no SSH, and this method will simply time out waiting /// for a lease or a login. When that happens the only recovery is a /// manual, GUI-driven first boot, which is deliberately **out of v1 /// scope**: this method fails with an explanatory /// ``CoreError/provisioningFailed(_:)`` telling the operator that a /// `--manual-setup` flow is not implemented and that the IPSW must be /// macOS 27 or newer. /// /// The API itself is gated at `#available(macOS 27.0, *)`, so a macOS 26 /// host takes the same explanatory failure path. /// /// - Parameters: /// - bundle: The installed bundle. /// - config: Guest credentials and timeouts. /// - progress: Stage callback. public func firstBootAndProvision( bundle: VMBundle, config: RunnerConfig, progress: (@Sendable (ImageBuildStage) -> Void)? = nil ) async throws { guard #available(macOS 27.0, *) else { throw CoreError.hostUnsupported( """ automating Setup Assistant requires macOS 27 or newer on the host; this host is older. \ Without it the freshly installed guest sits at the setup screen forever, with no \ account and no SSH. A manual, GUI-driven first boot is not implemented in v1. """ ) } let provisioningOptions = VZMacGuestProvisioningOptions() provisioningOptions.username = config.guest.username provisioningOptions.password = config.guest.password provisioningOptions.fullName = ImageBuilder.guestAccountFullName // Auto-login keeps a GUI session alive, which codesign against the login // keychain and the simulators both need. provisioningOptions.logsInAutomatically = true // This is what turns on sshd — the only channel provisioning has. provisioningOptions.enablesRemoteLogin = true let startOptions = VZMacOSVirtualMachineStartOptions() do { // The validating setter: it rejects, for instance, a password that // the guest's account policy will not accept, here rather than by // quietly producing a guest with no usable account. try startOptions.setGuestProvisioning(provisioningOptions) } catch { throw CoreError.configInvalid( "guest provisioning options were rejected (check guest.username / guest.password): " + error.localizedDescription ) } try await bootProvisionAndSeal( bundle: bundle, config: config, startOptions: startOptions, isFirstBoot: true, xcodeXIPPath: nil, progress: progress ) } /// Re-runs guest provisioning against an already-installed image. /// /// Backs `image provision NAME`, which exists so that bumping the /// `gitea-runner` version or adding Xcode does not require a 15 GB /// reinstall. /// /// - Parameters: /// - name: Image name. /// - config: Guest credentials and download URLs. /// - xcodeXIPPath: Optional Xcode `.xip` to install as well. /// - progress: Stage callback. public func reprovision( name: String, config: RunnerConfig, xcodeXIPPath: String? = nil, progress: (@Sendable (ImageBuildStage) -> Void)? = nil ) async throws { let bundleURL = store.imagesDir.appendingPathComponent(name, isDirectory: true) guard FileManager.default.fileExists(atPath: bundleURL.path) else { throw CoreError.notFound("image '\(name)' at \(bundleURL.path)") } let bundle = VMBundle(rootURL: bundleURL) if let xcodeXIPPath { let expanded = (xcodeXIPPath as NSString).expandingTildeInPath guard FileManager.default.fileExists(atPath: expanded) else { throw CoreError.notFound("Xcode .xip at \(expanded)") } } // No start options: the account already exists, so there is nothing for // Setup Assistant automation to do, and re-applying it on a guest that is // already past first boot has no effect anyway (macOS only evaluates // guest provisioning on the first boot after a restore). try await bootProvisionAndSeal( bundle: bundle, config: config, startOptions: nil, isFirstBoot: false, xcodeXIPPath: xcodeXIPPath, progress: progress ) } // MARK: - Boot, provision, seal /// The shared tail of ``firstBootAndProvision(bundle:config:progress:)`` and /// ``reprovision(name:config:xcodeXIPPath:progress:)``: boot, find the guest /// on the network, provision it, shut it down cleanly, mark it provisioned. private func bootProvisionAndSeal( bundle: VMBundle, config: RunnerConfig, startOptions: VZMacOSVirtualMachineStartOptions?, isFirstBoot: Bool, xcodeXIPPath: String?, progress: (@Sendable (ImageBuildStage) -> Void)? ) async throws { var bundleConfig = try bundle.loadConfig() let macAddress = bundleConfig.macAddress let bootTimeout = Duration.seconds(max(60, config.scheduler.bootTimeoutSeconds)) progress?(.firstBoot) let instance = try VMInstance(bundle: bundle, label: "image:\(bundle.name)", headless: true) do { try await instance.start(options: startOptions) } catch { throw CoreError.provisioningFailed( "could not boot image '\(bundle.name)': \(error)" ) } let address: String do { // A DHCP lease is the first observable sign of life: the guest has // booted far enough to bring up its NIC. SSH comes tens of seconds // later, once launchd has started sshd. address = try await ImageBuilder.waitForDHCPLease(macAddress: macAddress, timeout: bootTimeout) try await waitForSSH( host: address, username: config.guest.username, password: config.guest.password, timeout: bootTimeout ) } catch { _ = await instance.requestStopThenForce() if isFirstBoot { // The most likely cause by far, and the one with no diagnostic of // its own: a pre-27 guest accepts the provisioning options and // ignores them, so it sits at Setup Assistant with no account and // no sshd while we wait for a login that will never be possible. throw CoreError.provisioningFailed( """ the guest never became reachable over SSH within \(config.scheduler.bootTimeoutSeconds)s. The usual cause is a guest older than macOS 27: earlier versions do not implement \ the automated setup protocol and silently ignore the provisioning options, leaving \ the VM parked at Setup Assistant with no account and no Remote Login. Rebuild with \ a macOS 27 or newer restore image. A manual, GUI-driven first boot is not implemented in v1. Underlying error: \(error) """ ) } throw CoreError.provisioningFailed( "image '\(bundle.name)' booted but never became reachable over SSH: \(error)" ) } let executor = SSHExecutor( host: address, username: config.guest.username, password: config.guest.password ) do { let provisioner = GuestProvisioner() try await provisioner.provision(executor: executor, config: config) { step in progress?(.provisioning(step: step)) } if let xcodeXIPPath { progress?(.provisioning(step: "Xcode")) try await provisioner.installXcode( executor: executor, xipPath: (xcodeXIPPath as NSString).expandingTildeInPath ) } } catch { await executor.close() _ = await instance.requestStopThenForce() throw error } // Shut down from inside. A forced stop is a power cut: it leaves the // guest's filesystem in whatever state it was in, and every clone would // inherit that state, so the graceful path is worth waiting for. progress?(.finalizing) _ = try? await executor.run("sudo -n /sbin/shutdown -h now", timeout: .seconds(30)) await executor.close() let stopped = await ImageBuilder.withTimeout(.seconds(180)) { await instance.waitUntilStopped() } if stopped == nil { _ = await instance.requestStopThenForce() } bundleConfig.provisioned = true try bundle.saveConfig(bundleConfig) } // MARK: - Helpers /// Full name for the account Setup Assistant automation creates. static let guestAccountFullName = "Gitea Runner" /// Polls `/var/db/dhcpd_leases` until the guest's MAC appears. /// /// - Parameters: /// - macAddress: The bundle's MAC, in any common formatting. /// - timeout: Overall ceiling. /// - pollInterval: Delay between reads. Defaults to 2 s. /// - Returns: The leased IP address. /// - Throws: ``CoreError/timeout(_:)`` if no lease appears in time. static func waitForDHCPLease( macAddress: String, timeout: Duration, pollInterval: Duration = .seconds(2) ) async throws -> String { let started = ContinuousClock.now while true { let leases = DHCPLeaseParser.parseFile() if let address = DHCPLeaseParser.ipAddress(forMAC: macAddress, in: leases) { return address } guard ContinuousClock.now - started < timeout else { break } try await Task.sleep(for: pollInterval) guard ContinuousClock.now - started < timeout else { break } } throw CoreError.timeout("no DHCP lease for \(macAddress) in /var/db/dhcpd_leases") } /// Runs an async operation with a ceiling, returning `nil` if it elapses. /// /// Used for the graceful-shutdown wait, which otherwise has no bound: /// `waitUntilStopped()` waits forever, and a guest that hangs on shutdown /// would hang the build with it. static func withTimeout( _ duration: Duration, operation: @escaping @Sendable () async -> T ) async -> T? { await withTaskGroup(of: Optional.self) { group in group.addTask { await operation() } group.addTask { try? await Task.sleep(for: duration) return nil } let first = await group.next() ?? nil group.cancelAll() return first } } /// Runs a host process and throws with its output if it exits non-zero. @discardableResult static func runProcess(_ executablePath: String, _ arguments: [String]) throws -> String { let process = Process() process.executableURL = URL(fileURLWithPath: executablePath) process.arguments = arguments let pipe = Pipe() process.standardOutput = pipe process.standardError = pipe do { try process.run() } catch { throw CoreError.processFailed( command: "\(executablePath) \(arguments.joined(separator: " "))", exitCode: -1, output: error.localizedDescription ) } // Drained before waiting: a command that outfills the pipe buffer would // block forever otherwise. let data = pipe.fileHandleForReading.readDataToEndOfFile() process.waitUntilExit() let output = String(decoding: data, as: UTF8.self) guard process.terminationStatus == 0 else { throw CoreError.processFailed( command: "\(executablePath) \(arguments.joined(separator: " "))", exitCode: process.terminationStatus, output: output ) } return output } } // MARK: - Install plumbing /// Carries a non-`Sendable` Virtualization object onto the VM's serial queue. /// /// The framework's configuration objects are not `Sendable` and never will be, /// but handing one to the queue that will own the VM is exactly the transfer the /// framework itself prescribes. private final class UncheckedBox: @unchecked Sendable { let value: T init(_ value: T) { self.value = value } } /// Owns the VM, installer, and KVO observation for one install. /// /// Every field is read and written only on the install queue, which is what /// makes the unchecked conformance sound. private final class InstallSession: @unchecked Sendable { var virtualMachine: VZVirtualMachine? var installer: VZMacOSInstaller? var observation: NSKeyValueObservation? }