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
+609
View File
@@ -0,0 +1,609 @@
import Foundation
import Testing
@testable import RunnerCore
/// Tests for ``RunnerConfig`` decoding, normalization, validation, and token
/// resolution.
@Suite("RunnerConfig")
struct ConfigTests {
// MARK: - Fixtures
/// A configuration that passes ``RunnerConfig/validated()`` unmodified, so
/// each test can break exactly one thing.
private func validConfig() -> RunnerConfig {
RunnerConfig(
gitea: .init(
instanceURL: URL(string: "https://gitea.example.com")!,
adminToken: "abc123",
registrationToken: "REG123"))
}
/// Writes `contents` to a unique file under a fresh temporary directory and
/// returns its path. The directory is left for the OS to reap; these are a
/// handful of bytes per test.
private func temporaryFile(named name: String = "token", contents: String) throws -> String {
let dir = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("gmr-tests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let file = dir.appendingPathComponent(name)
try Data(contents.utf8).write(to: file)
return file.path
}
/// Runs `body`, requiring it to throw ``CoreError/configInvalid(_:)``, and
/// returns the detail message so a test can assert *which* rule fired.
@discardableResult
private func configInvalidDetail(
_ body: () throws -> Void,
sourceLocation: SourceLocation = #_sourceLocation
) -> String {
do {
try body()
Issue.record("expected CoreError.configInvalid", sourceLocation: sourceLocation)
return ""
} catch let CoreError.configInvalid(detail) {
return detail
} catch {
Issue.record("expected CoreError.configInvalid, got \(error)", sourceLocation: sourceLocation)
return ""
}
}
// MARK: - Defaults
@Test("the default configuration carries every documented default")
func defaultsArePresent() {
let c = RunnerConfig.default
#expect(c.runner.labels == ["macos-arm64"])
#expect(c.runner.namePrefix == "macos-vm-")
#expect(c.runner.version == "3.0.2")
#expect(c.scheduler.maxConcurrentVMs == 2)
#expect(c.scheduler.pollIntervalSeconds == 5)
#expect(c.scheduler.reconcileIntervalSeconds == 300)
#expect(c.scheduler.jobTimeoutMinutes == 120)
#expect(c.scheduler.bootTimeoutSeconds == 300)
#expect(c.guest.username == "admin")
#expect(c.guest.cpuCount == 4)
#expect(c.guest.memoryGB == 8)
#expect(c.guest.diskGB == 64)
#expect(c.storage.minFreeDiskGB == 20)
// No token of either kind: `config init` writes a template an operator
// must still fill in, and `validated()` says so rather than starting.
#expect(c.gitea.adminToken == nil)
#expect(c.gitea.adminTokenFile == nil)
}
@Test("the default download URL substitutes the configured version")
func downloadURLSubstitutesVersion() throws {
var section = RunnerConfig.RunnerSection()
section.version = "3.1.0"
let url = try section.resolvedDownloadURL
#expect(url.absoluteString.contains("v3.1.0/"))
#expect(url.absoluteString.hasSuffix("gitea-runner-3.1.0-darwin-arm64"))
#expect(!url.absoluteString.contains("{version}"))
}
@Test("a download URL template with no scheme is rejected")
func downloadURLWithoutSchemeIsRejected() {
var section = RunnerConfig.RunnerSection()
section.runnerDownloadURL = "gitea.com/runner-{version}"
configInvalidDetail { _ = try section.resolvedDownloadURL }
}
@Test("the default config path lives under the user's home directory")
func defaultPathIsExpanded() {
#expect(!RunnerConfig.defaultPath.hasPrefix("~"))
#expect(RunnerConfig.defaultPath.hasSuffix("/.config/gitea-macos-runner/config.json"))
}
// MARK: - Decoding
@Test("a minimal config decodes with every other section defaulted")
func minimalConfigDecodes() throws {
let json = """
{"gitea": {"instanceURL": "https://gitea.example.com",
"adminToken": "abc", "registrationToken": "reg"}}
"""
let c = try JSONDecoder().decode(RunnerConfig.self, from: Data(json.utf8))
#expect(c.gitea.instanceURL.absoluteString == "https://gitea.example.com")
#expect(c.runner.labels == ["macos-arm64"])
#expect(c.scheduler.pollIntervalSeconds == 5)
#expect(c.guest.username == "admin")
#expect(c.gitea.fetchRegistrationTokenViaAPI == false)
}
@Test("a partial section keeps defaults for the keys it omits")
func partialSectionKeepsDefaults() throws {
let json = """
{"gitea": {"instanceURL": "https://g.example.com", "adminToken": "a", "registrationToken": "r"},
"guest": {"cpuCount": 8}}
"""
let c = try JSONDecoder().decode(RunnerConfig.self, from: Data(json.utf8))
#expect(c.guest.cpuCount == 8)
#expect(c.guest.memoryGB == 8) // untouched default
#expect(c.guest.username == "admin")
}
@Test("a config with no gitea section is rejected by name")
func missingGiteaSectionIsReported() throws {
let path = try temporaryFile(named: "config.json", contents: #"{"guest": {"cpuCount": 2}}"#)
let detail = configInvalidDetail { _ = try RunnerConfig.load(from: path) }
#expect(detail.contains("gitea"))
}
@Test("loading a file that is not JSON reports it as malformed, not missing")
func malformedJSONIsReported() throws {
let path = try temporaryFile(named: "config.json", contents: "not json {")
let detail = configInvalidDetail { _ = try RunnerConfig.load(from: path) }
#expect(detail.contains(path))
#expect(!detail.contains("no configuration file"))
}
@Test("loading a missing file names the expanded path")
func missingFileIsReported() {
let detail = configInvalidDetail {
_ = try RunnerConfig.load(from: "/nonexistent/gmr/config.json")
}
#expect(detail.contains("/nonexistent/gmr/config.json"))
}
// MARK: - load / save round trip
@Test("load applies validation, so the loaded value is already normalized")
func loadNormalizes() throws {
let json = """
{"gitea": {"instanceURL": "https://g.example.com", "adminToken": "a", "registrationToken": "r"},
"scheduler": {"maxConcurrentVMs": 16},
"storage": {"storeDir": "~/gmr-store"}}
"""
let path = try temporaryFile(named: "config.json", contents: json)
let c = try RunnerConfig.load(from: path)
#expect(c.scheduler.maxConcurrentVMs == 2)
#expect(!c.storage.storeDir.hasPrefix("~"))
}
@Test("a saved config reloads equal to what was saved")
func saveRoundTrips() throws {
let dir = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("gmr-tests-\(UUID().uuidString)", isDirectory: true)
// Nested and not yet created: `save` is expected to make the parents.
let path = dir.appendingPathComponent("nested/config.json").path
let original = try validConfig().validated()
try original.save(to: path)
#expect(FileManager.default.fileExists(atPath: path))
#expect(try RunnerConfig.load(from: path) == original)
}
@Test("writeExample does not clobber an existing file unless told to")
func writeExampleRespectsExistingFile() throws {
let path = try temporaryFile(named: "config.json", contents: "ORIGINAL")
let c = try validConfig().validated()
#expect(try c.writeExample(to: path, exampleContents: "EXAMPLE") == false)
#expect(try String(contentsOfFile: path, encoding: .utf8) == "ORIGINAL")
#expect(try c.writeExample(to: path, exampleContents: "EXAMPLE", overwrite: true) == true)
#expect(try String(contentsOfFile: path, encoding: .utf8) == "EXAMPLE")
}
// MARK: - Tilde expansion
@Test("expandTilde resolves a leading tilde and leaves other paths alone")
func expandTildeBehaviour() {
let home = NSHomeDirectory()
#expect(RunnerConfig.expandTilde("~/x") == home + "/x")
#expect(RunnerConfig.expandTilde("/absolute/x") == "/absolute/x")
#expect(RunnerConfig.expandTilde("relative/x") == "relative/x")
// A tilde anywhere but the front is an ordinary character.
#expect(RunnerConfig.expandTilde("/a/~/b") == "/a/~/b")
}
@Test("validation expands tildes in every path-bearing field")
func validationExpandsPaths() throws {
var c = validConfig()
c.gitea.adminToken = nil
c.gitea.adminTokenFile = "~/admin.token"
c.gitea.registrationToken = nil
c.gitea.registrationTokenFile = "~/reg.token"
c.storage.storeDir = "~/gmr"
let v = try c.validated()
let home = NSHomeDirectory()
#expect(v.gitea.adminTokenFile == home + "/admin.token")
#expect(v.gitea.registrationTokenFile == home + "/reg.token")
#expect(v.storage.storeDir == home + "/gmr")
#expect(v.storeDirectoryURL.path == home + "/gmr")
}
// MARK: - Validation: instance URL
@Test("a valid configuration validates unchanged")
func validConfigurationSurvivesValidation() throws {
let c = try validConfig().validated()
#expect(c.gitea.instanceURL.absoluteString == "https://gitea.example.com")
#expect(c.gitea.adminToken == "abc123")
}
@Test("a plain http instance URL is allowed")
func httpInstanceURLIsAllowed() throws {
var c = validConfig()
c.gitea.instanceURL = URL(string: "http://gitea.lan:3000")!
#expect(throws: Never.self) { try c.validated() }
}
@Test("a non-http scheme is rejected")
func nonHTTPSchemeIsRejected() {
var c = validConfig()
c.gitea.instanceURL = URL(string: "ssh://gitea.example.com")!
#expect(configInvalidDetail { _ = try c.validated() }.contains("instanceURL"))
}
@Test("an instance URL with no host is rejected")
func hostlessInstanceURLIsRejected() throws {
// `#require` rather than a force-unwrap: whether an empty authority
// parses at all is a Foundation detail, and a nil here should fail this
// one test rather than trap the whole suite.
var c = validConfig()
c.gitea.instanceURL = try #require(URL(string: "https:///path"))
#expect(configInvalidDetail { _ = try c.validated() }.contains("instanceURL"))
}
// MARK: - Validation: admin token
@Test("no admin token at all names the keys to set")
func missingAdminTokenIsRejected() {
var c = validConfig()
c.gitea.adminToken = nil
let detail = configInvalidDetail { _ = try c.validated() }
#expect(detail.contains("gitea.adminToken"))
#expect(detail.contains("gitea.adminTokenFile"))
}
@Test("both admin token sources set is rejected as ambiguous")
func bothAdminTokenSourcesRejected() {
// A stale inline token beside a live token file is the shape of a
// baffling 401; better to fail at load with the reason spelled out.
var c = validConfig()
c.gitea.adminTokenFile = "/tmp/admin.token"
let detail = configInvalidDetail { _ = try c.validated() }
#expect(detail.contains("both"))
}
@Test("a whitespace-only admin token counts as absent")
func whitespaceAdminTokenIsAbsent() {
var c = validConfig()
c.gitea.adminToken = " \n "
#expect(configInvalidDetail { _ = try c.validated() }.contains("adminToken"))
}
@Test("a surrounding-whitespace admin token is trimmed rather than rejected")
func adminTokenIsTrimmed() throws {
var c = validConfig()
c.gitea.adminToken = " abc123\n"
#expect(try c.validated().gitea.adminToken == "abc123")
}
// MARK: - Validation: registration token
@Test("no registration source at all is rejected")
func missingRegistrationTokenIsRejected() {
var c = validConfig()
c.gitea.registrationToken = nil
let detail = configInvalidDetail { _ = try c.validated() }
#expect(detail.contains("registration"))
}
@Test("the API fallback alone satisfies the registration requirement")
func apiFallbackSatisfiesRegistration() throws {
var c = validConfig()
c.gitea.registrationToken = nil
c.gitea.fetchRegistrationTokenViaAPI = true
#expect(throws: Never.self) { try c.validated() }
}
@Test("a file and an inline registration token together are allowed")
func bothRegistrationSourcesAllowed() throws {
// Unlike the admin token: the file simply wins, and the redundancy is
// how operators migrate from inline to file without downtime.
var c = validConfig()
c.gitea.registrationTokenFile = "/tmp/reg.token"
#expect(throws: Never.self) { try c.validated() }
}
// MARK: - Validation: labels
@Test("an empty label list is rejected")
func emptyLabelsRejected() {
var c = validConfig()
c.runner.labels = []
#expect(configInvalidDetail { _ = try c.validated() }.contains("runner.labels"))
}
@Test("an empty label name is rejected")
func emptyLabelNameRejected() {
var c = validConfig()
c.runner.labels = ["macos-arm64", " "]
#expect(configInvalidDetail { _ = try c.validated() }.contains("empty label"))
}
@Test("a ':schema' suffix in configured labels is rejected")
func schemedLabelRejected() {
// Gitea reports bare names on jobs, so "macos-arm64:host" in config
// would silently match nothing at all — a config error, not a runtime
// mystery.
var c = validConfig()
c.runner.labels = ["macos-arm64:host"]
let detail = configInvalidDetail { _ = try c.validated() }
#expect(detail.contains("bare names"))
}
@Test("labels are trimmed by validation")
func labelsAreTrimmed() throws {
var c = validConfig()
c.runner.labels = [" macos-arm64 ", "macos"]
#expect(try c.validated().runner.labels == ["macos-arm64", "macos"])
}
@Test("the label set derived from config drives job matching")
func labelSetMatchesJobs() throws {
var c = validConfig()
c.runner.labels = ["macos-arm64", "macos"]
let set = try c.validated().labelSet
#expect(set.matches(jobLabels: ["macos-arm64"]))
#expect(!set.matches(jobLabels: ["ubuntu-latest"]))
}
@Test("an empty name prefix is rejected")
func emptyNamePrefixRejected() {
// An empty prefix would make the reconcile loop treat every runner on
// the instance as ours.
var c = validConfig()
c.runner.namePrefix = " "
#expect(configInvalidDetail { _ = try c.validated() }.contains("namePrefix"))
}
@Test("an empty runner version is rejected")
func emptyVersionRejected() {
var c = validConfig()
c.runner.version = ""
#expect(configInvalidDetail { _ = try c.validated() }.contains("version"))
}
// MARK: - Validation: scheduler
@Test("maxConcurrentVMs is clamped to the kernel's limit of 2")
func maxConcurrentVMsClampedHigh() throws {
// Apple's kernel fails the third `start()` with
// VZError.virtualMachineLimitExceeded; that is not negotiable in JSON.
for requested in [3, 8, 64, Int.max] {
var c = validConfig()
c.scheduler.maxConcurrentVMs = requested
#expect(try c.validated().scheduler.maxConcurrentVMs == 2)
}
}
@Test("maxConcurrentVMs is clamped up to 1")
func maxConcurrentVMsClampedLow() throws {
for requested in [0, -1, Int.min] {
var c = validConfig()
c.scheduler.maxConcurrentVMs = requested
#expect(try c.validated().scheduler.maxConcurrentVMs == 1)
}
}
@Test("an in-range maxConcurrentVMs is left alone")
func maxConcurrentVMsInRange() throws {
var c = validConfig()
c.scheduler.maxConcurrentVMs = 1
#expect(try c.validated().scheduler.maxConcurrentVMs == 1)
}
@Test("the hard cap is 2")
func hardCapIsTwo() {
#expect(RunnerConfig.SchedulerSection.hardMaxConcurrentVMs == 2)
}
@Test("non-positive intervals and timeouts are rejected")
func nonPositiveIntervalsRejected() {
var poll = validConfig()
poll.scheduler.pollIntervalSeconds = 0
#expect(configInvalidDetail { _ = try poll.validated() }.contains("pollIntervalSeconds"))
var reconcile = validConfig()
reconcile.scheduler.reconcileIntervalSeconds = -5
#expect(
configInvalidDetail { _ = try reconcile.validated() }.contains("reconcileIntervalSeconds"))
var job = validConfig()
job.scheduler.jobTimeoutMinutes = 0
#expect(configInvalidDetail { _ = try job.validated() }.contains("jobTimeoutMinutes"))
var boot = validConfig()
boot.scheduler.bootTimeoutSeconds = 0
#expect(configInvalidDetail { _ = try boot.validated() }.contains("bootTimeoutSeconds"))
}
// MARK: - Validation: guest
@Test("an empty guest username is rejected")
func emptyGuestUsernameRejected() {
var c = validConfig()
c.guest.username = " "
#expect(configInvalidDetail { _ = try c.validated() }.contains("guest.username"))
}
@Test("an empty guest password is rejected")
func emptyGuestPasswordRejected() {
// SSH password auth is the only channel into the guest; an empty
// password turns every boot into an unexplained authentication hang.
var c = validConfig()
c.guest.password = ""
#expect(configInvalidDetail { _ = try c.validated() }.contains("guest.password"))
}
@Test("a guest password of only whitespace is accepted verbatim")
func whitespaceGuestPasswordIsKept() throws {
// Unlike tokens, the password is not trimmed: whitespace is a legal
// part of a password, and silently trimming it would break SSH.
var c = validConfig()
c.guest.password = " "
#expect(try c.validated().guest.password == " ")
}
@Test("under-sized guests are rejected")
func undersizedGuestRejected() {
var cpu = validConfig()
cpu.guest.cpuCount = 0
#expect(configInvalidDetail { _ = try cpu.validated() }.contains("cpuCount"))
var mem = validConfig()
mem.guest.memoryGB = 0
#expect(configInvalidDetail { _ = try mem.validated() }.contains("memoryGB"))
var disk = validConfig()
disk.guest.diskGB = 0
#expect(configInvalidDetail { _ = try disk.validated() }.contains("diskGB"))
}
// MARK: - Validation: storage
@Test("an empty store directory is rejected")
func emptyStoreDirRejected() {
var c = validConfig()
c.storage.storeDir = " "
#expect(configInvalidDetail { _ = try c.validated() }.contains("storeDir"))
}
@Test("a negative free-space floor is rejected")
func negativeMinFreeDiskRejected() {
var c = validConfig()
c.storage.minFreeDiskGB = -1
#expect(configInvalidDetail { _ = try c.validated() }.contains("minFreeDiskGB"))
}
@Test("a zero free-space floor is allowed")
func zeroMinFreeDiskAllowed() throws {
var c = validConfig()
c.storage.minFreeDiskGB = 0
#expect(throws: Never.self) { try c.validated() }
}
// MARK: - Token resolution
@Test("an inline admin token resolves to itself")
func resolveInlineAdminToken() throws {
#expect(try validConfig().resolveAdminToken() == "abc123")
}
@Test("an admin token file is read and trimmed")
func resolveAdminTokenFromFile() throws {
// `echo secret > token` always leaves a trailing newline; sending that
// in an Authorization header is an instant 401.
let path = try temporaryFile(contents: " file-token-value\n")
var c = validConfig()
c.gitea.adminToken = nil
c.gitea.adminTokenFile = path
#expect(try c.resolveAdminToken() == "file-token-value")
}
@Test("the token file wins over an inline value when both are somehow present")
func tokenFileTakesPrecedence() throws {
// `validated()` rejects this combination, but resolution is also called
// on configs assembled in code, so the precedence must be defined.
let path = try temporaryFile(contents: "from-file")
var c = validConfig()
c.gitea.adminTokenFile = path
#expect(try c.resolveAdminToken() == "from-file")
}
@Test("resolving from a missing token file names the key and the path")
func missingTokenFileIsReported() {
var c = validConfig()
c.gitea.adminToken = nil
c.gitea.adminTokenFile = "/nonexistent/admin.token"
let detail = configInvalidDetail { _ = try c.resolveAdminToken() }
#expect(detail.contains("gitea.adminTokenFile"))
#expect(detail.contains("/nonexistent/admin.token"))
}
@Test("an empty token file is rejected rather than yielding an empty token")
func emptyTokenFileIsRejected() throws {
let path = try temporaryFile(contents: "\n\n \n")
var c = validConfig()
c.gitea.adminToken = nil
c.gitea.adminTokenFile = path
#expect(configInvalidDetail { _ = try c.resolveAdminToken() }.contains("empty"))
}
@Test("resolving with no admin source configured yields nil, not an error")
func resolveAdminTokenWithNoSource() throws {
var c = validConfig()
c.gitea.adminToken = nil
#expect(try c.resolveAdminToken() == nil)
}
@Test("a static registration token resolves from file, then inline")
func resolveRegistrationToken() throws {
var inline = validConfig()
#expect(try inline.resolveStaticRegistrationToken() == "REG123")
let path = try temporaryFile(named: "reg", contents: "REG-FROM-FILE\n")
inline.gitea.registrationTokenFile = path
#expect(try inline.resolveStaticRegistrationToken() == "REG-FROM-FILE")
}
@Test("no static registration token yields nil so the caller can use the API")
func resolveRegistrationTokenWithNoSource() throws {
var c = validConfig()
c.gitea.registrationToken = nil
c.gitea.fetchRegistrationTokenViaAPI = true
#expect(try c.resolveStaticRegistrationToken() == nil)
}
// MARK: - Token file permissions
@Test("a 0600 token file is not reported as insecure")
func ownerOnlyTokenFileIsSecure() throws {
let path = try temporaryFile(contents: "s")
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: path)
#expect(RunnerConfig.tokenFileIsGroupOrWorldReadable(path) == false)
var c = validConfig()
c.gitea.adminToken = nil
c.gitea.adminTokenFile = path
#expect(c.insecureTokenFilePaths.isEmpty)
}
@Test("a group- or world-readable token file is flagged but not fatal")
func looseTokenFileIsFlagged() throws {
// Deliberately a warning: refusing to start over a 0644 file on a
// single-user CI Mac would be a poor trade.
let path = try temporaryFile(contents: "s")
try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: path)
#expect(RunnerConfig.tokenFileIsGroupOrWorldReadable(path) == true)
var c = validConfig()
c.gitea.adminToken = nil
c.gitea.adminTokenFile = path
#expect(c.insecureTokenFilePaths == [path])
// Still valid: the permission check never blocks startup.
#expect(throws: Never.self) { try c.validated() }
}
@Test("an unreadable path reports an unknown mode rather than a verdict")
func unknownPermissionsAreNil() {
#expect(RunnerConfig.tokenFileIsGroupOrWorldReadable("/nonexistent/token") == nil)
}
}
+208
View File
@@ -0,0 +1,208 @@
import Foundation
import Testing
@testable import RunnerCore
/// Tests for parsing `/var/db/dhcpd_leases`, including the `1,` hardware-type
/// prefix, non-zero-padded octets, and newest-lease-wins on duplicate MACs.
@Suite("DHCP leases")
struct DHCPLeasesTests {
/// A file shaped like the real thing: a `1,` prefix on every `hw_address`,
/// octets that lack zero padding, one block that is missing its MAC, a
/// decimal `lease=` alongside the usual hex ones, a MAC that appears three
/// times with different expiries, and a truncated trailing block of the kind
/// a mid-write read produces.
static let fixture = """
{
\tname=macos-vm-a
\tip_address=192.168.64.2
\thw_address=1,aa:bb:c:d:ee:ff
\tidentifier=1,aa:bb:c:d:ee:ff
\tlease=0x66b2c0de
}
{
\tname=macos-vm-b
\tip_address=192.168.64.3
\thw_address=1,DE:AD:BE:EF:00:01
\tidentifier=1,de:ad:be:ef:00:01
\tlease=1723000000
}
{
\tname=no-hardware-address
\tip_address=192.168.64.4
\tlease=0x66b2c0de
}
{
\tname=macos-vm-a
\tip_address=192.168.64.9
\thw_address=1,aa:bb:0c:0d:ee:ff
\tlease=0x66b2ffff
}
{
\tname=macos-vm-a
\tip_address=192.168.64.5
\thw_address=1,aa:bb:0c:0d:ee:ff
\tlease=0x66b20000
}
{
\tname=truncated-mid-write
\tip_address=192.168.64.6
"""
@Test("a lease carries the fields it was constructed with")
func leaseIsConstructible() {
let lease = DHCPLease(
name: "guest",
ipAddress: "192.168.64.7",
hwAddress: "aa:bb:0c:dd:ee:ff",
leaseExpiry: nil
)
#expect(lease.ipAddress == "192.168.64.7")
}
@Test("well-formed blocks parse and malformed ones are skipped")
func parsesWellFormedBlocksOnly() {
let leases = DHCPLeaseParser.parse(Self.fixture)
// Six blocks in the fixture: one has no hw_address and one is truncated.
#expect(leases.count == 4)
#expect(leases.map(\.ipAddress) == ["192.168.64.2", "192.168.64.3", "192.168.64.9", "192.168.64.5"])
#expect(!leases.contains { $0.ipAddress == "192.168.64.4" })
#expect(!leases.contains { $0.ipAddress == "192.168.64.6" })
#expect(leases[0].name == "macos-vm-a")
}
@Test("the 1, prefix is stripped and octets are zero-padded")
func normalizesHardwareAddresses() {
let leases = DHCPLeaseParser.parse(Self.fixture)
#expect(leases[0].hwAddress == "aa:bb:0c:0d:ee:ff")
#expect(leases[1].hwAddress == "de:ad:be:ef:00:01")
}
@Test("lease expiry parses from hex and from decimal")
func parsesHexAndDecimalLeaseTimes() {
let leases = DHCPLeaseParser.parse(Self.fixture)
#expect(leases[0].leaseExpiry == Date(timeIntervalSince1970: TimeInterval(0x66b2_c0de)))
#expect(leases[1].leaseExpiry == Date(timeIntervalSince1970: 1_723_000_000))
}
@Test("a duplicate MAC resolves to the newest lease, whatever the file order")
func newestLeaseWinsForDuplicateMACs() {
let leases = DHCPLeaseParser.parse(Self.fixture)
// Three blocks share this MAC: 0x66b2c0de, 0x66b2ffff, then 0x66b20000.
// The newest expiry wins even though it is not the last block.
#expect(DHCPLeaseParser.ipAddress(forMAC: "aa:bb:0c:0d:ee:ff", in: leases) == "192.168.64.9")
}
@Test("the query MAC is normalized the same way as the file's")
func lookupNormalizesTheQuery() {
let leases = DHCPLeaseParser.parse(Self.fixture)
// Every rendering of the same address must find the same lease.
for query in ["aa:bb:c:d:ee:ff", "AA:BB:0C:0D:EE:FF", "aa-bb-0c-0d-ee-ff", "1,aa:bb:c:d:ee:ff"] {
#expect(DHCPLeaseParser.ipAddress(forMAC: query, in: leases) == "192.168.64.9")
}
#expect(DHCPLeaseParser.ipAddress(forMAC: "de:ad:be:ef:00:01", in: leases) == "192.168.64.3")
}
@Test("an unknown or unparseable MAC has no address")
func unknownMACHasNoAddress() {
let leases = DHCPLeaseParser.parse(Self.fixture)
#expect(DHCPLeaseParser.ipAddress(forMAC: "00:11:22:33:44:55", in: leases) == nil)
#expect(DHCPLeaseParser.ipAddress(forMAC: "not-a-mac", in: leases) == nil)
}
@Test("a lease is only newer when bootpd actually rewrote it")
func leaseFreshnessGate() {
// Slot MACs are persistent and leases live 24 h, so the previous guest's
// entry is normally still there when the next clone boots. Only a later
// expiry (or a different address) proves the new guest has leased.
let previous = DHCPLease(
name: nil,
ipAddress: "192.168.64.7",
hwAddress: "aa:bb:0c:dd:ee:ff",
leaseExpiry: Date(timeIntervalSince1970: 1_000)
)
let same = previous
let renewed = DHCPLease(
name: nil,
ipAddress: "192.168.64.7",
hwAddress: "aa:bb:0c:dd:ee:ff",
leaseExpiry: Date(timeIntervalSince1970: 2_000)
)
let reassigned = DHCPLease(
name: nil,
ipAddress: "192.168.64.9",
hwAddress: "aa:bb:0c:dd:ee:ff",
leaseExpiry: Date(timeIntervalSince1970: 1_000)
)
#expect(!DHCPLeaseParser.isNewer(same, than: previous))
#expect(DHCPLeaseParser.isNewer(renewed, than: previous))
#expect(DHCPLeaseParser.isNewer(reassigned, than: previous))
// First boot on this MAC: there is nothing to be stale against.
#expect(DHCPLeaseParser.isNewer(same, than: nil))
}
@Test("lease(forMAC:) returns the same record the address lookup uses")
func leaseLookupAgreesWithAddressLookup() {
let leases = DHCPLeaseParser.parse(Self.fixture)
let lease = DHCPLeaseParser.lease(forMAC: "aa:bb:0c:0d:ee:ff", in: leases)
#expect(lease?.ipAddress == "192.168.64.9")
#expect(lease?.ipAddress == DHCPLeaseParser.ipAddress(forMAC: "aa:bb:0c:0d:ee:ff", in: leases))
#expect(DHCPLeaseParser.lease(forMAC: "00:11:22:33:44:55", in: leases) == nil)
}
@Test("normalizeMAC accepts the renderings bootpd and VZMACAddress produce")
func normalizeMACAcceptsCommonForms() {
#expect(DHCPLeaseParser.normalizeMAC("1,aa:bb:c:dd:ee:ff") == "aa:bb:0c:dd:ee:ff")
#expect(DHCPLeaseParser.normalizeMAC("aa:bb:0c:dd:ee:ff") == "aa:bb:0c:dd:ee:ff")
#expect(DHCPLeaseParser.normalizeMAC("AA-BB-0C-DD-EE-FF") == "aa:bb:0c:dd:ee:ff")
#expect(DHCPLeaseParser.normalizeMAC(" 1,0:0:0:0:0:1 ") == "00:00:00:00:00:01")
}
@Test("normalizeMAC rejects anything that is not six hex octets")
func normalizeMACRejectsGarbage() {
#expect(DHCPLeaseParser.normalizeMAC("") == nil)
#expect(DHCPLeaseParser.normalizeMAC("aa:bb:cc:dd:ee") == nil)
#expect(DHCPLeaseParser.normalizeMAC("aa:bb:cc:dd:ee:ff:00") == nil)
#expect(DHCPLeaseParser.normalizeMAC("aa:bb:cc:dd:ee:gg") == nil)
#expect(DHCPLeaseParser.normalizeMAC("aa:bb:cc:dd:ee:fff") == nil)
#expect(DHCPLeaseParser.normalizeMAC("192.168.64.2") == nil)
}
@Test("parsing never throws on hostile input")
func parsingIsTotal() {
#expect(DHCPLeaseParser.parse("").isEmpty)
#expect(DHCPLeaseParser.parse("}}}{{{\n=\nname=\n").isEmpty)
#expect(DHCPLeaseParser.parse("{\nip_address=1.2.3.4\n").isEmpty)
// A block interrupted by the start of the next one is dropped, not merged.
let interrupted = """
{
ip_address=192.168.64.20
{
ip_address=192.168.64.21
hw_address=1,2:2:2:2:2:2
}
"""
let leases = DHCPLeaseParser.parse(interrupted)
#expect(leases.count == 1)
#expect(leases[0].ipAddress == "192.168.64.21")
#expect(leases[0].leaseExpiry == nil)
}
@Test("a missing lease database reads as no leases")
func missingFileIsEmpty() {
#expect(DHCPLeaseParser.parseFile(at: "/var/db/definitely-not-a-lease-file").isEmpty)
}
@Test("a lease database on disk round-trips through parseFile")
func parsesFromDisk() throws {
let path = FileManager.default.temporaryDirectory
.appendingPathComponent("dhcpd_leases_test_\(UUID().uuidString)")
try Self.fixture.write(to: path, atomically: true, encoding: .utf8)
defer { try? FileManager.default.removeItem(at: path) }
let leases = DHCPLeaseParser.parseFile(at: path.path)
#expect(DHCPLeaseParser.ipAddress(forMAC: "aa:bb:c:d:ee:ff", in: leases) == "192.168.64.9")
}
}
@@ -0,0 +1,443 @@
import Foundation
import Testing
#if canImport(FoundationNetworking)
// `URLRequest` lives in FoundationNetworking on Linux, as it does in RunnerCore.
import FoundationNetworking
#endif
@testable import RunnerCore
/// Ways a fixture can be wrong about its own inputs.
private enum FixtureFailure: Error {
case unexpectedRequestCount(Int)
case unusableURL
}
/// A canned ``HTTPTransport`` that records what it was asked to send.
///
/// An actor rather than a locked class: ``HTTPTransport/send(_:)`` is `async`,
/// so actor isolation satisfies the requirement directly and the recorded
/// requests need no lock of their own. No `URLProtocol`, no loopback server —
/// the seam is the protocol.
private actor MockTransport: HTTPTransport {
/// Every request handed to ``send(_:)``, in order.
private(set) var requests: [URLRequest] = []
private let handler: @Sendable (URLRequest) -> (Data, Int)
/// - Parameter handler: Produces the canned `(body, status)` for a request.
init(handler: @escaping @Sendable (URLRequest) -> (Data, Int)) {
self.handler = handler
}
/// Convenience: always answer with one status and body.
init(status: Int, body: String = "") {
self.handler = { (_: URLRequest) in (Data(body.utf8), status) }
}
func send(_ request: URLRequest) async throws -> (Data, Int) {
requests.append(request)
return handler(request)
}
/// The single request that was sent, or a failure if the count differs.
func onlyRequest() throws -> URLRequest {
guard requests.count == 1, let only = requests.first else {
throw FixtureFailure.unexpectedRequestCount(requests.count)
}
return only
}
}
/// Tests for ``GiteaClient`` request shaping and error mapping, driven through a
/// fake ``HTTPTransport``.
@Suite("GiteaClient")
struct GiteaClientTests {
private let base = URL(string: "https://gitea.example.com")!
private func components(_ request: URLRequest) throws -> URLComponents {
guard let url = request.url,
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
else { throw FixtureFailure.unusableURL }
return components
}
private func queryValue(_ request: URLRequest, _ name: String) throws -> String? {
try components(request).queryItems?.first(where: { $0.name == name })?.value
}
// MARK: - Construction
@Test("a client retains the base URL it was constructed with")
func clientRetainsBaseURL() throws {
let url = try #require(URL(string: "https://gitea.example.com"))
let client = GiteaClient(baseURL: url, token: "t")
#expect(client.baseURL == url)
}
// MARK: - Headers
@Test("every request carries Gitea's token auth and a JSON Accept header")
func requestHeaders() throws {
let client = GiteaClient(baseURL: base, token: "s3cret")
let request = try client.makeRequest(method: "GET", path: "/api/v1/admin/actions/runners")
#expect(request.value(forHTTPHeaderField: "Authorization") == "token s3cret")
#expect(request.value(forHTTPHeaderField: "Accept") == "application/json")
// No body, so no Content-Type.
#expect(request.value(forHTTPHeaderField: "Content-Type") == nil)
}
@Test("a request with a body declares JSON content")
func requestWithBody() throws {
let client = GiteaClient(baseURL: base, token: "t")
let request = try client.makeRequest(
method: "POST", path: "/api/v1/x", body: Data(#"{"a":1}"#.utf8))
#expect(request.httpMethod == "POST")
#expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json")
#expect(request.httpBody == Data(#"{"a":1}"#.utf8))
}
@Test("a trailing slash on the base URL does not double up")
func baseURLTrailingSlash() throws {
let client = GiteaClient(baseURL: try #require(URL(string: "https://gitea.example.com/")), token: "t")
let request = try client.makeRequest(method: "GET", path: "/api/v1/version")
#expect(request.url?.absoluteString == "https://gitea.example.com/api/v1/version")
}
@Test("an instance served under a subpath keeps that subpath")
func baseURLWithSubpath() throws {
// `URL(string:relativeTo:)` would drop "/gitea" here; string joining does not.
let client = GiteaClient(baseURL: try #require(URL(string: "https://example.com/gitea")), token: "t")
let request = try client.makeRequest(method: "GET", path: "/api/v1/version")
#expect(request.url?.absoluteString == "https://example.com/gitea/api/v1/version")
}
// MARK: - listQueuedJobs
@Test("listQueuedJobs GETs the admin jobs endpoint with status=queued")
func listQueuedJobsRequestShape() async throws {
let body = """
{"total_count": 1, "jobs": [
{"id": 4711, "run_id": 12, "name": "build", "labels": ["macos-arm64"],
"status": "queued", "created_at": "2026-08-07T09:15:04Z"}
]}
"""
let transport = MockTransport(status: 200, body: body)
let client = GiteaClient(baseURL: base, token: "t", transport: transport)
let jobs = try await client.listQueuedJobs(limit: 25)
#expect(jobs.map(\.id) == [4711])
#expect(jobs.first?.labels == ["macos-arm64"])
let request = try await transport.onlyRequest()
#expect(request.httpMethod == "GET")
#expect(try components(request).path == "/api/v1/admin/actions/jobs")
// "queued" and never "waiting": the latter means blocked on a dependency.
#expect(try queryValue(request, "status") == "queued")
#expect(try queryValue(request, "limit") == "25")
#expect(request.value(forHTTPHeaderField: "Authorization") == "token t")
}
@Test("listQueuedJobs defaults to a limit of 50")
func listQueuedJobsDefaultLimit() async throws {
let transport = MockTransport(status: 200, body: #"{"total_count": 0, "jobs": []}"#)
let client = GiteaClient(baseURL: base, token: "t", transport: transport)
#expect(try await client.listQueuedJobs().isEmpty)
#expect(try await queryValue(transport.onlyRequest(), "limit") == "50")
}
@Test("listQueuedJobs never asks for a limit below 1")
func listQueuedJobsClampsLimit() async throws {
let transport = MockTransport(status: 200, body: #"{"jobs": []}"#)
let client = GiteaClient(baseURL: base, token: "t", transport: transport)
_ = try await client.listQueuedJobs(limit: 0)
#expect(try await queryValue(transport.onlyRequest(), "limit") == "1")
}
@Test("listQueuedJobs surfaces a 401 as a gitea error")
func listQueuedJobsUnauthorized() async throws {
let transport = MockTransport(status: 401, body: #"{"message": "token is invalid", "url": "..."}"#)
let client = GiteaClient(baseURL: base, token: "bad", transport: transport)
await #expect(throws: CoreError.self) {
_ = try await client.listQueuedJobs()
}
do {
_ = try await client.listQueuedJobs()
Issue.record("expected a CoreError.gitea")
} catch let CoreError.gitea(status, message) {
#expect(status == 401)
// Gitea's own `message` field, not the raw envelope.
#expect(message == "token is invalid")
}
}
@Test("a 500 with a non-JSON body reports a body excerpt")
func serverErrorReportsExcerpt() async throws {
let transport = MockTransport(status: 500, body: "<html>Internal Server Error</html>")
let client = GiteaClient(baseURL: base, token: "t", transport: transport)
do {
_ = try await client.listQueuedJobs()
Issue.record("expected a CoreError.gitea")
} catch let CoreError.gitea(status, message) {
#expect(status == 500)
#expect(message.contains("Internal Server Error"))
}
}
@Test("a 2xx with an undecodable body is an error, not a silent empty list")
func undecodableBodyIsAnError() async throws {
let transport = MockTransport(status: 200, body: "not json at all")
let client = GiteaClient(baseURL: base, token: "t", transport: transport)
await #expect(throws: CoreError.self) {
_ = try await client.listQueuedJobs()
}
}
// MARK: - listRunners
@Test("listRunners GETs the admin runners endpoint and decodes object labels")
func listRunnersRequestShape() async throws {
let body = """
{"total_count": 1, "runners": [
{"id": 9, "name": "macos-vm-abc", "status": "online", "busy": false,
"ephemeral": true, "labels": [{"id": 3, "name": "macos-arm64", "type": "custom"}]}
]}
"""
let transport = MockTransport(status: 200, body: body)
let client = GiteaClient(baseURL: base, token: "t", transport: transport)
let runners = try await client.listRunners()
#expect(runners.count == 1)
#expect(runners.first?.labels == ["macos-arm64"])
#expect(runners.first?.isEphemeral == true)
#expect(runners.first?.isBusy == false)
let request = try await transport.onlyRequest()
#expect(request.httpMethod == "GET")
#expect(try components(request).path == "/api/v1/admin/actions/runners")
// Paginated: `total_count` says there is nothing past this page, so one
// request is all it takes.
let query = try components(request).queryItems ?? []
#expect(query.contains(URLQueryItem(name: "page", value: "1")))
#expect(query.contains(URLQueryItem(name: "limit", value: "50")))
}
@Test("listRunners walks every page rather than returning only the first")
func listRunnersPaginates() async throws {
// Gitea clamps `limit` to its own maximum, so a page shorter than the
// one asked for does not mean the walk is over — only `total_count` does.
let transport = MockTransport { request in
let page = URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?
.queryItems?.first { $0.name == "page" }?.value ?? "1"
let id = page == "1" ? 1 : 2
let body = """
{"total_count": 2, "runners": [
{"id": \(id), "name": "macos-vm-\(id)", "status": "online", "busy": false,
"ephemeral": true, "labels": ["macos-arm64"]}
]}
"""
return (Data(body.utf8), 200)
}
let client = GiteaClient(baseURL: base, token: "t", transport: transport)
let runners = try await client.listRunners()
#expect(runners.map(\.id) == [1, 2])
await #expect(transport.requests.count == 2)
}
@Test("listRunners stops on an empty page when the server omits total_count")
func listRunnersStopsOnEmptyPage() async throws {
let transport = MockTransport { request in
let page = URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?
.queryItems?.first { $0.name == "page" }?.value ?? "1"
let body = page == "1"
? #"{"runners": [{"id": 1, "name": "macos-vm-1", "ephemeral": true, "labels": []}]}"#
: #"{"runners": []}"#
return (Data(body.utf8), 200)
}
let client = GiteaClient(baseURL: base, token: "t", transport: transport)
let runners = try await client.listRunners()
#expect(runners.map(\.id) == [1])
await #expect(transport.requests.count == 2)
}
@Test("listRunners surfaces a 403 as a gitea error")
func listRunnersForbidden() async throws {
let transport = MockTransport(status: 403, body: #"{"message": "not an admin"}"#)
let client = GiteaClient(baseURL: base, token: "t", transport: transport)
do {
_ = try await client.listRunners()
Issue.record("expected a CoreError.gitea")
} catch let CoreError.gitea(status, message) {
#expect(status == 403)
#expect(message == "not an admin")
}
}
// MARK: - deleteRunner
@Test("deleteRunner DELETEs the runner by id and accepts 204")
func deleteRunnerRequestShape() async throws {
let transport = MockTransport(status: 204)
let client = GiteaClient(baseURL: base, token: "t", transport: transport)
try await client.deleteRunner(id: 9)
let request = try await transport.onlyRequest()
#expect(request.httpMethod == "DELETE")
#expect(try components(request).path == "/api/v1/admin/actions/runners/9")
}
@Test("deleteRunner tolerates a 404")
func deleteRunnerTolerates404() async throws {
// The goal is only that the row be gone. We race Gitea's own midnight
// sweep and `--ephemeral` auto-deregistration, so "already absent" is
// success, not a failure worth logging every five minutes.
let transport = MockTransport(status: 404, body: #"{"message": "runner not found"}"#)
let client = GiteaClient(baseURL: base, token: "t", transport: transport)
try await client.deleteRunner(id: 9)
}
@Test("deleteRunner accepts a 200 as well as a 204")
func deleteRunnerTolerates200() async throws {
let client = GiteaClient(baseURL: base, token: "t", transport: MockTransport(status: 200))
try await client.deleteRunner(id: 1)
}
@Test("deleteRunner still fails on a 500")
func deleteRunnerFailsOn500() async throws {
let transport = MockTransport(status: 500, body: #"{"message": "boom"}"#)
let client = GiteaClient(baseURL: base, token: "t", transport: transport)
do {
try await client.deleteRunner(id: 9)
Issue.record("expected a CoreError.gitea")
} catch let CoreError.gitea(status, message) {
#expect(status == 500)
#expect(message == "boom")
}
}
@Test("deleteRunner rejects a 401 rather than treating it as done")
func deleteRunnerFailsOn401() async throws {
let client = GiteaClient(
baseURL: base, token: "t", transport: MockTransport(status: 401, body: "unauthorized"))
await #expect(throws: CoreError.self) {
try await client.deleteRunner(id: 9)
}
}
// MARK: - getRegistrationToken
@Test("getRegistrationToken POSTs and returns the token")
func registrationTokenRequestShape() async throws {
let transport = MockTransport(status: 200, body: #"{"token": "AABBCC00112233"}"#)
let client = GiteaClient(baseURL: base, token: "t", transport: transport)
#expect(try await client.getRegistrationToken() == "AABBCC00112233")
let request = try await transport.onlyRequest()
#expect(request.httpMethod == "POST")
#expect(try components(request).path == "/api/v1/admin/actions/runners/registration-token")
#expect(request.value(forHTTPHeaderField: "Authorization") == "token t")
}
@Test("getRegistrationToken trims surrounding whitespace")
func registrationTokenIsTrimmed() async throws {
let transport = MockTransport(status: 200, body: "{\"token\": \" AABB \\n\"}")
let client = GiteaClient(baseURL: base, token: "t", transport: transport)
#expect(try await client.getRegistrationToken() == "AABB")
}
@Test("getRegistrationToken rejects an empty token")
func registrationTokenRejectsEmpty() async throws {
// An empty token would be written into the guest and fail at
// `gitea-runner register`, tens of seconds and one VM boot later.
let client = GiteaClient(
baseURL: base, token: "t", transport: MockTransport(status: 200, body: #"{"token": ""}"#))
await #expect(throws: CoreError.self) {
_ = try await client.getRegistrationToken()
}
}
@Test("getRegistrationToken surfaces a 500")
func registrationTokenServerError() async throws {
let client = GiteaClient(
baseURL: base, token: "t", transport: MockTransport(status: 500, body: #"{"message": "nope"}"#))
do {
_ = try await client.getRegistrationToken()
Issue.record("expected a CoreError.gitea")
} catch let CoreError.gitea(status, _) {
#expect(status == 500)
}
}
// MARK: - ping
@Test("ping probes an admin endpoint, so a non-admin token fails")
func pingUsesAnAdminEndpoint() async throws {
let transport = MockTransport(status: 200, body: #"{"total_count": 0, "runners": []}"#)
let client = GiteaClient(baseURL: base, token: "t", transport: transport)
try await client.ping()
let request = try await transport.onlyRequest()
// Deliberately not /api/v1/version, which most instances serve
// anonymously and would therefore pass with a bad token.
#expect(try components(request).path == "/api/v1/admin/actions/runners")
#expect(request.value(forHTTPHeaderField: "Authorization") == "token t")
}
@Test("ping fails when the token is rejected")
func pingFailsOnBadToken() async throws {
let client = GiteaClient(
baseURL: base, token: "bad",
transport: MockTransport(status: 401, body: #"{"message": "token is invalid"}"#))
do {
try await client.ping()
Issue.record("expected a CoreError.gitea")
} catch let CoreError.gitea(status, _) {
#expect(status == 401)
}
}
// MARK: - Transport-level failures
@Test("a transport error propagates unchanged")
func transportErrorPropagates() async throws {
// A dropped connection is not an API error; the poll loop logs it and
// retries on the next tick without touching any state. It must not be
// laundered into a `CoreError.gitea` with a made-up status.
let client = GiteaClient(baseURL: base, token: "t", transport: ThrowingTransport())
await #expect(throws: ThrowingTransport.Offline.self) {
_ = try await client.listQueuedJobs()
}
}
}
/// A transport that always fails, standing in for an unreachable instance.
private struct ThrowingTransport: HTTPTransport {
struct Offline: Error {}
func send(_ request: URLRequest) async throws -> (Data, Int) {
throw Offline()
}
}
@@ -0,0 +1,334 @@
import Foundation
import Testing
@testable import RunnerCore
/// Tests for the Gitea API models' JSON mapping.
///
/// The fixtures below are written from the shapes Gitea actually serializes,
/// verified against `modules/structs/repo_actions.go` and
/// `routers/api/v1/shared/{action,runners}.go` at tag `v1.25.0`, cross-checked
/// against the published swagger (`ActionWorkflowJobsResponse`,
/// `ActionRunnersResponse`, `ActionRunner`, `ActionRunnerLabel`). Two details
/// are easy to get wrong and are pinned here deliberately:
///
/// * a job's `labels` is an array of **strings**, but a runner's `labels` is an
/// array of **objects** (`{id, name, type}`);
/// * timestamps are Go `time.Time` values, so fractional seconds appear only
/// when non-zero and an unset time serializes as `0001-01-01T00:00:00Z`.
@Suite("Gitea models")
struct GiteaModelsTests {
private func decode<T: Decodable>(_ type: T.Type, _ json: String) throws -> T {
try GiteaClient.makeDecoder().decode(T.self, from: Data(json.utf8))
}
// MARK: - Job status
@Test("a job with status 'queued' is schedulable")
func queuedStatusIsSchedulable() {
let job = WorkflowJob(id: 1, runID: 2, name: "build", status: "queued", labels: ["macos-arm64"])
#expect(job.isQueued)
#expect(job.jobStatus == .queued)
}
@Test("'waiting' means blocked and is never schedulable")
func waitingIsNotSchedulable() {
// Gitea maps the external "queued" onto its internal StatusWaiting, and
// the external "waiting" onto StatusBlocked — a job waiting on a
// dependency, not on a runner. Booting a VM for one would be pure waste.
let job = WorkflowJob(id: 1, runID: 2, name: "build", status: "waiting", labels: ["macos-arm64"])
#expect(!job.isQueued)
#expect(job.jobStatus == .waiting)
}
@Test("every reported status maps to a case, unknown strings included")
func statusMapping() {
#expect(JobStatus(rawValue: "queued") == .queued)
#expect(JobStatus(rawValue: "waiting") == .waiting)
#expect(JobStatus(rawValue: "in_progress") == .inProgress)
#expect(JobStatus(rawValue: "completed") == .completed)
// A status this build has never heard of must round-trip, not throw:
// the mapping is Gitea's to change, and a decode failure would stop the
// poll loop entirely.
#expect(JobStatus(rawValue: "some_new_status") == .unknown("some_new_status"))
#expect(JobStatus(rawValue: "some_new_status").rawValue == "some_new_status")
}
// MARK: - Job decoding
@Test("a realistic queued-job response decodes")
func decodeQueuedJobsResponse() throws {
let json = """
{
"total_count": 1,
"jobs": [
{
"id": 4711,
"url": "https://gitea.example.com/api/v1/repos/acme/widget/actions/jobs/4711",
"html_url": "https://gitea.example.com/acme/widget/actions/runs/12/jobs/0",
"run_id": 12,
"run_url": "https://gitea.example.com/api/v1/repos/acme/widget/actions/runs/12",
"name": "build (macos)",
"labels": ["macos-arm64"],
"run_attempt": 1,
"head_sha": "0f1e2d3c4b5a69788796a5b4c3d2e1f001234567",
"head_branch": "main",
"status": "queued",
"steps": [],
"created_at": "2026-08-07T09:15:04Z",
"started_at": "0001-01-01T00:00:00Z",
"completed_at": "0001-01-01T00:00:00Z"
}
]
}
"""
let response = try decode(WorkflowJobsResponse.self, json)
#expect(response.totalCount == 1)
#expect(response.items.count == 1)
let job = try #require(response.items.first)
#expect(job.id == 4711)
#expect(job.runID == 12)
#expect(job.name == "build (macos)")
#expect(job.status == "queued")
#expect(job.isQueued)
#expect(job.labels == ["macos-arm64"])
// `runner_id` / `runner_name` carry omitempty and are absent while queued.
#expect(job.runnerID == nil)
#expect(job.runnerName == nil)
#expect(job.createdAt != nil)
// Go's zero time must not surface as a year-1 date.
#expect(job.startedAt == nil)
#expect(job.completedAt == nil)
}
@Test("a running job reports its runner")
func decodeRunningJob() throws {
let json = """
{
"total_count": 1,
"jobs": [
{
"id": 4712,
"run_id": 12,
"name": "test",
"labels": ["macos-arm64", "self-hosted"],
"status": "in_progress",
"runner_id": 9,
"runner_name": "macos-vm-3f1c2f8e-0a4b-4c1d-9e2f-5a6b7c8d9e0f",
"created_at": "2026-08-07T09:15:04Z",
"started_at": "2026-08-07T09:16:31.482913Z",
"completed_at": "0001-01-01T00:00:00Z"
}
]
}
"""
let job = try #require(try decode(WorkflowJobsResponse.self, json).items.first)
#expect(!job.isQueued)
#expect(job.jobStatus == .inProgress)
#expect(job.runnerID == 9)
#expect(job.runnerName == "macos-vm-3f1c2f8e-0a4b-4c1d-9e2f-5a6b7c8d9e0f")
// Fractional seconds are present here and absent above — both must parse.
#expect(job.startedAt != nil)
#expect(job.completedAt == nil)
}
@Test("an unknown status string decodes rather than throwing")
func unknownStatusTolerated() throws {
let json = """
{"total_count": 1, "jobs": [
{"id": 1, "run_id": 1, "name": "j", "labels": [], "status": "quantum_superposition"}
]}
"""
let job = try #require(try decode(WorkflowJobsResponse.self, json).items.first)
#expect(job.status == "quantum_superposition")
#expect(job.jobStatus == .unknown("quantum_superposition"))
#expect(!job.isQueued)
}
@Test("an empty jobs response decodes to no jobs")
func decodeEmptyJobsResponse() throws {
#expect(try decode(WorkflowJobsResponse.self, #"{"total_count": 0, "jobs": []}"#).items.isEmpty)
// A server that omits the array entirely, or nulls it, must not throw:
// "nothing queued" is the overwhelmingly common case in the poll loop.
#expect(try decode(WorkflowJobsResponse.self, #"{"total_count": 0}"#).items.isEmpty)
#expect(try decode(WorkflowJobsResponse.self, #"{"total_count": 0, "jobs": null}"#).items.isEmpty)
#expect(try decode(WorkflowJobsResponse.self, "{}").items.isEmpty)
}
@Test("the 'workflow_jobs' array key is accepted as a fallback")
func decodeAlternateJobsKey() throws {
let json = """
{"total_count": 1, "workflow_jobs": [
{"id": 7, "run_id": 1, "name": "j", "labels": ["macos-arm64"], "status": "queued"}
]}
"""
let response = try decode(WorkflowJobsResponse.self, json)
#expect(response.items.map(\.id) == [7])
}
@Test("a job round-trips through encode and decode")
func jobRoundTrip() throws {
let original = WorkflowJobsResponse(
totalCount: 1,
jobs: [WorkflowJob(id: 1, runID: 2, name: "n", status: "queued", labels: ["macos-arm64"])])
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let data = try encoder.encode(original)
let decoded = try GiteaClient.makeDecoder().decode(WorkflowJobsResponse.self, from: data)
#expect(decoded == original)
}
// MARK: - Runner decoding
@Test("a runners response with object-shaped labels decodes")
func decodeRunnersResponse() throws {
// This is the shape that matters most: `ActionRunner.Labels` is
// `[]*ActionRunnerLabel`, NOT `[]string` as on a job.
let json = """
{
"total_count": 2,
"runners": [
{
"id": 9,
"name": "macos-vm-3f1c2f8e-0a4b-4c1d-9e2f-5a6b7c8d9e0f",
"status": "online",
"busy": false,
"ephemeral": true,
"labels": [
{"id": 31, "name": "macos-arm64", "type": "custom"}
]
},
{
"id": 10,
"name": "shared-linux-1",
"status": "offline",
"busy": true,
"ephemeral": false,
"labels": [
{"id": 32, "name": "ubuntu-latest", "type": "custom"},
{"id": 33, "name": "self-hosted", "type": "custom"}
]
}
]
}
"""
let response = try decode(RunnersResponse.self, json)
#expect(response.totalCount == 2)
#expect(response.items.count == 2)
let ours = try #require(response.items.first)
#expect(ours.id == 9)
#expect(ours.labels == ["macos-arm64"])
#expect(ours.status == "online")
#expect(ours.isEphemeral)
#expect(!ours.isBusy)
// All four reconcile conditions are readable from this row.
#expect(RunnerNaming.hasPrefix(ours.name, prefix: "macos-vm-"))
let theirs = try #require(response.items.last)
#expect(theirs.labels == ["ubuntu-latest", "self-hosted"])
#expect(!theirs.isEphemeral)
#expect(theirs.isBusy)
#expect(!RunnerNaming.hasPrefix(theirs.name, prefix: "macos-vm-"))
}
@Test("string-shaped runner labels are also accepted")
func decodeRunnerWithStringLabels() throws {
// Defensive: a proxy, an older build, or a hand-written fixture may use
// the job-style array of strings.
let json = #"{"id": 1, "name": "r", "labels": ["macos-arm64", "macos"]}"#
let runner = try decode(ActionRunner.self, json)
#expect(runner.labels == ["macos-arm64", "macos"])
}
@Test("absent, null, and empty runner labels all decode to no labels")
func decodeRunnerWithoutLabels() throws {
#expect(try decode(ActionRunner.self, #"{"id": 1, "name": "r"}"#).labels.isEmpty)
#expect(try decode(ActionRunner.self, #"{"id": 1, "name": "r", "labels": null}"#).labels.isEmpty)
#expect(try decode(ActionRunner.self, #"{"id": 1, "name": "r", "labels": []}"#).labels.isEmpty)
}
@Test("omitted busy and ephemeral default to false")
func runnerFlagDefaults() throws {
// The reconcile loop only ever deletes a row it is sure is ephemeral and
// idle, so absence must read as "not ephemeral", never as "assume yes".
let runner = try decode(ActionRunner.self, #"{"id": 1, "name": "r", "labels": []}"#)
#expect(runner.busy == nil)
#expect(runner.ephemeral == nil)
#expect(!runner.isBusy)
#expect(!runner.isEphemeral)
}
@Test("an unknown runner status string is tolerated")
func runnerUnknownStatus() throws {
let json = #"{"id": 1, "name": "r", "labels": [], "status": "hibernating"}"#
#expect(try decode(ActionRunner.self, json).status == "hibernating")
}
@Test("extra server-side fields are ignored")
func unknownFieldsIgnored() throws {
// Gitea 1.27 adds `disabled` to ActionRunner; newer fields must not
// break a 1.25-era client.
let json = """
{"id": 1, "name": "r", "labels": [], "disabled": false, "some_future_field": {"a": 1}}
"""
#expect(try decode(ActionRunner.self, json).id == 1)
}
@Test("an empty runners response decodes to no runners")
func decodeEmptyRunnersResponse() throws {
#expect(try decode(RunnersResponse.self, #"{"total_count": 0, "runners": []}"#).items.isEmpty)
#expect(try decode(RunnersResponse.self, "{}").items.isEmpty)
}
@Test("the 'entries' array key is accepted as a fallback")
func decodeAlternateRunnersKey() throws {
let json = #"{"total_count": 1, "entries": [{"id": 5, "name": "r", "labels": []}]}"#
#expect(try decode(RunnersResponse.self, json).items.map(\.id) == [5])
}
// MARK: - Registration token
@Test("a registration-token response decodes")
func decodeRegistrationToken() throws {
// Verified: `shared.RegistrationToken` is `{Token string `json:"token"`}`.
let response = try decode(RegistrationTokenResponse.self, #"{"token": "AABBCCDDEEFF00112233"}"#)
#expect(response.token == "AABBCCDDEEFF00112233")
}
// MARK: - Timestamps
@Test("timestamps parse with and without fractional seconds")
func timestampParsing() throws {
// Go marshals time.Time as RFC 3339 Nano: the fractional part appears
// only when non-zero, so both spellings occur in one response.
let plain = try #require(GiteaClient.parseTimestamp("2026-08-07T09:15:04Z"))
let fractional = try #require(GiteaClient.parseTimestamp("2026-08-07T09:15:04.482913Z"))
#expect(fractional > plain)
#expect(fractional.timeIntervalSince(plain) < 1)
// An offset rather than Z.
let offset = try #require(GiteaClient.parseTimestamp("2026-08-07T11:15:04+02:00"))
#expect(offset == plain)
#expect(GiteaClient.parseTimestamp("not a date") == nil)
}
@Test("a malformed timestamp fails the field, loudly")
func malformedTimestampThrows() {
let json = """
{"total_count": 1, "jobs": [
{"id": 1, "run_id": 1, "name": "j", "labels": [], "status": "queued",
"created_at": "yesterday"}
]}
"""
#expect(throws: DecodingError.self) {
try decode(WorkflowJobsResponse.self, json)
}
}
}
+182
View File
@@ -0,0 +1,182 @@
import Foundation
import Testing
@testable import RunnerCore
/// Tests for ``LabelSet`` matching against a job's bare `runs-on` labels.
///
/// The semantics under test are exactly: a job matches iff its label array is
/// non-empty and every entry is one of ours. That is a subset test with an
/// explicit carve-out for the empty set, which subset semantics would otherwise
/// accept.
@Suite("LabelSet")
struct LabelsTests {
// MARK: - Construction
@Test("bare names are kept verbatim")
func bareNamesAreKept() {
let set = LabelSet(["macos-arm64", "macos"])
#expect(set.names == ["macos-arm64", "macos"])
}
@Test("a ':schema' suffix is stripped at construction")
func schemaSuffixIsStripped() {
// Safe to hand this the same array the config file holds, even if an
// operator wrote the registration form by mistake.
let set = LabelSet(["macos-arm64:host", "macos:docker"])
#expect(set.names == ["macos-arm64", "macos"])
}
@Test("empty and whitespace-only names are dropped")
func emptyNamesAreDropped() {
let set = LabelSet(["macos-arm64", "", " ", ":host"])
#expect(set.names == ["macos-arm64"])
}
@Test("bareName strips at the first colon only")
func bareNameStripsAtFirstColon() {
#expect(LabelSet.bareName("macos-arm64") == "macos-arm64")
#expect(LabelSet.bareName("macos-arm64:host") == "macos-arm64")
#expect(LabelSet.bareName("a:b:c") == "a")
#expect(LabelSet.bareName(" macos:host") == "macos")
}
// MARK: - Matching
@Test("an exact single-label match succeeds")
func exactMatch() {
#expect(LabelSet(["macos-arm64"]).matches(jobLabels: ["macos-arm64"]))
}
@Test("a job asking for a subset of our labels matches")
func subsetMatches() {
// The runner is a superset: it advertises more than the job needs.
let set = LabelSet(["macos-arm64", "macos", "self-hosted"])
#expect(set.matches(jobLabels: ["macos-arm64"]))
#expect(set.matches(jobLabels: ["macos-arm64", "self-hosted"]))
#expect(set.matches(jobLabels: ["macos-arm64", "macos", "self-hosted"]))
}
@Test("a job asking for anything we lack does not match")
func supersetDoesNotMatch() {
let set = LabelSet(["macos-arm64"])
#expect(!set.matches(jobLabels: ["ubuntu-latest"]))
// One unknown label is enough to disqualify the whole job.
#expect(!set.matches(jobLabels: ["macos-arm64", "xcode-16"]))
}
@Test("an empty job label array never matches")
func emptyJobLabelsDoNotMatch() {
// Subset semantics alone would say yes — the empty set is a subset of
// everything. A job that declares no requirement must not consume one of
// two scarce macOS VMs.
#expect(!LabelSet(["macos-arm64"]).matches(jobLabels: []))
#expect(!LabelSet([]).matches(jobLabels: []))
}
@Test("an empty runner label set matches nothing")
func emptyRunnerLabelsMatchNothing() {
#expect(!LabelSet([]).matches(jobLabels: ["macos-arm64"]))
}
@Test("matching is case-sensitive")
func matchingIsCaseSensitive() {
// Gitea compares labels case-sensitively, so `macOS-ARM64` in a workflow
// is a different label from `macos-arm64` and must not be claimed.
let set = LabelSet(["macos-arm64"])
#expect(!set.matches(jobLabels: ["macOS-ARM64"]))
#expect(!set.matches(jobLabels: ["MACOS-ARM64"]))
#expect(set.matches(jobLabels: ["macos-arm64"]))
}
@Test("a schema suffix on the job side is stripped before matching")
func jobLabelsAreNormalized() {
// The server stores bare names, so this is unusual — but a workflow that
// writes `runs-on: [macos-arm64:host]` would otherwise be skipped
// silently, with no log line to explain why.
let set = LabelSet(["macos-arm64", "macos"])
#expect(set.matches(jobLabels: ["macos-arm64:host"]))
#expect(set.matches(jobLabels: ["macos-arm64:host", "macos"]))
#expect(!set.matches(jobLabels: ["ubuntu-latest:host"]))
// A label that is nothing but a schema separator is not a match.
#expect(!set.matches(jobLabels: [":host"]))
}
@Test("duplicate job labels are harmless")
func duplicateJobLabels() {
#expect(LabelSet(["macos-arm64"]).matches(jobLabels: ["macos-arm64", "macos-arm64"]))
}
// MARK: - Registration argument
@Test("the registration argument appends the schema to every name")
func registrationArgumentAppendsSchema() {
#expect(LabelSet(["macos-arm64"]).registrationArgument() == "macos-arm64:host")
#expect(
LabelSet(["macos-arm64", "macos"]).registrationArgument() == "macos:host,macos-arm64:host")
}
@Test("the registration argument is stable across calls")
func registrationArgumentIsStable() {
// `names` is a Set; sorting is what keeps the guest command line — and
// therefore its logs — reproducible.
let set = LabelSet(["zulu", "alpha", "mike"])
#expect(set.registrationArgument() == set.registrationArgument())
#expect(set.registrationArgument() == "alpha:host,mike:host,zulu:host")
}
@Test("a non-default schema is honoured")
func registrationArgumentWithCustomSchema() {
#expect(LabelSet(["ubuntu"]).registrationArgument(schema: "docker") == "ubuntu:docker")
}
@Test("an empty label set produces an empty registration argument")
func registrationArgumentOfEmptySet() {
#expect(LabelSet([]).registrationArgument() == "")
}
}
/// Tests for ``RunnerNaming``.
@Suite("RunnerNaming")
struct RunnerNamingTests {
@Test("a generated name carries the prefix and a lowercase UUID")
func generatedNameShape() throws {
let name = RunnerNaming.makeRunnerName(prefix: "macos-vm-")
#expect(name.hasPrefix("macos-vm-"))
let suffix = String(name.dropFirst("macos-vm-".count))
#expect(suffix == suffix.lowercased())
#expect(UUID(uuidString: suffix) != nil)
}
@Test("generated names are unique")
func generatedNamesAreUnique() {
// Uniqueness is what lets the reconcile loop decide a row is ours and
// unbacked; a collision would make that decision unsound.
let names = Set((0..<200).map { _ in RunnerNaming.makeRunnerName(prefix: "macos-vm-") })
#expect(names.count == 200)
}
@Test("prefix detection matches only our names")
func prefixDetection() {
#expect(RunnerNaming.hasPrefix("macos-vm-abc", prefix: "macos-vm-"))
#expect(!RunnerNaming.hasPrefix("linux-runner-1", prefix: "macos-vm-"))
#expect(!RunnerNaming.hasPrefix("MACOS-VM-abc", prefix: "macos-vm-"))
#expect(!RunnerNaming.hasPrefix("x-macos-vm-abc", prefix: "macos-vm-"))
}
@Test("an empty prefix matches nothing")
func emptyPrefixMatchesNothing() {
// Otherwise the reconcile loop would consider every runner on the
// instance — including other hosts' — a deletion candidate.
#expect(!RunnerNaming.hasPrefix("anything", prefix: ""))
}
@Test("a generated name is recognized by its own prefix")
func roundTrip() {
let name = RunnerNaming.makeRunnerName(prefix: "macos-vm-")
#expect(RunnerNaming.hasPrefix(name, prefix: "macos-vm-"))
}
}
@@ -0,0 +1,344 @@
import Foundation
import Testing
@testable import RunnerCore
/// Tests for the pure scheduling state machine.
///
/// Every case drives ``SchedulerCore/plan(state:queuedJobs:labels:maxVMs:now:jobTimeout:bootTimeout:)``
/// with an injected `now`, so nothing here touches a clock, the network, or a VM.
@Suite("SchedulerCore")
struct SchedulerCoreTests {
// MARK: - Fixtures
static let labels = LabelSet(["macos-arm64", "macos"])
static let now = Date(timeIntervalSince1970: 1_700_000_000)
static let jobTimeout: TimeInterval = 3600
static let bootTimeout: TimeInterval = 300
static func job(_ id: Int64, labels: [String] = ["macos-arm64"]) -> WorkflowJob {
WorkflowJob(id: id, runID: id * 10, name: "job-\(id)", status: "queued", labels: labels)
}
/// Plans one tick with the suite's fixed labels and timeouts.
static func tick(
_ state: SchedulerState,
_ queued: [WorkflowJob],
maxVMs: Int = 2,
now: Date = SchedulerCoreTests.now
) -> (SchedulerState, [SchedulerAction]) {
SchedulerCore.plan(
state: state,
queuedJobs: queued,
labels: labels,
maxVMs: maxVMs,
now: now,
jobTimeout: jobTimeout,
bootTimeout: bootTimeout
)
}
// MARK: - Baseline
@Test("a fresh state has no VMs and no ledger")
func freshStateIsAllIdle() {
let state = SchedulerState(slotCount: 2)
#expect(state.slots.count == 2)
#expect(state.idleSlots.count == 2)
#expect(state.occupiedSlots.isEmpty)
#expect(state.dispatchedJobIDs.isEmpty)
}
@Test("an empty queue is a no-op")
func emptyQueueDoesNothing() {
let state = SchedulerState(slotCount: 2)
let (next, actions) = Self.tick(state, [])
#expect(actions.isEmpty)
#expect(next == state)
}
// MARK: - Booting
@Test("one queued job boots one VM")
func oneJobBootsOneVM() {
let (next, actions) = Self.tick(SchedulerState(slotCount: 2), [Self.job(1)])
#expect(actions == [.bootVM(slot: 0, jobHint: 1)])
#expect(next.slots[0].state == .provisioning(since: Self.now))
#expect(next.slots[1].state == .idle)
#expect(next.dispatchedJobIDs == [1])
}
@Test("the same job across two ticks boots only one VM")
func dedupsAcrossTicks() {
let queue = [Self.job(1)]
let (afterFirst, firstActions) = Self.tick(SchedulerState(slotCount: 2), queue)
// The job is still queued a poll later: the VM has not registered yet.
let (afterSecond, secondActions) = Self.tick(
afterFirst, queue, now: Self.now.addingTimeInterval(10))
#expect(firstActions == [.bootVM(slot: 0, jobHint: 1)])
#expect(secondActions.isEmpty)
#expect(afterSecond.occupiedSlots.count == 1)
#expect(afterSecond.dispatchedJobIDs == [1])
}
@Test("a job still queued while its VM is running does not boot a second VM")
func dedupSurvivesTheRunningTransition() {
let queue = [Self.job(1)]
let (booted, _) = Self.tick(SchedulerState(slotCount: 2), queue)
let running = SchedulerCore.markRunning(state: booted, slot: 0, jobHint: 1, now: Self.now)
let (next, actions) = Self.tick(running, queue, now: Self.now.addingTimeInterval(30))
#expect(actions.isEmpty)
#expect(next.occupiedSlots.count == 1)
}
@Test("two jobs boot two VMs but a third waits for capacity")
func capacityIsCapped() {
let queue = [Self.job(1), Self.job(2), Self.job(3)]
let (next, actions) = Self.tick(SchedulerState(slotCount: 2), queue)
#expect(actions == [.bootVM(slot: 0, jobHint: 1), .bootVM(slot: 1, jobHint: 2)])
#expect(next.occupiedSlots.count == 2)
// Job 3 never entered the ledger, so it is eligible the moment a slot frees.
#expect(next.dispatchedJobIDs == [1, 2])
}
@Test("maxVMs above two is clamped to the kernel's concurrent-guest limit")
func maxVMsIsClampedToTwo() {
let queue = [Self.job(1), Self.job(2), Self.job(3), Self.job(4)]
let (next, actions) = Self.tick(SchedulerState(slotCount: 4), queue, maxVMs: 5)
#expect(actions.count == 2)
#expect(next.occupiedSlots.count == 2)
}
@Test("maxVMs of zero boots nothing")
func zeroCapacityBootsNothing() {
let (next, actions) = Self.tick(SchedulerState(slotCount: 2), [Self.job(1)], maxVMs: 0)
#expect(actions.isEmpty)
#expect(next.occupiedSlots.isEmpty)
}
// MARK: - Label matching
@Test("jobs whose labels do not match are ignored")
func nonMatchingLabelsAreIgnored() {
let queue = [
Self.job(1, labels: ["ubuntu-latest"]),
Self.job(2, labels: ["windows-2022", "self-hosted"]),
]
let (next, actions) = Self.tick(SchedulerState(slotCount: 2), queue)
#expect(actions.isEmpty)
#expect(next.dispatchedJobIDs.isEmpty)
#expect(next.occupiedSlots.isEmpty)
}
@Test("a matching job among non-matching ones still boots")
func matchingJobIsPickedOutOfAMixedQueue() {
let queue = [
Self.job(1, labels: ["ubuntu-latest"]),
Self.job(2, labels: ["macos-arm64"]),
Self.job(3, labels: ["ubuntu-latest"]),
]
let (_, actions) = Self.tick(SchedulerState(slotCount: 2), queue)
#expect(actions == [.bootVM(slot: 0, jobHint: 2)])
}
// MARK: - Ledger expiry
@Test("a job that leaves the queue drops out of the dedup ledger")
func dequeuedJobClearsItsLedgerEntry() {
let (booted, _) = Self.tick(SchedulerState(slotCount: 2), [Self.job(1)])
let running = SchedulerCore.markRunning(state: booted, slot: 0, jobHint: 1, now: Self.now)
#expect(running.dispatchedJobIDs == [1])
// The VM registered and claimed job 1, so Gitea no longer reports it queued.
let (next, actions) = Self.tick(running, [], now: Self.now.addingTimeInterval(60))
#expect(actions.isEmpty)
#expect(next.dispatchedJobIDs.isEmpty)
#expect(next.occupiedSlots.count == 1)
}
@Test("releasing a job lets a still-queued job boot again after a failure")
func releasedJobIsRedispatched() {
// A boot that failed (no disk, clone error, dead SSH) leaves its job
// queued, so the ledger's "no longer queued" expiry never fires for it.
// Without the explicit release the job is stranded for good.
let (booted, _) = Self.tick(SchedulerState(slotCount: 2), [Self.job(1)])
#expect(booted.dispatchedJobIDs == [1])
let failed = SchedulerCore.releaseJob(
state: SchedulerCore.markIdle(state: booted, slot: 0),
jobID: 1
)
#expect(failed.dispatchedJobIDs.isEmpty)
let (next, actions) = Self.tick(failed, [Self.job(1)], now: Self.now.addingTimeInterval(30))
#expect(actions == [.bootVM(slot: 0, jobHint: 1)])
#expect(next.dispatchedJobIDs == [1])
}
@Test("releasing an id that was never dispatched is a no-op")
func releasingAnUnknownJobIsHarmless() {
let (booted, _) = Self.tick(SchedulerState(slotCount: 2), [Self.job(1)])
let after = SchedulerCore.releaseJob(state: booted, jobID: 99)
#expect(after.dispatchedJobIDs == [1])
#expect(SchedulerCore.releaseJob(state: after, jobID: 1).dispatchedJobIDs.isEmpty)
}
@Test("a freed slot is re-earned by a genuinely new job")
func freedCapacityServesTheNextJob() {
let (booted, _) = Self.tick(SchedulerState(slotCount: 2), [Self.job(1)])
let done = SchedulerCore.markIdle(state: booted, slot: 0)
let (next, actions) = Self.tick(done, [Self.job(2)], now: Self.now.addingTimeInterval(120))
#expect(actions == [.bootVM(slot: 0, jobHint: 2)])
#expect(next.dispatchedJobIDs == [2])
}
// MARK: - Timeouts
@Test("a stuck boot is torn down and replaced in the same pass")
func bootTimeoutTearsDownAndAllowsAReplacement() {
let stuckSince = Self.now.addingTimeInterval(-(Self.bootTimeout + 60))
let state = SchedulerState(
slots: [
VMSlot(id: 0, state: .provisioning(since: stuckSince)),
VMSlot(id: 1, state: .idle),
],
dispatchedJobIDs: [1]
)
let (next, actions) = Self.tick(state, [Self.job(1)])
// Teardown first so the orchestrator frees the slot before reusing it.
#expect(actions.count == 2)
if case .teardownVM(let slot, let reason) = actions[0] {
#expect(slot == 0)
#expect(reason.contains("boot timeout"))
} else {
Issue.record("expected a teardown first, got \(actions[0])")
}
#expect(actions[1] == .bootVM(slot: 0, jobHint: 1))
#expect(next.slots[0].state == .provisioning(since: Self.now))
#expect(next.dispatchedJobIDs == [1])
}
@Test("a boot inside its timeout is left alone")
func youngBootIsNotTornDown() {
let state = SchedulerState(
slots: [VMSlot(id: 0, state: .provisioning(since: Self.now.addingTimeInterval(-10)))],
dispatchedJobIDs: [1]
)
let (next, actions) = Self.tick(state, [Self.job(1)])
#expect(actions.isEmpty)
#expect(next == state)
}
@Test("a run that overshoots the job timeout is torn down")
func jobTimeoutTearsDownARunningSlot() {
let startedAt = Self.now.addingTimeInterval(-(Self.jobTimeout + 300))
let state = SchedulerState(
slots: [
VMSlot(id: 0, state: .running(jobHint: 7, since: startedAt)),
VMSlot(id: 1, state: .idle),
],
dispatchedJobIDs: [7]
)
let (next, actions) = Self.tick(state, [])
#expect(actions.count == 1)
if case .teardownVM(let slot, let reason) = actions[0] {
#expect(slot == 0)
#expect(reason.contains("job timeout"))
} else {
Issue.record("expected a teardown, got \(actions[0])")
}
#expect(next.slots[0].state == .idle)
#expect(next.dispatchedJobIDs.isEmpty)
}
@Test("a run inside its timeout is left alone")
func youngRunIsNotTornDown() {
let state = SchedulerState(
slots: [VMSlot(id: 0, state: .running(jobHint: 7, since: Self.now.addingTimeInterval(-60)))]
)
let (next, actions) = Self.tick(state, [])
#expect(actions.isEmpty)
#expect(next == state)
}
@Test("both slots can time out on the same tick")
func bothSlotsCanTimeOutTogether() {
let state = SchedulerState(
slots: [
VMSlot(id: 0, state: .provisioning(since: Self.now.addingTimeInterval(-1000))),
VMSlot(id: 1, state: .running(jobHint: 9, since: Self.now.addingTimeInterval(-100_000))),
],
dispatchedJobIDs: [9]
)
let (next, actions) = Self.tick(state, [])
#expect(actions.count == 2)
#expect(next.occupiedSlots.isEmpty)
}
// MARK: - Transitions
@Test("the mark* helpers move a slot through its lifecycle")
func slotTransitions() {
var state = SchedulerState(slotCount: 2)
state = SchedulerCore.markProvisioning(state: state, slot: 1, jobHint: 42, now: Self.now)
#expect(state.slots[1].state == .provisioning(since: Self.now))
#expect(state.dispatchedJobIDs == [42])
let live = Self.now.addingTimeInterval(90)
state = SchedulerCore.markRunning(state: state, slot: 1, jobHint: 42, now: live)
#expect(state.slots[1].state == .running(jobHint: 42, since: live))
state = SchedulerCore.markIdle(state: state, slot: 1)
#expect(state.slots[1].state == .idle)
#expect(state.occupiedSlots.isEmpty)
}
@Test("markRunning keeps an existing hint and tolerates an unknown slot")
func markRunningIsForgiving() {
var state = SchedulerState(slotCount: 1)
state = SchedulerCore.markRunning(state: state, slot: 0, jobHint: 5, now: Self.now)
let later = Self.now.addingTimeInterval(30)
let carried = SchedulerCore.markRunning(state: state, slot: 0, now: later)
#expect(carried.slots[0].state == .running(jobHint: 5, since: later))
// A slot id we do not own is ignored rather than trapping.
#expect(SchedulerCore.markRunning(state: state, slot: 99, now: later) == state)
#expect(SchedulerCore.markIdle(state: state, slot: 99) == state)
}
// MARK: - Purity
@Test("planning is deterministic and leaves its input untouched")
func planningIsPure() {
let state = SchedulerState(slotCount: 2)
let queue = [Self.job(1), Self.job(2)]
let (firstState, firstActions) = Self.tick(state, queue)
let (secondState, secondActions) = Self.tick(state, queue)
#expect(firstState == secondState)
#expect(firstActions == secondActions)
// The value passed in is unchanged — `plan` returns a new state.
#expect(state.occupiedSlots.isEmpty)
#expect(state.dispatchedJobIDs.isEmpty)
}
@Test("the plan never contains an explicit no-op action")
func noOpIsAnEmptyPlan() {
let (_, actions) = Self.tick(SchedulerState(slotCount: 2), [Self.job(1)])
#expect(!actions.contains(.none))
}
}