Merge nucleic/mellow-dewy-falcon-rjhr into main

This commit is contained in:
2026-08-07 00:44:36 -07:00
parent 749f0be4fb
commit bc2b6cd33b
47 changed files with 13159 additions and 0 deletions
+195
View File
@@ -0,0 +1,195 @@
import ArgumentParser
import Foundation
import RunnerCore
import RunnerHost
/// `gitea-macos-runner vm …` — debugging helpers that operate on VMs directly,
/// without any Gitea involvement.
struct VMCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "vm",
abstract: "Boot and inspect VMs directly (debugging).",
subcommands: [Boot.self, List.self]
)
/// `vm boot --image NAME` — clone an image, boot it, print its IP, wait.
///
/// The fastest way to answer "is the image itself broken, or is it the
/// Gitea integration?". Clones the image onto slot 0's MAC, boots it, waits
/// for a DHCP lease, prints the address and an `ssh` line, then blocks until
/// Ctrl-C — at which point the VM is stopped and the clone deleted, exactly
/// as the daemon would.
struct Boot: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "boot",
abstract: "Clone an image, boot it, print its IP, and wait for Ctrl-C."
)
@OptionGroup var options: GlobalOptions
/// Base image to clone.
@Option(name: .long, help: "Base image to clone.")
var image: String = "default"
/// Which slot's persistent MAC to use.
@Option(name: .long, help: "Slot index whose persistent MAC the clone should use.")
var slot: Int = 0
/// Leave the clone on disk after exit, for post-mortem inspection.
@Flag(name: .long, help: "Do not delete the clone on exit.")
var keep: Bool = false
func run() async throws {
CLI.bootstrapLogging(verbose: options.verbose)
let config = try options.loadConfig()
let store = VMStore(config: config)
try store.ensureLayout()
guard try store.image(named: image) != nil else {
throw ValidationError("no image named '\(image)'")
}
try store.ensureFreeSpace(minGB: config.storage.minFreeDiskGB)
let session = BootSession(store: store, keepClone: keep)
let slotIndex = slot
let imageName = image
let bootTimeout = Duration.seconds(max(30, config.scheduler.bootTimeoutSeconds))
let username = config.guest.username
await VZAppRuntime.run(
onSignal: { await session.teardown() },
body: {
do {
let mac = try store.macAddress(
forSlot: slotIndex,
slotCount: RunnerConfig.SchedulerSection.hardMaxConcurrentVMs
)
let bundle = try store.cloneImage(named: imageName, slotMAC: mac)
let instance = try VMInstance(bundle: bundle, label: "vm-boot")
await session.adopt(bundle: bundle, instance: instance)
CLI.note("booting clone \(bundle.name) (mac \(mac))…")
try await instance.start()
let ip = try await VMCommand.waitForLease(mac: mac, timeout: bootTimeout)
print("ip: \(ip)")
print("ssh: ssh \(username)@\(ip)")
print("")
CLI.note("press Ctrl-C to stop the VM and delete the clone")
// Whichever happens first: the guest shuts itself down,
// or the operator interrupts (handled by onSignal).
let reason = await instance.waitUntilStopped()
CLI.note("guest stopped: \(reason)")
await session.teardown()
} catch {
CLI.error("\(error)")
await session.teardown()
// Fully qualified: inside a ParsableCommand a bare `exit`
// resolves to ParsableCommand.exit(withError:).
await MainActor.run { Foundation.exit(1) }
}
}
)
}
}
/// `vm list` — show ephemeral clones currently on disk.
///
/// Under normal operation this is empty between jobs; anything listed after
/// the daemon has settled is an orphan from an unclean shutdown.
struct List: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "list",
abstract: "List ephemeral VM clones on disk."
)
@OptionGroup var options: GlobalOptions
func run() async throws {
let config = try options.loadConfig()
let store = VMStore(config: config)
try store.ensureLayout()
let clones = try store.listClones()
guard !clones.isEmpty else {
print("no ephemeral clones on disk")
return
}
let leases = DHCPLeaseParser.parseFile()
print("CLONE MAC IP SIZE")
for clone in clones {
let bundleConfig = try? clone.loadConfig()
let mac = bundleConfig?.macAddress ?? "-"
let ip = bundleConfig.flatMap { DHCPLeaseParser.ipAddress(forMAC: $0.macAddress, in: leases) } ?? "-"
let size = (try? clone.diskUsageBytes()).map(CLI.formatBytes) ?? "-"
print(pad(clone.name, 31) + pad(mac, 19) + pad(ip, 17) + size)
}
print("")
CLI.note("clones left behind after the daemon has settled are orphans; `purge` happens at daemon start")
}
private func pad(_ value: String, _ width: Int) -> String {
value.count >= width
? value + " "
: value + String(repeating: " ", count: width - value.count)
}
}
/// Polls `/var/db/dhcpd_leases` for a MAC, as the orchestrator does.
static func waitForLease(mac: String, timeout: Duration) async throws -> String {
let deadline = Date().addingTimeInterval(
TimeInterval(timeout.components.seconds)
)
while Date() < deadline {
if let ip = DHCPLeaseParser.ipAddress(forMAC: mac, in: DHCPLeaseParser.parseFile()) {
return ip
}
try await Task.sleep(for: .seconds(2))
}
throw CoreError.timeout("dhcp lease for \(mac)")
}
}
/// Holds the VM and clone `vm boot` created, so the signal handler can tear them
/// down from outside the task that made them.
actor BootSession {
private let store: VMStore
private let keepClone: Bool
private var bundle: VMBundle?
private var instance: VMInstance?
private var finished = false
init(store: VMStore, keepClone: Bool) {
self.store = store
self.keepClone = keepClone
}
func adopt(bundle: VMBundle, instance: VMInstance) {
self.bundle = bundle
self.instance = instance
}
/// Stops the VM and removes the clone. Idempotent.
func teardown() async {
guard !finished else { return }
finished = true
if let instance {
_ = await instance.requestStopThenForce(gracePeriod: .seconds(30))
}
guard let bundle else { return }
if keepClone {
CLI.note("keeping clone at \(bundle.rootURL.path)")
} else {
do {
try store.deleteClone(bundle)
CLI.note("deleted clone \(bundle.name)")
} catch {
CLI.error("could not delete clone: \(error)")
}
}
}
}