189 lines
6.7 KiB
Swift
189 lines
6.7 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 = ""
|
|
|
|
/// 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.
|
|
func update(_ line: String) {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
guard line != lastLine else { return }
|
|
lastLine = line
|
|
guard isInteractive else {
|
|
FileHandle.standardError.write(Data((line + "\n").utf8))
|
|
return
|
|
}
|
|
let padding = String(repeating: " ", count: max(0, 78 - line.count))
|
|
FileHandle.standardError.write(Data(("\r" + line + padding).utf8))
|
|
}
|
|
|
|
/// 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 {
|
|
let padding = isInteractive ? String(repeating: " ", count: max(0, 78 - line.count)) : ""
|
|
let prefix = isInteractive ? "\r" : ""
|
|
FileHandle.standardError.write(Data((prefix + line + padding + "\n").utf8))
|
|
} else if !lastLine.isEmpty, isInteractive {
|
|
FileHandle.standardError.write(Data("\n".utf8))
|
|
}
|
|
lastLine = ""
|
|
}
|
|
}
|