Nucleic: Gitea Runner macOS VM Support
This commit is contained in:
@@ -0,0 +1,733 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user