Nucleic: Gitea Runner macOS VM Support
This commit is contained in:
@@ -64,7 +64,12 @@ struct ImageCommand: AsyncParsableCommand {
|
||||
let store = VMStore(config: config)
|
||||
try store.ensureLayout()
|
||||
|
||||
if try store.image(named: name) != nil {
|
||||
// Only a *finished* image blocks a rebuild. An installed but
|
||||
// unprovisioned bundle is an hour of work that `ImageBuilder.build`
|
||||
// knows how to resume, so it must get the chance to say so.
|
||||
if let existing = try store.image(named: name),
|
||||
(try? existing.loadConfig())?.provisioned == true
|
||||
{
|
||||
throw ValidationError(
|
||||
"image '\(name)' already exists — delete it first with `image delete \(name)`"
|
||||
)
|
||||
@@ -101,7 +106,10 @@ struct ImageCommand: AsyncParsableCommand {
|
||||
// it to exit is its own deadlock. See `VZAppRuntime.run`.
|
||||
VZAppRuntime.flushAndExit(1)
|
||||
}
|
||||
printer.finish("done")
|
||||
// Just seals the line: the builder's own `.done` stage has
|
||||
// already printed it, and saying it twice down a pipe reads
|
||||
// like something ran twice.
|
||||
printer.finish()
|
||||
|
||||
print("built image '\(imageName)'")
|
||||
print("next: gitea-macos-runner vm boot --image \(imageName)")
|
||||
@@ -281,7 +289,30 @@ struct ImageCommand: AsyncParsableCommand {
|
||||
if case .note(let text) = stage {
|
||||
printer.line(text)
|
||||
} else {
|
||||
printer.update(describe(stage))
|
||||
printer.update(describe(stage), group: group(of: stage))
|
||||
}
|
||||
}
|
||||
|
||||
/// The stage a status line belongs to, ignoring its varying payload.
|
||||
///
|
||||
/// Two lines share a group exactly when one is meant to overwrite the
|
||||
/// other. Crossing a group boundary seals the previous line instead, which
|
||||
/// is why `installing macOS … 100%` survives into scrollback rather than
|
||||
/// being replaced by `first boot + guest provisioning…`.
|
||||
static func group(of stage: ImageBuildStage) -> String {
|
||||
switch stage {
|
||||
case .downloadingIPSW: return "download"
|
||||
case .preparing: return "preparing"
|
||||
case .loadingRestoreImage: return "loading"
|
||||
case .creatingBundle: return "bundle"
|
||||
case .note: return "note"
|
||||
case .installing: return "install"
|
||||
case .firstBoot: return "firstBoot"
|
||||
// Each provisioning step is its own headline — "installing Node.js"
|
||||
// should not erase "downloading gitea-runner".
|
||||
case .provisioning(let step): return "provisioning:\(step)"
|
||||
case .finalizing: return "finalizing"
|
||||
case .done: return "done"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -136,6 +136,7 @@ final class OnceFlag: @unchecked Sendable {
|
||||
final class ProgressPrinter: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var lastLine = ""
|
||||
private var lastGroup: String?
|
||||
|
||||
/// Whether carriage-return rewriting means anything here.
|
||||
///
|
||||
@@ -147,17 +148,31 @@ final class ProgressPrinter: @unchecked Sendable {
|
||||
private let isInteractive = isatty(fileno(stderr)) == 1
|
||||
|
||||
/// Rewrites the current line.
|
||||
func update(_ line: String) {
|
||||
///
|
||||
/// - 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 {
|
||||
FileHandle.standardError.write(Data((line + "\n").utf8))
|
||||
emit(line + "\n")
|
||||
return
|
||||
}
|
||||
let padding = String(repeating: " ", count: max(0, 78 - line.count))
|
||||
FileHandle.standardError.write(Data(("\r" + line + padding).utf8))
|
||||
emit("\r" + line + pad(line))
|
||||
}
|
||||
|
||||
/// Emits a standalone line without losing the status line under it.
|
||||
@@ -177,12 +192,20 @@ final class ProgressPrinter: @unchecked Sendable {
|
||||
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))
|
||||
emit((isInteractive ? "\r" : "") + line + (isInteractive ? pad(line) : "") + "\n")
|
||||
} else if !lastLine.isEmpty, isInteractive {
|
||||
FileHandle.standardError.write(Data("\n".utf8))
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user