Nucleic: Gitea Runner macOS VM Support

This commit is contained in:
2026-08-07 00:44:36 -07:00
parent 749f0be4fb
commit 33f299396a
47 changed files with 13159 additions and 0 deletions
+366
View File
@@ -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)
}
}
}