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