Nucleic: Gitea Runner macOS VM Support

This commit is contained in:
2026-08-07 00:44:36 -07:00
parent 749f0be4fb
commit 33f299396a
47 changed files with 13159 additions and 0 deletions
+330
View File
@@ -0,0 +1,330 @@
import Foundation
/// A single job within a workflow run, as reported by
/// `GET /api/v1/admin/actions/jobs` (Gitea 1.25+).
///
/// - Note: `status` values are the *external* strings. `queued` is the one we
/// act on: it maps to Gitea's internal `StatusWaiting`, meaning "ready and
/// waiting for a matching runner". The string `waiting` means something quite
/// different — the job is **blocked** on a dependency — and must never be
/// treated as schedulable.
public struct WorkflowJob: Codable, Sendable, Equatable, Identifiable {
/// Job id. Unique across the instance; the scheduler dedups on this.
public let id: Int64
/// The workflow run this job belongs to.
public let runID: Int64
/// The job's display name.
public let name: String
/// One of `queued`, `waiting`, `running`, `success`, `failure`, `cancelled`,
/// `skipped`, `blocked`.
public let status: String
/// The job's `runs-on:` values, as **bare** label names.
public let labels: [String]
/// The runner that claimed the job, if any.
public let runnerID: Int64?
/// That runner's name, if any. Lets the reconcile loop tie a Gitea runner
/// row back to one of our VMs.
public let runnerName: String?
/// When the job was created.
public let createdAt: Date?
/// When a runner picked it up.
public let startedAt: Date?
/// When it finished.
public let completedAt: Date?
public init(
id: Int64,
runID: Int64,
name: String,
status: String,
labels: [String],
runnerID: Int64? = nil,
runnerName: String? = nil,
createdAt: Date? = nil,
startedAt: Date? = nil,
completedAt: Date? = nil
) {
self.id = id
self.runID = runID
self.name = name
self.status = status
self.labels = labels
self.runnerID = runnerID
self.runnerName = runnerName
self.createdAt = createdAt
self.startedAt = startedAt
self.completedAt = completedAt
}
private enum CodingKeys: String, CodingKey {
case id
case runID = "run_id"
case name
case status
case labels
case runnerID = "runner_id"
case runnerName = "runner_name"
case createdAt = "created_at"
case startedAt = "started_at"
case completedAt = "completed_at"
}
/// Whether this job is waiting for a runner right now.
public var isQueued: Bool { status == "queued" }
/// ``status`` as a case, with an ``JobStatus/unknown(_:)`` catch-all.
public var jobStatus: JobStatus { JobStatus(rawValue: status) }
/// Decodes tolerantly: `runner_id` / `runner_name` carry `omitempty` in
/// Gitea, and the timestamps are Go `time.Time` values that serialize as
/// `0001-01-01T00:00:00Z` when unset (a queued job has no `started_at`).
/// Those zero instants are surfaced as `nil` rather than as a year-1 date.
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.id = try c.decode(Int64.self, forKey: .id)
self.runID = try c.decodeIfPresent(Int64.self, forKey: .runID) ?? 0
self.name = try c.decodeIfPresent(String.self, forKey: .name) ?? ""
self.status = try c.decodeIfPresent(String.self, forKey: .status) ?? ""
self.labels = try c.decodeIfPresent([String].self, forKey: .labels) ?? []
self.runnerID = try c.decodeIfPresent(Int64.self, forKey: .runnerID)
self.runnerName = try c.decodeIfPresent(String.self, forKey: .runnerName)
self.createdAt = WorkflowJob.nonZero(try c.decodeIfPresent(Date.self, forKey: .createdAt))
self.startedAt = WorkflowJob.nonZero(try c.decodeIfPresent(Date.self, forKey: .startedAt))
self.completedAt = WorkflowJob.nonZero(try c.decodeIfPresent(Date.self, forKey: .completedAt))
}
/// Maps Go's zero `time.Time` (year 1) to `nil`.
private static func nonZero(_ date: Date?) -> Date? {
guard let date else { return nil }
// 0001-01-01T00:00:00Z is ~62.1e9 seconds before the reference date.
return date.timeIntervalSinceReferenceDate <= -62_135_596_800 ? nil : date
}
}
/// The *external* status strings Gitea reports for a workflow job.
///
/// Gitea maps its internal statuses onto GitHub's vocabulary in
/// `convert.ToActionsStatus`: `StatusWaiting → "queued"`,
/// `StatusBlocked → "waiting"`, `StatusRunning → "in_progress"`, and every
/// terminal status → `"completed"` (the detail moves to a separate `conclusion`
/// field). The `unknown` case exists because that mapping is Gitea's to change:
/// a closed enum that threw on an unrecognized string would turn a new server
/// version into a decode failure and stop the poll loop dead.
public enum JobStatus: RawRepresentable, Sendable, Equatable, Hashable {
/// Ready and waiting for a matching runner — Gitea's internal `StatusWaiting`.
/// This is the only status that is schedulable.
case queued
/// **Blocked** on a dependency — Gitea's internal `StatusBlocked`. Despite
/// the name, this is *not* a job waiting for a runner.
case waiting
/// Claimed by a runner and executing.
case inProgress
/// Terminal, in any of success / failure / cancelled / skipped.
case completed
/// A status string this build does not know about.
case unknown(String)
public init(rawValue: String) {
switch rawValue {
case "queued": self = .queued
case "waiting": self = .waiting
case "in_progress": self = .inProgress
case "completed": self = .completed
default: self = .unknown(rawValue)
}
}
public var rawValue: String {
switch self {
case .queued: return "queued"
case .waiting: return "waiting"
case .inProgress: return "in_progress"
case .completed: return "completed"
case .unknown(let raw): return raw
}
}
}
/// Envelope returned by `GET /api/v1/admin/actions/jobs`.
///
/// - Important: The array key has been observed as `jobs`, which is what is
/// decoded here; some Gitea builds/OpenAPI revisions have used `workflow_jobs`
/// for the equivalent repo-scoped endpoint. ``CodingKeys`` is written out
/// explicitly so that adding a fallback is a one-line change, and
/// ``jobs`` is optional so an empty response body decodes rather than throwing.
public struct WorkflowJobsResponse: Codable, Sendable, Equatable {
/// Total matching jobs server-side, ignoring `limit`.
public let totalCount: Int?
/// The page of jobs. `nil` and `[]` both mean "nothing queued".
public let jobs: [WorkflowJob]?
public init(totalCount: Int?, jobs: [WorkflowJob]?) {
self.totalCount = totalCount
self.jobs = jobs
}
private enum CodingKeys: String, CodingKey {
case totalCount = "total_count"
case jobs
case workflowJobs = "workflow_jobs"
case entries
}
/// The jobs, never `nil`.
public var items: [WorkflowJob] { jobs ?? [] }
/// Decodes the array under `jobs`, falling back to `workflow_jobs` and
/// `entries`.
///
/// Gitea 1.25's `ActionWorkflowJobsResponse` tags its slice `json:"jobs"`
/// (the Go field is named `Entries`), which is what the fallbacks guard
/// against: a future rename of the tag, or a proxy that reserializes from
/// the Go field name.
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.totalCount = try c.decodeIfPresent(Int.self, forKey: .totalCount)
if let jobs = try c.decodeIfPresent([WorkflowJob].self, forKey: .jobs) {
self.jobs = jobs
} else if let jobs = try c.decodeIfPresent([WorkflowJob].self, forKey: .workflowJobs) {
self.jobs = jobs
} else {
self.jobs = try c.decodeIfPresent([WorkflowJob].self, forKey: .entries)
}
}
public func encode(to encoder: Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encodeIfPresent(totalCount, forKey: .totalCount)
try c.encodeIfPresent(jobs, forKey: .jobs)
}
}
/// A registered Actions runner, from `GET /api/v1/admin/actions/runners`.
public struct ActionRunner: Codable, Sendable, Equatable, Identifiable {
/// Runner id, used for `DELETE /api/v1/admin/actions/runners/{id}`.
public let id: Int64
/// Runner name. Ours always start with the configured `namePrefix`.
public let name: String
/// Bare label names the runner advertises.
public let labels: [String]
/// Server-side liveness, e.g. `online` / `offline`.
public let status: String?
/// Whether the runner is currently executing a task.
public let busy: Bool?
/// Whether the runner registered with `--ephemeral`, i.e. the server will
/// hand it exactly one task and then auto-deregister it (Gitea 1.24+).
public let ephemeral: Bool?
public init(
id: Int64,
name: String,
labels: [String],
status: String? = nil,
busy: Bool? = nil,
ephemeral: Bool? = nil
) {
self.id = id
self.name = name
self.labels = labels
self.status = status
self.busy = busy
self.ephemeral = ephemeral
}
private enum CodingKeys: String, CodingKey {
case id, name, labels, status, busy, ephemeral
}
/// A single entry of `ActionRunner.labels` as Gitea actually serializes it:
/// an object, not a string.
///
/// Verified against `modules/structs/repo_actions.go` at tag `v1.25.0`,
/// where `ActionRunner.Labels` is `[]*ActionRunnerLabel` and
/// `ActionRunnerLabel` is `{id int64, name string, type string}`. Only
/// ``name`` is of any use here.
private struct LabelObject: Decodable {
let name: String?
}
/// Decodes `labels` from either shape.
///
/// Gitea's job payload gives labels as plain strings, its runner payload
/// gives them as objects. Both are accepted so that this one model keeps
/// working if a version, a proxy, or a hand-written fixture disagrees.
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.id = try c.decode(Int64.self, forKey: .id)
self.name = try c.decodeIfPresent(String.self, forKey: .name) ?? ""
self.status = try c.decodeIfPresent(String.self, forKey: .status)
self.busy = try c.decodeIfPresent(Bool.self, forKey: .busy)
self.ephemeral = try c.decodeIfPresent(Bool.self, forKey: .ephemeral)
if let strings = try? c.decode([String].self, forKey: .labels) {
self.labels = strings
} else if let objects = try? c.decode([LabelObject].self, forKey: .labels) {
self.labels = objects.compactMap(\.name)
} else {
// Absent or explicitly null. Gitea does emit `"labels": null` for a
// runner registered without any.
self.labels = []
}
}
/// `busy` treated as `false` when the server omits it.
public var isBusy: Bool { busy ?? false }
/// `ephemeral` treated as `false` when the server omits it. The reconcile
/// loop only ever deletes rows it is sure are ephemeral.
public var isEphemeral: Bool { ephemeral ?? false }
}
/// Envelope returned by `GET /api/v1/admin/actions/runners`.
public struct RunnersResponse: Codable, Sendable, Equatable {
public let totalCount: Int?
public let runners: [ActionRunner]?
public init(totalCount: Int?, runners: [ActionRunner]?) {
self.totalCount = totalCount
self.runners = runners
}
private enum CodingKeys: String, CodingKey {
case totalCount = "total_count"
case runners
case entries
}
/// The runners, never `nil`.
public var items: [ActionRunner] { runners ?? [] }
/// Decodes the array under `runners` (Gitea 1.25's tag), falling back to
/// `entries` (the Go field name).
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.totalCount = try c.decodeIfPresent(Int.self, forKey: .totalCount)
if let runners = try c.decodeIfPresent([ActionRunner].self, forKey: .runners) {
self.runners = runners
} else {
self.runners = try c.decodeIfPresent([ActionRunner].self, forKey: .entries)
}
}
public func encode(to encoder: Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encodeIfPresent(totalCount, forKey: .totalCount)
try c.encodeIfPresent(runners, forKey: .runners)
}
}
/// Response from `POST /api/v1/admin/actions/runners/registration-token`.
///
/// - Warning: This is scope-wide and **reusable**. Treat the returned value as
/// "the current token for this scope", not as a freshly minted per-VM secret.
public struct RegistrationTokenResponse: Codable, Sendable, Equatable {
/// The registration token.
public let token: String
public init(token: String) {
self.token = token
}
}