Nucleic: Gitea Runner macOS VM Support

This commit is contained in:
2026-08-07 00:44:36 -07:00
parent 749f0be4fb
commit 33f299396a
47 changed files with 13159 additions and 0 deletions
+733
View File
@@ -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
}
}