733 lines
31 KiB
Swift
733 lines
31 KiB
Swift
import Foundation
|
|
import RunnerCore
|
|
import Virtualization
|
|
|
|
/// A coarse progress report from ``ImageBuilder``.
|
|
public enum ImageBuildStage: Sendable, Equatable {
|
|
/// Downloading the IPSW.
|
|
case downloadingIPSW(fraction: Double)
|
|
/// Working out which file the operator meant and whether it is usable.
|
|
case preparing
|
|
/// Inside `VZMacOSRestoreImage.image(from:)`, which reports no progress of
|
|
/// its own and is the longest silent stretch of a local-IPSW build.
|
|
case loadingRestoreImage
|
|
/// Creating the disk, NVRAM, and bundle metadata.
|
|
case creatingBundle(diskGB: Int)
|
|
/// An out-of-band remark about the stage in flight — printed on its own
|
|
/// line rather than replacing the status line.
|
|
case note(String)
|
|
/// `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 <N>G <path>`
|
|
/// (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 `<storeDir>/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?(.preparing)
|
|
progress?(.loadingRestoreImage)
|
|
|
|
// Reading a 21 GB archive's metadata takes a while and the
|
|
// framework says nothing while it does. A build that looks frozen
|
|
// is the single most-reported symptom of this command, so say out
|
|
// loud what the other likely explanation is rather than letting the
|
|
// operator guess.
|
|
let watchdog = Task {
|
|
try? await Task.sleep(for: .seconds(60))
|
|
// Cancelling is how a *fast* load ends this task, and a
|
|
// cancelled sleep returns rather than throwing past `try?` — so
|
|
// without this the note prints on every quick failure, which is
|
|
// precisely when it is misleading.
|
|
guard !Task.isCancelled else { return }
|
|
progress?(
|
|
.note(
|
|
"still loading — a truncated or partially downloaded .ipsw can block here; "
|
|
+ "verify the download completed"))
|
|
}
|
|
defer { watchdog.cancel() }
|
|
|
|
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?(.creatingBundle(diskGB: config.guest.diskGB))
|
|
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<Void, any Error>) 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<T: Sendable>(
|
|
_ duration: Duration,
|
|
operation: @escaping @Sendable () async -> T
|
|
) async -> T? {
|
|
await withTaskGroup(of: Optional<T>.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<T>: @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?
|
|
}
|