Nucleic: Gitea Runner macOS VM Support

This commit is contained in:
2026-08-07 00:44:36 -07:00
parent 749f0be4fb
commit 33f299396a
47 changed files with 13159 additions and 0 deletions
+181
View File
@@ -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)
}
}