Files

273 lines
9.8 KiB
Swift

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.
///
/// ```
/// <bundle>/
/// disk.asif (or disk.img for the RAW fallback)
/// nvram.bin VZMacAuxiliaryStorage — the guest's NVRAM
/// config.json VMBundleConfig
/// ```
///
/// Base images live under `<storeDir>/images/<name>/`; ephemeral clones under
/// `<storeDir>/vms/<uuid>/`. 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
}()
}