734 lines
32 KiB
Swift
734 lines
32 KiB
Swift
import Foundation
|
|
|
|
/// The runner's on-disk configuration, loaded from
|
|
/// `~/.config/gitea-macos-runner/config.json`.
|
|
///
|
|
/// Every section has defaults, and decoding tolerates missing keys, so a minimal
|
|
/// config only needs `gitea.instanceURL` plus a way to obtain tokens. See
|
|
/// `Resources/config.example.json` for an annotated full example.
|
|
public struct RunnerConfig: Codable, Sendable, Equatable {
|
|
|
|
// MARK: - Sections
|
|
|
|
/// How to reach the Gitea instance and how to authenticate to it.
|
|
public struct GiteaSection: Codable, Sendable, Equatable {
|
|
/// Base URL of the Gitea instance, e.g. `https://gitea.example.com`.
|
|
/// Paths are appended to this, so a trailing slash is harmless.
|
|
public var instanceURL: URL
|
|
|
|
/// A Gitea admin API token, inline. Used for the admin Actions endpoints
|
|
/// (job listing, runner listing/deletion, registration-token minting).
|
|
/// Prefer ``adminTokenFile`` so the secret is not world-readable in JSON.
|
|
///
|
|
/// - Important: Exactly one of this and ``adminTokenFile`` must be set.
|
|
/// ``RunnerConfig/validated()`` rejects both-set and neither-set alike;
|
|
/// a stale inline token sitting beside a live token file is exactly the
|
|
/// ambiguity that produces a baffling 401 at 3am.
|
|
public var adminToken: String?
|
|
|
|
/// Path to a file whose (trimmed) contents are the admin API token.
|
|
/// Tilde-expanded.
|
|
///
|
|
/// - Important: Exactly one of this and ``adminToken`` must be set — see
|
|
/// that property. This one does *not* silently win over an inline
|
|
/// value; setting both is a validation error.
|
|
public var adminTokenFile: String?
|
|
|
|
/// The shared runner registration token, inline.
|
|
///
|
|
/// - Important: Registration tokens are **reusable** and **scoped**.
|
|
/// Minting a new token for a scope invalidates all prior tokens of that
|
|
/// scope, so per-VM tokens must never be pre-generated. One shared
|
|
/// token serves the whole fleet. See docs/DESIGN.md, Verified Fact 4.
|
|
public var registrationToken: String?
|
|
|
|
/// Path to a file whose (trimmed) contents are the registration token.
|
|
/// Tilde-expanded. Takes precedence over ``registrationToken``.
|
|
public var registrationTokenFile: String?
|
|
|
|
/// When no static registration token is configured, fetch one from
|
|
/// `POST /api/v1/admin/actions/runners/registration-token`.
|
|
///
|
|
/// Defaults to `false` because that endpoint effectively returns the
|
|
/// *existing* active token for the scope, and any implementation change
|
|
/// that made it mint a fresh one would invalidate tokens held by runners
|
|
/// registered elsewhere.
|
|
public var fetchRegistrationTokenViaAPI: Bool
|
|
|
|
public init(
|
|
instanceURL: URL,
|
|
adminToken: String? = nil,
|
|
adminTokenFile: String? = nil,
|
|
registrationToken: String? = nil,
|
|
registrationTokenFile: String? = nil,
|
|
fetchRegistrationTokenViaAPI: Bool = false
|
|
) {
|
|
self.instanceURL = instanceURL
|
|
self.adminToken = adminToken
|
|
self.adminTokenFile = adminTokenFile
|
|
self.registrationToken = registrationToken
|
|
self.registrationTokenFile = registrationTokenFile
|
|
self.fetchRegistrationTokenViaAPI = fetchRegistrationTokenViaAPI
|
|
}
|
|
}
|
|
|
|
/// Identity and provenance of the runners registered inside each guest.
|
|
public struct RunnerSection: Codable, Sendable, Equatable {
|
|
/// Bare label names this host serves. Matched case-sensitively against a
|
|
/// job's `labels` (i.e. its `runs-on:`). The `:host` schema suffix is
|
|
/// added only when calling `gitea-runner register`.
|
|
public var labels: [String]
|
|
|
|
/// Prefix for generated runner names. Must be distinctive enough that the
|
|
/// reconcile loop can tell our stale rows from other runners'.
|
|
public var namePrefix: String
|
|
|
|
/// Template for the `gitea-runner` release asset to install in the guest.
|
|
/// `{version}` is substituted with ``version``.
|
|
public var runnerDownloadURL: String
|
|
|
|
/// The `gitea-runner` version to install (v3.x; the binary was renamed
|
|
/// from `act_runner`, and now lives at `gitea.com/gitea/runner`).
|
|
public var version: String
|
|
|
|
public init(
|
|
labels: [String] = ["macos-arm64"],
|
|
namePrefix: String = "macos-vm-",
|
|
runnerDownloadURL: String = RunnerSection.defaultDownloadURLTemplate,
|
|
version: String = "3.0.2"
|
|
) {
|
|
self.labels = labels
|
|
self.namePrefix = namePrefix
|
|
self.runnerDownloadURL = runnerDownloadURL
|
|
self.version = version
|
|
}
|
|
|
|
/// Default release-asset URL template for the darwin/arm64 build.
|
|
public static let defaultDownloadURLTemplate =
|
|
"https://gitea.com/gitea/runner/releases/download/v{version}/gitea-runner-{version}-darwin-arm64"
|
|
|
|
/// ``runnerDownloadURL`` with `{version}` substituted.
|
|
public var resolvedDownloadURL: URL {
|
|
get throws {
|
|
let substituted = runnerDownloadURL.replacingOccurrences(of: "{version}", with: version)
|
|
guard let url = URL(string: substituted), url.scheme != nil else {
|
|
throw CoreError.configInvalid(
|
|
"runner.runnerDownloadURL does not form a valid URL: \(substituted)")
|
|
}
|
|
return url
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Polling cadence, concurrency, and the timeouts that bound a stuck VM.
|
|
public struct SchedulerSection: Codable, Sendable, Equatable {
|
|
/// How many macOS guests may run at once.
|
|
///
|
|
/// - Important: Hard-clamped to 2 by ``RunnerConfig/validated()``. Apple's
|
|
/// kernel enforces a limit of two concurrent macOS VMs per host; a third
|
|
/// `start()` fails with `VZError.virtualMachineLimitExceeded`.
|
|
public var maxConcurrentVMs: Int
|
|
|
|
/// Seconds between queued-job polls.
|
|
public var pollIntervalSeconds: Int
|
|
|
|
/// Seconds between reconcile passes that sweep orphaned runner rows.
|
|
public var reconcileIntervalSeconds: Int
|
|
|
|
/// Wall-clock ceiling on a single job before its VM is torn down.
|
|
public var jobTimeoutMinutes: Int
|
|
|
|
/// Ceiling on boot + DHCP lease + SSH readiness before a slot is
|
|
/// declared dead and recycled.
|
|
public var bootTimeoutSeconds: Int
|
|
|
|
public init(
|
|
maxConcurrentVMs: Int = 2,
|
|
pollIntervalSeconds: Int = 5,
|
|
reconcileIntervalSeconds: Int = 300,
|
|
jobTimeoutMinutes: Int = 120,
|
|
bootTimeoutSeconds: Int = 300
|
|
) {
|
|
self.maxConcurrentVMs = maxConcurrentVMs
|
|
self.pollIntervalSeconds = pollIntervalSeconds
|
|
self.reconcileIntervalSeconds = reconcileIntervalSeconds
|
|
self.jobTimeoutMinutes = jobTimeoutMinutes
|
|
self.bootTimeoutSeconds = bootTimeoutSeconds
|
|
}
|
|
|
|
/// The absolute cap on concurrent macOS guests, enforced by the kernel.
|
|
public static let hardMaxConcurrentVMs = 2
|
|
}
|
|
|
|
/// Shape of each guest VM and the credentials used to reach it over SSH.
|
|
///
|
|
/// - Note: These credentials only ever exist on the NAT network between the
|
|
/// host and its own ephemeral guests. They are not secrets in any
|
|
/// meaningful sense, but they are also why the NAT attachment (rather than
|
|
/// bridged networking) is not optional.
|
|
public struct GuestSection: Codable, Sendable, Equatable {
|
|
/// The admin account created by Setup Assistant automation.
|
|
public var username: String
|
|
/// That account's password, also used for SSH password auth.
|
|
public var password: String
|
|
/// Virtual CPUs per guest.
|
|
public var cpuCount: Int
|
|
/// RAM per guest, in gibibytes.
|
|
public var memoryGB: Int
|
|
/// Backing disk size per guest, in gibibytes. Sparse (ASIF) where
|
|
/// available, so this is a ceiling rather than an allocation.
|
|
public var diskGB: Int
|
|
|
|
public init(
|
|
username: String = "admin",
|
|
password: String = "admin",
|
|
cpuCount: Int = 4,
|
|
memoryGB: Int = 8,
|
|
diskGB: Int = 64
|
|
) {
|
|
self.username = username
|
|
self.password = password
|
|
self.cpuCount = cpuCount
|
|
self.memoryGB = memoryGB
|
|
self.diskGB = diskGB
|
|
}
|
|
}
|
|
|
|
/// Where images, clones, IPSWs, and host state live on disk.
|
|
public struct StorageSection: Codable, Sendable, Equatable {
|
|
/// Root of the store. Tilde-expanded.
|
|
///
|
|
/// - Important: Clones are made with APFS copy-on-write, which requires
|
|
/// source and destination on the *same volume*. Keep base images and
|
|
/// ephemeral clones under one root.
|
|
public var storeDir: String
|
|
|
|
/// Refuse to clone a new VM when the store volume has less than this
|
|
/// much free space. CoW clones start near-free but grow as the guest
|
|
/// writes, so a floor well above one clone's nominal size is prudent.
|
|
public var minFreeDiskGB: Int
|
|
|
|
public init(
|
|
storeDir: String = "~/Library/Application Support/gitea-macos-runner",
|
|
minFreeDiskGB: Int = 20
|
|
) {
|
|
self.storeDir = storeDir
|
|
self.minFreeDiskGB = minFreeDiskGB
|
|
}
|
|
}
|
|
|
|
// MARK: - Stored properties
|
|
|
|
public var gitea: GiteaSection
|
|
public var runner: RunnerSection
|
|
public var scheduler: SchedulerSection
|
|
public var guest: GuestSection
|
|
public var storage: StorageSection
|
|
|
|
public init(
|
|
gitea: GiteaSection,
|
|
runner: RunnerSection = .init(),
|
|
scheduler: SchedulerSection = .init(),
|
|
guest: GuestSection = .init(),
|
|
storage: StorageSection = .init()
|
|
) {
|
|
self.gitea = gitea
|
|
self.runner = runner
|
|
self.scheduler = scheduler
|
|
self.guest = guest
|
|
self.storage = storage
|
|
}
|
|
|
|
// MARK: - Defaults
|
|
|
|
/// A configuration with every default applied and a placeholder instance URL.
|
|
/// Used by `config init` to seed a new file, and by tests.
|
|
public static var `default`: RunnerConfig {
|
|
RunnerConfig(gitea: GiteaSection(instanceURL: URL(string: "https://gitea.example.com")!))
|
|
}
|
|
|
|
/// The conventional config path, `~/.config/gitea-macos-runner/config.json`,
|
|
/// tilde-expanded.
|
|
public static var defaultPath: String {
|
|
expandTilde("~/.config/gitea-macos-runner/config.json")
|
|
}
|
|
|
|
// MARK: - Loading & validation
|
|
|
|
/// Loads and validates a configuration from a JSON file.
|
|
///
|
|
/// - Parameter path: Filesystem path; tilde-expanded. Defaults to
|
|
/// ``defaultPath``.
|
|
/// - Returns: A validated configuration.
|
|
/// - Throws: ``CoreError/configInvalid(_:)`` if the file is missing,
|
|
/// unparseable, or fails ``validated()``.
|
|
public static func load(from path: String = RunnerConfig.defaultPath) throws -> RunnerConfig {
|
|
let expanded = expandTilde(path)
|
|
|
|
guard FileManager.default.fileExists(atPath: expanded) else {
|
|
throw CoreError.configInvalid("no configuration file at \(expanded)")
|
|
}
|
|
|
|
let data: Data
|
|
do {
|
|
data = try Data(contentsOf: URL(fileURLWithPath: expanded))
|
|
} catch {
|
|
throw CoreError.configInvalid("cannot read \(expanded): \(error.localizedDescription)")
|
|
}
|
|
|
|
let decoded: RunnerConfig
|
|
do {
|
|
decoded = try JSONDecoder().decode(RunnerConfig.self, from: data)
|
|
} catch let error as DecodingError {
|
|
throw CoreError.configInvalid("\(expanded): \(RunnerConfig.describe(error))")
|
|
} catch {
|
|
throw CoreError.configInvalid("\(expanded): \(error.localizedDescription)")
|
|
}
|
|
|
|
return try decoded.validated()
|
|
}
|
|
|
|
/// Renders a `DecodingError` as something an operator can act on, since the
|
|
/// default description is a multi-line dump of the underlying context.
|
|
private static func describe(_ error: DecodingError) -> String {
|
|
func keyPath(_ context: DecodingError.Context) -> String {
|
|
let path = context.codingPath.map(\.stringValue).joined(separator: ".")
|
|
return path.isEmpty ? "<root>" : path
|
|
}
|
|
switch error {
|
|
case .keyNotFound(let key, let context):
|
|
let parent = keyPath(context)
|
|
return "missing required key `\(key.stringValue)`"
|
|
+ (parent == "<root>" ? "" : " under `\(parent)`")
|
|
case .typeMismatch(let type, let context):
|
|
return "key `\(keyPath(context))` has the wrong type (expected \(type))"
|
|
case .valueNotFound(let type, let context):
|
|
return "key `\(keyPath(context))` is null (expected \(type))"
|
|
case .dataCorrupted(let context):
|
|
let path = keyPath(context)
|
|
return path == "<root>"
|
|
? "not valid JSON (\(context.debugDescription))"
|
|
: "key `\(path)` is malformed (\(context.debugDescription))"
|
|
@unknown default:
|
|
return "\(error)"
|
|
}
|
|
}
|
|
|
|
/// Writes this configuration as pretty-printed JSON, creating parent
|
|
/// directories as needed.
|
|
///
|
|
/// - Parameter path: Destination; tilde-expanded.
|
|
public func save(to path: String) throws {
|
|
let expanded = RunnerConfig.expandTilde(path)
|
|
let url = URL(fileURLWithPath: expanded)
|
|
|
|
let encoder = JSONEncoder()
|
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
|
|
|
|
do {
|
|
try FileManager.default.createDirectory(
|
|
at: url.deletingLastPathComponent(),
|
|
withIntermediateDirectories: true)
|
|
var data = try encoder.encode(self)
|
|
data.append(0x0A) // trailing newline, so the file is diff-friendly
|
|
try data.write(to: url, options: .atomic)
|
|
} catch {
|
|
throw CoreError.configInvalid("cannot write \(expanded): \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
/// Writes the annotated example configuration shipped in `Resources/`, or —
|
|
/// when that resource is not reachable — this configuration serialized by
|
|
/// ``save(to:)``.
|
|
///
|
|
/// `config init` uses this so a fresh install lands an operator on the
|
|
/// commented example rather than a bare JSON dump.
|
|
///
|
|
/// - Parameters:
|
|
/// - path: Destination; tilde-expanded.
|
|
/// - exampleContents: The example document, if the caller could load it.
|
|
/// - overwrite: When `false` (the default) an existing file is left alone.
|
|
/// - Returns: `true` if a file was written, `false` if one already existed.
|
|
@discardableResult
|
|
public func writeExample(
|
|
to path: String,
|
|
exampleContents: String? = nil,
|
|
overwrite: Bool = false
|
|
) throws -> Bool {
|
|
let expanded = RunnerConfig.expandTilde(path)
|
|
if !overwrite, FileManager.default.fileExists(atPath: expanded) {
|
|
return false
|
|
}
|
|
guard let example = exampleContents else {
|
|
try save(to: expanded)
|
|
return true
|
|
}
|
|
let url = URL(fileURLWithPath: expanded)
|
|
do {
|
|
try FileManager.default.createDirectory(
|
|
at: url.deletingLastPathComponent(),
|
|
withIntermediateDirectories: true)
|
|
try Data(example.utf8).write(to: url, options: .atomic)
|
|
} catch {
|
|
throw CoreError.configInvalid("cannot write \(expanded): \(error.localizedDescription)")
|
|
}
|
|
return true
|
|
}
|
|
|
|
/// Returns a normalized copy, or throws describing what is wrong.
|
|
///
|
|
/// Normalization clamps ``SchedulerSection/maxConcurrentVMs`` into
|
|
/// `1...2` and expands tildes in path-bearing fields. Validation rejects a
|
|
/// non-http(s) instance URL, an empty label list, an empty name prefix,
|
|
/// non-positive intervals or timeouts, a guest with fewer than 1 CPU or less
|
|
/// than 1 GB of RAM, and a configuration with no way to obtain either token.
|
|
///
|
|
/// - Returns: The normalized configuration.
|
|
/// - Throws: ``CoreError/configInvalid(_:)``.
|
|
public func validated() throws -> RunnerConfig {
|
|
var c = self
|
|
|
|
// --- gitea.instanceURL ------------------------------------------------
|
|
let scheme = c.gitea.instanceURL.scheme?.lowercased()
|
|
guard scheme == "http" || scheme == "https" else {
|
|
throw CoreError.configInvalid(
|
|
"gitea.instanceURL must be an http:// or https:// URL, got \"\(c.gitea.instanceURL.absoluteString)\"")
|
|
}
|
|
guard let host = c.gitea.instanceURL.host, !host.isEmpty else {
|
|
throw CoreError.configInvalid(
|
|
"gitea.instanceURL has no host: \"\(c.gitea.instanceURL.absoluteString)\"")
|
|
}
|
|
|
|
// --- admin token: exactly one source ----------------------------------
|
|
//
|
|
// Both-set is rejected rather than silently preferring one, because a
|
|
// stale inline token sitting next to a live token file is precisely the
|
|
// kind of ambiguity that produces a baffling 401 at 3am.
|
|
let inlineAdmin = RunnerConfig.nonEmpty(c.gitea.adminToken)
|
|
let fileAdmin = RunnerConfig.nonEmpty(c.gitea.adminTokenFile)
|
|
switch (inlineAdmin, fileAdmin) {
|
|
case (nil, nil):
|
|
throw CoreError.configInvalid(
|
|
"no admin API token configured: set exactly one of gitea.adminToken or gitea.adminTokenFile")
|
|
case (.some, .some):
|
|
throw CoreError.configInvalid(
|
|
"gitea.adminToken and gitea.adminTokenFile are both set: use exactly one")
|
|
default:
|
|
break
|
|
}
|
|
c.gitea.adminToken = inlineAdmin
|
|
c.gitea.adminTokenFile = fileAdmin.map(RunnerConfig.expandTilde)
|
|
|
|
// --- registration token: at least one source --------------------------
|
|
//
|
|
// Unlike the admin token, a file and an inline value are not mutually
|
|
// exclusive here (the file wins); what is rejected is having no source
|
|
// at all with the API fallback switched off.
|
|
let inlineReg = RunnerConfig.nonEmpty(c.gitea.registrationToken)
|
|
let fileReg = RunnerConfig.nonEmpty(c.gitea.registrationTokenFile)
|
|
if inlineReg == nil, fileReg == nil, !c.gitea.fetchRegistrationTokenViaAPI {
|
|
throw CoreError.configInvalid(
|
|
"no runner registration token configured: set gitea.registrationTokenFile "
|
|
+ "(or gitea.registrationToken), or set gitea.fetchRegistrationTokenViaAPI to true")
|
|
}
|
|
c.gitea.registrationToken = inlineReg
|
|
c.gitea.registrationTokenFile = fileReg.map(RunnerConfig.expandTilde)
|
|
|
|
// --- runner -----------------------------------------------------------
|
|
let labels = c.runner.labels.map { $0.trimmingCharacters(in: .whitespaces) }
|
|
guard !labels.isEmpty else {
|
|
throw CoreError.configInvalid("runner.labels must not be empty")
|
|
}
|
|
if labels.contains(where: \.isEmpty) {
|
|
throw CoreError.configInvalid("runner.labels contains an empty label name")
|
|
}
|
|
// Bare names only: the `:schema` suffix belongs on the `register
|
|
// --labels` argument, never in stored config, and Gitea reports bare
|
|
// names on jobs — so a configured "macos-arm64:host" would never match.
|
|
if let schemed = labels.first(where: { $0.contains(":") }) {
|
|
throw CoreError.configInvalid(
|
|
"runner.labels must contain bare names only, but \"\(schemed)\" carries a ':schema' suffix; "
|
|
+ "the schema is appended automatically at registration time")
|
|
}
|
|
c.runner.labels = labels
|
|
|
|
let prefix = c.runner.namePrefix.trimmingCharacters(in: .whitespaces)
|
|
guard !prefix.isEmpty else {
|
|
throw CoreError.configInvalid("runner.namePrefix must not be empty")
|
|
}
|
|
c.runner.namePrefix = prefix
|
|
|
|
guard !c.runner.version.trimmingCharacters(in: .whitespaces).isEmpty else {
|
|
throw CoreError.configInvalid("runner.version must not be empty")
|
|
}
|
|
c.runner.version = c.runner.version.trimmingCharacters(in: .whitespaces)
|
|
_ = try c.runner.resolvedDownloadURL
|
|
|
|
// --- scheduler --------------------------------------------------------
|
|
//
|
|
// Clamped rather than rejected: Apple's kernel caps concurrent macOS
|
|
// guests at two, and that is not a limit a config file gets to negotiate.
|
|
c.scheduler.maxConcurrentVMs = min(
|
|
max(c.scheduler.maxConcurrentVMs, 1),
|
|
SchedulerSection.hardMaxConcurrentVMs)
|
|
|
|
guard c.scheduler.pollIntervalSeconds > 0 else {
|
|
throw CoreError.configInvalid("scheduler.pollIntervalSeconds must be greater than 0")
|
|
}
|
|
guard c.scheduler.reconcileIntervalSeconds > 0 else {
|
|
throw CoreError.configInvalid("scheduler.reconcileIntervalSeconds must be greater than 0")
|
|
}
|
|
guard c.scheduler.jobTimeoutMinutes > 0 else {
|
|
throw CoreError.configInvalid("scheduler.jobTimeoutMinutes must be greater than 0")
|
|
}
|
|
guard c.scheduler.bootTimeoutSeconds > 0 else {
|
|
throw CoreError.configInvalid("scheduler.bootTimeoutSeconds must be greater than 0")
|
|
}
|
|
|
|
// --- guest ------------------------------------------------------------
|
|
guard !c.guest.username.trimmingCharacters(in: .whitespaces).isEmpty else {
|
|
throw CoreError.configInvalid("guest.username must not be empty")
|
|
}
|
|
// SSH password auth is the only channel into the guest, and an empty
|
|
// password would leave the boot hanging at authentication with no
|
|
// diagnostic worth reading.
|
|
guard !c.guest.password.isEmpty else {
|
|
throw CoreError.configInvalid("guest.password must not be empty")
|
|
}
|
|
guard c.guest.cpuCount >= 1 else {
|
|
throw CoreError.configInvalid("guest.cpuCount must be at least 1")
|
|
}
|
|
guard c.guest.memoryGB >= 1 else {
|
|
throw CoreError.configInvalid("guest.memoryGB must be at least 1")
|
|
}
|
|
guard c.guest.diskGB >= 1 else {
|
|
throw CoreError.configInvalid("guest.diskGB must be at least 1")
|
|
}
|
|
|
|
// --- storage ----------------------------------------------------------
|
|
let storeDir = c.storage.storeDir.trimmingCharacters(in: .whitespaces)
|
|
guard !storeDir.isEmpty else {
|
|
throw CoreError.configInvalid("storage.storeDir must not be empty")
|
|
}
|
|
c.storage.storeDir = RunnerConfig.expandTilde(storeDir)
|
|
guard c.storage.minFreeDiskGB >= 0 else {
|
|
throw CoreError.configInvalid("storage.minFreeDiskGB must not be negative")
|
|
}
|
|
|
|
return c
|
|
}
|
|
|
|
/// Trims a string and maps `""` to `nil`, so an empty JSON value reads as
|
|
/// "not configured" rather than as a zero-length token.
|
|
private static func nonEmpty(_ value: String?) -> String? {
|
|
guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!trimmed.isEmpty
|
|
else { return nil }
|
|
return trimmed
|
|
}
|
|
|
|
/// The admin API token, resolved from ``GiteaSection/adminTokenFile`` (read
|
|
/// and trimmed) or ``GiteaSection/adminToken``.
|
|
///
|
|
/// On a configuration that has been through ``validated()`` exactly one of
|
|
/// those is set, so the file-first order here never actually chooses between
|
|
/// two live values.
|
|
///
|
|
/// - Returns: The token, or `nil` when neither source is configured.
|
|
public func resolveAdminToken() throws -> String? {
|
|
if let path = RunnerConfig.nonEmpty(gitea.adminTokenFile) {
|
|
return try RunnerConfig.readTokenFile(path, describedAs: "gitea.adminTokenFile")
|
|
}
|
|
return RunnerConfig.nonEmpty(gitea.adminToken)
|
|
}
|
|
|
|
/// The registration token from static configuration only — file first, then
|
|
/// inline value. Returns `nil` when the caller must fall back to the API
|
|
/// (see ``GiteaSection/fetchRegistrationTokenViaAPI``).
|
|
public func resolveStaticRegistrationToken() throws -> String? {
|
|
if let path = RunnerConfig.nonEmpty(gitea.registrationTokenFile) {
|
|
return try RunnerConfig.readTokenFile(path, describedAs: "gitea.registrationTokenFile")
|
|
}
|
|
return RunnerConfig.nonEmpty(gitea.registrationToken)
|
|
}
|
|
|
|
/// Reads a secret from a file: tilde-expanded, trimmed of surrounding
|
|
/// whitespace and newlines (an `echo`-written token file always has one).
|
|
///
|
|
/// - Throws: ``CoreError/configInvalid(_:)`` when the file is missing,
|
|
/// unreadable, not UTF-8, or empty once trimmed.
|
|
private static func readTokenFile(_ path: String, describedAs key: String) throws -> String {
|
|
let expanded = expandTilde(path)
|
|
guard FileManager.default.fileExists(atPath: expanded) else {
|
|
throw CoreError.configInvalid("\(key): no such file: \(expanded)")
|
|
}
|
|
let data: Data
|
|
do {
|
|
data = try Data(contentsOf: URL(fileURLWithPath: expanded))
|
|
} catch {
|
|
throw CoreError.configInvalid("\(key): cannot read \(expanded): \(error.localizedDescription)")
|
|
}
|
|
guard let text = String(data: data, encoding: .utf8) else {
|
|
throw CoreError.configInvalid("\(key): \(expanded) is not valid UTF-8")
|
|
}
|
|
let token = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !token.isEmpty else {
|
|
throw CoreError.configInvalid("\(key): \(expanded) is empty")
|
|
}
|
|
return token
|
|
}
|
|
|
|
/// Whether a token file is readable by users other than its owner.
|
|
///
|
|
/// Permissions are deliberately **not** enforced — refusing to start because
|
|
/// a file is `0644` would be a poor trade on a single-user CI Mac — but
|
|
/// `doctor` surfaces this as a warning.
|
|
///
|
|
/// - Parameter path: Path to check; tilde-expanded.
|
|
/// - Returns: `true` when group or other bits are set, `false` when the file
|
|
/// is owner-only, and `nil` when the mode cannot be read.
|
|
public static func tokenFileIsGroupOrWorldReadable(_ path: String) -> Bool? {
|
|
let expanded = expandTilde(path)
|
|
guard
|
|
let attrs = try? FileManager.default.attributesOfItem(atPath: expanded),
|
|
let mode = attrs[.posixPermissions] as? NSNumber
|
|
else { return nil }
|
|
return (mode.int16Value & 0o077) != 0
|
|
}
|
|
|
|
/// Paths of configured token files whose permissions are looser than `0600`.
|
|
/// Empty when everything is owner-only or nothing is file-backed.
|
|
public var insecureTokenFilePaths: [String] {
|
|
[gitea.adminTokenFile, gitea.registrationTokenFile]
|
|
.compactMap { RunnerConfig.nonEmpty($0) }
|
|
.filter { RunnerConfig.tokenFileIsGroupOrWorldReadable($0) == true }
|
|
}
|
|
|
|
/// ``StorageSection/storeDir`` with `~` expanded, as a `URL`.
|
|
public var storeDirectoryURL: URL {
|
|
URL(fileURLWithPath: RunnerConfig.expandTilde(storage.storeDir), isDirectory: true)
|
|
}
|
|
|
|
/// The label set used for job matching.
|
|
public var labelSet: LabelSet {
|
|
LabelSet(runner.labels)
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
/// Expands a leading `~` or `~/` to the current user's home directory.
|
|
///
|
|
/// `NSString.expandingTildeInPath` is used rather than `FileManager`'s
|
|
/// deprecated home lookup so the behaviour matches the shell.
|
|
///
|
|
/// - Parameter path: A possibly tilde-prefixed path.
|
|
/// - Returns: An absolute path.
|
|
public static func expandTilde(_ path: String) -> String {
|
|
(path as NSString).expandingTildeInPath
|
|
}
|
|
|
|
// MARK: - Codable
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case gitea, runner, scheduler, guest, storage
|
|
}
|
|
|
|
/// Decodes a configuration, substituting section defaults for absent keys.
|
|
public init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
self.gitea = try c.decode(GiteaSection.self, forKey: .gitea)
|
|
self.runner = try c.decodeIfPresent(RunnerSection.self, forKey: .runner) ?? .init()
|
|
self.scheduler = try c.decodeIfPresent(SchedulerSection.self, forKey: .scheduler) ?? .init()
|
|
self.guest = try c.decodeIfPresent(GuestSection.self, forKey: .guest) ?? .init()
|
|
self.storage = try c.decodeIfPresent(StorageSection.self, forKey: .storage) ?? .init()
|
|
}
|
|
}
|
|
|
|
// MARK: - Tolerant section decoding
|
|
|
|
extension RunnerConfig.GiteaSection {
|
|
private enum CodingKeys: String, CodingKey {
|
|
case instanceURL, adminToken, adminTokenFile
|
|
case registrationToken, registrationTokenFile, fetchRegistrationTokenViaAPI
|
|
}
|
|
|
|
public init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
self.instanceURL = try c.decode(URL.self, forKey: .instanceURL)
|
|
self.adminToken = try c.decodeIfPresent(String.self, forKey: .adminToken)
|
|
self.adminTokenFile = try c.decodeIfPresent(String.self, forKey: .adminTokenFile)
|
|
self.registrationToken = try c.decodeIfPresent(String.self, forKey: .registrationToken)
|
|
self.registrationTokenFile = try c.decodeIfPresent(String.self, forKey: .registrationTokenFile)
|
|
self.fetchRegistrationTokenViaAPI =
|
|
try c.decodeIfPresent(Bool.self, forKey: .fetchRegistrationTokenViaAPI) ?? false
|
|
}
|
|
}
|
|
|
|
extension RunnerConfig.RunnerSection {
|
|
private enum CodingKeys: String, CodingKey {
|
|
case labels, namePrefix, runnerDownloadURL, version
|
|
}
|
|
|
|
public init(from decoder: Decoder) throws {
|
|
let d = RunnerConfig.RunnerSection()
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
self.labels = try c.decodeIfPresent([String].self, forKey: .labels) ?? d.labels
|
|
self.namePrefix = try c.decodeIfPresent(String.self, forKey: .namePrefix) ?? d.namePrefix
|
|
self.runnerDownloadURL =
|
|
try c.decodeIfPresent(String.self, forKey: .runnerDownloadURL) ?? d.runnerDownloadURL
|
|
self.version = try c.decodeIfPresent(String.self, forKey: .version) ?? d.version
|
|
}
|
|
}
|
|
|
|
extension RunnerConfig.SchedulerSection {
|
|
private enum CodingKeys: String, CodingKey {
|
|
case maxConcurrentVMs, pollIntervalSeconds, reconcileIntervalSeconds
|
|
case jobTimeoutMinutes, bootTimeoutSeconds
|
|
}
|
|
|
|
public init(from decoder: Decoder) throws {
|
|
let d = RunnerConfig.SchedulerSection()
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
self.maxConcurrentVMs =
|
|
try c.decodeIfPresent(Int.self, forKey: .maxConcurrentVMs) ?? d.maxConcurrentVMs
|
|
self.pollIntervalSeconds =
|
|
try c.decodeIfPresent(Int.self, forKey: .pollIntervalSeconds) ?? d.pollIntervalSeconds
|
|
self.reconcileIntervalSeconds =
|
|
try c.decodeIfPresent(Int.self, forKey: .reconcileIntervalSeconds) ?? d.reconcileIntervalSeconds
|
|
self.jobTimeoutMinutes =
|
|
try c.decodeIfPresent(Int.self, forKey: .jobTimeoutMinutes) ?? d.jobTimeoutMinutes
|
|
self.bootTimeoutSeconds =
|
|
try c.decodeIfPresent(Int.self, forKey: .bootTimeoutSeconds) ?? d.bootTimeoutSeconds
|
|
}
|
|
}
|
|
|
|
extension RunnerConfig.GuestSection {
|
|
private enum CodingKeys: String, CodingKey {
|
|
case username, password, cpuCount, memoryGB, diskGB
|
|
}
|
|
|
|
public init(from decoder: Decoder) throws {
|
|
let d = RunnerConfig.GuestSection()
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
self.username = try c.decodeIfPresent(String.self, forKey: .username) ?? d.username
|
|
self.password = try c.decodeIfPresent(String.self, forKey: .password) ?? d.password
|
|
self.cpuCount = try c.decodeIfPresent(Int.self, forKey: .cpuCount) ?? d.cpuCount
|
|
self.memoryGB = try c.decodeIfPresent(Int.self, forKey: .memoryGB) ?? d.memoryGB
|
|
self.diskGB = try c.decodeIfPresent(Int.self, forKey: .diskGB) ?? d.diskGB
|
|
}
|
|
}
|
|
|
|
extension RunnerConfig.StorageSection {
|
|
private enum CodingKeys: String, CodingKey {
|
|
case storeDir, minFreeDiskGB
|
|
}
|
|
|
|
public init(from decoder: Decoder) throws {
|
|
let d = RunnerConfig.StorageSection()
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
self.storeDir = try c.decodeIfPresent(String.self, forKey: .storeDir) ?? d.storeDir
|
|
self.minFreeDiskGB =
|
|
try c.decodeIfPresent(Int.self, forKey: .minFreeDiskGB) ?? d.minFreeDiskGB
|
|
}
|
|
}
|