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 `. 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=` /// /// 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=&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(_ 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) 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 "" } 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) } }