import Foundation import RunnerCore import Virtualization /// Host-level state persisted across daemon restarts. /// /// The only thing in it today is the pair of per-slot MAC addresses, but it is /// versioned so future fields (saved-state handles, image pins) can be added. public struct HostState: Codable, Sendable, Equatable { /// Schema version of this file. public var version: Int /// One MAC per VM slot, generated once with /// `VZMACAddress.randomLocallyAdministered()` and then **never changed**. /// /// Reusing a small fixed set of MACs is deliberate. macOS's `bootpd` hands /// out 24-hour leases and records each in `/var/db/dhcpd_leases`; a fleet /// that randomized a MAC per ephemeral VM would leave a day's worth of dead /// leases behind and eventually exhaust the NAT subnet. Two persistent MACs /// mean each slot simply renews the same lease forever. public var slotMACAddresses: [String] public init(version: Int = 1, slotMACAddresses: [String] = []) { self.version = version self.slotMACAddresses = slotMACAddresses } } /// Owns the on-disk layout of images, ephemeral clones, IPSWs, and host state. /// /// ``` /// / /// images// base VM bundles (installed + provisioned) /// vms// ephemeral clones, destroyed after each job /// ipsw/ downloaded restore images /// state.json HostState /// ``` public struct VMStore: Sendable { /// Root directory, tilde-expanded by the caller. public let storeDir: URL /// Creates a store rooted at `storeDir`. Does not touch the filesystem; /// call ``ensureLayout()`` first. public init(storeDir: URL) { self.storeDir = storeDir } /// Convenience initializer reading ``RunnerConfig/storeDirectoryURL``. public init(config: RunnerConfig) { self.init(storeDir: config.storeDirectoryURL) } // MARK: - Paths /// `/images`. public var imagesDir: URL { storeDir.appendingPathComponent("images", isDirectory: true) } /// `/vms`. public var clonesDir: URL { storeDir.appendingPathComponent("vms", isDirectory: true) } /// `/ipsw`. public var ipswDir: URL { storeDir.appendingPathComponent("ipsw", isDirectory: true) } /// `/state.json`. public var stateURL: URL { storeDir.appendingPathComponent("state.json") } /// Creates every directory in the layout if missing. public func ensureLayout() throws { let fm = FileManager.default for dir in [storeDir, imagesDir, clonesDir, ipswDir] { do { try fm.createDirectory(at: dir, withIntermediateDirectories: true) } catch { throw CoreError.bundleCorrupt( "cannot create \(dir.path): \(error.localizedDescription)") } } } // MARK: - Images /// Names of every base image, sorted. public func listImages() throws -> [String] { let fm = FileManager.default guard fm.fileExists(atPath: imagesDir.path) else { return [] } let entries: [URL] do { entries = try fm.contentsOfDirectory( at: imagesDir, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles] ) } catch { throw CoreError.bundleCorrupt( "cannot list \(imagesDir.path): \(error.localizedDescription)") } return entries .filter { (try? $0.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true } // A directory without a decodable config.json is not an image — it is // a half-finished build or somebody's scratch folder. Skip silently. .filter { (try? VMBundle(rootURL: $0).loadConfig()) != nil } .map { $0.lastPathComponent } .sorted() } /// The bundle for a named base image. /// /// - Parameter name: Image name, e.g. `default`. /// - Returns: The bundle, or `nil` when no such directory exists. public func image(named name: String) throws -> VMBundle? { let url = imagesDir.appendingPathComponent(name, isDirectory: true) var isDir: ObjCBool = false guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir), isDir.boolValue else { return nil } return VMBundle(rootURL: url) } /// Deletes a base image and everything in it. public func deleteImage(named name: String) throws { guard let bundle = try image(named: name) else { throw CoreError.notFound("image '\(name)'") } try bundle.destroy() } // MARK: - Clones /// Copy-on-write clones a base image into a fresh ephemeral bundle. /// /// Cloning is done with `FileManager.copyItem` **per file**, which on APFS /// performs a copy-on-write clone: the new disk costs almost nothing until /// the guest writes to it. Two constraints follow, and both are enforced /// here: /// /// * Source and destination must be on the **same APFS volume**, so images /// and clones both live under `storeDir`. /// * A CoW clone's *apparent* size is the full disk size while its real cost /// grows with guest writes, so ``ensureFreeSpace(minGB:)`` must be called /// before cloning and the floor kept generous. /// /// The clone's `config.json` is rewritten with `slotMAC` so the VM comes up /// on its slot's persistent address; everything else is inherited. /// /// - Parameters: /// - name: Base image name. Must be ``VMBundleConfig/provisioned``. /// - slotMAC: The persistent MAC for the slot this clone will occupy. /// - Returns: The new clone bundle under `/vms//`. /// - Throws: ``CoreError/notFound(_:)`` if the image is missing, /// ``CoreError/bundleCorrupt(_:)`` if it is unprovisioned or incomplete. public func cloneImage(named name: String, slotMAC: String) throws -> VMBundle { guard let source = try image(named: name) else { throw CoreError.notFound("base image '\(name)' under \(imagesDir.path)") } let sourceConfig = try source.loadConfig() guard sourceConfig.provisioned else { throw CoreError.bundleCorrupt( "base image '\(name)' is not provisioned; run `image build` to completion first") } guard source.isComplete() else { throw CoreError.bundleCorrupt( "base image '\(name)' is missing its disk, nvram.bin, or config.json") } try ensureLayout() let fm = FileManager.default let destination = VMBundle( rootURL: clonesDir.appendingPathComponent(UUID().uuidString, isDirectory: true)) try destination.createDirectory() // Anything that fails past this point leaves a partial clone behind, and // a partial clone is worse than none: it would be counted by // `listClones` and booted by nobody. func abort(_ error: any Error) -> any Error { try? destination.destroy() return error } do { // Per-file `copyItem`, NOT a directory copy: APFS performs a // copy-on-write clone for a regular file copied within the same // volume, so this is effectively instantaneous and costs no space // until the guest writes. It is *only* copy-on-write when source and // destination share a volume — which is why images/ and vms/ both // live under storeDir (docs/DESIGN.md, Verified Fact 13). Cloning // across volumes silently degrades to a full byte copy of a // multi-gigabyte disk. let diskName = source.diskURL(format: sourceConfig.diskFormat) try fm.copyItem( at: diskName, to: destination.diskURL(format: sourceConfig.diskFormat)) try fm.copyItem(at: source.auxiliaryStorageURL, to: destination.auxiliaryStorageURL) try fm.copyItem(at: source.configURL, to: destination.configURL) } catch { throw abort( CoreError.bundleCorrupt( "cannot clone image '\(name)': \(error.localizedDescription)")) } do { // Rewrite only the MAC. The machine identifier is deliberately // SHARED with the base image: the guest's Setup Assistant state and // its installed system are tied to it, regenerating it would present // the guest with new hardware, and a future save/restore path // (docs/DESIGN.md §9) forbids changing the ECID anyway. var cloneConfig = sourceConfig cloneConfig.macAddress = slotMAC cloneConfig.provisioned = true try destination.saveConfig(cloneConfig) } catch { throw abort(error) } return destination } /// Removes an ephemeral clone. Safe to call twice. /// /// - Parameter bundle: A bundle previously returned by /// ``cloneImage(named:slotMAC:)``. Refuses to delete anything outside /// ``clonesDir``. public func deleteClone(_ bundle: VMBundle) throws { // `rm -rf` driven by a path that came from elsewhere deserves a guard. let root = clonesDir.standardizedFileURL.resolvingSymlinksInPath().path let target = bundle.rootURL.standardizedFileURL.resolvingSymlinksInPath().path guard target.hasPrefix(root.hasSuffix("/") ? root : root + "/"), target != root else { throw CoreError.bundleCorrupt( "refusing to delete \(bundle.rootURL.path): not inside \(clonesDir.path)") } try bundle.destroy() } /// Every ephemeral clone currently on disk. /// /// Used at startup to garbage-collect clones orphaned by a crash. public func listClones() throws -> [VMBundle] { let fm = FileManager.default guard fm.fileExists(atPath: clonesDir.path) else { return [] } let entries: [URL] do { entries = try fm.contentsOfDirectory( at: clonesDir, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles] ) } catch { throw CoreError.bundleCorrupt( "cannot list \(clonesDir.path): \(error.localizedDescription)") } return entries .filter { (try? $0.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true } .sorted { $0.lastPathComponent < $1.lastPathComponent } .map { VMBundle(rootURL: $0) } } /// Deletes every clone. Called on daemon startup, before any VM is booted. public func purgeClones() throws { // Best-effort per clone: one undeletable directory must not stop the // daemon from starting, so the first failure is remembered and rethrown // only after every other clone has been tried. var firstError: (any Error)? for clone in try listClones() { do { try deleteClone(clone) } catch { if firstError == nil { firstError = error } } } if let firstError { throw firstError } } // MARK: - Host state /// Reads `state.json`, returning a fresh ``HostState`` when absent. public func loadState() throws -> HostState { guard FileManager.default.fileExists(atPath: stateURL.path) else { return HostState() } do { let data = try Data(contentsOf: stateURL) return try JSONDecoder().decode(HostState.self, from: data) } catch { throw CoreError.bundleCorrupt( "cannot read \(stateURL.path): \(error.localizedDescription)") } } /// Atomically writes `state.json`. public func saveState(_ state: HostState) throws { try ensureLayout() do { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] try encoder.encode(state).write(to: stateURL, options: .atomic) } catch { throw CoreError.bundleCorrupt( "cannot write \(stateURL.path): \(error.localizedDescription)") } } /// Returns the persistent MAC for a slot, generating and persisting the /// whole table the first time. /// /// - Parameters: /// - slot: Slot index. /// - slotCount: How many slots to provision addresses for. /// - Returns: A MAC string such as `aa:bb:0c:dd:ee:ff`. public func macAddress(forSlot slot: Int, slotCount: Int) throws -> String { guard slot >= 0, slot < slotCount else { throw CoreError.configInvalid( "slot \(slot) is out of range for \(slotCount) slot(s)") } var state = try loadState() if state.slotMACAddresses.count < slotCount { // Generated exactly once and then persisted forever. See HostState's // doc comment and docs/DESIGN.md, Verified Fact 12: randomizing a MAC // per ephemeral clone would strand a 24-hour bootpd lease per boot // and eventually exhaust the NAT subnet. while state.slotMACAddresses.count < slotCount { state.slotMACAddresses.append( VZMACAddress.randomLocallyAdministered().string) } try saveState(state) } return state.slotMACAddresses[slot] } // MARK: - Disk space /// Free space on the store's volume, in bytes. /// /// Uses the *important usage* resource key so the number matches what Finder /// reports and accounts for purgeable space. public func freeDiskSpace() throws -> Int64 { // The volume keys only resolve for a path that exists, and the daemon may // call this before anything has been created. try ensureLayout() do { let values = try storeDir.resourceValues(forKeys: [ .volumeAvailableCapacityForImportantUsageKey ]) guard let available = values.volumeAvailableCapacityForImportantUsage else { throw CoreError.notFound( "free-space information for the volume holding \(storeDir.path)") } return available } catch let error as CoreError { throw error } catch { throw CoreError.notFound( "free space for \(storeDir.path): \(error.localizedDescription)") } } /// Throws unless the store volume has at least `minGB` free. /// /// - Throws: ``CoreError/insufficientDiskSpace(requiredGB:availableGB:)``. public func ensureFreeSpace(minGB: Int) throws { guard minGB > 0 else { return } let availableBytes = try freeDiskSpace() let availableGB = Int(availableBytes / 1_073_741_824) guard availableGB >= minGB else { throw CoreError.insufficientDiskSpace(requiredGB: minGB, availableGB: availableGB) } } }