Nucleic: Gitea Runner macOS VM Support
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user