Files
gitea-macos-vm-orchestrator/Sources/gitea-macos-runner/Main.swift
T

212 lines
7.4 KiB
Swift

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 = ""
private var lastGroup: String?
/// Whether carriage-return rewriting means anything here.
///
/// Piped to a file or captured by `launchd`, `\r` produces one unreadable
/// mega-line, so each update becomes its own line instead. `FileHandle`
/// writes go straight to the descriptor either way — there is no buffer to
/// flush, which is what makes a stall attributable to the stage last
/// printed rather than to output sitting unwritten.
private let isInteractive = isatty(fileno(stderr)) == 1
/// Rewrites the current line.
///
/// - Parameters:
/// - line: The text to show.
/// - group: Names the stage this line belongs to. When it changes, the
/// outgoing stage's final line is sealed with a newline rather than
/// overwritten — so `installing macOS [####] 100%` is still on screen
/// when the operator scrolls back to work out where the last hour went,
/// instead of being replaced by whatever came next.
func update(_ line: String, group: String? = nil) {
lock.lock()
defer { lock.unlock() }
if let group, let lastGroup, group != lastGroup, !lastLine.isEmpty, isInteractive {
emit("\n")
lastLine = ""
}
if let group { lastGroup = group }
guard line != lastLine else { return }
lastLine = line
guard isInteractive else {
emit(line + "\n")
return
}
emit("\r" + line + pad(line))
}
/// Emits a standalone line without losing the status line under it.
func line(_ text: String) {
lock.lock()
let carried = lastLine
lock.unlock()
finish(text)
if !carried.isEmpty {
update(carried)
}
}
/// Ends the line so subsequent output starts cleanly.
func finish(_ line: String? = nil) {
lock.lock()
defer { lock.unlock() }
if let line {
emit((isInteractive ? "\r" : "") + line + (isInteractive ? pad(line) : "") + "\n")
} else if !lastLine.isEmpty, isInteractive {
emit("\n")
}
lastLine = ""
}
/// Trailing blanks that erase whatever the previous, longer line left behind.
private func pad(_ line: String) -> String {
String(repeating: " ", count: max(0, 78 - line.count))
}
/// Writes straight to the descriptor. Call with ``lock`` held.
private func emit(_ text: String) {
FileHandle.standardError.write(Data(text.utf8))
}
}