Nucleic: Gitea Runner macOS VM Support
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
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.
|
||||
///
|
||||
/// ```
|
||||
/// <storeDir>/
|
||||
/// images/<name>/ base VM bundles (installed + provisioned)
|
||||
/// vms/<uuid>/ 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
|
||||
|
||||
/// `<storeDir>/images`.
|
||||
public var imagesDir: URL { storeDir.appendingPathComponent("images", isDirectory: true) }
|
||||
/// `<storeDir>/vms`.
|
||||
public var clonesDir: URL { storeDir.appendingPathComponent("vms", isDirectory: true) }
|
||||
/// `<storeDir>/ipsw`.
|
||||
public var ipswDir: URL { storeDir.appendingPathComponent("ipsw", isDirectory: true) }
|
||||
/// `<storeDir>/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 `<storeDir>/vms/<uuid>/`.
|
||||
/// - 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user