import Foundation import RunnerCore import Virtualization /// The persisted description of a VM, stored alongside its disk in a bundle /// directory. /// /// Virtualization.framework requires that a macOS guest be recreated with /// *exactly* the hardware model and machine identifier it was installed with — /// change either and the guest will not boot. Both are opaque blobs the /// framework hands us at install time, so they are stored verbatim here. /// `Data` encodes to base64 in JSON, which keeps `config.json` human-inspectable. public struct VMBundleConfig: Codable, Sendable, Equatable { /// How the backing disk was created. public enum DiskFormat: String, Codable, Sendable { /// Sparse Apple System Image Format, via `diskutil image create` /// (macOS 26+). Preferred: clones and grows lazily. case asif /// A plain sparse file created with `truncate`. Fallback. case raw } /// `VZMacHardwareModel.dataRepresentation` from the restore image's /// `mostFeaturefulSupportedConfiguration`. public var hardwareModelData: Data /// `VZMacMachineIdentifier.dataRepresentation`. Uniquely identifies the /// "machine"; the guest's Setup Assistant state is tied to it. public var machineIdentifierData: Data /// The NIC MAC, e.g. `aa:bb:0c:dd:ee:ff`. /// /// For a **clone** this is one of the two persistent per-slot MACs, not a /// fresh random address — see ``VMStore`` and docs/DESIGN.md, Verified /// Fact 12. public var macAddress: String /// Backing disk format. public var diskFormat: DiskFormat /// Virtual CPU count. public var cpuCount: Int /// RAM in gibibytes. public var memoryGB: Int /// The guest admin account created during installation. public var guestUsername: String /// Bundle creation timestamp. public var createdAt: Date /// The installed macOS version/build, when known (from /// `VZMacOSRestoreImage.buildVersion`). public var macOSVersion: String? /// Whether guest provisioning (Node.js, `gitea-runner`, sudoers, power /// settings) has completed. A base image is only clonable once this is true. public var provisioned: Bool public init( hardwareModelData: Data, machineIdentifierData: Data, macAddress: String, diskFormat: DiskFormat, cpuCount: Int, memoryGB: Int, guestUsername: String, createdAt: Date = Date(), macOSVersion: String? = nil, provisioned: Bool = false ) { self.hardwareModelData = hardwareModelData self.machineIdentifierData = machineIdentifierData self.macAddress = macAddress self.diskFormat = diskFormat self.cpuCount = cpuCount self.memoryGB = memoryGB self.guestUsername = guestUsername self.createdAt = createdAt self.macOSVersion = macOSVersion self.provisioned = provisioned } } /// A directory holding everything needed to boot one VM. /// /// ``` /// / /// disk.asif (or disk.img for the RAW fallback) /// nvram.bin VZMacAuxiliaryStorage — the guest's NVRAM /// config.json VMBundleConfig /// ``` /// /// Base images live under `/images//`; ephemeral clones under /// `/vms//`. A clone is byte-identical except for `config.json`, /// which is rewritten with the slot's MAC. public struct VMBundle: Sendable, Equatable { /// The bundle directory. public let rootURL: URL /// Wraps an existing directory path. Does not touch the filesystem. public init(rootURL: URL) { self.rootURL = rootURL } // MARK: - Paths /// Path to `config.json`. public var configURL: URL { rootURL.appendingPathComponent("config.json") } /// Path to `nvram.bin`, the `VZMacAuxiliaryStorage` backing file. public var auxiliaryStorageURL: URL { rootURL.appendingPathComponent("nvram.bin") } /// Path to the ASIF disk, used when ``VMBundleConfig/DiskFormat/asif``. public var asifDiskURL: URL { rootURL.appendingPathComponent("disk.asif") } /// Path to the RAW disk, used when ``VMBundleConfig/DiskFormat/raw``. public var rawDiskURL: URL { rootURL.appendingPathComponent("disk.img") } /// The disk file for a given format. public func diskURL(format: VMBundleConfig.DiskFormat) -> URL { switch format { case .asif: return asifDiskURL case .raw: return rawDiskURL } } /// The bundle's directory name — the image name, or the clone's UUID. public var name: String { rootURL.lastPathComponent } /// The disk file this bundle actually uses, per its recorded /// ``VMBundleConfig/diskFormat``. /// /// Reads `config.json`, so the format is never re-probed from the /// filesystem — the builder recorded which of ASIF/RAW it managed to create /// and that record is authoritative. public func diskURL() throws -> URL { diskURL(format: try loadConfig().diskFormat) } // MARK: - Lifecycle /// Creates the bundle directory, failing if it already exists. /// /// - Throws: ``CoreError/bundleCorrupt(_:)`` if the path exists as a file. public func createDirectory() throws { let fm = FileManager.default var isDir: ObjCBool = false if fm.fileExists(atPath: rootURL.path, isDirectory: &isDir) { if isDir.boolValue { throw CoreError.bundleCorrupt("bundle directory already exists: \(rootURL.path)") } throw CoreError.bundleCorrupt("bundle path exists but is a file: \(rootURL.path)") } do { try fm.createDirectory(at: rootURL, withIntermediateDirectories: true) } catch { throw CoreError.bundleCorrupt( "could not create bundle directory \(rootURL.path): \(error.localizedDescription)") } } /// Reads and decodes `config.json`. /// /// - Throws: ``CoreError/bundleCorrupt(_:)`` when absent or undecodable. public func loadConfig() throws -> VMBundleConfig { let data: Data do { data = try Data(contentsOf: configURL) } catch { throw CoreError.bundleCorrupt( "cannot read \(configURL.path): \(error.localizedDescription)") } do { return try Self.decoder.decode(VMBundleConfig.self, from: data) } catch { throw CoreError.bundleCorrupt( "cannot decode \(configURL.path): \(error.localizedDescription)") } } /// Encodes and atomically writes `config.json`. public func saveConfig(_ config: VMBundleConfig) throws { let data: Data do { data = try Self.encoder.encode(config) } catch { throw CoreError.bundleCorrupt( "cannot encode config for \(rootURL.path): \(error.localizedDescription)") } do { // .atomic writes to a temporary sibling and renames, so a crash // mid-write can never leave a half-written config behind. try data.write(to: configURL, options: .atomic) } catch { throw CoreError.bundleCorrupt( "cannot write \(configURL.path): \(error.localizedDescription)") } } /// Whether `config.json`, `nvram.bin`, and the disk all exist. public func isComplete() -> Bool { let fm = FileManager.default guard fm.fileExists(atPath: configURL.path), fm.fileExists(atPath: auxiliaryStorageURL.path), let config = try? loadConfig() else { return false } return fm.fileExists(atPath: diskURL(format: config.diskFormat).path) } /// Total on-disk size of the bundle in bytes, following sparse allocation /// (i.e. blocks actually used, not the disk's nominal size). public func diskUsageBytes() throws -> Int64 { let fm = FileManager.default let keys: [URLResourceKey] = [.isRegularFileKey, .totalFileAllocatedSizeKey, .fileAllocatedSizeKey] guard let enumerator = fm.enumerator( at: rootURL, includingPropertiesForKeys: keys, options: [], errorHandler: nil ) else { throw CoreError.bundleCorrupt("cannot enumerate \(rootURL.path)") } var total: Int64 = 0 for case let url as URL in enumerator { guard let values = try? url.resourceValues(forKeys: Set(keys)), values.isRegularFile == true else { continue } // totalFileAllocatedSize is the blocks actually committed, which for // a sparse ASIF/RAW disk is far below its nominal size. if let allocated = values.totalFileAllocatedSize ?? values.fileAllocatedSize { total += Int64(allocated) } } return total } /// Recursively removes the bundle directory. public func destroy() throws { let fm = FileManager.default guard fm.fileExists(atPath: rootURL.path) else { return } do { try fm.removeItem(at: rootURL) } catch { throw CoreError.bundleCorrupt( "cannot remove \(rootURL.path): \(error.localizedDescription)") } } // MARK: - Coding /// Shared coders. ISO-8601 dates keep `config.json` readable by humans and /// by `jq`; `Data` still encodes as base64, which is what the two opaque /// Virtualization blobs need. private static let decoder: JSONDecoder = { let d = JSONDecoder() d.dateDecodingStrategy = .iso8601 return d }() private static let encoder: JSONEncoder = { let e = JSONEncoder() e.dateEncodingStrategy = .iso8601 e.outputFormatting = [.prettyPrinted, .sortedKeys] return e }() }