Nucleic: Gitea Runner macOS VM Support
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user