Merge nucleic/mellow-dewy-falcon-rjhr into main
This commit is contained in:
@@ -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