182 lines
8.9 KiB
Swift
182 lines
8.9 KiB
Swift
import Foundation
|
||
import RunnerCore
|
||
import Virtualization
|
||
|
||
/// Builds a `VZVirtualMachineConfiguration` from a ``VMBundle``.
|
||
///
|
||
/// The configuration is assembled the same way for base-image installs and for
|
||
/// ephemeral clones; only the bundle differs. Devices are chosen for the minimum
|
||
/// that a headless CI guest needs while still satisfying macOS's own
|
||
/// requirements.
|
||
public enum VZConfigFactory {
|
||
|
||
/// Assembles and validates a configuration.
|
||
///
|
||
/// Composition:
|
||
///
|
||
/// * **Platform** — `VZMacPlatformConfiguration` with `hardwareModel` and
|
||
/// `machineIdentifier` restored from the bundle's stored blobs, and
|
||
/// `auxiliaryStorage` opened from `nvram.bin`. These three must match the
|
||
/// install exactly or the guest will not boot.
|
||
/// * **Boot loader** — `VZMacOSBootLoader`.
|
||
/// * **CPU / memory** — `max(4, config.cpuCount)` clamped into the
|
||
/// framework's supported range; memory likewise clamped.
|
||
/// * **Storage** — `VZVirtioBlockDeviceConfiguration` over a
|
||
/// `VZDiskImageStorageDeviceAttachment` on the bundle's disk.
|
||
/// * **Network** — `VZVirtioNetworkDeviceConfiguration` with a
|
||
/// `VZNATNetworkDeviceAttachment` and the bundle's MAC. NAT, not bridged:
|
||
/// bridged networking requires the restricted
|
||
/// `com.apple.vm.networking` entitlement, which Apple does not grant for
|
||
/// ad-hoc signing, whereas NAT needs nothing beyond
|
||
/// `com.apple.security.virtualization`. NAT is also what puts the guest in
|
||
/// `/var/db/dhcpd_leases`, which is how we discover its IP.
|
||
/// * **Graphics** — a `VZMacGraphicsDeviceConfiguration` with a single
|
||
/// 1920×1200 @ 72 ppi display, configured **always**, even headless. macOS
|
||
/// guests misbehave without a display device; we simply never attach a
|
||
/// `VZVirtualMachineView` to it.
|
||
/// * **Input** — `VZMacKeyboardConfiguration` and a pointing device, needed
|
||
/// for Setup Assistant automation to have something to talk to.
|
||
/// * **Entropy** — `VZVirtioEntropyDeviceConfiguration`, so the guest's RNG
|
||
/// seeds promptly instead of blocking early boot.
|
||
/// * **Socket** — `VZVirtioSocketDeviceConfiguration`, reserved for a future
|
||
/// vsock control channel that would replace SSH.
|
||
///
|
||
/// - Parameters:
|
||
/// - bundle: The VM to configure.
|
||
/// - headless: When `true`, no view will be attached. Retained as a
|
||
/// parameter because `vm boot` may later want a window; it does **not**
|
||
/// change whether the graphics device is present.
|
||
/// - Returns: A configuration that has passed `validate()`.
|
||
/// - Throws: ``CoreError/bundleCorrupt(_:)`` when the bundle's blobs cannot
|
||
/// be restored, or the framework's own validation error.
|
||
public static func makeConfiguration(
|
||
bundle: VMBundle,
|
||
headless: Bool = true
|
||
) throws -> VZVirtualMachineConfiguration {
|
||
let bundleConfig = try bundle.loadConfig()
|
||
|
||
let configuration = VZVirtualMachineConfiguration()
|
||
configuration.platform = try makePlatform(bundle: bundle)
|
||
configuration.bootLoader = VZMacOSBootLoader()
|
||
configuration.cpuCount = clampedCPUCount(bundleConfig.cpuCount)
|
||
configuration.memorySize = clampedMemorySize(gigabytes: bundleConfig.memoryGB)
|
||
|
||
// Storage. The bundle records which of ASIF/RAW the builder produced, so
|
||
// the right file is attached without probing the filesystem.
|
||
let diskURL = bundle.diskURL(format: bundleConfig.diskFormat)
|
||
guard FileManager.default.fileExists(atPath: diskURL.path) else {
|
||
throw CoreError.bundleCorrupt("missing disk image at \(diskURL.path)")
|
||
}
|
||
let attachment: VZDiskImageStorageDeviceAttachment
|
||
do {
|
||
attachment = try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false)
|
||
} catch {
|
||
throw CoreError.bundleCorrupt(
|
||
"cannot attach disk \(diskURL.path): \(error.localizedDescription)")
|
||
}
|
||
configuration.storageDevices = [VZVirtioBlockDeviceConfiguration(attachment: attachment)]
|
||
|
||
// Network: NAT, with the bundle's MAC. NAT is what puts the guest into
|
||
// /var/db/dhcpd_leases, which is the only way we learn its IP.
|
||
guard let mac = VZMACAddress(string: bundleConfig.macAddress) else {
|
||
throw CoreError.bundleCorrupt(
|
||
"malformed MAC address '\(bundleConfig.macAddress)' in \(bundle.configURL.path)")
|
||
}
|
||
let network = VZVirtioNetworkDeviceConfiguration()
|
||
network.attachment = VZNATNetworkDeviceAttachment()
|
||
network.macAddress = mac
|
||
configuration.networkDevices = [network]
|
||
|
||
// Graphics: always present, even headless, and never sized from
|
||
// NSScreen — the daemon runs as a LaunchAgent that may have no attached
|
||
// display at all, and a nil main screen there would be fatal. `headless`
|
||
// only decides whether a VZVirtualMachineView is ever bound to this
|
||
// device; the device itself is unconditional because macOS guests
|
||
// misbehave without one.
|
||
_ = headless
|
||
let graphics = VZMacGraphicsDeviceConfiguration()
|
||
graphics.displays = [
|
||
VZMacGraphicsDisplayConfiguration(
|
||
widthInPixels: 1920,
|
||
heightInPixels: 1200,
|
||
pixelsPerInch: 72
|
||
)
|
||
]
|
||
configuration.graphicsDevices = [graphics]
|
||
|
||
// Input: Setup Assistant automation needs something to talk to.
|
||
configuration.keyboards = [VZMacKeyboardConfiguration()]
|
||
configuration.pointingDevices = [VZMacTrackpadConfiguration()]
|
||
|
||
// Entropy, so the guest's RNG seeds promptly rather than blocking early boot.
|
||
configuration.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]
|
||
|
||
// Exactly one socket device — the framework permits no more. Reserved for
|
||
// the vsock control channel that would eventually replace SSH.
|
||
configuration.socketDevices = [VZVirtioSocketDeviceConfiguration()]
|
||
|
||
try configuration.validate()
|
||
return configuration
|
||
}
|
||
|
||
/// Builds only the platform configuration, so the installer path can share it.
|
||
///
|
||
/// - Parameter bundle: The VM whose hardware model, machine identifier, and
|
||
/// auxiliary storage should be restored.
|
||
public static func makePlatform(bundle: VMBundle) throws -> VZMacPlatformConfiguration {
|
||
let bundleConfig = try bundle.loadConfig()
|
||
let platform = VZMacPlatformConfiguration()
|
||
|
||
guard
|
||
let hardwareModel = VZMacHardwareModel(
|
||
dataRepresentation: bundleConfig.hardwareModelData)
|
||
else {
|
||
throw CoreError.bundleCorrupt(
|
||
"hardwareModelData in \(bundle.configURL.path) is not a valid VZMacHardwareModel")
|
||
}
|
||
guard hardwareModel.isSupported else {
|
||
throw CoreError.hostUnsupported(
|
||
"this host does not support the hardware model recorded in \(bundle.configURL.path)"
|
||
)
|
||
}
|
||
guard
|
||
let machineIdentifier = VZMacMachineIdentifier(
|
||
dataRepresentation: bundleConfig.machineIdentifierData)
|
||
else {
|
||
throw CoreError.bundleCorrupt(
|
||
"machineIdentifierData in \(bundle.configURL.path) is not a valid VZMacMachineIdentifier"
|
||
)
|
||
}
|
||
|
||
// The *existing*-storage initializer. Using
|
||
// VZMacAuxiliaryStorage(creatingStorageAt:hardwareModel:) here would
|
||
// blank the guest's NVRAM and it would no longer boot.
|
||
guard FileManager.default.fileExists(atPath: bundle.auxiliaryStorageURL.path) else {
|
||
throw CoreError.bundleCorrupt("missing nvram.bin at \(bundle.auxiliaryStorageURL.path)")
|
||
}
|
||
platform.auxiliaryStorage = VZMacAuxiliaryStorage(url: bundle.auxiliaryStorageURL)
|
||
platform.hardwareModel = hardwareModel
|
||
platform.machineIdentifier = machineIdentifier
|
||
return platform
|
||
}
|
||
|
||
/// Clamps a requested CPU count into the framework's supported range, with a
|
||
/// floor of 4 — Xcode builds are miserable below that.
|
||
public static func clampedCPUCount(_ requested: Int) -> Int {
|
||
let lowerBound = max(VZVirtualMachineConfiguration.minimumAllowedCPUCount, 4)
|
||
let upperBound = VZVirtualMachineConfiguration.maximumAllowedCPUCount
|
||
// On a host whose maximum is below our floor, the maximum wins.
|
||
guard lowerBound <= upperBound else { return upperBound }
|
||
return min(max(requested, lowerBound), upperBound)
|
||
}
|
||
|
||
/// Clamps a requested memory size (in gibibytes) into the framework's
|
||
/// supported range, returning bytes.
|
||
public static func clampedMemorySize(gigabytes: Int) -> UInt64 {
|
||
let lowerBound = VZVirtualMachineConfiguration.minimumAllowedMemorySize
|
||
let upperBound = VZVirtualMachineConfiguration.maximumAllowedMemorySize
|
||
let requested = UInt64(max(gigabytes, 0)) * 1_073_741_824
|
||
return min(max(requested, lowerBound), upperBound)
|
||
}
|
||
}
|