Merge nucleic/mellow-dewy-falcon-rjhr into main
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
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
|
||||
}()
|
||||
}
|
||||
Reference in New Issue
Block a user