Files
gitea-macos-vm-orchestrator/Sources/RunnerHost/VZConfigFactory.swift
T

182 lines
8.9 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}