358 lines
16 KiB
Swift
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)
|
|
}
|
|
}
|