import Foundation /// The set of labels this host's runners advertise, used to decide whether a /// queued Gitea job is ours to pick up. /// /// ## Bare names only /// /// Gitea's label syntax at *registration* time is `name:schema` (for example /// `macos-arm64:host`), where the schema defaults to `host` when omitted. The /// schema is a runner-side execution hint — it tells `gitea-runner` to run the /// job directly on the machine instead of inside a container. **The server only /// ever stores and reports the bare name.** A workflow's `runs-on:` value, and /// therefore the `labels` array on a queued job, likewise contains bare names. /// /// So: pass `macos-arm64:host` to `gitea-runner register --labels`, but match /// against `macos-arm64` here. Matching is case-sensitive, because Gitea's own /// comparison is. /// /// - Warning: If the guest's `.runner`/`config.yaml` sets `runner.labels`, it /// silently overrides whatever `--labels` was passed at registration. The /// guest must therefore never ship a config file containing labels. public struct LabelSet: Sendable, Equatable, Hashable { /// The bare label names this host serves, e.g. `["macos-arm64", "macos"]`. public let names: Set /// Creates a label set from bare names. /// /// Any `:schema` suffix present in `names` is stripped, so it is safe to /// hand this the same array that is written into the config file. /// /// - Parameter names: Label names, with or without a `:schema` suffix. public init(_ names: [String]) { self.names = Set( names .map(LabelSet.bareName) .filter { !$0.isEmpty } ) } /// Whether a queued job's `labels` array can be satisfied by this host. /// /// Returns `true` if and only if `jobLabels` is non-empty *and* every entry /// is a member of ``names``. An empty job label array is treated as "no /// declared requirement" and is deliberately **not** matched — a job that /// asks for nothing must not be scheduled onto a scarce macOS VM. /// /// The job side is put through ``bareName(_:)`` too. The server normally /// stores bare names, so this changes nothing in the common case — but a /// workflow that writes `runs-on: [macos-arm64:host]` would otherwise never /// match anything and its job would be skipped with no log line at all. /// /// - Parameter jobLabels: The `labels` array from a `WorkflowJob`. /// - Returns: `true` when this host should boot a VM for the job. public func matches(jobLabels: [String]) -> Bool { guard !jobLabels.isEmpty else { return false } let wanted = Set(jobLabels.map(LabelSet.bareName).filter { !$0.isEmpty }) guard !wanted.isEmpty else { return false } return wanted.isSubset(of: names) } /// The value to pass to `gitea-runner register --labels`, i.e. each bare /// name suffixed with the given schema and joined by commas. /// /// - Parameter schema: The execution schema; `host` for a bare-metal guest. /// - Returns: For example `"macos-arm64:host,macos:host"`. public func registrationArgument(schema: String = "host") -> String { // Sorted so the argument is stable across process runs — a `Set` has no // inherent order, and an unstable registration argument would make the // guest command line (and its logs) needlessly non-reproducible. names.sorted() .map { schema.isEmpty ? $0 : "\($0):\(schema)" } .joined(separator: ",") } /// Strips an optional `:schema` suffix from a single label token. /// /// - Parameter label: A label such as `macos-arm64:host` or `macos-arm64`. /// - Returns: The bare name. public static func bareName(_ label: String) -> String { let trimmed = label.trimmingCharacters(in: .whitespaces) guard let colon = trimmed.firstIndex(of: ":") else { return trimmed } return String(trimmed[trimmed.startIndex..