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) } }