273 lines
9.8 KiB
Swift
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
|
|
}()
|
|
}
|