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. /// /// - Important: This must exceed the guest's *worst-case* time to become /// SSH-ready, not its typical one. A clone that is still booting when /// this expires is destroyed and replaced by another clone that starts /// from zero — and because the replacement adds load to an already /// contended host, the next boot is slower still. Set too tight, this /// is not a timeout but a livelock: the daemon boots forever and no /// runner ever registers. /// /// A guest sharing an Apple Silicon host with other Virtualization /// guests can take several minutes to reach `sshd`, so the default is /// deliberately generous. A genuinely wedged guest still gets caught; /// it just takes longer to notice, which is the cheaper mistake. public var bootTimeoutSeconds: Int public init( maxConcurrentVMs: Int = 2, pollIntervalSeconds: Int = 5, reconcileIntervalSeconds: Int = 300, jobTimeoutMinutes: Int = 120, bootTimeoutSeconds: Int = 900 ) { 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 ? "" : path } switch error { case .keyNotFound(let key, let context): let parent = keyPath(context) return "missing required key `\(key.stringValue)`" + (parent == "" ? "" : " 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 == "" ? "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 } }