197 lines
7.6 KiB
Swift
197 lines
7.6 KiB
Swift
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()
|
|
// Not `MainActor.run`: the main actor is parked inside
|
|
// `app.run()` for the life of the process, so hopping onto
|
|
// it to exit is its own deadlock. See `VZAppRuntime.run`.
|
|
VZAppRuntime.flushAndExit(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)")
|
|
}
|
|
}
|
|
}
|
|
}
|