Merge nucleic/mellow-dewy-falcon-rjhr into main
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
import RunnerCore
|
||||
|
||||
/// `gitea-macos-runner config …` — create and inspect configuration.
|
||||
struct ConfigCommand: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "config",
|
||||
abstract: "Create and inspect the runner configuration.",
|
||||
subcommands: [Init.self, Show.self, Path.self]
|
||||
)
|
||||
|
||||
/// `config init` — write a commented example config.
|
||||
struct Init: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "init",
|
||||
abstract: "Write an example config.json, creating parent directories.",
|
||||
discussion: """
|
||||
Writes to ~/.config/gitea-macos-runner/config.json unless --config says \
|
||||
otherwise. Refuses to overwrite an existing file without --force. The \
|
||||
written file carries "_comment" keys explaining each section; they are \
|
||||
ignored when the config is read back.
|
||||
"""
|
||||
)
|
||||
|
||||
@OptionGroup var options: GlobalOptions
|
||||
|
||||
/// Overwrite an existing file.
|
||||
@Flag(name: .shortAndLong, help: "Overwrite an existing config file.")
|
||||
var force: Bool = false
|
||||
|
||||
/// Seed `gitea.instanceURL` instead of the placeholder.
|
||||
@Option(name: .long, help: "Gitea instance URL to seed into the config.")
|
||||
var instanceURL: String?
|
||||
|
||||
func run() async throws {
|
||||
var config = RunnerConfig.default
|
||||
if let instanceURL {
|
||||
guard let url = URL(string: instanceURL), url.scheme != nil, url.host != nil else {
|
||||
throw ValidationError("not a valid absolute URL: \(instanceURL)")
|
||||
}
|
||||
config.gitea.instanceURL = url
|
||||
}
|
||||
|
||||
var example = ConfigCommand.loadExampleDocument()
|
||||
if let instanceURL, example != nil {
|
||||
example = example?.replacingOccurrences(
|
||||
of: "https://gitea.example.com",
|
||||
with: instanceURL
|
||||
)
|
||||
}
|
||||
|
||||
let path = RunnerConfig.expandTilde(options.configPath)
|
||||
let written = try config.writeExample(to: path, exampleContents: example, overwrite: force)
|
||||
|
||||
guard written else {
|
||||
CLI.error("\(path) already exists; pass --force to overwrite")
|
||||
throw ExitCode(1)
|
||||
}
|
||||
|
||||
print("wrote \(path)")
|
||||
print("")
|
||||
if let contents = try? String(contentsOfFile: path, encoding: .utf8) {
|
||||
print(contents)
|
||||
}
|
||||
print("edit it, then run: gitea-macos-runner doctor")
|
||||
}
|
||||
}
|
||||
|
||||
/// `config show` — print the effective, validated configuration.
|
||||
///
|
||||
/// Token values are redacted; token *sources* are shown, which is what you
|
||||
/// actually need when debugging "why does it say no registration token".
|
||||
struct Show: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "show",
|
||||
abstract: "Print the effective configuration with secrets redacted."
|
||||
)
|
||||
|
||||
@OptionGroup var options: GlobalOptions
|
||||
|
||||
func run() async throws {
|
||||
let config = try options.loadConfig()
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
let encoded = try encoder.encode(config)
|
||||
|
||||
var object = (try JSONSerialization.jsonObject(with: encoded)) as? [String: Any] ?? [:]
|
||||
if var gitea = object["gitea"] as? [String: Any] {
|
||||
if gitea["adminToken"] != nil { gitea["adminToken"] = "<redacted>" }
|
||||
if gitea["registrationToken"] != nil { gitea["registrationToken"] = "<redacted>" }
|
||||
object["gitea"] = gitea
|
||||
}
|
||||
|
||||
let redacted = try JSONSerialization.data(
|
||||
withJSONObject: object,
|
||||
options: [.prettyPrinted, .sortedKeys]
|
||||
)
|
||||
print(String(data: redacted, encoding: .utf8) ?? "{}")
|
||||
|
||||
// The sources matter more than the values: "no registration token"
|
||||
// is almost always a path problem, not a secret problem.
|
||||
print("")
|
||||
print("config path: \(RunnerConfig.expandTilde(options.configPath))")
|
||||
print("store directory: \(config.storeDirectoryURL.path)")
|
||||
print("labels: \(config.runner.labels.joined(separator: ", "))")
|
||||
print("register --labels: \(config.labelSet.registrationArgument())")
|
||||
let downloadURL = (try? config.runner.resolvedDownloadURL)?.absoluteString ?? "<invalid>"
|
||||
print("runner download: \(downloadURL)")
|
||||
let adminSource = ConfigCommand.describeSource(
|
||||
inline: config.gitea.adminToken,
|
||||
file: config.gitea.adminTokenFile,
|
||||
resolved: (try? config.resolveAdminToken()) ?? nil
|
||||
)
|
||||
let registrationSource = ConfigCommand.describeSource(
|
||||
inline: config.gitea.registrationToken,
|
||||
file: config.gitea.registrationTokenFile,
|
||||
resolved: (try? config.resolveStaticRegistrationToken()) ?? nil,
|
||||
fallback: config.gitea.fetchRegistrationTokenViaAPI
|
||||
? "admin API (fetchRegistrationTokenViaAPI)"
|
||||
: nil
|
||||
)
|
||||
print("admin token: \(adminSource)")
|
||||
print("registration token: \(registrationSource)")
|
||||
|
||||
let insecure = config.insecureTokenFilePaths
|
||||
if !insecure.isEmpty {
|
||||
print("")
|
||||
CLI.note("warning: group/world readable token files: \(insecure.joined(separator: ", "))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `config path` — print the config path being used.
|
||||
struct Path: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "path",
|
||||
abstract: "Print the configuration file path."
|
||||
)
|
||||
|
||||
@OptionGroup var options: GlobalOptions
|
||||
|
||||
func run() async throws {
|
||||
print(RunnerConfig.expandTilde(options.configPath))
|
||||
}
|
||||
}
|
||||
|
||||
/// Describes where a secret comes from, without printing it.
|
||||
static func describeSource(
|
||||
inline: String?,
|
||||
file: String?,
|
||||
resolved: String?,
|
||||
fallback: String? = nil
|
||||
) -> String {
|
||||
let value = resolved
|
||||
if let file, !file.isEmpty {
|
||||
let expanded = RunnerConfig.expandTilde(file)
|
||||
let readable = (value?.isEmpty == false)
|
||||
return "\(expanded) (\(readable ? "readable" : "MISSING or empty"))"
|
||||
}
|
||||
if let inline, !inline.isEmpty {
|
||||
return "inline value in config.json (prefer a file)"
|
||||
}
|
||||
return fallback ?? "not configured"
|
||||
}
|
||||
|
||||
/// Finds `Resources/config.example.json` next to the binary or in a checkout.
|
||||
///
|
||||
/// The example is not an SPM resource bundle and `make bundle` does not copy
|
||||
/// it into the app, so several plausible locations are tried; `writeExample`
|
||||
/// falls back to a plain serialization when none is found.
|
||||
static func loadExampleDocument() -> String? {
|
||||
var candidates: [URL] = []
|
||||
|
||||
if let resource = Bundle.main.url(forResource: "config.example", withExtension: "json") {
|
||||
candidates.append(resource)
|
||||
}
|
||||
candidates.append(Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/config.example.json"))
|
||||
if let executableURL = Bundle.main.executableURL?.resolvingSymlinksInPath() {
|
||||
let directory = executableURL.deletingLastPathComponent()
|
||||
candidates.append(directory.appendingPathComponent("Resources/config.example.json"))
|
||||
candidates.append(
|
||||
directory.deletingLastPathComponent().appendingPathComponent("Resources/config.example.json")
|
||||
)
|
||||
}
|
||||
// Sources/gitea-macos-runner/CommandConfig.swift → repository root.
|
||||
let repositoryRoot = URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
candidates.append(repositoryRoot.appendingPathComponent("Resources/config.example.json"))
|
||||
candidates.append(
|
||||
URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
|
||||
.appendingPathComponent("Resources/config.example.json")
|
||||
)
|
||||
|
||||
for candidate in candidates {
|
||||
if let contents = try? String(contentsOf: candidate, encoding: .utf8) {
|
||||
return contents
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import AppKit
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
import Logging
|
||||
import RunnerCore
|
||||
import RunnerHost
|
||||
|
||||
/// `gitea-macos-runner daemon` — the long-running service.
|
||||
///
|
||||
/// ## Why there is an `NSApplication` here
|
||||
///
|
||||
/// Virtualization.framework requires a running main run loop in an application
|
||||
/// context; a plain command-line process that blocks in `await` never services
|
||||
/// it, and VM startup either hangs or fails. The fix is to start a real
|
||||
/// `NSApplication` but suppress every trace of a GUI:
|
||||
///
|
||||
/// ```swift
|
||||
/// NSApplication.shared.setActivationPolicy(.prohibited) // no Dock icon, no menu bar
|
||||
/// // spawn the orchestrator Task
|
||||
/// NSApplication.shared.run() // never returns
|
||||
/// ```
|
||||
///
|
||||
/// `.prohibited` (mirrored by `LSUIElement` in `Info.plist`) is what makes this
|
||||
/// invisible. The orchestrator runs in a detached `Task`; `run()` owns the main
|
||||
/// thread from then on.
|
||||
///
|
||||
/// `SIGTERM` and `SIGINT` are trapped with `DispatchSourceSignal` — not
|
||||
/// `signal(2)` handlers, which cannot safely touch Swift concurrency — and
|
||||
/// trigger ``Orchestrator/shutdown()`` before the process leaves, so guests get
|
||||
/// a chance to stop cleanly instead of having their disks yanked.
|
||||
struct DaemonCommand: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "daemon",
|
||||
abstract: "Watch Gitea for queued macOS jobs and run each in a fresh VM."
|
||||
)
|
||||
|
||||
@OptionGroup var options: GlobalOptions
|
||||
|
||||
/// Base image to clone for each job.
|
||||
@Option(name: .long, help: "Base image to clone for each job.")
|
||||
var image: String = "default"
|
||||
|
||||
/// Run one poll/reconcile tick and exit. Useful for debugging without
|
||||
/// installing the service.
|
||||
@Flag(name: .long, help: "Run a single scheduling tick, then exit.")
|
||||
var once: Bool = false
|
||||
|
||||
func run() async throws {
|
||||
CLI.bootstrapLogging(verbose: options.verbose)
|
||||
let logger = Logger(label: "daemon")
|
||||
|
||||
let config = try options.loadConfig()
|
||||
|
||||
guard let adminToken = try config.resolveAdminToken(), !adminToken.isEmpty else {
|
||||
throw ValidationError(
|
||||
"""
|
||||
no Gitea admin token: set gitea.adminTokenFile (preferred) or gitea.adminToken \
|
||||
in \(RunnerConfig.expandTilde(options.configPath))
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
for path in config.insecureTokenFilePaths {
|
||||
logger.warning("token file is group/world readable", metadata: ["path": .string(path)])
|
||||
}
|
||||
|
||||
let store = VMStore(config: config)
|
||||
try store.ensureLayout()
|
||||
|
||||
guard try store.image(named: image) != nil else {
|
||||
throw ValidationError(
|
||||
"no base image named '\(image)' — build one with `gitea-macos-runner image build --name \(image)`"
|
||||
)
|
||||
}
|
||||
|
||||
let client = GiteaClient(baseURL: config.gitea.instanceURL, token: adminToken)
|
||||
let orchestrator = Orchestrator(
|
||||
config: config,
|
||||
client: client,
|
||||
store: store,
|
||||
imageName: image,
|
||||
logger: Logger(label: "orchestrator")
|
||||
)
|
||||
|
||||
let singleTick = once
|
||||
let jobTimeout = TimeInterval(config.scheduler.jobTimeoutMinutes * 60)
|
||||
|
||||
// Even a single tick can start a VM, and a VM needs the run loop — so
|
||||
// both modes go through NSApplication.
|
||||
await VZAppRuntime.run(
|
||||
onSignal: { await orchestrator.shutdown() },
|
||||
body: {
|
||||
do {
|
||||
if singleTick {
|
||||
await orchestrator.reconcileOnce()
|
||||
await orchestrator.tick()
|
||||
// Let whatever the tick started run to completion rather
|
||||
// than tearing a just-booted guest down mid-boot.
|
||||
let deadline = Date().addingTimeInterval(jobTimeout)
|
||||
var pending = await orchestrator.liveVMs().count
|
||||
while pending > 0, Date() < deadline {
|
||||
try? await Task.sleep(for: .seconds(5))
|
||||
pending = await orchestrator.liveVMs().count
|
||||
}
|
||||
await orchestrator.shutdown()
|
||||
} else {
|
||||
try await orchestrator.runForever()
|
||||
}
|
||||
} catch is CancellationError {
|
||||
// Expected on shutdown.
|
||||
} catch {
|
||||
logger.critical("daemon stopped", metadata: ["error": .string("\(error)")])
|
||||
// Fully qualified: inside a ParsableCommand a bare `exit`
|
||||
// resolves to ParsableCommand.exit(withError:).
|
||||
await MainActor.run { Foundation.exit(1) }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Hosts an `NSApplication` run loop so Virtualization.framework has the main
|
||||
/// run loop it requires, while the real work runs in a `Task`.
|
||||
///
|
||||
/// Shared by `daemon` and `vm boot`: any command that starts a VM needs this.
|
||||
@MainActor
|
||||
enum VZAppRuntime {
|
||||
/// Signal sources have to outlive the call that creates them or they are
|
||||
/// cancelled on deinit and the signals go nowhere.
|
||||
private static var signalSources: [DispatchSourceSignal] = []
|
||||
private static var isTerminating = false
|
||||
|
||||
/// Starts the run loop and runs `body` alongside it. Never returns.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - onSignal: Cleanup to perform on `SIGINT`/`SIGTERM` before exiting.
|
||||
/// - body: The work to run. When it returns, the process exits zero.
|
||||
static func run(
|
||||
onSignal: @escaping @Sendable () async -> Void,
|
||||
body: @escaping @Sendable () async -> Void
|
||||
) -> Never {
|
||||
let app = NSApplication.shared
|
||||
// No Dock icon, no menu bar, no activation: this is a background agent
|
||||
// that merely needs to be an application as far as the kernel is
|
||||
// concerned.
|
||||
app.setActivationPolicy(.prohibited)
|
||||
|
||||
for signalNumber in [SIGINT, SIGTERM] {
|
||||
// DispatchSourceSignal only observes; the default disposition still
|
||||
// kills the process unless it is ignored first.
|
||||
signal(signalNumber, SIG_IGN)
|
||||
let source = DispatchSource.makeSignalSource(signal: signalNumber, queue: .main)
|
||||
source.setEventHandler {
|
||||
Task { @MainActor in
|
||||
guard !isTerminating else { return }
|
||||
isTerminating = true
|
||||
CLI.note("received signal; shutting down…")
|
||||
await onSignal()
|
||||
NSApp.terminate(nil)
|
||||
exit(0)
|
||||
}
|
||||
}
|
||||
source.resume()
|
||||
signalSources.append(source)
|
||||
}
|
||||
|
||||
Task {
|
||||
await body()
|
||||
await MainActor.run {
|
||||
NSApp.terminate(nil)
|
||||
exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
app.run()
|
||||
exit(0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
import RunnerCore
|
||||
import RunnerHost
|
||||
|
||||
/// `gitea-macos-runner doctor` — verify the host before anything else.
|
||||
///
|
||||
/// Every check corresponds to a failure that would otherwise show up as an
|
||||
/// opaque error deep inside a VM boot: wrong architecture, unsigned binary,
|
||||
/// locked keychain, non-admin Gitea token, dead download URL. Run this first,
|
||||
/// and again after `service install`.
|
||||
struct DoctorCommand: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "doctor",
|
||||
abstract: "Check that this host can build and run macOS guests."
|
||||
)
|
||||
|
||||
@OptionGroup var options: GlobalOptions
|
||||
|
||||
/// Emit machine-readable JSON instead of aligned text.
|
||||
@Flag(name: .long, help: "Emit results as JSON.")
|
||||
var json: Bool = false
|
||||
|
||||
/// By default `doctor` exits non-zero when any check fails, so it can gate a
|
||||
/// setup script. This makes it always exit zero.
|
||||
@Flag(name: .customLong("no-fail"), help: "Exit zero even when checks fail.")
|
||||
var noFail: Bool = false
|
||||
|
||||
func run() async throws {
|
||||
// Deliberately does not use options.loadConfig(): a broken or missing
|
||||
// config is exactly the state doctor exists to diagnose, so it is
|
||||
// reported as a check rather than thrown as an error.
|
||||
let checks = await Doctor.runChecks(configPath: options.configPath)
|
||||
|
||||
if json {
|
||||
let payload: [[String: Any]] = checks.map { check in
|
||||
var entry: [String: Any] = [
|
||||
"name": check.name,
|
||||
"result": check.result.label,
|
||||
"detail": check.detail,
|
||||
"blocking": check.isBlocking,
|
||||
]
|
||||
if let remediation = check.remediation {
|
||||
entry["remediation"] = remediation
|
||||
}
|
||||
return entry
|
||||
}
|
||||
let data = try JSONSerialization.data(
|
||||
withJSONObject: payload,
|
||||
options: [.prettyPrinted, .sortedKeys]
|
||||
)
|
||||
print(String(data: data, encoding: .utf8) ?? "[]")
|
||||
} else {
|
||||
print(Doctor.format(checks))
|
||||
}
|
||||
|
||||
if !noFail, checks.contains(where: \.isBlocking) {
|
||||
throw ExitCode(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
import RunnerCore
|
||||
import RunnerHost
|
||||
|
||||
/// `gitea-macos-runner image …` — manage base VM images.
|
||||
///
|
||||
/// A base image is installed and provisioned once and then cloned per job.
|
||||
/// Building one takes the better part of an hour, most of it downloading a
|
||||
/// ~15 GB IPSW; cloning one takes milliseconds.
|
||||
struct ImageCommand: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "image",
|
||||
abstract: "Build, list, provision, and delete base VM images.",
|
||||
subcommands: [Build.self, List.self, Delete.self, Provision.self]
|
||||
)
|
||||
|
||||
/// `image build` — install macOS from an IPSW and provision it.
|
||||
struct Build: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "build",
|
||||
abstract: "Install macOS into a new base image and provision it.",
|
||||
discussion: """
|
||||
Downloads the latest supported restore image unless --ipsw is given, \
|
||||
installs it, automates Setup Assistant, then installs Node.js and the \
|
||||
gitea-runner binary over SSH. The guest must be macOS 27 or newer for \
|
||||
unattended Setup Assistant automation to work.
|
||||
"""
|
||||
)
|
||||
|
||||
@OptionGroup var options: GlobalOptions
|
||||
|
||||
/// Image name under `<storeDir>/images/`.
|
||||
@Option(name: .long, help: "Image name.")
|
||||
var name: String = "default"
|
||||
|
||||
/// A local `.ipsw`; omit to download the latest supported image.
|
||||
@Option(name: .long, help: "Path to a local .ipsw (default: download the latest supported).")
|
||||
var ipsw: String?
|
||||
|
||||
/// Nominal guest disk size, overriding `guest.diskGB`.
|
||||
@Option(name: .customLong("disk-gb"), help: "Guest disk size in GB (overrides config).")
|
||||
var diskGB: Int?
|
||||
|
||||
func run() async throws {
|
||||
CLI.bootstrapLogging(verbose: options.verbose)
|
||||
var config = try options.loadConfig()
|
||||
if let diskGB {
|
||||
config.guest.diskGB = diskGB
|
||||
}
|
||||
|
||||
let store = VMStore(config: config)
|
||||
try store.ensureLayout()
|
||||
|
||||
if try store.image(named: name) != nil {
|
||||
throw ValidationError(
|
||||
"image '\(name)' already exists — delete it first with `image delete \(name)`"
|
||||
)
|
||||
}
|
||||
try store.ensureFreeSpace(minGB: max(config.storage.minFreeDiskGB, 40))
|
||||
|
||||
CLI.note("building image '\(name)' (this takes a while; the IPSW alone is ~15 GB)")
|
||||
|
||||
let printer = ProgressPrinter()
|
||||
let builder = ImageBuilder(store: store)
|
||||
let imageName = name
|
||||
let ipswPath = ipsw
|
||||
let frozenConfig = config
|
||||
|
||||
// `image build` runs `VZMacOSInstaller` and then boots the guest, so
|
||||
// it needs the same `NSApplication` main run loop `daemon` and
|
||||
// `vm boot` do — without it Virtualization.framework's callbacks are
|
||||
// never serviced and the install hangs. See `VZAppRuntime`.
|
||||
await VZAppRuntime.run(
|
||||
onSignal: {},
|
||||
body: {
|
||||
do {
|
||||
try await builder.build(
|
||||
name: imageName,
|
||||
ipswPath: ipswPath,
|
||||
config: frozenConfig,
|
||||
progress: { stage in printer.update(ImageCommand.describe(stage)) }
|
||||
)
|
||||
} catch {
|
||||
printer.finish()
|
||||
CLI.error("\(error)")
|
||||
// Fully qualified: inside a ParsableCommand a bare `exit`
|
||||
// resolves to ParsableCommand.exit(withError:).
|
||||
await MainActor.run { Foundation.exit(1) }
|
||||
}
|
||||
printer.finish("done")
|
||||
|
||||
print("built image '\(imageName)'")
|
||||
print("next: gitea-macos-runner vm boot --image \(imageName)")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// `image list` — show base images and whether they are provisioned.
|
||||
struct List: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "list",
|
||||
abstract: "List base images."
|
||||
)
|
||||
|
||||
@OptionGroup var options: GlobalOptions
|
||||
|
||||
func run() async throws {
|
||||
let config = try options.loadConfig()
|
||||
let store = VMStore(config: config)
|
||||
try store.ensureLayout()
|
||||
|
||||
let names = try store.listImages()
|
||||
guard !names.isEmpty else {
|
||||
print("no images (build one with `gitea-macos-runner image build`)")
|
||||
return
|
||||
}
|
||||
|
||||
print("NAME MACOS PROVISIONED DISK SIZE")
|
||||
for name in names {
|
||||
guard let bundle = try store.image(named: name) else { continue }
|
||||
let bundleConfig = try? bundle.loadConfig()
|
||||
let size = (try? bundle.diskUsageBytes()).map(CLI.formatBytes) ?? "-"
|
||||
print(
|
||||
pad(name, 20)
|
||||
+ pad(bundleConfig?.macOSVersion ?? "-", 12)
|
||||
+ pad((bundleConfig?.provisioned ?? false) ? "yes" : "no", 13)
|
||||
+ pad(bundleConfig?.diskFormat.rawValue ?? "-", 11)
|
||||
+ size
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func pad(_ value: String, _ width: Int) -> String {
|
||||
value.count >= width
|
||||
? value + " "
|
||||
: value + String(repeating: " ", count: width - value.count)
|
||||
}
|
||||
}
|
||||
|
||||
/// `image delete NAME` — remove a base image.
|
||||
struct Delete: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "delete",
|
||||
abstract: "Delete a base image and its disk."
|
||||
)
|
||||
|
||||
@OptionGroup var options: GlobalOptions
|
||||
|
||||
/// Image name.
|
||||
@Argument(help: "Image name.")
|
||||
var name: String
|
||||
|
||||
/// Skip the confirmation prompt.
|
||||
@Flag(name: .shortAndLong, help: "Do not prompt for confirmation.")
|
||||
var force: Bool = false
|
||||
|
||||
func run() async throws {
|
||||
let config = try options.loadConfig()
|
||||
let store = VMStore(config: config)
|
||||
|
||||
guard let bundle = try store.image(named: name) else {
|
||||
throw ValidationError("no image named '\(name)'")
|
||||
}
|
||||
|
||||
if !force {
|
||||
let size = (try? bundle.diskUsageBytes()).map(CLI.formatBytes) ?? "unknown size"
|
||||
guard CLI.confirm("delete image '\(name)' (\(size))?") else {
|
||||
print("cancelled")
|
||||
throw ExitCode(1)
|
||||
}
|
||||
}
|
||||
|
||||
try store.deleteImage(named: name)
|
||||
print("deleted image '\(name)'")
|
||||
}
|
||||
}
|
||||
|
||||
/// `image provision NAME` — re-run guest provisioning on an existing image.
|
||||
///
|
||||
/// Exists so that bumping the `gitea-runner` version, or adding Xcode, does
|
||||
/// not require reinstalling macOS.
|
||||
struct Provision: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "provision",
|
||||
abstract: "Re-run guest provisioning against an existing image.",
|
||||
discussion: """
|
||||
Boots the BASE image bundle itself — not a clone — runs provisioning, and \
|
||||
shuts it down. This deliberately mutates the golden image in place, which \
|
||||
is the point: every clone made afterwards inherits the change. Nothing \
|
||||
else may be using the image while this runs, so stop the daemon first.
|
||||
"""
|
||||
)
|
||||
|
||||
@OptionGroup var options: GlobalOptions
|
||||
|
||||
/// Image name.
|
||||
@Argument(help: "Image name.")
|
||||
var name: String
|
||||
|
||||
/// Optional Xcode `.xip` to install into the guest. Adds tens of
|
||||
/// gigabytes; omitted by default.
|
||||
@Option(name: .customLong("xcode-xip"), help: "Path to an Xcode .xip to install into the guest.")
|
||||
var xcodeXIP: String?
|
||||
|
||||
func run() async throws {
|
||||
CLI.bootstrapLogging(verbose: options.verbose)
|
||||
let config = try options.loadConfig()
|
||||
let store = VMStore(config: config)
|
||||
|
||||
guard try store.image(named: name) != nil else {
|
||||
throw ValidationError("no image named '\(name)'")
|
||||
}
|
||||
if let xcodeXIP, !FileManager.default.fileExists(atPath: RunnerConfig.expandTilde(xcodeXIP)) {
|
||||
throw ValidationError("no file at \(RunnerConfig.expandTilde(xcodeXIP))")
|
||||
}
|
||||
|
||||
CLI.note("provisioning base image '\(name)' in place — stop the daemon before doing this")
|
||||
|
||||
let printer = ProgressPrinter()
|
||||
let builder = ImageBuilder(store: store)
|
||||
let imageName = name
|
||||
let frozenConfig = config
|
||||
let xipPath = xcodeXIP.map(RunnerConfig.expandTilde)
|
||||
|
||||
// Boots the image to run provision.sh in it, so it needs the run
|
||||
// loop for exactly the reason `image build` does.
|
||||
await VZAppRuntime.run(
|
||||
onSignal: {},
|
||||
body: {
|
||||
do {
|
||||
try await builder.reprovision(
|
||||
name: imageName,
|
||||
config: frozenConfig,
|
||||
xcodeXIPPath: xipPath,
|
||||
progress: { stage in printer.update(ImageCommand.describe(stage)) }
|
||||
)
|
||||
} catch {
|
||||
printer.finish()
|
||||
CLI.error("\(error)")
|
||||
// Fully qualified: inside a ParsableCommand a bare `exit`
|
||||
// resolves to ParsableCommand.exit(withError:).
|
||||
await MainActor.run { Foundation.exit(1) }
|
||||
}
|
||||
printer.finish("done")
|
||||
print("provisioned image '\(imageName)'")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a build stage as one status line.
|
||||
static func describe(_ stage: ImageBuildStage) -> String {
|
||||
switch stage {
|
||||
case .downloadingIPSW(let fraction):
|
||||
return "downloading IPSW " + CLI.progressBar(fraction)
|
||||
case .preparing:
|
||||
return "preparing"
|
||||
case .creatingBundle:
|
||||
return "creating bundle"
|
||||
case .installing(let fraction):
|
||||
return "installing macOS " + CLI.progressBar(fraction)
|
||||
case .firstBoot:
|
||||
return "first boot (Setup Assistant)"
|
||||
case .provisioning(let step):
|
||||
return "provisioning: \(step)"
|
||||
case .finalizing:
|
||||
return "finalizing"
|
||||
case .done:
|
||||
return "done"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
import RunnerCore
|
||||
import RunnerHost
|
||||
|
||||
/// `gitea-macos-runner service …` — manage the `launchd` LaunchAgent.
|
||||
///
|
||||
/// - Important: This installs a **LaunchAgent** in the logged-in user's session,
|
||||
/// never a LaunchDaemon. Virtualization needs a GUI session, and macOS 15+
|
||||
/// additionally needs an unlocked `login.keychain` to start a VM — neither of
|
||||
/// which exists in the system context. The host should be set to log in
|
||||
/// automatically.
|
||||
struct ServiceCommand: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "service",
|
||||
abstract: "Install, remove, or inspect the launchd LaunchAgent.",
|
||||
subcommands: [Install.self, Uninstall.self, Status.self]
|
||||
)
|
||||
|
||||
/// `service install` — write the plist and load the job.
|
||||
struct Install: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "install",
|
||||
abstract: "Write ~/Library/LaunchAgents/xyz.blakeslee.gitea-macos-runner.plist and load it.",
|
||||
discussion: """
|
||||
Points the agent at the installed, signed .app bundle — not at a bare \
|
||||
binary. The com.apple.security.virtualization entitlement only survives \
|
||||
on the signed bundle, so a daemon started from .build/ cannot start VMs.
|
||||
"""
|
||||
)
|
||||
|
||||
@OptionGroup var options: GlobalOptions
|
||||
|
||||
/// Path to the installed executable inside the signed `.app`.
|
||||
@Option(name: .long, help: "Path to the installed executable (default: ~/Applications/GiteaMacosRunner.app/Contents/MacOS/gitea-macos-runner).")
|
||||
var executable: String?
|
||||
|
||||
func run() async throws {
|
||||
let executablePath = executable ?? LaunchdService.defaultExecutablePath
|
||||
|
||||
// Only pass --config when it is not the default; a plist that
|
||||
// hard-codes the default path is one more thing to keep in sync.
|
||||
let configPath = options.configPath == RunnerConfig.defaultPath ? nil : options.configPath
|
||||
|
||||
if (try? options.loadConfig()) == nil {
|
||||
CLI.note("warning: \(RunnerConfig.expandTilde(options.configPath)) is missing or invalid; the agent will fail to start until it is fixed")
|
||||
}
|
||||
|
||||
try LaunchdService.install(executablePath: executablePath, configPath: configPath)
|
||||
|
||||
print("installed \(LaunchdService.agentPlistURL.path)")
|
||||
print("program: \(RunnerConfig.expandTilde(executablePath)) daemon")
|
||||
print("logs: \(LaunchdService.logDirectoryURL.path)")
|
||||
print("")
|
||||
print("check it with: gitea-macos-runner service status")
|
||||
}
|
||||
}
|
||||
|
||||
/// `service uninstall` — unload and remove the plist.
|
||||
struct Uninstall: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "uninstall",
|
||||
abstract: "Unload the LaunchAgent and remove its plist."
|
||||
)
|
||||
|
||||
@OptionGroup var options: GlobalOptions
|
||||
|
||||
func run() async throws {
|
||||
let path = LaunchdService.agentPlistURL.path
|
||||
let existed = FileManager.default.fileExists(atPath: path)
|
||||
try LaunchdService.uninstall()
|
||||
print(existed ? "removed \(path)" : "not installed (\(path))")
|
||||
}
|
||||
}
|
||||
|
||||
/// `service status` — report whether the agent is installed and running.
|
||||
struct Status: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "status",
|
||||
abstract: "Report LaunchAgent installation and run state."
|
||||
)
|
||||
|
||||
@OptionGroup var options: GlobalOptions
|
||||
|
||||
func run() async throws {
|
||||
let status = try LaunchdService.status()
|
||||
|
||||
print("label: \(LaunchdService.label)")
|
||||
print("plist: \(status.plistPath)")
|
||||
print("installed: \(status.installed ? "yes" : "no")")
|
||||
print("loaded: \(status.loaded ? "yes" : "no")")
|
||||
if let pid = status.pid {
|
||||
print("pid: \(pid)")
|
||||
}
|
||||
if let lastExitStatus = status.lastExitStatus {
|
||||
print("last exit: \(lastExitStatus)")
|
||||
}
|
||||
print("logs: \(LaunchdService.logDirectoryURL.path)")
|
||||
|
||||
if status.installed, !status.loaded {
|
||||
print("")
|
||||
CLI.note("installed but not loaded — reinstall with `service install`, or check the logs above")
|
||||
throw ExitCode(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
import Logging
|
||||
import RunnerCore
|
||||
|
||||
/// Options every subcommand accepts.
|
||||
struct GlobalOptions: ParsableArguments {
|
||||
/// Path to `config.json`. Tilde-expanded.
|
||||
@Option(name: [.customLong("config"), .customShort("c")],
|
||||
help: "Path to config.json (default: ~/.config/gitea-macos-runner/config.json)")
|
||||
var configPath: String = RunnerConfig.defaultPath
|
||||
|
||||
/// Emit debug-level logs.
|
||||
@Flag(name: .long, help: "Verbose logging.")
|
||||
var verbose: Bool = false
|
||||
|
||||
/// Loads and validates the configuration named by ``configPath``.
|
||||
func loadConfig() throws -> RunnerConfig {
|
||||
try RunnerConfig.load(from: configPath).validated()
|
||||
}
|
||||
}
|
||||
|
||||
/// Root command.
|
||||
///
|
||||
/// The tool is both the daemon and its own admin CLI: `daemon` is what
|
||||
/// `launchd` starts, and everything else is operator-facing.
|
||||
@main
|
||||
struct GiteaMacOSRunner: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "gitea-macos-runner",
|
||||
abstract: "Run Gitea Actions macOS jobs in fresh, ephemeral Virtualization.framework VMs.",
|
||||
discussion: """
|
||||
Each queued job that matches this host's labels gets a brand-new macOS VM \
|
||||
cloned from a base image, an ephemeral runner registered with Gitea, and a \
|
||||
teardown as soon as the job finishes. Nothing is reused between jobs.
|
||||
|
||||
Start with `doctor` to verify the host, then `config init`, then \
|
||||
`image build`, then `service install`.
|
||||
""",
|
||||
version: RunnerVersion.current,
|
||||
subcommands: [
|
||||
DaemonCommand.self,
|
||||
ImageCommand.self,
|
||||
VMCommand.self,
|
||||
ServiceCommand.self,
|
||||
DoctorCommand.self,
|
||||
ConfigCommand.self,
|
||||
],
|
||||
defaultSubcommand: nil
|
||||
)
|
||||
}
|
||||
|
||||
/// Shared helpers for command bodies.
|
||||
enum CLI {
|
||||
/// Prints to stderr.
|
||||
static func error(_ message: String) {
|
||||
FileHandle.standardError.write(Data(("error: " + message + "\n").utf8))
|
||||
}
|
||||
|
||||
/// Prints a note to stderr, so it does not pollute pipeable stdout.
|
||||
static func note(_ message: String) {
|
||||
FileHandle.standardError.write(Data((message + "\n").utf8))
|
||||
}
|
||||
|
||||
/// Prints an "unimplemented" notice and exits non-zero.
|
||||
static func unimplemented(_ what: String) throws -> Never {
|
||||
error("\(what): unimplemented")
|
||||
throw ExitCode(1)
|
||||
}
|
||||
|
||||
/// Routes swift-log to stderr, leaving stdout for command output.
|
||||
///
|
||||
/// Only the first call has any effect: `LoggingSystem.bootstrap` traps when
|
||||
/// called twice, and subcommands are free to call this independently.
|
||||
static func bootstrapLogging(verbose: Bool) {
|
||||
loggingBootstrap.once {
|
||||
let level: Logger.Level = verbose ? .debug : .info
|
||||
LoggingSystem.bootstrap { label in
|
||||
var handler = StreamLogHandler.standardError(label: label)
|
||||
handler.logLevel = level
|
||||
return handler
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static let loggingBootstrap = OnceFlag()
|
||||
|
||||
/// Asks a yes/no question on stderr. Answers `false` when stdin is not a
|
||||
/// terminal, so a piped invocation never blocks forever.
|
||||
static func confirm(_ question: String) -> Bool {
|
||||
guard isatty(fileno(stdin)) == 1 else { return false }
|
||||
FileHandle.standardError.write(Data((question + " [y/N] ").utf8))
|
||||
guard let answer = readLine(strippingNewline: true)?.lowercased() else { return false }
|
||||
return answer == "y" || answer == "yes"
|
||||
}
|
||||
|
||||
/// Formats a byte count as a human-readable size.
|
||||
static func formatBytes(_ bytes: Int64) -> String {
|
||||
let units = ["B", "KB", "MB", "GB", "TB"]
|
||||
var value = Double(bytes)
|
||||
var unit = 0
|
||||
while value >= 1024, unit < units.count - 1 {
|
||||
value /= 1024
|
||||
unit += 1
|
||||
}
|
||||
return unit == 0
|
||||
? "\(Int(value)) \(units[unit])"
|
||||
: String(format: "%.1f %@", value, units[unit])
|
||||
}
|
||||
|
||||
/// Renders a fixed-width progress bar, e.g. `[####------] 40%`.
|
||||
static func progressBar(_ fraction: Double, width: Int = 30) -> String {
|
||||
let clamped = min(max(fraction, 0), 1)
|
||||
let filled = Int((Double(width) * clamped).rounded())
|
||||
let bar = String(repeating: "#", count: filled) + String(repeating: "-", count: width - filled)
|
||||
return String(format: "[%@] %3d%%", bar, Int((clamped * 100).rounded()))
|
||||
}
|
||||
}
|
||||
|
||||
/// A thread-safe "run this exactly once" latch.
|
||||
final class OnceFlag: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var done = false
|
||||
|
||||
func once(_ body: () -> Void) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard !done else { return }
|
||||
done = true
|
||||
body()
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializes progress output arriving from arbitrary threads and keeps it on a
|
||||
/// single rewritten stderr line.
|
||||
final class ProgressPrinter: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var lastLine = ""
|
||||
|
||||
/// Rewrites the current line.
|
||||
func update(_ line: String) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard line != lastLine else { return }
|
||||
lastLine = line
|
||||
let padding = String(repeating: " ", count: max(0, 78 - line.count))
|
||||
FileHandle.standardError.write(Data(("\r" + line + padding).utf8))
|
||||
}
|
||||
|
||||
/// Ends the line so subsequent output starts cleanly.
|
||||
func finish(_ line: String? = nil) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
if let line {
|
||||
let padding = String(repeating: " ", count: max(0, 78 - line.count))
|
||||
FileHandle.standardError.write(Data(("\r" + line + padding + "\n").utf8))
|
||||
} else if !lastLine.isEmpty {
|
||||
FileHandle.standardError.write(Data("\n".utf8))
|
||||
}
|
||||
lastLine = ""
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user