Files
gitea-macos-vm-orchestrator/Sources/RunnerCore/Labels.swift
T

85 lines
4.0 KiB
Swift

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<String>
/// 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..<colon])
}
}