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)") } } } }