Nucleic: Gitea Runner macOS VM Support
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
import Foundation
|
||||
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
/// The HTTP seam under ``GiteaClient``.
|
||||
///
|
||||
/// Everything network-facing goes through this protocol so tests can supply a
|
||||
/// canned transport without a live Gitea instance.
|
||||
public protocol HTTPTransport: Sendable {
|
||||
/// Performs a request.
|
||||
///
|
||||
/// - Parameter request: A fully-formed request, including auth headers.
|
||||
/// - Returns: The response body and its HTTP status code.
|
||||
/// - Throws: Transport-level errors only; a non-2xx status is *not* an error
|
||||
/// here — ``GiteaClient`` maps that to ``CoreError/gitea(status:message:)``.
|
||||
func send(_ request: URLRequest) async throws -> (Data, Int)
|
||||
}
|
||||
|
||||
/// The production transport, backed by `URLSession`.
|
||||
public struct URLSessionTransport: HTTPTransport {
|
||||
/// The underlying session.
|
||||
public let session: URLSession
|
||||
|
||||
/// Creates a transport.
|
||||
///
|
||||
/// - Parameter session: Defaults to an ephemeral session with a 30 s request
|
||||
/// timeout, so a hung Gitea cannot stall the poll loop.
|
||||
public init(session: URLSession = URLSessionTransport.makeDefaultSession()) {
|
||||
self.session = session
|
||||
}
|
||||
|
||||
/// Builds the default ephemeral session.
|
||||
public static func makeDefaultSession() -> URLSession {
|
||||
let cfg = URLSessionConfiguration.ephemeral
|
||||
cfg.timeoutIntervalForRequest = 30
|
||||
cfg.timeoutIntervalForResource = 60
|
||||
return URLSession(configuration: cfg)
|
||||
}
|
||||
|
||||
public func send(_ request: URLRequest) async throws -> (Data, Int) {
|
||||
// `dataTask` + a continuation rather than `session.data(for:)`, because
|
||||
// the async URLSession API is not uniformly available in
|
||||
// swift-corelibs-foundation, and RunnerCore must build on Linux.
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<(Data, Int), Error>) in
|
||||
let task = session.dataTask(with: request) { data, response, error in
|
||||
if let error {
|
||||
continuation.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
continuation.resume(
|
||||
throwing: CoreError.gitea(status: 0, message: "no HTTP response"))
|
||||
return
|
||||
}
|
||||
continuation.resume(returning: (data ?? Data(), http.statusCode))
|
||||
}
|
||||
task.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A thin, typed client for the subset of Gitea's admin Actions API this daemon
|
||||
/// needs.
|
||||
///
|
||||
/// All endpoints used here are **admin**-scoped, so the token must belong to a
|
||||
/// Gitea administrator. `doctor` verifies that by calling ``listRunners()``.
|
||||
public struct GiteaClient: Sendable {
|
||||
/// Instance base URL, e.g. `https://gitea.example.com`.
|
||||
public let baseURL: URL
|
||||
/// Admin API token, sent as `Authorization: token <value>`.
|
||||
public let token: String
|
||||
/// The HTTP seam.
|
||||
public let transport: any HTTPTransport
|
||||
|
||||
/// Creates a client.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - baseURL: Instance base URL; a trailing slash is tolerated.
|
||||
/// - token: Admin API token.
|
||||
/// - transport: Defaults to ``URLSessionTransport``.
|
||||
public init(baseURL: URL, token: String, transport: any HTTPTransport = URLSessionTransport()) {
|
||||
self.baseURL = baseURL
|
||||
self.token = token
|
||||
self.transport = transport
|
||||
}
|
||||
|
||||
// MARK: - Endpoints
|
||||
|
||||
/// Lists jobs currently waiting for a runner.
|
||||
///
|
||||
/// `GET /api/v1/admin/actions/jobs?status=queued&limit=<limit>`
|
||||
///
|
||||
/// A queued job stays queued until a matching runner claims it, or until
|
||||
/// Gitea's `ABANDONED_JOB_TIMEOUT` (default 24 h, swept every 6 h) expires
|
||||
/// it. There is therefore no urgency risk in a 5-second poll.
|
||||
///
|
||||
/// - Parameter limit: Page size. The scheduler only ever needs a handful.
|
||||
/// - Returns: The queued jobs, oldest-first as Gitea returns them.
|
||||
/// - Throws: ``CoreError/gitea(status:message:)`` on a non-2xx response.
|
||||
public func listQueuedJobs(limit: Int = 50) async throws -> [WorkflowJob] {
|
||||
// `status=queued` and nothing else. Gitea's `convertToInternal` maps
|
||||
// "queued" onto StatusWaiting ("ready, waiting for a runner") and maps
|
||||
// "waiting" onto StatusBlocked ("blocked on a dependency") — so asking
|
||||
// for "waiting" would return exactly the jobs that must not be booted.
|
||||
let request = try makeRequest(
|
||||
method: "GET",
|
||||
path: "/api/v1/admin/actions/jobs",
|
||||
query: [
|
||||
URLQueryItem(name: "status", value: "queued"),
|
||||
URLQueryItem(name: "limit", value: String(max(limit, 1))),
|
||||
])
|
||||
let response = try await send(request, as: WorkflowJobsResponse.self)
|
||||
return response.items
|
||||
}
|
||||
|
||||
/// Lists every registered runner on the instance.
|
||||
///
|
||||
/// `GET /api/v1/admin/actions/runners?page=<n>&limit=<limit>`
|
||||
///
|
||||
/// Used by the reconcile loop and by `doctor` (as an admin-scope probe).
|
||||
///
|
||||
/// Paginated deliberately: an unpaginated request returns only Gitea's
|
||||
/// default first page, and reconcile is precisely the thing that stops an
|
||||
/// instance from accumulating orphan rows. Missing rows past the page
|
||||
/// boundary would let the leak accelerate — each undeleted row pushes more
|
||||
/// rows out of view — and it would silently no-op the targeted cleanup that
|
||||
/// looks a single runner up by name.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - limit: Page size.
|
||||
/// - maxPages: Defensive ceiling, so a server that ignores `page` cannot
|
||||
/// spin this forever.
|
||||
/// - Returns: Every runner across all fetched pages, in server order.
|
||||
public func listRunners(limit: Int = 50, maxPages: Int = 50) async throws -> [ActionRunner] {
|
||||
let pageSize = max(limit, 1)
|
||||
var all: [ActionRunner] = []
|
||||
|
||||
for page in 1...max(maxPages, 1) {
|
||||
let request = try makeRequest(
|
||||
method: "GET",
|
||||
path: "/api/v1/admin/actions/runners",
|
||||
query: [
|
||||
URLQueryItem(name: "page", value: String(page)),
|
||||
URLQueryItem(name: "limit", value: String(pageSize)),
|
||||
])
|
||||
let response = try await send(request, as: RunnersResponse.self)
|
||||
all.append(contentsOf: response.items)
|
||||
// Terminate on the server's own total rather than on a short page:
|
||||
// Gitea clamps `limit` to its configured maximum, so a page shorter
|
||||
// than the one we asked for is not evidence that it is the last.
|
||||
if response.items.isEmpty { break }
|
||||
if let total = response.totalCount, all.count >= total { break }
|
||||
}
|
||||
|
||||
return all
|
||||
}
|
||||
|
||||
/// Deletes a runner row.
|
||||
///
|
||||
/// `DELETE /api/v1/admin/actions/runners/{id}`
|
||||
///
|
||||
/// Needed because a VM that dies uncleanly leaves its row behind: Gitea only
|
||||
/// sweeps runner rows at midnight, and never sweeps a runner that claimed no
|
||||
/// task. A 404 is treated as success (someone else already removed it).
|
||||
///
|
||||
/// - Parameter id: The runner id.
|
||||
public func deleteRunner(id: Int64) async throws {
|
||||
let request = try makeRequest(
|
||||
method: "DELETE",
|
||||
path: "/api/v1/admin/actions/runners/\(id)")
|
||||
// Gitea answers 204. 200 is accepted for tolerance, and 404 counts as
|
||||
// success: the reconcile loop's only goal is that the row be gone, and
|
||||
// it races with Gitea's own midnight sweep and with `--ephemeral`
|
||||
// auto-deregistration.
|
||||
try await sendIgnoringBody(request, acceptingStatuses: [200, 202, 204, 404])
|
||||
}
|
||||
|
||||
/// Returns the instance-scoped runner registration token.
|
||||
///
|
||||
/// `POST /api/v1/admin/actions/runners/registration-token`
|
||||
///
|
||||
/// - Warning: In current Gitea this returns the *existing* active token for
|
||||
/// the scope rather than minting a new one — but the semantics of "mint"
|
||||
/// are that a new token **invalidates all prior tokens of that scope**.
|
||||
/// Never call this per VM as a way of getting a throwaway secret; call it
|
||||
/// once and cache. Prefer seeding the token server-side via
|
||||
/// `GITEA_RUNNER_REGISTRATION_TOKEN` and configuring it statically.
|
||||
public func getRegistrationToken() async throws -> String {
|
||||
let request = try makeRequest(
|
||||
method: "POST",
|
||||
path: "/api/v1/admin/actions/runners/registration-token")
|
||||
let response = try await send(request, as: RegistrationTokenResponse.self)
|
||||
let token = response.token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !token.isEmpty else {
|
||||
throw CoreError.gitea(status: 200, message: "registration-token response carried an empty token")
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
/// Cheap reachability + auth probe used by `doctor`.
|
||||
///
|
||||
/// - Throws: ``CoreError/gitea(status:message:)`` when the instance is
|
||||
/// reachable but rejects the token.
|
||||
public func ping() async throws {
|
||||
// The runners list rather than /api/v1/version: version is anonymously
|
||||
// readable on most instances, so it would report "reachable" for a token
|
||||
// that is expired, wrong, or simply not an admin's — which is the exact
|
||||
// failure `doctor` exists to catch.
|
||||
_ = try await listRunners()
|
||||
}
|
||||
|
||||
// MARK: - Request plumbing
|
||||
|
||||
/// Builds an authenticated request against an API path.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - method: HTTP method.
|
||||
/// - path: API path relative to the instance root, e.g.
|
||||
/// `/api/v1/admin/actions/runners`.
|
||||
/// - query: Optional query items.
|
||||
/// - body: Optional request body; sets `Content-Type: application/json`.
|
||||
/// - Returns: A request carrying `Authorization` and `Accept` headers.
|
||||
public func makeRequest(
|
||||
method: String,
|
||||
path: String,
|
||||
query: [URLQueryItem] = [],
|
||||
body: Data? = nil
|
||||
) throws -> URLRequest {
|
||||
// Built by string-joining rather than `URL(string:relativeTo:)`, which
|
||||
// would discard any path component of `baseURL` — instances served under
|
||||
// a subpath (https://example.com/gitea) are common enough to matter.
|
||||
var base = baseURL.absoluteString
|
||||
while base.hasSuffix("/") { base.removeLast() }
|
||||
let suffix = path.hasPrefix("/") ? path : "/" + path
|
||||
|
||||
guard var components = URLComponents(string: base + suffix) else {
|
||||
throw CoreError.configInvalid("cannot form a request URL from \(base + suffix)")
|
||||
}
|
||||
if !query.isEmpty {
|
||||
components.queryItems = query
|
||||
}
|
||||
guard let url = components.url else {
|
||||
throw CoreError.configInvalid("cannot form a request URL from \(base + suffix)")
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method
|
||||
// Gitea's PAT scheme. `Bearer` also works on recent versions, but
|
||||
// `token` is the documented form and works on every 1.x.
|
||||
request.setValue("token \(token)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.setValue(
|
||||
"gitea-macos-runner/\(RunnerVersion.current)", forHTTPHeaderField: "User-Agent")
|
||||
if let body {
|
||||
request.httpBody = body
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
/// Sends a request and decodes a JSON body, mapping non-2xx to
|
||||
/// ``CoreError/gitea(status:message:)``.
|
||||
public func send<T: Decodable>(_ request: URLRequest, as type: T.Type) async throws -> T {
|
||||
let (data, status) = try await transport.send(request)
|
||||
guard (200..<300).contains(status) else {
|
||||
throw CoreError.gitea(status: status, message: GiteaClient.errorMessage(from: data))
|
||||
}
|
||||
do {
|
||||
return try GiteaClient.makeDecoder().decode(T.self, from: data)
|
||||
} catch {
|
||||
throw CoreError.gitea(
|
||||
status: status,
|
||||
message: "could not decode \(T.self): \(error) — body: \(GiteaClient.excerpt(data))")
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends a request that is expected to have no useful body.
|
||||
public func sendIgnoringBody(_ request: URLRequest, acceptingStatuses: Set<Int>) async throws {
|
||||
let (data, status) = try await transport.send(request)
|
||||
guard acceptingStatuses.contains(status) || (200..<300).contains(status) else {
|
||||
throw CoreError.gitea(status: status, message: GiteaClient.errorMessage(from: data))
|
||||
}
|
||||
}
|
||||
|
||||
/// Gitea's error bodies are `{"message": "...", "url": "..."}`. Prefer that
|
||||
/// message; fall back to a truncated raw body so nothing is ever reported as
|
||||
/// an empty error.
|
||||
private static func errorMessage(from data: Data) -> String {
|
||||
struct APIError: Decodable {
|
||||
let message: String?
|
||||
let errors: [String]?
|
||||
}
|
||||
if let decoded = try? JSONDecoder().decode(APIError.self, from: data) {
|
||||
if let message = decoded.message?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!message.isEmpty
|
||||
{
|
||||
return message
|
||||
}
|
||||
if let errors = decoded.errors, !errors.isEmpty {
|
||||
return errors.joined(separator: "; ")
|
||||
}
|
||||
}
|
||||
return excerpt(data)
|
||||
}
|
||||
|
||||
/// At most `limit` characters of a response body, for error messages.
|
||||
private static func excerpt(_ data: Data, limit: Int = 512) -> String {
|
||||
guard !data.isEmpty else { return "<empty body>" }
|
||||
let text = String(decoding: data, as: UTF8.self)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard text.count > limit else { return text }
|
||||
return String(text.prefix(limit)) + "… (\(data.count) bytes)"
|
||||
}
|
||||
|
||||
/// A `JSONDecoder` configured for Gitea's timestamps (RFC 3339 / ISO 8601
|
||||
/// with an offset).
|
||||
///
|
||||
/// Go's `time.Time` marshals as RFC 3339 **Nano**: the fractional-seconds
|
||||
/// part is present only when non-zero, so a single strict formatter fails
|
||||
/// intermittently on real traffic. Both spellings are tried, plus a plain
|
||||
/// `YYYY-MM-DD` for good measure.
|
||||
public static func makeDecoder() -> JSONDecoder {
|
||||
let d = JSONDecoder()
|
||||
d.dateDecodingStrategy = .custom { decoder in
|
||||
let raw = try decoder.singleValueContainer().decode(String.self)
|
||||
if let date = parseTimestamp(raw) { return date }
|
||||
throw DecodingError.dataCorrupted(
|
||||
DecodingError.Context(
|
||||
codingPath: decoder.codingPath,
|
||||
debugDescription: "not an RFC 3339 timestamp: \"\(raw)\""))
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
/// Parses an RFC 3339 timestamp with or without fractional seconds.
|
||||
///
|
||||
/// - Parameter raw: The timestamp string.
|
||||
/// - Returns: The instant, or `nil` if it is in no recognized form.
|
||||
public static func parseTimestamp(_ raw: String) -> Date? {
|
||||
// Formatters are built per call rather than cached in a `static let`:
|
||||
// `ISO8601DateFormatter` is a non-Sendable reference type, and this is
|
||||
// called a handful of times per poll — not a hot path.
|
||||
let withFractional = ISO8601DateFormatter()
|
||||
withFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let date = withFractional.date(from: raw) { return date }
|
||||
|
||||
let plain = ISO8601DateFormatter()
|
||||
plain.formatOptions = [.withInternetDateTime]
|
||||
if let date = plain.date(from: raw) { return date }
|
||||
|
||||
let dateOnly = ISO8601DateFormatter()
|
||||
dateOnly.formatOptions = [.withFullDate]
|
||||
return dateOnly.date(from: raw)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user