Files
gitea-macos-vm-orchestrator/Sources/RunnerCore/GiteaClient.swift
T

358 lines
16 KiB
Swift

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)
}
}