Merge nucleic/mellow-dewy-falcon-rjhr into main
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
.build/
|
||||||
|
*.xcodeproj
|
||||||
|
.DS_Store
|
||||||
|
.swiftpm
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# gitea-macos-runner
|
||||||
|
#
|
||||||
|
# Virtualization.framework refuses to start a VM unless the calling process
|
||||||
|
# carries the `com.apple.security.virtualization` entitlement, and entitlements
|
||||||
|
# only survive on a signed bundle. So the shipping artifact is not a bare
|
||||||
|
# executable but a minimal `.app` bundle that we ad-hoc sign. See docs/DESIGN.md
|
||||||
|
# ("Verified Facts", item 10).
|
||||||
|
|
||||||
|
SHELL := /bin/bash
|
||||||
|
APP_NAME := GiteaMacosRunner
|
||||||
|
BIN_NAME := gitea-macos-runner
|
||||||
|
BUILD_DIR := .build
|
||||||
|
APP_DIR := $(BUILD_DIR)/$(APP_NAME).app
|
||||||
|
CONTENTS := $(APP_DIR)/Contents
|
||||||
|
MACOS_DIR := $(CONTENTS)/MacOS
|
||||||
|
RES_DIR := $(CONTENTS)/Resources
|
||||||
|
INFO_PLIST := Resources/Info.plist
|
||||||
|
|
||||||
|
# The entitlements plist grants exactly one entitlement,
|
||||||
|
# `com.apple.security.virtualization`. Virtualization.framework refuses to
|
||||||
|
# create a VM without it, and it is granted by ad-hoc signing
|
||||||
|
# (`codesign --sign -`) -- no Apple developer account required.
|
||||||
|
#
|
||||||
|
# Deliberately absent: com.apple.vm.networking, which would be needed for a
|
||||||
|
# bridged network attachment. That one IS restricted and requires an approved
|
||||||
|
# provisioning profile. We use NAT instead, which needs nothing extra and has
|
||||||
|
# the side benefit of putting each guest into /var/db/dhcpd_leases, which is
|
||||||
|
# how the daemon discovers guest IPs.
|
||||||
|
#
|
||||||
|
# Keep that plist free of XML comments. `plutil` accepts them, but codesign
|
||||||
|
# hands the file to AMFI's stricter parser, which rejects a `<!-- -->` block
|
||||||
|
# with "AMFIUnserializeXML: syntax error" -- and the bundle then signs with no
|
||||||
|
# entitlements at all, so every VM start fails at runtime.
|
||||||
|
ENTITLEMENTS:= Resources/gitea-macos-runner.entitlements
|
||||||
|
|
||||||
|
# Data files the tool reads at runtime. `GuestProvisioner`, `LaunchdService`,
|
||||||
|
# and `config init` each look in `Contents/Resources` first and only then fall
|
||||||
|
# back to repo-relative paths, so an installed .app that lacks these is a
|
||||||
|
# working binary with a broken `image build` / `service install` / `config init`.
|
||||||
|
APP_RESOURCES := Resources/provision.sh \
|
||||||
|
Resources/launchd.plist.template \
|
||||||
|
Resources/config.example.json
|
||||||
|
INSTALL_DIR := $(HOME)/Applications
|
||||||
|
LINK_PATH := /usr/local/bin/$(BIN_NAME)
|
||||||
|
|
||||||
|
# Release by default; `make dev` overrides to debug.
|
||||||
|
CONFIG ?= release
|
||||||
|
BIN_PATH = $(BUILD_DIR)/$(CONFIG)/$(BIN_NAME)
|
||||||
|
|
||||||
|
.PHONY: all build bundle sign dev test install uninstall clean help
|
||||||
|
|
||||||
|
# These targets are a pipeline, not independent work: `bundle` needs the binary
|
||||||
|
# `build` produced, and `sign` signs the tree `bundle` assembled — a signature
|
||||||
|
# that overtook the resource copy would not cover Contents/Resources, and the
|
||||||
|
# bundle would fail to launch. Expressing that as prerequisites is not an option
|
||||||
|
# because `dev` reuses `bundle` against a debug build it made itself, so serial
|
||||||
|
# execution is imposed instead.
|
||||||
|
.NOTPARALLEL:
|
||||||
|
|
||||||
|
all: build bundle sign
|
||||||
|
|
||||||
|
## build: compile the release binary for arm64
|
||||||
|
build:
|
||||||
|
swift build -c release --arch arm64
|
||||||
|
|
||||||
|
## bundle: assemble the minimal .app around the compiled binary
|
||||||
|
bundle:
|
||||||
|
@test -x "$(BIN_PATH)" || { echo "error: $(BIN_PATH) not built; run 'make build' (or 'make dev')"; exit 1; }
|
||||||
|
mkdir -p "$(MACOS_DIR)" "$(RES_DIR)"
|
||||||
|
cp "$(BIN_PATH)" "$(MACOS_DIR)/$(BIN_NAME)"
|
||||||
|
cp "$(INFO_PLIST)" "$(CONTENTS)/Info.plist"
|
||||||
|
cp $(APP_RESOURCES) "$(RES_DIR)/"
|
||||||
|
chmod +x "$(RES_DIR)/provision.sh"
|
||||||
|
|
||||||
|
## sign: ad-hoc sign the bundle with the virtualization entitlement
|
||||||
|
sign:
|
||||||
|
codesign --sign - --entitlements "$(ENTITLEMENTS)" --force "$(APP_DIR)"
|
||||||
|
@echo "--- entitlements ---"
|
||||||
|
@codesign -d --entitlements - "$(APP_DIR)" 2>/dev/null || true
|
||||||
|
|
||||||
|
## dev: debug build + bundle + sign (fast iteration loop)
|
||||||
|
dev:
|
||||||
|
swift build --arch arm64
|
||||||
|
$(MAKE) CONFIG=debug bundle
|
||||||
|
$(MAKE) sign
|
||||||
|
|
||||||
|
## test: run the unit test suite
|
||||||
|
test:
|
||||||
|
swift test
|
||||||
|
|
||||||
|
## install: copy the signed app to ~/Applications and link the CLI
|
||||||
|
install: all
|
||||||
|
mkdir -p "$(INSTALL_DIR)"
|
||||||
|
rm -rf "$(INSTALL_DIR)/$(APP_NAME).app"
|
||||||
|
cp -R "$(APP_DIR)" "$(INSTALL_DIR)/$(APP_NAME).app"
|
||||||
|
@if [ -w "$$(dirname $(LINK_PATH))" ]; then \
|
||||||
|
ln -sf "$(INSTALL_DIR)/$(APP_NAME).app/Contents/MacOS/$(BIN_NAME)" "$(LINK_PATH)"; \
|
||||||
|
echo "linked $(LINK_PATH)"; \
|
||||||
|
else \
|
||||||
|
echo "note: $$(dirname $(LINK_PATH)) not writable; skipping symlink."; \
|
||||||
|
echo " run: sudo ln -sf $(INSTALL_DIR)/$(APP_NAME).app/Contents/MacOS/$(BIN_NAME) $(LINK_PATH)"; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
## uninstall: remove the installed app and symlink
|
||||||
|
uninstall:
|
||||||
|
rm -rf "$(INSTALL_DIR)/$(APP_NAME).app"
|
||||||
|
@if [ -L "$(LINK_PATH)" ]; then rm -f "$(LINK_PATH)"; fi
|
||||||
|
|
||||||
|
## clean: remove all build products
|
||||||
|
clean:
|
||||||
|
rm -rf "$(BUILD_DIR)"
|
||||||
|
|
||||||
|
## help: list targets
|
||||||
|
help:
|
||||||
|
@grep -E '^## ' $(MAKEFILE_LIST) | sed 's/^## / /'
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
{
|
||||||
|
"originHash" : "b4c3eb0620d177330f884f26ad0d38bc155bb3bf0cc96c36dfbbf45f3f6547b1",
|
||||||
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "swift-argument-parser",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-argument-parser.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382",
|
||||||
|
"version" : "1.8.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-asn1",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-asn1.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "a9a5efd40eaf558a2bcd48d64b1d1646be686008",
|
||||||
|
"version" : "1.7.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-atomics",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-atomics.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34",
|
||||||
|
"version" : "1.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-collections",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-collections.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a",
|
||||||
|
"version" : "1.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-crypto",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-crypto.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "47d3869a7291f085c1fb9fb1e6d3b97a793f45c6",
|
||||||
|
"version" : "4.5.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-log",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-log.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "3ffafb9722d5d918c614feb496c8789a3b59d222",
|
||||||
|
"version" : "1.15.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-nio",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-nio.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "0b18836bd8b0162e7e17a995a3fbee20ed8f3b2b",
|
||||||
|
"version" : "2.101.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-nio-ssh",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-nio-ssh.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "3ec281496f28a3b6581afd946b759e2642f5cd8d",
|
||||||
|
"version" : "0.15.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-system",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-system.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "704705c5c51156ede21172a38654d522ce487074",
|
||||||
|
"version" : "1.8.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 3
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// swift-tools-version: 6.0
|
||||||
|
//
|
||||||
|
// Package.swift
|
||||||
|
// gitea-macos-runner
|
||||||
|
//
|
||||||
|
// A single-host daemon for an Apple Silicon Mac that watches a Gitea instance
|
||||||
|
// for queued Actions jobs requiring macOS, runs each in a fresh ephemeral macOS
|
||||||
|
// VM via Apple's Virtualization.framework, then destroys the VM.
|
||||||
|
//
|
||||||
|
// Layout
|
||||||
|
// ------
|
||||||
|
// * `RunnerCore` — portable, side-effect-light logic (config, Gitea API models
|
||||||
|
// and client, label matching, DHCP lease parsing, SSH transport, and the pure
|
||||||
|
// scheduling state machine). Deliberately free of `import Virtualization` so
|
||||||
|
// it also builds and unit-tests on Linux.
|
||||||
|
// * `RunnerHost` — everything that touches Apple's Virtualization.framework:
|
||||||
|
// VM bundles, image building, guest provisioning, and the orchestrator actor.
|
||||||
|
// macOS-only.
|
||||||
|
// * `gitea-macos-runner` — the CLI/daemon executable.
|
||||||
|
//
|
||||||
|
// SwiftPM auto-globs sources under each target directory; files are never
|
||||||
|
// enumerated here, so parallel workers can add files without touching this file.
|
||||||
|
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "gitea-macos-runner",
|
||||||
|
platforms: [
|
||||||
|
// Virtualization's ASIF disk support (`diskutil image create --format ASIF`)
|
||||||
|
// and the macOS 26 guest tooling are hard requirements. See docs/DESIGN.md.
|
||||||
|
.macOS("26.0")
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
.executable(name: "gitea-macos-runner", targets: ["gitea-macos-runner"]),
|
||||||
|
.library(name: "RunnerCore", targets: ["RunnerCore"]),
|
||||||
|
.library(name: "RunnerHost", targets: ["RunnerHost"]),
|
||||||
|
],
|
||||||
|
dependencies: [
|
||||||
|
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0"),
|
||||||
|
.package(url: "https://github.com/apple/swift-log.git", from: "1.6.0"),
|
||||||
|
.package(url: "https://github.com/apple/swift-nio.git", from: "2.76.0"),
|
||||||
|
.package(url: "https://github.com/apple/swift-nio-ssh.git", from: "0.9.0"),
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.target(
|
||||||
|
name: "RunnerCore",
|
||||||
|
dependencies: [
|
||||||
|
.product(name: "Logging", package: "swift-log"),
|
||||||
|
.product(name: "NIOCore", package: "swift-nio"),
|
||||||
|
.product(name: "NIOPosix", package: "swift-nio"),
|
||||||
|
.product(name: "NIOSSH", package: "swift-nio-ssh"),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
.target(
|
||||||
|
name: "RunnerHost",
|
||||||
|
dependencies: [
|
||||||
|
"RunnerCore",
|
||||||
|
.product(name: "Logging", package: "swift-log"),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
.executableTarget(
|
||||||
|
name: "gitea-macos-runner",
|
||||||
|
dependencies: [
|
||||||
|
"RunnerCore",
|
||||||
|
"RunnerHost",
|
||||||
|
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
||||||
|
.product(name: "Logging", package: "swift-log"),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "RunnerCoreTests",
|
||||||
|
dependencies: ["RunnerCore"]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# gitea-macos-runner
|
||||||
|
|
||||||
|
A Swift daemon that gives a self-hosted Gitea instance on-demand macOS CI capacity from a single
|
||||||
|
Apple Silicon Mac. It polls Gitea for queued Actions jobs that request macOS, boots a fresh
|
||||||
|
ephemeral macOS VM through Apple's Virtualization.framework for each one, lets Gitea's own
|
||||||
|
`gitea-runner` execute exactly one job inside that VM, and then destroys the VM. Nothing from a
|
||||||
|
job survives into the next: every build starts from an identical, freshly cloned base image.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
```
|
||||||
|
Gitea host daemon ephemeral VM
|
||||||
|
│ │ │
|
||||||
|
│ GET /api/v1/admin/actions/ │ │
|
||||||
|
│ jobs?status=queued │ │
|
||||||
|
│◄────────────────────────────────┤ poll every 5s │
|
||||||
|
│ [{id, labels: ["macos-arm64"]}]│ │
|
||||||
|
├────────────────────────────────►│ │
|
||||||
|
│ │ APFS clone base image (instant) │
|
||||||
|
│ ├──────────────────────────────────►│
|
||||||
|
│ │ boot headless, NAT networking │
|
||||||
|
│ │ resolve IP (/var/db/dhcpd_leases) │
|
||||||
|
│ │ ssh in │
|
||||||
|
│ ├──────────────────────────────────►│
|
||||||
|
│ │ gitea-runner register --ephemeral │
|
||||||
|
│◄────────────────────────────────┼───────────────────────────────────┤
|
||||||
|
│ runner appears, job dispatched │ │
|
||||||
|
├─────────────────────────────────┼──────────────────────────────────►│
|
||||||
|
│ │ gitea-runner daemon │
|
||||||
|
│ job completes; server refuses │ runs ONE job │
|
||||||
|
│ a second job and deletes the │ │
|
||||||
|
│ ephemeral registration │ │
|
||||||
|
│ │ destroy VM + clone │
|
||||||
|
│ ├───────────────────────────────► ✗ │
|
||||||
|
```
|
||||||
|
|
||||||
|
A background reconcile loop (every 5 minutes) deletes runner registrations left behind by VMs that
|
||||||
|
were killed uncleanly, so the Gitea runner list does not accumulate dead entries.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- **Apple Silicon Mac.** Virtualization.framework macOS guests are ARM-only.
|
||||||
|
- **macOS 26 or newer on the host; macOS 27 or newer strongly recommended.** The automated image
|
||||||
|
builder uses `VZMacGuestProvisioningOptions` (macOS 27) to create the admin user and skip Setup
|
||||||
|
Assistant. Both host *and* guest must be 27+ — an older guest silently ignores the options and
|
||||||
|
stalls at Setup Assistant.
|
||||||
|
- **Disk:** ~60 GB free for a vanilla image (IPSW ~15 GB plus a sparse ASIF disk); 140 GB+ if you
|
||||||
|
provision Xcode.
|
||||||
|
- **RAM:** 16 GB minimum. Each guest defaults to 8 GB, and macOS caps the host at **2 concurrent
|
||||||
|
macOS VMs** regardless of hardware.
|
||||||
|
- **Gitea 1.25 or newer** (1.26+ recommended). 1.25 added the admin jobs API with the `labels`
|
||||||
|
field this daemon depends on.
|
||||||
|
- A code-signed app bundle. The binary must carry the `com.apple.security.virtualization`
|
||||||
|
entitlement; ad-hoc signing (`codesign -s -`) is sufficient, so no paid Apple developer account
|
||||||
|
is required.
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone <this repo> && cd gitea-macos-runner
|
||||||
|
|
||||||
|
# Build, bundle (binary + Resources + Info.plist), ad-hoc sign with the
|
||||||
|
# virtualization entitlement, then copy to ~/Applications and symlink the CLI
|
||||||
|
# into /usr/local/bin.
|
||||||
|
make install # = make build bundle sign, then the install step
|
||||||
|
|
||||||
|
# Write a starter config to ~/.config/gitea-macos-runner/config.json
|
||||||
|
gitea-macos-runner config init
|
||||||
|
|
||||||
|
# Fill in gitea.instanceURL, gitea.adminTokenFile, and the registration token settings.
|
||||||
|
$EDITOR "$(gitea-macos-runner config path)"
|
||||||
|
|
||||||
|
# Read the config back with secrets redacted, to confirm it parses and validates.
|
||||||
|
gitea-macos-runner config show
|
||||||
|
|
||||||
|
# Verify entitlements, config, Gitea reachability, disk space, and host/guest versions.
|
||||||
|
gitea-macos-runner doctor
|
||||||
|
|
||||||
|
# Build a base image from an IPSW (long — installs macOS, then provisions the guest).
|
||||||
|
# The image is named "default", which is also what `daemon` and `vm boot` look for.
|
||||||
|
gitea-macos-runner image build --ipsw ~/Downloads/UniversalMac_27.0_*.ipsw
|
||||||
|
|
||||||
|
# Optional: add Xcode to the image.
|
||||||
|
gitea-macos-runner image provision default --xcode-xip ~/Downloads/Xcode_XX.xip
|
||||||
|
|
||||||
|
# Install and start the LaunchAgent (runs in your GUI login session — not a LaunchDaemon).
|
||||||
|
gitea-macos-runner service install
|
||||||
|
gitea-macos-runner service status
|
||||||
|
```
|
||||||
|
|
||||||
|
Then push a workflow that targets the runner:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .gitea/workflows/macos.yml
|
||||||
|
name: macOS build
|
||||||
|
on: [push]
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: macos-arm64
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- run: sw_vers && swift build
|
||||||
|
```
|
||||||
|
|
||||||
|
The daemon picks the job up within one poll interval, boots a VM, and tears it down when the job
|
||||||
|
finishes.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [docs/setup.md](docs/setup.md) — full Gitea-side and host-side walkthrough, config reference,
|
||||||
|
image building, service installation, verification.
|
||||||
|
- [docs/security.md](docs/security.md) — threat model, isolation boundaries, token handling.
|
||||||
|
- [docs/troubleshooting.md](docs/troubleshooting.md) — symptom → cause → fix.
|
||||||
|
|
||||||
|
## Command reference
|
||||||
|
|
||||||
|
Every subcommand accepts the global options `--config PATH` (`-c`, default
|
||||||
|
`~/.config/gitea-macos-runner/config.json`) and `--verbose`.
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `daemon [--image NAME] [--once]` | The scheduler loop. Normally started by launchd via `service install`. `--image` defaults to `default`; `--once` runs a single scheduling tick and exits. |
|
||||||
|
| `image build [--name NAME] [--ipsw PATH] [--disk-gb N]` | Install macOS into a new base image and provision it. `--name` defaults to `default`; without `--ipsw` the latest supported restore image is downloaded. `--disk-gb` overrides `guest.diskGB`. |
|
||||||
|
| `image provision NAME [--xcode-xip PATH]` | Re-run guest provisioning on an existing image; optionally install Xcode from a `.xip`. |
|
||||||
|
| `image list` | List base images in the store. |
|
||||||
|
| `image delete NAME [--force]` | Delete a base image and its disk. `--force` (`-f`) skips the confirmation prompt. |
|
||||||
|
| `vm boot [--image NAME] [--slot N] [--keep]` | Clone an image, boot it, print its IP, and wait for Ctrl-C. `--slot` picks which persistent per-slot MAC to use (default `0`); `--keep` leaves the clone on disk. |
|
||||||
|
| `vm list` | List ephemeral VM clones on disk. |
|
||||||
|
| `service install [--executable PATH]` | Write and load `~/Library/LaunchAgents/xyz.blakeslee.gitea-macos-runner.plist`. |
|
||||||
|
| `service uninstall` | Unload the LaunchAgent and remove its plist. |
|
||||||
|
| `service status` | Report LaunchAgent installation and run state. |
|
||||||
|
| `doctor [--json] [--no-fail]` | Preflight checks. `--json` emits machine-readable results; `--no-fail` exits zero even when checks fail. |
|
||||||
|
| `config init [--force] [--instance-url URL]` | Write the annotated example config. `--force` (`-f`) overwrites an existing file. |
|
||||||
|
| `config show` | Print the effective configuration with secrets redacted. |
|
||||||
|
| `config path` | Print the configuration file path. |
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>xyz.blakeslee.gitea-macos-runner</string>
|
||||||
|
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>GiteaMacosRunner</string>
|
||||||
|
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Gitea macOS Runner</string>
|
||||||
|
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>gitea-macos-runner</string>
|
||||||
|
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>0.1.0</string>
|
||||||
|
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
An agent app: no Dock icon, no menu bar. The daemon still needs a real
|
||||||
|
NSApplication run loop for Virtualization.framework, but nothing about it
|
||||||
|
should be user-visible. Mirrored at runtime by
|
||||||
|
NSApplication.shared.setActivationPolicy(.prohibited).
|
||||||
|
-->
|
||||||
|
<key>LSUIElement</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>26.0</string>
|
||||||
|
|
||||||
|
<key>NSHumanReadableCopyright</key>
|
||||||
|
<string></string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"_comment": "Example configuration for gitea-macos-runner. Copy to ~/.config/gitea-macos-runner/config.json and edit. Keys beginning with an underscore are comments and are ignored by the loader.",
|
||||||
|
|
||||||
|
"gitea": {
|
||||||
|
"_comment": "How to reach Gitea and how to authenticate. The token must belong to a Gitea ADMIN: every endpoint used lives under /api/v1/admin/actions/.",
|
||||||
|
"instanceURL": "https://gitea.example.com",
|
||||||
|
|
||||||
|
"_comment_adminToken": "Admin API token. Set EXACTLY ONE of adminToken and adminTokenFile: setting both, or neither, is rejected at load. Prefer adminTokenFile so the secret is not sitting in a world-readable JSON file.",
|
||||||
|
"adminTokenFile": "~/.config/gitea-macos-runner/admin-token",
|
||||||
|
|
||||||
|
"_comment_registrationToken": "The shared runner registration token. IMPORTANT: registration tokens are REUSABLE and scope-wide, and minting a new one INVALIDATES every prior token for that scope. Never pre-generate one per VM. The recommended setup is to seed a fixed token server-side with GITEA_RUNNER_REGISTRATION_TOKEN and point registrationTokenFile at a copy of it.",
|
||||||
|
"registrationTokenFile": "~/.config/gitea-macos-runner/registration-token",
|
||||||
|
|
||||||
|
"_comment_fetchViaAPI": "When no static registration token is configured, fetch one from POST /api/v1/admin/actions/runners/registration-token. Off by default: that endpoint returns the scope's active token, and any behaviour change that made it mint a fresh one would invalidate tokens held by runners elsewhere.",
|
||||||
|
"fetchRegistrationTokenViaAPI": false
|
||||||
|
},
|
||||||
|
|
||||||
|
"runner": {
|
||||||
|
"_comment": "Identity of the ephemeral runners registered inside each guest.",
|
||||||
|
|
||||||
|
"_comment_labels": "BARE label names, matched case-sensitively against a job's runs-on. The ':host' schema suffix is added only when calling `gitea-runner register --labels`; the server never stores it.",
|
||||||
|
"labels": ["macos-arm64"],
|
||||||
|
|
||||||
|
"_comment_namePrefix": "Prefix for generated runner names. Each VM registers as <prefix><uuid>, globally unique, which is what lets the reconcile loop identify and delete rows orphaned by an unclean VM death.",
|
||||||
|
"namePrefix": "macos-vm-",
|
||||||
|
|
||||||
|
"_comment_download": "Release asset for the gitea-runner binary installed into the guest. {version} is substituted. The binary is v3.x, renamed from act_runner and published from gitea.com/gitea/runner.",
|
||||||
|
"runnerDownloadURL": "https://gitea.com/gitea/runner/releases/download/v{version}/gitea-runner-{version}-darwin-arm64",
|
||||||
|
"version": "3.0.2"
|
||||||
|
},
|
||||||
|
|
||||||
|
"scheduler": {
|
||||||
|
"_comment": "Polling cadence, concurrency, and the timeouts that bound a stuck VM.",
|
||||||
|
|
||||||
|
"_comment_maxConcurrentVMs": "Hard-clamped to 2. Apple's kernel allows at most two concurrent macOS guests per host; a third start() fails with VZError.virtualMachineLimitExceeded.",
|
||||||
|
"maxConcurrentVMs": 2,
|
||||||
|
|
||||||
|
"pollIntervalSeconds": 5,
|
||||||
|
|
||||||
|
"_comment_reconcile": "How often to sweep Gitea for orphaned runner rows. Gitea itself only sweeps runner rows at midnight, and never sweeps a runner that claimed no task, so this loop is not optional.",
|
||||||
|
"reconcileIntervalSeconds": 300,
|
||||||
|
|
||||||
|
"_comment_jobTimeout": "Wall-clock ceiling on a single job before its VM is destroyed. Should be comfortably under Gitea's own ABANDONED_JOB_TIMEOUT (default 24h).",
|
||||||
|
"jobTimeoutMinutes": 120,
|
||||||
|
|
||||||
|
"_comment_bootTimeout": "Ceiling on clone + boot + DHCP lease + SSH readiness before the slot is declared dead and recycled.",
|
||||||
|
"bootTimeoutSeconds": 300
|
||||||
|
},
|
||||||
|
|
||||||
|
"guest": {
|
||||||
|
"_comment": "Shape of each guest VM and the credentials used to reach it over SSH. These credentials only ever traverse the host-private NAT link between this Mac and its own ephemeral guests.",
|
||||||
|
"username": "admin",
|
||||||
|
"password": "admin",
|
||||||
|
"cpuCount": 4,
|
||||||
|
"memoryGB": 8,
|
||||||
|
|
||||||
|
"_comment_diskGB": "Nominal disk size. With the ASIF sparse format this is a ceiling, not an allocation.",
|
||||||
|
"diskGB": 64
|
||||||
|
},
|
||||||
|
|
||||||
|
"storage": {
|
||||||
|
"_comment": "Where images, ephemeral clones, IPSWs, and host state live. Images and clones must share one APFS volume: cloning relies on copy-on-write, which requires the same volume.",
|
||||||
|
"storeDir": "~/Library/Application Support/gitea-macos-runner",
|
||||||
|
|
||||||
|
"_comment_minFree": "Refuse to clone a VM when the store volume has less than this free. CoW clones start nearly free but grow with every guest write, so keep this well above one clone's nominal size.",
|
||||||
|
"minFreeDiskGB": 20
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.virtualization</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<!--
|
||||||
|
Template rendered by LaunchdService.renderPlist(executablePath:arguments:).
|
||||||
|
Placeholders: {{LABEL}}, {{PROGRAM}}, {{ARGUMENTS}}, {{STDOUT_PATH}}, {{STDERR_PATH}}
|
||||||
|
|
||||||
|
This is a LaunchAgent — it MUST be installed to ~/Library/LaunchAgents and run
|
||||||
|
in the logged-in user's GUI session, never to /Library/LaunchDaemons.
|
||||||
|
Virtualization.framework needs a GUI session, and macOS 15+ additionally
|
||||||
|
refuses to start a VM unless login.keychain is unlocked, which only happens
|
||||||
|
after a graphical login. Configure the host for automatic login.
|
||||||
|
|
||||||
|
{{PROGRAM}} must point at the executable inside the signed .app bundle; the
|
||||||
|
com.apple.security.virtualization entitlement does not survive on a bare
|
||||||
|
binary copied out of it.
|
||||||
|
-->
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>{{LABEL}}</string>
|
||||||
|
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>{{PROGRAM}}</string>
|
||||||
|
{{ARGUMENTS}}
|
||||||
|
</array>
|
||||||
|
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<dict>
|
||||||
|
<key>SuccessfulExit</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
|
||||||
|
<!-- Back off rather than spin if the daemon exits immediately at startup. -->
|
||||||
|
<key>ThrottleInterval</key>
|
||||||
|
<integer>30</integer>
|
||||||
|
|
||||||
|
<key>ProcessType</key>
|
||||||
|
<string>Interactive</string>
|
||||||
|
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>{{STDOUT_PATH}}</string>
|
||||||
|
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>{{STDERR_PATH}}</string>
|
||||||
|
|
||||||
|
<key>EnvironmentVariables</key>
|
||||||
|
<dict>
|
||||||
|
<key>PATH</key>
|
||||||
|
<string>/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
Executable
+397
@@ -0,0 +1,397 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# provision.sh — run once inside a freshly installed macOS guest.
|
||||||
|
#
|
||||||
|
# Uploaded to /tmp/provision.sh by GuestProvisioner.runProvisionScript and run
|
||||||
|
# under sudo. Non-secret values arrive via the environment (GUEST_USER,
|
||||||
|
# GITEA_HOST) rather than as arguments, since arguments are visible to every
|
||||||
|
# process on the guest via ps. The account password is never passed here at all:
|
||||||
|
# it is fed to `sudo -S` on stdin from a mode-0600 file, which this script then
|
||||||
|
# detaches from (see `exec </dev/null` below).
|
||||||
|
#
|
||||||
|
# Recognised environment:
|
||||||
|
# GUEST_USER (required) the runner account to configure.
|
||||||
|
# GITEA_HOST (optional) hostname of the Gitea instance, pre-seeded into
|
||||||
|
# /etc/ssh/ssh_known_hosts alongside github.com.
|
||||||
|
# INSTALL_CLT (optional) "0" skips the Command Line Tools install.
|
||||||
|
#
|
||||||
|
# Must be idempotent: `image provision NAME` re-runs it against an existing image.
|
||||||
|
# Every step below is either a full-file overwrite of a file this script owns or
|
||||||
|
# a guarded edit, so a second run converges to the same state.
|
||||||
|
#
|
||||||
|
# The last line of stdout on success is the marker PROVISION_OK, which
|
||||||
|
# GuestProvisioner asserts on. Individual hardening steps are best-effort and
|
||||||
|
# warn rather than abort: a guest that indexes with Spotlight still runs jobs,
|
||||||
|
# whereas a guest without passwordless sudo does not, so only the load-bearing
|
||||||
|
# steps are fatal.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# We are invoked as `sudo -S ... /bin/bash /tmp/provision.sh < /tmp/.gmr-auth`,
|
||||||
|
# and that file holds the account password for sudo's own prompt. sudo consumes
|
||||||
|
# that line only if it actually prompts — on a re-run the sudoers drop-in this
|
||||||
|
# script installs is already in place, so it does not, and the password would be
|
||||||
|
# left at the head of OUR stdin for the first command in here that reads it
|
||||||
|
# (`softwareupdate` being the realistic candidate). Detach immediately: nothing
|
||||||
|
# below this line is interactive.
|
||||||
|
exec </dev/null
|
||||||
|
|
||||||
|
GUEST_USER="${GUEST_USER:?GUEST_USER must be set}"
|
||||||
|
GITEA_HOST="${GITEA_HOST:-}"
|
||||||
|
INSTALL_CLT="${INSTALL_CLT:-1}"
|
||||||
|
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
echo "provision.sh: must run as root (invoke via sudo)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log() { echo "provision.sh: $*"; }
|
||||||
|
warn() { echo "provision.sh: WARNING: $*" >&2; }
|
||||||
|
|
||||||
|
# macOS ships no timeout(1) — it is GNU coreutils, not BSD. Several steps here
|
||||||
|
# can block forever (softwareupdate against an unreachable server, ssh-keyscan
|
||||||
|
# against a firewalled host), and a hung provision looks exactly like a hung VM
|
||||||
|
# from the host side, so they all get bounded by hand.
|
||||||
|
#
|
||||||
|
# Usage: run_with_timeout SECONDS cmd args... → 124 on timeout.
|
||||||
|
run_with_timeout() {
|
||||||
|
local secs="$1"
|
||||||
|
shift
|
||||||
|
"$@" &
|
||||||
|
local pid=$!
|
||||||
|
local waited=0
|
||||||
|
while kill -0 "$pid" 2>/dev/null; do
|
||||||
|
if [ "$waited" -ge "$secs" ]; then
|
||||||
|
kill -TERM "$pid" 2>/dev/null || true
|
||||||
|
sleep 2
|
||||||
|
kill -KILL "$pid" 2>/dev/null || true
|
||||||
|
wait "$pid" 2>/dev/null || true
|
||||||
|
return 124
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
waited=$((waited + 1))
|
||||||
|
done
|
||||||
|
wait "$pid"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run a command as the runner account, in its own login context.
|
||||||
|
as_guest_user() {
|
||||||
|
launchctl asuser "$(id -u "$GUEST_USER")" sudo -u "$GUEST_USER" "$@" 2>/dev/null \
|
||||||
|
|| sudo -u "$GUEST_USER" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 1. Passwordless sudo for the runner account
|
||||||
|
#
|
||||||
|
# This comes first on purpose: every later step in this script and every later
|
||||||
|
# command GuestProvisioner issues assumes `sudo -n` works. Validated with
|
||||||
|
# `visudo -cf` on a temporary file BEFORE moving it into place — a syntax error
|
||||||
|
# in sudoers locks the account out of sudo entirely, and there is no recovery in
|
||||||
|
# a headless VM.
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
log "configuring passwordless sudo for ${GUEST_USER}"
|
||||||
|
SUDOERS_TMP="$(mktemp /tmp/gmr-sudoers.XXXXXX)"
|
||||||
|
cat >"$SUDOERS_TMP" <<EOF
|
||||||
|
# Managed by gitea-macos-runner provision.sh. Do not edit by hand.
|
||||||
|
${GUEST_USER} ALL=(ALL) NOPASSWD: ALL
|
||||||
|
Defaults:${GUEST_USER} !requiretty
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if visudo -cf "$SUDOERS_TMP" >/dev/null 2>&1; then
|
||||||
|
mkdir -p /etc/sudoers.d
|
||||||
|
chmod 755 /etc/sudoers.d
|
||||||
|
install -m 0440 -o root -g wheel "$SUDOERS_TMP" /etc/sudoers.d/gitea-macos-runner
|
||||||
|
rm -f "$SUDOERS_TMP"
|
||||||
|
log "passwordless sudo installed at /etc/sudoers.d/gitea-macos-runner"
|
||||||
|
else
|
||||||
|
rm -f "$SUDOERS_TMP"
|
||||||
|
echo "provision.sh: generated sudoers drop-in failed validation; refusing to install it" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 2. /usr/local/bin and a PATH that non-interactive SSH sessions actually see
|
||||||
|
#
|
||||||
|
# On a clean arm64 macOS install /usr/local does not exist at all, and
|
||||||
|
# `installer -pkg node.pkg` plus the gitea-runner binary both land there.
|
||||||
|
#
|
||||||
|
# The PATH half matters more than it looks: an `ssh host command` invocation
|
||||||
|
# runs a NON-login, NON-interactive shell, so /etc/zprofile (which is where
|
||||||
|
# path_helper injects /usr/local/bin) is never sourced. Without this the
|
||||||
|
# orchestrator's `gitea-runner …` invocation fails with "command not found" even
|
||||||
|
# though the binary is installed. /etc/zshenv is the one file zsh reads for
|
||||||
|
# every invocation, login or not.
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
log "ensuring /usr/local/bin exists and is on PATH for non-login shells"
|
||||||
|
mkdir -p /usr/local/bin
|
||||||
|
chown root:wheel /usr/local /usr/local/bin
|
||||||
|
chmod 755 /usr/local /usr/local/bin
|
||||||
|
|
||||||
|
ZSHENV_MARKER="# gitea-macos-runner: ensure /usr/local/bin on PATH"
|
||||||
|
if [ ! -f /etc/zshenv ] || ! grep -qF "$ZSHENV_MARKER" /etc/zshenv 2>/dev/null; then
|
||||||
|
cat >>/etc/zshenv <<EOF
|
||||||
|
|
||||||
|
${ZSHENV_MARKER}
|
||||||
|
case ":\$PATH:" in
|
||||||
|
*:/usr/local/bin:*) ;;
|
||||||
|
*) export PATH="/usr/local/bin:\$PATH" ;;
|
||||||
|
esac
|
||||||
|
EOF
|
||||||
|
chmod 644 /etc/zshenv
|
||||||
|
fi
|
||||||
|
|
||||||
|
# bash only reads a startup file for non-interactive shells via BASH_ENV, so
|
||||||
|
# /etc/bashrc is not enough; anything invoking bash non-interactively gets the
|
||||||
|
# PATH from its parent. Still worth setting for interactive debugging sessions.
|
||||||
|
BASHRC_MARKER="# gitea-macos-runner: ensure /usr/local/bin on PATH"
|
||||||
|
if [ ! -f /etc/bashrc ] || ! grep -qF "$BASHRC_MARKER" /etc/bashrc 2>/dev/null; then
|
||||||
|
cat >>/etc/bashrc <<EOF
|
||||||
|
|
||||||
|
${BASHRC_MARKER}
|
||||||
|
case ":\$PATH:" in
|
||||||
|
*:/usr/local/bin:*) ;;
|
||||||
|
*) export PATH="/usr/local/bin:\$PATH" ;;
|
||||||
|
esac
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 3. Never sleep, never lock
|
||||||
|
#
|
||||||
|
# A guest that sleeps mid-job stops answering SSH and the job dies at jobTimeout
|
||||||
|
# with no useful diagnostic. `systemsetup` is the blunt instrument and is
|
||||||
|
# best-effort (it needs Full Disk Access in some configurations and returns
|
||||||
|
# nonzero without it); `pmset` is the one that actually has to work.
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
log "disabling sleep, display sleep, and the screen saver"
|
||||||
|
systemsetup -setsleep Off >/dev/null 2>&1 || warn "systemsetup -setsleep failed (continuing; pmset below is authoritative)"
|
||||||
|
systemsetup -setcomputersleep Off >/dev/null 2>&1 || true
|
||||||
|
systemsetup -setdisplaysleep Off >/dev/null 2>&1 || true
|
||||||
|
systemsetup -setharddisksleep Off >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
pmset -a sleep 0 displaysleep 0 disksleep 0 >/dev/null 2>&1 || warn "pmset sleep settings failed"
|
||||||
|
# standby/autopoweroff/powernap only exist on some models; ignore failures.
|
||||||
|
pmset -a standby 0 >/dev/null 2>&1 || true
|
||||||
|
pmset -a autopoweroff 0 >/dev/null 2>&1 || true
|
||||||
|
pmset -a powernap 0 >/dev/null 2>&1 || true
|
||||||
|
pmset -a womp 0 >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
# Screen saver idle time 0 == never. -currentHost because the screensaver
|
||||||
|
# domain is per-host, and as the user because it is a per-user preference.
|
||||||
|
as_guest_user defaults -currentHost write com.apple.screensaver idleTime -int 0 >/dev/null 2>&1 \
|
||||||
|
|| warn "could not disable the screen saver idle timer"
|
||||||
|
as_guest_user defaults write com.apple.screensaver askForPassword -int 0 >/dev/null 2>&1 || true
|
||||||
|
as_guest_user defaults write com.apple.screensaver askForPasswordDelay -int 0 >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
# Auto-login keeps the guest's GUI session alive after a reboot, which some
|
||||||
|
# toolchains (simulators, codesign against the login keychain) depend on.
|
||||||
|
# VZMacGuestProvisioningOptions.logsInAutomatically already sets this on first
|
||||||
|
# boot; re-asserting it here keeps `image provision` runs consistent.
|
||||||
|
defaults write /Library/Preferences/com.apple.loginwindow autoLoginUser -string "$GUEST_USER" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 4. Disable Spotlight indexing
|
||||||
|
#
|
||||||
|
# Indexing a checkout and a build directory is pure waste in a VM that is
|
||||||
|
# destroyed after one job, and it competes for I/O with the build itself.
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
log "disabling Spotlight indexing"
|
||||||
|
mdutil -a -i off >/dev/null 2>&1 || warn "mdutil -a -i off failed"
|
||||||
|
# Drop any index that the installer already built.
|
||||||
|
mdutil -a -E >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 5. Raise file descriptor limits
|
||||||
|
#
|
||||||
|
# The stock 256 soft limit is exhausted by npm installs and by Xcode builds of
|
||||||
|
# any size, and the failure mode ("EMFILE: too many open files") reads like a
|
||||||
|
# bug in the job rather than in the image.
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
log "raising the maxfiles limit"
|
||||||
|
cat >/Library/LaunchDaemons/limit.maxfiles.plist <<'EOF'
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>limit.maxfiles</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>launchctl</string>
|
||||||
|
<string>limit</string>
|
||||||
|
<string>maxfiles</string>
|
||||||
|
<string>65536</string>
|
||||||
|
<string>200000</string>
|
||||||
|
</array>
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
<key>ServiceIPC</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
EOF
|
||||||
|
chown root:wheel /Library/LaunchDaemons/limit.maxfiles.plist
|
||||||
|
chmod 644 /Library/LaunchDaemons/limit.maxfiles.plist
|
||||||
|
# Already-loaded is not an error on a re-run, hence the `|| true`.
|
||||||
|
launchctl load -w /Library/LaunchDaemons/limit.maxfiles.plist >/dev/null 2>&1 || true
|
||||||
|
# Apply now too, so this boot benefits without a restart.
|
||||||
|
launchctl limit maxfiles 65536 200000 >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 6. Pre-seed known_hosts
|
||||||
|
#
|
||||||
|
# Without this, a git+ssh checkout blocks forever on an interactive host-key
|
||||||
|
# confirmation that nothing will ever answer — and it blocks *silently*, so the
|
||||||
|
# job just sits there until jobTimeout.
|
||||||
|
#
|
||||||
|
# Seeded system-wide (/etc/ssh/ssh_known_hosts) rather than into the user's
|
||||||
|
# ~/.ssh, so it survives a job that resets the home directory.
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
log "pre-seeding SSH host keys"
|
||||||
|
KNOWN_HOSTS=/etc/ssh/ssh_known_hosts
|
||||||
|
mkdir -p /etc/ssh
|
||||||
|
touch "$KNOWN_HOSTS"
|
||||||
|
chmod 644 "$KNOWN_HOSTS"
|
||||||
|
|
||||||
|
seed_host_key() {
|
||||||
|
local host="$1"
|
||||||
|
[ -n "$host" ] || return 0
|
||||||
|
# Already present? Nothing to do — keeps re-runs from growing the file.
|
||||||
|
if ssh-keygen -F "$host" -f "$KNOWN_HOSTS" >/dev/null 2>&1; then
|
||||||
|
log "host key for ${host} already present"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
local tmp
|
||||||
|
tmp="$(mktemp /tmp/gmr-keyscan.XXXXXX)"
|
||||||
|
if run_with_timeout 30 ssh-keyscan -t rsa,ecdsa,ed25519 "$host" >"$tmp" 2>/dev/null && [ -s "$tmp" ]; then
|
||||||
|
cat "$tmp" >>"$KNOWN_HOSTS"
|
||||||
|
log "seeded host key for ${host}"
|
||||||
|
else
|
||||||
|
warn "ssh-keyscan for ${host} failed or timed out; git+ssh checkouts against it may hang"
|
||||||
|
fi
|
||||||
|
rm -f "$tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
seed_host_key github.com
|
||||||
|
seed_host_key "$GITEA_HOST"
|
||||||
|
|
||||||
|
# Belt and braces: if a keyscan failed, a checkout should fail fast rather than
|
||||||
|
# block on a prompt no one can answer.
|
||||||
|
SSHCONF_MARKER="# gitea-macos-runner: never prompt for unknown host keys"
|
||||||
|
if [ ! -f /etc/ssh/ssh_config ] || ! grep -qF "$SSHCONF_MARKER" /etc/ssh/ssh_config 2>/dev/null; then
|
||||||
|
cat >>/etc/ssh/ssh_config <<EOF
|
||||||
|
|
||||||
|
${SSHCONF_MARKER}
|
||||||
|
Host *
|
||||||
|
StrictHostKeyChecking accept-new
|
||||||
|
BatchMode yes
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 7. Command Line Tools
|
||||||
|
#
|
||||||
|
# Vanilla macOS ships /usr/bin/git as a shim that, on first invocation, pops a
|
||||||
|
# GUI "install command line developer tools" dialog and blocks. In a headless VM
|
||||||
|
# nothing answers that dialog, so `git --version` hangs until the job times out.
|
||||||
|
#
|
||||||
|
# The touch-file below is how softwareupdate is told to surface CLT packages in
|
||||||
|
# its list; this is a widely used community technique rather than a documented
|
||||||
|
# Apple interface, so it is treated as best-effort. If it does not work, the
|
||||||
|
# fallback is `image provision NAME --xcode-xip PATH`, which installs a full
|
||||||
|
# Xcode (and with it a real git).
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
install_command_line_tools() {
|
||||||
|
if pkgutil --pkg-info=com.apple.pkg.CLTools_Executables >/dev/null 2>&1; then
|
||||||
|
log "Command Line Tools already installed"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [ -x /Applications/Xcode.app/Contents/Developer/usr/bin/git ]; then
|
||||||
|
log "Xcode is installed; skipping Command Line Tools"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "installing Command Line Tools (this can take several minutes)"
|
||||||
|
local sentinel=/tmp/.com.apple.dt.CommandLineTools.installondemand.in-progress
|
||||||
|
touch "$sentinel"
|
||||||
|
|
||||||
|
local label
|
||||||
|
label="$(softwareupdate -l 2>/dev/null \
|
||||||
|
| sed -n 's/^.*Label: \(Command Line Tools.*\)$/\1/p' \
|
||||||
|
| tail -1 || true)"
|
||||||
|
|
||||||
|
local rc=0
|
||||||
|
if [ -n "$label" ]; then
|
||||||
|
log "found update label: ${label}"
|
||||||
|
run_with_timeout 2700 softwareupdate -i "$label" --verbose || rc=$?
|
||||||
|
else
|
||||||
|
warn "softwareupdate listed no Command Line Tools package"
|
||||||
|
rc=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$sentinel"
|
||||||
|
|
||||||
|
if [ "$rc" -eq 124 ]; then
|
||||||
|
warn "Command Line Tools install timed out"
|
||||||
|
elif [ "$rc" -ne 0 ]; then
|
||||||
|
warn "Command Line Tools install failed (exit ${rc})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -d /Library/Developer/CommandLineTools ]; then
|
||||||
|
xcode-select --switch /Library/Developer/CommandLineTools >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if pkgutil --pkg-info=com.apple.pkg.CLTools_Executables >/dev/null 2>&1; then
|
||||||
|
log "Command Line Tools installed"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$INSTALL_CLT" != "0" ]; then
|
||||||
|
if ! install_command_line_tools; then
|
||||||
|
warn "Command Line Tools are not installed. git will not work in this guest."
|
||||||
|
warn "Re-run with: image provision <NAME> --xcode-xip /path/to/Xcode.xip"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log "INSTALL_CLT=0; skipping Command Line Tools"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 8. Sanity checks
|
||||||
|
#
|
||||||
|
# Node.js and the gitea-runner binary are installed separately by
|
||||||
|
# GuestProvisioner (host-side download, then upload), not here, so their absence
|
||||||
|
# at this point is expected and only reported.
|
||||||
|
#
|
||||||
|
# NOTE for future edits: do NOT write a gitea-runner config.yaml that sets
|
||||||
|
# runner.labels. That key silently overrides the --labels passed at
|
||||||
|
# registration, and the runner would advertise labels the server never matches.
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
log "running sanity checks"
|
||||||
|
export PATH="/usr/local/bin:$PATH"
|
||||||
|
|
||||||
|
if ! command -v bash >/dev/null 2>&1; then
|
||||||
|
echo "provision.sh: bash is missing — this guest cannot run Gitea Actions" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
log "bash: $(bash --version | head -1)"
|
||||||
|
|
||||||
|
# Guarded by the CLT check so this cannot be the call that hangs on the GUI
|
||||||
|
# installer dialog.
|
||||||
|
if pkgutil --pkg-info=com.apple.pkg.CLTools_Executables >/dev/null 2>&1 \
|
||||||
|
|| [ -x /Applications/Xcode.app/Contents/Developer/usr/bin/git ]; then
|
||||||
|
if run_with_timeout 60 git --version >/dev/null 2>&1; then
|
||||||
|
log "git: $(git --version)"
|
||||||
|
else
|
||||||
|
warn "git is present but did not respond within 60s"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "git is unavailable (no Command Line Tools); host-side verifyToolchain will fail the build"
|
||||||
|
fi
|
||||||
|
|
||||||
|
command -v node >/dev/null 2>&1 && log "node: $(node --version)" || log "node: not installed yet (host installs it next)"
|
||||||
|
command -v gitea-runner >/dev/null 2>&1 && log "gitea-runner: present" || log "gitea-runner: not installed yet (host installs it next)"
|
||||||
|
|
||||||
|
log "host-side steps remaining: Node.js, gitea-runner binary"
|
||||||
|
echo "PROVISION_OK"
|
||||||
@@ -0,0 +1,733 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The runner's on-disk configuration, loaded from
|
||||||
|
/// `~/.config/gitea-macos-runner/config.json`.
|
||||||
|
///
|
||||||
|
/// Every section has defaults, and decoding tolerates missing keys, so a minimal
|
||||||
|
/// config only needs `gitea.instanceURL` plus a way to obtain tokens. See
|
||||||
|
/// `Resources/config.example.json` for an annotated full example.
|
||||||
|
public struct RunnerConfig: Codable, Sendable, Equatable {
|
||||||
|
|
||||||
|
// MARK: - Sections
|
||||||
|
|
||||||
|
/// How to reach the Gitea instance and how to authenticate to it.
|
||||||
|
public struct GiteaSection: Codable, Sendable, Equatable {
|
||||||
|
/// Base URL of the Gitea instance, e.g. `https://gitea.example.com`.
|
||||||
|
/// Paths are appended to this, so a trailing slash is harmless.
|
||||||
|
public var instanceURL: URL
|
||||||
|
|
||||||
|
/// A Gitea admin API token, inline. Used for the admin Actions endpoints
|
||||||
|
/// (job listing, runner listing/deletion, registration-token minting).
|
||||||
|
/// Prefer ``adminTokenFile`` so the secret is not world-readable in JSON.
|
||||||
|
///
|
||||||
|
/// - Important: Exactly one of this and ``adminTokenFile`` must be set.
|
||||||
|
/// ``RunnerConfig/validated()`` rejects both-set and neither-set alike;
|
||||||
|
/// a stale inline token sitting beside a live token file is exactly the
|
||||||
|
/// ambiguity that produces a baffling 401 at 3am.
|
||||||
|
public var adminToken: String?
|
||||||
|
|
||||||
|
/// Path to a file whose (trimmed) contents are the admin API token.
|
||||||
|
/// Tilde-expanded.
|
||||||
|
///
|
||||||
|
/// - Important: Exactly one of this and ``adminToken`` must be set — see
|
||||||
|
/// that property. This one does *not* silently win over an inline
|
||||||
|
/// value; setting both is a validation error.
|
||||||
|
public var adminTokenFile: String?
|
||||||
|
|
||||||
|
/// The shared runner registration token, inline.
|
||||||
|
///
|
||||||
|
/// - Important: Registration tokens are **reusable** and **scoped**.
|
||||||
|
/// Minting a new token for a scope invalidates all prior tokens of that
|
||||||
|
/// scope, so per-VM tokens must never be pre-generated. One shared
|
||||||
|
/// token serves the whole fleet. See docs/DESIGN.md, Verified Fact 4.
|
||||||
|
public var registrationToken: String?
|
||||||
|
|
||||||
|
/// Path to a file whose (trimmed) contents are the registration token.
|
||||||
|
/// Tilde-expanded. Takes precedence over ``registrationToken``.
|
||||||
|
public var registrationTokenFile: String?
|
||||||
|
|
||||||
|
/// When no static registration token is configured, fetch one from
|
||||||
|
/// `POST /api/v1/admin/actions/runners/registration-token`.
|
||||||
|
///
|
||||||
|
/// Defaults to `false` because that endpoint effectively returns the
|
||||||
|
/// *existing* active token for the scope, and any implementation change
|
||||||
|
/// that made it mint a fresh one would invalidate tokens held by runners
|
||||||
|
/// registered elsewhere.
|
||||||
|
public var fetchRegistrationTokenViaAPI: Bool
|
||||||
|
|
||||||
|
public init(
|
||||||
|
instanceURL: URL,
|
||||||
|
adminToken: String? = nil,
|
||||||
|
adminTokenFile: String? = nil,
|
||||||
|
registrationToken: String? = nil,
|
||||||
|
registrationTokenFile: String? = nil,
|
||||||
|
fetchRegistrationTokenViaAPI: Bool = false
|
||||||
|
) {
|
||||||
|
self.instanceURL = instanceURL
|
||||||
|
self.adminToken = adminToken
|
||||||
|
self.adminTokenFile = adminTokenFile
|
||||||
|
self.registrationToken = registrationToken
|
||||||
|
self.registrationTokenFile = registrationTokenFile
|
||||||
|
self.fetchRegistrationTokenViaAPI = fetchRegistrationTokenViaAPI
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Identity and provenance of the runners registered inside each guest.
|
||||||
|
public struct RunnerSection: Codable, Sendable, Equatable {
|
||||||
|
/// Bare label names this host serves. Matched case-sensitively against a
|
||||||
|
/// job's `labels` (i.e. its `runs-on:`). The `:host` schema suffix is
|
||||||
|
/// added only when calling `gitea-runner register`.
|
||||||
|
public var labels: [String]
|
||||||
|
|
||||||
|
/// Prefix for generated runner names. Must be distinctive enough that the
|
||||||
|
/// reconcile loop can tell our stale rows from other runners'.
|
||||||
|
public var namePrefix: String
|
||||||
|
|
||||||
|
/// Template for the `gitea-runner` release asset to install in the guest.
|
||||||
|
/// `{version}` is substituted with ``version``.
|
||||||
|
public var runnerDownloadURL: String
|
||||||
|
|
||||||
|
/// The `gitea-runner` version to install (v3.x; the binary was renamed
|
||||||
|
/// from `act_runner`, and now lives at `gitea.com/gitea/runner`).
|
||||||
|
public var version: String
|
||||||
|
|
||||||
|
public init(
|
||||||
|
labels: [String] = ["macos-arm64"],
|
||||||
|
namePrefix: String = "macos-vm-",
|
||||||
|
runnerDownloadURL: String = RunnerSection.defaultDownloadURLTemplate,
|
||||||
|
version: String = "3.0.2"
|
||||||
|
) {
|
||||||
|
self.labels = labels
|
||||||
|
self.namePrefix = namePrefix
|
||||||
|
self.runnerDownloadURL = runnerDownloadURL
|
||||||
|
self.version = version
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default release-asset URL template for the darwin/arm64 build.
|
||||||
|
public static let defaultDownloadURLTemplate =
|
||||||
|
"https://gitea.com/gitea/runner/releases/download/v{version}/gitea-runner-{version}-darwin-arm64"
|
||||||
|
|
||||||
|
/// ``runnerDownloadURL`` with `{version}` substituted.
|
||||||
|
public var resolvedDownloadURL: URL {
|
||||||
|
get throws {
|
||||||
|
let substituted = runnerDownloadURL.replacingOccurrences(of: "{version}", with: version)
|
||||||
|
guard let url = URL(string: substituted), url.scheme != nil else {
|
||||||
|
throw CoreError.configInvalid(
|
||||||
|
"runner.runnerDownloadURL does not form a valid URL: \(substituted)")
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Polling cadence, concurrency, and the timeouts that bound a stuck VM.
|
||||||
|
public struct SchedulerSection: Codable, Sendable, Equatable {
|
||||||
|
/// How many macOS guests may run at once.
|
||||||
|
///
|
||||||
|
/// - Important: Hard-clamped to 2 by ``RunnerConfig/validated()``. Apple's
|
||||||
|
/// kernel enforces a limit of two concurrent macOS VMs per host; a third
|
||||||
|
/// `start()` fails with `VZError.virtualMachineLimitExceeded`.
|
||||||
|
public var maxConcurrentVMs: Int
|
||||||
|
|
||||||
|
/// Seconds between queued-job polls.
|
||||||
|
public var pollIntervalSeconds: Int
|
||||||
|
|
||||||
|
/// Seconds between reconcile passes that sweep orphaned runner rows.
|
||||||
|
public var reconcileIntervalSeconds: Int
|
||||||
|
|
||||||
|
/// Wall-clock ceiling on a single job before its VM is torn down.
|
||||||
|
public var jobTimeoutMinutes: Int
|
||||||
|
|
||||||
|
/// Ceiling on boot + DHCP lease + SSH readiness before a slot is
|
||||||
|
/// declared dead and recycled.
|
||||||
|
public var bootTimeoutSeconds: Int
|
||||||
|
|
||||||
|
public init(
|
||||||
|
maxConcurrentVMs: Int = 2,
|
||||||
|
pollIntervalSeconds: Int = 5,
|
||||||
|
reconcileIntervalSeconds: Int = 300,
|
||||||
|
jobTimeoutMinutes: Int = 120,
|
||||||
|
bootTimeoutSeconds: Int = 300
|
||||||
|
) {
|
||||||
|
self.maxConcurrentVMs = maxConcurrentVMs
|
||||||
|
self.pollIntervalSeconds = pollIntervalSeconds
|
||||||
|
self.reconcileIntervalSeconds = reconcileIntervalSeconds
|
||||||
|
self.jobTimeoutMinutes = jobTimeoutMinutes
|
||||||
|
self.bootTimeoutSeconds = bootTimeoutSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The absolute cap on concurrent macOS guests, enforced by the kernel.
|
||||||
|
public static let hardMaxConcurrentVMs = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shape of each guest VM and the credentials used to reach it over SSH.
|
||||||
|
///
|
||||||
|
/// - Note: These credentials only ever exist on the NAT network between the
|
||||||
|
/// host and its own ephemeral guests. They are not secrets in any
|
||||||
|
/// meaningful sense, but they are also why the NAT attachment (rather than
|
||||||
|
/// bridged networking) is not optional.
|
||||||
|
public struct GuestSection: Codable, Sendable, Equatable {
|
||||||
|
/// The admin account created by Setup Assistant automation.
|
||||||
|
public var username: String
|
||||||
|
/// That account's password, also used for SSH password auth.
|
||||||
|
public var password: String
|
||||||
|
/// Virtual CPUs per guest.
|
||||||
|
public var cpuCount: Int
|
||||||
|
/// RAM per guest, in gibibytes.
|
||||||
|
public var memoryGB: Int
|
||||||
|
/// Backing disk size per guest, in gibibytes. Sparse (ASIF) where
|
||||||
|
/// available, so this is a ceiling rather than an allocation.
|
||||||
|
public var diskGB: Int
|
||||||
|
|
||||||
|
public init(
|
||||||
|
username: String = "admin",
|
||||||
|
password: String = "admin",
|
||||||
|
cpuCount: Int = 4,
|
||||||
|
memoryGB: Int = 8,
|
||||||
|
diskGB: Int = 64
|
||||||
|
) {
|
||||||
|
self.username = username
|
||||||
|
self.password = password
|
||||||
|
self.cpuCount = cpuCount
|
||||||
|
self.memoryGB = memoryGB
|
||||||
|
self.diskGB = diskGB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where images, clones, IPSWs, and host state live on disk.
|
||||||
|
public struct StorageSection: Codable, Sendable, Equatable {
|
||||||
|
/// Root of the store. Tilde-expanded.
|
||||||
|
///
|
||||||
|
/// - Important: Clones are made with APFS copy-on-write, which requires
|
||||||
|
/// source and destination on the *same volume*. Keep base images and
|
||||||
|
/// ephemeral clones under one root.
|
||||||
|
public var storeDir: String
|
||||||
|
|
||||||
|
/// Refuse to clone a new VM when the store volume has less than this
|
||||||
|
/// much free space. CoW clones start near-free but grow as the guest
|
||||||
|
/// writes, so a floor well above one clone's nominal size is prudent.
|
||||||
|
public var minFreeDiskGB: Int
|
||||||
|
|
||||||
|
public init(
|
||||||
|
storeDir: String = "~/Library/Application Support/gitea-macos-runner",
|
||||||
|
minFreeDiskGB: Int = 20
|
||||||
|
) {
|
||||||
|
self.storeDir = storeDir
|
||||||
|
self.minFreeDiskGB = minFreeDiskGB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stored properties
|
||||||
|
|
||||||
|
public var gitea: GiteaSection
|
||||||
|
public var runner: RunnerSection
|
||||||
|
public var scheduler: SchedulerSection
|
||||||
|
public var guest: GuestSection
|
||||||
|
public var storage: StorageSection
|
||||||
|
|
||||||
|
public init(
|
||||||
|
gitea: GiteaSection,
|
||||||
|
runner: RunnerSection = .init(),
|
||||||
|
scheduler: SchedulerSection = .init(),
|
||||||
|
guest: GuestSection = .init(),
|
||||||
|
storage: StorageSection = .init()
|
||||||
|
) {
|
||||||
|
self.gitea = gitea
|
||||||
|
self.runner = runner
|
||||||
|
self.scheduler = scheduler
|
||||||
|
self.guest = guest
|
||||||
|
self.storage = storage
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Defaults
|
||||||
|
|
||||||
|
/// A configuration with every default applied and a placeholder instance URL.
|
||||||
|
/// Used by `config init` to seed a new file, and by tests.
|
||||||
|
public static var `default`: RunnerConfig {
|
||||||
|
RunnerConfig(gitea: GiteaSection(instanceURL: URL(string: "https://gitea.example.com")!))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The conventional config path, `~/.config/gitea-macos-runner/config.json`,
|
||||||
|
/// tilde-expanded.
|
||||||
|
public static var defaultPath: String {
|
||||||
|
expandTilde("~/.config/gitea-macos-runner/config.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Loading & validation
|
||||||
|
|
||||||
|
/// Loads and validates a configuration from a JSON file.
|
||||||
|
///
|
||||||
|
/// - Parameter path: Filesystem path; tilde-expanded. Defaults to
|
||||||
|
/// ``defaultPath``.
|
||||||
|
/// - Returns: A validated configuration.
|
||||||
|
/// - Throws: ``CoreError/configInvalid(_:)`` if the file is missing,
|
||||||
|
/// unparseable, or fails ``validated()``.
|
||||||
|
public static func load(from path: String = RunnerConfig.defaultPath) throws -> RunnerConfig {
|
||||||
|
let expanded = expandTilde(path)
|
||||||
|
|
||||||
|
guard FileManager.default.fileExists(atPath: expanded) else {
|
||||||
|
throw CoreError.configInvalid("no configuration file at \(expanded)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: Data
|
||||||
|
do {
|
||||||
|
data = try Data(contentsOf: URL(fileURLWithPath: expanded))
|
||||||
|
} catch {
|
||||||
|
throw CoreError.configInvalid("cannot read \(expanded): \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoded: RunnerConfig
|
||||||
|
do {
|
||||||
|
decoded = try JSONDecoder().decode(RunnerConfig.self, from: data)
|
||||||
|
} catch let error as DecodingError {
|
||||||
|
throw CoreError.configInvalid("\(expanded): \(RunnerConfig.describe(error))")
|
||||||
|
} catch {
|
||||||
|
throw CoreError.configInvalid("\(expanded): \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return try decoded.validated()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders a `DecodingError` as something an operator can act on, since the
|
||||||
|
/// default description is a multi-line dump of the underlying context.
|
||||||
|
private static func describe(_ error: DecodingError) -> String {
|
||||||
|
func keyPath(_ context: DecodingError.Context) -> String {
|
||||||
|
let path = context.codingPath.map(\.stringValue).joined(separator: ".")
|
||||||
|
return path.isEmpty ? "<root>" : path
|
||||||
|
}
|
||||||
|
switch error {
|
||||||
|
case .keyNotFound(let key, let context):
|
||||||
|
let parent = keyPath(context)
|
||||||
|
return "missing required key `\(key.stringValue)`"
|
||||||
|
+ (parent == "<root>" ? "" : " under `\(parent)`")
|
||||||
|
case .typeMismatch(let type, let context):
|
||||||
|
return "key `\(keyPath(context))` has the wrong type (expected \(type))"
|
||||||
|
case .valueNotFound(let type, let context):
|
||||||
|
return "key `\(keyPath(context))` is null (expected \(type))"
|
||||||
|
case .dataCorrupted(let context):
|
||||||
|
let path = keyPath(context)
|
||||||
|
return path == "<root>"
|
||||||
|
? "not valid JSON (\(context.debugDescription))"
|
||||||
|
: "key `\(path)` is malformed (\(context.debugDescription))"
|
||||||
|
@unknown default:
|
||||||
|
return "\(error)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes this configuration as pretty-printed JSON, creating parent
|
||||||
|
/// directories as needed.
|
||||||
|
///
|
||||||
|
/// - Parameter path: Destination; tilde-expanded.
|
||||||
|
public func save(to path: String) throws {
|
||||||
|
let expanded = RunnerConfig.expandTilde(path)
|
||||||
|
let url = URL(fileURLWithPath: expanded)
|
||||||
|
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
|
||||||
|
|
||||||
|
do {
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: url.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true)
|
||||||
|
var data = try encoder.encode(self)
|
||||||
|
data.append(0x0A) // trailing newline, so the file is diff-friendly
|
||||||
|
try data.write(to: url, options: .atomic)
|
||||||
|
} catch {
|
||||||
|
throw CoreError.configInvalid("cannot write \(expanded): \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes the annotated example configuration shipped in `Resources/`, or —
|
||||||
|
/// when that resource is not reachable — this configuration serialized by
|
||||||
|
/// ``save(to:)``.
|
||||||
|
///
|
||||||
|
/// `config init` uses this so a fresh install lands an operator on the
|
||||||
|
/// commented example rather than a bare JSON dump.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - path: Destination; tilde-expanded.
|
||||||
|
/// - exampleContents: The example document, if the caller could load it.
|
||||||
|
/// - overwrite: When `false` (the default) an existing file is left alone.
|
||||||
|
/// - Returns: `true` if a file was written, `false` if one already existed.
|
||||||
|
@discardableResult
|
||||||
|
public func writeExample(
|
||||||
|
to path: String,
|
||||||
|
exampleContents: String? = nil,
|
||||||
|
overwrite: Bool = false
|
||||||
|
) throws -> Bool {
|
||||||
|
let expanded = RunnerConfig.expandTilde(path)
|
||||||
|
if !overwrite, FileManager.default.fileExists(atPath: expanded) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
guard let example = exampleContents else {
|
||||||
|
try save(to: expanded)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
let url = URL(fileURLWithPath: expanded)
|
||||||
|
do {
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: url.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true)
|
||||||
|
try Data(example.utf8).write(to: url, options: .atomic)
|
||||||
|
} catch {
|
||||||
|
throw CoreError.configInvalid("cannot write \(expanded): \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a normalized copy, or throws describing what is wrong.
|
||||||
|
///
|
||||||
|
/// Normalization clamps ``SchedulerSection/maxConcurrentVMs`` into
|
||||||
|
/// `1...2` and expands tildes in path-bearing fields. Validation rejects a
|
||||||
|
/// non-http(s) instance URL, an empty label list, an empty name prefix,
|
||||||
|
/// non-positive intervals or timeouts, a guest with fewer than 1 CPU or less
|
||||||
|
/// than 1 GB of RAM, and a configuration with no way to obtain either token.
|
||||||
|
///
|
||||||
|
/// - Returns: The normalized configuration.
|
||||||
|
/// - Throws: ``CoreError/configInvalid(_:)``.
|
||||||
|
public func validated() throws -> RunnerConfig {
|
||||||
|
var c = self
|
||||||
|
|
||||||
|
// --- gitea.instanceURL ------------------------------------------------
|
||||||
|
let scheme = c.gitea.instanceURL.scheme?.lowercased()
|
||||||
|
guard scheme == "http" || scheme == "https" else {
|
||||||
|
throw CoreError.configInvalid(
|
||||||
|
"gitea.instanceURL must be an http:// or https:// URL, got \"\(c.gitea.instanceURL.absoluteString)\"")
|
||||||
|
}
|
||||||
|
guard let host = c.gitea.instanceURL.host, !host.isEmpty else {
|
||||||
|
throw CoreError.configInvalid(
|
||||||
|
"gitea.instanceURL has no host: \"\(c.gitea.instanceURL.absoluteString)\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- admin token: exactly one source ----------------------------------
|
||||||
|
//
|
||||||
|
// Both-set is rejected rather than silently preferring one, because a
|
||||||
|
// stale inline token sitting next to a live token file is precisely the
|
||||||
|
// kind of ambiguity that produces a baffling 401 at 3am.
|
||||||
|
let inlineAdmin = RunnerConfig.nonEmpty(c.gitea.adminToken)
|
||||||
|
let fileAdmin = RunnerConfig.nonEmpty(c.gitea.adminTokenFile)
|
||||||
|
switch (inlineAdmin, fileAdmin) {
|
||||||
|
case (nil, nil):
|
||||||
|
throw CoreError.configInvalid(
|
||||||
|
"no admin API token configured: set exactly one of gitea.adminToken or gitea.adminTokenFile")
|
||||||
|
case (.some, .some):
|
||||||
|
throw CoreError.configInvalid(
|
||||||
|
"gitea.adminToken and gitea.adminTokenFile are both set: use exactly one")
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
c.gitea.adminToken = inlineAdmin
|
||||||
|
c.gitea.adminTokenFile = fileAdmin.map(RunnerConfig.expandTilde)
|
||||||
|
|
||||||
|
// --- registration token: at least one source --------------------------
|
||||||
|
//
|
||||||
|
// Unlike the admin token, a file and an inline value are not mutually
|
||||||
|
// exclusive here (the file wins); what is rejected is having no source
|
||||||
|
// at all with the API fallback switched off.
|
||||||
|
let inlineReg = RunnerConfig.nonEmpty(c.gitea.registrationToken)
|
||||||
|
let fileReg = RunnerConfig.nonEmpty(c.gitea.registrationTokenFile)
|
||||||
|
if inlineReg == nil, fileReg == nil, !c.gitea.fetchRegistrationTokenViaAPI {
|
||||||
|
throw CoreError.configInvalid(
|
||||||
|
"no runner registration token configured: set gitea.registrationTokenFile "
|
||||||
|
+ "(or gitea.registrationToken), or set gitea.fetchRegistrationTokenViaAPI to true")
|
||||||
|
}
|
||||||
|
c.gitea.registrationToken = inlineReg
|
||||||
|
c.gitea.registrationTokenFile = fileReg.map(RunnerConfig.expandTilde)
|
||||||
|
|
||||||
|
// --- runner -----------------------------------------------------------
|
||||||
|
let labels = c.runner.labels.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||||
|
guard !labels.isEmpty else {
|
||||||
|
throw CoreError.configInvalid("runner.labels must not be empty")
|
||||||
|
}
|
||||||
|
if labels.contains(where: \.isEmpty) {
|
||||||
|
throw CoreError.configInvalid("runner.labels contains an empty label name")
|
||||||
|
}
|
||||||
|
// Bare names only: the `:schema` suffix belongs on the `register
|
||||||
|
// --labels` argument, never in stored config, and Gitea reports bare
|
||||||
|
// names on jobs — so a configured "macos-arm64:host" would never match.
|
||||||
|
if let schemed = labels.first(where: { $0.contains(":") }) {
|
||||||
|
throw CoreError.configInvalid(
|
||||||
|
"runner.labels must contain bare names only, but \"\(schemed)\" carries a ':schema' suffix; "
|
||||||
|
+ "the schema is appended automatically at registration time")
|
||||||
|
}
|
||||||
|
c.runner.labels = labels
|
||||||
|
|
||||||
|
let prefix = c.runner.namePrefix.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !prefix.isEmpty else {
|
||||||
|
throw CoreError.configInvalid("runner.namePrefix must not be empty")
|
||||||
|
}
|
||||||
|
c.runner.namePrefix = prefix
|
||||||
|
|
||||||
|
guard !c.runner.version.trimmingCharacters(in: .whitespaces).isEmpty else {
|
||||||
|
throw CoreError.configInvalid("runner.version must not be empty")
|
||||||
|
}
|
||||||
|
c.runner.version = c.runner.version.trimmingCharacters(in: .whitespaces)
|
||||||
|
_ = try c.runner.resolvedDownloadURL
|
||||||
|
|
||||||
|
// --- scheduler --------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Clamped rather than rejected: Apple's kernel caps concurrent macOS
|
||||||
|
// guests at two, and that is not a limit a config file gets to negotiate.
|
||||||
|
c.scheduler.maxConcurrentVMs = min(
|
||||||
|
max(c.scheduler.maxConcurrentVMs, 1),
|
||||||
|
SchedulerSection.hardMaxConcurrentVMs)
|
||||||
|
|
||||||
|
guard c.scheduler.pollIntervalSeconds > 0 else {
|
||||||
|
throw CoreError.configInvalid("scheduler.pollIntervalSeconds must be greater than 0")
|
||||||
|
}
|
||||||
|
guard c.scheduler.reconcileIntervalSeconds > 0 else {
|
||||||
|
throw CoreError.configInvalid("scheduler.reconcileIntervalSeconds must be greater than 0")
|
||||||
|
}
|
||||||
|
guard c.scheduler.jobTimeoutMinutes > 0 else {
|
||||||
|
throw CoreError.configInvalid("scheduler.jobTimeoutMinutes must be greater than 0")
|
||||||
|
}
|
||||||
|
guard c.scheduler.bootTimeoutSeconds > 0 else {
|
||||||
|
throw CoreError.configInvalid("scheduler.bootTimeoutSeconds must be greater than 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- guest ------------------------------------------------------------
|
||||||
|
guard !c.guest.username.trimmingCharacters(in: .whitespaces).isEmpty else {
|
||||||
|
throw CoreError.configInvalid("guest.username must not be empty")
|
||||||
|
}
|
||||||
|
// SSH password auth is the only channel into the guest, and an empty
|
||||||
|
// password would leave the boot hanging at authentication with no
|
||||||
|
// diagnostic worth reading.
|
||||||
|
guard !c.guest.password.isEmpty else {
|
||||||
|
throw CoreError.configInvalid("guest.password must not be empty")
|
||||||
|
}
|
||||||
|
guard c.guest.cpuCount >= 1 else {
|
||||||
|
throw CoreError.configInvalid("guest.cpuCount must be at least 1")
|
||||||
|
}
|
||||||
|
guard c.guest.memoryGB >= 1 else {
|
||||||
|
throw CoreError.configInvalid("guest.memoryGB must be at least 1")
|
||||||
|
}
|
||||||
|
guard c.guest.diskGB >= 1 else {
|
||||||
|
throw CoreError.configInvalid("guest.diskGB must be at least 1")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- storage ----------------------------------------------------------
|
||||||
|
let storeDir = c.storage.storeDir.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !storeDir.isEmpty else {
|
||||||
|
throw CoreError.configInvalid("storage.storeDir must not be empty")
|
||||||
|
}
|
||||||
|
c.storage.storeDir = RunnerConfig.expandTilde(storeDir)
|
||||||
|
guard c.storage.minFreeDiskGB >= 0 else {
|
||||||
|
throw CoreError.configInvalid("storage.minFreeDiskGB must not be negative")
|
||||||
|
}
|
||||||
|
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trims a string and maps `""` to `nil`, so an empty JSON value reads as
|
||||||
|
/// "not configured" rather than as a zero-length token.
|
||||||
|
private static func nonEmpty(_ value: String?) -> String? {
|
||||||
|
guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!trimmed.isEmpty
|
||||||
|
else { return nil }
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The admin API token, resolved from ``GiteaSection/adminTokenFile`` (read
|
||||||
|
/// and trimmed) or ``GiteaSection/adminToken``.
|
||||||
|
///
|
||||||
|
/// On a configuration that has been through ``validated()`` exactly one of
|
||||||
|
/// those is set, so the file-first order here never actually chooses between
|
||||||
|
/// two live values.
|
||||||
|
///
|
||||||
|
/// - Returns: The token, or `nil` when neither source is configured.
|
||||||
|
public func resolveAdminToken() throws -> String? {
|
||||||
|
if let path = RunnerConfig.nonEmpty(gitea.adminTokenFile) {
|
||||||
|
return try RunnerConfig.readTokenFile(path, describedAs: "gitea.adminTokenFile")
|
||||||
|
}
|
||||||
|
return RunnerConfig.nonEmpty(gitea.adminToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The registration token from static configuration only — file first, then
|
||||||
|
/// inline value. Returns `nil` when the caller must fall back to the API
|
||||||
|
/// (see ``GiteaSection/fetchRegistrationTokenViaAPI``).
|
||||||
|
public func resolveStaticRegistrationToken() throws -> String? {
|
||||||
|
if let path = RunnerConfig.nonEmpty(gitea.registrationTokenFile) {
|
||||||
|
return try RunnerConfig.readTokenFile(path, describedAs: "gitea.registrationTokenFile")
|
||||||
|
}
|
||||||
|
return RunnerConfig.nonEmpty(gitea.registrationToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a secret from a file: tilde-expanded, trimmed of surrounding
|
||||||
|
/// whitespace and newlines (an `echo`-written token file always has one).
|
||||||
|
///
|
||||||
|
/// - Throws: ``CoreError/configInvalid(_:)`` when the file is missing,
|
||||||
|
/// unreadable, not UTF-8, or empty once trimmed.
|
||||||
|
private static func readTokenFile(_ path: String, describedAs key: String) throws -> String {
|
||||||
|
let expanded = expandTilde(path)
|
||||||
|
guard FileManager.default.fileExists(atPath: expanded) else {
|
||||||
|
throw CoreError.configInvalid("\(key): no such file: \(expanded)")
|
||||||
|
}
|
||||||
|
let data: Data
|
||||||
|
do {
|
||||||
|
data = try Data(contentsOf: URL(fileURLWithPath: expanded))
|
||||||
|
} catch {
|
||||||
|
throw CoreError.configInvalid("\(key): cannot read \(expanded): \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
guard let text = String(data: data, encoding: .utf8) else {
|
||||||
|
throw CoreError.configInvalid("\(key): \(expanded) is not valid UTF-8")
|
||||||
|
}
|
||||||
|
let token = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !token.isEmpty else {
|
||||||
|
throw CoreError.configInvalid("\(key): \(expanded) is empty")
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a token file is readable by users other than its owner.
|
||||||
|
///
|
||||||
|
/// Permissions are deliberately **not** enforced — refusing to start because
|
||||||
|
/// a file is `0644` would be a poor trade on a single-user CI Mac — but
|
||||||
|
/// `doctor` surfaces this as a warning.
|
||||||
|
///
|
||||||
|
/// - Parameter path: Path to check; tilde-expanded.
|
||||||
|
/// - Returns: `true` when group or other bits are set, `false` when the file
|
||||||
|
/// is owner-only, and `nil` when the mode cannot be read.
|
||||||
|
public static func tokenFileIsGroupOrWorldReadable(_ path: String) -> Bool? {
|
||||||
|
let expanded = expandTilde(path)
|
||||||
|
guard
|
||||||
|
let attrs = try? FileManager.default.attributesOfItem(atPath: expanded),
|
||||||
|
let mode = attrs[.posixPermissions] as? NSNumber
|
||||||
|
else { return nil }
|
||||||
|
return (mode.int16Value & 0o077) != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paths of configured token files whose permissions are looser than `0600`.
|
||||||
|
/// Empty when everything is owner-only or nothing is file-backed.
|
||||||
|
public var insecureTokenFilePaths: [String] {
|
||||||
|
[gitea.adminTokenFile, gitea.registrationTokenFile]
|
||||||
|
.compactMap { RunnerConfig.nonEmpty($0) }
|
||||||
|
.filter { RunnerConfig.tokenFileIsGroupOrWorldReadable($0) == true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ``StorageSection/storeDir`` with `~` expanded, as a `URL`.
|
||||||
|
public var storeDirectoryURL: URL {
|
||||||
|
URL(fileURLWithPath: RunnerConfig.expandTilde(storage.storeDir), isDirectory: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The label set used for job matching.
|
||||||
|
public var labelSet: LabelSet {
|
||||||
|
LabelSet(runner.labels)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
/// Expands a leading `~` or `~/` to the current user's home directory.
|
||||||
|
///
|
||||||
|
/// `NSString.expandingTildeInPath` is used rather than `FileManager`'s
|
||||||
|
/// deprecated home lookup so the behaviour matches the shell.
|
||||||
|
///
|
||||||
|
/// - Parameter path: A possibly tilde-prefixed path.
|
||||||
|
/// - Returns: An absolute path.
|
||||||
|
public static func expandTilde(_ path: String) -> String {
|
||||||
|
(path as NSString).expandingTildeInPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Codable
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case gitea, runner, scheduler, guest, storage
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes a configuration, substituting section defaults for absent keys.
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
self.gitea = try c.decode(GiteaSection.self, forKey: .gitea)
|
||||||
|
self.runner = try c.decodeIfPresent(RunnerSection.self, forKey: .runner) ?? .init()
|
||||||
|
self.scheduler = try c.decodeIfPresent(SchedulerSection.self, forKey: .scheduler) ?? .init()
|
||||||
|
self.guest = try c.decodeIfPresent(GuestSection.self, forKey: .guest) ?? .init()
|
||||||
|
self.storage = try c.decodeIfPresent(StorageSection.self, forKey: .storage) ?? .init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tolerant section decoding
|
||||||
|
|
||||||
|
extension RunnerConfig.GiteaSection {
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case instanceURL, adminToken, adminTokenFile
|
||||||
|
case registrationToken, registrationTokenFile, fetchRegistrationTokenViaAPI
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
self.instanceURL = try c.decode(URL.self, forKey: .instanceURL)
|
||||||
|
self.adminToken = try c.decodeIfPresent(String.self, forKey: .adminToken)
|
||||||
|
self.adminTokenFile = try c.decodeIfPresent(String.self, forKey: .adminTokenFile)
|
||||||
|
self.registrationToken = try c.decodeIfPresent(String.self, forKey: .registrationToken)
|
||||||
|
self.registrationTokenFile = try c.decodeIfPresent(String.self, forKey: .registrationTokenFile)
|
||||||
|
self.fetchRegistrationTokenViaAPI =
|
||||||
|
try c.decodeIfPresent(Bool.self, forKey: .fetchRegistrationTokenViaAPI) ?? false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension RunnerConfig.RunnerSection {
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case labels, namePrefix, runnerDownloadURL, version
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let d = RunnerConfig.RunnerSection()
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
self.labels = try c.decodeIfPresent([String].self, forKey: .labels) ?? d.labels
|
||||||
|
self.namePrefix = try c.decodeIfPresent(String.self, forKey: .namePrefix) ?? d.namePrefix
|
||||||
|
self.runnerDownloadURL =
|
||||||
|
try c.decodeIfPresent(String.self, forKey: .runnerDownloadURL) ?? d.runnerDownloadURL
|
||||||
|
self.version = try c.decodeIfPresent(String.self, forKey: .version) ?? d.version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension RunnerConfig.SchedulerSection {
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case maxConcurrentVMs, pollIntervalSeconds, reconcileIntervalSeconds
|
||||||
|
case jobTimeoutMinutes, bootTimeoutSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let d = RunnerConfig.SchedulerSection()
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
self.maxConcurrentVMs =
|
||||||
|
try c.decodeIfPresent(Int.self, forKey: .maxConcurrentVMs) ?? d.maxConcurrentVMs
|
||||||
|
self.pollIntervalSeconds =
|
||||||
|
try c.decodeIfPresent(Int.self, forKey: .pollIntervalSeconds) ?? d.pollIntervalSeconds
|
||||||
|
self.reconcileIntervalSeconds =
|
||||||
|
try c.decodeIfPresent(Int.self, forKey: .reconcileIntervalSeconds) ?? d.reconcileIntervalSeconds
|
||||||
|
self.jobTimeoutMinutes =
|
||||||
|
try c.decodeIfPresent(Int.self, forKey: .jobTimeoutMinutes) ?? d.jobTimeoutMinutes
|
||||||
|
self.bootTimeoutSeconds =
|
||||||
|
try c.decodeIfPresent(Int.self, forKey: .bootTimeoutSeconds) ?? d.bootTimeoutSeconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension RunnerConfig.GuestSection {
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case username, password, cpuCount, memoryGB, diskGB
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let d = RunnerConfig.GuestSection()
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
self.username = try c.decodeIfPresent(String.self, forKey: .username) ?? d.username
|
||||||
|
self.password = try c.decodeIfPresent(String.self, forKey: .password) ?? d.password
|
||||||
|
self.cpuCount = try c.decodeIfPresent(Int.self, forKey: .cpuCount) ?? d.cpuCount
|
||||||
|
self.memoryGB = try c.decodeIfPresent(Int.self, forKey: .memoryGB) ?? d.memoryGB
|
||||||
|
self.diskGB = try c.decodeIfPresent(Int.self, forKey: .diskGB) ?? d.diskGB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension RunnerConfig.StorageSection {
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case storeDir, minFreeDiskGB
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let d = RunnerConfig.StorageSection()
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
self.storeDir = try c.decodeIfPresent(String.self, forKey: .storeDir) ?? d.storeDir
|
||||||
|
self.minFreeDiskGB =
|
||||||
|
try c.decodeIfPresent(Int.self, forKey: .minFreeDiskGB) ?? d.minFreeDiskGB
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The single error domain shared by every layer of the runner.
|
||||||
|
///
|
||||||
|
/// Host-side (`RunnerHost`) code wraps Virtualization.framework's `VZError` into
|
||||||
|
/// these cases rather than propagating it, so the CLI only ever has to render one
|
||||||
|
/// error type. `unimplemented` exists so that skeleton bodies can `throw` instead
|
||||||
|
/// of trapping in code paths where a trap would take down the daemon.
|
||||||
|
public enum CoreError: Error, Sendable {
|
||||||
|
/// A code path that has not been written yet.
|
||||||
|
case unimplemented
|
||||||
|
|
||||||
|
/// The on-disk configuration is missing, malformed, or internally inconsistent.
|
||||||
|
/// The payload is a human-readable explanation suitable for printing to stderr.
|
||||||
|
case configInvalid(String)
|
||||||
|
|
||||||
|
/// The Gitea API returned a non-2xx status.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - status: The HTTP status code.
|
||||||
|
/// - message: The response body (truncated) or a decoded API error message.
|
||||||
|
case gitea(status: Int, message: String)
|
||||||
|
|
||||||
|
/// An SSH session could not be established, authenticated, or the remote
|
||||||
|
/// command exited non-zero when a zero exit was required.
|
||||||
|
case sshFailed(String)
|
||||||
|
|
||||||
|
/// A bounded wait elapsed. The payload names what was being waited on
|
||||||
|
/// (for example `"dhcp lease for aa:bb:cc:dd:ee:ff"` or `"ssh on 192.168.64.7"`).
|
||||||
|
case timeout(String)
|
||||||
|
|
||||||
|
/// A required external tool or file was absent (`diskutil`, an IPSW, the
|
||||||
|
/// `gitea-runner` release asset, …).
|
||||||
|
case notFound(String)
|
||||||
|
|
||||||
|
/// The host cannot run VMs: wrong architecture, unsupported macOS, missing
|
||||||
|
/// `com.apple.security.virtualization` entitlement, or a locked login keychain.
|
||||||
|
case hostUnsupported(String)
|
||||||
|
|
||||||
|
/// Apple's kernel-enforced limit of two concurrent macOS guests was hit.
|
||||||
|
/// Surfaced distinctly because it is transient and the scheduler retries.
|
||||||
|
case vmLimitExceeded
|
||||||
|
|
||||||
|
/// A VM bundle on disk is missing files or has an unreadable `config.json`.
|
||||||
|
case bundleCorrupt(String)
|
||||||
|
|
||||||
|
/// Not enough free space on the store volume to safely clone or grow a VM.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - requiredGB: The configured floor.
|
||||||
|
/// - availableGB: What the volume actually has.
|
||||||
|
case insufficientDiskSpace(requiredGB: Int, availableGB: Int)
|
||||||
|
|
||||||
|
/// A subprocess (`diskutil`, `codesign`, `security`, …) exited non-zero.
|
||||||
|
case processFailed(command: String, exitCode: Int32, output: String)
|
||||||
|
|
||||||
|
/// The image build or provisioning pipeline failed at a named stage.
|
||||||
|
case provisioningFailed(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CoreError: CustomStringConvertible {
|
||||||
|
/// A one-line, user-facing rendering of the error.
|
||||||
|
public var description: String {
|
||||||
|
switch self {
|
||||||
|
case .unimplemented:
|
||||||
|
return "not implemented"
|
||||||
|
|
||||||
|
case .configInvalid(let detail):
|
||||||
|
return "invalid configuration: \(detail)"
|
||||||
|
|
||||||
|
case .gitea(let status, let message):
|
||||||
|
let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return trimmed.isEmpty
|
||||||
|
? "gitea API error (HTTP \(status))"
|
||||||
|
: "gitea API error (HTTP \(status)): \(trimmed)"
|
||||||
|
|
||||||
|
case .sshFailed(let detail):
|
||||||
|
return "ssh failed: \(detail)"
|
||||||
|
|
||||||
|
case .timeout(let what):
|
||||||
|
return "timed out waiting for \(what)"
|
||||||
|
|
||||||
|
case .notFound(let what):
|
||||||
|
return "not found: \(what)"
|
||||||
|
|
||||||
|
case .hostUnsupported(let detail):
|
||||||
|
return "host cannot run VMs: \(detail)"
|
||||||
|
|
||||||
|
case .vmLimitExceeded:
|
||||||
|
return "macOS guest limit reached (Apple allows at most 2 concurrent VMs per host)"
|
||||||
|
|
||||||
|
case .bundleCorrupt(let detail):
|
||||||
|
return "VM bundle is corrupt: \(detail)"
|
||||||
|
|
||||||
|
case .insufficientDiskSpace(let requiredGB, let availableGB):
|
||||||
|
return "insufficient disk space: need \(requiredGB) GB free, have \(availableGB) GB"
|
||||||
|
|
||||||
|
case .processFailed(let command, let exitCode, let output):
|
||||||
|
let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return trimmed.isEmpty
|
||||||
|
? "`\(command)` exited \(exitCode)"
|
||||||
|
: "`\(command)` exited \(exitCode): \(trimmed)"
|
||||||
|
|
||||||
|
case .provisioningFailed(let stage):
|
||||||
|
return "provisioning failed: \(stage)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CoreError: LocalizedError {
|
||||||
|
public var errorDescription: String? { description }
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// One entry from macOS's `/var/db/dhcpd_leases`.
|
||||||
|
///
|
||||||
|
/// The Virtualization NAT attachment hands guests addresses from the host's
|
||||||
|
/// built-in `bootpd`, which records each lease in that file. There is no API for
|
||||||
|
/// this, so parsing the file keyed by the guest's MAC is how we learn a VM's IP.
|
||||||
|
public struct DHCPLease: Sendable, Equatable {
|
||||||
|
/// The guest's advertised hostname (`name=` in the lease block). Often the
|
||||||
|
/// guest's local hostname, sometimes absent.
|
||||||
|
public let name: String?
|
||||||
|
/// The leased IPv4 address, e.g. `192.168.64.7`.
|
||||||
|
public let ipAddress: String
|
||||||
|
/// The hardware address, **normalized**: lowercase, colon-separated, each
|
||||||
|
/// octet zero-padded to two hex digits, with the `1,` type prefix stripped.
|
||||||
|
public let hwAddress: String
|
||||||
|
/// Lease expiry, parsed from the `lease=` hex epoch, when present.
|
||||||
|
public let leaseExpiry: Date?
|
||||||
|
|
||||||
|
public init(name: String?, ipAddress: String, hwAddress: String, leaseExpiry: Date?) {
|
||||||
|
self.name = name
|
||||||
|
self.ipAddress = ipAddress
|
||||||
|
self.hwAddress = hwAddress
|
||||||
|
self.leaseExpiry = leaseExpiry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parser for `/var/db/dhcpd_leases`.
|
||||||
|
///
|
||||||
|
/// ## File format
|
||||||
|
///
|
||||||
|
/// A sequence of brace-delimited blocks of `key=value` lines:
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// {
|
||||||
|
/// name=macos-guest
|
||||||
|
/// ip_address=192.168.64.7
|
||||||
|
/// hw_address=1,aa:bb:c:dd:ee:ff
|
||||||
|
/// identifier=1,aa:bb:c:dd:ee:ff
|
||||||
|
/// lease=0x67a1b2c3
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Two details bite:
|
||||||
|
///
|
||||||
|
/// 1. `hw_address` carries a leading hardware-type prefix (`1,` for Ethernet)
|
||||||
|
/// that is not part of the MAC.
|
||||||
|
/// 2. Octets are **not zero-padded** — `aa:bb:c:dd:ee:ff` is the same address
|
||||||
|
/// that `VZMACAddress.string` renders as `aa:bb:0c:dd:ee:ff`. Comparing raw
|
||||||
|
/// strings silently fails to match; both sides must be normalized.
|
||||||
|
///
|
||||||
|
/// Blocks accumulate: a MAC can appear more than once as leases are renewed or
|
||||||
|
/// reissued, so lookups take the **newest** lease (latest `leaseExpiry`, falling
|
||||||
|
/// back to last-in-file when expiry is missing).
|
||||||
|
///
|
||||||
|
/// - Note: macOS's DHCP lease time is 24 hours. That is exactly why clones must
|
||||||
|
/// reuse a small set of **persistent per-slot MACs** rather than randomizing a
|
||||||
|
/// MAC per VM: a randomized fleet would fill this file with day-long stale
|
||||||
|
/// leases and exhaust the NAT subnet.
|
||||||
|
public enum DHCPLeaseParser {
|
||||||
|
/// The canonical path of the lease database.
|
||||||
|
public static let defaultPath = "/var/db/dhcpd_leases"
|
||||||
|
|
||||||
|
/// Parses the whole file.
|
||||||
|
///
|
||||||
|
/// Malformed blocks are skipped rather than throwing — the file is written
|
||||||
|
/// by another process and may be observed mid-write.
|
||||||
|
///
|
||||||
|
/// - Parameter text: The file's contents.
|
||||||
|
/// - Returns: Leases in file order.
|
||||||
|
public static func parse(_ text: String) -> [DHCPLease] {
|
||||||
|
var leases: [DHCPLease] = []
|
||||||
|
var fields: [String: String] = [:]
|
||||||
|
var inBlock = false
|
||||||
|
|
||||||
|
for rawLine in text.split(separator: "\n", omittingEmptySubsequences: false) {
|
||||||
|
let line = rawLine.trimmingCharacters(in: .whitespaces)
|
||||||
|
if line.isEmpty { continue }
|
||||||
|
|
||||||
|
if line.hasPrefix("{") {
|
||||||
|
// A `{` while already inside a block means the previous one was
|
||||||
|
// truncated (the file is written by bootpd and can be observed
|
||||||
|
// mid-write). Drop it and start over rather than merging.
|
||||||
|
inBlock = true
|
||||||
|
fields = [:]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if line.hasPrefix("}") {
|
||||||
|
if inBlock, let lease = makeLease(from: fields) { leases.append(lease) }
|
||||||
|
inBlock = false
|
||||||
|
fields = [:]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
guard inBlock, let separator = line.firstIndex(of: "=") else { continue }
|
||||||
|
let key = line[line.startIndex..<separator].trimmingCharacters(in: .whitespaces).lowercased()
|
||||||
|
let value = line[line.index(after: separator)...].trimmingCharacters(in: .whitespaces)
|
||||||
|
if key.isEmpty { continue }
|
||||||
|
fields[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
return leases
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a lease from one block's `key=value` pairs, or `nil` when the block
|
||||||
|
/// lacks the two fields that make it useful (an address and a MAC we can
|
||||||
|
/// normalize). Never throws: a half-written block is simply not a lease.
|
||||||
|
private static func makeLease(from fields: [String: String]) -> DHCPLease? {
|
||||||
|
guard
|
||||||
|
let ip = fields["ip_address"], !ip.isEmpty,
|
||||||
|
let rawMAC = fields["hw_address"] ?? fields["identifier"],
|
||||||
|
let mac = normalizeMAC(rawMAC)
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
let name = fields["name"].flatMap { $0.isEmpty ? nil : $0 }
|
||||||
|
return DHCPLease(
|
||||||
|
name: name,
|
||||||
|
ipAddress: ip,
|
||||||
|
hwAddress: mac,
|
||||||
|
leaseExpiry: fields["lease"].flatMap(parseLeaseTime)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a `lease=` value. `bootpd` writes a hex epoch (`0x66b2c0de`), but
|
||||||
|
/// a plain decimal epoch has been observed too, so both are accepted.
|
||||||
|
private static func parseLeaseTime(_ raw: String) -> Date? {
|
||||||
|
let text = raw.trimmingCharacters(in: .whitespaces).lowercased()
|
||||||
|
guard !text.isEmpty else { return nil }
|
||||||
|
|
||||||
|
let seconds: UInt64?
|
||||||
|
if text.hasPrefix("0x") {
|
||||||
|
seconds = UInt64(text.dropFirst(2), radix: 16)
|
||||||
|
} else {
|
||||||
|
seconds = UInt64(text, radix: 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let seconds else { return nil }
|
||||||
|
return Date(timeIntervalSince1970: TimeInterval(seconds))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads and parses the lease database from disk.
|
||||||
|
///
|
||||||
|
/// - Parameter path: Defaults to ``defaultPath``.
|
||||||
|
/// - Returns: Leases, or `[]` when the file does not exist yet (no guest has
|
||||||
|
/// ever leased an address).
|
||||||
|
public static func parseFile(at path: String = DHCPLeaseParser.defaultPath) -> [DHCPLease] {
|
||||||
|
guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return [] }
|
||||||
|
return parse(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finds the current IP for a MAC.
|
||||||
|
///
|
||||||
|
/// Both `mac` and each lease's `hwAddress` are normalized before comparison.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - mac: The guest's MAC, in any common rendering.
|
||||||
|
/// - leases: Leases from ``parse(_:)``.
|
||||||
|
/// - Returns: The newest matching lease's IP, or `nil`.
|
||||||
|
public static func ipAddress(forMAC mac: String, in leases: [DHCPLease]) -> String? {
|
||||||
|
lease(forMAC: mac, in: leases)?.ipAddress
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finds the newest lease for a MAC.
|
||||||
|
///
|
||||||
|
/// Callers that must distinguish a *fresh* lease from the 24 h-old one the
|
||||||
|
/// slot's previous guest left behind need the whole record, not just its
|
||||||
|
/// address — see ``isNewer(_:than:)``.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - mac: The guest's MAC, in any common rendering.
|
||||||
|
/// - leases: Leases from ``parse(_:)``.
|
||||||
|
/// - Returns: The newest matching lease, or `nil`.
|
||||||
|
public static func lease(forMAC mac: String, in leases: [DHCPLease]) -> DHCPLease? {
|
||||||
|
guard let wanted = normalizeMAC(mac) else { return nil }
|
||||||
|
|
||||||
|
var best: DHCPLease?
|
||||||
|
for lease in leases where lease.hwAddress == wanted {
|
||||||
|
guard let current = best else {
|
||||||
|
best = lease
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Newest expiry wins; a missing expiry sorts oldest. `>=` means that
|
||||||
|
// among equally-dated (or equally-undated) duplicates the last block
|
||||||
|
// in the file wins, which is the one bootpd wrote most recently.
|
||||||
|
let candidate = lease.leaseExpiry ?? .distantPast
|
||||||
|
let incumbent = current.leaseExpiry ?? .distantPast
|
||||||
|
if candidate >= incumbent { best = lease }
|
||||||
|
}
|
||||||
|
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `candidate` is a lease `bootpd` wrote *after* `previous`.
|
||||||
|
///
|
||||||
|
/// Slot MACs are persistent and macOS leases live 24 h, so a MAC almost
|
||||||
|
/// always still has its previous guest's entry when the next clone boots.
|
||||||
|
/// A caller that accepted the first entry it saw would hand out a stale
|
||||||
|
/// address and then spend the whole boot timeout SSHing at nothing.
|
||||||
|
///
|
||||||
|
/// `bootpd` rewrites the block — bumping `lease=` — whenever it hands the
|
||||||
|
/// address out again, so a strictly later expiry means a new lease. A
|
||||||
|
/// changed address means the same thing. With no `previous` (first boot on
|
||||||
|
/// this MAC) anything counts as new.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - candidate: The lease just read from the file.
|
||||||
|
/// - previous: The lease observed before the guest was started.
|
||||||
|
/// - Returns: `true` when `candidate` may be used.
|
||||||
|
public static func isNewer(_ candidate: DHCPLease, than previous: DHCPLease?) -> Bool {
|
||||||
|
guard let previous else { return true }
|
||||||
|
if candidate.ipAddress != previous.ipAddress { return true }
|
||||||
|
guard let previousExpiry = previous.leaseExpiry else { return true }
|
||||||
|
guard let candidateExpiry = candidate.leaseExpiry else { return false }
|
||||||
|
return candidateExpiry > previousExpiry
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalizes a MAC to lowercase, colon-separated, zero-padded octets.
|
||||||
|
///
|
||||||
|
/// Accepts an optional `<type>,` prefix (as written by `bootpd`), and
|
||||||
|
/// tolerates `-` separators.
|
||||||
|
///
|
||||||
|
/// - Parameter raw: For example `1,aa:bb:c:dd:ee:ff` or `AA-BB-0C-DD-EE-FF`.
|
||||||
|
/// - Returns: For example `aa:bb:0c:dd:ee:ff`, or `nil` if unparseable.
|
||||||
|
public static func normalizeMAC(_ raw: String) -> String? {
|
||||||
|
var text = raw.trimmingCharacters(in: .whitespaces)
|
||||||
|
|
||||||
|
// `bootpd` prefixes the hardware type: `1,` for Ethernet.
|
||||||
|
if let comma = text.lastIndex(of: ",") {
|
||||||
|
text = String(text[text.index(after: comma)...])
|
||||||
|
}
|
||||||
|
text = text.replacingOccurrences(of: "-", with: ":")
|
||||||
|
|
||||||
|
let octets = text.split(separator: ":", omittingEmptySubsequences: false)
|
||||||
|
guard octets.count == 6 else { return nil }
|
||||||
|
|
||||||
|
var normalized: [String] = []
|
||||||
|
normalized.reserveCapacity(6)
|
||||||
|
for octet in octets {
|
||||||
|
guard (1...2).contains(octet.count), octet.allSatisfy(\.isHexDigit) else { return nil }
|
||||||
|
normalized.append(String(repeating: "0", count: 2 - octet.count) + octet.lowercased())
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized.joined(separator: ":")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
#if canImport(FoundationNetworking)
|
||||||
|
import FoundationNetworking
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// The HTTP seam under ``GiteaClient``.
|
||||||
|
///
|
||||||
|
/// Everything network-facing goes through this protocol so tests can supply a
|
||||||
|
/// canned transport without a live Gitea instance.
|
||||||
|
public protocol HTTPTransport: Sendable {
|
||||||
|
/// Performs a request.
|
||||||
|
///
|
||||||
|
/// - Parameter request: A fully-formed request, including auth headers.
|
||||||
|
/// - Returns: The response body and its HTTP status code.
|
||||||
|
/// - Throws: Transport-level errors only; a non-2xx status is *not* an error
|
||||||
|
/// here — ``GiteaClient`` maps that to ``CoreError/gitea(status:message:)``.
|
||||||
|
func send(_ request: URLRequest) async throws -> (Data, Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The production transport, backed by `URLSession`.
|
||||||
|
public struct URLSessionTransport: HTTPTransport {
|
||||||
|
/// The underlying session.
|
||||||
|
public let session: URLSession
|
||||||
|
|
||||||
|
/// Creates a transport.
|
||||||
|
///
|
||||||
|
/// - Parameter session: Defaults to an ephemeral session with a 30 s request
|
||||||
|
/// timeout, so a hung Gitea cannot stall the poll loop.
|
||||||
|
public init(session: URLSession = URLSessionTransport.makeDefaultSession()) {
|
||||||
|
self.session = session
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the default ephemeral session.
|
||||||
|
public static func makeDefaultSession() -> URLSession {
|
||||||
|
let cfg = URLSessionConfiguration.ephemeral
|
||||||
|
cfg.timeoutIntervalForRequest = 30
|
||||||
|
cfg.timeoutIntervalForResource = 60
|
||||||
|
return URLSession(configuration: cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func send(_ request: URLRequest) async throws -> (Data, Int) {
|
||||||
|
// `dataTask` + a continuation rather than `session.data(for:)`, because
|
||||||
|
// the async URLSession API is not uniformly available in
|
||||||
|
// swift-corelibs-foundation, and RunnerCore must build on Linux.
|
||||||
|
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<(Data, Int), Error>) in
|
||||||
|
let task = session.dataTask(with: request) { data, response, error in
|
||||||
|
if let error {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let http = response as? HTTPURLResponse else {
|
||||||
|
continuation.resume(
|
||||||
|
throwing: CoreError.gitea(status: 0, message: "no HTTP response"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continuation.resume(returning: (data ?? Data(), http.statusCode))
|
||||||
|
}
|
||||||
|
task.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A thin, typed client for the subset of Gitea's admin Actions API this daemon
|
||||||
|
/// needs.
|
||||||
|
///
|
||||||
|
/// All endpoints used here are **admin**-scoped, so the token must belong to a
|
||||||
|
/// Gitea administrator. `doctor` verifies that by calling ``listRunners()``.
|
||||||
|
public struct GiteaClient: Sendable {
|
||||||
|
/// Instance base URL, e.g. `https://gitea.example.com`.
|
||||||
|
public let baseURL: URL
|
||||||
|
/// Admin API token, sent as `Authorization: token <value>`.
|
||||||
|
public let token: String
|
||||||
|
/// The HTTP seam.
|
||||||
|
public let transport: any HTTPTransport
|
||||||
|
|
||||||
|
/// Creates a client.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - baseURL: Instance base URL; a trailing slash is tolerated.
|
||||||
|
/// - token: Admin API token.
|
||||||
|
/// - transport: Defaults to ``URLSessionTransport``.
|
||||||
|
public init(baseURL: URL, token: String, transport: any HTTPTransport = URLSessionTransport()) {
|
||||||
|
self.baseURL = baseURL
|
||||||
|
self.token = token
|
||||||
|
self.transport = transport
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Endpoints
|
||||||
|
|
||||||
|
/// Lists jobs currently waiting for a runner.
|
||||||
|
///
|
||||||
|
/// `GET /api/v1/admin/actions/jobs?status=queued&limit=<limit>`
|
||||||
|
///
|
||||||
|
/// A queued job stays queued until a matching runner claims it, or until
|
||||||
|
/// Gitea's `ABANDONED_JOB_TIMEOUT` (default 24 h, swept every 6 h) expires
|
||||||
|
/// it. There is therefore no urgency risk in a 5-second poll.
|
||||||
|
///
|
||||||
|
/// - Parameter limit: Page size. The scheduler only ever needs a handful.
|
||||||
|
/// - Returns: The queued jobs, oldest-first as Gitea returns them.
|
||||||
|
/// - Throws: ``CoreError/gitea(status:message:)`` on a non-2xx response.
|
||||||
|
public func listQueuedJobs(limit: Int = 50) async throws -> [WorkflowJob] {
|
||||||
|
// `status=queued` and nothing else. Gitea's `convertToInternal` maps
|
||||||
|
// "queued" onto StatusWaiting ("ready, waiting for a runner") and maps
|
||||||
|
// "waiting" onto StatusBlocked ("blocked on a dependency") — so asking
|
||||||
|
// for "waiting" would return exactly the jobs that must not be booted.
|
||||||
|
let request = try makeRequest(
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/v1/admin/actions/jobs",
|
||||||
|
query: [
|
||||||
|
URLQueryItem(name: "status", value: "queued"),
|
||||||
|
URLQueryItem(name: "limit", value: String(max(limit, 1))),
|
||||||
|
])
|
||||||
|
let response = try await send(request, as: WorkflowJobsResponse.self)
|
||||||
|
return response.items
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists every registered runner on the instance.
|
||||||
|
///
|
||||||
|
/// `GET /api/v1/admin/actions/runners?page=<n>&limit=<limit>`
|
||||||
|
///
|
||||||
|
/// Used by the reconcile loop and by `doctor` (as an admin-scope probe).
|
||||||
|
///
|
||||||
|
/// Paginated deliberately: an unpaginated request returns only Gitea's
|
||||||
|
/// default first page, and reconcile is precisely the thing that stops an
|
||||||
|
/// instance from accumulating orphan rows. Missing rows past the page
|
||||||
|
/// boundary would let the leak accelerate — each undeleted row pushes more
|
||||||
|
/// rows out of view — and it would silently no-op the targeted cleanup that
|
||||||
|
/// looks a single runner up by name.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - limit: Page size.
|
||||||
|
/// - maxPages: Defensive ceiling, so a server that ignores `page` cannot
|
||||||
|
/// spin this forever.
|
||||||
|
/// - Returns: Every runner across all fetched pages, in server order.
|
||||||
|
public func listRunners(limit: Int = 50, maxPages: Int = 50) async throws -> [ActionRunner] {
|
||||||
|
let pageSize = max(limit, 1)
|
||||||
|
var all: [ActionRunner] = []
|
||||||
|
|
||||||
|
for page in 1...max(maxPages, 1) {
|
||||||
|
let request = try makeRequest(
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/v1/admin/actions/runners",
|
||||||
|
query: [
|
||||||
|
URLQueryItem(name: "page", value: String(page)),
|
||||||
|
URLQueryItem(name: "limit", value: String(pageSize)),
|
||||||
|
])
|
||||||
|
let response = try await send(request, as: RunnersResponse.self)
|
||||||
|
all.append(contentsOf: response.items)
|
||||||
|
// Terminate on the server's own total rather than on a short page:
|
||||||
|
// Gitea clamps `limit` to its configured maximum, so a page shorter
|
||||||
|
// than the one we asked for is not evidence that it is the last.
|
||||||
|
if response.items.isEmpty { break }
|
||||||
|
if let total = response.totalCount, all.count >= total { break }
|
||||||
|
}
|
||||||
|
|
||||||
|
return all
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes a runner row.
|
||||||
|
///
|
||||||
|
/// `DELETE /api/v1/admin/actions/runners/{id}`
|
||||||
|
///
|
||||||
|
/// Needed because a VM that dies uncleanly leaves its row behind: Gitea only
|
||||||
|
/// sweeps runner rows at midnight, and never sweeps a runner that claimed no
|
||||||
|
/// task. A 404 is treated as success (someone else already removed it).
|
||||||
|
///
|
||||||
|
/// - Parameter id: The runner id.
|
||||||
|
public func deleteRunner(id: Int64) async throws {
|
||||||
|
let request = try makeRequest(
|
||||||
|
method: "DELETE",
|
||||||
|
path: "/api/v1/admin/actions/runners/\(id)")
|
||||||
|
// Gitea answers 204. 200 is accepted for tolerance, and 404 counts as
|
||||||
|
// success: the reconcile loop's only goal is that the row be gone, and
|
||||||
|
// it races with Gitea's own midnight sweep and with `--ephemeral`
|
||||||
|
// auto-deregistration.
|
||||||
|
try await sendIgnoringBody(request, acceptingStatuses: [200, 202, 204, 404])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the instance-scoped runner registration token.
|
||||||
|
///
|
||||||
|
/// `POST /api/v1/admin/actions/runners/registration-token`
|
||||||
|
///
|
||||||
|
/// - Warning: In current Gitea this returns the *existing* active token for
|
||||||
|
/// the scope rather than minting a new one — but the semantics of "mint"
|
||||||
|
/// are that a new token **invalidates all prior tokens of that scope**.
|
||||||
|
/// Never call this per VM as a way of getting a throwaway secret; call it
|
||||||
|
/// once and cache. Prefer seeding the token server-side via
|
||||||
|
/// `GITEA_RUNNER_REGISTRATION_TOKEN` and configuring it statically.
|
||||||
|
public func getRegistrationToken() async throws -> String {
|
||||||
|
let request = try makeRequest(
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/v1/admin/actions/runners/registration-token")
|
||||||
|
let response = try await send(request, as: RegistrationTokenResponse.self)
|
||||||
|
let token = response.token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !token.isEmpty else {
|
||||||
|
throw CoreError.gitea(status: 200, message: "registration-token response carried an empty token")
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cheap reachability + auth probe used by `doctor`.
|
||||||
|
///
|
||||||
|
/// - Throws: ``CoreError/gitea(status:message:)`` when the instance is
|
||||||
|
/// reachable but rejects the token.
|
||||||
|
public func ping() async throws {
|
||||||
|
// The runners list rather than /api/v1/version: version is anonymously
|
||||||
|
// readable on most instances, so it would report "reachable" for a token
|
||||||
|
// that is expired, wrong, or simply not an admin's — which is the exact
|
||||||
|
// failure `doctor` exists to catch.
|
||||||
|
_ = try await listRunners()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Request plumbing
|
||||||
|
|
||||||
|
/// Builds an authenticated request against an API path.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - method: HTTP method.
|
||||||
|
/// - path: API path relative to the instance root, e.g.
|
||||||
|
/// `/api/v1/admin/actions/runners`.
|
||||||
|
/// - query: Optional query items.
|
||||||
|
/// - body: Optional request body; sets `Content-Type: application/json`.
|
||||||
|
/// - Returns: A request carrying `Authorization` and `Accept` headers.
|
||||||
|
public func makeRequest(
|
||||||
|
method: String,
|
||||||
|
path: String,
|
||||||
|
query: [URLQueryItem] = [],
|
||||||
|
body: Data? = nil
|
||||||
|
) throws -> URLRequest {
|
||||||
|
// Built by string-joining rather than `URL(string:relativeTo:)`, which
|
||||||
|
// would discard any path component of `baseURL` — instances served under
|
||||||
|
// a subpath (https://example.com/gitea) are common enough to matter.
|
||||||
|
var base = baseURL.absoluteString
|
||||||
|
while base.hasSuffix("/") { base.removeLast() }
|
||||||
|
let suffix = path.hasPrefix("/") ? path : "/" + path
|
||||||
|
|
||||||
|
guard var components = URLComponents(string: base + suffix) else {
|
||||||
|
throw CoreError.configInvalid("cannot form a request URL from \(base + suffix)")
|
||||||
|
}
|
||||||
|
if !query.isEmpty {
|
||||||
|
components.queryItems = query
|
||||||
|
}
|
||||||
|
guard let url = components.url else {
|
||||||
|
throw CoreError.configInvalid("cannot form a request URL from \(base + suffix)")
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = method
|
||||||
|
// Gitea's PAT scheme. `Bearer` also works on recent versions, but
|
||||||
|
// `token` is the documented form and works on every 1.x.
|
||||||
|
request.setValue("token \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
request.setValue(
|
||||||
|
"gitea-macos-runner/\(RunnerVersion.current)", forHTTPHeaderField: "User-Agent")
|
||||||
|
if let body {
|
||||||
|
request.httpBody = body
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
}
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends a request and decodes a JSON body, mapping non-2xx to
|
||||||
|
/// ``CoreError/gitea(status:message:)``.
|
||||||
|
public func send<T: Decodable>(_ request: URLRequest, as type: T.Type) async throws -> T {
|
||||||
|
let (data, status) = try await transport.send(request)
|
||||||
|
guard (200..<300).contains(status) else {
|
||||||
|
throw CoreError.gitea(status: status, message: GiteaClient.errorMessage(from: data))
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
return try GiteaClient.makeDecoder().decode(T.self, from: data)
|
||||||
|
} catch {
|
||||||
|
throw CoreError.gitea(
|
||||||
|
status: status,
|
||||||
|
message: "could not decode \(T.self): \(error) — body: \(GiteaClient.excerpt(data))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends a request that is expected to have no useful body.
|
||||||
|
public func sendIgnoringBody(_ request: URLRequest, acceptingStatuses: Set<Int>) async throws {
|
||||||
|
let (data, status) = try await transport.send(request)
|
||||||
|
guard acceptingStatuses.contains(status) || (200..<300).contains(status) else {
|
||||||
|
throw CoreError.gitea(status: status, message: GiteaClient.errorMessage(from: data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gitea's error bodies are `{"message": "...", "url": "..."}`. Prefer that
|
||||||
|
/// message; fall back to a truncated raw body so nothing is ever reported as
|
||||||
|
/// an empty error.
|
||||||
|
private static func errorMessage(from data: Data) -> String {
|
||||||
|
struct APIError: Decodable {
|
||||||
|
let message: String?
|
||||||
|
let errors: [String]?
|
||||||
|
}
|
||||||
|
if let decoded = try? JSONDecoder().decode(APIError.self, from: data) {
|
||||||
|
if let message = decoded.message?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!message.isEmpty
|
||||||
|
{
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
if let errors = decoded.errors, !errors.isEmpty {
|
||||||
|
return errors.joined(separator: "; ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return excerpt(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// At most `limit` characters of a response body, for error messages.
|
||||||
|
private static func excerpt(_ data: Data, limit: Int = 512) -> String {
|
||||||
|
guard !data.isEmpty else { return "<empty body>" }
|
||||||
|
let text = String(decoding: data, as: UTF8.self)
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard text.count > limit else { return text }
|
||||||
|
return String(text.prefix(limit)) + "… (\(data.count) bytes)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `JSONDecoder` configured for Gitea's timestamps (RFC 3339 / ISO 8601
|
||||||
|
/// with an offset).
|
||||||
|
///
|
||||||
|
/// Go's `time.Time` marshals as RFC 3339 **Nano**: the fractional-seconds
|
||||||
|
/// part is present only when non-zero, so a single strict formatter fails
|
||||||
|
/// intermittently on real traffic. Both spellings are tried, plus a plain
|
||||||
|
/// `YYYY-MM-DD` for good measure.
|
||||||
|
public static func makeDecoder() -> JSONDecoder {
|
||||||
|
let d = JSONDecoder()
|
||||||
|
d.dateDecodingStrategy = .custom { decoder in
|
||||||
|
let raw = try decoder.singleValueContainer().decode(String.self)
|
||||||
|
if let date = parseTimestamp(raw) { return date }
|
||||||
|
throw DecodingError.dataCorrupted(
|
||||||
|
DecodingError.Context(
|
||||||
|
codingPath: decoder.codingPath,
|
||||||
|
debugDescription: "not an RFC 3339 timestamp: \"\(raw)\""))
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses an RFC 3339 timestamp with or without fractional seconds.
|
||||||
|
///
|
||||||
|
/// - Parameter raw: The timestamp string.
|
||||||
|
/// - Returns: The instant, or `nil` if it is in no recognized form.
|
||||||
|
public static func parseTimestamp(_ raw: String) -> Date? {
|
||||||
|
// Formatters are built per call rather than cached in a `static let`:
|
||||||
|
// `ISO8601DateFormatter` is a non-Sendable reference type, and this is
|
||||||
|
// called a handful of times per poll — not a hot path.
|
||||||
|
let withFractional = ISO8601DateFormatter()
|
||||||
|
withFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
if let date = withFractional.date(from: raw) { return date }
|
||||||
|
|
||||||
|
let plain = ISO8601DateFormatter()
|
||||||
|
plain.formatOptions = [.withInternetDateTime]
|
||||||
|
if let date = plain.date(from: raw) { return date }
|
||||||
|
|
||||||
|
let dateOnly = ISO8601DateFormatter()
|
||||||
|
dateOnly.formatOptions = [.withFullDate]
|
||||||
|
return dateOnly.date(from: raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A single job within a workflow run, as reported by
|
||||||
|
/// `GET /api/v1/admin/actions/jobs` (Gitea 1.25+).
|
||||||
|
///
|
||||||
|
/// - Note: `status` values are the *external* strings. `queued` is the one we
|
||||||
|
/// act on: it maps to Gitea's internal `StatusWaiting`, meaning "ready and
|
||||||
|
/// waiting for a matching runner". The string `waiting` means something quite
|
||||||
|
/// different — the job is **blocked** on a dependency — and must never be
|
||||||
|
/// treated as schedulable.
|
||||||
|
public struct WorkflowJob: Codable, Sendable, Equatable, Identifiable {
|
||||||
|
/// Job id. Unique across the instance; the scheduler dedups on this.
|
||||||
|
public let id: Int64
|
||||||
|
/// The workflow run this job belongs to.
|
||||||
|
public let runID: Int64
|
||||||
|
/// The job's display name.
|
||||||
|
public let name: String
|
||||||
|
/// One of `queued`, `waiting`, `running`, `success`, `failure`, `cancelled`,
|
||||||
|
/// `skipped`, `blocked`.
|
||||||
|
public let status: String
|
||||||
|
/// The job's `runs-on:` values, as **bare** label names.
|
||||||
|
public let labels: [String]
|
||||||
|
/// The runner that claimed the job, if any.
|
||||||
|
public let runnerID: Int64?
|
||||||
|
/// That runner's name, if any. Lets the reconcile loop tie a Gitea runner
|
||||||
|
/// row back to one of our VMs.
|
||||||
|
public let runnerName: String?
|
||||||
|
/// When the job was created.
|
||||||
|
public let createdAt: Date?
|
||||||
|
/// When a runner picked it up.
|
||||||
|
public let startedAt: Date?
|
||||||
|
/// When it finished.
|
||||||
|
public let completedAt: Date?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: Int64,
|
||||||
|
runID: Int64,
|
||||||
|
name: String,
|
||||||
|
status: String,
|
||||||
|
labels: [String],
|
||||||
|
runnerID: Int64? = nil,
|
||||||
|
runnerName: String? = nil,
|
||||||
|
createdAt: Date? = nil,
|
||||||
|
startedAt: Date? = nil,
|
||||||
|
completedAt: Date? = nil
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.runID = runID
|
||||||
|
self.name = name
|
||||||
|
self.status = status
|
||||||
|
self.labels = labels
|
||||||
|
self.runnerID = runnerID
|
||||||
|
self.runnerName = runnerName
|
||||||
|
self.createdAt = createdAt
|
||||||
|
self.startedAt = startedAt
|
||||||
|
self.completedAt = completedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case runID = "run_id"
|
||||||
|
case name
|
||||||
|
case status
|
||||||
|
case labels
|
||||||
|
case runnerID = "runner_id"
|
||||||
|
case runnerName = "runner_name"
|
||||||
|
case createdAt = "created_at"
|
||||||
|
case startedAt = "started_at"
|
||||||
|
case completedAt = "completed_at"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this job is waiting for a runner right now.
|
||||||
|
public var isQueued: Bool { status == "queued" }
|
||||||
|
|
||||||
|
/// ``status`` as a case, with an ``JobStatus/unknown(_:)`` catch-all.
|
||||||
|
public var jobStatus: JobStatus { JobStatus(rawValue: status) }
|
||||||
|
|
||||||
|
/// Decodes tolerantly: `runner_id` / `runner_name` carry `omitempty` in
|
||||||
|
/// Gitea, and the timestamps are Go `time.Time` values that serialize as
|
||||||
|
/// `0001-01-01T00:00:00Z` when unset (a queued job has no `started_at`).
|
||||||
|
/// Those zero instants are surfaced as `nil` rather than as a year-1 date.
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
self.id = try c.decode(Int64.self, forKey: .id)
|
||||||
|
self.runID = try c.decodeIfPresent(Int64.self, forKey: .runID) ?? 0
|
||||||
|
self.name = try c.decodeIfPresent(String.self, forKey: .name) ?? ""
|
||||||
|
self.status = try c.decodeIfPresent(String.self, forKey: .status) ?? ""
|
||||||
|
self.labels = try c.decodeIfPresent([String].self, forKey: .labels) ?? []
|
||||||
|
self.runnerID = try c.decodeIfPresent(Int64.self, forKey: .runnerID)
|
||||||
|
self.runnerName = try c.decodeIfPresent(String.self, forKey: .runnerName)
|
||||||
|
self.createdAt = WorkflowJob.nonZero(try c.decodeIfPresent(Date.self, forKey: .createdAt))
|
||||||
|
self.startedAt = WorkflowJob.nonZero(try c.decodeIfPresent(Date.self, forKey: .startedAt))
|
||||||
|
self.completedAt = WorkflowJob.nonZero(try c.decodeIfPresent(Date.self, forKey: .completedAt))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps Go's zero `time.Time` (year 1) to `nil`.
|
||||||
|
private static func nonZero(_ date: Date?) -> Date? {
|
||||||
|
guard let date else { return nil }
|
||||||
|
// 0001-01-01T00:00:00Z is ~62.1e9 seconds before the reference date.
|
||||||
|
return date.timeIntervalSinceReferenceDate <= -62_135_596_800 ? nil : date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The *external* status strings Gitea reports for a workflow job.
|
||||||
|
///
|
||||||
|
/// Gitea maps its internal statuses onto GitHub's vocabulary in
|
||||||
|
/// `convert.ToActionsStatus`: `StatusWaiting → "queued"`,
|
||||||
|
/// `StatusBlocked → "waiting"`, `StatusRunning → "in_progress"`, and every
|
||||||
|
/// terminal status → `"completed"` (the detail moves to a separate `conclusion`
|
||||||
|
/// field). The `unknown` case exists because that mapping is Gitea's to change:
|
||||||
|
/// a closed enum that threw on an unrecognized string would turn a new server
|
||||||
|
/// version into a decode failure and stop the poll loop dead.
|
||||||
|
public enum JobStatus: RawRepresentable, Sendable, Equatable, Hashable {
|
||||||
|
/// Ready and waiting for a matching runner — Gitea's internal `StatusWaiting`.
|
||||||
|
/// This is the only status that is schedulable.
|
||||||
|
case queued
|
||||||
|
/// **Blocked** on a dependency — Gitea's internal `StatusBlocked`. Despite
|
||||||
|
/// the name, this is *not* a job waiting for a runner.
|
||||||
|
case waiting
|
||||||
|
/// Claimed by a runner and executing.
|
||||||
|
case inProgress
|
||||||
|
/// Terminal, in any of success / failure / cancelled / skipped.
|
||||||
|
case completed
|
||||||
|
/// A status string this build does not know about.
|
||||||
|
case unknown(String)
|
||||||
|
|
||||||
|
public init(rawValue: String) {
|
||||||
|
switch rawValue {
|
||||||
|
case "queued": self = .queued
|
||||||
|
case "waiting": self = .waiting
|
||||||
|
case "in_progress": self = .inProgress
|
||||||
|
case "completed": self = .completed
|
||||||
|
default: self = .unknown(rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var rawValue: String {
|
||||||
|
switch self {
|
||||||
|
case .queued: return "queued"
|
||||||
|
case .waiting: return "waiting"
|
||||||
|
case .inProgress: return "in_progress"
|
||||||
|
case .completed: return "completed"
|
||||||
|
case .unknown(let raw): return raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Envelope returned by `GET /api/v1/admin/actions/jobs`.
|
||||||
|
///
|
||||||
|
/// - Important: The array key has been observed as `jobs`, which is what is
|
||||||
|
/// decoded here; some Gitea builds/OpenAPI revisions have used `workflow_jobs`
|
||||||
|
/// for the equivalent repo-scoped endpoint. ``CodingKeys`` is written out
|
||||||
|
/// explicitly so that adding a fallback is a one-line change, and
|
||||||
|
/// ``jobs`` is optional so an empty response body decodes rather than throwing.
|
||||||
|
public struct WorkflowJobsResponse: Codable, Sendable, Equatable {
|
||||||
|
/// Total matching jobs server-side, ignoring `limit`.
|
||||||
|
public let totalCount: Int?
|
||||||
|
/// The page of jobs. `nil` and `[]` both mean "nothing queued".
|
||||||
|
public let jobs: [WorkflowJob]?
|
||||||
|
|
||||||
|
public init(totalCount: Int?, jobs: [WorkflowJob]?) {
|
||||||
|
self.totalCount = totalCount
|
||||||
|
self.jobs = jobs
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case totalCount = "total_count"
|
||||||
|
case jobs
|
||||||
|
case workflowJobs = "workflow_jobs"
|
||||||
|
case entries
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The jobs, never `nil`.
|
||||||
|
public var items: [WorkflowJob] { jobs ?? [] }
|
||||||
|
|
||||||
|
/// Decodes the array under `jobs`, falling back to `workflow_jobs` and
|
||||||
|
/// `entries`.
|
||||||
|
///
|
||||||
|
/// Gitea 1.25's `ActionWorkflowJobsResponse` tags its slice `json:"jobs"`
|
||||||
|
/// (the Go field is named `Entries`), which is what the fallbacks guard
|
||||||
|
/// against: a future rename of the tag, or a proxy that reserializes from
|
||||||
|
/// the Go field name.
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
self.totalCount = try c.decodeIfPresent(Int.self, forKey: .totalCount)
|
||||||
|
if let jobs = try c.decodeIfPresent([WorkflowJob].self, forKey: .jobs) {
|
||||||
|
self.jobs = jobs
|
||||||
|
} else if let jobs = try c.decodeIfPresent([WorkflowJob].self, forKey: .workflowJobs) {
|
||||||
|
self.jobs = jobs
|
||||||
|
} else {
|
||||||
|
self.jobs = try c.decodeIfPresent([WorkflowJob].self, forKey: .entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func encode(to encoder: Encoder) throws {
|
||||||
|
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try c.encodeIfPresent(totalCount, forKey: .totalCount)
|
||||||
|
try c.encodeIfPresent(jobs, forKey: .jobs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A registered Actions runner, from `GET /api/v1/admin/actions/runners`.
|
||||||
|
public struct ActionRunner: Codable, Sendable, Equatable, Identifiable {
|
||||||
|
/// Runner id, used for `DELETE /api/v1/admin/actions/runners/{id}`.
|
||||||
|
public let id: Int64
|
||||||
|
/// Runner name. Ours always start with the configured `namePrefix`.
|
||||||
|
public let name: String
|
||||||
|
/// Bare label names the runner advertises.
|
||||||
|
public let labels: [String]
|
||||||
|
/// Server-side liveness, e.g. `online` / `offline`.
|
||||||
|
public let status: String?
|
||||||
|
/// Whether the runner is currently executing a task.
|
||||||
|
public let busy: Bool?
|
||||||
|
/// Whether the runner registered with `--ephemeral`, i.e. the server will
|
||||||
|
/// hand it exactly one task and then auto-deregister it (Gitea 1.24+).
|
||||||
|
public let ephemeral: Bool?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: Int64,
|
||||||
|
name: String,
|
||||||
|
labels: [String],
|
||||||
|
status: String? = nil,
|
||||||
|
busy: Bool? = nil,
|
||||||
|
ephemeral: Bool? = nil
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.labels = labels
|
||||||
|
self.status = status
|
||||||
|
self.busy = busy
|
||||||
|
self.ephemeral = ephemeral
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id, name, labels, status, busy, ephemeral
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single entry of `ActionRunner.labels` as Gitea actually serializes it:
|
||||||
|
/// an object, not a string.
|
||||||
|
///
|
||||||
|
/// Verified against `modules/structs/repo_actions.go` at tag `v1.25.0`,
|
||||||
|
/// where `ActionRunner.Labels` is `[]*ActionRunnerLabel` and
|
||||||
|
/// `ActionRunnerLabel` is `{id int64, name string, type string}`. Only
|
||||||
|
/// ``name`` is of any use here.
|
||||||
|
private struct LabelObject: Decodable {
|
||||||
|
let name: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes `labels` from either shape.
|
||||||
|
///
|
||||||
|
/// Gitea's job payload gives labels as plain strings, its runner payload
|
||||||
|
/// gives them as objects. Both are accepted so that this one model keeps
|
||||||
|
/// working if a version, a proxy, or a hand-written fixture disagrees.
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
self.id = try c.decode(Int64.self, forKey: .id)
|
||||||
|
self.name = try c.decodeIfPresent(String.self, forKey: .name) ?? ""
|
||||||
|
self.status = try c.decodeIfPresent(String.self, forKey: .status)
|
||||||
|
self.busy = try c.decodeIfPresent(Bool.self, forKey: .busy)
|
||||||
|
self.ephemeral = try c.decodeIfPresent(Bool.self, forKey: .ephemeral)
|
||||||
|
|
||||||
|
if let strings = try? c.decode([String].self, forKey: .labels) {
|
||||||
|
self.labels = strings
|
||||||
|
} else if let objects = try? c.decode([LabelObject].self, forKey: .labels) {
|
||||||
|
self.labels = objects.compactMap(\.name)
|
||||||
|
} else {
|
||||||
|
// Absent or explicitly null. Gitea does emit `"labels": null` for a
|
||||||
|
// runner registered without any.
|
||||||
|
self.labels = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `busy` treated as `false` when the server omits it.
|
||||||
|
public var isBusy: Bool { busy ?? false }
|
||||||
|
|
||||||
|
/// `ephemeral` treated as `false` when the server omits it. The reconcile
|
||||||
|
/// loop only ever deletes rows it is sure are ephemeral.
|
||||||
|
public var isEphemeral: Bool { ephemeral ?? false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Envelope returned by `GET /api/v1/admin/actions/runners`.
|
||||||
|
public struct RunnersResponse: Codable, Sendable, Equatable {
|
||||||
|
public let totalCount: Int?
|
||||||
|
public let runners: [ActionRunner]?
|
||||||
|
|
||||||
|
public init(totalCount: Int?, runners: [ActionRunner]?) {
|
||||||
|
self.totalCount = totalCount
|
||||||
|
self.runners = runners
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case totalCount = "total_count"
|
||||||
|
case runners
|
||||||
|
case entries
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The runners, never `nil`.
|
||||||
|
public var items: [ActionRunner] { runners ?? [] }
|
||||||
|
|
||||||
|
/// Decodes the array under `runners` (Gitea 1.25's tag), falling back to
|
||||||
|
/// `entries` (the Go field name).
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
self.totalCount = try c.decodeIfPresent(Int.self, forKey: .totalCount)
|
||||||
|
if let runners = try c.decodeIfPresent([ActionRunner].self, forKey: .runners) {
|
||||||
|
self.runners = runners
|
||||||
|
} else {
|
||||||
|
self.runners = try c.decodeIfPresent([ActionRunner].self, forKey: .entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func encode(to encoder: Encoder) throws {
|
||||||
|
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try c.encodeIfPresent(totalCount, forKey: .totalCount)
|
||||||
|
try c.encodeIfPresent(runners, forKey: .runners)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response from `POST /api/v1/admin/actions/runners/registration-token`.
|
||||||
|
///
|
||||||
|
/// - Warning: This is scope-wide and **reusable**. Treat the returned value as
|
||||||
|
/// "the current token for this scope", not as a freshly minted per-VM secret.
|
||||||
|
public struct RegistrationTokenResponse: Codable, Sendable, Equatable {
|
||||||
|
/// The registration token.
|
||||||
|
public let token: String
|
||||||
|
|
||||||
|
public init(token: String) {
|
||||||
|
self.token = token
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
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])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Naming scheme for the ephemeral runners this host registers with Gitea.
|
||||||
|
///
|
||||||
|
/// Every booted VM registers under a **globally unique** name. That uniqueness
|
||||||
|
/// is what makes the reconcile loop safe: when a VM dies uncleanly, Gitea keeps
|
||||||
|
/// the runner row forever (rows are only swept at midnight, and never at all if
|
||||||
|
/// the runner never claimed a task), so we must be able to look at a runner row
|
||||||
|
/// and decide "this name is mine and no live VM of mine owns it" without any
|
||||||
|
/// ambiguity. A shared or reused name would make that decision impossible.
|
||||||
|
public enum RunnerNaming {
|
||||||
|
/// Generates a fresh runner name.
|
||||||
|
///
|
||||||
|
/// - Parameter prefix: The configured prefix, e.g. `macos-vm-`.
|
||||||
|
/// - Returns: `prefix` followed by a lowercase UUID, e.g.
|
||||||
|
/// `macos-vm-3f1c2f8e-...`.
|
||||||
|
public static func makeRunnerName(prefix: String) -> String {
|
||||||
|
prefix + UUID().uuidString.lowercased()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a runner name reported by Gitea was minted by this host.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - name: A runner name from `GET /api/v1/admin/actions/runners`.
|
||||||
|
/// - prefix: The configured prefix.
|
||||||
|
/// - Returns: `true` when the reconcile loop may consider deleting it.
|
||||||
|
public static func hasPrefix(_ name: String, prefix: String) -> Bool {
|
||||||
|
// An empty prefix would match every runner on the instance, including
|
||||||
|
// other people's. The reconcile loop deletes what this returns true for,
|
||||||
|
// so refuse rather than match everything. `validated()` also rejects an
|
||||||
|
// empty `namePrefix`; this is the second line of defence.
|
||||||
|
guard !prefix.isEmpty else { return false }
|
||||||
|
return name.hasPrefix(prefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,589 @@
|
|||||||
|
import Foundation
|
||||||
|
import NIOCore
|
||||||
|
import NIOPosix
|
||||||
|
import NIOSSH
|
||||||
|
|
||||||
|
/// The outcome of a command run inside a guest.
|
||||||
|
public struct SSHCommandResult: Sendable, Equatable {
|
||||||
|
/// The remote process's exit status. `0` on success.
|
||||||
|
public let exitCode: Int32
|
||||||
|
/// Captured stdout, UTF-8 decoded with lossy replacement.
|
||||||
|
public let stdout: String
|
||||||
|
/// Captured stderr, UTF-8 decoded with lossy replacement.
|
||||||
|
public let stderr: String
|
||||||
|
|
||||||
|
public init(exitCode: Int32, stdout: String, stderr: String) {
|
||||||
|
self.exitCode = exitCode
|
||||||
|
self.stdout = stdout
|
||||||
|
self.stderr = stderr
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the command exited zero.
|
||||||
|
public var succeeded: Bool { exitCode == 0 }
|
||||||
|
|
||||||
|
/// Throws ``CoreError/sshFailed(_:)`` unless the command exited zero.
|
||||||
|
///
|
||||||
|
/// - Parameter command: Echoed into the error message for context.
|
||||||
|
public func throwIfFailed(command: String) throws {
|
||||||
|
guard exitCode != 0 else { return }
|
||||||
|
let detail = stderr.isEmpty ? stdout : stderr
|
||||||
|
let trimmed = detail.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
throw CoreError.sshFailed(
|
||||||
|
"command failed (exit \(exitCode)): \(command)" + (trimmed.isEmpty ? "" : "\n\(trimmed)")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The seam for talking to a guest.
|
||||||
|
///
|
||||||
|
/// The orchestrator and the provisioner are written against this rather than
|
||||||
|
/// against ``SSHExecutor`` so that provisioning logic can be unit-tested with a
|
||||||
|
/// recording fake, and so a future vsock-based transport could be dropped in
|
||||||
|
/// without touching callers.
|
||||||
|
public protocol GuestExecutor: Sendable {
|
||||||
|
/// Runs a shell command in the guest and waits for it to exit.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - command: A `/bin/sh`-compatible command line.
|
||||||
|
/// - timeout: Wall-clock ceiling; exceeding it throws
|
||||||
|
/// ``CoreError/timeout(_:)`` and closes the channel.
|
||||||
|
/// - Returns: Exit status and captured output.
|
||||||
|
func run(_ command: String, timeout: Duration) async throws -> SSHCommandResult
|
||||||
|
|
||||||
|
/// Copies a local file into the guest.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - localPath: Source path on the host.
|
||||||
|
/// - remotePath: Destination path in the guest.
|
||||||
|
func upload(localPath: String, remotePath: String) async throws
|
||||||
|
|
||||||
|
/// Writes bytes to a guest file with an explicit mode.
|
||||||
|
///
|
||||||
|
/// Used for secrets — notably the registration token, which is written with
|
||||||
|
/// mode `0600` and deleted immediately after `gitea-runner register` reads
|
||||||
|
/// it, so it never appears in a process argument list.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - data: File contents.
|
||||||
|
/// - remotePath: Destination path in the guest.
|
||||||
|
/// - mode: Octal mode string, e.g. `"0600"`.
|
||||||
|
func uploadData(_ data: Data, remotePath: String, mode: String) async throws
|
||||||
|
}
|
||||||
|
|
||||||
|
extension GuestExecutor {
|
||||||
|
/// ``run(_:timeout:)`` with a two-minute default ceiling.
|
||||||
|
public func run(_ command: String) async throws -> SSHCommandResult {
|
||||||
|
try await run(command, timeout: .seconds(120))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs a command and throws unless it exits zero.
|
||||||
|
///
|
||||||
|
/// - Returns: The successful result.
|
||||||
|
@discardableResult
|
||||||
|
public func runChecked(_ command: String, timeout: Duration = .seconds(120)) async throws -> SSHCommandResult {
|
||||||
|
let result = try await run(command, timeout: timeout)
|
||||||
|
try result.throwIfFailed(command: command)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SSH client over swift-nio-ssh using password authentication.
|
||||||
|
///
|
||||||
|
/// Password auth (rather than keys) is deliberate: the guest is a throwaway VM
|
||||||
|
/// on a host-private NAT network whose credentials come from the same config
|
||||||
|
/// that created it, and injecting a key would mean another provisioning step
|
||||||
|
/// during the window before SSH is up.
|
||||||
|
///
|
||||||
|
/// - Important: Host keys are **not** verified. The peer is a VM this process
|
||||||
|
/// just booted, on a link no other host shares; there is no trust-on-first-use
|
||||||
|
/// story that would add security here, and pinning would break on every clone.
|
||||||
|
public final class SSHExecutor: GuestExecutor, @unchecked Sendable {
|
||||||
|
/// Guest IP, as learned from ``DHCPLeaseParser``.
|
||||||
|
public let host: String
|
||||||
|
/// SSH port; `22` for a stock guest with Remote Login enabled.
|
||||||
|
public let port: Int
|
||||||
|
/// Guest account name.
|
||||||
|
public let username: String
|
||||||
|
/// Guest account password.
|
||||||
|
public let password: String
|
||||||
|
|
||||||
|
/// Creates an executor. No connection is made until the first command.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - host: Guest IP address.
|
||||||
|
/// - port: SSH port. Defaults to `22`.
|
||||||
|
/// - username: Guest account.
|
||||||
|
/// - password: Guest password.
|
||||||
|
public init(host: String, port: Int = 22, username: String, password: String) {
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self.username = username
|
||||||
|
self.password = password
|
||||||
|
}
|
||||||
|
|
||||||
|
public func run(_ command: String, timeout: Duration) async throws -> SSHCommandResult {
|
||||||
|
do {
|
||||||
|
return try await execute(command, stdin: nil, timeout: timeout)
|
||||||
|
} catch let error as SSHTransportError {
|
||||||
|
throw error.asCoreError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func upload(localPath: String, remotePath: String) async throws {
|
||||||
|
let url = URL(fileURLWithPath: localPath)
|
||||||
|
guard let data = try? Data(contentsOf: url) else {
|
||||||
|
throw CoreError.notFound("local file for upload: \(localPath)")
|
||||||
|
}
|
||||||
|
try await uploadData(data, remotePath: remotePath, mode: "0644")
|
||||||
|
}
|
||||||
|
|
||||||
|
public func uploadData(_ data: Data, remotePath: String, mode: String) async throws {
|
||||||
|
// Deliberately not SFTP or SCP: a stock macOS guest runs an sshd whose
|
||||||
|
// subsystem set we do not control at this point in provisioning, and an
|
||||||
|
// exec channel with the payload as stdin needs nothing beyond what we
|
||||||
|
// already use for every other command.
|
||||||
|
let quotedPath = Self.shellQuote(remotePath)
|
||||||
|
guard mode.allSatisfy(\.isNumber), !mode.isEmpty else {
|
||||||
|
throw CoreError.sshFailed("invalid file mode \(mode.debugDescription) for \(remotePath)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let command = """
|
||||||
|
mkdir -p "$(dirname \(quotedPath))" && cat > \(quotedPath) && chmod \(mode) \(quotedPath)
|
||||||
|
"""
|
||||||
|
|
||||||
|
let result: SSHCommandResult
|
||||||
|
do {
|
||||||
|
result = try await execute(command, stdin: data, timeout: .seconds(300))
|
||||||
|
} catch let error as SSHTransportError {
|
||||||
|
throw error.asCoreError
|
||||||
|
}
|
||||||
|
try result.throwIfFailed(command: "upload to \(remotePath)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Releases any pooled connection and event loop resources.
|
||||||
|
///
|
||||||
|
/// Connections are not pooled — each command opens and closes its own — and
|
||||||
|
/// the event loop group is NIO's process-wide singleton, so there is nothing
|
||||||
|
/// to release. Kept so callers can be written against a lifecycle that a
|
||||||
|
/// future pooling or vsock transport may need.
|
||||||
|
public func close() async {}
|
||||||
|
|
||||||
|
// MARK: - Transport
|
||||||
|
|
||||||
|
/// Opens a connection, runs one exec channel, and tears both down.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - command: The `/bin/sh` command line to exec.
|
||||||
|
/// - stdin: Bytes to stream as the command's standard input. Standard
|
||||||
|
/// input is closed (channel EOF) either way, so a command that would
|
||||||
|
/// otherwise read from the terminal exits instead of hanging.
|
||||||
|
/// - timeout: Wall-clock ceiling on the whole exchange.
|
||||||
|
/// - Throws: ``SSHTransportError`` for connect/auth problems (which
|
||||||
|
/// ``waitForSSH(host:port:username:password:timeout:pollInterval:)`` needs
|
||||||
|
/// to tell apart), or ``CoreError/timeout(_:)`` when the ceiling elapses.
|
||||||
|
func execute(_ command: String, stdin: Data?, timeout: Duration) async throws -> SSHCommandResult {
|
||||||
|
let group = MultiThreadedEventLoopGroup.singleton
|
||||||
|
let auth = SSHAuthOutcome()
|
||||||
|
let username = self.username
|
||||||
|
let password = self.password
|
||||||
|
let host = self.host
|
||||||
|
let port = self.port
|
||||||
|
|
||||||
|
let bootstrap = ClientBootstrap(group: group)
|
||||||
|
.channelOption(ChannelOptions.socketOption(.tcp_nodelay), value: 1)
|
||||||
|
.channelInitializer { channel in
|
||||||
|
channel.eventLoop.makeCompletedFuture {
|
||||||
|
let configuration = SSHClientConfiguration(
|
||||||
|
userAuthDelegate: PasswordOnlyAuthDelegate(
|
||||||
|
username: username,
|
||||||
|
password: password,
|
||||||
|
outcome: auth
|
||||||
|
),
|
||||||
|
serverAuthDelegate: AcceptAnyHostKeyDelegate()
|
||||||
|
)
|
||||||
|
try channel.pipeline.syncOperations.addHandler(
|
||||||
|
NIOSSHHandler(
|
||||||
|
role: .client(configuration),
|
||||||
|
allocator: channel.allocator,
|
||||||
|
inboundChildChannelInitializer: nil
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let channel: Channel
|
||||||
|
do {
|
||||||
|
channel = try await bootstrap.connect(host: host, port: port).get()
|
||||||
|
} catch {
|
||||||
|
// No TCP connection at all: sshd is not listening yet (or the guest
|
||||||
|
// is unreachable). Recoverable — this is what waitForSSH retries on.
|
||||||
|
throw SSHTransportError.connectFailed(host: host, port: port, underlying: error)
|
||||||
|
}
|
||||||
|
|
||||||
|
let loop = channel.eventLoop
|
||||||
|
let resultPromise = loop.makePromise(of: SSHCommandResult.self)
|
||||||
|
let stdinBuffer = stdin.map { ByteBuffer(bytes: $0) }
|
||||||
|
let description = command
|
||||||
|
|
||||||
|
let timeoutTask = loop.scheduleTask(in: .nanoseconds(Self.nanoseconds(timeout))) {
|
||||||
|
resultPromise.fail(CoreError.timeout("ssh command on \(host): \(description)"))
|
||||||
|
channel.close(promise: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Completing an already-completed NIO promise is a no-op, so these
|
||||||
|
// racing completions are safe: whichever fires first wins.
|
||||||
|
channel.closeFuture.whenComplete { _ in
|
||||||
|
if auth.wasRejected {
|
||||||
|
resultPromise.fail(
|
||||||
|
SSHTransportError.authenticationFailed(host: host, username: username)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
resultPromise.fail(
|
||||||
|
CoreError.sshFailed("ssh connection to \(host):\(port) closed before the command finished")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
channel.pipeline.handler(type: NIOSSHHandler.self).flatMap { sshHandler -> EventLoopFuture<Channel> in
|
||||||
|
let childPromise = loop.makePromise(of: Channel.self)
|
||||||
|
sshHandler.createChannel(childPromise, channelType: .session) { child, channelType in
|
||||||
|
guard channelType == .session else {
|
||||||
|
return child.eventLoop.makeFailedFuture(
|
||||||
|
CoreError.sshFailed("unexpected SSH channel type \(channelType)")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return child.eventLoop.makeCompletedFuture {
|
||||||
|
try child.pipeline.syncOperations.addHandler(
|
||||||
|
ExecChannelHandler(
|
||||||
|
command: description,
|
||||||
|
stdin: stdinBuffer,
|
||||||
|
promise: resultPromise
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Without this the guest's EOF would close the channel before the
|
||||||
|
// exit-status request arrives.
|
||||||
|
.flatMap { child.setOption(ChannelOptions.allowRemoteHalfClosure, value: true) }
|
||||||
|
}
|
||||||
|
return childPromise.futureResult
|
||||||
|
}.whenFailure { error in
|
||||||
|
resultPromise.fail(error)
|
||||||
|
channel.close(promise: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let result = try await resultPromise.futureResult.get()
|
||||||
|
timeoutTask.cancel()
|
||||||
|
channel.close(promise: nil)
|
||||||
|
return result
|
||||||
|
} catch {
|
||||||
|
timeoutTask.cancel()
|
||||||
|
channel.close(promise: nil)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps a path (or any argument) so `/bin/sh` sees it literally.
|
||||||
|
static func shellQuote(_ value: String) -> String {
|
||||||
|
"'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func nanoseconds(_ duration: Duration) -> Int64 {
|
||||||
|
let components = duration.components
|
||||||
|
let seconds = components.seconds.multipliedReportingOverflow(by: 1_000_000_000)
|
||||||
|
guard !seconds.overflow else { return .max }
|
||||||
|
let sum = seconds.partialValue.addingReportingOverflow(
|
||||||
|
Int64(components.attoseconds / 1_000_000_000)
|
||||||
|
)
|
||||||
|
return sum.overflow ? .max : sum.partialValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Transport failures
|
||||||
|
|
||||||
|
/// Connection-level failures, kept distinct from ``CoreError`` so that
|
||||||
|
/// ``waitForSSH(host:port:username:password:timeout:pollInterval:)`` can tell
|
||||||
|
/// "sshd is not up yet" (retry) from "the password is wrong" (give up now).
|
||||||
|
enum SSHTransportError: Error {
|
||||||
|
/// No TCP connection could be established.
|
||||||
|
case connectFailed(host: String, port: Int, underlying: Error)
|
||||||
|
/// The server rejected our credentials.
|
||||||
|
case authenticationFailed(host: String, username: String)
|
||||||
|
|
||||||
|
var asCoreError: CoreError {
|
||||||
|
switch self {
|
||||||
|
case .connectFailed(let host, let port, let underlying):
|
||||||
|
return .sshFailed("cannot connect to \(host):\(port): \(underlying)")
|
||||||
|
case .authenticationFailed(let host, let username):
|
||||||
|
return .sshFailed("authentication failed for \(username)@\(host)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared, thread-safe record of whether the server rejected our password.
|
||||||
|
///
|
||||||
|
/// The auth delegate runs on the event loop; the value is read from the async
|
||||||
|
/// caller, hence the lock.
|
||||||
|
final class SSHAuthOutcome: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var rejected = false
|
||||||
|
|
||||||
|
var wasRejected: Bool {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return rejected
|
||||||
|
}
|
||||||
|
|
||||||
|
func markRejected() {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
rejected = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Delegates
|
||||||
|
|
||||||
|
/// Accepts every host key.
|
||||||
|
///
|
||||||
|
/// The peer is a VM this process booted seconds ago, on a host-private NAT link
|
||||||
|
/// no other machine shares, from an image that is destroyed after one job. Its
|
||||||
|
/// host key is freshly generated per clone, so there is nothing to pin:
|
||||||
|
/// trust-on-first-use would accept whatever the first connection presented —
|
||||||
|
/// exactly what this does — while a pinned key would reject every legitimate
|
||||||
|
/// guest. See docs/DESIGN.md §6.
|
||||||
|
final class AcceptAnyHostKeyDelegate: NIOSSHClientServerAuthenticationDelegate {
|
||||||
|
func validateHostKey(hostKey: NIOSSHPublicKey, validationCompletePromise: EventLoopPromise<Void>) {
|
||||||
|
validationCompletePromise.succeed(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Offers the configured password once, then reports rejection.
|
||||||
|
///
|
||||||
|
/// NIOSSH asks again after a failed attempt; a second ask means the server
|
||||||
|
/// refused the first, which is a provisioning bug rather than a transient
|
||||||
|
/// condition, so it is recorded for the caller to fail fast on.
|
||||||
|
final class PasswordOnlyAuthDelegate: NIOSSHClientUserAuthenticationDelegate {
|
||||||
|
private let username: String
|
||||||
|
private let password: String
|
||||||
|
private let outcome: SSHAuthOutcome
|
||||||
|
private var offered = false
|
||||||
|
|
||||||
|
init(username: String, password: String, outcome: SSHAuthOutcome) {
|
||||||
|
self.username = username
|
||||||
|
self.password = password
|
||||||
|
self.outcome = outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
func nextAuthenticationType(
|
||||||
|
availableMethods: NIOSSHAvailableUserAuthenticationMethods,
|
||||||
|
nextChallengePromise: EventLoopPromise<NIOSSHUserAuthenticationOffer?>
|
||||||
|
) {
|
||||||
|
guard !offered, availableMethods.contains(.password) else {
|
||||||
|
// Either the server refused our password, or it never offered
|
||||||
|
// password auth at all. Both mean this guest will not let us in.
|
||||||
|
outcome.markRejected()
|
||||||
|
nextChallengePromise.succeed(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
offered = true
|
||||||
|
nextChallengePromise.succeed(
|
||||||
|
NIOSSHUserAuthenticationOffer(
|
||||||
|
username: username,
|
||||||
|
serviceName: "",
|
||||||
|
offer: .password(.init(password: password))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Exec channel
|
||||||
|
|
||||||
|
/// Drives one exec channel: sends the request, streams stdin, splits stdout from
|
||||||
|
/// stderr, and captures the exit status.
|
||||||
|
final class ExecChannelHandler: ChannelInboundHandler {
|
||||||
|
typealias InboundIn = SSHChannelData
|
||||||
|
typealias OutboundOut = SSHChannelData
|
||||||
|
|
||||||
|
/// SSH channel data is framed into packets; keep writes comfortably under
|
||||||
|
/// the 128 KiB default maximum packet size.
|
||||||
|
private static let chunkSize = 32 * 1024
|
||||||
|
|
||||||
|
private let command: String
|
||||||
|
private var stdin: ByteBuffer?
|
||||||
|
private var promise: EventLoopPromise<SSHCommandResult>?
|
||||||
|
|
||||||
|
private var stdout = ByteBufferAllocator().buffer(capacity: 0)
|
||||||
|
private var stderr = ByteBufferAllocator().buffer(capacity: 0)
|
||||||
|
private var exitCode: Int32?
|
||||||
|
|
||||||
|
init(command: String, stdin: ByteBuffer?, promise: EventLoopPromise<SSHCommandResult>) {
|
||||||
|
self.command = command
|
||||||
|
self.stdin = stdin
|
||||||
|
self.promise = promise
|
||||||
|
}
|
||||||
|
|
||||||
|
func channelActive(context: ChannelHandlerContext) {
|
||||||
|
let request = SSHChannelRequestEvent.ExecRequest(command: command, wantReply: true)
|
||||||
|
let sent = context.eventLoop.makePromise(of: Void.self)
|
||||||
|
// Capture only Sendable values: the handler and its context must not
|
||||||
|
// escape onto another thread.
|
||||||
|
let resultPromise = promise
|
||||||
|
let channel = context.channel
|
||||||
|
sent.futureResult.whenFailure { error in
|
||||||
|
resultPromise?.fail(error)
|
||||||
|
channel.close(promise: nil)
|
||||||
|
}
|
||||||
|
context.triggerUserOutboundEvent(request, promise: sent)
|
||||||
|
context.fireChannelActive()
|
||||||
|
}
|
||||||
|
|
||||||
|
func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) {
|
||||||
|
switch event {
|
||||||
|
case is ChannelSuccessEvent:
|
||||||
|
sendStandardInput(context: context)
|
||||||
|
|
||||||
|
case is ChannelFailureEvent:
|
||||||
|
fail(context: context, error: CoreError.sshFailed("guest refused to exec: \(command)"))
|
||||||
|
|
||||||
|
case let status as SSHChannelRequestEvent.ExitStatus:
|
||||||
|
exitCode = Int32(truncatingIfNeeded: status.exitStatus)
|
||||||
|
|
||||||
|
case let signal as SSHChannelRequestEvent.ExitSignal:
|
||||||
|
// A signalled process has no exit status; report it the way a shell
|
||||||
|
// would, and keep the signal name in stderr so it is not lost.
|
||||||
|
exitCode = 128
|
||||||
|
var note = ByteBuffer(string: "\nterminated by SIG\(signal.signalName): \(signal.errorMessage)\n")
|
||||||
|
stderr.writeBuffer(¬e)
|
||||||
|
|
||||||
|
default:
|
||||||
|
context.fireUserInboundEventTriggered(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
|
||||||
|
let channelData = unwrapInboundIn(data)
|
||||||
|
guard case .byteBuffer(var bytes) = channelData.data else { return }
|
||||||
|
|
||||||
|
switch channelData.type {
|
||||||
|
case .channel: stdout.writeBuffer(&bytes)
|
||||||
|
case .stdErr: stderr.writeBuffer(&bytes)
|
||||||
|
default: break // An extended data type we did not ask for.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func channelInactive(context: ChannelHandlerContext) {
|
||||||
|
complete()
|
||||||
|
context.fireChannelInactive()
|
||||||
|
}
|
||||||
|
|
||||||
|
func handlerRemoved(context: ChannelHandlerContext) {
|
||||||
|
complete()
|
||||||
|
}
|
||||||
|
|
||||||
|
func errorCaught(context: ChannelHandlerContext, error: Error) {
|
||||||
|
fail(context: context, error: error)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendStandardInput(context: ChannelHandlerContext) {
|
||||||
|
if var payload = stdin {
|
||||||
|
stdin = nil
|
||||||
|
while payload.readableBytes > 0 {
|
||||||
|
let slice = payload.readSlice(length: min(Self.chunkSize, payload.readableBytes))!
|
||||||
|
context.write(
|
||||||
|
wrapOutboundOut(SSHChannelData(type: .channel, data: .byteBuffer(slice))),
|
||||||
|
promise: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
context.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// EOF either way: `cat > file` needs it to finish, and a command that
|
||||||
|
// would otherwise block reading stdin gets an immediate end of input.
|
||||||
|
context.close(mode: .output, promise: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func complete() {
|
||||||
|
guard let promise else { return }
|
||||||
|
self.promise = nil
|
||||||
|
|
||||||
|
if let exitCode {
|
||||||
|
promise.succeed(
|
||||||
|
SSHCommandResult(
|
||||||
|
exitCode: exitCode,
|
||||||
|
stdout: String(buffer: stdout),
|
||||||
|
stderr: String(buffer: stderr)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
promise.fail(
|
||||||
|
CoreError.sshFailed("guest closed the channel without an exit status: \(command)")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fail(context: ChannelHandlerContext, error: Error) {
|
||||||
|
if let promise {
|
||||||
|
self.promise = nil
|
||||||
|
promise.fail(error)
|
||||||
|
}
|
||||||
|
context.close(promise: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blocks until a guest accepts an authenticated SSH session, or the deadline
|
||||||
|
/// passes.
|
||||||
|
///
|
||||||
|
/// Called after a DHCP lease appears but before any provisioning: a fresh guest
|
||||||
|
/// answers on port 22 only once `launchd` has started `sshd`, which lags the
|
||||||
|
/// lease by tens of seconds.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - host: Guest IP.
|
||||||
|
/// - port: SSH port. Defaults to `22`.
|
||||||
|
/// - username: Guest account.
|
||||||
|
/// - password: Guest password.
|
||||||
|
/// - timeout: Overall ceiling.
|
||||||
|
/// - pollInterval: Delay between attempts. Defaults to 2 s.
|
||||||
|
/// - Throws: ``CoreError/timeout(_:)`` if the guest never answers.
|
||||||
|
public func waitForSSH(
|
||||||
|
host: String,
|
||||||
|
port: Int = 22,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
timeout: Duration,
|
||||||
|
pollInterval: Duration = .seconds(2)
|
||||||
|
) async throws {
|
||||||
|
let executor = SSHExecutor(host: host, port: port, username: username, password: password)
|
||||||
|
let started = ContinuousClock.now
|
||||||
|
var lastError: Error?
|
||||||
|
|
||||||
|
while true {
|
||||||
|
do {
|
||||||
|
// A real authenticated session running a trivial command, not a bare
|
||||||
|
// TCP probe: sshd binds the port before it is ready to authenticate,
|
||||||
|
// so a connect that succeeds proves very little.
|
||||||
|
_ = try await executor.execute("true", stdin: nil, timeout: .seconds(20))
|
||||||
|
return
|
||||||
|
} catch let error as SSHTransportError {
|
||||||
|
if case .authenticationFailed = error {
|
||||||
|
// Wrong credentials will not become right by waiting: the guest
|
||||||
|
// was provisioned with a different account or password, which is
|
||||||
|
// a build failure, not a boot delay.
|
||||||
|
throw error.asCoreError
|
||||||
|
}
|
||||||
|
lastError = error
|
||||||
|
} catch {
|
||||||
|
// Timeouts and mid-handshake closures are what a guest that is still
|
||||||
|
// starting `sshd` looks like. Keep waiting.
|
||||||
|
lastError = error
|
||||||
|
}
|
||||||
|
|
||||||
|
guard ContinuousClock.now - started < timeout else { break }
|
||||||
|
try await Task.sleep(for: pollInterval)
|
||||||
|
guard ContinuousClock.now - started < timeout else { break }
|
||||||
|
}
|
||||||
|
|
||||||
|
let detail = lastError.map { "; last error: \($0)" } ?? ""
|
||||||
|
throw CoreError.timeout("ssh on \(host):\(port)\(detail)")
|
||||||
|
}
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// What a VM slot is doing.
|
||||||
|
///
|
||||||
|
/// Slots are fixed in number (two, matching both the kernel's concurrent-VM cap
|
||||||
|
/// and our two persistent MAC addresses) and are recycled, never created.
|
||||||
|
public enum SlotState: Sendable, Equatable {
|
||||||
|
/// No VM. Available to boot.
|
||||||
|
case idle
|
||||||
|
|
||||||
|
/// A VM is being cloned/booted/provisioned; not yet registered with Gitea.
|
||||||
|
/// - Parameter since: When the transition happened, for boot-timeout checks.
|
||||||
|
case provisioning(since: Date)
|
||||||
|
|
||||||
|
/// A VM is up with `gitea-runner daemon` attached.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - jobHint: The queued job whose presence motivated this boot, if known.
|
||||||
|
/// **Only a hint** — the server, not us, decides which job this runner
|
||||||
|
/// actually claims.
|
||||||
|
/// - since: When the VM went live, for job-timeout checks.
|
||||||
|
case running(jobHint: Int64?, since: Date)
|
||||||
|
|
||||||
|
/// Whether the slot currently holds a VM (booting or live).
|
||||||
|
public var isOccupied: Bool {
|
||||||
|
if case .idle = self { return false }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When the slot entered its current state, or `nil` when idle.
|
||||||
|
public var since: Date? {
|
||||||
|
switch self {
|
||||||
|
case .idle: return nil
|
||||||
|
case .provisioning(let t): return t
|
||||||
|
case .running(_, let t): return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One recyclable VM slot.
|
||||||
|
public struct VMSlot: Sendable, Equatable, Identifiable {
|
||||||
|
/// Stable index, `0..<maxVMs`. Also indexes the persistent per-slot MAC.
|
||||||
|
public let id: Int
|
||||||
|
/// Current state.
|
||||||
|
public var state: SlotState
|
||||||
|
|
||||||
|
public init(id: Int, state: SlotState = .idle) {
|
||||||
|
self.id = id
|
||||||
|
self.state = state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The scheduler's complete observable state.
|
||||||
|
public struct SchedulerState: Sendable, Equatable {
|
||||||
|
/// Fixed-size slot table.
|
||||||
|
public var slots: [VMSlot]
|
||||||
|
|
||||||
|
/// Job ids that have already caused a boot.
|
||||||
|
///
|
||||||
|
/// This is the dedup ledger. Without it, a job that stays queued for the
|
||||||
|
/// several seconds a VM takes to come up would trigger a second boot on the
|
||||||
|
/// next poll, and a third after that — burning the entire slot budget on one
|
||||||
|
/// job. Entries are dropped once the job stops appearing as queued.
|
||||||
|
public var dispatchedJobIDs: Set<Int64>
|
||||||
|
|
||||||
|
/// Creates a state with `count` idle slots and an empty ledger.
|
||||||
|
public init(slotCount: Int) {
|
||||||
|
self.slots = (0..<slotCount).map { VMSlot(id: $0) }
|
||||||
|
self.dispatchedJobIDs = []
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(slots: [VMSlot], dispatchedJobIDs: Set<Int64> = []) {
|
||||||
|
self.slots = slots
|
||||||
|
self.dispatchedJobIDs = dispatchedJobIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Slots not currently holding a VM.
|
||||||
|
public var idleSlots: [VMSlot] { slots.filter { !$0.state.isOccupied } }
|
||||||
|
|
||||||
|
/// Slots holding a VM.
|
||||||
|
public var occupiedSlots: [VMSlot] { slots.filter { $0.state.isOccupied } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A side effect the orchestrator should perform.
|
||||||
|
///
|
||||||
|
/// The planner returns these; it never performs I/O itself, which is what makes
|
||||||
|
/// the whole scheduling policy unit-testable against a fixed `now`.
|
||||||
|
public enum SchedulerAction: Sendable, Equatable {
|
||||||
|
/// Clone, boot, provision, and register a VM in the given slot.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - slot: Slot id.
|
||||||
|
/// - jobHint: The queued job that motivated the boot.
|
||||||
|
case bootVM(slot: Int, jobHint: Int64)
|
||||||
|
|
||||||
|
/// Stop and delete the VM in the given slot.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - slot: Slot id.
|
||||||
|
/// - reason: Human-readable cause, logged and used in tests.
|
||||||
|
case teardownVM(slot: Int, reason: String)
|
||||||
|
|
||||||
|
/// Explicit no-op. Returned so a caller can distinguish "planner ran and
|
||||||
|
/// chose to do nothing" from "planner returned an empty list".
|
||||||
|
case none
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pure scheduling state machine.
|
||||||
|
///
|
||||||
|
/// ## Capacity, not assignment
|
||||||
|
///
|
||||||
|
/// A booted VM is **capacity**, not a promise to run a specific job. We register
|
||||||
|
/// an ephemeral runner and the *server* decides which queued job it claims —
|
||||||
|
/// possibly not the one that triggered the boot. That is fine and in fact
|
||||||
|
/// desirable: it means we never have to reimplement Gitea's matching rules. The
|
||||||
|
/// `jobHint` carried through ``SchedulerAction/bootVM(slot:jobHint:)`` and
|
||||||
|
/// ``SlotState/running(jobHint:since:)`` exists purely for logs and for the
|
||||||
|
/// dedup ledger.
|
||||||
|
///
|
||||||
|
/// Because `--ephemeral` makes the server hand each runner exactly one task and
|
||||||
|
/// then deregister it, a slot's life is: boot → register → claim one job → the
|
||||||
|
/// `gitea-runner daemon` process exits → we tear down. There is no reuse, which
|
||||||
|
/// is what makes the VM genuinely disposable.
|
||||||
|
///
|
||||||
|
/// ## Rules
|
||||||
|
///
|
||||||
|
/// 1. Only jobs whose labels ``LabelSet/matches(jobLabels:)`` are considered.
|
||||||
|
/// 2. A job id already in ``SchedulerState/dispatchedJobIDs`` never boots a
|
||||||
|
/// second VM.
|
||||||
|
/// 3. At most `maxVMs` slots may be occupied (hard-clamped to 2 — the kernel
|
||||||
|
/// fails a third `start()` with `VZError.virtualMachineLimitExceeded`).
|
||||||
|
/// 4. Ledger entries for jobs no longer visible as queued are expired, so a
|
||||||
|
/// slot freed by a completed job can be re-earned by a genuinely new job.
|
||||||
|
/// 5. A slot in ``SlotState/provisioning(since:)`` longer than `bootTimeout`, or
|
||||||
|
/// ``SlotState/running(jobHint:since:)`` longer than `jobTimeout`, is torn
|
||||||
|
/// down.
|
||||||
|
public enum SchedulerCore {
|
||||||
|
/// Computes the next state and the actions to reach it.
|
||||||
|
///
|
||||||
|
/// Deterministic and side-effect free: same inputs, same outputs. `now` is
|
||||||
|
/// injected rather than read so timeout behaviour is testable.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - state: Current state.
|
||||||
|
/// - queuedJobs: Jobs Gitea currently reports as `queued`. Callers must
|
||||||
|
/// not include `waiting` (blocked) jobs.
|
||||||
|
/// - labels: This host's label set.
|
||||||
|
/// - maxVMs: Concurrency cap; values above 2 are clamped.
|
||||||
|
/// - now: Reference time for timeout arithmetic.
|
||||||
|
/// - jobTimeout: Ceiling on ``SlotState/running(jobHint:since:)``.
|
||||||
|
/// - bootTimeout: Ceiling on ``SlotState/provisioning(since:)``.
|
||||||
|
/// - Returns: The updated state and the actions to execute, teardowns first
|
||||||
|
/// so a freed slot can be reused within the same pass.
|
||||||
|
public static func plan(
|
||||||
|
state: SchedulerState,
|
||||||
|
queuedJobs: [WorkflowJob],
|
||||||
|
labels: LabelSet,
|
||||||
|
maxVMs: Int,
|
||||||
|
now: Date,
|
||||||
|
jobTimeout: TimeInterval,
|
||||||
|
bootTimeout: TimeInterval
|
||||||
|
) -> (SchedulerState, [SchedulerAction]) {
|
||||||
|
// The kernel fails a third concurrent guest, so the config never gets to
|
||||||
|
// negotiate this. Clamped here as well as in `RunnerConfig.validated()`.
|
||||||
|
let cap = min(max(maxVMs, 0), 2)
|
||||||
|
|
||||||
|
var newState = state
|
||||||
|
var teardowns: [SchedulerAction] = []
|
||||||
|
var boots: [SchedulerAction] = []
|
||||||
|
|
||||||
|
// 1. Which of the queued jobs are ours to serve, in the order Gitea
|
||||||
|
// reported them (so the plan is a deterministic function of input).
|
||||||
|
let matching = queuedJobs.filter { labels.matches(jobLabels: $0.labels) }
|
||||||
|
let queuedIDs = Set(queuedJobs.map(\.id))
|
||||||
|
|
||||||
|
// 2. Expire the dedup ledger against reality rather than against a
|
||||||
|
// timer: an id that is no longer queued was either claimed or
|
||||||
|
// cancelled, and dedup only matters while a job is still waiting.
|
||||||
|
newState.dispatchedJobIDs.formIntersection(queuedIDs)
|
||||||
|
|
||||||
|
// 3. Timeouts, emitted before any boot so a slot freed here can be
|
||||||
|
// reused in this same pass.
|
||||||
|
for index in newState.slots.indices {
|
||||||
|
let slot = newState.slots[index]
|
||||||
|
switch slot.state {
|
||||||
|
case .idle:
|
||||||
|
continue
|
||||||
|
|
||||||
|
case .provisioning(let since):
|
||||||
|
let age = now.timeIntervalSince(since)
|
||||||
|
guard age > bootTimeout else { continue }
|
||||||
|
teardowns.append(
|
||||||
|
.teardownVM(
|
||||||
|
slot: slot.id,
|
||||||
|
reason: "boot timeout: provisioning for \(Int(age))s (limit \(Int(bootTimeout))s)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
newState.slots[index].state = .idle
|
||||||
|
// Losing a boot must not permanently strand the job that
|
||||||
|
// motivated it. `SlotState.provisioning` deliberately carries no
|
||||||
|
// jobHint (the hint is a log/dedup detail, not an assignment), so
|
||||||
|
// there is no specific id to drop here. Instead we release one
|
||||||
|
// ledger entry — the lowest still-queued dispatched id, i.e. the
|
||||||
|
// oldest such job, since Gitea's ids increase monotonically.
|
||||||
|
// That is deterministic, releases exactly the capacity we lost,
|
||||||
|
// and lets a replacement VM boot (possibly on this very tick).
|
||||||
|
if let oldest = newState.dispatchedJobIDs.min() {
|
||||||
|
newState.dispatchedJobIDs.remove(oldest)
|
||||||
|
}
|
||||||
|
|
||||||
|
case .running(let jobHint, let since):
|
||||||
|
let age = now.timeIntervalSince(since)
|
||||||
|
guard age > jobTimeout else { continue }
|
||||||
|
teardowns.append(
|
||||||
|
.teardownVM(
|
||||||
|
slot: slot.id,
|
||||||
|
reason: "job timeout: running for \(Int(age))s (limit \(Int(jobTimeout))s)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
newState.slots[index].state = .idle
|
||||||
|
// Same reasoning as above, except here we do know the hint. It is
|
||||||
|
// usually gone from the ledger already (a claimed job stops being
|
||||||
|
// queued), so this is normally a no-op.
|
||||||
|
if let jobHint { newState.dispatchedJobIDs.remove(jobHint) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Boot capacity for jobs we have not already booted for.
|
||||||
|
//
|
||||||
|
// A booted VM is CAPACITY, not an assignment: the ephemeral runner we
|
||||||
|
// register may legally claim a DIFFERENT matching job than the one
|
||||||
|
// whose presence motivated the boot. The counting still works out —
|
||||||
|
// one queued matching job earns one VM, and whichever job that VM
|
||||||
|
// claims stops being queued and drops out of the ledger.
|
||||||
|
for job in matching {
|
||||||
|
guard !newState.dispatchedJobIDs.contains(job.id) else { continue }
|
||||||
|
guard newState.occupiedSlots.count < cap else { break }
|
||||||
|
guard let free = newState.slots.firstIndex(where: { !$0.state.isOccupied }) else { break }
|
||||||
|
|
||||||
|
boots.append(.bootVM(slot: newState.slots[free].id, jobHint: job.id))
|
||||||
|
newState.slots[free].state = .provisioning(since: now)
|
||||||
|
newState.dispatchedJobIDs.insert(job.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Teardowns first, boots second. An empty list is the no-op; `.none` is
|
||||||
|
// never emitted, so callers never have to filter it out of a real plan.
|
||||||
|
return (newState, teardowns + boots)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records that a slot began booting for a job.
|
||||||
|
///
|
||||||
|
/// Called by the orchestrator once it has actually started the clone/boot,
|
||||||
|
/// so that a failed `plan` execution does not leave a phantom occupied slot.
|
||||||
|
///
|
||||||
|
/// - Returns: The updated state.
|
||||||
|
public static func markProvisioning(
|
||||||
|
state: SchedulerState,
|
||||||
|
slot: Int,
|
||||||
|
jobHint: Int64,
|
||||||
|
now: Date
|
||||||
|
) -> SchedulerState {
|
||||||
|
var newState = state
|
||||||
|
guard let index = newState.slots.firstIndex(where: { $0.id == slot }) else { return newState }
|
||||||
|
newState.slots[index].state = .provisioning(since: now)
|
||||||
|
newState.dispatchedJobIDs.insert(jobHint)
|
||||||
|
return newState
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Promotes a slot from provisioning to running.
|
||||||
|
public static func markRunning(
|
||||||
|
state: SchedulerState,
|
||||||
|
slot: Int,
|
||||||
|
now: Date
|
||||||
|
) -> SchedulerState {
|
||||||
|
var newState = state
|
||||||
|
guard let index = newState.slots.firstIndex(where: { $0.id == slot }) else { return newState }
|
||||||
|
// The hint, if any, is carried over purely so logs and the job-timeout
|
||||||
|
// teardown reason can name a job. It is never an assignment.
|
||||||
|
let hint: Int64?
|
||||||
|
if case .running(let existing, _) = newState.slots[index].state {
|
||||||
|
hint = existing
|
||||||
|
} else {
|
||||||
|
hint = nil
|
||||||
|
}
|
||||||
|
newState.slots[index].state = .running(jobHint: hint, since: now)
|
||||||
|
return newState
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Promotes a slot to running while recording the job that motivated its
|
||||||
|
/// boot, which ``SlotState/provisioning(since:)`` does not carry.
|
||||||
|
///
|
||||||
|
/// Additive convenience over ``markRunning(state:slot:now:)``; the hint is
|
||||||
|
/// still only ever used for logging and the job-timeout reason string.
|
||||||
|
public static func markRunning(
|
||||||
|
state: SchedulerState,
|
||||||
|
slot: Int,
|
||||||
|
jobHint: Int64?,
|
||||||
|
now: Date
|
||||||
|
) -> SchedulerState {
|
||||||
|
var newState = state
|
||||||
|
guard let index = newState.slots.firstIndex(where: { $0.id == slot }) else { return newState }
|
||||||
|
newState.slots[index].state = .running(jobHint: jobHint, since: now)
|
||||||
|
return newState
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops a job id from the dedup ledger.
|
||||||
|
///
|
||||||
|
/// The ledger's only automatic expiry is "the job stopped being queued"
|
||||||
|
/// (``plan(state:queuedJobs:labels:maxVMs:now:jobTimeout:bootTimeout:)``,
|
||||||
|
/// step 2), which is exactly wrong for a boot that never happened: the job
|
||||||
|
/// is *still* queued, so its entry is retained and no further VM is ever
|
||||||
|
/// booted for it. Every failure path — a refused boot, a clone error, a lost
|
||||||
|
/// lease, a dead SSH channel — must call this, or the job waits out Gitea's
|
||||||
|
/// 24 h `ABANDONED_JOB_TIMEOUT` for nothing.
|
||||||
|
///
|
||||||
|
/// Safe to call for an id that was never dispatched, or twice.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - state: Current state.
|
||||||
|
/// - jobID: The job to release.
|
||||||
|
/// - Returns: The updated state.
|
||||||
|
public static func releaseJob(
|
||||||
|
state: SchedulerState,
|
||||||
|
jobID: Int64
|
||||||
|
) -> SchedulerState {
|
||||||
|
var newState = state
|
||||||
|
newState.dispatchedJobIDs.remove(jobID)
|
||||||
|
return newState
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a slot to ``SlotState/idle`` after teardown.
|
||||||
|
public static func markIdle(
|
||||||
|
state: SchedulerState,
|
||||||
|
slot: Int
|
||||||
|
) -> SchedulerState {
|
||||||
|
var newState = state
|
||||||
|
guard let index = newState.slots.firstIndex(where: { $0.id == slot }) else { return newState }
|
||||||
|
newState.slots[index].state = .idle
|
||||||
|
return newState
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Version stamp for the runner host tool itself.
|
||||||
|
///
|
||||||
|
/// This is *not* the version of the `gitea-runner` binary installed into the
|
||||||
|
/// guest — that one lives in ``RunnerConfig/RunnerSection/version``.
|
||||||
|
public enum RunnerVersion {
|
||||||
|
/// The semantic version of this build, reported by `--version` and by
|
||||||
|
/// `doctor`.
|
||||||
|
public static let current = "0.1.0"
|
||||||
|
}
|
||||||
@@ -0,0 +1,597 @@
|
|||||||
|
import Foundation
|
||||||
|
import RunnerCore
|
||||||
|
import Virtualization
|
||||||
|
|
||||||
|
/// The outcome of one preflight check.
|
||||||
|
public struct DoctorCheck: Sendable, Equatable {
|
||||||
|
/// How a check turned out.
|
||||||
|
public enum Result: Sendable, Equatable {
|
||||||
|
/// Requirement satisfied.
|
||||||
|
case pass
|
||||||
|
/// Requirement not satisfied; the daemon will not work.
|
||||||
|
case fail
|
||||||
|
/// Not a hard requirement, but worth knowing about.
|
||||||
|
case warn
|
||||||
|
/// Informational only.
|
||||||
|
case info
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Short check name, e.g. `virtualization entitlement`.
|
||||||
|
public let name: String
|
||||||
|
/// Outcome.
|
||||||
|
public let result: Result
|
||||||
|
/// What was observed.
|
||||||
|
public let detail: String
|
||||||
|
/// What to do about it, when the outcome is not ``Result/pass``.
|
||||||
|
public let remediation: String?
|
||||||
|
|
||||||
|
public init(name: String, result: Result, detail: String, remediation: String? = nil) {
|
||||||
|
self.name = name
|
||||||
|
self.result = result
|
||||||
|
self.detail = detail
|
||||||
|
self.remediation = remediation
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this check blocks the daemon from working.
|
||||||
|
public var isBlocking: Bool { result == .fail }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DoctorCheck.Result {
|
||||||
|
/// Lowercase name, for JSON output and log lines.
|
||||||
|
public var label: String {
|
||||||
|
switch self {
|
||||||
|
case .pass: return "pass"
|
||||||
|
case .fail: return "fail"
|
||||||
|
case .warn: return "warn"
|
||||||
|
case .info: return "info"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single-character marker used by ``Doctor/format(_:)``.
|
||||||
|
public var symbol: String {
|
||||||
|
switch self {
|
||||||
|
case .pass: return "✓"
|
||||||
|
case .fail: return "✗"
|
||||||
|
case .warn: return "!"
|
||||||
|
case .info: return "·"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Preflight checks for a host that is supposed to run macOS guests.
|
||||||
|
///
|
||||||
|
/// Each of these corresponds to a failure mode that is otherwise diagnosed only
|
||||||
|
/// by a confusing runtime error deep inside the boot path, so `doctor` exists to
|
||||||
|
/// surface them all at once, before anything is installed.
|
||||||
|
public enum Doctor {
|
||||||
|
|
||||||
|
/// Runs every check.
|
||||||
|
///
|
||||||
|
/// Checks performed:
|
||||||
|
///
|
||||||
|
/// 1. **Architecture is arm64.** Virtualization cannot run macOS guests on
|
||||||
|
/// Intel at all.
|
||||||
|
/// 2. **Host macOS ≥ 26.** Required for ASIF disks; the guest-provisioning
|
||||||
|
/// automation additionally wants 27.
|
||||||
|
/// 3. **`VZVirtualMachine.isSupported`.** The framework's own verdict.
|
||||||
|
/// 4. **`com.apple.security.virtualization` entitlement present** on the
|
||||||
|
/// running binary, read with `codesign -d --entitlements - <path>`.
|
||||||
|
/// Running from `.build/` instead of the signed `.app` is the single most
|
||||||
|
/// common setup mistake, and this is what catches it.
|
||||||
|
/// 5. **Free disk ≥ `storage.minFreeDiskGB`.** CoW clones grow as guests
|
||||||
|
/// write.
|
||||||
|
/// 6. **`login.keychain` unlocked**, via `security show-keychain-info
|
||||||
|
/// login.keychain`. macOS 15+ refuses to start a VM otherwise — the
|
||||||
|
/// reason the daemon must be a LaunchAgent in a logged-in session.
|
||||||
|
/// 7. **Gitea reachable and the token has admin scope**, probed with
|
||||||
|
/// ``GiteaClient/listRunners()``. A non-admin token fails here rather
|
||||||
|
/// than at the first poll.
|
||||||
|
/// 8. **Registration token resolvable** from file, inline value, or (if
|
||||||
|
/// enabled) the API.
|
||||||
|
/// 9. **Runner download URL is live**, via a `HEAD` expecting 200. Catches a
|
||||||
|
/// version bump that no longer has a darwin-arm64 asset.
|
||||||
|
/// 10. **Local Network privacy note** (informational). On macOS 15+ the
|
||||||
|
/// first attempt to reach a guest over the NAT link can be blocked by
|
||||||
|
/// the Local Network permission prompt, which a background agent cannot
|
||||||
|
/// answer; the operator must approve the app once.
|
||||||
|
///
|
||||||
|
/// - Parameter config: Validated configuration. Gitea-dependent checks are
|
||||||
|
/// skipped with a ``DoctorCheck/Result/warn`` when no admin token is set.
|
||||||
|
/// - Returns: Checks in the order above.
|
||||||
|
public static func runChecks(config: RunnerConfig) async -> [DoctorCheck] {
|
||||||
|
var checks = hostChecks()
|
||||||
|
checks.append(checkDiskSpace(config: config))
|
||||||
|
checks.append(checkLoginKeychain())
|
||||||
|
checks.append(contentsOf: await checkGitea(config: config))
|
||||||
|
checks.append(await checkRunnerDownloadURL(config: config))
|
||||||
|
checks.append(localNetworkNote())
|
||||||
|
return checks
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs every check, loading configuration from `path` first.
|
||||||
|
///
|
||||||
|
/// The host checks still run when the configuration is missing or invalid,
|
||||||
|
/// which is the state a first-time operator is actually in.
|
||||||
|
///
|
||||||
|
/// - Parameter configPath: Path to the configuration file; tilde-expanded.
|
||||||
|
/// - Returns: Checks, with configuration loading itself reported as a check.
|
||||||
|
public static func runChecks(configPath: String) async -> [DoctorCheck] {
|
||||||
|
var checks = hostChecks()
|
||||||
|
|
||||||
|
let loaded: RunnerConfig
|
||||||
|
do {
|
||||||
|
loaded = try RunnerConfig.load(from: configPath).validated()
|
||||||
|
checks.append(
|
||||||
|
DoctorCheck(
|
||||||
|
name: "configuration",
|
||||||
|
result: .pass,
|
||||||
|
detail: "loaded and validated \(RunnerConfig.expandTilde(configPath))"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
checks.append(
|
||||||
|
DoctorCheck(
|
||||||
|
name: "configuration",
|
||||||
|
result: .fail,
|
||||||
|
detail: "\(error)",
|
||||||
|
remediation: "run `gitea-macos-runner config init` and edit \(RunnerConfig.expandTilde(configPath))"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
checks.append(localNetworkNote())
|
||||||
|
return checks
|
||||||
|
}
|
||||||
|
|
||||||
|
checks.append(checkDiskSpace(config: loaded))
|
||||||
|
checks.append(checkLoginKeychain())
|
||||||
|
checks.append(contentsOf: await checkGitea(config: loaded))
|
||||||
|
checks.append(await checkRunnerDownloadURL(config: loaded))
|
||||||
|
checks.append(contentsOf: checkTokenFilePermissions(config: loaded))
|
||||||
|
checks.append(localNetworkNote())
|
||||||
|
return checks
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The configuration-independent host checks: architecture, OS version,
|
||||||
|
/// framework support, entitlement.
|
||||||
|
public static func hostChecks() -> [DoctorCheck] {
|
||||||
|
[
|
||||||
|
checkHostCapability(),
|
||||||
|
checkVirtualizationSupported(),
|
||||||
|
checkVirtualizationEntitlement(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the running binary carries `com.apple.security.virtualization`.
|
||||||
|
///
|
||||||
|
/// - Parameter binaryPath: Defaults to the current executable.
|
||||||
|
/// - Returns: The check result.
|
||||||
|
public static func checkVirtualizationEntitlement(
|
||||||
|
binaryPath: String = CommandLine.arguments.first ?? ""
|
||||||
|
) -> DoctorCheck {
|
||||||
|
let name = "virtualization entitlement"
|
||||||
|
let remediation = """
|
||||||
|
build and install the signed bundle: `make install`, then run \
|
||||||
|
~/Applications/GiteaMacosRunner.app/Contents/MacOS/gitea-macos-runner
|
||||||
|
"""
|
||||||
|
|
||||||
|
guard let executable = resolveExecutablePath(binaryPath) else {
|
||||||
|
return DoctorCheck(
|
||||||
|
name: name,
|
||||||
|
result: .warn,
|
||||||
|
detail: "could not locate the running executable to inspect",
|
||||||
|
remediation: remediation
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The entitlement lives on the signature, so a bare binary copied out of
|
||||||
|
// the bundle loses it. Report where we are as well as what we found.
|
||||||
|
let inAppBundle = executable.contains(".app/Contents/MacOS/")
|
||||||
|
let signedTarget = inAppBundle
|
||||||
|
? String(executable.prefix(upTo: executable.range(of: ".app/Contents/MacOS/")!.upperBound)
|
||||||
|
.dropLast("/Contents/MacOS/".count))
|
||||||
|
: executable
|
||||||
|
|
||||||
|
let result = DoctorShell.run(
|
||||||
|
"/usr/bin/codesign",
|
||||||
|
["-d", "--entitlements", "-", "--xml", signedTarget]
|
||||||
|
)
|
||||||
|
let hasEntitlement = result.output.contains("com.apple.security.virtualization")
|
||||||
|
|
||||||
|
if hasEntitlement {
|
||||||
|
return DoctorCheck(
|
||||||
|
name: name,
|
||||||
|
result: .pass,
|
||||||
|
detail: "present on \(signedTarget)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if inAppBundle {
|
||||||
|
return DoctorCheck(
|
||||||
|
name: name,
|
||||||
|
result: .fail,
|
||||||
|
detail: "\(signedTarget) is not signed with com.apple.security.virtualization",
|
||||||
|
remediation: "re-sign the bundle: `make sign` (or `make install`)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Running the plain SwiftPM binary is normal for `doctor`, `config`, and
|
||||||
|
// `service`; it only becomes fatal when a VM is actually started.
|
||||||
|
return DoctorCheck(
|
||||||
|
name: name,
|
||||||
|
result: .warn,
|
||||||
|
detail: "running an unsigned binary at \(executable); VM starts will fail",
|
||||||
|
remediation: remediation
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `login.keychain` is currently unlocked.
|
||||||
|
public static func checkLoginKeychain() -> DoctorCheck {
|
||||||
|
let name = "login.keychain unlocked"
|
||||||
|
let result = DoctorShell.run("/usr/bin/security", ["show-keychain-info", "login.keychain"])
|
||||||
|
|
||||||
|
if result.exitCode == 0 {
|
||||||
|
return DoctorCheck(name: name, result: .pass, detail: "unlocked")
|
||||||
|
}
|
||||||
|
|
||||||
|
return DoctorCheck(
|
||||||
|
name: name,
|
||||||
|
result: .warn,
|
||||||
|
detail: "locked or unavailable (security exited \(result.exitCode))",
|
||||||
|
remediation: """
|
||||||
|
macOS 15+ refuses to start a VM while login.keychain is locked. Configure \
|
||||||
|
automatic login, and do not lock the session — the daemon runs as a \
|
||||||
|
LaunchAgent inside it.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the host architecture and macOS version can run macOS guests.
|
||||||
|
public static func checkHostCapability() -> DoctorCheck {
|
||||||
|
let name = "host capability"
|
||||||
|
let version = ProcessInfo.processInfo.operatingSystemVersion
|
||||||
|
let versionString = "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)"
|
||||||
|
|
||||||
|
// Emitted straight from the preprocessor branch rather than via a `let
|
||||||
|
// isAppleSilicon` flag: with the flag, one arm is a compile-time
|
||||||
|
// constant and the compiler warns that the other is dead code.
|
||||||
|
#if !arch(arm64)
|
||||||
|
return DoctorCheck(
|
||||||
|
name: name,
|
||||||
|
result: .fail,
|
||||||
|
detail: "not an Apple silicon host; macOS guests require arm64",
|
||||||
|
remediation: "run this daemon on an Apple silicon Mac"
|
||||||
|
)
|
||||||
|
#else
|
||||||
|
|
||||||
|
guard version.majorVersion >= 26 else {
|
||||||
|
return DoctorCheck(
|
||||||
|
name: name,
|
||||||
|
result: .fail,
|
||||||
|
detail: "macOS \(versionString); this daemon requires macOS 26 or newer",
|
||||||
|
remediation: "upgrade the host: ASIF sparse disks need macOS 26+"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if version.majorVersion < 27 {
|
||||||
|
return DoctorCheck(
|
||||||
|
name: name,
|
||||||
|
result: .warn,
|
||||||
|
detail: "arm64, macOS \(versionString)",
|
||||||
|
remediation: """
|
||||||
|
automated guest provisioning (VZMacGuestProvisioningOptions) needs macOS 27 \
|
||||||
|
on both host and guest; on 26 the first boot's Setup Assistant must be \
|
||||||
|
completed by hand once per image
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return DoctorCheck(name: name, result: .pass, detail: "arm64, macOS \(versionString)")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The framework's own verdict on this host.
|
||||||
|
public static func checkVirtualizationSupported() -> DoctorCheck {
|
||||||
|
let name = "Virtualization.framework"
|
||||||
|
if VZVirtualMachine.isSupported {
|
||||||
|
return DoctorCheck(name: name, result: .pass, detail: "VZVirtualMachine.isSupported == true")
|
||||||
|
}
|
||||||
|
return DoctorCheck(
|
||||||
|
name: name,
|
||||||
|
result: .fail,
|
||||||
|
detail: "VZVirtualMachine.isSupported == false",
|
||||||
|
remediation: "this host cannot run virtual machines"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Free space on the store volume against `storage.minFreeDiskGB`.
|
||||||
|
public static func checkDiskSpace(config: RunnerConfig) -> DoctorCheck {
|
||||||
|
let name = "free disk space"
|
||||||
|
let store = VMStore(config: config)
|
||||||
|
do {
|
||||||
|
let free = try store.freeDiskSpace()
|
||||||
|
let freeGB = Double(free) / 1_073_741_824
|
||||||
|
let detail = String(
|
||||||
|
format: "%.1f GB free at %@ (minimum %d GB)",
|
||||||
|
freeGB, config.storeDirectoryURL.path, config.storage.minFreeDiskGB
|
||||||
|
)
|
||||||
|
if freeGB < Double(config.storage.minFreeDiskGB) {
|
||||||
|
return DoctorCheck(
|
||||||
|
name: name,
|
||||||
|
result: .fail,
|
||||||
|
detail: detail,
|
||||||
|
remediation: "free space, or lower storage.minFreeDiskGB"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return DoctorCheck(name: name, result: .pass, detail: detail)
|
||||||
|
} catch {
|
||||||
|
return DoctorCheck(
|
||||||
|
name: name,
|
||||||
|
result: .warn,
|
||||||
|
detail: "could not measure free space: \(error)",
|
||||||
|
remediation: "check that \(config.storeDirectoryURL.path) exists and is readable"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reachability, admin scope, and registration-token availability.
|
||||||
|
public static func checkGitea(config: RunnerConfig) async -> [DoctorCheck] {
|
||||||
|
var checks: [DoctorCheck] = []
|
||||||
|
|
||||||
|
let adminToken: String?
|
||||||
|
do {
|
||||||
|
adminToken = try config.resolveAdminToken()
|
||||||
|
} catch {
|
||||||
|
checks.append(
|
||||||
|
DoctorCheck(
|
||||||
|
name: "gitea admin token",
|
||||||
|
result: .fail,
|
||||||
|
detail: "\(error)",
|
||||||
|
remediation: "check gitea.adminTokenFile / gitea.adminToken"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return checks
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let adminToken, !adminToken.isEmpty else {
|
||||||
|
checks.append(
|
||||||
|
DoctorCheck(
|
||||||
|
name: "gitea admin api",
|
||||||
|
result: .warn,
|
||||||
|
detail: "no admin token configured; skipped",
|
||||||
|
remediation: "set gitea.adminTokenFile to a file holding an ADMIN user's API token"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return checks
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = GiteaClient(baseURL: config.gitea.instanceURL, token: adminToken)
|
||||||
|
do {
|
||||||
|
let runners = try await client.listRunners()
|
||||||
|
checks.append(
|
||||||
|
DoctorCheck(
|
||||||
|
name: "gitea admin api",
|
||||||
|
result: .pass,
|
||||||
|
detail: "\(config.gitea.instanceURL.absoluteString) reachable; \(runners.count) runner(s) registered"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
checks.append(
|
||||||
|
DoctorCheck(
|
||||||
|
name: "gitea admin api",
|
||||||
|
result: .fail,
|
||||||
|
detail: "\(error)",
|
||||||
|
remediation: """
|
||||||
|
every endpoint used lives under /api/v1/admin/actions/ — the token must \
|
||||||
|
belong to a Gitea administrator, and the instance must be reachable
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
checks.append(await checkRegistrationToken(config: config, client: client))
|
||||||
|
return checks
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a registration token can be obtained at all.
|
||||||
|
private static func checkRegistrationToken(config: RunnerConfig, client: GiteaClient) async -> DoctorCheck {
|
||||||
|
let name = "registration token"
|
||||||
|
do {
|
||||||
|
if let staticToken = try config.resolveStaticRegistrationToken(), !staticToken.isEmpty {
|
||||||
|
return DoctorCheck(name: name, result: .pass, detail: "resolved from configuration")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return DoctorCheck(
|
||||||
|
name: name,
|
||||||
|
result: .fail,
|
||||||
|
detail: "\(error)",
|
||||||
|
remediation: "check gitea.registrationTokenFile"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard config.gitea.fetchRegistrationTokenViaAPI else {
|
||||||
|
return DoctorCheck(
|
||||||
|
name: name,
|
||||||
|
result: .fail,
|
||||||
|
detail: "no static token configured and gitea.fetchRegistrationTokenViaAPI is off",
|
||||||
|
remediation: """
|
||||||
|
seed a fixed token server-side (GITEA_RUNNER_REGISTRATION_TOKEN) and point \
|
||||||
|
gitea.registrationTokenFile at a copy of it
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let token = try await client.getRegistrationToken()
|
||||||
|
guard !token.isEmpty else {
|
||||||
|
return DoctorCheck(
|
||||||
|
name: name,
|
||||||
|
result: .fail,
|
||||||
|
detail: "the API returned an empty token",
|
||||||
|
remediation: "configure gitea.registrationTokenFile instead"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return DoctorCheck(name: name, result: .pass, detail: "fetched from the admin API")
|
||||||
|
} catch {
|
||||||
|
return DoctorCheck(
|
||||||
|
name: name,
|
||||||
|
result: .fail,
|
||||||
|
detail: "\(error)",
|
||||||
|
remediation: "configure gitea.registrationTokenFile instead"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the configured `gitea-runner` asset still exists.
|
||||||
|
public static func checkRunnerDownloadURL(config: RunnerConfig) async -> DoctorCheck {
|
||||||
|
let name = "runner download url"
|
||||||
|
let url: URL
|
||||||
|
do {
|
||||||
|
url = try config.runner.resolvedDownloadURL
|
||||||
|
} catch {
|
||||||
|
return DoctorCheck(
|
||||||
|
name: name,
|
||||||
|
result: .fail,
|
||||||
|
detail: "\(error)",
|
||||||
|
remediation: "check runner.runnerDownloadURL and runner.version"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "HEAD"
|
||||||
|
request.timeoutInterval = 15
|
||||||
|
|
||||||
|
do {
|
||||||
|
let (_, response) = try await URLSession.shared.data(for: request)
|
||||||
|
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||||
|
if status <= 399 {
|
||||||
|
return DoctorCheck(name: name, result: .pass, detail: "\(url.absoluteString) → \(status)")
|
||||||
|
}
|
||||||
|
return DoctorCheck(
|
||||||
|
name: name,
|
||||||
|
result: .warn,
|
||||||
|
detail: "\(url.absoluteString) → \(status)",
|
||||||
|
remediation: "check runner.version and runner.runnerDownloadURL for a darwin-arm64 asset"
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// Best effort: a proxy or offline build host is not a reason to
|
||||||
|
// block the daemon.
|
||||||
|
return DoctorCheck(
|
||||||
|
name: name,
|
||||||
|
result: .warn,
|
||||||
|
detail: "could not reach \(url.absoluteString): \(error.localizedDescription)",
|
||||||
|
remediation: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Warns about token files readable by other users on this Mac.
|
||||||
|
public static func checkTokenFilePermissions(config: RunnerConfig) -> [DoctorCheck] {
|
||||||
|
let insecure = config.insecureTokenFilePaths
|
||||||
|
guard !insecure.isEmpty else { return [] }
|
||||||
|
return [
|
||||||
|
DoctorCheck(
|
||||||
|
name: "token file permissions",
|
||||||
|
result: .warn,
|
||||||
|
detail: "group/world readable: \(insecure.joined(separator: ", "))",
|
||||||
|
remediation: "chmod 600 \(insecure.joined(separator: " "))"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The macOS 15+ Local Network permission note.
|
||||||
|
public static func localNetworkNote() -> DoctorCheck {
|
||||||
|
DoctorCheck(
|
||||||
|
name: "local network access",
|
||||||
|
result: .info,
|
||||||
|
detail: "guests are reached over the host-private NAT link",
|
||||||
|
remediation: """
|
||||||
|
on macOS 15+ the first connection to a guest can be blocked by the Local Network \
|
||||||
|
privacy prompt, which a background LaunchAgent cannot answer. Approve the app once \
|
||||||
|
under System Settings → Privacy & Security → Local Network.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders checks as aligned, human-readable lines for the CLI.
|
||||||
|
public static func format(_ checks: [DoctorCheck]) -> String {
|
||||||
|
let width = checks.map(\.name.count).max() ?? 0
|
||||||
|
var lines: [String] = []
|
||||||
|
|
||||||
|
for check in checks {
|
||||||
|
let padded = check.name.padding(toLength: max(width, check.name.count), withPad: " ", startingAt: 0)
|
||||||
|
lines.append("\(check.result.symbol) \(padded) \(check.detail)")
|
||||||
|
if check.result != .pass, let remediation = check.remediation {
|
||||||
|
for (index, wrapped) in wrap(remediation, width: 76).enumerated() {
|
||||||
|
let prefix = index == 0 ? "→ " : " "
|
||||||
|
lines.append(String(repeating: " ", count: width + 4) + prefix + wrapped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let failures = checks.filter(\.isBlocking).count
|
||||||
|
let warnings = checks.filter { $0.result == .warn }.count
|
||||||
|
lines.append("")
|
||||||
|
lines.append("\(checks.count) checks, \(failures) failed, \(warnings) warned")
|
||||||
|
return lines.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Greedy word wrap for remediation text.
|
||||||
|
private static func wrap(_ text: String, width: Int) -> [String] {
|
||||||
|
var lines: [String] = []
|
||||||
|
var current = ""
|
||||||
|
for word in text.split(whereSeparator: { $0 == " " || $0 == "\n" }) {
|
||||||
|
if current.isEmpty {
|
||||||
|
current = String(word)
|
||||||
|
} else if current.count + 1 + word.count <= width {
|
||||||
|
current += " " + word
|
||||||
|
} else {
|
||||||
|
lines.append(current)
|
||||||
|
current = String(word)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !current.isEmpty { lines.append(current) }
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the running executable, preferring the bundle's own record of it
|
||||||
|
/// over `argv[0]`, which may be a symlink or a bare command name.
|
||||||
|
private static func resolveExecutablePath(_ candidate: String) -> String? {
|
||||||
|
let fm = FileManager.default
|
||||||
|
if !candidate.isEmpty, candidate.hasPrefix("/"), fm.fileExists(atPath: candidate) {
|
||||||
|
return URL(fileURLWithPath: candidate).resolvingSymlinksInPath().path
|
||||||
|
}
|
||||||
|
if let executableURL = Bundle.main.executableURL {
|
||||||
|
return executableURL.resolvingSymlinksInPath().path
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimal synchronous process runner for the tools `doctor` shells out to.
|
||||||
|
private enum DoctorShell {
|
||||||
|
struct Output {
|
||||||
|
let exitCode: Int32
|
||||||
|
let output: String
|
||||||
|
}
|
||||||
|
|
||||||
|
static func run(_ launchPath: String, _ arguments: [String]) -> Output {
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = URL(fileURLWithPath: launchPath)
|
||||||
|
process.arguments = arguments
|
||||||
|
|
||||||
|
let pipe = Pipe()
|
||||||
|
process.standardOutput = pipe
|
||||||
|
// codesign and security both report on stderr; merge so callers can grep
|
||||||
|
// one stream.
|
||||||
|
process.standardError = pipe
|
||||||
|
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
} catch {
|
||||||
|
return Output(exitCode: 127, output: "\(error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
process.waitUntilExit()
|
||||||
|
return Output(exitCode: process.terminationStatus, output: String(data: data, encoding: .utf8) ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,599 @@
|
|||||||
|
import Foundation
|
||||||
|
import RunnerCore
|
||||||
|
|
||||||
|
/// Turns a bare macOS guest into something that can execute Gitea Actions jobs.
|
||||||
|
///
|
||||||
|
/// ## What a guest actually needs
|
||||||
|
///
|
||||||
|
/// Gitea Actions in `host` schema does not containerize anything: it shells out
|
||||||
|
/// on the guest. The hard requirements are therefore small but non-negotiable:
|
||||||
|
///
|
||||||
|
/// * **`gitea-runner`** — the runner binary itself (v3.x; renamed from
|
||||||
|
/// `act_runner`, now published from `gitea.com/gitea/runner`).
|
||||||
|
/// * **`node`** — not optional. JavaScript actions such as `actions/checkout`
|
||||||
|
/// are executed by spawning `node` directly; without it, essentially every
|
||||||
|
/// real workflow fails at its first step. Installed from Apple's official
|
||||||
|
/// arm64 `.pkg` via `installer -pkg`.
|
||||||
|
/// * **`git`** and **`bash`** — present on stock macOS, but `git` only after the
|
||||||
|
/// Command Line Tools are materialized, so presence is verified rather than
|
||||||
|
/// assumed.
|
||||||
|
/// * **A writable `$HOME`** — the runner writes its registration and workspace
|
||||||
|
/// under the guest account's home directory.
|
||||||
|
///
|
||||||
|
/// ## What else provisioning does
|
||||||
|
///
|
||||||
|
/// Everything in `Resources/provision.sh`: a passwordless-sudo drop-in
|
||||||
|
/// installed through `visudo -cf` (validated before it is moved into place, so a
|
||||||
|
/// syntax error cannot lock the account out), disabling sleep/screensaver so a
|
||||||
|
/// long job is not interrupted, disabling Spotlight indexing of build
|
||||||
|
/// directories, raising `maxfiles` (Xcode and npm both exhaust the stock 256),
|
||||||
|
/// and pre-seeding `github.com` into `known_hosts` so a checkout does not stall
|
||||||
|
/// on host-key confirmation.
|
||||||
|
///
|
||||||
|
/// - Note: The guest must **never** ship a `gitea-runner` `config.yaml` that
|
||||||
|
/// sets `runner.labels`: that key silently overrides the `--labels` passed at
|
||||||
|
/// registration, so the runner would advertise the wrong labels and never be
|
||||||
|
/// matched.
|
||||||
|
public struct GuestProvisioner: Sendable {
|
||||||
|
|
||||||
|
/// Creates a provisioner.
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
/// Runs the full provisioning sequence against a booted guest.
|
||||||
|
///
|
||||||
|
/// Steps, in order:
|
||||||
|
/// 1. Upload `Resources/provision.sh` to `/tmp/provision.sh`, `chmod +x`,
|
||||||
|
/// and run it under `sudo` with the guest username and password passed
|
||||||
|
/// via the environment (never as arguments, which are world-visible in
|
||||||
|
/// `ps`).
|
||||||
|
/// 2. Download the Node.js arm64 `.pkg` on the **host**, upload it, and
|
||||||
|
/// `installer -pkg … -target /`. Downloading host-side keeps the guest
|
||||||
|
/// off the public internet for this step and makes the version pinnable.
|
||||||
|
/// 3. Verify `git`, `bash`, and `node` all resolve.
|
||||||
|
/// 4. Download the `gitea-runner` darwin-arm64 release asset on the host
|
||||||
|
/// (URL from ``RunnerConfig/RunnerSection/resolvedDownloadURL``), upload
|
||||||
|
/// it to `/usr/local/bin/gitea-runner`, and `chmod +x`.
|
||||||
|
/// 5. Verify `gitea-runner --version` runs.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - executor: A connected guest executor.
|
||||||
|
/// - config: Supplies guest credentials and the runner download URL.
|
||||||
|
/// - progress: Optional per-step callback, for `image build` output.
|
||||||
|
/// - Throws: ``CoreError/provisioningFailed(_:)`` naming the failed step.
|
||||||
|
public func provision(
|
||||||
|
executor: any GuestExecutor,
|
||||||
|
config: RunnerConfig,
|
||||||
|
progress: (@Sendable (String) -> Void)? = nil
|
||||||
|
) async throws {
|
||||||
|
progress?("system configuration (provision.sh)")
|
||||||
|
try await runProvisionScript(executor: executor, config: config)
|
||||||
|
|
||||||
|
progress?("Node.js \(Self.defaultNodeVersion)")
|
||||||
|
try await installNode(executor: executor)
|
||||||
|
|
||||||
|
progress?("verifying toolchain")
|
||||||
|
try await verifyToolchain(executor: executor)
|
||||||
|
|
||||||
|
progress?("gitea-runner \(config.runner.version)")
|
||||||
|
try await installGiteaRunner(executor: executor, config: config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uploads and executes `Resources/provision.sh`.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - executor: A connected guest executor.
|
||||||
|
/// - config: Guest credentials.
|
||||||
|
public func runProvisionScript(
|
||||||
|
executor: any GuestExecutor,
|
||||||
|
config: RunnerConfig
|
||||||
|
) async throws {
|
||||||
|
let scriptURL = try Self.provisionScriptURL()
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await executor.upload(localPath: scriptURL.path, remotePath: "/tmp/provision.sh")
|
||||||
|
} catch {
|
||||||
|
throw CoreError.provisioningFailed(
|
||||||
|
"could not upload provision.sh from \(scriptURL.path): \(error)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The account password has to reach `sudo -S` somehow, and every obvious
|
||||||
|
// route leaks it: as an argument it is visible in `ps` to any process on
|
||||||
|
// the guest, and `echo pw | sudo -S` puts it in the shell's own argv,
|
||||||
|
// which is exactly the same exposure. A mode-0600 file read via stdin
|
||||||
|
// redirection is the one form that never appears in an argument list; it
|
||||||
|
// is removed in the same command, so it does not outlive the call even if
|
||||||
|
// the script fails.
|
||||||
|
try await executor.uploadData(
|
||||||
|
Data((config.guest.password + "\n").utf8),
|
||||||
|
remotePath: "/tmp/.gmr-auth",
|
||||||
|
mode: "0600"
|
||||||
|
)
|
||||||
|
|
||||||
|
let giteaHost = config.gitea.instanceURL.host ?? ""
|
||||||
|
// `sudo VAR=value cmd` is how variables survive sudo's env_reset; a
|
||||||
|
// plain `VAR=value sudo cmd` would be stripped. Neither the username nor
|
||||||
|
// the Gitea hostname is secret, so argv exposure is fine for these.
|
||||||
|
let command = """
|
||||||
|
sudo -S -p '' \
|
||||||
|
GUEST_USER=\(Self.shellQuote(config.guest.username)) \
|
||||||
|
GITEA_HOST=\(Self.shellQuote(giteaHost)) \
|
||||||
|
/bin/bash /tmp/provision.sh < /tmp/.gmr-auth; \
|
||||||
|
rc=$?; rm -f /tmp/.gmr-auth /tmp/provision.sh; exit $rc
|
||||||
|
"""
|
||||||
|
|
||||||
|
// Generous: the Command Line Tools download inside the script is the
|
||||||
|
// long pole and is itself bounded at 45 minutes.
|
||||||
|
let result = try await executor.run(command, timeout: .seconds(3600))
|
||||||
|
|
||||||
|
guard result.succeeded else {
|
||||||
|
throw CoreError.provisioningFailed(
|
||||||
|
"provision.sh failed (exit \(result.exitCode))\n"
|
||||||
|
+ Self.tail(result.stderr.isEmpty ? result.stdout : result.stderr)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The script warns rather than aborts on best-effort steps, so a zero
|
||||||
|
// exit alone does not prove it ran to the end — a truncated SSH channel
|
||||||
|
// would also look like success. The marker is the actual proof.
|
||||||
|
guard result.stdout.contains("PROVISION_OK") else {
|
||||||
|
throw CoreError.provisioningFailed(
|
||||||
|
"provision.sh exited 0 but never printed PROVISION_OK; it did not run to completion\n"
|
||||||
|
+ Self.tail(result.stdout)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installs Node.js from the official arm64 package.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - executor: A connected guest executor.
|
||||||
|
/// - version: Node major/minor/patch, e.g. `22.11.0`.
|
||||||
|
/// - packageURL: Overrides the derived download URL entirely. Used by
|
||||||
|
/// ``resolveLatestLTSNodeVersion()`` callers and by air-gapped setups
|
||||||
|
/// pointing at an internal mirror.
|
||||||
|
public func installNode(
|
||||||
|
executor: any GuestExecutor,
|
||||||
|
version: String = GuestProvisioner.defaultNodeVersion,
|
||||||
|
packageURL: URL? = nil
|
||||||
|
) async throws {
|
||||||
|
guard let url = packageURL ?? Self.nodePackageURL(version: version) else {
|
||||||
|
throw CoreError.configInvalid("cannot form a Node.js package URL for version \(version)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Downloaded host-side rather than by the guest: the version is then
|
||||||
|
// pinned by the host's config, the guest needs no outbound access for
|
||||||
|
// this step, and a rebuild of ten images hits the host's cache instead of
|
||||||
|
// nodejs.org ten times.
|
||||||
|
let local = try await Self.downloadToTemporaryFile(url: url, suggestedName: "node.pkg")
|
||||||
|
defer { try? FileManager.default.removeItem(at: local) }
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await executor.upload(localPath: local.path, remotePath: "/tmp/node.pkg")
|
||||||
|
} catch {
|
||||||
|
throw CoreError.provisioningFailed("could not upload the Node.js package: \(error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let install = try await executor.run(
|
||||||
|
"sudo -n /usr/sbin/installer -pkg /tmp/node.pkg -target /; rc=$?; rm -f /tmp/node.pkg; exit $rc",
|
||||||
|
timeout: .seconds(900)
|
||||||
|
)
|
||||||
|
guard install.succeeded else {
|
||||||
|
throw CoreError.provisioningFailed(
|
||||||
|
"installing Node.js failed (exit \(install.exitCode))\n"
|
||||||
|
+ Self.tail(install.stderr.isEmpty ? install.stdout : install.stderr)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let check = try await executor.run(Self.withGuestPath("node --version"), timeout: .seconds(120))
|
||||||
|
guard check.succeeded else {
|
||||||
|
throw CoreError.provisioningFailed(
|
||||||
|
"Node.js installed but `node --version` failed (exit \(check.exitCode)). "
|
||||||
|
+ "Gitea's JavaScript actions spawn `node` directly, so this image would fail "
|
||||||
|
+ "every workflow that uses actions/checkout.\n"
|
||||||
|
+ Self.tail(check.stderr.isEmpty ? check.stdout : check.stderr)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies that `git`, `bash`, and `node` are all present and executable.
|
||||||
|
///
|
||||||
|
/// - Throws: ``CoreError/provisioningFailed(_:)`` naming what is missing.
|
||||||
|
public func verifyToolchain(executor: any GuestExecutor) async throws {
|
||||||
|
// Order matters. On a vanilla guest `/usr/bin/git` is a shim that pops a
|
||||||
|
// GUI "install command line developer tools" dialog and blocks until
|
||||||
|
// someone clicks it — which, headless, is never. So the presence of a
|
||||||
|
// real git is established from the *package receipt* first, and `git`
|
||||||
|
// itself is only invoked once that check passes.
|
||||||
|
let hasTools = try await executor.run(
|
||||||
|
"pkgutil --pkg-info=com.apple.pkg.CLTools_Executables >/dev/null 2>&1 "
|
||||||
|
+ "|| [ -x /Applications/Xcode.app/Contents/Developer/usr/bin/git ]",
|
||||||
|
timeout: .seconds(120)
|
||||||
|
)
|
||||||
|
guard hasTools.succeeded else {
|
||||||
|
throw CoreError.provisioningFailed(
|
||||||
|
"""
|
||||||
|
the guest has no Command Line Tools, so `git` is only a stub that blocks on a \
|
||||||
|
GUI installer dialog. provision.sh attempted a non-interactive install and it \
|
||||||
|
did not take. Install a real toolchain instead:
|
||||||
|
|
||||||
|
gitea-macos-runner image provision <NAME> --xcode-xip /path/to/Xcode.xip
|
||||||
|
|
||||||
|
(Shipping the image without git would fail every checkout at job time rather \
|
||||||
|
than here, so the build stops now.)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (tool, command) in [
|
||||||
|
("git", "git --version"),
|
||||||
|
("bash", "bash --version"),
|
||||||
|
("node", "node --version"),
|
||||||
|
] {
|
||||||
|
let result = try await executor.run(Self.withGuestPath(command), timeout: .seconds(120))
|
||||||
|
guard result.succeeded else {
|
||||||
|
throw CoreError.provisioningFailed(
|
||||||
|
"required tool `\(tool)` is not usable in the guest (`\(command)` exited \(result.exitCode))\n"
|
||||||
|
+ Self.tail(result.stderr.isEmpty ? result.stdout : result.stderr)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downloads the `gitea-runner` release asset on the host and installs it
|
||||||
|
/// into the guest at `/usr/local/bin/gitea-runner`.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - executor: A connected guest executor.
|
||||||
|
/// - config: Supplies the download URL template and version.
|
||||||
|
public func installGiteaRunner(
|
||||||
|
executor: any GuestExecutor,
|
||||||
|
config: RunnerConfig
|
||||||
|
) async throws {
|
||||||
|
let url = try config.runner.resolvedDownloadURL
|
||||||
|
|
||||||
|
let local = try await Self.downloadToTemporaryFile(url: url, suggestedName: "gitea-runner")
|
||||||
|
defer { try? FileManager.default.removeItem(at: local) }
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await executor.upload(localPath: local.path, remotePath: "/tmp/gitea-runner")
|
||||||
|
} catch {
|
||||||
|
throw CoreError.provisioningFailed("could not upload the gitea-runner binary: \(error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
try await executor.runChecked(
|
||||||
|
"sudo -n /usr/bin/install -o root -g wheel -m 755 /tmp/gitea-runner /usr/local/bin/gitea-runner "
|
||||||
|
+ "&& rm -f /tmp/gitea-runner",
|
||||||
|
timeout: .seconds(300)
|
||||||
|
)
|
||||||
|
|
||||||
|
// arm64 macOS refuses to exec a binary with no code signature at all
|
||||||
|
// (SIGKILL, no diagnostic). Release tarballs are usually ad-hoc signed
|
||||||
|
// already, in which case re-signing is a no-op; when they are not, this
|
||||||
|
// is what keeps the runner from being killed on its first invocation.
|
||||||
|
// Best-effort: `codesign` needs the Command Line Tools, and a signature
|
||||||
|
// that was already valid does not need replacing.
|
||||||
|
_ = try? await executor.run(
|
||||||
|
"sudo -n /usr/bin/codesign --force --sign - /usr/local/bin/gitea-runner",
|
||||||
|
timeout: .seconds(300)
|
||||||
|
)
|
||||||
|
|
||||||
|
let check = try await executor.run(
|
||||||
|
Self.withGuestPath("gitea-runner --version"),
|
||||||
|
timeout: .seconds(120)
|
||||||
|
)
|
||||||
|
guard check.succeeded else {
|
||||||
|
throw CoreError.provisioningFailed(
|
||||||
|
"gitea-runner installed from \(url.absoluteString) but `gitea-runner --version` "
|
||||||
|
+ "failed (exit \(check.exitCode))\n"
|
||||||
|
+ Self.tail(check.stderr.isEmpty ? check.stdout : check.stderr)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installs Xcode from a `.xip` into the guest — the optional heavy step.
|
||||||
|
///
|
||||||
|
/// Xcode is not installed by default: the `.xip` is ~8 GB and expanding it
|
||||||
|
/// roughly triples the base image, which is a poor default for a workflow
|
||||||
|
/// that only needs `swift build`. Operators who need it run
|
||||||
|
/// `image provision NAME --xcode-xip PATH` once, after which every clone
|
||||||
|
/// inherits it.
|
||||||
|
///
|
||||||
|
/// Expansion uses `xip --expand` inside the guest, followed by
|
||||||
|
/// `xcode-select -s` and `xcodebuild -license accept`, and finishes by
|
||||||
|
/// running `xcodebuild -runFirstLaunch` so the first job does not pay for
|
||||||
|
/// component installation.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - executor: A connected guest executor.
|
||||||
|
/// - xipPath: Path to the `.xip` **on the host**; it is uploaded.
|
||||||
|
public func installXcode(executor: any GuestExecutor, xipPath: String) async throws {
|
||||||
|
let localURL = URL(fileURLWithPath: (xipPath as NSString).expandingTildeInPath)
|
||||||
|
guard FileManager.default.fileExists(atPath: localURL.path) else {
|
||||||
|
throw CoreError.notFound("Xcode .xip not found at \(localURL.path)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let remoteXIP = "/tmp/Xcode.xip"
|
||||||
|
// Uploads go over an SSH exec channel with the payload as stdin, and
|
||||||
|
// `GuestExecutor.upload` reads the whole local file into memory first —
|
||||||
|
// fine for a 90 MB pkg, ruinous for a 12 GB xip. So this streams the file
|
||||||
|
// in bounded chunks and appends them guest-side instead. It is still slow
|
||||||
|
// (an exec channel is not SCP), but it is functional and its host memory
|
||||||
|
// use is capped at one chunk.
|
||||||
|
try await executor.runChecked("rm -f \(Self.shellQuote(remoteXIP))", timeout: .seconds(120))
|
||||||
|
try await Self.uploadLargeFile(executor: executor, localURL: localURL, remotePath: remoteXIP)
|
||||||
|
|
||||||
|
// Free the disk the old copy occupies before expanding into ~40 GB more.
|
||||||
|
_ = try? await executor.run("sudo -n rm -rf /Applications/Xcode.app", timeout: .seconds(600))
|
||||||
|
|
||||||
|
let staging = "/tmp/xcode-expand"
|
||||||
|
// `xip --expand` writes into the current directory and needs no sudo, but
|
||||||
|
// /tmp is small on some layouts; staging under /tmp keeps it beside the
|
||||||
|
// archive so the later move is a rename within one volume where possible.
|
||||||
|
try await executor.runChecked(
|
||||||
|
"rm -rf \(Self.shellQuote(staging)) && mkdir -p \(Self.shellQuote(staging))",
|
||||||
|
timeout: .seconds(300)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Expansion of a full Xcode takes 20–45 minutes on VM-backed storage.
|
||||||
|
try await executor.runChecked(
|
||||||
|
"cd \(Self.shellQuote(staging)) && sudo -n /usr/bin/xip --expand \(Self.shellQuote(remoteXIP))",
|
||||||
|
timeout: .seconds(5400)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Writing into /Applications needs root.
|
||||||
|
try await executor.runChecked(
|
||||||
|
"sudo -n mv \(Self.shellQuote(staging + "/Xcode.app")) /Applications/Xcode.app "
|
||||||
|
+ "&& sudo -n rm -rf \(Self.shellQuote(staging)) \(Self.shellQuote(remoteXIP))",
|
||||||
|
timeout: .seconds(1800)
|
||||||
|
)
|
||||||
|
|
||||||
|
// xcode-select writes /var/db/xcode_select_link — root only.
|
||||||
|
try await executor.runChecked(
|
||||||
|
"sudo -n /usr/bin/xcode-select -s /Applications/Xcode.app/Contents/Developer",
|
||||||
|
timeout: .seconds(300)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Both of these write under /Library and must run as root; -runFirstLaunch
|
||||||
|
// installs the bundled packages (simulators, device support) that would
|
||||||
|
// otherwise be installed lazily during the first job.
|
||||||
|
try await executor.runChecked(
|
||||||
|
"sudo -n /usr/bin/xcodebuild -license accept",
|
||||||
|
timeout: .seconds(600)
|
||||||
|
)
|
||||||
|
try await executor.runChecked(
|
||||||
|
"sudo -n /usr/bin/xcodebuild -runFirstLaunch",
|
||||||
|
timeout: .seconds(3600)
|
||||||
|
)
|
||||||
|
|
||||||
|
let check = try await executor.run("/usr/bin/xcodebuild -version", timeout: .seconds(300))
|
||||||
|
guard check.succeeded else {
|
||||||
|
throw CoreError.provisioningFailed(
|
||||||
|
"Xcode installed but `xcodebuild -version` failed (exit \(check.exitCode))\n"
|
||||||
|
+ Self.tail(check.stderr.isEmpty ? check.stdout : check.stderr)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Node.js version installed when none is specified.
|
||||||
|
///
|
||||||
|
/// Pinned rather than resolved at build time so that two images built weeks
|
||||||
|
/// apart are identical unless someone changes this line. Use
|
||||||
|
/// ``resolveLatestLTSNodeVersion()`` to look up a newer LTS deliberately.
|
||||||
|
public static let defaultNodeVersion = "24.19.0"
|
||||||
|
|
||||||
|
/// The official Node.js macOS arm64 package URL for a version.
|
||||||
|
public static func nodePackageURL(version: String) -> URL? {
|
||||||
|
URL(string: "https://nodejs.org/dist/v\(version)/node-v\(version).pkg")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Looks up the current Node.js LTS version from nodejs.org.
|
||||||
|
///
|
||||||
|
/// Best-effort and deliberately not called by ``provision(executor:config:progress:)``:
|
||||||
|
/// an image build that silently picks up a different Node depending on the
|
||||||
|
/// day it ran is not reproducible. Callers that want the newest LTS pass the
|
||||||
|
/// result to ``installNode(executor:version:packageURL:)`` explicitly.
|
||||||
|
///
|
||||||
|
/// - Returns: The version string without the leading `v`, or `nil` if the
|
||||||
|
/// index could not be read.
|
||||||
|
public static func resolveLatestLTSNodeVersion() async -> String? {
|
||||||
|
guard let indexURL = URL(string: "https://nodejs.org/dist/index.json") else { return nil }
|
||||||
|
var request = URLRequest(url: indexURL)
|
||||||
|
request.timeoutInterval = 30
|
||||||
|
|
||||||
|
guard let (data, response) = try? await URLSession.shared.data(for: request),
|
||||||
|
let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode),
|
||||||
|
let entries = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]]
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
// The index is newest-first, and `lts` is `false` for non-LTS releases
|
||||||
|
// and the codename string ("Krypton") for LTS ones.
|
||||||
|
for entry in entries {
|
||||||
|
guard let version = entry["version"] as? String else { continue }
|
||||||
|
if entry["lts"] is String {
|
||||||
|
return String(version.dropFirst()) // "v24.19.0" -> "24.19.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Locating provision.sh
|
||||||
|
|
||||||
|
/// Finds `Resources/provision.sh`.
|
||||||
|
///
|
||||||
|
/// The package declares no SwiftPM `resources:`, so `Bundle.module` does not
|
||||||
|
/// exist and the script has to be located by hand. Three deployments matter:
|
||||||
|
/// the signed `.app` the daemon actually runs from (`Contents/Resources`), a
|
||||||
|
/// bare `swift build` binary in `.build/debug`, and a `swift run` from the
|
||||||
|
/// checkout. Each is tried in turn, and the error names every path searched
|
||||||
|
/// so a packaging mistake is diagnosable from the message alone.
|
||||||
|
///
|
||||||
|
/// - Returns: URL of the script.
|
||||||
|
/// - Throws: ``CoreError/notFound(_:)`` listing the searched paths.
|
||||||
|
public static func provisionScriptURL() throws -> URL {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
var searched: [URL] = []
|
||||||
|
|
||||||
|
func check(_ url: URL) -> URL? {
|
||||||
|
searched.append(url)
|
||||||
|
return fileManager.isReadableFile(atPath: url.path) ? url : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. The .app's own resources, via the bundle API and by hand (the API
|
||||||
|
// returns nil for a bare executable with no Info.plist).
|
||||||
|
if let url = Bundle.main.url(forResource: "provision", withExtension: "sh") {
|
||||||
|
searched.append(url)
|
||||||
|
if fileManager.isReadableFile(atPath: url.path) { return url }
|
||||||
|
}
|
||||||
|
|
||||||
|
var roots: [URL] = [Bundle.main.bundleURL]
|
||||||
|
if let executableDirectory = Bundle.main.executableURL?
|
||||||
|
.resolvingSymlinksInPath()
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
{
|
||||||
|
roots.append(executableDirectory)
|
||||||
|
}
|
||||||
|
roots.append(URL(fileURLWithPath: fileManager.currentDirectoryPath))
|
||||||
|
// The checkout this file was compiled from: Sources/RunnerHost/<file> →
|
||||||
|
// three levels up is the package root. Only useful for `swift run` during
|
||||||
|
// development, hence last.
|
||||||
|
roots.append(
|
||||||
|
URL(fileURLWithPath: #filePath)
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
)
|
||||||
|
|
||||||
|
for root in roots {
|
||||||
|
var candidate = root.resolvingSymlinksInPath()
|
||||||
|
// Walk upward: `.build/debug/gitea-macos-runner` is four levels below
|
||||||
|
// the checkout root, and an .app nested in a staging directory is
|
||||||
|
// similar.
|
||||||
|
for _ in 0..<6 {
|
||||||
|
if let found = check(candidate.appendingPathComponent("Contents/Resources/provision.sh")) {
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
if let found = check(candidate.appendingPathComponent("Resources/provision.sh")) {
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
let parent = candidate.deletingLastPathComponent()
|
||||||
|
if parent.path == candidate.path { break }
|
||||||
|
candidate = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let list = searched.map { " \($0.path)" }.joined(separator: "\n")
|
||||||
|
throw CoreError.notFound(
|
||||||
|
"provision.sh could not be located. Searched:\n\(list)\n"
|
||||||
|
+ "When running from a bundled .app, Resources/provision.sh must be copied into "
|
||||||
|
+ "Contents/Resources/ by the build."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
/// A PATH that includes `/usr/local/bin`.
|
||||||
|
///
|
||||||
|
/// `ssh host command` runs a non-login, non-interactive shell, which never
|
||||||
|
/// sources the file where `path_helper` adds `/usr/local/bin`. Both `node`
|
||||||
|
/// and `gitea-runner` install there, so every command that names one is
|
||||||
|
/// wrapped in this. (`provision.sh` also writes `/etc/zshenv` to fix this for
|
||||||
|
/// everything else that talks to the guest.)
|
||||||
|
static func withGuestPath(_ command: String) -> String {
|
||||||
|
"export PATH=/usr/local/bin:/opt/homebrew/bin:$PATH; " + command
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps a value so `/bin/sh` sees it literally.
|
||||||
|
static func shellQuote(_ value: String) -> String {
|
||||||
|
"'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trims captured output to something a terminal error can carry.
|
||||||
|
static func tail(_ output: String, lines: Int = 30) -> String {
|
||||||
|
let all = output.split(separator: "\n", omittingEmptySubsequences: false)
|
||||||
|
return all.suffix(lines).joined(separator: "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downloads a URL to a unique temporary file.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - url: Source.
|
||||||
|
/// - suggestedName: File name within the temporary directory.
|
||||||
|
/// - Returns: The local file, which the caller owns and must delete.
|
||||||
|
static func downloadToTemporaryFile(url: URL, suggestedName: String) async throws -> URL {
|
||||||
|
let configuration = URLSessionConfiguration.ephemeral
|
||||||
|
configuration.timeoutIntervalForRequest = 60
|
||||||
|
configuration.timeoutIntervalForResource = 60 * 60
|
||||||
|
let session = URLSession(configuration: configuration)
|
||||||
|
defer { session.finishTasksAndInvalidate() }
|
||||||
|
|
||||||
|
let temporary: URL
|
||||||
|
let response: URLResponse
|
||||||
|
do {
|
||||||
|
(temporary, response) = try await session.download(from: url)
|
||||||
|
} catch {
|
||||||
|
throw CoreError.provisioningFailed(
|
||||||
|
"download failed for \(url.absoluteString): \(error.localizedDescription)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
|
||||||
|
try? FileManager.default.removeItem(at: temporary)
|
||||||
|
throw CoreError.provisioningFailed(
|
||||||
|
"download failed: HTTP \(http.statusCode) for \(url.absoluteString)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let destination = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("gmr-\(UUID().uuidString)-\(suggestedName)")
|
||||||
|
do {
|
||||||
|
try FileManager.default.moveItem(at: temporary, to: destination)
|
||||||
|
} catch {
|
||||||
|
try? FileManager.default.removeItem(at: temporary)
|
||||||
|
throw CoreError.provisioningFailed(
|
||||||
|
"could not stage the download from \(url.absoluteString): \(error.localizedDescription)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return destination
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uploads a file too large to hold in memory, one chunk at a time.
|
||||||
|
///
|
||||||
|
/// ``GuestExecutor/upload(localPath:remotePath:)`` slurps the whole file, so
|
||||||
|
/// a multi-gigabyte Xcode archive would exhaust host memory before a byte
|
||||||
|
/// moved. Each chunk is written to a scratch path and appended guest-side,
|
||||||
|
/// which keeps both ends bounded.
|
||||||
|
static func uploadLargeFile(
|
||||||
|
executor: any GuestExecutor,
|
||||||
|
localURL: URL,
|
||||||
|
remotePath: String,
|
||||||
|
chunkBytes: Int = 128 * 1024 * 1024,
|
||||||
|
progress: (@Sendable (Double) -> Void)? = nil
|
||||||
|
) async throws {
|
||||||
|
let handle = try FileHandle(forReadingFrom: localURL)
|
||||||
|
defer { try? handle.close() }
|
||||||
|
|
||||||
|
let attributes = try? FileManager.default.attributesOfItem(atPath: localURL.path)
|
||||||
|
let totalBytes = attributes?[.size] as? Int
|
||||||
|
let quotedRemote = shellQuote(remotePath)
|
||||||
|
let scratch = remotePath + ".part"
|
||||||
|
let quotedScratch = shellQuote(scratch)
|
||||||
|
|
||||||
|
var sent = 0
|
||||||
|
while true {
|
||||||
|
let chunk = try handle.read(upToCount: chunkBytes) ?? Data()
|
||||||
|
if chunk.isEmpty { break }
|
||||||
|
|
||||||
|
try await executor.uploadData(chunk, remotePath: scratch, mode: "0644")
|
||||||
|
try await executor.runChecked(
|
||||||
|
"cat \(quotedScratch) >> \(quotedRemote) && rm -f \(quotedScratch)",
|
||||||
|
timeout: .seconds(600)
|
||||||
|
)
|
||||||
|
|
||||||
|
sent += chunk.count
|
||||||
|
if let totalBytes, totalBytes > 0 {
|
||||||
|
progress?(min(Double(sent) / Double(totalBytes), 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import Foundation
|
||||||
|
import RunnerCore
|
||||||
|
import Virtualization
|
||||||
|
|
||||||
|
/// Locates, downloads, and opens macOS restore images (IPSWs).
|
||||||
|
///
|
||||||
|
/// Two distinct notions of "restore image" get conflated easily, so this type
|
||||||
|
/// keeps them apart:
|
||||||
|
///
|
||||||
|
/// * `VZMacOSRestoreImage.latestSupported` returns an image whose `url` is a
|
||||||
|
/// **network** URL on Apple's CDN. It cannot be handed to `VZMacOSInstaller`.
|
||||||
|
/// * `VZMacOSRestoreImage.image(from:)` (or `load(from:)`) opens a **local
|
||||||
|
/// file** URL. That is what the installer needs.
|
||||||
|
///
|
||||||
|
/// So the pipeline is always: discover → download → load.
|
||||||
|
public struct IPSWProvider: Sendable {
|
||||||
|
/// Where downloads are written, typically `<storeDir>/ipsw`.
|
||||||
|
public let downloadDirectory: URL
|
||||||
|
|
||||||
|
/// Creates a provider.
|
||||||
|
///
|
||||||
|
/// - Parameter downloadDirectory: Destination directory for downloads.
|
||||||
|
public init(downloadDirectory: URL) {
|
||||||
|
self.downloadDirectory = downloadDirectory
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asks Apple for the newest restore image this host can run.
|
||||||
|
///
|
||||||
|
/// - Returns: The CDN URL to download and the image's build version (e.g.
|
||||||
|
/// `25A354`), recorded into ``VMBundleConfig/macOSVersion``.
|
||||||
|
/// - Throws: ``CoreError/notFound(_:)`` when Apple reports no supported
|
||||||
|
/// image (which also happens with no network).
|
||||||
|
public func latestSupported() async throws -> (url: URL, buildVersion: String) {
|
||||||
|
let image: VZMacOSRestoreImage
|
||||||
|
do {
|
||||||
|
image = try await VZMacOSRestoreImage.latestSupported
|
||||||
|
} catch {
|
||||||
|
// The framework reports "no supported image" and "could not reach
|
||||||
|
// the CDN" identically, so the message has to cover both.
|
||||||
|
throw CoreError.notFound(
|
||||||
|
"no supported macOS restore image available: \(error.localizedDescription) "
|
||||||
|
+ "(check network connectivity, or pass --ipsw with a local file)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (image.url, image.buildVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downloads a restore image to ``downloadDirectory``.
|
||||||
|
///
|
||||||
|
/// IPSWs are ~15 GB, so this reports progress and resumes nothing — a failed
|
||||||
|
/// download is retried from scratch. The file is written to a `.partial`
|
||||||
|
/// name and renamed on completion so an interrupted run never leaves a
|
||||||
|
/// truncated file that looks valid.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - remoteURL: The CDN URL from ``latestSupported()``.
|
||||||
|
/// - progress: Called with a fraction in `0...1`. May be called from an
|
||||||
|
/// arbitrary thread.
|
||||||
|
/// - Returns: The local file URL.
|
||||||
|
public func download(
|
||||||
|
from remoteURL: URL,
|
||||||
|
progress: (@Sendable (Double) -> Void)? = nil
|
||||||
|
) async throws -> URL {
|
||||||
|
// A file URL is already local; nothing to do.
|
||||||
|
if remoteURL.isFileURL {
|
||||||
|
progress?(1.0)
|
||||||
|
return remoteURL
|
||||||
|
}
|
||||||
|
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
try fileManager.createDirectory(at: downloadDirectory, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
let fileName = IPSWProvider.localFileName(for: remoteURL)
|
||||||
|
let finalURL = downloadDirectory.appendingPathComponent(fileName)
|
||||||
|
|
||||||
|
// A previously completed download is reused: only fully-written files
|
||||||
|
// ever get the final name.
|
||||||
|
if fileManager.fileExists(atPath: finalURL.path) {
|
||||||
|
progress?(1.0)
|
||||||
|
return finalURL
|
||||||
|
}
|
||||||
|
|
||||||
|
let partialURL = downloadDirectory.appendingPathComponent(fileName + ".partial")
|
||||||
|
try? fileManager.removeItem(at: partialURL)
|
||||||
|
|
||||||
|
let configuration = URLSessionConfiguration.default
|
||||||
|
// The default 7-day resource timeout is useless as a failure signal and
|
||||||
|
// the default 60 s request timeout only bounds the *response start*.
|
||||||
|
// Six hours is generous for 15 GB on a slow link and still finite.
|
||||||
|
configuration.timeoutIntervalForRequest = 120
|
||||||
|
configuration.timeoutIntervalForResource = 6 * 60 * 60
|
||||||
|
configuration.waitsForConnectivity = true
|
||||||
|
|
||||||
|
let delegate = IPSWDownloadProgressDelegate(onProgress: progress)
|
||||||
|
let session = URLSession(configuration: configuration)
|
||||||
|
defer { session.finishTasksAndInvalidate() }
|
||||||
|
|
||||||
|
let temporaryURL: URL
|
||||||
|
let response: URLResponse
|
||||||
|
do {
|
||||||
|
(temporaryURL, response) = try await session.download(from: remoteURL, delegate: delegate)
|
||||||
|
} catch {
|
||||||
|
throw CoreError.notFound(
|
||||||
|
"restore image download failed for \(remoteURL.absoluteString): \(error.localizedDescription)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
|
||||||
|
try? fileManager.removeItem(at: temporaryURL)
|
||||||
|
throw CoreError.notFound(
|
||||||
|
"restore image download failed: HTTP \(http.statusCode) for \(remoteURL.absoluteString)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move into `.partial` first, then rename: the final name is the
|
||||||
|
// "this file is complete" marker that the reuse check above trusts.
|
||||||
|
do {
|
||||||
|
try fileManager.moveItem(at: temporaryURL, to: partialURL)
|
||||||
|
try fileManager.moveItem(at: partialURL, to: finalURL)
|
||||||
|
} catch {
|
||||||
|
try? fileManager.removeItem(at: temporaryURL)
|
||||||
|
try? fileManager.removeItem(at: partialURL)
|
||||||
|
throw CoreError.provisioningFailed(
|
||||||
|
"could not store the downloaded restore image at \(finalURL.path): \(error.localizedDescription)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
progress?(1.0)
|
||||||
|
return finalURL
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens a local IPSW.
|
||||||
|
///
|
||||||
|
/// Symlinks are resolved first: `VZMacOSRestoreImage` rejects a symlinked
|
||||||
|
/// path, and `~/Downloads` paths handed in by users are frequently symlinked
|
||||||
|
/// through `/Users` → `/System/Volumes/Data/Users`.
|
||||||
|
///
|
||||||
|
/// - Parameter localPath: Path to an `.ipsw` file.
|
||||||
|
/// - Returns: The loaded restore image.
|
||||||
|
/// - Throws: ``CoreError/notFound(_:)`` when the path does not exist, or
|
||||||
|
/// ``CoreError/configInvalid(_:)`` when it is not a local file URL.
|
||||||
|
public func load(localPath: String) async throws -> VZMacOSRestoreImage {
|
||||||
|
let expanded = (localPath as NSString).expandingTildeInPath
|
||||||
|
var url = URL(fileURLWithPath: expanded)
|
||||||
|
// Must happen before the framework ever sees the URL.
|
||||||
|
url.resolveSymlinksInPath()
|
||||||
|
|
||||||
|
guard url.isFileURL else {
|
||||||
|
throw CoreError.configInvalid(
|
||||||
|
"restore image path must be a local file, got \(url.absoluteString)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// `VZMacOSRestoreImage.image(from:)` raises an Objective-C exception —
|
||||||
|
// not a Swift error — when handed a non-file or missing path, and an
|
||||||
|
// ObjC exception cannot be caught here. So the existence check is not
|
||||||
|
// politeness; it is the only thing standing between a typo and a crash.
|
||||||
|
var isDirectory: ObjCBool = false
|
||||||
|
guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory),
|
||||||
|
!isDirectory.boolValue
|
||||||
|
else {
|
||||||
|
throw CoreError.notFound("restore image not found at \(url.path)")
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
return try await VZMacOSRestoreImage.image(from: url)
|
||||||
|
} catch {
|
||||||
|
throw CoreError.provisioningFailed(
|
||||||
|
"could not read restore image at \(url.path): \(error.localizedDescription)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience: discover, download if not already present, and load.
|
||||||
|
///
|
||||||
|
/// - Parameter progress: Download progress callback.
|
||||||
|
/// - Returns: The loaded image and the local file it came from.
|
||||||
|
public func fetchLatest(
|
||||||
|
progress: (@Sendable (Double) -> Void)? = nil
|
||||||
|
) async throws -> (image: VZMacOSRestoreImage, localURL: URL) {
|
||||||
|
let (remoteURL, _) = try await latestSupported()
|
||||||
|
let localURL = try await download(from: remoteURL, progress: progress)
|
||||||
|
// Deliberately reloaded from the local file: the image returned by
|
||||||
|
// `latestSupported` carries a network URL, and the installer needs one
|
||||||
|
// whose `url` is on disk.
|
||||||
|
let image = try await load(localPath: localURL.path)
|
||||||
|
return (image, localURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
/// The on-disk name for a remote restore image.
|
||||||
|
///
|
||||||
|
/// Apple's CDN names are already unique (`UniversalMac_15.2_24C101_Restore.ipsw`);
|
||||||
|
/// anything else falls back to a name derived from the URL so two different
|
||||||
|
/// sources cannot collide.
|
||||||
|
static func localFileName(for remoteURL: URL) -> String {
|
||||||
|
let candidate = remoteURL.lastPathComponent
|
||||||
|
if candidate.lowercased().hasSuffix(".ipsw"), candidate.count > ".ipsw".count {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
let digest = abs(remoteURL.absoluteString.hashValue)
|
||||||
|
return "restore-\(String(digest, radix: 16)).ipsw"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reports `URLSession` download progress as a fraction.
|
||||||
|
///
|
||||||
|
/// A task-scoped delegate is the only way to observe byte progress from the
|
||||||
|
/// `async` download API; the `didFinishDownloadingTo` callback is deliberately
|
||||||
|
/// *not* implemented, because the `async` variant owns the temporary file.
|
||||||
|
private final class IPSWDownloadProgressDelegate: NSObject, URLSessionDownloadDelegate, @unchecked Sendable {
|
||||||
|
private let onProgress: (@Sendable (Double) -> Void)?
|
||||||
|
|
||||||
|
init(onProgress: (@Sendable (Double) -> Void)?) {
|
||||||
|
self.onProgress = onProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
func urlSession(
|
||||||
|
_ session: URLSession,
|
||||||
|
downloadTask: URLSessionDownloadTask,
|
||||||
|
didWriteData bytesWritten: Int64,
|
||||||
|
totalBytesWritten: Int64,
|
||||||
|
totalBytesExpectedToWrite: Int64
|
||||||
|
) {
|
||||||
|
// A chunked response reports -1 for the expected length; report nothing
|
||||||
|
// rather than a nonsense fraction.
|
||||||
|
guard totalBytesExpectedToWrite > 0 else { return }
|
||||||
|
let fraction = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
|
||||||
|
onProgress?(min(max(fraction, 0), 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
func urlSession(
|
||||||
|
_ session: URLSession,
|
||||||
|
downloadTask: URLSessionDownloadTask,
|
||||||
|
didFinishDownloadingTo location: URL
|
||||||
|
) {
|
||||||
|
// Intentionally empty. `URLSession.download(from:delegate:)` moves the
|
||||||
|
// file itself; doing anything here would race with it.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,706 @@
|
|||||||
|
import Foundation
|
||||||
|
import RunnerCore
|
||||||
|
import Virtualization
|
||||||
|
|
||||||
|
/// A coarse progress report from ``ImageBuilder``.
|
||||||
|
public enum ImageBuildStage: Sendable, Equatable {
|
||||||
|
/// Downloading the IPSW.
|
||||||
|
case downloadingIPSW(fraction: Double)
|
||||||
|
/// Reading the restore image and deriving a hardware configuration.
|
||||||
|
case preparing
|
||||||
|
/// Creating the disk, NVRAM, and bundle metadata.
|
||||||
|
case creatingBundle
|
||||||
|
/// `VZMacOSInstaller` is writing macOS onto the disk.
|
||||||
|
case installing(fraction: Double)
|
||||||
|
/// First boot; waiting for Setup Assistant, a DHCP lease, and SSH.
|
||||||
|
case firstBoot
|
||||||
|
/// Running guest provisioning over SSH.
|
||||||
|
case provisioning(step: String)
|
||||||
|
/// Shutting the guest down cleanly and sealing the bundle.
|
||||||
|
case finalizing
|
||||||
|
/// Done.
|
||||||
|
case done
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a base macOS image from an IPSW, end to end.
|
||||||
|
///
|
||||||
|
/// ## Pipeline
|
||||||
|
///
|
||||||
|
/// 1. **Load restore image** — `VZMacOSRestoreImage` from a local `.ipsw`
|
||||||
|
/// (downloaded first if the caller did not supply one).
|
||||||
|
/// 2. **Derive hardware** — `mostFeaturefulSupportedConfiguration`. A `nil`
|
||||||
|
/// here means this host cannot run this image at all; fail loudly rather
|
||||||
|
/// than trying to guess a configuration.
|
||||||
|
/// 3. **Create the bundle** — persist `hardwareModel.dataRepresentation` and a
|
||||||
|
/// fresh `VZMacMachineIdentifier`; create NVRAM with
|
||||||
|
/// `VZMacAuxiliaryStorage(creatingStorageAt:hardwareModel:)`; create the
|
||||||
|
/// disk, preferring sparse ASIF via
|
||||||
|
/// `/usr/sbin/diskutil image create blank --fs none --format ASIF --size <N>G <path>`
|
||||||
|
/// (macOS 26+) and falling back to a `truncate`-style sparse RAW file.
|
||||||
|
/// 4. **Install** — `VZMacOSInstaller` against a *stopped* VM built from that
|
||||||
|
/// configuration, observing its `Progress` via KVO.
|
||||||
|
/// 5. **First boot with Setup Assistant automation** — see
|
||||||
|
/// ``firstBootAndProvision(bundle:config:progress:)``.
|
||||||
|
/// 6. **Provision** — ``GuestProvisioner`` over SSH.
|
||||||
|
/// 7. **Finalize** — clean guest shutdown, then set
|
||||||
|
/// ``VMBundleConfig/provisioned`` to `true`. Only then is the image clonable.
|
||||||
|
public struct ImageBuilder: Sendable {
|
||||||
|
/// The store this image is built into.
|
||||||
|
public let store: VMStore
|
||||||
|
|
||||||
|
/// Creates a builder.
|
||||||
|
public init(store: VMStore) {
|
||||||
|
self.store = store
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs the whole pipeline.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - name: Image name under `<storeDir>/images/`, e.g. `default`.
|
||||||
|
/// - ipswPath: A local `.ipsw`. When `nil`, the latest supported image is
|
||||||
|
/// discovered and downloaded.
|
||||||
|
/// - config: Supplies guest shape (CPU/RAM/disk), credentials, and the
|
||||||
|
/// `gitea-runner` download URL.
|
||||||
|
/// - progress: Stage callback. May be invoked from arbitrary threads.
|
||||||
|
/// - Throws: ``CoreError/provisioningFailed(_:)`` naming the failed stage.
|
||||||
|
public func build(
|
||||||
|
name: String,
|
||||||
|
ipswPath: String?,
|
||||||
|
config: RunnerConfig,
|
||||||
|
progress: (@Sendable (ImageBuildStage) -> Void)? = nil
|
||||||
|
) async throws {
|
||||||
|
try store.ensureLayout()
|
||||||
|
|
||||||
|
// Checked against the filesystem rather than `store.image(named:)`: a
|
||||||
|
// half-built bundle from a previous failed run is exactly the thing this
|
||||||
|
// needs to catch, and it would not read back as a valid image.
|
||||||
|
let bundleURL = store.imagesDir.appendingPathComponent(name, isDirectory: true)
|
||||||
|
if FileManager.default.fileExists(atPath: bundleURL.path) {
|
||||||
|
throw CoreError.configInvalid(
|
||||||
|
"image '\(name)' already exists at \(bundleURL.path). "
|
||||||
|
+ "Delete it first (`image delete \(name)`), or build under a different --name."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The IPSW alone is ~15 GB and the installed disk grows to tens more.
|
||||||
|
try store.ensureFreeSpace(minGB: ipswPath == nil ? 60 : 45)
|
||||||
|
|
||||||
|
// 1. Restore image.
|
||||||
|
let provider = IPSWProvider(downloadDirectory: store.ipswDir)
|
||||||
|
let restoreImage: VZMacOSRestoreImage
|
||||||
|
if let ipswPath {
|
||||||
|
progress?(.downloadingIPSW(fraction: 1.0))
|
||||||
|
restoreImage = try await provider.load(localPath: ipswPath)
|
||||||
|
} else {
|
||||||
|
progress?(.downloadingIPSW(fraction: 0))
|
||||||
|
let (image, _) = try await provider.fetchLatest { fraction in
|
||||||
|
progress?(.downloadingIPSW(fraction: fraction))
|
||||||
|
}
|
||||||
|
restoreImage = image
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2/3. Hardware model and bundle.
|
||||||
|
progress?(.preparing)
|
||||||
|
progress?(.creatingBundle)
|
||||||
|
let bundle = try await createBundle(name: name, restoreImage: restoreImage, config: config)
|
||||||
|
|
||||||
|
// 4. Install.
|
||||||
|
progress?(.installing(fraction: 0))
|
||||||
|
try await install(bundle: bundle, restoreImage: restoreImage) { fraction in
|
||||||
|
progress?(.installing(fraction: fraction))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5/6/7. First boot, provisioning, seal.
|
||||||
|
try await firstBootAndProvision(bundle: bundle, config: config, progress: progress)
|
||||||
|
|
||||||
|
progress?(.done)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates the bundle directory, disk, NVRAM, and `config.json`.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - name: Image name.
|
||||||
|
/// - restoreImage: The loaded IPSW, used for its
|
||||||
|
/// `mostFeaturefulSupportedConfiguration` and `buildVersion`.
|
||||||
|
/// - config: Guest shape and credentials.
|
||||||
|
/// - Returns: The new, uninstalled bundle.
|
||||||
|
public func createBundle(
|
||||||
|
name: String,
|
||||||
|
restoreImage: VZMacOSRestoreImage,
|
||||||
|
config: RunnerConfig
|
||||||
|
) async throws -> VMBundle {
|
||||||
|
// `mostFeaturefulSupportedConfiguration` is nil when this host cannot run
|
||||||
|
// this image at all — an Intel host, or a restore image newer than the
|
||||||
|
// host's Virtualization stack. There is nothing to fall back to, and
|
||||||
|
// guessing a hardware model produces a VM that fails to boot much later
|
||||||
|
// with a far less useful message.
|
||||||
|
guard let requirements = restoreImage.mostFeaturefulSupportedConfiguration else {
|
||||||
|
let version = restoreImage.operatingSystemVersion
|
||||||
|
throw CoreError.hostUnsupported(
|
||||||
|
"this host cannot virtualize macOS \(version.majorVersion).\(version.minorVersion) "
|
||||||
|
+ "(build \(restoreImage.buildVersion)). The restore image reports no supported "
|
||||||
|
+ "configuration — the host is either not Apple silicon or is older than the guest."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let hardwareModel = requirements.hardwareModel
|
||||||
|
guard hardwareModel.isSupported else {
|
||||||
|
throw CoreError.hostUnsupported(
|
||||||
|
"the hardware model required by build \(restoreImage.buildVersion) is not supported on this host"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The image's own minimums win over the configured shape: a guest below
|
||||||
|
// them will not boot, and silently honouring a too-small config would
|
||||||
|
// produce that failure at first boot instead of here.
|
||||||
|
let cpuCount = max(config.guest.cpuCount, requirements.minimumSupportedCPUCount)
|
||||||
|
let minimumMemoryGB = Int(
|
||||||
|
(requirements.minimumSupportedMemorySize + (1 << 30) - 1) / (1 << 30)
|
||||||
|
)
|
||||||
|
let memoryGB = max(config.guest.memoryGB, minimumMemoryGB)
|
||||||
|
|
||||||
|
let bundle = VMBundle(rootURL: store.imagesDir.appendingPathComponent(name, isDirectory: true))
|
||||||
|
try bundle.createDirectory()
|
||||||
|
|
||||||
|
do {
|
||||||
|
let diskFormat = try createDisk(bundle: bundle, sizeGB: config.guest.diskGB)
|
||||||
|
|
||||||
|
// NVRAM must be created against the *same* hardware model that goes
|
||||||
|
// into config.json and into VZMacPlatformConfiguration. A mismatch is
|
||||||
|
// undefined behaviour in the framework, not a validation error.
|
||||||
|
_ = try VZMacAuxiliaryStorage(
|
||||||
|
creatingStorageAt: bundle.auxiliaryStorageURL,
|
||||||
|
hardwareModel: hardwareModel,
|
||||||
|
options: []
|
||||||
|
)
|
||||||
|
|
||||||
|
let osVersion = restoreImage.operatingSystemVersion
|
||||||
|
let bundleConfig = VMBundleConfig(
|
||||||
|
hardwareModelData: hardwareModel.dataRepresentation,
|
||||||
|
machineIdentifierData: VZMacMachineIdentifier().dataRepresentation,
|
||||||
|
// A placeholder: the base image is never booted on a slot. Every
|
||||||
|
// clone rewrites this with its slot's persistent MAC, which is
|
||||||
|
// what DHCP lease discovery keys on. It still has to be a valid
|
||||||
|
// locally-administered address, because the base image *is*
|
||||||
|
// booted once, here, for provisioning.
|
||||||
|
macAddress: VZMACAddress.randomLocallyAdministered().string,
|
||||||
|
diskFormat: diskFormat,
|
||||||
|
cpuCount: cpuCount,
|
||||||
|
memoryGB: memoryGB,
|
||||||
|
guestUsername: config.guest.username,
|
||||||
|
macOSVersion:
|
||||||
|
"\(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion) "
|
||||||
|
+ "(\(restoreImage.buildVersion))",
|
||||||
|
provisioned: false
|
||||||
|
)
|
||||||
|
try bundle.saveConfig(bundleConfig)
|
||||||
|
} catch {
|
||||||
|
// A bundle that got partway through creation is not something a later
|
||||||
|
// run can recover from, and leaving it behind would make `build` with
|
||||||
|
// the same name fail on the "already exists" check for the wrong
|
||||||
|
// reason.
|
||||||
|
try? bundle.destroy()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
return bundle
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates the backing disk, preferring sparse ASIF.
|
||||||
|
///
|
||||||
|
/// ASIF (`diskutil image create blank --fs none --format ASIF`) is available
|
||||||
|
/// from macOS 26 and is the right choice here: it is sparse, so a 64 GB
|
||||||
|
/// nominal disk costs what the guest actually writes, and it CoW-clones
|
||||||
|
/// cleanly on APFS. If `diskutil` fails for any reason, a sparse RAW file is
|
||||||
|
/// created instead and the format recorded in the bundle config so
|
||||||
|
/// ``VZConfigFactory`` attaches the right file.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - bundle: Destination bundle.
|
||||||
|
/// - sizeGB: Nominal disk size.
|
||||||
|
/// - Returns: The format that was actually used.
|
||||||
|
public func createDisk(bundle: VMBundle, sizeGB: Int) throws -> VMBundleConfig.DiskFormat {
|
||||||
|
guard sizeGB > 0 else {
|
||||||
|
throw CoreError.configInvalid("guest.diskGB must be positive, got \(sizeGB)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let asifURL = bundle.asifDiskURL
|
||||||
|
try? FileManager.default.removeItem(at: asifURL)
|
||||||
|
|
||||||
|
// No `#available` guard: the package's deployment target is already
|
||||||
|
// macOS 26, so the compiler would reject the check as redundant. The
|
||||||
|
// runtime feature check that matters is whether *this* diskutil
|
||||||
|
// understands `--format ASIF`, which the exit status answers directly —
|
||||||
|
// that also covers early 26 builds where the format was still landing.
|
||||||
|
do {
|
||||||
|
try ImageBuilder.runProcess(
|
||||||
|
"/usr/sbin/diskutil",
|
||||||
|
[
|
||||||
|
"image", "create", "blank",
|
||||||
|
"--fs", "none",
|
||||||
|
"--format", "ASIF",
|
||||||
|
"--size", "\(sizeGB)G",
|
||||||
|
asifURL.path,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
// diskutil occasionally appends its own extension; accept either
|
||||||
|
// spelling rather than failing on a cosmetic difference.
|
||||||
|
if !FileManager.default.fileExists(atPath: asifURL.path) {
|
||||||
|
let suffixed = URL(fileURLWithPath: asifURL.path + ".asif")
|
||||||
|
if FileManager.default.fileExists(atPath: suffixed.path) {
|
||||||
|
try FileManager.default.moveItem(at: suffixed, to: asifURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if FileManager.default.fileExists(atPath: asifURL.path) {
|
||||||
|
return .asif
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to RAW.
|
||||||
|
}
|
||||||
|
|
||||||
|
try? FileManager.default.removeItem(at: asifURL)
|
||||||
|
|
||||||
|
// RAW fallback: an empty file extended to the nominal size. APFS keeps it
|
||||||
|
// sparse, so this costs nothing until the guest writes. Sizes are decimal
|
||||||
|
// GB (1000³) to match what `diskutil … --size NG` produces, so switching
|
||||||
|
// formats does not silently change the guest's disk size.
|
||||||
|
let rawURL = bundle.rawDiskURL
|
||||||
|
try? FileManager.default.removeItem(at: rawURL)
|
||||||
|
guard FileManager.default.createFile(atPath: rawURL.path, contents: nil) else {
|
||||||
|
throw CoreError.provisioningFailed("could not create the disk image at \(rawURL.path)")
|
||||||
|
}
|
||||||
|
let handle = try FileHandle(forWritingTo: rawURL)
|
||||||
|
defer { try? handle.close() }
|
||||||
|
do {
|
||||||
|
try handle.truncate(atOffset: UInt64(sizeGB) * 1_000_000_000)
|
||||||
|
} catch {
|
||||||
|
throw CoreError.provisioningFailed(
|
||||||
|
"could not size the disk image at \(rawURL.path) to \(sizeGB) GB: \(error.localizedDescription)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return .raw
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs `VZMacOSInstaller` to completion.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - bundle: The bundle to install into.
|
||||||
|
/// - restoreImage: The loaded IPSW.
|
||||||
|
/// - progress: Called with the installer's completed fraction.
|
||||||
|
public func install(
|
||||||
|
bundle: VMBundle,
|
||||||
|
restoreImage: VZMacOSRestoreImage,
|
||||||
|
progress: (@Sendable (Double) -> Void)? = nil
|
||||||
|
) async throws {
|
||||||
|
let imageURL = restoreImage.url
|
||||||
|
guard imageURL.isFileURL else {
|
||||||
|
// The image returned by `VZMacOSRestoreImage.latestSupported` carries
|
||||||
|
// a CDN URL. Handing that to the installer fails deep inside the
|
||||||
|
// framework; catching it here names the actual mistake.
|
||||||
|
throw CoreError.configInvalid(
|
||||||
|
"VZMacOSInstaller needs a local restore image, but this one points at "
|
||||||
|
+ "\(imageURL.absoluteString). Download it first with IPSWProvider.download."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let configuration = try VZConfigFactory.makeConfiguration(bundle: bundle, headless: true)
|
||||||
|
|
||||||
|
// Everything about VZMacOSInstaller is queue-bound: the VM must be
|
||||||
|
// created on a queue, the installer must be *constructed* on that same
|
||||||
|
// queue with the VM stopped, and `install` must be *called* on it too.
|
||||||
|
// The VM is also created here rather than through VMInstance because the
|
||||||
|
// installer needs the VZVirtualMachine object itself, and because the VM
|
||||||
|
// must never be started, paused, or stopped while installing — behaviour
|
||||||
|
// VMInstance exists to provide and which would be actively harmful here.
|
||||||
|
let queue = DispatchQueue(label: "gitea-macos-runner.install.\(bundle.name)")
|
||||||
|
let session = InstallSession()
|
||||||
|
let boxedConfiguration = UncheckedBox(configuration)
|
||||||
|
|
||||||
|
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
|
||||||
|
queue.async {
|
||||||
|
let virtualMachine = VZVirtualMachine(
|
||||||
|
configuration: boxedConfiguration.value,
|
||||||
|
queue: queue
|
||||||
|
)
|
||||||
|
let installer = VZMacOSInstaller(
|
||||||
|
virtualMachine: virtualMachine,
|
||||||
|
restoringFromImageAt: imageURL
|
||||||
|
)
|
||||||
|
// Held for the duration: the completion handler is the only other
|
||||||
|
// strong reference, and dropping the VM mid-install would be a
|
||||||
|
// use-after-free rather than a cancellation.
|
||||||
|
session.virtualMachine = virtualMachine
|
||||||
|
session.installer = installer
|
||||||
|
|
||||||
|
if let progress {
|
||||||
|
session.observation = installer.progress.observe(
|
||||||
|
\.fractionCompleted,
|
||||||
|
options: [.initial, .new]
|
||||||
|
) { observed, _ in
|
||||||
|
progress(observed.fractionCompleted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
installer.install { result in
|
||||||
|
session.observation = nil
|
||||||
|
session.installer = nil
|
||||||
|
session.virtualMachine = nil
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
progress?(1.0)
|
||||||
|
continuation.resume()
|
||||||
|
case .failure(let error):
|
||||||
|
continuation.resume(throwing: VMInstance.mapVZError(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Boots the freshly installed guest, gets it onto the network, and hands it
|
||||||
|
/// to ``GuestProvisioner``.
|
||||||
|
///
|
||||||
|
/// ## Setup Assistant
|
||||||
|
///
|
||||||
|
/// A newly installed macOS sits at Setup Assistant with no account and no
|
||||||
|
/// SSH. On a **macOS 27+ host with a macOS 27+ guest**, Virtualization can
|
||||||
|
/// automate that: build a `VZMacGuestProvisioningOptions` carrying the
|
||||||
|
/// configured username, password, and full name, with
|
||||||
|
/// `logsInAutomatically = true` and `enablesRemoteLogin = true`, and attach
|
||||||
|
/// it to the start options via
|
||||||
|
/// `VZMacOSVirtualMachineStartOptions.setGuestProvisioning(_:)` — the ObjC
|
||||||
|
/// selector is `setGuestProvisioningOptions:error:`, but Swift imports it
|
||||||
|
/// under the shorter name. The guest then creates the account and enables
|
||||||
|
/// SSH unattended.
|
||||||
|
///
|
||||||
|
/// - Important: An **older guest silently ignores** these options — no
|
||||||
|
/// error, no account, no SSH, and this method will simply time out waiting
|
||||||
|
/// for a lease or a login. When that happens the only recovery is a
|
||||||
|
/// manual, GUI-driven first boot, which is deliberately **out of v1
|
||||||
|
/// scope**: this method fails with an explanatory
|
||||||
|
/// ``CoreError/provisioningFailed(_:)`` telling the operator that a
|
||||||
|
/// `--manual-setup` flow is not implemented and that the IPSW must be
|
||||||
|
/// macOS 27 or newer.
|
||||||
|
///
|
||||||
|
/// The API itself is gated at `#available(macOS 27.0, *)`, so a macOS 26
|
||||||
|
/// host takes the same explanatory failure path.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - bundle: The installed bundle.
|
||||||
|
/// - config: Guest credentials and timeouts.
|
||||||
|
/// - progress: Stage callback.
|
||||||
|
public func firstBootAndProvision(
|
||||||
|
bundle: VMBundle,
|
||||||
|
config: RunnerConfig,
|
||||||
|
progress: (@Sendable (ImageBuildStage) -> Void)? = nil
|
||||||
|
) async throws {
|
||||||
|
guard #available(macOS 27.0, *) else {
|
||||||
|
throw CoreError.hostUnsupported(
|
||||||
|
"""
|
||||||
|
automating Setup Assistant requires macOS 27 or newer on the host; this host is older. \
|
||||||
|
Without it the freshly installed guest sits at the setup screen forever, with no \
|
||||||
|
account and no SSH. A manual, GUI-driven first boot is not implemented in v1.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let provisioningOptions = VZMacGuestProvisioningOptions()
|
||||||
|
provisioningOptions.username = config.guest.username
|
||||||
|
provisioningOptions.password = config.guest.password
|
||||||
|
provisioningOptions.fullName = ImageBuilder.guestAccountFullName
|
||||||
|
// Auto-login keeps a GUI session alive, which codesign against the login
|
||||||
|
// keychain and the simulators both need.
|
||||||
|
provisioningOptions.logsInAutomatically = true
|
||||||
|
// This is what turns on sshd — the only channel provisioning has.
|
||||||
|
provisioningOptions.enablesRemoteLogin = true
|
||||||
|
|
||||||
|
let startOptions = VZMacOSVirtualMachineStartOptions()
|
||||||
|
do {
|
||||||
|
// The validating setter: it rejects, for instance, a password that
|
||||||
|
// the guest's account policy will not accept, here rather than by
|
||||||
|
// quietly producing a guest with no usable account.
|
||||||
|
try startOptions.setGuestProvisioning(provisioningOptions)
|
||||||
|
} catch {
|
||||||
|
throw CoreError.configInvalid(
|
||||||
|
"guest provisioning options were rejected (check guest.username / guest.password): "
|
||||||
|
+ error.localizedDescription
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try await bootProvisionAndSeal(
|
||||||
|
bundle: bundle,
|
||||||
|
config: config,
|
||||||
|
startOptions: startOptions,
|
||||||
|
isFirstBoot: true,
|
||||||
|
xcodeXIPPath: nil,
|
||||||
|
progress: progress
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-runs guest provisioning against an already-installed image.
|
||||||
|
///
|
||||||
|
/// Backs `image provision NAME`, which exists so that bumping the
|
||||||
|
/// `gitea-runner` version or adding Xcode does not require a 15 GB
|
||||||
|
/// reinstall.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - name: Image name.
|
||||||
|
/// - config: Guest credentials and download URLs.
|
||||||
|
/// - xcodeXIPPath: Optional Xcode `.xip` to install as well.
|
||||||
|
/// - progress: Stage callback.
|
||||||
|
public func reprovision(
|
||||||
|
name: String,
|
||||||
|
config: RunnerConfig,
|
||||||
|
xcodeXIPPath: String? = nil,
|
||||||
|
progress: (@Sendable (ImageBuildStage) -> Void)? = nil
|
||||||
|
) async throws {
|
||||||
|
let bundleURL = store.imagesDir.appendingPathComponent(name, isDirectory: true)
|
||||||
|
guard FileManager.default.fileExists(atPath: bundleURL.path) else {
|
||||||
|
throw CoreError.notFound("image '\(name)' at \(bundleURL.path)")
|
||||||
|
}
|
||||||
|
let bundle = VMBundle(rootURL: bundleURL)
|
||||||
|
|
||||||
|
if let xcodeXIPPath {
|
||||||
|
let expanded = (xcodeXIPPath as NSString).expandingTildeInPath
|
||||||
|
guard FileManager.default.fileExists(atPath: expanded) else {
|
||||||
|
throw CoreError.notFound("Xcode .xip at \(expanded)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No start options: the account already exists, so there is nothing for
|
||||||
|
// Setup Assistant automation to do, and re-applying it on a guest that is
|
||||||
|
// already past first boot has no effect anyway (macOS only evaluates
|
||||||
|
// guest provisioning on the first boot after a restore).
|
||||||
|
try await bootProvisionAndSeal(
|
||||||
|
bundle: bundle,
|
||||||
|
config: config,
|
||||||
|
startOptions: nil,
|
||||||
|
isFirstBoot: false,
|
||||||
|
xcodeXIPPath: xcodeXIPPath,
|
||||||
|
progress: progress
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Boot, provision, seal
|
||||||
|
|
||||||
|
/// The shared tail of ``firstBootAndProvision(bundle:config:progress:)`` and
|
||||||
|
/// ``reprovision(name:config:xcodeXIPPath:progress:)``: boot, find the guest
|
||||||
|
/// on the network, provision it, shut it down cleanly, mark it provisioned.
|
||||||
|
private func bootProvisionAndSeal(
|
||||||
|
bundle: VMBundle,
|
||||||
|
config: RunnerConfig,
|
||||||
|
startOptions: VZMacOSVirtualMachineStartOptions?,
|
||||||
|
isFirstBoot: Bool,
|
||||||
|
xcodeXIPPath: String?,
|
||||||
|
progress: (@Sendable (ImageBuildStage) -> Void)?
|
||||||
|
) async throws {
|
||||||
|
var bundleConfig = try bundle.loadConfig()
|
||||||
|
let macAddress = bundleConfig.macAddress
|
||||||
|
let bootTimeout = Duration.seconds(max(60, config.scheduler.bootTimeoutSeconds))
|
||||||
|
|
||||||
|
progress?(.firstBoot)
|
||||||
|
let instance = try VMInstance(bundle: bundle, label: "image:\(bundle.name)", headless: true)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await instance.start(options: startOptions)
|
||||||
|
} catch {
|
||||||
|
throw CoreError.provisioningFailed(
|
||||||
|
"could not boot image '\(bundle.name)': \(error)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let address: String
|
||||||
|
do {
|
||||||
|
// A DHCP lease is the first observable sign of life: the guest has
|
||||||
|
// booted far enough to bring up its NIC. SSH comes tens of seconds
|
||||||
|
// later, once launchd has started sshd.
|
||||||
|
address = try await ImageBuilder.waitForDHCPLease(macAddress: macAddress, timeout: bootTimeout)
|
||||||
|
try await waitForSSH(
|
||||||
|
host: address,
|
||||||
|
username: config.guest.username,
|
||||||
|
password: config.guest.password,
|
||||||
|
timeout: bootTimeout
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
_ = await instance.requestStopThenForce()
|
||||||
|
if isFirstBoot {
|
||||||
|
// The most likely cause by far, and the one with no diagnostic of
|
||||||
|
// its own: a pre-27 guest accepts the provisioning options and
|
||||||
|
// ignores them, so it sits at Setup Assistant with no account and
|
||||||
|
// no sshd while we wait for a login that will never be possible.
|
||||||
|
throw CoreError.provisioningFailed(
|
||||||
|
"""
|
||||||
|
the guest never became reachable over SSH within \(config.scheduler.bootTimeoutSeconds)s.
|
||||||
|
|
||||||
|
The usual cause is a guest older than macOS 27: earlier versions do not implement \
|
||||||
|
the automated setup protocol and silently ignore the provisioning options, leaving \
|
||||||
|
the VM parked at Setup Assistant with no account and no Remote Login. Rebuild with \
|
||||||
|
a macOS 27 or newer restore image.
|
||||||
|
|
||||||
|
A manual, GUI-driven first boot is not implemented in v1.
|
||||||
|
|
||||||
|
Underlying error: \(error)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
throw CoreError.provisioningFailed(
|
||||||
|
"image '\(bundle.name)' booted but never became reachable over SSH: \(error)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let executor = SSHExecutor(
|
||||||
|
host: address,
|
||||||
|
username: config.guest.username,
|
||||||
|
password: config.guest.password
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let provisioner = GuestProvisioner()
|
||||||
|
try await provisioner.provision(executor: executor, config: config) { step in
|
||||||
|
progress?(.provisioning(step: step))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let xcodeXIPPath {
|
||||||
|
progress?(.provisioning(step: "Xcode"))
|
||||||
|
try await provisioner.installXcode(
|
||||||
|
executor: executor,
|
||||||
|
xipPath: (xcodeXIPPath as NSString).expandingTildeInPath
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await executor.close()
|
||||||
|
_ = await instance.requestStopThenForce()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shut down from inside. A forced stop is a power cut: it leaves the
|
||||||
|
// guest's filesystem in whatever state it was in, and every clone would
|
||||||
|
// inherit that state, so the graceful path is worth waiting for.
|
||||||
|
progress?(.finalizing)
|
||||||
|
_ = try? await executor.run("sudo -n /sbin/shutdown -h now", timeout: .seconds(30))
|
||||||
|
await executor.close()
|
||||||
|
|
||||||
|
let stopped = await ImageBuilder.withTimeout(.seconds(180)) {
|
||||||
|
await instance.waitUntilStopped()
|
||||||
|
}
|
||||||
|
if stopped == nil {
|
||||||
|
_ = await instance.requestStopThenForce()
|
||||||
|
}
|
||||||
|
|
||||||
|
bundleConfig.provisioned = true
|
||||||
|
try bundle.saveConfig(bundleConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
/// Full name for the account Setup Assistant automation creates.
|
||||||
|
static let guestAccountFullName = "Gitea Runner"
|
||||||
|
|
||||||
|
/// Polls `/var/db/dhcpd_leases` until the guest's MAC appears.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - macAddress: The bundle's MAC, in any common formatting.
|
||||||
|
/// - timeout: Overall ceiling.
|
||||||
|
/// - pollInterval: Delay between reads. Defaults to 2 s.
|
||||||
|
/// - Returns: The leased IP address.
|
||||||
|
/// - Throws: ``CoreError/timeout(_:)`` if no lease appears in time.
|
||||||
|
static func waitForDHCPLease(
|
||||||
|
macAddress: String,
|
||||||
|
timeout: Duration,
|
||||||
|
pollInterval: Duration = .seconds(2)
|
||||||
|
) async throws -> String {
|
||||||
|
let started = ContinuousClock.now
|
||||||
|
while true {
|
||||||
|
let leases = DHCPLeaseParser.parseFile()
|
||||||
|
if let address = DHCPLeaseParser.ipAddress(forMAC: macAddress, in: leases) {
|
||||||
|
return address
|
||||||
|
}
|
||||||
|
guard ContinuousClock.now - started < timeout else { break }
|
||||||
|
try await Task.sleep(for: pollInterval)
|
||||||
|
guard ContinuousClock.now - started < timeout else { break }
|
||||||
|
}
|
||||||
|
throw CoreError.timeout("no DHCP lease for \(macAddress) in /var/db/dhcpd_leases")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs an async operation with a ceiling, returning `nil` if it elapses.
|
||||||
|
///
|
||||||
|
/// Used for the graceful-shutdown wait, which otherwise has no bound:
|
||||||
|
/// `waitUntilStopped()` waits forever, and a guest that hangs on shutdown
|
||||||
|
/// would hang the build with it.
|
||||||
|
static func withTimeout<T: Sendable>(
|
||||||
|
_ duration: Duration,
|
||||||
|
operation: @escaping @Sendable () async -> T
|
||||||
|
) async -> T? {
|
||||||
|
await withTaskGroup(of: Optional<T>.self) { group in
|
||||||
|
group.addTask { await operation() }
|
||||||
|
group.addTask {
|
||||||
|
try? await Task.sleep(for: duration)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let first = await group.next() ?? nil
|
||||||
|
group.cancelAll()
|
||||||
|
return first
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs a host process and throws with its output if it exits non-zero.
|
||||||
|
@discardableResult
|
||||||
|
static func runProcess(_ executablePath: String, _ arguments: [String]) throws -> String {
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = URL(fileURLWithPath: executablePath)
|
||||||
|
process.arguments = arguments
|
||||||
|
|
||||||
|
let pipe = Pipe()
|
||||||
|
process.standardOutput = pipe
|
||||||
|
process.standardError = pipe
|
||||||
|
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
} catch {
|
||||||
|
throw CoreError.processFailed(
|
||||||
|
command: "\(executablePath) \(arguments.joined(separator: " "))",
|
||||||
|
exitCode: -1,
|
||||||
|
output: error.localizedDescription
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drained before waiting: a command that outfills the pipe buffer would
|
||||||
|
// block forever otherwise.
|
||||||
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
process.waitUntilExit()
|
||||||
|
|
||||||
|
let output = String(decoding: data, as: UTF8.self)
|
||||||
|
guard process.terminationStatus == 0 else {
|
||||||
|
throw CoreError.processFailed(
|
||||||
|
command: "\(executablePath) \(arguments.joined(separator: " "))",
|
||||||
|
exitCode: process.terminationStatus,
|
||||||
|
output: output
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Install plumbing
|
||||||
|
|
||||||
|
/// Carries a non-`Sendable` Virtualization object onto the VM's serial queue.
|
||||||
|
///
|
||||||
|
/// The framework's configuration objects are not `Sendable` and never will be,
|
||||||
|
/// but handing one to the queue that will own the VM is exactly the transfer the
|
||||||
|
/// framework itself prescribes.
|
||||||
|
private final class UncheckedBox<T>: @unchecked Sendable {
|
||||||
|
let value: T
|
||||||
|
init(_ value: T) { self.value = value }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owns the VM, installer, and KVO observation for one install.
|
||||||
|
///
|
||||||
|
/// Every field is read and written only on the install queue, which is what
|
||||||
|
/// makes the unchecked conformance sound.
|
||||||
|
private final class InstallSession: @unchecked Sendable {
|
||||||
|
var virtualMachine: VZVirtualMachine?
|
||||||
|
var installer: VZMacOSInstaller?
|
||||||
|
var observation: NSKeyValueObservation?
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
import Foundation
|
||||||
|
import RunnerCore
|
||||||
|
|
||||||
|
/// Whether the LaunchAgent is installed and running.
|
||||||
|
public struct ServiceStatus: Sendable, Equatable {
|
||||||
|
/// Whether the plist exists at ``LaunchdService/agentPlistURL``.
|
||||||
|
public let installed: Bool
|
||||||
|
/// Whether `launchctl` reports the label as loaded.
|
||||||
|
public let loaded: Bool
|
||||||
|
/// The running PID, when loaded and alive.
|
||||||
|
public let pid: Int?
|
||||||
|
/// The last exit status `launchctl` reported, when not running.
|
||||||
|
public let lastExitStatus: Int?
|
||||||
|
/// Path to the plist, whether or not it exists.
|
||||||
|
public let plistPath: String
|
||||||
|
|
||||||
|
public init(
|
||||||
|
installed: Bool,
|
||||||
|
loaded: Bool,
|
||||||
|
pid: Int? = nil,
|
||||||
|
lastExitStatus: Int? = nil,
|
||||||
|
plistPath: String
|
||||||
|
) {
|
||||||
|
self.installed = installed
|
||||||
|
self.loaded = loaded
|
||||||
|
self.pid = pid
|
||||||
|
self.lastExitStatus = lastExitStatus
|
||||||
|
self.plistPath = plistPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installs, removes, and inspects the daemon's `launchd` job.
|
||||||
|
///
|
||||||
|
/// ## LaunchAgent, never LaunchDaemon
|
||||||
|
///
|
||||||
|
/// This is not a stylistic choice. Two hard constraints force it:
|
||||||
|
///
|
||||||
|
/// * Virtualization.framework needs a **GUI login session**. A LaunchDaemon runs
|
||||||
|
/// in the system context with no session, and VM startup fails there.
|
||||||
|
/// * From macOS 15, starting a VM requires an **unlocked `login.keychain`**.
|
||||||
|
/// That keychain unlocks when a user logs in graphically; a LaunchDaemon never
|
||||||
|
/// sees it.
|
||||||
|
///
|
||||||
|
/// So the daemon runs as a LaunchAgent in the logged-in user's session, and the
|
||||||
|
/// host must be configured for automatic login with the screen allowed to sleep
|
||||||
|
/// but the session never locked. `doctor` checks the keychain state precisely
|
||||||
|
/// because this is the failure people hit first.
|
||||||
|
public enum LaunchdService {
|
||||||
|
/// The `launchd` label, matching `CFBundleIdentifier`.
|
||||||
|
public static let label = "xyz.blakeslee.gitea-macos-runner"
|
||||||
|
|
||||||
|
/// `~/Library/LaunchAgents/xyz.blakeslee.gitea-macos-runner.plist`.
|
||||||
|
public static var agentPlistURL: URL {
|
||||||
|
URL(fileURLWithPath: RunnerConfig.expandTilde("~/Library/LaunchAgents/\(label).plist"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The default install location of the signed app's executable.
|
||||||
|
///
|
||||||
|
/// `make install` puts the bundle here; the entitlement only exists on the
|
||||||
|
/// signed bundle, so this — not a bare binary — is what `launchd` must run.
|
||||||
|
public static let defaultExecutablePath =
|
||||||
|
"~/Applications/GiteaMacosRunner.app/Contents/MacOS/gitea-macos-runner"
|
||||||
|
|
||||||
|
/// The GUI domain target for this user, e.g. `gui/501`.
|
||||||
|
public static var domainTarget: String { "gui/\(getuid())" }
|
||||||
|
|
||||||
|
/// The service target for this user's agent, e.g. `gui/501/xyz.blakeslee…`.
|
||||||
|
public static var serviceTarget: String { "\(domainTarget)/\(label)" }
|
||||||
|
|
||||||
|
/// Writes the plist and loads the job.
|
||||||
|
///
|
||||||
|
/// `ProgramArguments` is the **installed app bundle's** executable followed
|
||||||
|
/// by `daemon` — not `.build/…` and not a bare binary, because the
|
||||||
|
/// entitlement only exists on the signed bundle. `RunAtLoad` and `KeepAlive`
|
||||||
|
/// are both set so the daemon survives crashes and logins.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - executablePath: Absolute path to the installed binary, e.g.
|
||||||
|
/// `~/Applications/GiteaMacosRunner.app/Contents/MacOS/gitea-macos-runner`.
|
||||||
|
/// - configPath: Optional `--config` argument for a non-default location.
|
||||||
|
/// - Throws: ``CoreError/notFound(_:)`` when the executable or the template
|
||||||
|
/// is missing, ``CoreError/processFailed(command:exitCode:output:)`` when
|
||||||
|
/// `launchctl` refuses the job.
|
||||||
|
public static func install(executablePath: String, configPath: String? = nil) throws {
|
||||||
|
let executable = RunnerConfig.expandTilde(executablePath)
|
||||||
|
guard FileManager.default.isExecutableFile(atPath: executable) else {
|
||||||
|
throw CoreError.notFound(
|
||||||
|
"""
|
||||||
|
no executable at \(executable) — run `make install` to build, sign, \
|
||||||
|
and install the app bundle first
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var arguments = ["daemon"]
|
||||||
|
if let configPath {
|
||||||
|
arguments += ["--config", RunnerConfig.expandTilde(configPath)]
|
||||||
|
}
|
||||||
|
|
||||||
|
let xml = try renderPlist(executablePath: executable, arguments: arguments)
|
||||||
|
|
||||||
|
let fm = FileManager.default
|
||||||
|
try fm.createDirectory(at: logDirectoryURL, withIntermediateDirectories: true)
|
||||||
|
try fm.createDirectory(
|
||||||
|
at: agentPlistURL.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
|
||||||
|
// A reinstall over a loaded job is the common case (upgrade, config
|
||||||
|
// change), so unload before rewriting rather than failing on "already
|
||||||
|
// bootstrapped".
|
||||||
|
if fm.fileExists(atPath: agentPlistURL.path) {
|
||||||
|
_ = try? uninstallJobOnly()
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try Data(xml.utf8).write(to: agentPlistURL, options: .atomic)
|
||||||
|
} catch {
|
||||||
|
throw CoreError.processFailed(
|
||||||
|
command: "write \(agentPlistURL.path)",
|
||||||
|
exitCode: 1,
|
||||||
|
output: error.localizedDescription
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let bootstrap = LaunchdShell.run("/bin/launchctl", ["bootstrap", domainTarget, agentPlistURL.path])
|
||||||
|
if bootstrap.exitCode != 0 {
|
||||||
|
// `bootstrap` is the modern verb but is unavailable in some session
|
||||||
|
// contexts (and returns 5 for "input/output error" on odd domains);
|
||||||
|
// the legacy loader still works there.
|
||||||
|
let legacy = LaunchdShell.run("/bin/launchctl", ["load", "-w", agentPlistURL.path])
|
||||||
|
if legacy.exitCode != 0 {
|
||||||
|
throw CoreError.processFailed(
|
||||||
|
command: "launchctl bootstrap \(domainTarget) \(agentPlistURL.path)",
|
||||||
|
exitCode: bootstrap.exitCode,
|
||||||
|
output: (bootstrap.output + "\n" + legacy.output).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unloads the job and removes the plist. Safe when not installed.
|
||||||
|
public static func uninstall() throws {
|
||||||
|
_ = try? uninstallJobOnly()
|
||||||
|
if FileManager.default.fileExists(atPath: agentPlistURL.path) {
|
||||||
|
try FileManager.default.removeItem(at: agentPlistURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unloads the job but leaves the plist on disk.
|
||||||
|
private static func uninstallJobOnly() throws {
|
||||||
|
let bootout = LaunchdShell.run("/bin/launchctl", ["bootout", serviceTarget])
|
||||||
|
if bootout.exitCode != 0 {
|
||||||
|
_ = LaunchdShell.run("/bin/launchctl", ["unload", "-w", agentPlistURL.path])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reports installation and run state.
|
||||||
|
public static func status() throws -> ServiceStatus {
|
||||||
|
let installed = FileManager.default.fileExists(atPath: agentPlistURL.path)
|
||||||
|
let printed = LaunchdShell.run("/bin/launchctl", ["print", serviceTarget])
|
||||||
|
|
||||||
|
guard printed.exitCode == 0 else {
|
||||||
|
// 113 (EAGAIN-ish "Could not find service") and 36 are both "not
|
||||||
|
// loaded"; anything else is still, for our purposes, not loaded.
|
||||||
|
return ServiceStatus(installed: installed, loaded: false, plistPath: agentPlistURL.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ServiceStatus(
|
||||||
|
installed: installed,
|
||||||
|
loaded: true,
|
||||||
|
pid: firstInteger(in: printed.output, key: "pid"),
|
||||||
|
lastExitStatus: firstInteger(in: printed.output, key: "last exit code"),
|
||||||
|
plistPath: agentPlistURL.path
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts `key = <integer>` from `launchctl print` output.
|
||||||
|
private static func firstInteger(in output: String, key: String) -> Int? {
|
||||||
|
for line in output.split(separator: "\n") {
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard trimmed.hasPrefix(key) else { continue }
|
||||||
|
guard let equals = trimmed.firstIndex(of: "=") else { continue }
|
||||||
|
let value = trimmed[trimmed.index(after: equals)...].trimmingCharacters(in: .whitespaces)
|
||||||
|
return Int(value)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders `Resources/launchd.plist.template` with the given substitutions.
|
||||||
|
///
|
||||||
|
/// Placeholders: `{{LABEL}}`, `{{PROGRAM}}`, `{{ARGUMENTS}}`,
|
||||||
|
/// `{{STDOUT_PATH}}`, `{{STDERR_PATH}}`.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - executablePath: Absolute path to the installed binary.
|
||||||
|
/// - arguments: Arguments after the executable, e.g. `["daemon"]`.
|
||||||
|
/// - Returns: The plist XML.
|
||||||
|
public static func renderPlist(executablePath: String, arguments: [String]) throws -> String {
|
||||||
|
let template = try loadTemplate()
|
||||||
|
|
||||||
|
let argumentXML = arguments
|
||||||
|
.map { "\t\t<string>\(xmlEscape($0))</string>" }
|
||||||
|
.joined(separator: "\n")
|
||||||
|
|
||||||
|
return template
|
||||||
|
.replacingOccurrences(of: "{{LABEL}}", with: xmlEscape(label))
|
||||||
|
.replacingOccurrences(of: "{{PROGRAM}}", with: xmlEscape(executablePath))
|
||||||
|
.replacingOccurrences(of: "{{ARGUMENTS}}", with: argumentXML)
|
||||||
|
.replacingOccurrences(
|
||||||
|
of: "{{STDOUT_PATH}}",
|
||||||
|
with: xmlEscape(logDirectoryURL.appendingPathComponent("daemon.out.log").path)
|
||||||
|
)
|
||||||
|
.replacingOccurrences(
|
||||||
|
of: "{{STDERR_PATH}}",
|
||||||
|
with: xmlEscape(logDirectoryURL.appendingPathComponent("daemon.err.log").path)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Locates the plist template.
|
||||||
|
///
|
||||||
|
/// The template is not an SPM resource bundle and `make bundle` copies only
|
||||||
|
/// `Info.plist` into the app, so there is no single reliable location: this
|
||||||
|
/// walks the plausible ones and falls back to a built-in copy so
|
||||||
|
/// `service install` works from the installed app, from `swift run`, and from
|
||||||
|
/// a checkout.
|
||||||
|
private static func loadTemplate() throws -> String {
|
||||||
|
var candidates: [URL] = []
|
||||||
|
|
||||||
|
if let resourceURL = Bundle.main.url(forResource: "launchd.plist", withExtension: "template") {
|
||||||
|
candidates.append(resourceURL)
|
||||||
|
}
|
||||||
|
candidates.append(
|
||||||
|
Bundle.main.bundleURL
|
||||||
|
.appendingPathComponent("Contents/Resources/launchd.plist.template")
|
||||||
|
)
|
||||||
|
if let executableURL = Bundle.main.executableURL?.resolvingSymlinksInPath() {
|
||||||
|
let directory = executableURL.deletingLastPathComponent()
|
||||||
|
candidates.append(directory.appendingPathComponent("Resources/launchd.plist.template"))
|
||||||
|
candidates.append(
|
||||||
|
directory.deletingLastPathComponent()
|
||||||
|
.appendingPathComponent("Resources/launchd.plist.template")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Sources/RunnerHost/LaunchdService.swift → repository root.
|
||||||
|
let repositoryRoot = URL(fileURLWithPath: #filePath)
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
candidates.append(repositoryRoot.appendingPathComponent("Resources/launchd.plist.template"))
|
||||||
|
candidates.append(
|
||||||
|
URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
|
||||||
|
.appendingPathComponent("Resources/launchd.plist.template")
|
||||||
|
)
|
||||||
|
|
||||||
|
for candidate in candidates {
|
||||||
|
if let contents = try? String(contentsOf: candidate, encoding: .utf8) {
|
||||||
|
return contents
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return embeddedTemplate
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Escapes a string for an XML text node.
|
||||||
|
private static func xmlEscape(_ value: String) -> String {
|
||||||
|
value
|
||||||
|
.replacingOccurrences(of: "&", with: "&")
|
||||||
|
.replacingOccurrences(of: "<", with: "<")
|
||||||
|
.replacingOccurrences(of: ">", with: ">")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Directory for the agent's stdout/stderr logs,
|
||||||
|
/// `~/Library/Logs/gitea-macos-runner`.
|
||||||
|
public static var logDirectoryURL: URL {
|
||||||
|
URL(fileURLWithPath: RunnerConfig.expandTilde("~/Library/Logs/gitea-macos-runner"), isDirectory: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Byte-for-byte fallback copy of `Resources/launchd.plist.template`, used
|
||||||
|
/// when the file cannot be found next to the running binary.
|
||||||
|
private static let embeddedTemplate = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
\t<key>Label</key>
|
||||||
|
\t<string>{{LABEL}}</string>
|
||||||
|
|
||||||
|
\t<key>ProgramArguments</key>
|
||||||
|
\t<array>
|
||||||
|
\t\t<string>{{PROGRAM}}</string>
|
||||||
|
{{ARGUMENTS}}
|
||||||
|
\t</array>
|
||||||
|
|
||||||
|
\t<key>RunAtLoad</key>
|
||||||
|
\t<true/>
|
||||||
|
|
||||||
|
\t<key>KeepAlive</key>
|
||||||
|
\t<dict>
|
||||||
|
\t\t<key>SuccessfulExit</key>
|
||||||
|
\t\t<false/>
|
||||||
|
\t</dict>
|
||||||
|
|
||||||
|
\t<key>ThrottleInterval</key>
|
||||||
|
\t<integer>30</integer>
|
||||||
|
|
||||||
|
\t<key>ProcessType</key>
|
||||||
|
\t<string>Interactive</string>
|
||||||
|
|
||||||
|
\t<key>StandardOutPath</key>
|
||||||
|
\t<string>{{STDOUT_PATH}}</string>
|
||||||
|
|
||||||
|
\t<key>StandardErrorPath</key>
|
||||||
|
\t<string>{{STDERR_PATH}}</string>
|
||||||
|
|
||||||
|
\t<key>EnvironmentVariables</key>
|
||||||
|
\t<dict>
|
||||||
|
\t\t<key>PATH</key>
|
||||||
|
\t\t<string>/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
|
||||||
|
\t</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimal synchronous process runner for `launchctl`.
|
||||||
|
private enum LaunchdShell {
|
||||||
|
struct Output {
|
||||||
|
let exitCode: Int32
|
||||||
|
let output: String
|
||||||
|
}
|
||||||
|
|
||||||
|
static func run(_ launchPath: String, _ arguments: [String]) -> Output {
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = URL(fileURLWithPath: launchPath)
|
||||||
|
process.arguments = arguments
|
||||||
|
|
||||||
|
let pipe = Pipe()
|
||||||
|
process.standardOutput = pipe
|
||||||
|
process.standardError = pipe
|
||||||
|
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
} catch {
|
||||||
|
return Output(exitCode: 127, output: "\(error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
process.waitUntilExit()
|
||||||
|
return Output(
|
||||||
|
exitCode: process.terminationStatus,
|
||||||
|
output: String(data: data, encoding: .utf8) ?? ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,821 @@
|
|||||||
|
import Foundation
|
||||||
|
import Logging
|
||||||
|
import RunnerCore
|
||||||
|
import Virtualization
|
||||||
|
|
||||||
|
/// Everything the orchestrator tracks about one live slot.
|
||||||
|
public struct LiveVM: Sendable {
|
||||||
|
/// Slot index.
|
||||||
|
public let slot: Int
|
||||||
|
/// The ephemeral clone backing it.
|
||||||
|
public let bundle: VMBundle
|
||||||
|
/// The runner name registered with Gitea. Globally unique, prefixed with
|
||||||
|
/// ``RunnerConfig/RunnerSection/namePrefix`` — this is what lets the
|
||||||
|
/// reconcile loop tell a stale row apart from a live one.
|
||||||
|
public let runnerName: String
|
||||||
|
/// The guest's IP, once its DHCP lease appears.
|
||||||
|
public var ipAddress: String?
|
||||||
|
/// When the boot started.
|
||||||
|
public let startedAt: Date
|
||||||
|
|
||||||
|
public init(
|
||||||
|
slot: Int,
|
||||||
|
bundle: VMBundle,
|
||||||
|
runnerName: String,
|
||||||
|
ipAddress: String? = nil,
|
||||||
|
startedAt: Date
|
||||||
|
) {
|
||||||
|
self.slot = slot
|
||||||
|
self.bundle = bundle
|
||||||
|
self.runnerName = runnerName
|
||||||
|
self.ipAddress = ipAddress
|
||||||
|
self.startedAt = startedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The daemon: watches Gitea, boots ephemeral macOS VMs, and cleans up after
|
||||||
|
/// them.
|
||||||
|
///
|
||||||
|
/// ## Loop
|
||||||
|
///
|
||||||
|
/// Every `pollIntervalSeconds`:
|
||||||
|
/// 1. Fetch queued jobs (`status=queued` only — `waiting` means *blocked*).
|
||||||
|
/// 2. Ask ``SchedulerCore/plan(state:queuedJobs:labels:maxVMs:now:jobTimeout:bootTimeout:)``
|
||||||
|
/// what to do. The planner is pure; all I/O happens here.
|
||||||
|
/// 3. Execute the returned actions.
|
||||||
|
///
|
||||||
|
/// Every `reconcileIntervalSeconds`, additionally run ``reconcileOnce()``.
|
||||||
|
///
|
||||||
|
/// ## Booting a slot
|
||||||
|
///
|
||||||
|
/// `ensureFreeSpace` → `cloneImage(named:slotMAC:)` → ``VMInstance/start(options:)``
|
||||||
|
/// → poll `/var/db/dhcpd_leases` for the slot MAC until `bootTimeout` →
|
||||||
|
/// ``waitForSSH(host:port:username:password:timeout:pollInterval:)`` → write the
|
||||||
|
/// registration token into a guest file with mode `0600` → over SSH:
|
||||||
|
///
|
||||||
|
/// ```sh
|
||||||
|
/// gitea-runner register --no-interactive \
|
||||||
|
/// --instance <url> --token-file <f> \
|
||||||
|
/// --name <prefix><uuid> --labels "macos-arm64:host" --ephemeral \
|
||||||
|
/// && rm -f <f> \
|
||||||
|
/// && gitea-runner daemon
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// The token goes through a file rather than `--token` because arguments are
|
||||||
|
/// visible to every process on the guest, and it is deleted the instant
|
||||||
|
/// registration returns. `--ephemeral` is **server-enforced** (Gitea 1.24+): the
|
||||||
|
/// server hands this runner exactly one task and then deregisters it. The weaker
|
||||||
|
/// `--once` is runner-side only and is not used.
|
||||||
|
///
|
||||||
|
/// When the `gitea-runner daemon` SSH command returns — which it does after the
|
||||||
|
/// single job completes — or when `jobTimeout` elapses, the slot is torn down:
|
||||||
|
/// force-stop the VM, delete the clone, mark the slot idle.
|
||||||
|
///
|
||||||
|
/// ## Reconcile
|
||||||
|
///
|
||||||
|
/// A VM that dies uncleanly leaves a runner row behind, and Gitea only sweeps
|
||||||
|
/// rows at midnight — and *never* sweeps a runner that never claimed a task. So
|
||||||
|
/// every reconcile pass lists runners and deletes any that are `ephemeral`, not
|
||||||
|
/// `busy`, carry our name prefix, and have no live VM. The associated Running
|
||||||
|
/// task is reaped separately by Gitea's zombie sweep (~10–15 minutes); that part
|
||||||
|
/// is not ours to fix.
|
||||||
|
public actor Orchestrator {
|
||||||
|
|
||||||
|
/// Effective configuration.
|
||||||
|
public let config: RunnerConfig
|
||||||
|
/// Gitea admin API client.
|
||||||
|
public let client: GiteaClient
|
||||||
|
/// On-disk store.
|
||||||
|
public let store: VMStore
|
||||||
|
/// Base image name to clone for each job.
|
||||||
|
public let imageName: String
|
||||||
|
|
||||||
|
/// Structured logger.
|
||||||
|
private let logger: Logger
|
||||||
|
|
||||||
|
/// The pure scheduler's state. Every mutation goes through `SchedulerCore`.
|
||||||
|
private var state: SchedulerState
|
||||||
|
|
||||||
|
/// Slots that currently hold a VM, keyed by slot index.
|
||||||
|
private var live: [Int: LiveVM] = [:]
|
||||||
|
|
||||||
|
/// The `VZVirtualMachine` wrapper for each live slot.
|
||||||
|
private var instances: [Int: VMInstance] = [:]
|
||||||
|
|
||||||
|
/// The supervising task per slot: clone → boot → register → run → teardown.
|
||||||
|
private var slotTasks: [Int: Task<Void, Never>] = [:]
|
||||||
|
|
||||||
|
/// The "the guest stopped on its own" watcher per slot.
|
||||||
|
private var deathWatchTasks: [Int: Task<Void, Never>] = [:]
|
||||||
|
|
||||||
|
/// Bumped every time a slot starts a new VM, so a stale watcher from a
|
||||||
|
/// previous occupant of the same slot cannot trigger a teardown of the
|
||||||
|
/// current one.
|
||||||
|
private var slotGeneration: [Int: Int] = [:]
|
||||||
|
|
||||||
|
/// Slots whose teardown is in flight. Guards against the SSH command
|
||||||
|
/// returning, the death watcher firing, and the scheduler's timeout all
|
||||||
|
/// racing to tear the same slot down.
|
||||||
|
private var tearingDown: Set<Int> = []
|
||||||
|
|
||||||
|
/// Runner names minted but not yet visible in ``live`` (the window between
|
||||||
|
/// deciding to boot and the clone finishing). The reconcile loop must not
|
||||||
|
/// delete a row that one of these is about to create.
|
||||||
|
private var reservedRunnerNames: Set<String> = []
|
||||||
|
|
||||||
|
/// Runner names whose `gitea-runner daemon` exited cleanly, meaning Gitea
|
||||||
|
/// already deregistered them (`--ephemeral`). Teardown skips the belt-and-
|
||||||
|
/// braces row deletion for these.
|
||||||
|
private var completedRunnerNames: Set<String> = []
|
||||||
|
|
||||||
|
/// The shared registration token, resolved once and cached for the process
|
||||||
|
/// lifetime. Never minted per VM — see ``registrationToken()``.
|
||||||
|
private var cachedRegistrationToken: String?
|
||||||
|
|
||||||
|
/// The in-flight fetch of ``cachedRegistrationToken``, if any.
|
||||||
|
///
|
||||||
|
/// Caching the *value* alone is not enough: `Orchestrator` is an actor, so a
|
||||||
|
/// second slot booting during the `await` on the API call would see an empty
|
||||||
|
/// cache and mint a second token — and minting invalidates every prior token
|
||||||
|
/// for the scope, including the one the first VM is about to use. Memoizing
|
||||||
|
/// the task instead makes concurrent callers share one request.
|
||||||
|
private var registrationTokenTask: Task<String, Error>?
|
||||||
|
|
||||||
|
/// Set once ``shutdown()`` has begun; stops new work being accepted.
|
||||||
|
private var isShuttingDown = false
|
||||||
|
|
||||||
|
/// Creates an orchestrator.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - config: Validated configuration.
|
||||||
|
/// - client: Admin-scoped Gitea client.
|
||||||
|
/// - store: The VM store.
|
||||||
|
/// - imageName: Base image to clone. Defaults to `default`.
|
||||||
|
/// - logger: Structured logger.
|
||||||
|
public init(
|
||||||
|
config: RunnerConfig,
|
||||||
|
client: GiteaClient,
|
||||||
|
store: VMStore,
|
||||||
|
imageName: String = "default",
|
||||||
|
logger: Logger = Logger(label: "orchestrator")
|
||||||
|
) {
|
||||||
|
self.config = config
|
||||||
|
self.client = client
|
||||||
|
self.store = store
|
||||||
|
self.imageName = imageName
|
||||||
|
self.logger = logger
|
||||||
|
self.state = SchedulerState(slotCount: Orchestrator.slotCount(for: config))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fixed slot count: the configured concurrency, hard-clamped to the
|
||||||
|
/// kernel's two-guest limit.
|
||||||
|
private static func slotCount(for config: RunnerConfig) -> Int {
|
||||||
|
min(
|
||||||
|
max(1, config.scheduler.maxConcurrentVMs),
|
||||||
|
RunnerConfig.SchedulerSection.hardMaxConcurrentVMs
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This orchestrator's slot count.
|
||||||
|
public var slotCount: Int { state.slots.count }
|
||||||
|
|
||||||
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
|
/// Runs the poll/reconcile loop until the task is cancelled.
|
||||||
|
///
|
||||||
|
/// On entry it purges clones orphaned by a previous crash and runs one
|
||||||
|
/// reconcile pass, so a restart converges before it schedules anything new.
|
||||||
|
///
|
||||||
|
/// On cancellation it stops accepting work and calls ``shutdown()``, so
|
||||||
|
/// `SIGTERM` from `launchd` results in guests being asked to stop rather
|
||||||
|
/// than being killed with their filesystems dirty.
|
||||||
|
///
|
||||||
|
/// - Throws: Only unrecoverable errors; transient Gitea or VM failures are
|
||||||
|
/// logged and retried on the next tick.
|
||||||
|
public func runForever() async throws {
|
||||||
|
try store.ensureLayout()
|
||||||
|
|
||||||
|
// Clones left behind by a crash are garbage: their guests are gone and
|
||||||
|
// their runner rows, if any, are handled by the reconcile pass below.
|
||||||
|
do {
|
||||||
|
try store.purgeClones()
|
||||||
|
} catch {
|
||||||
|
logger.warning("could not purge orphaned clones", metadata: ["error": "\(error)"])
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"orchestrator starting",
|
||||||
|
metadata: [
|
||||||
|
"image": .string(imageName),
|
||||||
|
"slots": .stringConvertible(slotCount),
|
||||||
|
"labels": .string(config.runner.labels.joined(separator: ",")),
|
||||||
|
"instance": .string(config.gitea.instanceURL.absoluteString),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
await reconcileOnce()
|
||||||
|
|
||||||
|
await withTaskGroup(of: Void.self) { group in
|
||||||
|
group.addTask { [pollInterval = config.scheduler.pollIntervalSeconds] in
|
||||||
|
while !Task.isCancelled {
|
||||||
|
await self.tick()
|
||||||
|
do {
|
||||||
|
try await Task.sleep(for: .seconds(max(1, pollInterval)))
|
||||||
|
} catch {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
group.addTask { [reconcileInterval = config.scheduler.reconcileIntervalSeconds] in
|
||||||
|
while !Task.isCancelled {
|
||||||
|
do {
|
||||||
|
try await Task.sleep(for: .seconds(max(1, reconcileInterval)))
|
||||||
|
} catch {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
await self.reconcileOnce()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await shutdown()
|
||||||
|
logger.info("orchestrator stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tears down every live VM and deletes their clones. Idempotent.
|
||||||
|
public func shutdown() async {
|
||||||
|
guard !isShuttingDown else { return }
|
||||||
|
isShuttingDown = true
|
||||||
|
logger.info("shutting down", metadata: ["liveVMs": .stringConvertible(live.count)])
|
||||||
|
|
||||||
|
// Cancel the supervising tasks first so they stop waiting on SSH, then
|
||||||
|
// let them run their own teardown; whatever they miss we clean up below.
|
||||||
|
let tasks = slotTasks
|
||||||
|
slotTasks.removeAll()
|
||||||
|
for (_, task) in tasks { task.cancel() }
|
||||||
|
for (_, task) in tasks { await task.value }
|
||||||
|
|
||||||
|
for slot in live.keys.sorted() {
|
||||||
|
await teardownSlot(slot, reason: "daemon shutdown")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One iteration of the poll loop: fetch, plan, execute.
|
||||||
|
///
|
||||||
|
/// Exposed separately so tests and `vm boot` can drive a single tick.
|
||||||
|
///
|
||||||
|
/// - Parameter now: Reference time, injected for testability.
|
||||||
|
public func tick(now: Date = Date()) async {
|
||||||
|
guard !isShuttingDown else { return }
|
||||||
|
|
||||||
|
let jobs: [WorkflowJob]
|
||||||
|
do {
|
||||||
|
jobs = try await client.listQueuedJobs()
|
||||||
|
} catch {
|
||||||
|
// A Gitea outage must never take the daemon down: queued jobs wait
|
||||||
|
// up to ABANDONED_JOB_TIMEOUT (24 h), so a missed tick costs nothing.
|
||||||
|
logger.warning("listQueuedJobs failed", metadata: ["error": .string("\(error)")])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// `waiting` means *blocked on a dependency* in Gitea's external
|
||||||
|
// vocabulary and must never be scheduled; only `queued` is schedulable.
|
||||||
|
let queued = jobs.filter { $0.isQueued }
|
||||||
|
|
||||||
|
let (newState, actions) = SchedulerCore.plan(
|
||||||
|
state: state,
|
||||||
|
queuedJobs: queued,
|
||||||
|
labels: config.labelSet,
|
||||||
|
maxVMs: slotCount,
|
||||||
|
now: now,
|
||||||
|
jobTimeout: TimeInterval(config.scheduler.jobTimeoutMinutes * 60),
|
||||||
|
bootTimeout: TimeInterval(config.scheduler.bootTimeoutSeconds)
|
||||||
|
)
|
||||||
|
state = newState
|
||||||
|
|
||||||
|
for action in actions {
|
||||||
|
switch action {
|
||||||
|
case .none:
|
||||||
|
continue
|
||||||
|
|
||||||
|
case .teardownVM(let slot, let reason):
|
||||||
|
// Retire the slot's lifecycle task *before* tearing down, and
|
||||||
|
// wait for it: the planner deliberately emits teardowns before
|
||||||
|
// boots so a timed-out slot can be recycled in this same pass,
|
||||||
|
// and `bootSlot` refuses a slot whose `slotTasks` entry is still
|
||||||
|
// populated. The task is parked in `executor.run` on a channel we
|
||||||
|
// are about to kill, so it would otherwise clear that entry only
|
||||||
|
// after the boot had already been refused.
|
||||||
|
if let task = slotTasks.removeValue(forKey: slot) {
|
||||||
|
task.cancel()
|
||||||
|
// Its own teardown runs to completion here, which also means
|
||||||
|
// it cannot race a successor booted later in this pass.
|
||||||
|
await task.value
|
||||||
|
}
|
||||||
|
await teardownSlot(slot, reason: reason)
|
||||||
|
|
||||||
|
case .bootVM(let slot, let jobHint):
|
||||||
|
do {
|
||||||
|
try await bootSlot(slot, jobHint: jobHint)
|
||||||
|
} catch {
|
||||||
|
logger.error(
|
||||||
|
"boot refused",
|
||||||
|
metadata: [
|
||||||
|
"slot": .stringConvertible(slot),
|
||||||
|
"job": .stringConvertible(jobHint),
|
||||||
|
"error": .string("\(error)"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
state = SchedulerCore.markIdle(state: state, slot: slot)
|
||||||
|
// The job is still queued, so the ledger would never expire
|
||||||
|
// its entry on its own and the job would never boot again.
|
||||||
|
state = SchedulerCore.releaseJob(state: state, jobID: jobHint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One reconcile pass over Gitea's runner rows.
|
||||||
|
///
|
||||||
|
/// Deletes runners that are ephemeral, idle, ours by name prefix, and not
|
||||||
|
/// backed by a live VM. Conservative by construction: a row we are unsure
|
||||||
|
/// about is left alone, because deleting a live runner would fail a job.
|
||||||
|
public func reconcileOnce() async {
|
||||||
|
let runners: [ActionRunner]
|
||||||
|
do {
|
||||||
|
runners = try await client.listRunners()
|
||||||
|
} catch {
|
||||||
|
logger.warning("listRunners failed", metadata: ["error": .string("\(error)")])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let ours = Set(live.values.map(\.runnerName)).union(reservedRunnerNames)
|
||||||
|
|
||||||
|
for runner in runners {
|
||||||
|
guard runner.isEphemeral else { continue }
|
||||||
|
guard !runner.isBusy else { continue }
|
||||||
|
guard RunnerNaming.hasPrefix(runner.name, prefix: config.runner.namePrefix) else { continue }
|
||||||
|
guard !ours.contains(runner.name) else { continue }
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await client.deleteRunner(id: runner.id)
|
||||||
|
logger.info(
|
||||||
|
"reconcile: deleted orphaned runner",
|
||||||
|
metadata: ["name": .string(runner.name), "id": .stringConvertible(runner.id)]
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
logger.warning(
|
||||||
|
"reconcile: could not delete runner",
|
||||||
|
metadata: ["name": .string(runner.name), "error": .string("\(error)")]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Slot operations
|
||||||
|
|
||||||
|
/// Boots, provisions, registers, and then supervises one slot.
|
||||||
|
///
|
||||||
|
/// Returns once the slot has been handed off to its supervising task; the
|
||||||
|
/// job itself runs asynchronously and teardown is triggered by the SSH
|
||||||
|
/// command returning or by `jobTimeout`.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - slot: Slot index.
|
||||||
|
/// - jobHint: The queued job that motivated this boot — a **hint** only;
|
||||||
|
/// the server chooses which job the runner actually claims.
|
||||||
|
public func bootSlot(_ slot: Int, jobHint: Int64) async throws {
|
||||||
|
guard !isShuttingDown else {
|
||||||
|
throw CoreError.provisioningFailed("shutting down; refusing to boot slot \(slot)")
|
||||||
|
}
|
||||||
|
guard slot >= 0, slot < slotCount else {
|
||||||
|
throw CoreError.provisioningFailed("slot \(slot) out of range")
|
||||||
|
}
|
||||||
|
guard slotTasks[slot] == nil, live[slot] == nil else {
|
||||||
|
throw CoreError.provisioningFailed("slot \(slot) is already occupied")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fail fast, on the caller's turn, for the conditions that make a boot
|
||||||
|
// pointless: no space, no image, no token source.
|
||||||
|
try store.ensureFreeSpace(minGB: config.storage.minFreeDiskGB)
|
||||||
|
|
||||||
|
let runnerName = RunnerNaming.makeRunnerName(prefix: config.runner.namePrefix)
|
||||||
|
reservedRunnerNames.insert(runnerName)
|
||||||
|
|
||||||
|
let generation = (slotGeneration[slot] ?? 0) + 1
|
||||||
|
slotGeneration[slot] = generation
|
||||||
|
state = SchedulerCore.markProvisioning(state: state, slot: slot, jobHint: jobHint, now: Date())
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"booting VM",
|
||||||
|
metadata: [
|
||||||
|
"slot": .stringConvertible(slot),
|
||||||
|
"job": .stringConvertible(jobHint),
|
||||||
|
"runner": .string(runnerName),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
slotTasks[slot] = Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
await self.runSlotLifecycle(slot: slot, jobHint: jobHint, runnerName: runnerName, generation: generation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole life of one slot, from clone to teardown.
|
||||||
|
///
|
||||||
|
/// Every failure path funnels into the same teardown, because a slot that is
|
||||||
|
/// neither live nor idle is a slot leaked for the process's lifetime.
|
||||||
|
private func runSlotLifecycle(slot: Int, jobHint: Int64, runnerName: String, generation: Int) async {
|
||||||
|
let bootTimeout = Duration.seconds(max(30, config.scheduler.bootTimeoutSeconds))
|
||||||
|
let jobTimeout = Duration.seconds(max(60, config.scheduler.jobTimeoutMinutes * 60))
|
||||||
|
var teardownReason = "job finished"
|
||||||
|
|
||||||
|
do {
|
||||||
|
let mac = try store.macAddress(forSlot: slot, slotCount: slotCount)
|
||||||
|
// Whatever lease this MAC already holds belongs to the *previous*
|
||||||
|
// guest on this slot — the MACs are persistent and macOS leases last
|
||||||
|
// 24 h. `waitForLease` must not hand that address back before the new
|
||||||
|
// guest has even brought its NIC up.
|
||||||
|
let priorLease = DHCPLeaseParser.lease(
|
||||||
|
forMAC: mac,
|
||||||
|
in: DHCPLeaseParser.parseFile()
|
||||||
|
)
|
||||||
|
let bundle = try store.cloneImage(named: imageName, slotMAC: mac)
|
||||||
|
live[slot] = LiveVM(slot: slot, bundle: bundle, runnerName: runnerName, startedAt: Date())
|
||||||
|
|
||||||
|
let instance = try VMInstance(bundle: bundle, label: "slot-\(slot)")
|
||||||
|
instances[slot] = instance
|
||||||
|
try await instance.start()
|
||||||
|
|
||||||
|
// A guest that panics, or is shut down from inside the job, must
|
||||||
|
// land in the same teardown path as a clean finish.
|
||||||
|
deathWatchTasks[slot] = Task { [weak self] in
|
||||||
|
let reason = await instance.waitUntilStopped()
|
||||||
|
await self?.vmStoppedUnexpectedly(slot: slot, generation: generation, reason: reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
let ip = try await waitForLease(mac: mac, timeout: bootTimeout, replacing: priorLease)
|
||||||
|
live[slot]?.ipAddress = ip
|
||||||
|
logger.info("guest leased address", metadata: ["slot": .stringConvertible(slot), "ip": .string(ip)])
|
||||||
|
|
||||||
|
try await waitForSSH(
|
||||||
|
host: ip,
|
||||||
|
username: config.guest.username,
|
||||||
|
password: config.guest.password,
|
||||||
|
timeout: bootTimeout
|
||||||
|
)
|
||||||
|
|
||||||
|
let token = try await registrationToken()
|
||||||
|
let executor = SSHExecutor(
|
||||||
|
host: ip,
|
||||||
|
username: config.guest.username,
|
||||||
|
password: config.guest.password
|
||||||
|
)
|
||||||
|
|
||||||
|
// With the hint: the slot is `.provisioning` right now, which carries
|
||||||
|
// no hint to inherit, so the hintless overload would drop it — and
|
||||||
|
// with it both the job-timeout ledger release and every log line
|
||||||
|
// naming which job a running slot is serving.
|
||||||
|
state = SchedulerCore.markRunning(state: state, slot: slot, jobHint: jobHint, now: Date())
|
||||||
|
logger.info(
|
||||||
|
"registering ephemeral runner",
|
||||||
|
metadata: [
|
||||||
|
"slot": .stringConvertible(slot),
|
||||||
|
"runner": .string(runnerName),
|
||||||
|
"job": .stringConvertible(jobHint),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
let result = try await registerAndRun(
|
||||||
|
executor: executor,
|
||||||
|
runnerName: runnerName,
|
||||||
|
token: token,
|
||||||
|
timeout: jobTimeout
|
||||||
|
)
|
||||||
|
await executor.close()
|
||||||
|
|
||||||
|
if result.succeeded {
|
||||||
|
// A clean exit means the ephemeral runner claimed its one task,
|
||||||
|
// finished it, and was deregistered by the server.
|
||||||
|
completedRunnerNames.insert(runnerName)
|
||||||
|
logger.info("job finished", metadata: ["slot": .stringConvertible(slot), "runner": .string(runnerName)])
|
||||||
|
} else {
|
||||||
|
teardownReason = "runner exited \(result.exitCode)"
|
||||||
|
logger.warning(
|
||||||
|
"runner exited non-zero",
|
||||||
|
metadata: [
|
||||||
|
"slot": .stringConvertible(slot),
|
||||||
|
"exit": .stringConvertible(result.exitCode),
|
||||||
|
"stderr": .string(String(result.stderr.suffix(500))),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch is CancellationError {
|
||||||
|
teardownReason = "cancelled"
|
||||||
|
} catch {
|
||||||
|
teardownReason = "\(error)"
|
||||||
|
logger.error(
|
||||||
|
"slot failed",
|
||||||
|
metadata: [
|
||||||
|
"slot": .stringConvertible(slot),
|
||||||
|
"runner": .string(runnerName),
|
||||||
|
"error": .string("\(error)"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await teardownSlot(slot, reason: teardownReason)
|
||||||
|
|
||||||
|
// The clone may never have been adopted into `live` (a MAC or clone
|
||||||
|
// failure throws before that), in which case teardown's own removal —
|
||||||
|
// which lives inside `if let info` — never ran. Left behind, the name
|
||||||
|
// would sit in the reconcile loop's "ours" set for the process lifetime.
|
||||||
|
reservedRunnerNames.remove(runnerName)
|
||||||
|
|
||||||
|
// A slot that did not finish a job leaves its motivating job queued, and
|
||||||
|
// the ledger expires entries only when a job *stops* being queued — so
|
||||||
|
// without this the job is never booted for again.
|
||||||
|
if teardownReason != "job finished" {
|
||||||
|
state = SchedulerCore.releaseJob(state: state, jobID: jobHint)
|
||||||
|
}
|
||||||
|
|
||||||
|
slotTasks[slot] = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called by the death watcher when a guest stops without us asking.
|
||||||
|
private func vmStoppedUnexpectedly(slot: Int, generation: Int, reason: VMStopReason) async {
|
||||||
|
guard slotGeneration[slot] == generation else { return }
|
||||||
|
guard !tearingDown.contains(slot), live[slot] != nil else { return }
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"guest stopped unexpectedly",
|
||||||
|
metadata: ["slot": .stringConvertible(slot), "reason": .string("\(reason)")]
|
||||||
|
)
|
||||||
|
slotTasks[slot]?.cancel()
|
||||||
|
await teardownSlot(slot, reason: "guest stopped: \(reason)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops the VM in a slot, deletes its clone, and marks the slot idle.
|
||||||
|
///
|
||||||
|
/// Best-effort and never throws: teardown that could fail would leak a slot.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - slot: Slot index.
|
||||||
|
/// - reason: Logged cause.
|
||||||
|
public func teardownSlot(_ slot: Int, reason: String) async {
|
||||||
|
guard !tearingDown.contains(slot) else { return }
|
||||||
|
|
||||||
|
let vm = instances.removeValue(forKey: slot)
|
||||||
|
let info = live.removeValue(forKey: slot)
|
||||||
|
guard vm != nil || info != nil else {
|
||||||
|
state = SchedulerCore.markIdle(state: state, slot: slot)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tearingDown.insert(slot)
|
||||||
|
slotGeneration[slot] = (slotGeneration[slot] ?? 0) + 1
|
||||||
|
deathWatchTasks.removeValue(forKey: slot)?.cancel()
|
||||||
|
|
||||||
|
logger.info("tearing down slot", metadata: ["slot": .stringConvertible(slot), "reason": .string(reason)])
|
||||||
|
|
||||||
|
if let vm {
|
||||||
|
// requestStop first: the guest gets a power-button press and a
|
||||||
|
// chance to flush before we pull the plug.
|
||||||
|
_ = await vm.requestStopThenForce(gracePeriod: .seconds(30))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let info {
|
||||||
|
do {
|
||||||
|
try store.deleteClone(info.bundle)
|
||||||
|
} catch {
|
||||||
|
logger.warning(
|
||||||
|
"could not delete clone",
|
||||||
|
metadata: ["path": .string(info.bundle.rootURL.path), "error": .string("\(error)")]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
reservedRunnerNames.remove(info.runnerName)
|
||||||
|
|
||||||
|
// If the runner never completed a job, Gitea will keep its row
|
||||||
|
// forever: rows are swept at midnight, and never at all for a
|
||||||
|
// runner that claimed no task. Delete it ourselves.
|
||||||
|
if !completedRunnerNames.contains(info.runnerName) {
|
||||||
|
await deleteRunnerRow(named: info.runnerName)
|
||||||
|
}
|
||||||
|
completedRunnerNames.remove(info.runnerName)
|
||||||
|
}
|
||||||
|
|
||||||
|
state = SchedulerCore.markIdle(state: state, slot: slot)
|
||||||
|
tearingDown.remove(slot)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort deletion of a runner row by name.
|
||||||
|
private func deleteRunnerRow(named name: String) async {
|
||||||
|
do {
|
||||||
|
let runners = try await client.listRunners()
|
||||||
|
guard let row = runners.first(where: { $0.name == name }) else { return }
|
||||||
|
guard !row.isBusy else {
|
||||||
|
// Deleting a busy runner would fail whatever job it is running;
|
||||||
|
// leave it for the next reconcile pass.
|
||||||
|
logger.info("runner still busy; leaving row for reconcile", metadata: ["name": .string(name)])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try await client.deleteRunner(id: row.id)
|
||||||
|
logger.info("deleted runner row", metadata: ["name": .string(name)])
|
||||||
|
} catch {
|
||||||
|
logger.warning(
|
||||||
|
"could not delete runner row",
|
||||||
|
metadata: ["name": .string(name), "error": .string("\(error)")]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Polls `/var/db/dhcpd_leases` until the slot's MAC has an address.
|
||||||
|
///
|
||||||
|
/// Slot MACs are persistent and macOS leases last 24 h, so the previous
|
||||||
|
/// guest's entry for this MAC is normally still in the file when a new clone
|
||||||
|
/// boots. `replacing` is that entry, sampled before the guest was started;
|
||||||
|
/// the poll holds out for a lease `bootpd` wrote afterwards rather than
|
||||||
|
/// returning an address that belongs to a VM that no longer exists.
|
||||||
|
///
|
||||||
|
/// The gate is deliberately soft: if no newer lease appears within half the
|
||||||
|
/// timeout but a stale one is present, that address is used with a warning.
|
||||||
|
/// `bootpd` overwhelmingly reissues the same address to the same MAC, and
|
||||||
|
/// failing a boot outright over a lease record that was merely not rewritten
|
||||||
|
/// would be worse than the stale read this guards against.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - mac: The slot's persistent MAC.
|
||||||
|
/// - timeout: Ceiling, from ``RunnerConfig/SchedulerSection/bootTimeoutSeconds``.
|
||||||
|
/// - replacing: The lease seen for `mac` before the guest was started.
|
||||||
|
/// - Returns: The guest's IPv4 address.
|
||||||
|
/// - Throws: ``CoreError/timeout(_:)``.
|
||||||
|
public func waitForLease(
|
||||||
|
mac: String,
|
||||||
|
timeout: Duration,
|
||||||
|
replacing previous: DHCPLease? = nil
|
||||||
|
) async throws -> String {
|
||||||
|
let start = Date()
|
||||||
|
let deadline = start.addingTimeInterval(timeout.seconds)
|
||||||
|
let staleFallbackAfter = start.addingTimeInterval(timeout.seconds / 2)
|
||||||
|
|
||||||
|
while Date() < deadline {
|
||||||
|
try Task.checkCancellation()
|
||||||
|
if let lease = DHCPLeaseParser.lease(forMAC: mac, in: DHCPLeaseParser.parseFile()) {
|
||||||
|
if DHCPLeaseParser.isNewer(lease, than: previous) {
|
||||||
|
return lease.ipAddress
|
||||||
|
}
|
||||||
|
if Date() >= staleFallbackAfter {
|
||||||
|
logger.warning(
|
||||||
|
"no fresh dhcp lease; using the previous one for this MAC",
|
||||||
|
metadata: ["mac": .string(mac), "ip": .string(lease.ipAddress)]
|
||||||
|
)
|
||||||
|
return lease.ipAddress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try await Task.sleep(for: .seconds(2))
|
||||||
|
}
|
||||||
|
throw CoreError.timeout("dhcp lease for \(mac)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registers an ephemeral runner in the guest and starts its daemon.
|
||||||
|
///
|
||||||
|
/// Blocks until the daemon exits, which — because the runner is ephemeral —
|
||||||
|
/// happens after exactly one job.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - executor: A connected guest executor.
|
||||||
|
/// - runnerName: The unique name to register under.
|
||||||
|
/// - token: The shared registration token.
|
||||||
|
/// - Returns: The daemon's exit result.
|
||||||
|
public func registerAndRun(
|
||||||
|
executor: any GuestExecutor,
|
||||||
|
runnerName: String,
|
||||||
|
token: String
|
||||||
|
) async throws -> SSHCommandResult {
|
||||||
|
try await registerAndRun(
|
||||||
|
executor: executor,
|
||||||
|
runnerName: runnerName,
|
||||||
|
token: token,
|
||||||
|
timeout: .seconds(max(60, config.scheduler.jobTimeoutMinutes * 60))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ``registerAndRun(executor:runnerName:token:)`` with an explicit ceiling on
|
||||||
|
/// how long the runner daemon may live.
|
||||||
|
public func registerAndRun(
|
||||||
|
executor: any GuestExecutor,
|
||||||
|
runnerName: String,
|
||||||
|
token: String,
|
||||||
|
timeout: Duration
|
||||||
|
) async throws -> SSHCommandResult {
|
||||||
|
// Mode 0600, and removed in the same && chain below. A registration
|
||||||
|
// token is fleet-wide; passing it as --token would publish it to every
|
||||||
|
// process on a guest that is about to run arbitrary repository code.
|
||||||
|
try await executor.uploadData(Data(token.utf8), remotePath: Orchestrator.tokenPath, mode: "0600")
|
||||||
|
|
||||||
|
// Only bare names are stored server-side; `:host` is a register-time
|
||||||
|
// execution hint. The guest must ship no config.yaml `runner.labels`,
|
||||||
|
// which would silently override this.
|
||||||
|
let labels = config.labelSet.registrationArgument(schema: "host")
|
||||||
|
let instance = config.gitea.instanceURL.absoluteString.hasSuffix("/")
|
||||||
|
? String(config.gitea.instanceURL.absoluteString.dropLast())
|
||||||
|
: config.gitea.instanceURL.absoluteString
|
||||||
|
|
||||||
|
let command = """
|
||||||
|
export PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin; \
|
||||||
|
gitea-runner register --no-interactive \
|
||||||
|
--instance \(Orchestrator.shellQuote(instance)) \
|
||||||
|
--token-file \(Orchestrator.tokenPath) \
|
||||||
|
--name \(Orchestrator.shellQuote(runnerName)) \
|
||||||
|
--labels \(Orchestrator.shellQuote(labels)) \
|
||||||
|
--ephemeral \
|
||||||
|
&& rm -f \(Orchestrator.tokenPath) \
|
||||||
|
&& gitea-runner daemon
|
||||||
|
"""
|
||||||
|
|
||||||
|
return try await executor.run(command, timeout: timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the registration token is staged inside the guest.
|
||||||
|
private static let tokenPath = "/tmp/.reg-token"
|
||||||
|
|
||||||
|
/// Single-quotes a value for `/bin/sh`.
|
||||||
|
private static func shellQuote(_ value: String) -> String {
|
||||||
|
"'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tokens
|
||||||
|
|
||||||
|
/// Resolves the registration token, caching it for the process lifetime.
|
||||||
|
///
|
||||||
|
/// Order: `registrationTokenFile`, then `registrationToken`, then — only if
|
||||||
|
/// ``RunnerConfig/GiteaSection/fetchRegistrationTokenViaAPI`` is set —
|
||||||
|
/// ``GiteaClient/getRegistrationToken()``.
|
||||||
|
///
|
||||||
|
/// - Important: Never called per VM as a way of minting a throwaway secret.
|
||||||
|
/// Registration tokens are reusable and scope-wide, and minting a new one
|
||||||
|
/// invalidates every prior token for that scope — including tokens held by
|
||||||
|
/// runners registered from other hosts. One token, cached, shared.
|
||||||
|
/// - Throws: ``CoreError/configInvalid(_:)`` when no source is available.
|
||||||
|
public func registrationToken() async throws -> String {
|
||||||
|
if let cachedRegistrationToken { return cachedRegistrationToken }
|
||||||
|
|
||||||
|
if let staticToken = try config.resolveStaticRegistrationToken(),
|
||||||
|
!staticToken.isEmpty {
|
||||||
|
cachedRegistrationToken = staticToken
|
||||||
|
return staticToken
|
||||||
|
}
|
||||||
|
|
||||||
|
guard config.gitea.fetchRegistrationTokenViaAPI else {
|
||||||
|
throw CoreError.configInvalid(
|
||||||
|
"""
|
||||||
|
no registration token available: set gitea.registrationTokenFile or \
|
||||||
|
gitea.registrationToken, or enable gitea.fetchRegistrationTokenViaAPI
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Share one in-flight mint between concurrent boots. Assigned before the
|
||||||
|
// first suspension point, so the second caller cannot miss it.
|
||||||
|
if let inFlight = registrationTokenTask {
|
||||||
|
return try await inFlight.value
|
||||||
|
}
|
||||||
|
let task = Task { [client] () -> String in
|
||||||
|
let fetched = try await client.getRegistrationToken()
|
||||||
|
guard !fetched.isEmpty else {
|
||||||
|
throw CoreError.configInvalid("Gitea returned an empty registration token")
|
||||||
|
}
|
||||||
|
return fetched
|
||||||
|
}
|
||||||
|
registrationTokenTask = task
|
||||||
|
|
||||||
|
do {
|
||||||
|
let fetched = try await task.value
|
||||||
|
cachedRegistrationToken = fetched
|
||||||
|
return fetched
|
||||||
|
} catch {
|
||||||
|
// A failed mint must not poison every later boot.
|
||||||
|
registrationTokenTask = nil
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A snapshot of live slot state, for `vm list` and diagnostics.
|
||||||
|
public func liveVMs() -> [LiveVM] {
|
||||||
|
live.keys.sorted().compactMap { live[$0] }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A snapshot of the scheduler's slot table.
|
||||||
|
public func slotStates() -> [VMSlot] {
|
||||||
|
state.slots
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Duration {
|
||||||
|
/// This duration as a floating-point number of seconds.
|
||||||
|
var seconds: TimeInterval {
|
||||||
|
let components = self.components
|
||||||
|
return TimeInterval(components.seconds) + TimeInterval(components.attoseconds) / 1e18
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import Foundation
|
||||||
|
import RunnerCore
|
||||||
|
import Virtualization
|
||||||
|
|
||||||
|
/// The persisted description of a VM, stored alongside its disk in a bundle
|
||||||
|
/// directory.
|
||||||
|
///
|
||||||
|
/// Virtualization.framework requires that a macOS guest be recreated with
|
||||||
|
/// *exactly* the hardware model and machine identifier it was installed with —
|
||||||
|
/// change either and the guest will not boot. Both are opaque blobs the
|
||||||
|
/// framework hands us at install time, so they are stored verbatim here.
|
||||||
|
/// `Data` encodes to base64 in JSON, which keeps `config.json` human-inspectable.
|
||||||
|
public struct VMBundleConfig: Codable, Sendable, Equatable {
|
||||||
|
/// How the backing disk was created.
|
||||||
|
public enum DiskFormat: String, Codable, Sendable {
|
||||||
|
/// Sparse Apple System Image Format, via `diskutil image create`
|
||||||
|
/// (macOS 26+). Preferred: clones and grows lazily.
|
||||||
|
case asif
|
||||||
|
/// A plain sparse file created with `truncate`. Fallback.
|
||||||
|
case raw
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `VZMacHardwareModel.dataRepresentation` from the restore image's
|
||||||
|
/// `mostFeaturefulSupportedConfiguration`.
|
||||||
|
public var hardwareModelData: Data
|
||||||
|
|
||||||
|
/// `VZMacMachineIdentifier.dataRepresentation`. Uniquely identifies the
|
||||||
|
/// "machine"; the guest's Setup Assistant state is tied to it.
|
||||||
|
public var machineIdentifierData: Data
|
||||||
|
|
||||||
|
/// The NIC MAC, e.g. `aa:bb:0c:dd:ee:ff`.
|
||||||
|
///
|
||||||
|
/// For a **clone** this is one of the two persistent per-slot MACs, not a
|
||||||
|
/// fresh random address — see ``VMStore`` and docs/DESIGN.md, Verified
|
||||||
|
/// Fact 12.
|
||||||
|
public var macAddress: String
|
||||||
|
|
||||||
|
/// Backing disk format.
|
||||||
|
public var diskFormat: DiskFormat
|
||||||
|
|
||||||
|
/// Virtual CPU count.
|
||||||
|
public var cpuCount: Int
|
||||||
|
|
||||||
|
/// RAM in gibibytes.
|
||||||
|
public var memoryGB: Int
|
||||||
|
|
||||||
|
/// The guest admin account created during installation.
|
||||||
|
public var guestUsername: String
|
||||||
|
|
||||||
|
/// Bundle creation timestamp.
|
||||||
|
public var createdAt: Date
|
||||||
|
|
||||||
|
/// The installed macOS version/build, when known (from
|
||||||
|
/// `VZMacOSRestoreImage.buildVersion`).
|
||||||
|
public var macOSVersion: String?
|
||||||
|
|
||||||
|
/// Whether guest provisioning (Node.js, `gitea-runner`, sudoers, power
|
||||||
|
/// settings) has completed. A base image is only clonable once this is true.
|
||||||
|
public var provisioned: Bool
|
||||||
|
|
||||||
|
public init(
|
||||||
|
hardwareModelData: Data,
|
||||||
|
machineIdentifierData: Data,
|
||||||
|
macAddress: String,
|
||||||
|
diskFormat: DiskFormat,
|
||||||
|
cpuCount: Int,
|
||||||
|
memoryGB: Int,
|
||||||
|
guestUsername: String,
|
||||||
|
createdAt: Date = Date(),
|
||||||
|
macOSVersion: String? = nil,
|
||||||
|
provisioned: Bool = false
|
||||||
|
) {
|
||||||
|
self.hardwareModelData = hardwareModelData
|
||||||
|
self.machineIdentifierData = machineIdentifierData
|
||||||
|
self.macAddress = macAddress
|
||||||
|
self.diskFormat = diskFormat
|
||||||
|
self.cpuCount = cpuCount
|
||||||
|
self.memoryGB = memoryGB
|
||||||
|
self.guestUsername = guestUsername
|
||||||
|
self.createdAt = createdAt
|
||||||
|
self.macOSVersion = macOSVersion
|
||||||
|
self.provisioned = provisioned
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A directory holding everything needed to boot one VM.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// <bundle>/
|
||||||
|
/// disk.asif (or disk.img for the RAW fallback)
|
||||||
|
/// nvram.bin VZMacAuxiliaryStorage — the guest's NVRAM
|
||||||
|
/// config.json VMBundleConfig
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Base images live under `<storeDir>/images/<name>/`; ephemeral clones under
|
||||||
|
/// `<storeDir>/vms/<uuid>/`. A clone is byte-identical except for `config.json`,
|
||||||
|
/// which is rewritten with the slot's MAC.
|
||||||
|
public struct VMBundle: Sendable, Equatable {
|
||||||
|
/// The bundle directory.
|
||||||
|
public let rootURL: URL
|
||||||
|
|
||||||
|
/// Wraps an existing directory path. Does not touch the filesystem.
|
||||||
|
public init(rootURL: URL) {
|
||||||
|
self.rootURL = rootURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Paths
|
||||||
|
|
||||||
|
/// Path to `config.json`.
|
||||||
|
public var configURL: URL { rootURL.appendingPathComponent("config.json") }
|
||||||
|
|
||||||
|
/// Path to `nvram.bin`, the `VZMacAuxiliaryStorage` backing file.
|
||||||
|
public var auxiliaryStorageURL: URL { rootURL.appendingPathComponent("nvram.bin") }
|
||||||
|
|
||||||
|
/// Path to the ASIF disk, used when ``VMBundleConfig/DiskFormat/asif``.
|
||||||
|
public var asifDiskURL: URL { rootURL.appendingPathComponent("disk.asif") }
|
||||||
|
|
||||||
|
/// Path to the RAW disk, used when ``VMBundleConfig/DiskFormat/raw``.
|
||||||
|
public var rawDiskURL: URL { rootURL.appendingPathComponent("disk.img") }
|
||||||
|
|
||||||
|
/// The disk file for a given format.
|
||||||
|
public func diskURL(format: VMBundleConfig.DiskFormat) -> URL {
|
||||||
|
switch format {
|
||||||
|
case .asif: return asifDiskURL
|
||||||
|
case .raw: return rawDiskURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bundle's directory name — the image name, or the clone's UUID.
|
||||||
|
public var name: String { rootURL.lastPathComponent }
|
||||||
|
|
||||||
|
/// The disk file this bundle actually uses, per its recorded
|
||||||
|
/// ``VMBundleConfig/diskFormat``.
|
||||||
|
///
|
||||||
|
/// Reads `config.json`, so the format is never re-probed from the
|
||||||
|
/// filesystem — the builder recorded which of ASIF/RAW it managed to create
|
||||||
|
/// and that record is authoritative.
|
||||||
|
public func diskURL() throws -> URL {
|
||||||
|
diskURL(format: try loadConfig().diskFormat)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
|
/// Creates the bundle directory, failing if it already exists.
|
||||||
|
///
|
||||||
|
/// - Throws: ``CoreError/bundleCorrupt(_:)`` if the path exists as a file.
|
||||||
|
public func createDirectory() throws {
|
||||||
|
let fm = FileManager.default
|
||||||
|
var isDir: ObjCBool = false
|
||||||
|
if fm.fileExists(atPath: rootURL.path, isDirectory: &isDir) {
|
||||||
|
if isDir.boolValue {
|
||||||
|
throw CoreError.bundleCorrupt("bundle directory already exists: \(rootURL.path)")
|
||||||
|
}
|
||||||
|
throw CoreError.bundleCorrupt("bundle path exists but is a file: \(rootURL.path)")
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try fm.createDirectory(at: rootURL, withIntermediateDirectories: true)
|
||||||
|
} catch {
|
||||||
|
throw CoreError.bundleCorrupt(
|
||||||
|
"could not create bundle directory \(rootURL.path): \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads and decodes `config.json`.
|
||||||
|
///
|
||||||
|
/// - Throws: ``CoreError/bundleCorrupt(_:)`` when absent or undecodable.
|
||||||
|
public func loadConfig() throws -> VMBundleConfig {
|
||||||
|
let data: Data
|
||||||
|
do {
|
||||||
|
data = try Data(contentsOf: configURL)
|
||||||
|
} catch {
|
||||||
|
throw CoreError.bundleCorrupt(
|
||||||
|
"cannot read \(configURL.path): \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
return try Self.decoder.decode(VMBundleConfig.self, from: data)
|
||||||
|
} catch {
|
||||||
|
throw CoreError.bundleCorrupt(
|
||||||
|
"cannot decode \(configURL.path): \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes and atomically writes `config.json`.
|
||||||
|
public func saveConfig(_ config: VMBundleConfig) throws {
|
||||||
|
let data: Data
|
||||||
|
do {
|
||||||
|
data = try Self.encoder.encode(config)
|
||||||
|
} catch {
|
||||||
|
throw CoreError.bundleCorrupt(
|
||||||
|
"cannot encode config for \(rootURL.path): \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
// .atomic writes to a temporary sibling and renames, so a crash
|
||||||
|
// mid-write can never leave a half-written config behind.
|
||||||
|
try data.write(to: configURL, options: .atomic)
|
||||||
|
} catch {
|
||||||
|
throw CoreError.bundleCorrupt(
|
||||||
|
"cannot write \(configURL.path): \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `config.json`, `nvram.bin`, and the disk all exist.
|
||||||
|
public func isComplete() -> Bool {
|
||||||
|
let fm = FileManager.default
|
||||||
|
guard fm.fileExists(atPath: configURL.path),
|
||||||
|
fm.fileExists(atPath: auxiliaryStorageURL.path),
|
||||||
|
let config = try? loadConfig()
|
||||||
|
else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return fm.fileExists(atPath: diskURL(format: config.diskFormat).path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total on-disk size of the bundle in bytes, following sparse allocation
|
||||||
|
/// (i.e. blocks actually used, not the disk's nominal size).
|
||||||
|
public func diskUsageBytes() throws -> Int64 {
|
||||||
|
let fm = FileManager.default
|
||||||
|
let keys: [URLResourceKey] = [.isRegularFileKey, .totalFileAllocatedSizeKey, .fileAllocatedSizeKey]
|
||||||
|
guard
|
||||||
|
let enumerator = fm.enumerator(
|
||||||
|
at: rootURL,
|
||||||
|
includingPropertiesForKeys: keys,
|
||||||
|
options: [],
|
||||||
|
errorHandler: nil
|
||||||
|
)
|
||||||
|
else {
|
||||||
|
throw CoreError.bundleCorrupt("cannot enumerate \(rootURL.path)")
|
||||||
|
}
|
||||||
|
var total: Int64 = 0
|
||||||
|
for case let url as URL in enumerator {
|
||||||
|
guard let values = try? url.resourceValues(forKeys: Set(keys)),
|
||||||
|
values.isRegularFile == true
|
||||||
|
else { continue }
|
||||||
|
// totalFileAllocatedSize is the blocks actually committed, which for
|
||||||
|
// a sparse ASIF/RAW disk is far below its nominal size.
|
||||||
|
if let allocated = values.totalFileAllocatedSize ?? values.fileAllocatedSize {
|
||||||
|
total += Int64(allocated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively removes the bundle directory.
|
||||||
|
public func destroy() throws {
|
||||||
|
let fm = FileManager.default
|
||||||
|
guard fm.fileExists(atPath: rootURL.path) else { return }
|
||||||
|
do {
|
||||||
|
try fm.removeItem(at: rootURL)
|
||||||
|
} catch {
|
||||||
|
throw CoreError.bundleCorrupt(
|
||||||
|
"cannot remove \(rootURL.path): \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Coding
|
||||||
|
|
||||||
|
/// Shared coders. ISO-8601 dates keep `config.json` readable by humans and
|
||||||
|
/// by `jq`; `Data` still encodes as base64, which is what the two opaque
|
||||||
|
/// Virtualization blobs need.
|
||||||
|
private static let decoder: JSONDecoder = {
|
||||||
|
let d = JSONDecoder()
|
||||||
|
d.dateDecodingStrategy = .iso8601
|
||||||
|
return d
|
||||||
|
}()
|
||||||
|
|
||||||
|
private static let encoder: JSONEncoder = {
|
||||||
|
let e = JSONEncoder()
|
||||||
|
e.dateEncodingStrategy = .iso8601
|
||||||
|
e.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
return e
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
import Foundation
|
||||||
|
import RunnerCore
|
||||||
|
import Virtualization
|
||||||
|
|
||||||
|
/// Why a VM stopped.
|
||||||
|
public enum VMStopReason: Sendable, Equatable {
|
||||||
|
/// The guest shut itself down (our normal path: the SSH session runs
|
||||||
|
/// `shutdown`, or `gitea-runner daemon` exits and provisioning halts it).
|
||||||
|
case guestInitiated
|
||||||
|
/// We asked it to stop and it complied.
|
||||||
|
case requested
|
||||||
|
/// The framework reported an error.
|
||||||
|
case failed(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owns one live `VZVirtualMachine` and exposes it as an `async` API.
|
||||||
|
///
|
||||||
|
/// ## Threading
|
||||||
|
///
|
||||||
|
/// `VZVirtualMachine` is not thread-safe and must be used only from the queue it
|
||||||
|
/// was created with. This class creates it with
|
||||||
|
/// `VZVirtualMachine(configuration:queue:)` on a **private serial queue** and
|
||||||
|
/// funnels every call through that queue, bridging the framework's
|
||||||
|
/// completion-handler API to `async` with continuations. That is why the daemon
|
||||||
|
/// can drive two VMs from an actor without ever touching the main queue for VM
|
||||||
|
/// control — though the process still needs a running `NSApplication` main loop
|
||||||
|
/// for the framework itself (see ``CommandDaemon``).
|
||||||
|
public final class VMInstance: @unchecked Sendable {
|
||||||
|
|
||||||
|
/// The bundle this instance was created from.
|
||||||
|
public let bundle: VMBundle
|
||||||
|
|
||||||
|
/// A caller-supplied label used in log messages, typically `slot-0`.
|
||||||
|
public let label: String
|
||||||
|
|
||||||
|
/// The private serial queue every `VZVirtualMachine` call and every delegate
|
||||||
|
/// callback runs on. `VZVirtualMachine` is not thread-safe; this queue *is*
|
||||||
|
/// its thread-safety.
|
||||||
|
private let queue: DispatchQueue
|
||||||
|
|
||||||
|
/// Only ever touched on ``queue``.
|
||||||
|
private let vm: VZVirtualMachine
|
||||||
|
|
||||||
|
/// Retained explicitly: `VZVirtualMachine.delegate` is a weak reference.
|
||||||
|
private let vmDelegate: VMInstanceDelegate
|
||||||
|
|
||||||
|
/// Guards ``stopReason`` and ``activityToken``. A plain lock rather than an
|
||||||
|
/// actor so the delegate callback — which arrives on ``queue`` and must not
|
||||||
|
/// block on an await — can publish the stop synchronously.
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var stopReason: VMStopReason?
|
||||||
|
private var activityToken: (any NSObjectProtocol)?
|
||||||
|
|
||||||
|
/// Wraps a non-`Sendable` value so it can cross into a `@Sendable` closure
|
||||||
|
/// that immediately hops onto ``queue``, which is the only place it is used.
|
||||||
|
private struct Unchecked<T>: @unchecked Sendable {
|
||||||
|
let value: T
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates an instance and its underlying `VZVirtualMachine`.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - bundle: The VM bundle to boot. Usually an ephemeral clone.
|
||||||
|
/// - label: Log label.
|
||||||
|
/// - headless: Passed through to ``VZConfigFactory``.
|
||||||
|
/// - Throws: Configuration or validation failures.
|
||||||
|
public init(bundle: VMBundle, label: String, headless: Bool = true) throws {
|
||||||
|
self.bundle = bundle
|
||||||
|
self.label = label
|
||||||
|
let vmQueue = DispatchQueue(label: "vm.\(label).\(bundle.name)", qos: .userInitiated)
|
||||||
|
self.queue = vmQueue
|
||||||
|
|
||||||
|
let configuration = try VZConfigFactory.makeConfiguration(bundle: bundle, headless: headless)
|
||||||
|
let delegate = VMInstanceDelegate()
|
||||||
|
self.vmDelegate = delegate
|
||||||
|
|
||||||
|
// Constructed on the queue it will be driven from, so no VZ object is
|
||||||
|
// ever created on one thread and used from another.
|
||||||
|
self.vm = vmQueue.sync {
|
||||||
|
let machine = VZVirtualMachine(configuration: configuration, queue: vmQueue)
|
||||||
|
machine.delegate = delegate
|
||||||
|
return machine
|
||||||
|
}
|
||||||
|
|
||||||
|
delegate.onStop = { [weak self] reason in
|
||||||
|
self?.finishStop(reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The framework's current state, read on the VM queue.
|
||||||
|
public var state: VZVirtualMachine.State {
|
||||||
|
get async {
|
||||||
|
// The raw value crosses the concurrency boundary rather than the
|
||||||
|
// enum, so no assumption is made about the imported type's Sendable
|
||||||
|
// conformance.
|
||||||
|
let raw: Int = await withCheckedContinuation { continuation in
|
||||||
|
queue.async {
|
||||||
|
continuation.resume(returning: self.vm.state.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return VZVirtualMachine.State(rawValue: raw) ?? .stopped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the VM is running or in a transitional state.
|
||||||
|
public var isActive: Bool {
|
||||||
|
get async {
|
||||||
|
switch await state {
|
||||||
|
case .stopped, .error:
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts the VM.
|
||||||
|
///
|
||||||
|
/// - Parameter options: Optional start options. The install/provision path
|
||||||
|
/// passes a `VZMacOSVirtualMachineStartOptions` — on macOS 27+ hosts that
|
||||||
|
/// is also where Setup Assistant automation is attached (see
|
||||||
|
/// ``GuestProvisioner`` and docs/DESIGN.md, Verified Fact 9). Pass `nil`
|
||||||
|
/// for a normal boot of an already-provisioned clone.
|
||||||
|
/// - Throws: ``CoreError/vmLimitExceeded`` when Apple's kernel-enforced cap
|
||||||
|
/// of **two** concurrent macOS guests is hit — the framework raises
|
||||||
|
/// `VZError.virtualMachineLimitExceeded` from `start()`, and that case is
|
||||||
|
/// translated here rather than propagated, because the scheduler treats it
|
||||||
|
/// as transient back-pressure rather than a failure.
|
||||||
|
public func start(options: VZMacOSVirtualMachineStartOptions? = nil) async throws {
|
||||||
|
clearStopReason()
|
||||||
|
|
||||||
|
let boxed = Unchecked(value: options)
|
||||||
|
do {
|
||||||
|
try await withCheckedThrowingContinuation {
|
||||||
|
(continuation: CheckedContinuation<Void, any Error>) in
|
||||||
|
queue.async {
|
||||||
|
if let options = boxed.value {
|
||||||
|
// The install/provision path: on a macOS 27+ host these
|
||||||
|
// options carry the Setup Assistant automation. Note the
|
||||||
|
// options-taking overload reports failure as an optional
|
||||||
|
// Error, not a Result.
|
||||||
|
self.vm.start(options: options) { error in
|
||||||
|
if let error {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
} else {
|
||||||
|
continuation.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.vm.start { result in
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
continuation.resume()
|
||||||
|
case .failure(let error):
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
throw Self.mapVZError(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hold a power assertion for the VM's lifetime: a CI guest that is
|
||||||
|
// building for twenty minutes over SSH looks completely idle to the host,
|
||||||
|
// and letting the Mac sleep underneath it would stall the job.
|
||||||
|
beginActivityAssertion()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// NSLock's `lock`/`unlock` are unavailable from an async context, so every
|
||||||
|
/// critical section lives in a synchronous helper.
|
||||||
|
private func clearStopReason() {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
stopReason = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Takes the power assertion, unless the VM already stopped in the meantime.
|
||||||
|
private func beginActivityAssertion() {
|
||||||
|
let token = ProcessInfo.processInfo.beginActivity(
|
||||||
|
options: [.userInitiated, .idleSystemSleepDisabled],
|
||||||
|
reason: "running macOS CI guest \(label) (\(bundle.name))"
|
||||||
|
)
|
||||||
|
lock.lock()
|
||||||
|
let alreadyStopped = stopReason != nil
|
||||||
|
if !alreadyStopped {
|
||||||
|
activityToken = token
|
||||||
|
}
|
||||||
|
lock.unlock()
|
||||||
|
if alreadyStopped {
|
||||||
|
// Raced with an immediate stop; don't strand the assertion.
|
||||||
|
ProcessInfo.processInfo.endActivity(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publishes a terminal stop and releases the power assertion. Idempotent:
|
||||||
|
/// the first reason wins, so a `didStopWithError` following a `requestStop`
|
||||||
|
/// cannot overwrite an already-recorded outcome.
|
||||||
|
private func finishStop(_ reason: VMStopReason) {
|
||||||
|
lock.lock()
|
||||||
|
if stopReason == nil {
|
||||||
|
stopReason = reason
|
||||||
|
}
|
||||||
|
let token = activityToken
|
||||||
|
activityToken = nil
|
||||||
|
lock.unlock()
|
||||||
|
if let token {
|
||||||
|
ProcessInfo.processInfo.endActivity(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The recorded stop reason, if the VM has already stopped.
|
||||||
|
private var recordedStopReason: VMStopReason? {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return stopReason
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asks the guest to shut down, then force-stops if it does not.
|
||||||
|
///
|
||||||
|
/// Tries `requestStop()` first — that delivers an ACPI-equivalent power
|
||||||
|
/// button press, giving the guest a chance to flush its filesystem — and
|
||||||
|
/// falls back to `stop()` after `gracePeriod`. Never throws: teardown must
|
||||||
|
/// always complete so the slot can be recycled.
|
||||||
|
///
|
||||||
|
/// - Parameter gracePeriod: How long to wait for a graceful stop.
|
||||||
|
/// - Returns: Why the VM ended up stopped.
|
||||||
|
@discardableResult
|
||||||
|
public func requestStopThenForce(gracePeriod: Duration = .seconds(30)) async -> VMStopReason {
|
||||||
|
if let reason = recordedStopReason { return reason }
|
||||||
|
if await !isActive {
|
||||||
|
// Stopped without a delegate callback ever landing (for example a
|
||||||
|
// start() that failed outright). Record it so waiters unblock.
|
||||||
|
finishStop(.requested)
|
||||||
|
return recordedStopReason ?? .requested
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guest-cooperative first: requestStop() is the equivalent of a power
|
||||||
|
// button press, which lets the guest flush its filesystem.
|
||||||
|
_ = await withCheckedContinuation { (continuation: CheckedContinuation<Bool, Never>) in
|
||||||
|
queue.async {
|
||||||
|
guard self.vm.canRequestStop else {
|
||||||
|
continuation.resume(returning: false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try self.vm.requestStop()
|
||||||
|
continuation.resume(returning: true)
|
||||||
|
} catch {
|
||||||
|
// "not running", or the guest refused. Force is next either way.
|
||||||
|
continuation.resume(returning: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let reason = await waitForStop(within: gracePeriod) {
|
||||||
|
return reason
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grace elapsed — pull the plug. Teardown must always complete so the
|
||||||
|
// slot can be recycled, so every failure here is swallowed.
|
||||||
|
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
||||||
|
queue.async {
|
||||||
|
guard self.vm.canStop else {
|
||||||
|
continuation.resume()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.vm.stop { _ in
|
||||||
|
continuation.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let reason = await waitForStop(within: .seconds(10)) {
|
||||||
|
return reason
|
||||||
|
}
|
||||||
|
// The framework never told us; treat it as stopped regardless rather
|
||||||
|
// than leaving the caller blocked on a dead slot.
|
||||||
|
finishStop(.requested)
|
||||||
|
return recordedStopReason ?? .requested
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Polls for a recorded stop for at most `limit`. Returns `nil` on timeout.
|
||||||
|
///
|
||||||
|
/// Polling rather than a parked continuation keeps this cancellable and
|
||||||
|
/// leak-free: a continuation registered for a VM that never stops would be
|
||||||
|
/// stranded forever.
|
||||||
|
private func waitForStop(within limit: Duration) async -> VMStopReason? {
|
||||||
|
let deadline = ContinuousClock.now.advanced(by: limit)
|
||||||
|
while true {
|
||||||
|
if let reason = recordedStopReason { return reason }
|
||||||
|
if ContinuousClock.now >= deadline { return nil }
|
||||||
|
do {
|
||||||
|
try await Task.sleep(for: .milliseconds(200))
|
||||||
|
} catch {
|
||||||
|
return recordedStopReason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Suspends until the VM stops for any reason.
|
||||||
|
///
|
||||||
|
/// - Returns: Why it stopped.
|
||||||
|
public func waitUntilStopped() async -> VMStopReason {
|
||||||
|
while true {
|
||||||
|
if let reason = recordedStopReason { return reason }
|
||||||
|
// A VM that reaches .stopped or .error without a delegate callback
|
||||||
|
// (an unusual but observed path) must not hang the caller.
|
||||||
|
if await !isActive {
|
||||||
|
finishStop(.guestInitiated)
|
||||||
|
return recordedStopReason ?? .guestInitiated
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try await Task.sleep(for: .milliseconds(500))
|
||||||
|
} catch {
|
||||||
|
return recordedStopReason ?? .requested
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Translates a Virtualization error into a ``CoreError``.
|
||||||
|
///
|
||||||
|
/// `VZError.Code.virtualMachineLimitExceeded` becomes
|
||||||
|
/// ``CoreError/vmLimitExceeded``; everything else becomes
|
||||||
|
/// ``CoreError/provisioningFailed(_:)`` carrying the framework's message.
|
||||||
|
public static func mapVZError(_ error: any Error) -> CoreError {
|
||||||
|
if let coreError = error as? CoreError { return coreError }
|
||||||
|
|
||||||
|
// Apple's kernel-enforced cap of two concurrent macOS guests
|
||||||
|
// (docs/DESIGN.md, Verified Fact 8). The scheduler treats this as
|
||||||
|
// transient back-pressure, so it must stay distinguishable.
|
||||||
|
if let vzError = error as? VZError, vzError.code == .virtualMachineLimitExceeded {
|
||||||
|
return .vmLimitExceeded
|
||||||
|
}
|
||||||
|
let nsError = error as NSError
|
||||||
|
if nsError.domain == VZErrorDomain,
|
||||||
|
nsError.code == VZError.Code.virtualMachineLimitExceeded.rawValue
|
||||||
|
{
|
||||||
|
return .vmLimitExceeded
|
||||||
|
}
|
||||||
|
return .provisioningFailed(nsError.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bridges `VZVirtualMachineDelegate` callbacks back into ``VMInstance``.
|
||||||
|
///
|
||||||
|
/// Kept as a separate object so ``VMInstance`` need not inherit `NSObject`, and
|
||||||
|
/// so the delegate's lifetime is explicitly owned rather than accidentally
|
||||||
|
/// retained by the framework.
|
||||||
|
final class VMInstanceDelegate: NSObject, VZVirtualMachineDelegate {
|
||||||
|
/// Invoked on the VM queue whenever the machine stops.
|
||||||
|
var onStop: (@Sendable (VMStopReason) -> Void)?
|
||||||
|
|
||||||
|
func guestDidStop(_ virtualMachine: VZVirtualMachine) {
|
||||||
|
onStop?(.guestInitiated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func virtualMachine(_ virtualMachine: VZVirtualMachine, didStopWithError error: any Error) {
|
||||||
|
onStop?(.failed((error as NSError).localizedDescription))
|
||||||
|
}
|
||||||
|
|
||||||
|
func virtualMachine(
|
||||||
|
_ virtualMachine: VZVirtualMachine,
|
||||||
|
networkDevice: VZNetworkDevice,
|
||||||
|
attachmentWasDisconnectedWithError error: any Error
|
||||||
|
) {
|
||||||
|
// NAT attachments do drop transiently. The VM keeps running and the
|
||||||
|
// guest's DHCP client recovers, so this is deliberately not treated as a
|
||||||
|
// stop — the boot/job timeouts are what catch a guest that never comes
|
||||||
|
// back onto the network.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
import Foundation
|
||||||
|
import RunnerCore
|
||||||
|
import Virtualization
|
||||||
|
|
||||||
|
/// Host-level state persisted across daemon restarts.
|
||||||
|
///
|
||||||
|
/// The only thing in it today is the pair of per-slot MAC addresses, but it is
|
||||||
|
/// versioned so future fields (saved-state handles, image pins) can be added.
|
||||||
|
public struct HostState: Codable, Sendable, Equatable {
|
||||||
|
/// Schema version of this file.
|
||||||
|
public var version: Int
|
||||||
|
|
||||||
|
/// One MAC per VM slot, generated once with
|
||||||
|
/// `VZMACAddress.randomLocallyAdministered()` and then **never changed**.
|
||||||
|
///
|
||||||
|
/// Reusing a small fixed set of MACs is deliberate. macOS's `bootpd` hands
|
||||||
|
/// out 24-hour leases and records each in `/var/db/dhcpd_leases`; a fleet
|
||||||
|
/// that randomized a MAC per ephemeral VM would leave a day's worth of dead
|
||||||
|
/// leases behind and eventually exhaust the NAT subnet. Two persistent MACs
|
||||||
|
/// mean each slot simply renews the same lease forever.
|
||||||
|
public var slotMACAddresses: [String]
|
||||||
|
|
||||||
|
public init(version: Int = 1, slotMACAddresses: [String] = []) {
|
||||||
|
self.version = version
|
||||||
|
self.slotMACAddresses = slotMACAddresses
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owns the on-disk layout of images, ephemeral clones, IPSWs, and host state.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// <storeDir>/
|
||||||
|
/// images/<name>/ base VM bundles (installed + provisioned)
|
||||||
|
/// vms/<uuid>/ ephemeral clones, destroyed after each job
|
||||||
|
/// ipsw/ downloaded restore images
|
||||||
|
/// state.json HostState
|
||||||
|
/// ```
|
||||||
|
public struct VMStore: Sendable {
|
||||||
|
/// Root directory, tilde-expanded by the caller.
|
||||||
|
public let storeDir: URL
|
||||||
|
|
||||||
|
/// Creates a store rooted at `storeDir`. Does not touch the filesystem;
|
||||||
|
/// call ``ensureLayout()`` first.
|
||||||
|
public init(storeDir: URL) {
|
||||||
|
self.storeDir = storeDir
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience initializer reading ``RunnerConfig/storeDirectoryURL``.
|
||||||
|
public init(config: RunnerConfig) {
|
||||||
|
self.init(storeDir: config.storeDirectoryURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Paths
|
||||||
|
|
||||||
|
/// `<storeDir>/images`.
|
||||||
|
public var imagesDir: URL { storeDir.appendingPathComponent("images", isDirectory: true) }
|
||||||
|
/// `<storeDir>/vms`.
|
||||||
|
public var clonesDir: URL { storeDir.appendingPathComponent("vms", isDirectory: true) }
|
||||||
|
/// `<storeDir>/ipsw`.
|
||||||
|
public var ipswDir: URL { storeDir.appendingPathComponent("ipsw", isDirectory: true) }
|
||||||
|
/// `<storeDir>/state.json`.
|
||||||
|
public var stateURL: URL { storeDir.appendingPathComponent("state.json") }
|
||||||
|
|
||||||
|
/// Creates every directory in the layout if missing.
|
||||||
|
public func ensureLayout() throws {
|
||||||
|
let fm = FileManager.default
|
||||||
|
for dir in [storeDir, imagesDir, clonesDir, ipswDir] {
|
||||||
|
do {
|
||||||
|
try fm.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
} catch {
|
||||||
|
throw CoreError.bundleCorrupt(
|
||||||
|
"cannot create \(dir.path): \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Images
|
||||||
|
|
||||||
|
/// Names of every base image, sorted.
|
||||||
|
public func listImages() throws -> [String] {
|
||||||
|
let fm = FileManager.default
|
||||||
|
guard fm.fileExists(atPath: imagesDir.path) else { return [] }
|
||||||
|
let entries: [URL]
|
||||||
|
do {
|
||||||
|
entries = try fm.contentsOfDirectory(
|
||||||
|
at: imagesDir,
|
||||||
|
includingPropertiesForKeys: [.isDirectoryKey],
|
||||||
|
options: [.skipsHiddenFiles]
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
throw CoreError.bundleCorrupt(
|
||||||
|
"cannot list \(imagesDir.path): \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
entries
|
||||||
|
.filter { (try? $0.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true }
|
||||||
|
// A directory without a decodable config.json is not an image — it is
|
||||||
|
// a half-finished build or somebody's scratch folder. Skip silently.
|
||||||
|
.filter { (try? VMBundle(rootURL: $0).loadConfig()) != nil }
|
||||||
|
.map { $0.lastPathComponent }
|
||||||
|
.sorted()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bundle for a named base image.
|
||||||
|
///
|
||||||
|
/// - Parameter name: Image name, e.g. `default`.
|
||||||
|
/// - Returns: The bundle, or `nil` when no such directory exists.
|
||||||
|
public func image(named name: String) throws -> VMBundle? {
|
||||||
|
let url = imagesDir.appendingPathComponent(name, isDirectory: true)
|
||||||
|
var isDir: ObjCBool = false
|
||||||
|
guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir),
|
||||||
|
isDir.boolValue
|
||||||
|
else { return nil }
|
||||||
|
return VMBundle(rootURL: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes a base image and everything in it.
|
||||||
|
public func deleteImage(named name: String) throws {
|
||||||
|
guard let bundle = try image(named: name) else {
|
||||||
|
throw CoreError.notFound("image '\(name)'")
|
||||||
|
}
|
||||||
|
try bundle.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Clones
|
||||||
|
|
||||||
|
/// Copy-on-write clones a base image into a fresh ephemeral bundle.
|
||||||
|
///
|
||||||
|
/// Cloning is done with `FileManager.copyItem` **per file**, which on APFS
|
||||||
|
/// performs a copy-on-write clone: the new disk costs almost nothing until
|
||||||
|
/// the guest writes to it. Two constraints follow, and both are enforced
|
||||||
|
/// here:
|
||||||
|
///
|
||||||
|
/// * Source and destination must be on the **same APFS volume**, so images
|
||||||
|
/// and clones both live under `storeDir`.
|
||||||
|
/// * A CoW clone's *apparent* size is the full disk size while its real cost
|
||||||
|
/// grows with guest writes, so ``ensureFreeSpace(minGB:)`` must be called
|
||||||
|
/// before cloning and the floor kept generous.
|
||||||
|
///
|
||||||
|
/// The clone's `config.json` is rewritten with `slotMAC` so the VM comes up
|
||||||
|
/// on its slot's persistent address; everything else is inherited.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - name: Base image name. Must be ``VMBundleConfig/provisioned``.
|
||||||
|
/// - slotMAC: The persistent MAC for the slot this clone will occupy.
|
||||||
|
/// - Returns: The new clone bundle under `<storeDir>/vms/<uuid>/`.
|
||||||
|
/// - Throws: ``CoreError/notFound(_:)`` if the image is missing,
|
||||||
|
/// ``CoreError/bundleCorrupt(_:)`` if it is unprovisioned or incomplete.
|
||||||
|
public func cloneImage(named name: String, slotMAC: String) throws -> VMBundle {
|
||||||
|
guard let source = try image(named: name) else {
|
||||||
|
throw CoreError.notFound("base image '\(name)' under \(imagesDir.path)")
|
||||||
|
}
|
||||||
|
let sourceConfig = try source.loadConfig()
|
||||||
|
guard sourceConfig.provisioned else {
|
||||||
|
throw CoreError.bundleCorrupt(
|
||||||
|
"base image '\(name)' is not provisioned; run `image build` to completion first")
|
||||||
|
}
|
||||||
|
guard source.isComplete() else {
|
||||||
|
throw CoreError.bundleCorrupt(
|
||||||
|
"base image '\(name)' is missing its disk, nvram.bin, or config.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
try ensureLayout()
|
||||||
|
|
||||||
|
let fm = FileManager.default
|
||||||
|
let destination = VMBundle(
|
||||||
|
rootURL: clonesDir.appendingPathComponent(UUID().uuidString, isDirectory: true))
|
||||||
|
try destination.createDirectory()
|
||||||
|
|
||||||
|
// Anything that fails past this point leaves a partial clone behind, and
|
||||||
|
// a partial clone is worse than none: it would be counted by
|
||||||
|
// `listClones` and booted by nobody.
|
||||||
|
func abort(_ error: any Error) -> any Error {
|
||||||
|
try? destination.destroy()
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
// Per-file `copyItem`, NOT a directory copy: APFS performs a
|
||||||
|
// copy-on-write clone for a regular file copied within the same
|
||||||
|
// volume, so this is effectively instantaneous and costs no space
|
||||||
|
// until the guest writes. It is *only* copy-on-write when source and
|
||||||
|
// destination share a volume — which is why images/ and vms/ both
|
||||||
|
// live under storeDir (docs/DESIGN.md, Verified Fact 13). Cloning
|
||||||
|
// across volumes silently degrades to a full byte copy of a
|
||||||
|
// multi-gigabyte disk.
|
||||||
|
let diskName = source.diskURL(format: sourceConfig.diskFormat)
|
||||||
|
try fm.copyItem(
|
||||||
|
at: diskName,
|
||||||
|
to: destination.diskURL(format: sourceConfig.diskFormat))
|
||||||
|
try fm.copyItem(at: source.auxiliaryStorageURL, to: destination.auxiliaryStorageURL)
|
||||||
|
try fm.copyItem(at: source.configURL, to: destination.configURL)
|
||||||
|
} catch {
|
||||||
|
throw abort(
|
||||||
|
CoreError.bundleCorrupt(
|
||||||
|
"cannot clone image '\(name)': \(error.localizedDescription)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
// Rewrite only the MAC. The machine identifier is deliberately
|
||||||
|
// SHARED with the base image: the guest's Setup Assistant state and
|
||||||
|
// its installed system are tied to it, regenerating it would present
|
||||||
|
// the guest with new hardware, and a future save/restore path
|
||||||
|
// (docs/DESIGN.md §9) forbids changing the ECID anyway.
|
||||||
|
var cloneConfig = sourceConfig
|
||||||
|
cloneConfig.macAddress = slotMAC
|
||||||
|
cloneConfig.provisioned = true
|
||||||
|
try destination.saveConfig(cloneConfig)
|
||||||
|
} catch {
|
||||||
|
throw abort(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return destination
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes an ephemeral clone. Safe to call twice.
|
||||||
|
///
|
||||||
|
/// - Parameter bundle: A bundle previously returned by
|
||||||
|
/// ``cloneImage(named:slotMAC:)``. Refuses to delete anything outside
|
||||||
|
/// ``clonesDir``.
|
||||||
|
public func deleteClone(_ bundle: VMBundle) throws {
|
||||||
|
// `rm -rf` driven by a path that came from elsewhere deserves a guard.
|
||||||
|
let root = clonesDir.standardizedFileURL.resolvingSymlinksInPath().path
|
||||||
|
let target = bundle.rootURL.standardizedFileURL.resolvingSymlinksInPath().path
|
||||||
|
guard target.hasPrefix(root.hasSuffix("/") ? root : root + "/"), target != root else {
|
||||||
|
throw CoreError.bundleCorrupt(
|
||||||
|
"refusing to delete \(bundle.rootURL.path): not inside \(clonesDir.path)")
|
||||||
|
}
|
||||||
|
try bundle.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every ephemeral clone currently on disk.
|
||||||
|
///
|
||||||
|
/// Used at startup to garbage-collect clones orphaned by a crash.
|
||||||
|
public func listClones() throws -> [VMBundle] {
|
||||||
|
let fm = FileManager.default
|
||||||
|
guard fm.fileExists(atPath: clonesDir.path) else { return [] }
|
||||||
|
let entries: [URL]
|
||||||
|
do {
|
||||||
|
entries = try fm.contentsOfDirectory(
|
||||||
|
at: clonesDir,
|
||||||
|
includingPropertiesForKeys: [.isDirectoryKey],
|
||||||
|
options: [.skipsHiddenFiles]
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
throw CoreError.bundleCorrupt(
|
||||||
|
"cannot list \(clonesDir.path): \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
entries
|
||||||
|
.filter { (try? $0.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true }
|
||||||
|
.sorted { $0.lastPathComponent < $1.lastPathComponent }
|
||||||
|
.map { VMBundle(rootURL: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes every clone. Called on daemon startup, before any VM is booted.
|
||||||
|
public func purgeClones() throws {
|
||||||
|
// Best-effort per clone: one undeletable directory must not stop the
|
||||||
|
// daemon from starting, so the first failure is remembered and rethrown
|
||||||
|
// only after every other clone has been tried.
|
||||||
|
var firstError: (any Error)?
|
||||||
|
for clone in try listClones() {
|
||||||
|
do {
|
||||||
|
try deleteClone(clone)
|
||||||
|
} catch {
|
||||||
|
if firstError == nil { firstError = error }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let firstError { throw firstError }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Host state
|
||||||
|
|
||||||
|
/// Reads `state.json`, returning a fresh ``HostState`` when absent.
|
||||||
|
public func loadState() throws -> HostState {
|
||||||
|
guard FileManager.default.fileExists(atPath: stateURL.path) else {
|
||||||
|
return HostState()
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let data = try Data(contentsOf: stateURL)
|
||||||
|
return try JSONDecoder().decode(HostState.self, from: data)
|
||||||
|
} catch {
|
||||||
|
throw CoreError.bundleCorrupt(
|
||||||
|
"cannot read \(stateURL.path): \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomically writes `state.json`.
|
||||||
|
public func saveState(_ state: HostState) throws {
|
||||||
|
try ensureLayout()
|
||||||
|
do {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
try encoder.encode(state).write(to: stateURL, options: .atomic)
|
||||||
|
} catch {
|
||||||
|
throw CoreError.bundleCorrupt(
|
||||||
|
"cannot write \(stateURL.path): \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the persistent MAC for a slot, generating and persisting the
|
||||||
|
/// whole table the first time.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - slot: Slot index.
|
||||||
|
/// - slotCount: How many slots to provision addresses for.
|
||||||
|
/// - Returns: A MAC string such as `aa:bb:0c:dd:ee:ff`.
|
||||||
|
public func macAddress(forSlot slot: Int, slotCount: Int) throws -> String {
|
||||||
|
guard slot >= 0, slot < slotCount else {
|
||||||
|
throw CoreError.configInvalid(
|
||||||
|
"slot \(slot) is out of range for \(slotCount) slot(s)")
|
||||||
|
}
|
||||||
|
var state = try loadState()
|
||||||
|
if state.slotMACAddresses.count < slotCount {
|
||||||
|
// Generated exactly once and then persisted forever. See HostState's
|
||||||
|
// doc comment and docs/DESIGN.md, Verified Fact 12: randomizing a MAC
|
||||||
|
// per ephemeral clone would strand a 24-hour bootpd lease per boot
|
||||||
|
// and eventually exhaust the NAT subnet.
|
||||||
|
while state.slotMACAddresses.count < slotCount {
|
||||||
|
state.slotMACAddresses.append(
|
||||||
|
VZMACAddress.randomLocallyAdministered().string)
|
||||||
|
}
|
||||||
|
try saveState(state)
|
||||||
|
}
|
||||||
|
return state.slotMACAddresses[slot]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Disk space
|
||||||
|
|
||||||
|
/// Free space on the store's volume, in bytes.
|
||||||
|
///
|
||||||
|
/// Uses the *important usage* resource key so the number matches what Finder
|
||||||
|
/// reports and accounts for purgeable space.
|
||||||
|
public func freeDiskSpace() throws -> Int64 {
|
||||||
|
// The volume keys only resolve for a path that exists, and the daemon may
|
||||||
|
// call this before anything has been created.
|
||||||
|
try ensureLayout()
|
||||||
|
do {
|
||||||
|
let values = try storeDir.resourceValues(forKeys: [
|
||||||
|
.volumeAvailableCapacityForImportantUsageKey
|
||||||
|
])
|
||||||
|
guard let available = values.volumeAvailableCapacityForImportantUsage else {
|
||||||
|
throw CoreError.notFound(
|
||||||
|
"free-space information for the volume holding \(storeDir.path)")
|
||||||
|
}
|
||||||
|
return available
|
||||||
|
} catch let error as CoreError {
|
||||||
|
throw error
|
||||||
|
} catch {
|
||||||
|
throw CoreError.notFound(
|
||||||
|
"free space for \(storeDir.path): \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Throws unless the store volume has at least `minGB` free.
|
||||||
|
///
|
||||||
|
/// - Throws: ``CoreError/insufficientDiskSpace(requiredGB:availableGB:)``.
|
||||||
|
public func ensureFreeSpace(minGB: Int) throws {
|
||||||
|
guard minGB > 0 else { return }
|
||||||
|
let availableBytes = try freeDiskSpace()
|
||||||
|
let availableGB = Int(availableBytes / 1_073_741_824)
|
||||||
|
guard availableGB >= minGB else {
|
||||||
|
throw CoreError.insufficientDiskSpace(requiredGB: minGB, availableGB: availableGB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import Foundation
|
||||||
|
import RunnerCore
|
||||||
|
import Virtualization
|
||||||
|
|
||||||
|
/// Builds a `VZVirtualMachineConfiguration` from a ``VMBundle``.
|
||||||
|
///
|
||||||
|
/// The configuration is assembled the same way for base-image installs and for
|
||||||
|
/// ephemeral clones; only the bundle differs. Devices are chosen for the minimum
|
||||||
|
/// that a headless CI guest needs while still satisfying macOS's own
|
||||||
|
/// requirements.
|
||||||
|
public enum VZConfigFactory {
|
||||||
|
|
||||||
|
/// Assembles and validates a configuration.
|
||||||
|
///
|
||||||
|
/// Composition:
|
||||||
|
///
|
||||||
|
/// * **Platform** — `VZMacPlatformConfiguration` with `hardwareModel` and
|
||||||
|
/// `machineIdentifier` restored from the bundle's stored blobs, and
|
||||||
|
/// `auxiliaryStorage` opened from `nvram.bin`. These three must match the
|
||||||
|
/// install exactly or the guest will not boot.
|
||||||
|
/// * **Boot loader** — `VZMacOSBootLoader`.
|
||||||
|
/// * **CPU / memory** — `max(4, config.cpuCount)` clamped into the
|
||||||
|
/// framework's supported range; memory likewise clamped.
|
||||||
|
/// * **Storage** — `VZVirtioBlockDeviceConfiguration` over a
|
||||||
|
/// `VZDiskImageStorageDeviceAttachment` on the bundle's disk.
|
||||||
|
/// * **Network** — `VZVirtioNetworkDeviceConfiguration` with a
|
||||||
|
/// `VZNATNetworkDeviceAttachment` and the bundle's MAC. NAT, not bridged:
|
||||||
|
/// bridged networking requires the restricted
|
||||||
|
/// `com.apple.vm.networking` entitlement, which Apple does not grant for
|
||||||
|
/// ad-hoc signing, whereas NAT needs nothing beyond
|
||||||
|
/// `com.apple.security.virtualization`. NAT is also what puts the guest in
|
||||||
|
/// `/var/db/dhcpd_leases`, which is how we discover its IP.
|
||||||
|
/// * **Graphics** — a `VZMacGraphicsDeviceConfiguration` with a single
|
||||||
|
/// 1920×1200 @ 72 ppi display, configured **always**, even headless. macOS
|
||||||
|
/// guests misbehave without a display device; we simply never attach a
|
||||||
|
/// `VZVirtualMachineView` to it.
|
||||||
|
/// * **Input** — `VZMacKeyboardConfiguration` and a pointing device, needed
|
||||||
|
/// for Setup Assistant automation to have something to talk to.
|
||||||
|
/// * **Entropy** — `VZVirtioEntropyDeviceConfiguration`, so the guest's RNG
|
||||||
|
/// seeds promptly instead of blocking early boot.
|
||||||
|
/// * **Socket** — `VZVirtioSocketDeviceConfiguration`, reserved for a future
|
||||||
|
/// vsock control channel that would replace SSH.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - bundle: The VM to configure.
|
||||||
|
/// - headless: When `true`, no view will be attached. Retained as a
|
||||||
|
/// parameter because `vm boot` may later want a window; it does **not**
|
||||||
|
/// change whether the graphics device is present.
|
||||||
|
/// - Returns: A configuration that has passed `validate()`.
|
||||||
|
/// - Throws: ``CoreError/bundleCorrupt(_:)`` when the bundle's blobs cannot
|
||||||
|
/// be restored, or the framework's own validation error.
|
||||||
|
public static func makeConfiguration(
|
||||||
|
bundle: VMBundle,
|
||||||
|
headless: Bool = true
|
||||||
|
) throws -> VZVirtualMachineConfiguration {
|
||||||
|
let bundleConfig = try bundle.loadConfig()
|
||||||
|
|
||||||
|
let configuration = VZVirtualMachineConfiguration()
|
||||||
|
configuration.platform = try makePlatform(bundle: bundle)
|
||||||
|
configuration.bootLoader = VZMacOSBootLoader()
|
||||||
|
configuration.cpuCount = clampedCPUCount(bundleConfig.cpuCount)
|
||||||
|
configuration.memorySize = clampedMemorySize(gigabytes: bundleConfig.memoryGB)
|
||||||
|
|
||||||
|
// Storage. The bundle records which of ASIF/RAW the builder produced, so
|
||||||
|
// the right file is attached without probing the filesystem.
|
||||||
|
let diskURL = bundle.diskURL(format: bundleConfig.diskFormat)
|
||||||
|
guard FileManager.default.fileExists(atPath: diskURL.path) else {
|
||||||
|
throw CoreError.bundleCorrupt("missing disk image at \(diskURL.path)")
|
||||||
|
}
|
||||||
|
let attachment: VZDiskImageStorageDeviceAttachment
|
||||||
|
do {
|
||||||
|
attachment = try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false)
|
||||||
|
} catch {
|
||||||
|
throw CoreError.bundleCorrupt(
|
||||||
|
"cannot attach disk \(diskURL.path): \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
configuration.storageDevices = [VZVirtioBlockDeviceConfiguration(attachment: attachment)]
|
||||||
|
|
||||||
|
// Network: NAT, with the bundle's MAC. NAT is what puts the guest into
|
||||||
|
// /var/db/dhcpd_leases, which is the only way we learn its IP.
|
||||||
|
guard let mac = VZMACAddress(string: bundleConfig.macAddress) else {
|
||||||
|
throw CoreError.bundleCorrupt(
|
||||||
|
"malformed MAC address '\(bundleConfig.macAddress)' in \(bundle.configURL.path)")
|
||||||
|
}
|
||||||
|
let network = VZVirtioNetworkDeviceConfiguration()
|
||||||
|
network.attachment = VZNATNetworkDeviceAttachment()
|
||||||
|
network.macAddress = mac
|
||||||
|
configuration.networkDevices = [network]
|
||||||
|
|
||||||
|
// Graphics: always present, even headless, and never sized from
|
||||||
|
// NSScreen — the daemon runs as a LaunchAgent that may have no attached
|
||||||
|
// display at all, and a nil main screen there would be fatal. `headless`
|
||||||
|
// only decides whether a VZVirtualMachineView is ever bound to this
|
||||||
|
// device; the device itself is unconditional because macOS guests
|
||||||
|
// misbehave without one.
|
||||||
|
_ = headless
|
||||||
|
let graphics = VZMacGraphicsDeviceConfiguration()
|
||||||
|
graphics.displays = [
|
||||||
|
VZMacGraphicsDisplayConfiguration(
|
||||||
|
widthInPixels: 1920,
|
||||||
|
heightInPixels: 1200,
|
||||||
|
pixelsPerInch: 72
|
||||||
|
)
|
||||||
|
]
|
||||||
|
configuration.graphicsDevices = [graphics]
|
||||||
|
|
||||||
|
// Input: Setup Assistant automation needs something to talk to.
|
||||||
|
configuration.keyboards = [VZMacKeyboardConfiguration()]
|
||||||
|
configuration.pointingDevices = [VZMacTrackpadConfiguration()]
|
||||||
|
|
||||||
|
// Entropy, so the guest's RNG seeds promptly rather than blocking early boot.
|
||||||
|
configuration.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]
|
||||||
|
|
||||||
|
// Exactly one socket device — the framework permits no more. Reserved for
|
||||||
|
// the vsock control channel that would eventually replace SSH.
|
||||||
|
configuration.socketDevices = [VZVirtioSocketDeviceConfiguration()]
|
||||||
|
|
||||||
|
try configuration.validate()
|
||||||
|
return configuration
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds only the platform configuration, so the installer path can share it.
|
||||||
|
///
|
||||||
|
/// - Parameter bundle: The VM whose hardware model, machine identifier, and
|
||||||
|
/// auxiliary storage should be restored.
|
||||||
|
public static func makePlatform(bundle: VMBundle) throws -> VZMacPlatformConfiguration {
|
||||||
|
let bundleConfig = try bundle.loadConfig()
|
||||||
|
let platform = VZMacPlatformConfiguration()
|
||||||
|
|
||||||
|
guard
|
||||||
|
let hardwareModel = VZMacHardwareModel(
|
||||||
|
dataRepresentation: bundleConfig.hardwareModelData)
|
||||||
|
else {
|
||||||
|
throw CoreError.bundleCorrupt(
|
||||||
|
"hardwareModelData in \(bundle.configURL.path) is not a valid VZMacHardwareModel")
|
||||||
|
}
|
||||||
|
guard hardwareModel.isSupported else {
|
||||||
|
throw CoreError.hostUnsupported(
|
||||||
|
"this host does not support the hardware model recorded in \(bundle.configURL.path)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
guard
|
||||||
|
let machineIdentifier = VZMacMachineIdentifier(
|
||||||
|
dataRepresentation: bundleConfig.machineIdentifierData)
|
||||||
|
else {
|
||||||
|
throw CoreError.bundleCorrupt(
|
||||||
|
"machineIdentifierData in \(bundle.configURL.path) is not a valid VZMacMachineIdentifier"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The *existing*-storage initializer. Using
|
||||||
|
// VZMacAuxiliaryStorage(creatingStorageAt:hardwareModel:) here would
|
||||||
|
// blank the guest's NVRAM and it would no longer boot.
|
||||||
|
guard FileManager.default.fileExists(atPath: bundle.auxiliaryStorageURL.path) else {
|
||||||
|
throw CoreError.bundleCorrupt("missing nvram.bin at \(bundle.auxiliaryStorageURL.path)")
|
||||||
|
}
|
||||||
|
platform.auxiliaryStorage = VZMacAuxiliaryStorage(url: bundle.auxiliaryStorageURL)
|
||||||
|
platform.hardwareModel = hardwareModel
|
||||||
|
platform.machineIdentifier = machineIdentifier
|
||||||
|
return platform
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clamps a requested CPU count into the framework's supported range, with a
|
||||||
|
/// floor of 4 — Xcode builds are miserable below that.
|
||||||
|
public static func clampedCPUCount(_ requested: Int) -> Int {
|
||||||
|
let lowerBound = max(VZVirtualMachineConfiguration.minimumAllowedCPUCount, 4)
|
||||||
|
let upperBound = VZVirtualMachineConfiguration.maximumAllowedCPUCount
|
||||||
|
// On a host whose maximum is below our floor, the maximum wins.
|
||||||
|
guard lowerBound <= upperBound else { return upperBound }
|
||||||
|
return min(max(requested, lowerBound), upperBound)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clamps a requested memory size (in gibibytes) into the framework's
|
||||||
|
/// supported range, returning bytes.
|
||||||
|
public static func clampedMemorySize(gigabytes: Int) -> UInt64 {
|
||||||
|
let lowerBound = VZVirtualMachineConfiguration.minimumAllowedMemorySize
|
||||||
|
let upperBound = VZVirtualMachineConfiguration.maximumAllowedMemorySize
|
||||||
|
let requested = UInt64(max(gigabytes, 0)) * 1_073_741_824
|
||||||
|
return min(max(requested, lowerBound), upperBound)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import Foundation
|
||||||
|
import RunnerCore
|
||||||
|
|
||||||
|
/// `gitea-macos-runner config …` — create and inspect configuration.
|
||||||
|
struct ConfigCommand: AsyncParsableCommand {
|
||||||
|
static let configuration = CommandConfiguration(
|
||||||
|
commandName: "config",
|
||||||
|
abstract: "Create and inspect the runner configuration.",
|
||||||
|
subcommands: [Init.self, Show.self, Path.self]
|
||||||
|
)
|
||||||
|
|
||||||
|
/// `config init` — write a commented example config.
|
||||||
|
struct Init: AsyncParsableCommand {
|
||||||
|
static let configuration = CommandConfiguration(
|
||||||
|
commandName: "init",
|
||||||
|
abstract: "Write an example config.json, creating parent directories.",
|
||||||
|
discussion: """
|
||||||
|
Writes to ~/.config/gitea-macos-runner/config.json unless --config says \
|
||||||
|
otherwise. Refuses to overwrite an existing file without --force. The \
|
||||||
|
written file carries "_comment" keys explaining each section; they are \
|
||||||
|
ignored when the config is read back.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptionGroup var options: GlobalOptions
|
||||||
|
|
||||||
|
/// Overwrite an existing file.
|
||||||
|
@Flag(name: .shortAndLong, help: "Overwrite an existing config file.")
|
||||||
|
var force: Bool = false
|
||||||
|
|
||||||
|
/// Seed `gitea.instanceURL` instead of the placeholder.
|
||||||
|
@Option(name: .long, help: "Gitea instance URL to seed into the config.")
|
||||||
|
var instanceURL: String?
|
||||||
|
|
||||||
|
func run() async throws {
|
||||||
|
var config = RunnerConfig.default
|
||||||
|
if let instanceURL {
|
||||||
|
guard let url = URL(string: instanceURL), url.scheme != nil, url.host != nil else {
|
||||||
|
throw ValidationError("not a valid absolute URL: \(instanceURL)")
|
||||||
|
}
|
||||||
|
config.gitea.instanceURL = url
|
||||||
|
}
|
||||||
|
|
||||||
|
var example = ConfigCommand.loadExampleDocument()
|
||||||
|
if let instanceURL, example != nil {
|
||||||
|
example = example?.replacingOccurrences(
|
||||||
|
of: "https://gitea.example.com",
|
||||||
|
with: instanceURL
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = RunnerConfig.expandTilde(options.configPath)
|
||||||
|
let written = try config.writeExample(to: path, exampleContents: example, overwrite: force)
|
||||||
|
|
||||||
|
guard written else {
|
||||||
|
CLI.error("\(path) already exists; pass --force to overwrite")
|
||||||
|
throw ExitCode(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
print("wrote \(path)")
|
||||||
|
print("")
|
||||||
|
if let contents = try? String(contentsOfFile: path, encoding: .utf8) {
|
||||||
|
print(contents)
|
||||||
|
}
|
||||||
|
print("edit it, then run: gitea-macos-runner doctor")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `config show` — print the effective, validated configuration.
|
||||||
|
///
|
||||||
|
/// Token values are redacted; token *sources* are shown, which is what you
|
||||||
|
/// actually need when debugging "why does it say no registration token".
|
||||||
|
struct Show: AsyncParsableCommand {
|
||||||
|
static let configuration = CommandConfiguration(
|
||||||
|
commandName: "show",
|
||||||
|
abstract: "Print the effective configuration with secrets redacted."
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptionGroup var options: GlobalOptions
|
||||||
|
|
||||||
|
func run() async throws {
|
||||||
|
let config = try options.loadConfig()
|
||||||
|
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
let encoded = try encoder.encode(config)
|
||||||
|
|
||||||
|
var object = (try JSONSerialization.jsonObject(with: encoded)) as? [String: Any] ?? [:]
|
||||||
|
if var gitea = object["gitea"] as? [String: Any] {
|
||||||
|
if gitea["adminToken"] != nil { gitea["adminToken"] = "<redacted>" }
|
||||||
|
if gitea["registrationToken"] != nil { gitea["registrationToken"] = "<redacted>" }
|
||||||
|
object["gitea"] = gitea
|
||||||
|
}
|
||||||
|
|
||||||
|
let redacted = try JSONSerialization.data(
|
||||||
|
withJSONObject: object,
|
||||||
|
options: [.prettyPrinted, .sortedKeys]
|
||||||
|
)
|
||||||
|
print(String(data: redacted, encoding: .utf8) ?? "{}")
|
||||||
|
|
||||||
|
// The sources matter more than the values: "no registration token"
|
||||||
|
// is almost always a path problem, not a secret problem.
|
||||||
|
print("")
|
||||||
|
print("config path: \(RunnerConfig.expandTilde(options.configPath))")
|
||||||
|
print("store directory: \(config.storeDirectoryURL.path)")
|
||||||
|
print("labels: \(config.runner.labels.joined(separator: ", "))")
|
||||||
|
print("register --labels: \(config.labelSet.registrationArgument())")
|
||||||
|
let downloadURL = (try? config.runner.resolvedDownloadURL)?.absoluteString ?? "<invalid>"
|
||||||
|
print("runner download: \(downloadURL)")
|
||||||
|
let adminSource = ConfigCommand.describeSource(
|
||||||
|
inline: config.gitea.adminToken,
|
||||||
|
file: config.gitea.adminTokenFile,
|
||||||
|
resolved: (try? config.resolveAdminToken()) ?? nil
|
||||||
|
)
|
||||||
|
let registrationSource = ConfigCommand.describeSource(
|
||||||
|
inline: config.gitea.registrationToken,
|
||||||
|
file: config.gitea.registrationTokenFile,
|
||||||
|
resolved: (try? config.resolveStaticRegistrationToken()) ?? nil,
|
||||||
|
fallback: config.gitea.fetchRegistrationTokenViaAPI
|
||||||
|
? "admin API (fetchRegistrationTokenViaAPI)"
|
||||||
|
: nil
|
||||||
|
)
|
||||||
|
print("admin token: \(adminSource)")
|
||||||
|
print("registration token: \(registrationSource)")
|
||||||
|
|
||||||
|
let insecure = config.insecureTokenFilePaths
|
||||||
|
if !insecure.isEmpty {
|
||||||
|
print("")
|
||||||
|
CLI.note("warning: group/world readable token files: \(insecure.joined(separator: ", "))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `config path` — print the config path being used.
|
||||||
|
struct Path: AsyncParsableCommand {
|
||||||
|
static let configuration = CommandConfiguration(
|
||||||
|
commandName: "path",
|
||||||
|
abstract: "Print the configuration file path."
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptionGroup var options: GlobalOptions
|
||||||
|
|
||||||
|
func run() async throws {
|
||||||
|
print(RunnerConfig.expandTilde(options.configPath))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Describes where a secret comes from, without printing it.
|
||||||
|
static func describeSource(
|
||||||
|
inline: String?,
|
||||||
|
file: String?,
|
||||||
|
resolved: String?,
|
||||||
|
fallback: String? = nil
|
||||||
|
) -> String {
|
||||||
|
let value = resolved
|
||||||
|
if let file, !file.isEmpty {
|
||||||
|
let expanded = RunnerConfig.expandTilde(file)
|
||||||
|
let readable = (value?.isEmpty == false)
|
||||||
|
return "\(expanded) (\(readable ? "readable" : "MISSING or empty"))"
|
||||||
|
}
|
||||||
|
if let inline, !inline.isEmpty {
|
||||||
|
return "inline value in config.json (prefer a file)"
|
||||||
|
}
|
||||||
|
return fallback ?? "not configured"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finds `Resources/config.example.json` next to the binary or in a checkout.
|
||||||
|
///
|
||||||
|
/// The example is not an SPM resource bundle and `make bundle` does not copy
|
||||||
|
/// it into the app, so several plausible locations are tried; `writeExample`
|
||||||
|
/// falls back to a plain serialization when none is found.
|
||||||
|
static func loadExampleDocument() -> String? {
|
||||||
|
var candidates: [URL] = []
|
||||||
|
|
||||||
|
if let resource = Bundle.main.url(forResource: "config.example", withExtension: "json") {
|
||||||
|
candidates.append(resource)
|
||||||
|
}
|
||||||
|
candidates.append(Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/config.example.json"))
|
||||||
|
if let executableURL = Bundle.main.executableURL?.resolvingSymlinksInPath() {
|
||||||
|
let directory = executableURL.deletingLastPathComponent()
|
||||||
|
candidates.append(directory.appendingPathComponent("Resources/config.example.json"))
|
||||||
|
candidates.append(
|
||||||
|
directory.deletingLastPathComponent().appendingPathComponent("Resources/config.example.json")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Sources/gitea-macos-runner/CommandConfig.swift → repository root.
|
||||||
|
let repositoryRoot = URL(fileURLWithPath: #filePath)
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
candidates.append(repositoryRoot.appendingPathComponent("Resources/config.example.json"))
|
||||||
|
candidates.append(
|
||||||
|
URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
|
||||||
|
.appendingPathComponent("Resources/config.example.json")
|
||||||
|
)
|
||||||
|
|
||||||
|
for candidate in candidates {
|
||||||
|
if let contents = try? String(contentsOf: candidate, encoding: .utf8) {
|
||||||
|
return contents
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import AppKit
|
||||||
|
import ArgumentParser
|
||||||
|
import Foundation
|
||||||
|
import Logging
|
||||||
|
import RunnerCore
|
||||||
|
import RunnerHost
|
||||||
|
|
||||||
|
/// `gitea-macos-runner daemon` — the long-running service.
|
||||||
|
///
|
||||||
|
/// ## Why there is an `NSApplication` here
|
||||||
|
///
|
||||||
|
/// Virtualization.framework requires a running main run loop in an application
|
||||||
|
/// context; a plain command-line process that blocks in `await` never services
|
||||||
|
/// it, and VM startup either hangs or fails. The fix is to start a real
|
||||||
|
/// `NSApplication` but suppress every trace of a GUI:
|
||||||
|
///
|
||||||
|
/// ```swift
|
||||||
|
/// NSApplication.shared.setActivationPolicy(.prohibited) // no Dock icon, no menu bar
|
||||||
|
/// // spawn the orchestrator Task
|
||||||
|
/// NSApplication.shared.run() // never returns
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// `.prohibited` (mirrored by `LSUIElement` in `Info.plist`) is what makes this
|
||||||
|
/// invisible. The orchestrator runs in a detached `Task`; `run()` owns the main
|
||||||
|
/// thread from then on.
|
||||||
|
///
|
||||||
|
/// `SIGTERM` and `SIGINT` are trapped with `DispatchSourceSignal` — not
|
||||||
|
/// `signal(2)` handlers, which cannot safely touch Swift concurrency — and
|
||||||
|
/// trigger ``Orchestrator/shutdown()`` before the process leaves, so guests get
|
||||||
|
/// a chance to stop cleanly instead of having their disks yanked.
|
||||||
|
struct DaemonCommand: AsyncParsableCommand {
|
||||||
|
static let configuration = CommandConfiguration(
|
||||||
|
commandName: "daemon",
|
||||||
|
abstract: "Watch Gitea for queued macOS jobs and run each in a fresh VM."
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptionGroup var options: GlobalOptions
|
||||||
|
|
||||||
|
/// Base image to clone for each job.
|
||||||
|
@Option(name: .long, help: "Base image to clone for each job.")
|
||||||
|
var image: String = "default"
|
||||||
|
|
||||||
|
/// Run one poll/reconcile tick and exit. Useful for debugging without
|
||||||
|
/// installing the service.
|
||||||
|
@Flag(name: .long, help: "Run a single scheduling tick, then exit.")
|
||||||
|
var once: Bool = false
|
||||||
|
|
||||||
|
func run() async throws {
|
||||||
|
CLI.bootstrapLogging(verbose: options.verbose)
|
||||||
|
let logger = Logger(label: "daemon")
|
||||||
|
|
||||||
|
let config = try options.loadConfig()
|
||||||
|
|
||||||
|
guard let adminToken = try config.resolveAdminToken(), !adminToken.isEmpty else {
|
||||||
|
throw ValidationError(
|
||||||
|
"""
|
||||||
|
no Gitea admin token: set gitea.adminTokenFile (preferred) or gitea.adminToken \
|
||||||
|
in \(RunnerConfig.expandTilde(options.configPath))
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for path in config.insecureTokenFilePaths {
|
||||||
|
logger.warning("token file is group/world readable", metadata: ["path": .string(path)])
|
||||||
|
}
|
||||||
|
|
||||||
|
let store = VMStore(config: config)
|
||||||
|
try store.ensureLayout()
|
||||||
|
|
||||||
|
guard try store.image(named: image) != nil else {
|
||||||
|
throw ValidationError(
|
||||||
|
"no base image named '\(image)' — build one with `gitea-macos-runner image build --name \(image)`"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = GiteaClient(baseURL: config.gitea.instanceURL, token: adminToken)
|
||||||
|
let orchestrator = Orchestrator(
|
||||||
|
config: config,
|
||||||
|
client: client,
|
||||||
|
store: store,
|
||||||
|
imageName: image,
|
||||||
|
logger: Logger(label: "orchestrator")
|
||||||
|
)
|
||||||
|
|
||||||
|
let singleTick = once
|
||||||
|
let jobTimeout = TimeInterval(config.scheduler.jobTimeoutMinutes * 60)
|
||||||
|
|
||||||
|
// Even a single tick can start a VM, and a VM needs the run loop — so
|
||||||
|
// both modes go through NSApplication.
|
||||||
|
await VZAppRuntime.run(
|
||||||
|
onSignal: { await orchestrator.shutdown() },
|
||||||
|
body: {
|
||||||
|
do {
|
||||||
|
if singleTick {
|
||||||
|
await orchestrator.reconcileOnce()
|
||||||
|
await orchestrator.tick()
|
||||||
|
// Let whatever the tick started run to completion rather
|
||||||
|
// than tearing a just-booted guest down mid-boot.
|
||||||
|
let deadline = Date().addingTimeInterval(jobTimeout)
|
||||||
|
var pending = await orchestrator.liveVMs().count
|
||||||
|
while pending > 0, Date() < deadline {
|
||||||
|
try? await Task.sleep(for: .seconds(5))
|
||||||
|
pending = await orchestrator.liveVMs().count
|
||||||
|
}
|
||||||
|
await orchestrator.shutdown()
|
||||||
|
} else {
|
||||||
|
try await orchestrator.runForever()
|
||||||
|
}
|
||||||
|
} catch is CancellationError {
|
||||||
|
// Expected on shutdown.
|
||||||
|
} catch {
|
||||||
|
logger.critical("daemon stopped", metadata: ["error": .string("\(error)")])
|
||||||
|
// Fully qualified: inside a ParsableCommand a bare `exit`
|
||||||
|
// resolves to ParsableCommand.exit(withError:).
|
||||||
|
await MainActor.run { Foundation.exit(1) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hosts an `NSApplication` run loop so Virtualization.framework has the main
|
||||||
|
/// run loop it requires, while the real work runs in a `Task`.
|
||||||
|
///
|
||||||
|
/// Shared by `daemon` and `vm boot`: any command that starts a VM needs this.
|
||||||
|
@MainActor
|
||||||
|
enum VZAppRuntime {
|
||||||
|
/// Signal sources have to outlive the call that creates them or they are
|
||||||
|
/// cancelled on deinit and the signals go nowhere.
|
||||||
|
private static var signalSources: [DispatchSourceSignal] = []
|
||||||
|
private static var isTerminating = false
|
||||||
|
|
||||||
|
/// Starts the run loop and runs `body` alongside it. Never returns.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - onSignal: Cleanup to perform on `SIGINT`/`SIGTERM` before exiting.
|
||||||
|
/// - body: The work to run. When it returns, the process exits zero.
|
||||||
|
static func run(
|
||||||
|
onSignal: @escaping @Sendable () async -> Void,
|
||||||
|
body: @escaping @Sendable () async -> Void
|
||||||
|
) -> Never {
|
||||||
|
let app = NSApplication.shared
|
||||||
|
// No Dock icon, no menu bar, no activation: this is a background agent
|
||||||
|
// that merely needs to be an application as far as the kernel is
|
||||||
|
// concerned.
|
||||||
|
app.setActivationPolicy(.prohibited)
|
||||||
|
|
||||||
|
for signalNumber in [SIGINT, SIGTERM] {
|
||||||
|
// DispatchSourceSignal only observes; the default disposition still
|
||||||
|
// kills the process unless it is ignored first.
|
||||||
|
signal(signalNumber, SIG_IGN)
|
||||||
|
let source = DispatchSource.makeSignalSource(signal: signalNumber, queue: .main)
|
||||||
|
source.setEventHandler {
|
||||||
|
Task { @MainActor in
|
||||||
|
guard !isTerminating else { return }
|
||||||
|
isTerminating = true
|
||||||
|
CLI.note("received signal; shutting down…")
|
||||||
|
await onSignal()
|
||||||
|
NSApp.terminate(nil)
|
||||||
|
exit(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
source.resume()
|
||||||
|
signalSources.append(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await body()
|
||||||
|
await MainActor.run {
|
||||||
|
NSApp.terminate(nil)
|
||||||
|
exit(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.run()
|
||||||
|
exit(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import Foundation
|
||||||
|
import RunnerCore
|
||||||
|
import RunnerHost
|
||||||
|
|
||||||
|
/// `gitea-macos-runner doctor` — verify the host before anything else.
|
||||||
|
///
|
||||||
|
/// Every check corresponds to a failure that would otherwise show up as an
|
||||||
|
/// opaque error deep inside a VM boot: wrong architecture, unsigned binary,
|
||||||
|
/// locked keychain, non-admin Gitea token, dead download URL. Run this first,
|
||||||
|
/// and again after `service install`.
|
||||||
|
struct DoctorCommand: AsyncParsableCommand {
|
||||||
|
static let configuration = CommandConfiguration(
|
||||||
|
commandName: "doctor",
|
||||||
|
abstract: "Check that this host can build and run macOS guests."
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptionGroup var options: GlobalOptions
|
||||||
|
|
||||||
|
/// Emit machine-readable JSON instead of aligned text.
|
||||||
|
@Flag(name: .long, help: "Emit results as JSON.")
|
||||||
|
var json: Bool = false
|
||||||
|
|
||||||
|
/// By default `doctor` exits non-zero when any check fails, so it can gate a
|
||||||
|
/// setup script. This makes it always exit zero.
|
||||||
|
@Flag(name: .customLong("no-fail"), help: "Exit zero even when checks fail.")
|
||||||
|
var noFail: Bool = false
|
||||||
|
|
||||||
|
func run() async throws {
|
||||||
|
// Deliberately does not use options.loadConfig(): a broken or missing
|
||||||
|
// config is exactly the state doctor exists to diagnose, so it is
|
||||||
|
// reported as a check rather than thrown as an error.
|
||||||
|
let checks = await Doctor.runChecks(configPath: options.configPath)
|
||||||
|
|
||||||
|
if json {
|
||||||
|
let payload: [[String: Any]] = checks.map { check in
|
||||||
|
var entry: [String: Any] = [
|
||||||
|
"name": check.name,
|
||||||
|
"result": check.result.label,
|
||||||
|
"detail": check.detail,
|
||||||
|
"blocking": check.isBlocking,
|
||||||
|
]
|
||||||
|
if let remediation = check.remediation {
|
||||||
|
entry["remediation"] = remediation
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
let data = try JSONSerialization.data(
|
||||||
|
withJSONObject: payload,
|
||||||
|
options: [.prettyPrinted, .sortedKeys]
|
||||||
|
)
|
||||||
|
print(String(data: data, encoding: .utf8) ?? "[]")
|
||||||
|
} else {
|
||||||
|
print(Doctor.format(checks))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !noFail, checks.contains(where: \.isBlocking) {
|
||||||
|
throw ExitCode(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import Foundation
|
||||||
|
import RunnerCore
|
||||||
|
import RunnerHost
|
||||||
|
|
||||||
|
/// `gitea-macos-runner image …` — manage base VM images.
|
||||||
|
///
|
||||||
|
/// A base image is installed and provisioned once and then cloned per job.
|
||||||
|
/// Building one takes the better part of an hour, most of it downloading a
|
||||||
|
/// ~15 GB IPSW; cloning one takes milliseconds.
|
||||||
|
struct ImageCommand: AsyncParsableCommand {
|
||||||
|
static let configuration = CommandConfiguration(
|
||||||
|
commandName: "image",
|
||||||
|
abstract: "Build, list, provision, and delete base VM images.",
|
||||||
|
subcommands: [Build.self, List.self, Delete.self, Provision.self]
|
||||||
|
)
|
||||||
|
|
||||||
|
/// `image build` — install macOS from an IPSW and provision it.
|
||||||
|
struct Build: AsyncParsableCommand {
|
||||||
|
static let configuration = CommandConfiguration(
|
||||||
|
commandName: "build",
|
||||||
|
abstract: "Install macOS into a new base image and provision it.",
|
||||||
|
discussion: """
|
||||||
|
Downloads the latest supported restore image unless --ipsw is given, \
|
||||||
|
installs it, automates Setup Assistant, then installs Node.js and the \
|
||||||
|
gitea-runner binary over SSH. The guest must be macOS 27 or newer for \
|
||||||
|
unattended Setup Assistant automation to work.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptionGroup var options: GlobalOptions
|
||||||
|
|
||||||
|
/// Image name under `<storeDir>/images/`.
|
||||||
|
@Option(name: .long, help: "Image name.")
|
||||||
|
var name: String = "default"
|
||||||
|
|
||||||
|
/// A local `.ipsw`; omit to download the latest supported image.
|
||||||
|
@Option(name: .long, help: "Path to a local .ipsw (default: download the latest supported).")
|
||||||
|
var ipsw: String?
|
||||||
|
|
||||||
|
/// Nominal guest disk size, overriding `guest.diskGB`.
|
||||||
|
@Option(name: .customLong("disk-gb"), help: "Guest disk size in GB (overrides config).")
|
||||||
|
var diskGB: Int?
|
||||||
|
|
||||||
|
func run() async throws {
|
||||||
|
CLI.bootstrapLogging(verbose: options.verbose)
|
||||||
|
var config = try options.loadConfig()
|
||||||
|
if let diskGB {
|
||||||
|
config.guest.diskGB = diskGB
|
||||||
|
}
|
||||||
|
|
||||||
|
let store = VMStore(config: config)
|
||||||
|
try store.ensureLayout()
|
||||||
|
|
||||||
|
if try store.image(named: name) != nil {
|
||||||
|
throw ValidationError(
|
||||||
|
"image '\(name)' already exists — delete it first with `image delete \(name)`"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
try store.ensureFreeSpace(minGB: max(config.storage.minFreeDiskGB, 40))
|
||||||
|
|
||||||
|
CLI.note("building image '\(name)' (this takes a while; the IPSW alone is ~15 GB)")
|
||||||
|
|
||||||
|
let printer = ProgressPrinter()
|
||||||
|
let builder = ImageBuilder(store: store)
|
||||||
|
let imageName = name
|
||||||
|
let ipswPath = ipsw
|
||||||
|
let frozenConfig = config
|
||||||
|
|
||||||
|
// `image build` runs `VZMacOSInstaller` and then boots the guest, so
|
||||||
|
// it needs the same `NSApplication` main run loop `daemon` and
|
||||||
|
// `vm boot` do — without it Virtualization.framework's callbacks are
|
||||||
|
// never serviced and the install hangs. See `VZAppRuntime`.
|
||||||
|
await VZAppRuntime.run(
|
||||||
|
onSignal: {},
|
||||||
|
body: {
|
||||||
|
do {
|
||||||
|
try await builder.build(
|
||||||
|
name: imageName,
|
||||||
|
ipswPath: ipswPath,
|
||||||
|
config: frozenConfig,
|
||||||
|
progress: { stage in printer.update(ImageCommand.describe(stage)) }
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
printer.finish()
|
||||||
|
CLI.error("\(error)")
|
||||||
|
// Fully qualified: inside a ParsableCommand a bare `exit`
|
||||||
|
// resolves to ParsableCommand.exit(withError:).
|
||||||
|
await MainActor.run { Foundation.exit(1) }
|
||||||
|
}
|
||||||
|
printer.finish("done")
|
||||||
|
|
||||||
|
print("built image '\(imageName)'")
|
||||||
|
print("next: gitea-macos-runner vm boot --image \(imageName)")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `image list` — show base images and whether they are provisioned.
|
||||||
|
struct List: AsyncParsableCommand {
|
||||||
|
static let configuration = CommandConfiguration(
|
||||||
|
commandName: "list",
|
||||||
|
abstract: "List base images."
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptionGroup var options: GlobalOptions
|
||||||
|
|
||||||
|
func run() async throws {
|
||||||
|
let config = try options.loadConfig()
|
||||||
|
let store = VMStore(config: config)
|
||||||
|
try store.ensureLayout()
|
||||||
|
|
||||||
|
let names = try store.listImages()
|
||||||
|
guard !names.isEmpty else {
|
||||||
|
print("no images (build one with `gitea-macos-runner image build`)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
print("NAME MACOS PROVISIONED DISK SIZE")
|
||||||
|
for name in names {
|
||||||
|
guard let bundle = try store.image(named: name) else { continue }
|
||||||
|
let bundleConfig = try? bundle.loadConfig()
|
||||||
|
let size = (try? bundle.diskUsageBytes()).map(CLI.formatBytes) ?? "-"
|
||||||
|
print(
|
||||||
|
pad(name, 20)
|
||||||
|
+ pad(bundleConfig?.macOSVersion ?? "-", 12)
|
||||||
|
+ pad((bundleConfig?.provisioned ?? false) ? "yes" : "no", 13)
|
||||||
|
+ pad(bundleConfig?.diskFormat.rawValue ?? "-", 11)
|
||||||
|
+ size
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pad(_ value: String, _ width: Int) -> String {
|
||||||
|
value.count >= width
|
||||||
|
? value + " "
|
||||||
|
: value + String(repeating: " ", count: width - value.count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `image delete NAME` — remove a base image.
|
||||||
|
struct Delete: AsyncParsableCommand {
|
||||||
|
static let configuration = CommandConfiguration(
|
||||||
|
commandName: "delete",
|
||||||
|
abstract: "Delete a base image and its disk."
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptionGroup var options: GlobalOptions
|
||||||
|
|
||||||
|
/// Image name.
|
||||||
|
@Argument(help: "Image name.")
|
||||||
|
var name: String
|
||||||
|
|
||||||
|
/// Skip the confirmation prompt.
|
||||||
|
@Flag(name: .shortAndLong, help: "Do not prompt for confirmation.")
|
||||||
|
var force: Bool = false
|
||||||
|
|
||||||
|
func run() async throws {
|
||||||
|
let config = try options.loadConfig()
|
||||||
|
let store = VMStore(config: config)
|
||||||
|
|
||||||
|
guard let bundle = try store.image(named: name) else {
|
||||||
|
throw ValidationError("no image named '\(name)'")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !force {
|
||||||
|
let size = (try? bundle.diskUsageBytes()).map(CLI.formatBytes) ?? "unknown size"
|
||||||
|
guard CLI.confirm("delete image '\(name)' (\(size))?") else {
|
||||||
|
print("cancelled")
|
||||||
|
throw ExitCode(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try store.deleteImage(named: name)
|
||||||
|
print("deleted image '\(name)'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `image provision NAME` — re-run guest provisioning on an existing image.
|
||||||
|
///
|
||||||
|
/// Exists so that bumping the `gitea-runner` version, or adding Xcode, does
|
||||||
|
/// not require reinstalling macOS.
|
||||||
|
struct Provision: AsyncParsableCommand {
|
||||||
|
static let configuration = CommandConfiguration(
|
||||||
|
commandName: "provision",
|
||||||
|
abstract: "Re-run guest provisioning against an existing image.",
|
||||||
|
discussion: """
|
||||||
|
Boots the BASE image bundle itself — not a clone — runs provisioning, and \
|
||||||
|
shuts it down. This deliberately mutates the golden image in place, which \
|
||||||
|
is the point: every clone made afterwards inherits the change. Nothing \
|
||||||
|
else may be using the image while this runs, so stop the daemon first.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptionGroup var options: GlobalOptions
|
||||||
|
|
||||||
|
/// Image name.
|
||||||
|
@Argument(help: "Image name.")
|
||||||
|
var name: String
|
||||||
|
|
||||||
|
/// Optional Xcode `.xip` to install into the guest. Adds tens of
|
||||||
|
/// gigabytes; omitted by default.
|
||||||
|
@Option(name: .customLong("xcode-xip"), help: "Path to an Xcode .xip to install into the guest.")
|
||||||
|
var xcodeXIP: String?
|
||||||
|
|
||||||
|
func run() async throws {
|
||||||
|
CLI.bootstrapLogging(verbose: options.verbose)
|
||||||
|
let config = try options.loadConfig()
|
||||||
|
let store = VMStore(config: config)
|
||||||
|
|
||||||
|
guard try store.image(named: name) != nil else {
|
||||||
|
throw ValidationError("no image named '\(name)'")
|
||||||
|
}
|
||||||
|
if let xcodeXIP, !FileManager.default.fileExists(atPath: RunnerConfig.expandTilde(xcodeXIP)) {
|
||||||
|
throw ValidationError("no file at \(RunnerConfig.expandTilde(xcodeXIP))")
|
||||||
|
}
|
||||||
|
|
||||||
|
CLI.note("provisioning base image '\(name)' in place — stop the daemon before doing this")
|
||||||
|
|
||||||
|
let printer = ProgressPrinter()
|
||||||
|
let builder = ImageBuilder(store: store)
|
||||||
|
let imageName = name
|
||||||
|
let frozenConfig = config
|
||||||
|
let xipPath = xcodeXIP.map(RunnerConfig.expandTilde)
|
||||||
|
|
||||||
|
// Boots the image to run provision.sh in it, so it needs the run
|
||||||
|
// loop for exactly the reason `image build` does.
|
||||||
|
await VZAppRuntime.run(
|
||||||
|
onSignal: {},
|
||||||
|
body: {
|
||||||
|
do {
|
||||||
|
try await builder.reprovision(
|
||||||
|
name: imageName,
|
||||||
|
config: frozenConfig,
|
||||||
|
xcodeXIPPath: xipPath,
|
||||||
|
progress: { stage in printer.update(ImageCommand.describe(stage)) }
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
printer.finish()
|
||||||
|
CLI.error("\(error)")
|
||||||
|
// Fully qualified: inside a ParsableCommand a bare `exit`
|
||||||
|
// resolves to ParsableCommand.exit(withError:).
|
||||||
|
await MainActor.run { Foundation.exit(1) }
|
||||||
|
}
|
||||||
|
printer.finish("done")
|
||||||
|
print("provisioned image '\(imageName)'")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders a build stage as one status line.
|
||||||
|
static func describe(_ stage: ImageBuildStage) -> String {
|
||||||
|
switch stage {
|
||||||
|
case .downloadingIPSW(let fraction):
|
||||||
|
return "downloading IPSW " + CLI.progressBar(fraction)
|
||||||
|
case .preparing:
|
||||||
|
return "preparing"
|
||||||
|
case .creatingBundle:
|
||||||
|
return "creating bundle"
|
||||||
|
case .installing(let fraction):
|
||||||
|
return "installing macOS " + CLI.progressBar(fraction)
|
||||||
|
case .firstBoot:
|
||||||
|
return "first boot (Setup Assistant)"
|
||||||
|
case .provisioning(let step):
|
||||||
|
return "provisioning: \(step)"
|
||||||
|
case .finalizing:
|
||||||
|
return "finalizing"
|
||||||
|
case .done:
|
||||||
|
return "done"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import Foundation
|
||||||
|
import RunnerCore
|
||||||
|
import RunnerHost
|
||||||
|
|
||||||
|
/// `gitea-macos-runner service …` — manage the `launchd` LaunchAgent.
|
||||||
|
///
|
||||||
|
/// - Important: This installs a **LaunchAgent** in the logged-in user's session,
|
||||||
|
/// never a LaunchDaemon. Virtualization needs a GUI session, and macOS 15+
|
||||||
|
/// additionally needs an unlocked `login.keychain` to start a VM — neither of
|
||||||
|
/// which exists in the system context. The host should be set to log in
|
||||||
|
/// automatically.
|
||||||
|
struct ServiceCommand: AsyncParsableCommand {
|
||||||
|
static let configuration = CommandConfiguration(
|
||||||
|
commandName: "service",
|
||||||
|
abstract: "Install, remove, or inspect the launchd LaunchAgent.",
|
||||||
|
subcommands: [Install.self, Uninstall.self, Status.self]
|
||||||
|
)
|
||||||
|
|
||||||
|
/// `service install` — write the plist and load the job.
|
||||||
|
struct Install: AsyncParsableCommand {
|
||||||
|
static let configuration = CommandConfiguration(
|
||||||
|
commandName: "install",
|
||||||
|
abstract: "Write ~/Library/LaunchAgents/xyz.blakeslee.gitea-macos-runner.plist and load it.",
|
||||||
|
discussion: """
|
||||||
|
Points the agent at the installed, signed .app bundle — not at a bare \
|
||||||
|
binary. The com.apple.security.virtualization entitlement only survives \
|
||||||
|
on the signed bundle, so a daemon started from .build/ cannot start VMs.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptionGroup var options: GlobalOptions
|
||||||
|
|
||||||
|
/// Path to the installed executable inside the signed `.app`.
|
||||||
|
@Option(name: .long, help: "Path to the installed executable (default: ~/Applications/GiteaMacosRunner.app/Contents/MacOS/gitea-macos-runner).")
|
||||||
|
var executable: String?
|
||||||
|
|
||||||
|
func run() async throws {
|
||||||
|
let executablePath = executable ?? LaunchdService.defaultExecutablePath
|
||||||
|
|
||||||
|
// Only pass --config when it is not the default; a plist that
|
||||||
|
// hard-codes the default path is one more thing to keep in sync.
|
||||||
|
let configPath = options.configPath == RunnerConfig.defaultPath ? nil : options.configPath
|
||||||
|
|
||||||
|
if (try? options.loadConfig()) == nil {
|
||||||
|
CLI.note("warning: \(RunnerConfig.expandTilde(options.configPath)) is missing or invalid; the agent will fail to start until it is fixed")
|
||||||
|
}
|
||||||
|
|
||||||
|
try LaunchdService.install(executablePath: executablePath, configPath: configPath)
|
||||||
|
|
||||||
|
print("installed \(LaunchdService.agentPlistURL.path)")
|
||||||
|
print("program: \(RunnerConfig.expandTilde(executablePath)) daemon")
|
||||||
|
print("logs: \(LaunchdService.logDirectoryURL.path)")
|
||||||
|
print("")
|
||||||
|
print("check it with: gitea-macos-runner service status")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `service uninstall` — unload and remove the plist.
|
||||||
|
struct Uninstall: AsyncParsableCommand {
|
||||||
|
static let configuration = CommandConfiguration(
|
||||||
|
commandName: "uninstall",
|
||||||
|
abstract: "Unload the LaunchAgent and remove its plist."
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptionGroup var options: GlobalOptions
|
||||||
|
|
||||||
|
func run() async throws {
|
||||||
|
let path = LaunchdService.agentPlistURL.path
|
||||||
|
let existed = FileManager.default.fileExists(atPath: path)
|
||||||
|
try LaunchdService.uninstall()
|
||||||
|
print(existed ? "removed \(path)" : "not installed (\(path))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `service status` — report whether the agent is installed and running.
|
||||||
|
struct Status: AsyncParsableCommand {
|
||||||
|
static let configuration = CommandConfiguration(
|
||||||
|
commandName: "status",
|
||||||
|
abstract: "Report LaunchAgent installation and run state."
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptionGroup var options: GlobalOptions
|
||||||
|
|
||||||
|
func run() async throws {
|
||||||
|
let status = try LaunchdService.status()
|
||||||
|
|
||||||
|
print("label: \(LaunchdService.label)")
|
||||||
|
print("plist: \(status.plistPath)")
|
||||||
|
print("installed: \(status.installed ? "yes" : "no")")
|
||||||
|
print("loaded: \(status.loaded ? "yes" : "no")")
|
||||||
|
if let pid = status.pid {
|
||||||
|
print("pid: \(pid)")
|
||||||
|
}
|
||||||
|
if let lastExitStatus = status.lastExitStatus {
|
||||||
|
print("last exit: \(lastExitStatus)")
|
||||||
|
}
|
||||||
|
print("logs: \(LaunchdService.logDirectoryURL.path)")
|
||||||
|
|
||||||
|
if status.installed, !status.loaded {
|
||||||
|
print("")
|
||||||
|
CLI.note("installed but not loaded — reinstall with `service install`, or check the logs above")
|
||||||
|
throw ExitCode(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import Foundation
|
||||||
|
import RunnerCore
|
||||||
|
import RunnerHost
|
||||||
|
|
||||||
|
/// `gitea-macos-runner vm …` — debugging helpers that operate on VMs directly,
|
||||||
|
/// without any Gitea involvement.
|
||||||
|
struct VMCommand: AsyncParsableCommand {
|
||||||
|
static let configuration = CommandConfiguration(
|
||||||
|
commandName: "vm",
|
||||||
|
abstract: "Boot and inspect VMs directly (debugging).",
|
||||||
|
subcommands: [Boot.self, List.self]
|
||||||
|
)
|
||||||
|
|
||||||
|
/// `vm boot --image NAME` — clone an image, boot it, print its IP, wait.
|
||||||
|
///
|
||||||
|
/// The fastest way to answer "is the image itself broken, or is it the
|
||||||
|
/// Gitea integration?". Clones the image onto slot 0's MAC, boots it, waits
|
||||||
|
/// for a DHCP lease, prints the address and an `ssh` line, then blocks until
|
||||||
|
/// Ctrl-C — at which point the VM is stopped and the clone deleted, exactly
|
||||||
|
/// as the daemon would.
|
||||||
|
struct Boot: AsyncParsableCommand {
|
||||||
|
static let configuration = CommandConfiguration(
|
||||||
|
commandName: "boot",
|
||||||
|
abstract: "Clone an image, boot it, print its IP, and wait for Ctrl-C."
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptionGroup var options: GlobalOptions
|
||||||
|
|
||||||
|
/// Base image to clone.
|
||||||
|
@Option(name: .long, help: "Base image to clone.")
|
||||||
|
var image: String = "default"
|
||||||
|
|
||||||
|
/// Which slot's persistent MAC to use.
|
||||||
|
@Option(name: .long, help: "Slot index whose persistent MAC the clone should use.")
|
||||||
|
var slot: Int = 0
|
||||||
|
|
||||||
|
/// Leave the clone on disk after exit, for post-mortem inspection.
|
||||||
|
@Flag(name: .long, help: "Do not delete the clone on exit.")
|
||||||
|
var keep: Bool = false
|
||||||
|
|
||||||
|
func run() async throws {
|
||||||
|
CLI.bootstrapLogging(verbose: options.verbose)
|
||||||
|
let config = try options.loadConfig()
|
||||||
|
let store = VMStore(config: config)
|
||||||
|
try store.ensureLayout()
|
||||||
|
|
||||||
|
guard try store.image(named: image) != nil else {
|
||||||
|
throw ValidationError("no image named '\(image)'")
|
||||||
|
}
|
||||||
|
try store.ensureFreeSpace(minGB: config.storage.minFreeDiskGB)
|
||||||
|
|
||||||
|
let session = BootSession(store: store, keepClone: keep)
|
||||||
|
let slotIndex = slot
|
||||||
|
let imageName = image
|
||||||
|
let bootTimeout = Duration.seconds(max(30, config.scheduler.bootTimeoutSeconds))
|
||||||
|
let username = config.guest.username
|
||||||
|
|
||||||
|
await VZAppRuntime.run(
|
||||||
|
onSignal: { await session.teardown() },
|
||||||
|
body: {
|
||||||
|
do {
|
||||||
|
let mac = try store.macAddress(
|
||||||
|
forSlot: slotIndex,
|
||||||
|
slotCount: RunnerConfig.SchedulerSection.hardMaxConcurrentVMs
|
||||||
|
)
|
||||||
|
let bundle = try store.cloneImage(named: imageName, slotMAC: mac)
|
||||||
|
let instance = try VMInstance(bundle: bundle, label: "vm-boot")
|
||||||
|
await session.adopt(bundle: bundle, instance: instance)
|
||||||
|
|
||||||
|
CLI.note("booting clone \(bundle.name) (mac \(mac))…")
|
||||||
|
try await instance.start()
|
||||||
|
|
||||||
|
let ip = try await VMCommand.waitForLease(mac: mac, timeout: bootTimeout)
|
||||||
|
print("ip: \(ip)")
|
||||||
|
print("ssh: ssh \(username)@\(ip)")
|
||||||
|
print("")
|
||||||
|
CLI.note("press Ctrl-C to stop the VM and delete the clone")
|
||||||
|
|
||||||
|
// Whichever happens first: the guest shuts itself down,
|
||||||
|
// or the operator interrupts (handled by onSignal).
|
||||||
|
let reason = await instance.waitUntilStopped()
|
||||||
|
CLI.note("guest stopped: \(reason)")
|
||||||
|
await session.teardown()
|
||||||
|
} catch {
|
||||||
|
CLI.error("\(error)")
|
||||||
|
await session.teardown()
|
||||||
|
// Fully qualified: inside a ParsableCommand a bare `exit`
|
||||||
|
// resolves to ParsableCommand.exit(withError:).
|
||||||
|
await MainActor.run { Foundation.exit(1) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `vm list` — show ephemeral clones currently on disk.
|
||||||
|
///
|
||||||
|
/// Under normal operation this is empty between jobs; anything listed after
|
||||||
|
/// the daemon has settled is an orphan from an unclean shutdown.
|
||||||
|
struct List: AsyncParsableCommand {
|
||||||
|
static let configuration = CommandConfiguration(
|
||||||
|
commandName: "list",
|
||||||
|
abstract: "List ephemeral VM clones on disk."
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptionGroup var options: GlobalOptions
|
||||||
|
|
||||||
|
func run() async throws {
|
||||||
|
let config = try options.loadConfig()
|
||||||
|
let store = VMStore(config: config)
|
||||||
|
try store.ensureLayout()
|
||||||
|
|
||||||
|
let clones = try store.listClones()
|
||||||
|
guard !clones.isEmpty else {
|
||||||
|
print("no ephemeral clones on disk")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let leases = DHCPLeaseParser.parseFile()
|
||||||
|
print("CLONE MAC IP SIZE")
|
||||||
|
for clone in clones {
|
||||||
|
let bundleConfig = try? clone.loadConfig()
|
||||||
|
let mac = bundleConfig?.macAddress ?? "-"
|
||||||
|
let ip = bundleConfig.flatMap { DHCPLeaseParser.ipAddress(forMAC: $0.macAddress, in: leases) } ?? "-"
|
||||||
|
let size = (try? clone.diskUsageBytes()).map(CLI.formatBytes) ?? "-"
|
||||||
|
print(pad(clone.name, 31) + pad(mac, 19) + pad(ip, 17) + size)
|
||||||
|
}
|
||||||
|
print("")
|
||||||
|
CLI.note("clones left behind after the daemon has settled are orphans; `purge` happens at daemon start")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pad(_ value: String, _ width: Int) -> String {
|
||||||
|
value.count >= width
|
||||||
|
? value + " "
|
||||||
|
: value + String(repeating: " ", count: width - value.count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Polls `/var/db/dhcpd_leases` for a MAC, as the orchestrator does.
|
||||||
|
static func waitForLease(mac: String, timeout: Duration) async throws -> String {
|
||||||
|
let deadline = Date().addingTimeInterval(
|
||||||
|
TimeInterval(timeout.components.seconds)
|
||||||
|
)
|
||||||
|
while Date() < deadline {
|
||||||
|
if let ip = DHCPLeaseParser.ipAddress(forMAC: mac, in: DHCPLeaseParser.parseFile()) {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
try await Task.sleep(for: .seconds(2))
|
||||||
|
}
|
||||||
|
throw CoreError.timeout("dhcp lease for \(mac)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holds the VM and clone `vm boot` created, so the signal handler can tear them
|
||||||
|
/// down from outside the task that made them.
|
||||||
|
actor BootSession {
|
||||||
|
private let store: VMStore
|
||||||
|
private let keepClone: Bool
|
||||||
|
private var bundle: VMBundle?
|
||||||
|
private var instance: VMInstance?
|
||||||
|
private var finished = false
|
||||||
|
|
||||||
|
init(store: VMStore, keepClone: Bool) {
|
||||||
|
self.store = store
|
||||||
|
self.keepClone = keepClone
|
||||||
|
}
|
||||||
|
|
||||||
|
func adopt(bundle: VMBundle, instance: VMInstance) {
|
||||||
|
self.bundle = bundle
|
||||||
|
self.instance = instance
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops the VM and removes the clone. Idempotent.
|
||||||
|
func teardown() async {
|
||||||
|
guard !finished else { return }
|
||||||
|
finished = true
|
||||||
|
|
||||||
|
if let instance {
|
||||||
|
_ = await instance.requestStopThenForce(gracePeriod: .seconds(30))
|
||||||
|
}
|
||||||
|
guard let bundle else { return }
|
||||||
|
|
||||||
|
if keepClone {
|
||||||
|
CLI.note("keeping clone at \(bundle.rootURL.path)")
|
||||||
|
} else {
|
||||||
|
do {
|
||||||
|
try store.deleteClone(bundle)
|
||||||
|
CLI.note("deleted clone \(bundle.name)")
|
||||||
|
} catch {
|
||||||
|
CLI.error("could not delete clone: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import Foundation
|
||||||
|
import Logging
|
||||||
|
import RunnerCore
|
||||||
|
|
||||||
|
/// Options every subcommand accepts.
|
||||||
|
struct GlobalOptions: ParsableArguments {
|
||||||
|
/// Path to `config.json`. Tilde-expanded.
|
||||||
|
@Option(name: [.customLong("config"), .customShort("c")],
|
||||||
|
help: "Path to config.json (default: ~/.config/gitea-macos-runner/config.json)")
|
||||||
|
var configPath: String = RunnerConfig.defaultPath
|
||||||
|
|
||||||
|
/// Emit debug-level logs.
|
||||||
|
@Flag(name: .long, help: "Verbose logging.")
|
||||||
|
var verbose: Bool = false
|
||||||
|
|
||||||
|
/// Loads and validates the configuration named by ``configPath``.
|
||||||
|
func loadConfig() throws -> RunnerConfig {
|
||||||
|
try RunnerConfig.load(from: configPath).validated()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Root command.
|
||||||
|
///
|
||||||
|
/// The tool is both the daemon and its own admin CLI: `daemon` is what
|
||||||
|
/// `launchd` starts, and everything else is operator-facing.
|
||||||
|
@main
|
||||||
|
struct GiteaMacOSRunner: AsyncParsableCommand {
|
||||||
|
static let configuration = CommandConfiguration(
|
||||||
|
commandName: "gitea-macos-runner",
|
||||||
|
abstract: "Run Gitea Actions macOS jobs in fresh, ephemeral Virtualization.framework VMs.",
|
||||||
|
discussion: """
|
||||||
|
Each queued job that matches this host's labels gets a brand-new macOS VM \
|
||||||
|
cloned from a base image, an ephemeral runner registered with Gitea, and a \
|
||||||
|
teardown as soon as the job finishes. Nothing is reused between jobs.
|
||||||
|
|
||||||
|
Start with `doctor` to verify the host, then `config init`, then \
|
||||||
|
`image build`, then `service install`.
|
||||||
|
""",
|
||||||
|
version: RunnerVersion.current,
|
||||||
|
subcommands: [
|
||||||
|
DaemonCommand.self,
|
||||||
|
ImageCommand.self,
|
||||||
|
VMCommand.self,
|
||||||
|
ServiceCommand.self,
|
||||||
|
DoctorCommand.self,
|
||||||
|
ConfigCommand.self,
|
||||||
|
],
|
||||||
|
defaultSubcommand: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared helpers for command bodies.
|
||||||
|
enum CLI {
|
||||||
|
/// Prints to stderr.
|
||||||
|
static func error(_ message: String) {
|
||||||
|
FileHandle.standardError.write(Data(("error: " + message + "\n").utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prints a note to stderr, so it does not pollute pipeable stdout.
|
||||||
|
static func note(_ message: String) {
|
||||||
|
FileHandle.standardError.write(Data((message + "\n").utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prints an "unimplemented" notice and exits non-zero.
|
||||||
|
static func unimplemented(_ what: String) throws -> Never {
|
||||||
|
error("\(what): unimplemented")
|
||||||
|
throw ExitCode(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Routes swift-log to stderr, leaving stdout for command output.
|
||||||
|
///
|
||||||
|
/// Only the first call has any effect: `LoggingSystem.bootstrap` traps when
|
||||||
|
/// called twice, and subcommands are free to call this independently.
|
||||||
|
static func bootstrapLogging(verbose: Bool) {
|
||||||
|
loggingBootstrap.once {
|
||||||
|
let level: Logger.Level = verbose ? .debug : .info
|
||||||
|
LoggingSystem.bootstrap { label in
|
||||||
|
var handler = StreamLogHandler.standardError(label: label)
|
||||||
|
handler.logLevel = level
|
||||||
|
return handler
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let loggingBootstrap = OnceFlag()
|
||||||
|
|
||||||
|
/// Asks a yes/no question on stderr. Answers `false` when stdin is not a
|
||||||
|
/// terminal, so a piped invocation never blocks forever.
|
||||||
|
static func confirm(_ question: String) -> Bool {
|
||||||
|
guard isatty(fileno(stdin)) == 1 else { return false }
|
||||||
|
FileHandle.standardError.write(Data((question + " [y/N] ").utf8))
|
||||||
|
guard let answer = readLine(strippingNewline: true)?.lowercased() else { return false }
|
||||||
|
return answer == "y" || answer == "yes"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats a byte count as a human-readable size.
|
||||||
|
static func formatBytes(_ bytes: Int64) -> String {
|
||||||
|
let units = ["B", "KB", "MB", "GB", "TB"]
|
||||||
|
var value = Double(bytes)
|
||||||
|
var unit = 0
|
||||||
|
while value >= 1024, unit < units.count - 1 {
|
||||||
|
value /= 1024
|
||||||
|
unit += 1
|
||||||
|
}
|
||||||
|
return unit == 0
|
||||||
|
? "\(Int(value)) \(units[unit])"
|
||||||
|
: String(format: "%.1f %@", value, units[unit])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders a fixed-width progress bar, e.g. `[####------] 40%`.
|
||||||
|
static func progressBar(_ fraction: Double, width: Int = 30) -> String {
|
||||||
|
let clamped = min(max(fraction, 0), 1)
|
||||||
|
let filled = Int((Double(width) * clamped).rounded())
|
||||||
|
let bar = String(repeating: "#", count: filled) + String(repeating: "-", count: width - filled)
|
||||||
|
return String(format: "[%@] %3d%%", bar, Int((clamped * 100).rounded()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A thread-safe "run this exactly once" latch.
|
||||||
|
final class OnceFlag: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var done = false
|
||||||
|
|
||||||
|
func once(_ body: () -> Void) {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
guard !done else { return }
|
||||||
|
done = true
|
||||||
|
body()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serializes progress output arriving from arbitrary threads and keeps it on a
|
||||||
|
/// single rewritten stderr line.
|
||||||
|
final class ProgressPrinter: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var lastLine = ""
|
||||||
|
|
||||||
|
/// Rewrites the current line.
|
||||||
|
func update(_ line: String) {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
guard line != lastLine else { return }
|
||||||
|
lastLine = line
|
||||||
|
let padding = String(repeating: " ", count: max(0, 78 - line.count))
|
||||||
|
FileHandle.standardError.write(Data(("\r" + line + padding).utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ends the line so subsequent output starts cleanly.
|
||||||
|
func finish(_ line: String? = nil) {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
if let line {
|
||||||
|
let padding = String(repeating: " ", count: max(0, 78 - line.count))
|
||||||
|
FileHandle.standardError.write(Data(("\r" + line + padding + "\n").utf8))
|
||||||
|
} else if !lastLine.isEmpty {
|
||||||
|
FileHandle.standardError.write(Data("\n".utf8))
|
||||||
|
}
|
||||||
|
lastLine = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+581
@@ -0,0 +1,581 @@
|
|||||||
|
# gitea-macos-runner — Design
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
`gitea-macos-runner` is a single-host daemon for an Apple Silicon Mac. It watches
|
||||||
|
a Gitea instance for queued Actions jobs that require macOS, and for each one it
|
||||||
|
boots a **fresh, ephemeral macOS VM** on Apple's Virtualization.framework,
|
||||||
|
registers a single-use runner inside it, lets the job run, and then destroys the
|
||||||
|
VM.
|
||||||
|
|
||||||
|
The design goal is that **no state survives a job**. Not a checkout, not a
|
||||||
|
keychain entry, not a `~/Library` mutation, not a leftover process. The guest
|
||||||
|
that runs job *N+1* is a byte-identical copy-on-write clone of the same base
|
||||||
|
image that job *N* started from. This is the property that a persistent
|
||||||
|
self-hosted Mac runner cannot offer, and it is the whole reason this tool exists.
|
||||||
|
|
||||||
|
Three constraints shape everything below:
|
||||||
|
|
||||||
|
1. **Apple's kernel allows at most two concurrent macOS guests per host.** Not a
|
||||||
|
policy, not a licence term we chose — a hard limit that surfaces as
|
||||||
|
`VZError.virtualMachineLimitExceeded` from `start()`. Concurrency is therefore
|
||||||
|
2, permanently, and the config value is clamped rather than trusted.
|
||||||
|
2. **Virtualization needs a GUI session and a signed bundle.** The daemon runs as
|
||||||
|
a LaunchAgent in a logged-in user session, from inside an ad-hoc-signed `.app`
|
||||||
|
carrying `com.apple.security.virtualization`.
|
||||||
|
3. **Gitea decides which job a runner claims, not us.** We supply capacity; the
|
||||||
|
server matches. Trying to pin a specific job to a specific VM would mean
|
||||||
|
reimplementing Gitea's matching rules, and would be wrong the moment they
|
||||||
|
change.
|
||||||
|
|
||||||
|
### Non-goals
|
||||||
|
|
||||||
|
* Multi-host scheduling. One daemon, one Mac, two slots.
|
||||||
|
* Container-based execution. Gitea's `host` schema runs jobs directly on the
|
||||||
|
guest; that is the point of having a real macOS VM.
|
||||||
|
* Bridged networking. NAT only — see §6.
|
||||||
|
* Guest reuse or warm pools. See §9 for why save/restore is deferred rather than
|
||||||
|
rejected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Component diagram
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────── Host (Apple Silicon Mac, macOS 26+) ─────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ LaunchAgent (user session, auto-login, login.keychain unlocked) │
|
||||||
|
│ └── GiteaMacosRunner.app (ad-hoc signed, com.apple.security.virtualization, LSUIElement) │
|
||||||
|
│ │ │
|
||||||
|
│ │ NSApplication(.prohibited).run() ── main thread, required by Virtualization │
|
||||||
|
│ │ │
|
||||||
|
│ ┌─────▼──────────────────────────── Orchestrator (actor) ────────────────────────────────┐ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ poll loop ──► GiteaClient.listQueuedJobs() ──► [WorkflowJob] │ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ │ ├──────► SchedulerCore.plan(...) ── PURE, no I/O ──► [SchedulerAction] │ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ │ ├──► bootVM(slot,jobHint) ──► VMStore.cloneImage ──► VMInstance.start │ │
|
||||||
|
│ │ │ │ │ │ │
|
||||||
|
│ │ │ │ ▼ │ │
|
||||||
|
│ │ │ │ DHCPLeaseParser(/var/db/…) │ │
|
||||||
|
│ │ │ │ │ │ │
|
||||||
|
│ │ │ │ ▼ │ │
|
||||||
|
│ │ │ │ SSHExecutor ──► gitea-runner │ │
|
||||||
|
│ │ │ │ register+daemon │ │
|
||||||
|
│ │ └──► teardownVM(slot,reason) ──► VMInstance.requestStopThenForce ──► deleteClone │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ reconcile loop ──► GiteaClient.listRunners / deleteRunner (sweep orphaned rows) │ │
|
||||||
|
│ └────────────────────────────────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ <storeDir>/ │
|
||||||
|
│ images/default/{disk.asif, nvram.bin, config.json} ← built once, provisioned, read-only │
|
||||||
|
│ vms/<uuid>/{disk.asif, nvram.bin, config.json} ← APFS CoW clones, destroyed per job │
|
||||||
|
│ ipsw/ ← downloaded restore images │
|
||||||
|
│ state.json ← the two persistent per-slot MACs │
|
||||||
|
│ │
|
||||||
|
│ ┌──── VM slot 0 (MAC A) ────┐ ┌──── VM slot 1 (MAC B) ────┐ ← at most 2, kernel-enforced │
|
||||||
|
│ │ macOS guest │ │ macOS guest │ │
|
||||||
|
│ │ gitea-runner --ephemeral │ │ gitea-runner --ephemeral │ │
|
||||||
|
│ │ node, git, bash │ │ node, git, bash │ │
|
||||||
|
│ └───────────┬───────────────┘ └───────────┬───────────────┘ │
|
||||||
|
└──────────────┼───────────────────────────────┼───────────────────────────────────────────────────┘
|
||||||
|
│ NAT (vmenet, bootpd) │
|
||||||
|
└───────────────┬───────────────┘
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ Gitea 1.25+ │
|
||||||
|
│ /api/v1/admin/… │
|
||||||
|
└─────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Module boundaries
|
||||||
|
|
||||||
|
| Target | Contains | Constraint |
|
||||||
|
|---|---|---|
|
||||||
|
| `RunnerCore` | Config, Gitea models + client, `LabelSet`, `DHCPLeaseParser`, `SSHExec`, `SchedulerCore` | **No `import Virtualization`.** Builds on Linux, so scheduling and parsing logic can be unit-tested anywhere. |
|
||||||
|
| `RunnerHost` | `VMBundle`, `VMStore`, `VZConfigFactory`, `VMInstance`, `IPSW`, `ImageBuilder`, `GuestProvisioner`, `Orchestrator`, `LaunchdService`, `Doctor` | macOS-only. Everything that touches the framework. |
|
||||||
|
| `gitea-macos-runner` | CLI + daemon entry point | Depends on both. |
|
||||||
|
|
||||||
|
The split is not cosmetic: `SchedulerCore` being pure and portable is what makes
|
||||||
|
the scheduling policy — the part most likely to have subtle bugs — testable
|
||||||
|
without a Mac, a VM, or a Gitea instance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Job lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
Gitea Orchestrator VMStore / VMInstance Guest
|
||||||
|
│ │ │ │
|
||||||
|
│◄── listQueuedJobs ────────┤ (every pollIntervalSeconds) │ │
|
||||||
|
├─── [job 4711, labels ─────► │ │
|
||||||
|
│ ["macos-arm64"]] │ │ │
|
||||||
|
│ ├── LabelSet.matches? ─────────┤ │
|
||||||
|
│ ├── SchedulerCore.plan ────────┤ │
|
||||||
|
│ │ → .bootVM(slot: 0, │ │
|
||||||
|
│ │ jobHint: 4711) │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ ├── ensureFreeSpace(minGB) ───►│ │
|
||||||
|
│ ├── cloneImage("default", ───►│ APFS CoW copy │
|
||||||
|
│ │ slotMAC: MAC-A) │ + rewrite config.json │
|
||||||
|
│ ├── VMInstance.start() ───────►│ ──── boot ───────────►│
|
||||||
|
│ │ │ │
|
||||||
|
│ ├── poll /var/db/dhcpd_leases ─┤◄─── DHCP request ──────┤
|
||||||
|
│ │ until MAC-A has an IP │ │
|
||||||
|
│ ├── waitForSSH(ip) ────────────┼───────────────────────►│
|
||||||
|
│ │ │ │
|
||||||
|
│ ├── uploadData(token, 0600) ───┼───────────────────────►│
|
||||||
|
│ ├── ssh: gitea-runner register ┼───────────────────────►│
|
||||||
|
│◄────────────────────── register (name=macos-vm-<uuid>, --ephemeral) ──────────────┤
|
||||||
|
│ ├── ssh: rm -f <tokenfile> │ │
|
||||||
|
│ ├── ssh: gitea-runner daemon ──┼───────────────────────►│
|
||||||
|
│ │ │ │
|
||||||
|
│◄────────────────────── poll for task ─────────────────────────────────────────────┤
|
||||||
|
├─── assign job 4711 ───────────────────────────────────────────────────────────────►
|
||||||
|
│ │ │ ...running... │
|
||||||
|
│◄────────────────────── job result, logs ──────────────────────────────────────────┤
|
||||||
|
├─── auto-deregister runner (server-enforced --ephemeral) ──────────────────────────►
|
||||||
|
│ │ │ daemon exits │
|
||||||
|
│ │◄─ SSH command returns ───────┼────────────────────────┤
|
||||||
|
│ ├── teardownVM(slot: 0) ──────►│ │
|
||||||
|
│ │ requestStopThenForce ──────┼───────────────────────►│ (halt)
|
||||||
|
│ │ deleteClone ──────────────►│ rm -rf vms/<uuid> │
|
||||||
|
│ ├── markIdle(slot: 0) │ │
|
||||||
|
```
|
||||||
|
|
||||||
|
Two details in that sequence carry more weight than their size suggests.
|
||||||
|
|
||||||
|
**The token goes through a file, not an argument.** `gitea-runner register` is
|
||||||
|
invoked with `--token-file <f>`, where `<f>` was written by `uploadData` with
|
||||||
|
mode `0600` and is `rm -f`'d in the same shell command. Passing `--token` would
|
||||||
|
put a fleet-wide credential into the guest's process table, visible to any
|
||||||
|
process the job spawns — and the job is arbitrary code from a repository.
|
||||||
|
|
||||||
|
**`--ephemeral`, not `--once`.** `--ephemeral` (Gitea 1.24+) is enforced *by the
|
||||||
|
server*: it hands this runner exactly one task and then deletes the registration.
|
||||||
|
`--once` is a runner-side convention only — the server still considers the runner
|
||||||
|
live, and a misbehaving or patched runner could claim more work. Since the whole
|
||||||
|
security story here rests on "one VM, one job", the enforcement has to live on
|
||||||
|
the side we don't hand to the job.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Image build pipeline
|
||||||
|
|
||||||
|
Base images are built once with `image build`, and every job clones one. The
|
||||||
|
build is slow (most of an hour, mostly a ~15 GB download); the clone is
|
||||||
|
milliseconds.
|
||||||
|
|
||||||
|
```
|
||||||
|
image build --name default [--ipsw PATH]
|
||||||
|
│
|
||||||
|
├─ 1. IPSWProvider.latestSupported() → CDN url + buildVersion
|
||||||
|
│ (VZMacOSRestoreImage.latestSupported returns a NETWORK url —
|
||||||
|
│ it cannot be handed to the installer)
|
||||||
|
├─ 2. IPSWProvider.download() → <storeDir>/ipsw/*.ipsw
|
||||||
|
├─ 3. IPSWProvider.load(localPath:) → VZMacOSRestoreImage
|
||||||
|
│ (resolveSymlinksInPath first; the framework rejects symlinks)
|
||||||
|
│
|
||||||
|
├─ 4. restoreImage.mostFeaturefulSupportedConfiguration
|
||||||
|
│ nil ⇒ this host cannot run this image. Fail loudly; do not guess.
|
||||||
|
│
|
||||||
|
├─ 5. createBundle()
|
||||||
|
│ hardwareModel.dataRepresentation → config.json
|
||||||
|
│ VZMacMachineIdentifier() (fresh) → config.json
|
||||||
|
│ VZMacAuxiliaryStorage(creatingStorageAt:hardwareModel:) → nvram.bin
|
||||||
|
│ disk: diskutil image create blank --fs none --format ASIF --size <N>G
|
||||||
|
│ └─ fallback: sparse RAW file (format recorded in config.json)
|
||||||
|
│
|
||||||
|
├─ 6. VZMacOSInstaller(virtualMachine:restoringFromImageAt:) on a STOPPED vm
|
||||||
|
│ KVO on installer.progress → percentage
|
||||||
|
│
|
||||||
|
├─ 7. first boot with Setup Assistant automation
|
||||||
|
│ #available(macOS 27.0, *):
|
||||||
|
│ VZMacGuestProvisioningOptions(username/password/fullName,
|
||||||
|
│ logsInAutomatically: true,
|
||||||
|
│ enablesRemoteLogin: true)
|
||||||
|
│ → VZMacOSVirtualMachineStartOptions.setGuestProvisioning(_:)
|
||||||
|
│ ⚠ an OLDER GUEST SILENTLY IGNORES THIS — no error, no account, no SSH
|
||||||
|
│
|
||||||
|
├─ 8. wait for DHCP lease (by MAC) → wait for SSH → GuestProvisioner
|
||||||
|
│ provision.sh (sudoers, no-sleep, no-Spotlight, maxfiles, known_hosts)
|
||||||
|
│ Node.js (official arm64 .pkg → installer -pkg) ← REQUIRED
|
||||||
|
│ verify git / bash / node
|
||||||
|
│ gitea-runner (host downloads asset → upload → chmod +x)
|
||||||
|
│ [optional] Xcode from a .xip
|
||||||
|
│
|
||||||
|
└─ 9. clean shutdown → config.provisioned = true ← only now is it clonable
|
||||||
|
```
|
||||||
|
|
||||||
|
### On step 7 and its failure mode
|
||||||
|
|
||||||
|
`VZMacGuestProvisioningOptions` needs **macOS 27 or newer on both the host and
|
||||||
|
the guest**. The host side is a compile/availability check we control. The guest
|
||||||
|
side is not: an older guest accepts the boot and simply ignores the options.
|
||||||
|
There is no error to catch. The observable symptom is that the VM boots, sits at
|
||||||
|
Setup Assistant forever, never requests a DHCP lease with a usable hostname, and
|
||||||
|
never answers SSH — so the build fails at step 8 with a timeout that says nothing
|
||||||
|
useful.
|
||||||
|
|
||||||
|
`firstBootAndProvision` therefore detects the timeout and reports it as an
|
||||||
|
explicit "guest is too old for unattended setup; supply a macOS 27+ IPSW"
|
||||||
|
failure. A `--manual-setup` flow that opens a window and lets a human click
|
||||||
|
through Setup Assistant once is **out of scope for v1** — deliberately, because a
|
||||||
|
GUI step in a tool whose whole purpose is unattended operation is a trap. It is
|
||||||
|
noted here so the omission is a decision rather than an oversight.
|
||||||
|
|
||||||
|
### On step 5's disk format
|
||||||
|
|
||||||
|
ASIF is preferred because it is sparse: a 64 GB nominal disk costs what the guest
|
||||||
|
actually writes, and it CoW-clones cleanly on APFS. `diskutil image create` is
|
||||||
|
shelled out to because there is no framework API for it. If that call fails for
|
||||||
|
any reason — older `diskutil`, unusual volume — a sparse RAW file is created
|
||||||
|
instead and the format is recorded in `config.json`, so `VZConfigFactory` attaches
|
||||||
|
the right file without re-probing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Scheduling semantics
|
||||||
|
|
||||||
|
`SchedulerCore.plan` is a pure function: `(state, queuedJobs, labels, maxVMs,
|
||||||
|
now, jobTimeout, bootTimeout) → (state', [action])`. It performs no I/O, reads no
|
||||||
|
clock, and is fully deterministic — which is what allows the entire scheduling
|
||||||
|
policy to be tested with a fixed `now` and a synthetic job list.
|
||||||
|
|
||||||
|
### Capacity, not assignment
|
||||||
|
|
||||||
|
This is the central idea and the easiest thing to get wrong.
|
||||||
|
|
||||||
|
A booted VM is **capacity**. It is not a promise to run a particular job. We see
|
||||||
|
job 4711 queued, we boot a VM, we register an ephemeral runner — and the *server*
|
||||||
|
then decides which queued job that runner claims. It may well claim job 4712
|
||||||
|
instead. That is fine and in fact preferable: Gitea's matching rules (labels,
|
||||||
|
repo permissions, ordering, priority) are its business, and any attempt to
|
||||||
|
predict them here would be a reimplementation that drifts out of sync.
|
||||||
|
|
||||||
|
The `jobHint` threaded through `SchedulerAction.bootVM` and
|
||||||
|
`SlotState.running` exists for exactly two purposes: log messages, and the dedup
|
||||||
|
ledger below. Nothing else may depend on it.
|
||||||
|
|
||||||
|
### Dedup by job id
|
||||||
|
|
||||||
|
`SchedulerState.dispatchedJobIDs` is a `Set<Int64>` of jobs that have already
|
||||||
|
caused a boot.
|
||||||
|
|
||||||
|
Without it, the loop is pathological. A VM takes tens of seconds to boot,
|
||||||
|
provision, and register. The poll interval is 5 seconds. So a single queued job
|
||||||
|
would still be queued on the next poll, and the next, and the next — triggering a
|
||||||
|
second boot, then exhausting the slot budget, all for one job.
|
||||||
|
|
||||||
|
The ledger is expired against reality rather than against a timer: any id no
|
||||||
|
longer appearing in the queued set is dropped. That way a slot freed by a
|
||||||
|
completed job can be re-earned by a genuinely new job, but a job that is *still*
|
||||||
|
waiting does not double-book.
|
||||||
|
|
||||||
|
### The cap
|
||||||
|
|
||||||
|
`maxVMs` is clamped to 2 in `plan`, and again in `RunnerConfig.validated()`. Both
|
||||||
|
places, because the kernel limit is not something a config file gets to
|
||||||
|
negotiate: a third `start()` raises `VZError.virtualMachineLimitExceeded`, which
|
||||||
|
`VMInstance.mapVZError` translates into `CoreError.vmLimitExceeded` and the
|
||||||
|
scheduler treats as transient back-pressure rather than a failure.
|
||||||
|
|
||||||
|
### Timeouts
|
||||||
|
|
||||||
|
* A slot in `.provisioning(since:)` longer than `bootTimeoutSeconds` (default
|
||||||
|
300) is torn down. Covers a guest that never gets a lease, never starts `sshd`,
|
||||||
|
or hangs in Setup Assistant.
|
||||||
|
* A slot in `.running(jobHint:since:)` longer than `jobTimeoutMinutes` (default
|
||||||
|
120) is torn down. Covers a job that hangs. This is comfortably below Gitea's
|
||||||
|
own `ABANDONED_JOB_TIMEOUT` (24 h), so our teardown always happens first and
|
||||||
|
the server sees a clean deregistration rather than an abandonment.
|
||||||
|
|
||||||
|
Teardown actions are emitted **before** boot actions in the returned list, so a
|
||||||
|
slot freed in one pass can be reused in that same pass.
|
||||||
|
|
||||||
|
### Reconcile
|
||||||
|
|
||||||
|
Every `reconcileIntervalSeconds` (default 300), the orchestrator lists runners and
|
||||||
|
deletes any that are:
|
||||||
|
|
||||||
|
* `ephemeral == true`, **and**
|
||||||
|
* `busy == false`, **and**
|
||||||
|
* `name` starts with our configured `namePrefix`, **and**
|
||||||
|
* not backed by a live VM in this process.
|
||||||
|
|
||||||
|
All four conditions, because deleting a live runner fails a running job. The loop
|
||||||
|
is deliberately conservative: a row we are unsure about is left alone, and will be
|
||||||
|
revisited in five minutes.
|
||||||
|
|
||||||
|
This loop is not optional housekeeping — it is load-bearing. See Verified Fact 7.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Security model
|
||||||
|
|
||||||
|
### The threat
|
||||||
|
|
||||||
|
A CI job is arbitrary code from a repository, running with the privileges of the
|
||||||
|
account it executes under. On a persistent self-hosted Mac runner, that code can
|
||||||
|
read every previous job's checkout, poison caches, install launch agents, and
|
||||||
|
harvest whatever credentials the machine has accumulated. Every subsequent job on
|
||||||
|
that host inherits the compromise.
|
||||||
|
|
||||||
|
### The mitigation: genuinely ephemeral guests
|
||||||
|
|
||||||
|
* **One job per VM, enforced server-side.** `--ephemeral` means Gitea hands the
|
||||||
|
runner exactly one task and then deletes the registration. A patched or
|
||||||
|
hijacked runner binary cannot ask for more work, because the server will not
|
||||||
|
give it any.
|
||||||
|
* **The VM is destroyed after that job.** Not reset, not cleaned — the clone
|
||||||
|
directory is `rm -rf`'d and the next job clones the base image afresh. There is
|
||||||
|
no path by which job *N* influences job *N+1* short of compromising the host.
|
||||||
|
* **The guest holds nothing worth stealing.** Its account credentials
|
||||||
|
(`admin`/`admin` by default) are meaningful only on a host-private NAT link to a
|
||||||
|
machine that is about to be deleted.
|
||||||
|
|
||||||
|
### The shared registration token
|
||||||
|
|
||||||
|
Registration tokens in Gitea are **reusable and scope-wide**, and minting a new
|
||||||
|
one for a scope **invalidates all prior tokens of that scope**. That makes
|
||||||
|
per-VM tokens actively harmful: generating one for each VM would break every
|
||||||
|
other runner registered against that scope, including ones on other hosts.
|
||||||
|
|
||||||
|
So the fleet shares one token. The mitigations are:
|
||||||
|
|
||||||
|
* It is written into the guest as a **file with mode `0600`**, never as a command
|
||||||
|
argument (arguments are world-readable via `ps`).
|
||||||
|
* It is **deleted immediately** after `gitea-runner register` consumes it, in the
|
||||||
|
same `&&` chain, before `gitea-runner daemon` starts and long before any job
|
||||||
|
code runs.
|
||||||
|
* It is a *registration* token, not an API token: it grants the ability to
|
||||||
|
register a runner, not to read repositories or act as a user.
|
||||||
|
|
||||||
|
The residual risk is real but bounded — a job that wins a race against `rm -f`
|
||||||
|
could register additional runners for that scope. The recommended deployment
|
||||||
|
seeds a fixed token server-side via `GITEA_RUNNER_REGISTRATION_TOKEN` so that
|
||||||
|
rotating it is a deliberate, coordinated act rather than an API call side effect.
|
||||||
|
|
||||||
|
### `:host` schema risk
|
||||||
|
|
||||||
|
Jobs run in `host` schema: directly on the guest OS, not in a container. That is
|
||||||
|
the point — a macOS job needs real macOS. But it means the job has full user-level
|
||||||
|
access to the guest, including `sudo` (which `provision.sh` makes passwordless,
|
||||||
|
because Xcode and `installer` need it). Everything above rests on the guest being
|
||||||
|
disposable and isolated, not on the job being constrained inside it.
|
||||||
|
|
||||||
|
### Host-side posture
|
||||||
|
|
||||||
|
* The daemon runs as a **LaunchAgent in a user session**, not as root. The
|
||||||
|
entitlement it carries (`com.apple.security.virtualization`) grants VM creation
|
||||||
|
and nothing else.
|
||||||
|
* Networking is **NAT**, not bridged. Guests can reach the LAN and Gitea, but are
|
||||||
|
not first-class hosts on it. Bridged networking would require the restricted
|
||||||
|
`com.apple.vm.networking` entitlement, which ad-hoc signing cannot grant — a
|
||||||
|
constraint that happens to align with what we want anyway.
|
||||||
|
* **SSH host keys are not verified.** The peer is a VM this process booted
|
||||||
|
moments ago on a link no other machine shares; pinning would break on every
|
||||||
|
clone and add nothing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Failure modes and recovery
|
||||||
|
|
||||||
|
| Failure | Detection | Recovery |
|
||||||
|
|---|---|---|
|
||||||
|
| Guest never gets a DHCP lease | `bootTimeout` in `waitForLease` | Teardown, slot recycled, retried next poll |
|
||||||
|
| Guest never answers SSH | `bootTimeout` in `waitForSSH` | Same |
|
||||||
|
| Job hangs | `jobTimeoutMinutes` | Teardown; Gitea reaps the task via its zombie sweep (~10–15 min) |
|
||||||
|
| VM dies uncleanly | Runner row left behind, task stuck Running | Reconcile loop deletes the row (§5); Gitea's zombie sweep handles the task |
|
||||||
|
| Daemon crashes with VMs live | Clones orphaned on disk | `purgeClones()` at startup, then one immediate reconcile pass |
|
||||||
|
| Third VM requested | `VZError.virtualMachineLimitExceeded` | Mapped to `CoreError.vmLimitExceeded`, treated as back-pressure |
|
||||||
|
| Disk fills | `ensureFreeSpace(minGB:)` before each clone | Boot refused, logged; jobs stay queued (safe — Gitea holds them ~24 h) |
|
||||||
|
| Gitea unreachable | Request error in the poll loop | Logged, retried next tick; no state change |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Configuration and operations summary
|
||||||
|
|
||||||
|
Config lives at `~/.config/gitea-macos-runner/config.json`; see
|
||||||
|
`Resources/config.example.json`. Order of operations for a new host:
|
||||||
|
|
||||||
|
```
|
||||||
|
gitea-macos-runner doctor # verify arch, macOS, entitlement, keychain, Gitea
|
||||||
|
gitea-macos-runner config init # write an annotated config
|
||||||
|
gitea-macos-runner image build # ~1 hour, mostly IPSW download
|
||||||
|
gitea-macos-runner vm boot # optional smoke test: boot a clone, print its IP
|
||||||
|
gitea-macos-runner service install # LaunchAgent, RunAtLoad + KeepAlive
|
||||||
|
gitea-macos-runner doctor # again, now that it runs from the signed .app
|
||||||
|
```
|
||||||
|
|
||||||
|
`doctor` exists because every one of its checks corresponds to a failure that
|
||||||
|
otherwise appears as an opaque error deep inside a VM boot. The most common by
|
||||||
|
far: running from `.build/` instead of the signed `.app`, so the entitlement is
|
||||||
|
absent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Future work
|
||||||
|
|
||||||
|
* **Save/restore for warm boots.** `VZVirtualMachine.saveMachineStateTo` (macOS
|
||||||
|
14+) could cut per-job boot from ~60 s to near-instant by restoring a snapshot
|
||||||
|
taken just after `gitea-runner` is ready. The blocker is that restore forbids
|
||||||
|
changing the MAC address or ECID, which collides with our per-slot MAC scheme
|
||||||
|
(§ Verified Fact 12) — a restored state would have to be captured per slot, and
|
||||||
|
the interaction with DHCP lease reuse needs care. Deferred, not rejected.
|
||||||
|
* **vsock control channel.** `VZVirtioSocketDeviceConfiguration` is already in the
|
||||||
|
VM configuration. Replacing SSH with a vsock agent would remove password auth,
|
||||||
|
the `waitForSSH` poll, and the Local Network privacy prompt entirely.
|
||||||
|
* **`--manual-setup`** for pre-macOS-27 guests (§4).
|
||||||
|
* **Image versioning / garbage collection** for multiple base images.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix: Verified Facts
|
||||||
|
|
||||||
|
Researched facts this design depends on, each with its consequence for the
|
||||||
|
implementation. Anyone changing the corresponding code should re-verify the fact
|
||||||
|
first.
|
||||||
|
|
||||||
|
**1. Job discovery is `GET /api/v1/admin/actions/jobs?status=queued` (Gitea
|
||||||
|
1.25+).** The `labels` field on a returned job is the workflow's `runs-on:`
|
||||||
|
value. The external status string `queued` maps to Gitea's internal
|
||||||
|
`StatusWaiting`, meaning "ready, waiting for a matching runner".
|
||||||
|
→ *Consequence:* the external string `waiting` means something entirely
|
||||||
|
different — the job is **blocked** on a dependency — and must **never** be
|
||||||
|
treated as schedulable. `WorkflowJob.isQueued` checks `status == "queued"` and
|
||||||
|
nothing else.
|
||||||
|
|
||||||
|
**2. A queued job waits for a matching runner up to `ABANDONED_JOB_TIMEOUT`
|
||||||
|
(default 24 h, swept every 6 h).**
|
||||||
|
→ *Consequence:* there is no urgency in the poll loop. A 5-second interval is for
|
||||||
|
responsiveness, not correctness; a daemon that is down for an hour loses nothing.
|
||||||
|
It also sets the ceiling that `jobTimeoutMinutes` (default 120) must stay well
|
||||||
|
under, so our teardown always precedes the server's abandonment.
|
||||||
|
|
||||||
|
**3. The runner binary is `gitea-runner` v3.x**, renamed from `act_runner` and
|
||||||
|
published from `gitea.com/gitea/runner`. The register-time flag `--ephemeral`
|
||||||
|
(Gitea 1.24+) is **server-enforced**: single job, then automatic deregistration.
|
||||||
|
`--once` is weaker — runner-side only.
|
||||||
|
→ *Consequence:* `--ephemeral` is mandatory and `--once` is never used. The
|
||||||
|
"one VM, one job" guarantee in §6 rests on the server enforcing it, not on the
|
||||||
|
runner cooperating. Download URLs and the binary name must reference
|
||||||
|
`gitea-runner`, not `act_runner`.
|
||||||
|
|
||||||
|
**4. Registration tokens are REUSABLE, and minting a new token for a scope
|
||||||
|
INVALIDATES prior tokens of that scope.** `POST
|
||||||
|
/api/v1/admin/actions/runners/registration-token` in practice returns the
|
||||||
|
existing active token. Seeding server-side via `GITEA_RUNNER_REGISTRATION_TOKEN`
|
||||||
|
is the recommended deployment.
|
||||||
|
→ *Consequence:* **never pre-generate a token per VM** — doing so would break
|
||||||
|
every other runner registered against that scope. One token is resolved once,
|
||||||
|
cached for the process lifetime, and shared by the fleet;
|
||||||
|
`fetchRegistrationTokenViaAPI` defaults to `false`.
|
||||||
|
|
||||||
|
**5. Label syntax is `name:schema`, schema defaults to `host`, and only BARE
|
||||||
|
names are stored server-side.** If the guest's runner `config.yaml` sets
|
||||||
|
`runner.labels`, it **silently overrides** `--labels` passed at registration.
|
||||||
|
→ *Consequence:* `LabelSet` matches bare names (`macos-arm64`) and only appends
|
||||||
|
`:host` when building the `register --labels` argument. The guest must **never**
|
||||||
|
ship a `config.yaml` containing labels — noted in both `GuestProvisioner` and
|
||||||
|
`Resources/provision.sh`, because the failure is silent: the runner registers
|
||||||
|
successfully and is simply never matched.
|
||||||
|
|
||||||
|
**6. Guest requirements: the `gitea-runner` binary, `node`, `git`, `bash`, and a
|
||||||
|
writable `$HOME`.** JavaScript actions such as `actions/checkout` spawn `node`
|
||||||
|
**directly**.
|
||||||
|
→ *Consequence:* Node.js installation is **not optional** and not a convenience —
|
||||||
|
without it essentially every real workflow fails at its first step.
|
||||||
|
`GuestProvisioner.verifyToolchain` fails the build rather than shipping an image
|
||||||
|
that will break at job time.
|
||||||
|
|
||||||
|
**7. An unclean VM death leaves both a runner row and a Running task behind.**
|
||||||
|
The task is reaped by Gitea's zombie sweep in roughly 10–15 minutes. The runner
|
||||||
|
row is swept only at midnight — and **never at all** if that runner claimed no
|
||||||
|
task. Rows are removed with `DELETE /api/v1/admin/actions/runners/{id}`.
|
||||||
|
→ *Consequence:* the reconcile loop (§5) is load-bearing, not housekeeping.
|
||||||
|
Without it, every crashed boot leaves a permanent phantom runner. It is also why
|
||||||
|
every VM registers under a **globally unique** name (`namePrefix` + UUID): that
|
||||||
|
uniqueness is what lets us look at a row and decide with certainty that it is
|
||||||
|
ours and unbacked.
|
||||||
|
|
||||||
|
**8. macOS guests are capped at 2 concurrent per host, enforced by the kernel.**
|
||||||
|
A third `start()` raises `VZError.virtualMachineLimitExceeded`.
|
||||||
|
→ *Consequence:* `maxConcurrentVMs` is hard-clamped to 2 in both
|
||||||
|
`RunnerConfig.validated()` and `SchedulerCore.plan`; the slot table is fixed-size;
|
||||||
|
and the error is mapped to `CoreError.vmLimitExceeded` and treated as transient
|
||||||
|
back-pressure rather than a failure.
|
||||||
|
|
||||||
|
**9. `VZMacGuestProvisioningOptions` (macOS 27+ host AND guest) automates Setup
|
||||||
|
Assistant**, including `enablesRemoteLogin` (SSH). Older guests **silently
|
||||||
|
ignore** it.
|
||||||
|
→ *Consequence:* the API is gated at `#available(macOS 27.0, *)`, and because the
|
||||||
|
guest-side failure produces no error, `firstBootAndProvision` must translate its
|
||||||
|
lease/SSH timeout into an explicit "guest too old" message rather than a bare
|
||||||
|
timeout. The `--manual-setup` fallback is out of v1 scope (§4).
|
||||||
|
|
||||||
|
**10. Headless Virtualization requires an `NSApplication` run loop with
|
||||||
|
`.prohibited` activation policy, inside a signed `.app` bundle** carrying
|
||||||
|
`com.apple.security.virtualization`. Ad-hoc signing (`codesign -s -`) suffices.
|
||||||
|
Bridged networking would additionally need a restricted entitlement; NAT does
|
||||||
|
not.
|
||||||
|
→ *Consequence:* `CommandDaemon` starts `NSApplication` and runs the orchestrator
|
||||||
|
in a detached `Task`. This applies to **every** command that starts a VM, not
|
||||||
|
just the daemon: `vm boot`, `image build`, and `image provision` all go through
|
||||||
|
the same `VZAppRuntime.run` host, since `VZMacOSInstaller` and the first-boot
|
||||||
|
provisioning pass need the run loop exactly as much as a job VM does. The
|
||||||
|
`Makefile` has `bundle` and `sign` targets and
|
||||||
|
`install` deliberately installs the bundle rather than the bare binary;
|
||||||
|
`Info.plist` sets `LSUIElement`; `Doctor` checks the entitlement on the running
|
||||||
|
binary because running from `.build/` is the most common setup failure.
|
||||||
|
Two packaging constraints follow from the same fact. The entitlements plist must
|
||||||
|
contain **no XML comments**: `plutil -lint` accepts them, but `codesign` hands
|
||||||
|
the file to AMFI's stricter parser, which fails with `AMFIUnserializeXML: syntax
|
||||||
|
error` and then signs the bundle with *zero* entitlements — a silent
|
||||||
|
downgrade that only surfaces as a failed VM start. And `bundle` must copy
|
||||||
|
`provision.sh`, `launchd.plist.template`, and `config.example.json` into
|
||||||
|
`Contents/Resources`, since `GuestProvisioner`, `LaunchdService`, and
|
||||||
|
`config init` look there before falling back to repo-relative paths; an installed
|
||||||
|
`.app` without them is a working binary with a broken `image build`,
|
||||||
|
`service install`, and `config init`.
|
||||||
|
|
||||||
|
**11. macOS 15+ requires an unlocked `login.keychain` to start a VM.**
|
||||||
|
→ *Consequence:* the service **must** be a LaunchAgent in the auto-logged-in
|
||||||
|
user's session, never a LaunchDaemon (which has no session and no unlocked
|
||||||
|
keychain). `LaunchdService` only ever writes to `~/Library/LaunchAgents`, and
|
||||||
|
`Doctor` probes with `security show-keychain-info login.keychain`.
|
||||||
|
|
||||||
|
**12. Guest IPs come from parsing `/var/db/dhcpd_leases`, keyed by MAC.**
|
||||||
|
`hw_address` lines carry a `1,` hardware-type prefix and octets that may lack
|
||||||
|
zero-padding (`aa:bb:c:dd:ee:ff`). Duplicate MACs occur; the newest lease wins.
|
||||||
|
macOS's DHCP lease time is 24 hours.
|
||||||
|
→ *Consequence:* `DHCPLeaseParser.normalizeMAC` must strip the prefix and
|
||||||
|
zero-pad, or lookups silently fail against `VZMACAddress.string`. And ephemeral
|
||||||
|
fleets must **not** randomize MACs per clone — a day of dead leases would
|
||||||
|
accumulate and exhaust the NAT subnet. Hence exactly two **persistent per-slot
|
||||||
|
MACs**, generated once with `VZMACAddress.randomLocallyAdministered()` and stored
|
||||||
|
in `state.json`.
|
||||||
|
|
||||||
|
**13. APFS copy-on-write cloning via `FileManager.copyItem` requires source and
|
||||||
|
destination on the same volume.** Clones grow as the guest writes.
|
||||||
|
→ *Consequence:* base images and ephemeral clones both live under `storeDir`, and
|
||||||
|
cloning is done **per file** rather than by copying a directory wholesale.
|
||||||
|
`ensureFreeSpace(minGB:)` runs before every clone, with a floor (default 20 GB)
|
||||||
|
well above one clone's nominal cost, because the apparent size and the real cost
|
||||||
|
diverge over a job's lifetime.
|
||||||
|
|
||||||
|
**14. The ASIF sparse disk format is created via `diskutil image create` (macOS
|
||||||
|
26+); RAW is the fallback.**
|
||||||
|
→ *Consequence:* `ImageBuilder.createDisk` shells out to
|
||||||
|
`/usr/sbin/diskutil image create blank --fs none --format ASIF --size <N>G <path>`
|
||||||
|
and falls back to a sparse RAW file, recording which was used in
|
||||||
|
`VMBundleConfig.diskFormat` so `VZConfigFactory` attaches the right file without
|
||||||
|
re-probing. This is also the reason the package's minimum platform is macOS 26.
|
||||||
|
|
||||||
|
**15. Save/restore (macOS 14+) could give near-instant warm boots, but forbids
|
||||||
|
changing the MAC address or ECID.**
|
||||||
|
→ *Consequence:* documented as future work (§9) rather than implemented. The
|
||||||
|
prohibition collides directly with the per-slot MAC scheme from Fact 12, so
|
||||||
|
adopting it would require per-slot saved states and a careful look at DHCP lease
|
||||||
|
reuse — not a drop-in optimization.
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# Security model
|
||||||
|
|
||||||
|
This document states what `gitea-macos-runner` protects, what it does not, and the operational
|
||||||
|
choices that follow. Read it before pointing the runner at an instance where untrusted code can
|
||||||
|
open a pull request.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Workflow code runs **as the guest admin user, with passwordless sudo, and with no container
|
||||||
|
isolation**. The only isolation boundary is the virtual machine, and that VM is destroyed after a
|
||||||
|
single job. This is the same trust posture as GitHub's hosted macOS runners: the job owns the
|
||||||
|
machine, and the machine is thrown away.
|
||||||
|
|
||||||
|
## Trust boundary
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ host Mac ─────────────────────────────────────────────┐
|
||||||
|
│ daemon, admin PAT, registration token, base images │ ← trusted
|
||||||
|
│ ┌─ ephemeral VM ─────────────────────────────────┐ │
|
||||||
|
│ │ gitea-runner + workflow code (root-capable) │ │ ← untrusted
|
||||||
|
│ └────────────────────────────────────────────────┘ │
|
||||||
|
└────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**The VM is the boundary.** Everything inside it is treated as untrusted and disposable;
|
||||||
|
everything outside it is trusted. A guest that escapes Virtualization.framework and compromises the
|
||||||
|
host is **out of scope** — the design assumes Apple's hypervisor holds. If your threat model
|
||||||
|
includes hypervisor escape, this tool is not sufficient on its own; isolate the host at the network
|
||||||
|
level and treat it as a machine that may be compromised.
|
||||||
|
|
||||||
|
## What runs where
|
||||||
|
|
||||||
|
- **In the guest:** the `gitea-runner` binary and every step of the workflow. The runner registers
|
||||||
|
with `--labels "macos-arm64:host"` — the `:host` execution mode means steps run directly on the
|
||||||
|
guest OS rather than inside a container. There is no second layer of isolation, by design;
|
||||||
|
container isolation is not meaningfully available for macOS builds, and it would defeat the point
|
||||||
|
of a real macOS environment.
|
||||||
|
- **On the host:** the daemon, the Gitea admin PAT, the registration token, base images, and the
|
||||||
|
clone/boot/destroy lifecycle. **The admin PAT is never copied into a guest.**
|
||||||
|
|
||||||
|
Because the guest admin has passwordless sudo, a job can install software, load kernel extensions
|
||||||
|
the guest permits, read every file in the image, and reconfigure the guest arbitrarily. None of
|
||||||
|
that persists: the clone is deleted when the job ends and the next job starts from the untouched
|
||||||
|
base image. Nothing a job writes is visible to any later job.
|
||||||
|
|
||||||
|
## Ephemeral registration is server-enforced
|
||||||
|
|
||||||
|
Each VM registers a fresh runner with `--ephemeral`. Ephemerality is enforced **by the Gitea
|
||||||
|
server**, not by the runner or by this daemon:
|
||||||
|
|
||||||
|
- After the runner accepts one job, Gitea **refuses to dispatch a second job** to that
|
||||||
|
registration.
|
||||||
|
- Gitea **deletes the registration** once that job completes.
|
||||||
|
|
||||||
|
The security consequence matters: a runner token exfiltrated from inside a running job cannot be
|
||||||
|
used to fetch additional jobs, because the server has already spent that registration. The worst an
|
||||||
|
attacker can do with it is nothing. This is why a compromised job does not become a persistent
|
||||||
|
foothold in your CI queue — the compromised VM is destroyed and its credential is already dead.
|
||||||
|
|
||||||
|
## The shared registration token and its blast radius
|
||||||
|
|
||||||
|
The registration token is a different matter, and it is the sharpest edge in this design.
|
||||||
|
|
||||||
|
Gitea's registration tokens are **reusable by construction**, and creating a new token for a scope
|
||||||
|
**invalidates the previous one**. There is no API for minting a single-use, per-VM registration
|
||||||
|
token. A per-VM token is therefore impossible — not merely unimplemented.
|
||||||
|
|
||||||
|
So every VM presents the same registration token, and that token is necessarily present inside an
|
||||||
|
untrusted guest for the duration of registration.
|
||||||
|
|
||||||
|
**Blast radius if the token leaks:** an attacker can register arbitrary runners against the scope
|
||||||
|
the token covers (instance-wide, if you seeded `GITEA_RUNNER_REGISTRATION_TOKEN` on the server).
|
||||||
|
Such a runner can advertise your labels and thereby **receive and execute jobs**, which means it
|
||||||
|
can read whatever secrets those jobs are given and return forged results. It does not by itself
|
||||||
|
grant API access to Gitea, read repositories the runner is not assigned jobs from, or confer admin
|
||||||
|
rights.
|
||||||
|
|
||||||
|
Mitigations, in order of effectiveness:
|
||||||
|
|
||||||
|
1. **Constrain who can dispatch jobs to the label** (below) so a rogue runner has a small pool of
|
||||||
|
jobs to intercept.
|
||||||
|
2. **Register at org or repo scope** rather than instance-wide when only a few repos need macOS.
|
||||||
|
The token's reach is then limited to that scope.
|
||||||
|
3. **Rotate the token** by changing `GITEA_RUNNER_REGISTRATION_TOKEN` and restarting Gitea. Update
|
||||||
|
`gitea.registrationTokenFile` on the host at the same time; in-flight VMs that have already
|
||||||
|
registered are unaffected.
|
||||||
|
4. **Keep secrets out of macOS jobs where possible.** Prefer short-lived, narrowly scoped
|
||||||
|
credentials injected per job over long-lived org-level secrets.
|
||||||
|
|
||||||
|
## Limiting who can use the runner
|
||||||
|
|
||||||
|
By default an instance-wide runner will execute any job from any repo that writes
|
||||||
|
`runs-on: macos-arm64`. On an instance where untrusted users can push branches or open PRs that
|
||||||
|
trigger workflows, that is effectively arbitrary code execution on your CI Mac's VM.
|
||||||
|
|
||||||
|
Restrict it:
|
||||||
|
|
||||||
|
- **Register at repo or org scope** instead of instance-wide — the runner is then only offered jobs
|
||||||
|
from that repo or org.
|
||||||
|
- Use Gitea's per-repo and per-org **Actions runner settings** to control which repositories may
|
||||||
|
use a shared runner.
|
||||||
|
- Configure Gitea so that **workflows from forked-repository pull requests require approval** before
|
||||||
|
running. This is the single most important setting if your instance accepts outside
|
||||||
|
contributions.
|
||||||
|
|
||||||
|
Decide this deliberately. Nothing in the daemon restricts job origin; that control lives entirely
|
||||||
|
in Gitea.
|
||||||
|
|
||||||
|
## Secrets hygiene
|
||||||
|
|
||||||
|
- **Prefer the file forms of every credential:** `gitea.adminTokenFile` and
|
||||||
|
`gitea.registrationTokenFile` rather than `adminToken`/`registrationToken` inline in
|
||||||
|
`config.json`. For the admin token this is not merely a preference in one direction: config
|
||||||
|
validation requires **exactly one** of `adminToken` and `adminTokenFile`, so a stale inline token
|
||||||
|
cannot sit unnoticed beside a live token file.
|
||||||
|
- **`chmod 600` every token file** and keep it owned by the runner user:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
chmod 600 ~/.config/gitea-macos-runner/admin-token \
|
||||||
|
~/.config/gitea-macos-runner/registration-token
|
||||||
|
```
|
||||||
|
|
||||||
|
`gitea-macos-runner doctor` checks this: its `token file permissions` check warns, and prints the
|
||||||
|
exact `chmod` to run, if any configured token file is group- or world-readable.
|
||||||
|
|
||||||
|
- **Token *files* keep secrets off `ps`.** Any local user can read another process's argument
|
||||||
|
vector; a token passed as `--token …` is visible there for the life of the process, and often in
|
||||||
|
shell history and logs too. The `--token-file` form passes a path instead, so the secret never
|
||||||
|
enters the command line.
|
||||||
|
- **Do not commit `config.json`** or any token file. If your config lives in a dotfiles repo, keep
|
||||||
|
the token files outside it.
|
||||||
|
- **The `guest.password`** is a credential for a throwaway machine, but it appears in the host's
|
||||||
|
config file and grants SSH into running guests. Treat the config file itself as sensitive
|
||||||
|
(`chmod 600`) and do not reuse a password from anywhere else.
|
||||||
|
- **Rotate the admin PAT** on the normal schedule you use for admin credentials. Its scope
|
||||||
|
(`read:admin` + `write:admin`) is genuinely powerful — it can enumerate and delete runner
|
||||||
|
registrations instance-wide — so it is the highest-value secret on the host. It lives only on the
|
||||||
|
host and never enters a VM; keep it that way.
|
||||||
|
|
||||||
|
## Host hardening notes
|
||||||
|
|
||||||
|
- Use a **dedicated Mac** for CI. The auto-login recommendation in
|
||||||
|
[setup.md](setup.md#25-install-the-service) means the disk is unlocked at boot, which is
|
||||||
|
acceptable for a purpose-built CI machine and not for a workstation holding other data.
|
||||||
|
- Run the daemon as a **non-administrator user** where practical. It needs a GUI session and
|
||||||
|
virtualization entitlements, not host root.
|
||||||
|
- Place the Mac on a **network segment that cannot reach production**. Guests get NAT'd egress
|
||||||
|
through the host; anything the host can reach, a job can reach.
|
||||||
|
- Keep the base image current. It is rebuilt from an IPSW, so refreshing macOS and the toolchain is
|
||||||
|
an `image build` away — and every job automatically picks up the new image.
|
||||||
+479
@@ -0,0 +1,479 @@
|
|||||||
|
# Setup
|
||||||
|
|
||||||
|
This walkthrough covers everything needed to go from a bare Apple Silicon Mac and a self-hosted
|
||||||
|
Gitea instance to a working macOS CI runner: Gitea-side configuration, host build and signing,
|
||||||
|
base image creation, service installation, and verification.
|
||||||
|
|
||||||
|
Work through it in order. The Gitea side can be done from any machine; the host side must be done
|
||||||
|
on the Mac that will run the VMs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Gitea-side configuration
|
||||||
|
|
||||||
|
### 1.1 Version requirements
|
||||||
|
|
||||||
|
| Component | Minimum | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Gitea server | **1.25** | 1.25 added `GET /api/v1/admin/actions/jobs`, including the `labels` field that carries the job's `runs-on`. The daemon cannot work without it. |
|
||||||
|
| Gitea server | 1.26+ recommended | Later fixes to Actions job dispatch and runner cleanup. |
|
||||||
|
| Runner binary in the guest | **`gitea-runner` v3.x** | This is Gitea's own runner. It is *not* the older `act_runner`; do not substitute it. |
|
||||||
|
|
||||||
|
Actions must be enabled on the instance (`[actions] ENABLED = true` in `app.ini`) and on any repo
|
||||||
|
that will use the runner.
|
||||||
|
|
||||||
|
### 1.2 Create the admin token (PAT)
|
||||||
|
|
||||||
|
The daemon needs a personal access token belonging to a **site administrator** so it can read the
|
||||||
|
queued-jobs list and delete stale runner registrations.
|
||||||
|
|
||||||
|
1. Sign in as a site-admin user.
|
||||||
|
2. **Settings → Applications → Manage Access Tokens → Generate New Token.**
|
||||||
|
3. Grant the token **`read:admin` and `write:admin`** scopes. Nothing else is required.
|
||||||
|
4. Copy the token immediately — Gitea shows it once.
|
||||||
|
|
||||||
|
Store it on the host in a file readable only by the runner user rather than inline in the config:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
install -m 600 /dev/null ~/.config/gitea-macos-runner/admin-token
|
||||||
|
printf '%s' 'PASTE_TOKEN_HERE' > ~/.config/gitea-macos-runner/admin-token
|
||||||
|
```
|
||||||
|
|
||||||
|
Then set `gitea.adminTokenFile` to that path. See [security.md](security.md) for why the file form
|
||||||
|
is preferred.
|
||||||
|
|
||||||
|
### 1.3 Choose a registration token strategy
|
||||||
|
|
||||||
|
Every VM registers itself as a runner before it can accept a job, and registration requires a
|
||||||
|
registration token. Gitea's registration tokens are **reusable**, and creating a new token for a
|
||||||
|
given scope **invalidates the previous one** — so a distinct token per VM is impossible by design.
|
||||||
|
You have two options.
|
||||||
|
|
||||||
|
**Option A (recommended): seed a stable instance-wide token on the Gitea server.**
|
||||||
|
|
||||||
|
Set an environment variable on the Gitea server process before it starts:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Must be at least 32 characters.
|
||||||
|
GITEA_RUNNER_REGISTRATION_TOKEN=<32+ character random string>
|
||||||
|
```
|
||||||
|
|
||||||
|
For example, in a systemd unit:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Service]
|
||||||
|
Environment=GITEA_RUNNER_REGISTRATION_TOKEN=<32+ character random string>
|
||||||
|
```
|
||||||
|
|
||||||
|
or in `docker-compose.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
gitea:
|
||||||
|
environment:
|
||||||
|
- GITEA_RUNNER_REGISTRATION_TOKEN=<32+ character random string>
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart Gitea, then put the same value on the host in `gitea.registrationTokenFile`. This token is
|
||||||
|
stable across restarts and is not invalidated by someone clicking "create new token" in the UI for
|
||||||
|
a different scope.
|
||||||
|
|
||||||
|
**Option B: let the daemon fetch a token via the admin API.**
|
||||||
|
|
||||||
|
Set `gitea.fetchRegistrationTokenViaAPI: true` and omit `gitea.registrationToken`/`…File`. The
|
||||||
|
daemon requests a token with the admin PAT when it needs one. This is simpler to set up, but any
|
||||||
|
out-of-band token creation for the same scope invalidates the token the daemon is holding, and the
|
||||||
|
daemon must re-fetch. Option A is more predictable for an unattended host.
|
||||||
|
|
||||||
|
Generate a token value with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
openssl rand -hex 24
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.4 Target the runner from a workflow
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .gitea/workflows/macos.yml
|
||||||
|
name: macOS build
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: macos-arm64 # bare label name — must match runner.labels
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Show host
|
||||||
|
run: sw_vers && uname -m
|
||||||
|
- name: Build
|
||||||
|
run: swift build
|
||||||
|
```
|
||||||
|
|
||||||
|
Two things to know about labels:
|
||||||
|
|
||||||
|
- Use **bare label names** in `runs-on`. The runner registers with `--labels "macos-arm64:host"`;
|
||||||
|
the `:host` suffix is runner-side only and declares that the label executes directly on the host
|
||||||
|
OS rather than in a container. It never appears in workflow YAML.
|
||||||
|
- Every label in `runs-on` must be present in the runner's `runner.labels`. A typo means the job
|
||||||
|
sits queued forever with no error.
|
||||||
|
|
||||||
|
Because no runner exists until a job appears, jobs necessarily wait for a VM to boot. Gitea holds a
|
||||||
|
queued job for `ABANDONED_JOB_TIMEOUT` (default **24 hours**) before marking it abandoned, so
|
||||||
|
scale-from-zero is safe. If your queue can legitimately back up for more than a day — a long
|
||||||
|
maintenance window, for example — raise that setting:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[actions]
|
||||||
|
ABANDONED_JOB_TIMEOUT = 72h
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.5 Optionally restrict which repos may use the runner
|
||||||
|
|
||||||
|
The runner is registered instance-wide by default, meaning any repo whose workflow says
|
||||||
|
`runs-on: macos-arm64` can execute code on it. If that is broader than you want, register the
|
||||||
|
runner at the org or repo level instead, or restrict via Gitea's runner settings. See
|
||||||
|
[security.md](security.md#limiting-who-can-use-the-runner).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Host-side setup
|
||||||
|
|
||||||
|
### 2.1 Host requirements
|
||||||
|
|
||||||
|
| Requirement | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| Architecture | Apple Silicon (arm64). Intel Macs cannot run macOS guests. |
|
||||||
|
| Host macOS | 26 minimum; **27+ strongly recommended** (see below). |
|
||||||
|
| Guest macOS | 27+ if you want unattended image builds. |
|
||||||
|
| Free disk | ~60 GB for a vanilla image; **140 GB+** with Xcode installed. |
|
||||||
|
| RAM | 16 GB minimum; each guest defaults to 8 GB. |
|
||||||
|
| Concurrent VMs | **2 maximum**, enforced by the macOS kernel. `scheduler.maxConcurrentVMs` must be ≤ 2. |
|
||||||
|
|
||||||
|
The macOS 27 recommendation is not cosmetic. The automated image builder uses
|
||||||
|
`VZMacGuestProvisioningOptions` — introduced in macOS 27 — to create the admin account and skip
|
||||||
|
Setup Assistant during first boot. Both host and guest must be 27+. An older guest **silently
|
||||||
|
ignores** the options: the install succeeds, the VM boots, and then sits at Setup Assistant with no
|
||||||
|
SSH server, so the build appears to hang. See
|
||||||
|
[troubleshooting.md](troubleshooting.md).
|
||||||
|
|
||||||
|
### 2.2 Build, sign, and install
|
||||||
|
|
||||||
|
Virtualization.framework refuses to run unless the calling binary carries the
|
||||||
|
`com.apple.security.virtualization` entitlement, and entitlements are only honoured on a signed
|
||||||
|
binary inside a proper `.app` bundle. Ad-hoc signing (`codesign -s -`) satisfies this — **no paid
|
||||||
|
Apple developer account is needed.**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone <this repo> && cd gitea-macos-runner
|
||||||
|
make install
|
||||||
|
```
|
||||||
|
|
||||||
|
`make install` runs the full chain and places the result:
|
||||||
|
|
||||||
|
| Target | What it does |
|
||||||
|
| --- | --- |
|
||||||
|
| `make build` | `swift build -c release --arch arm64` |
|
||||||
|
| `make bundle` | Assemble `GiteaMacosRunner.app` around the binary: `Contents/MacOS/gitea-macos-runner`, `Contents/Info.plist`, and `Contents/Resources/` (`provision.sh`, `launchd.plist.template`, `config.example.json`) |
|
||||||
|
| `make sign` | `codesign --sign - --entitlements …` (ad-hoc) and print the resulting entitlements |
|
||||||
|
| `make all` | `build` + `bundle` + `sign`. The default target. |
|
||||||
|
| `make install` | Runs `all`, copies the app to `~/Applications`, and symlinks the CLI to `/usr/local/bin/gitea-macos-runner` |
|
||||||
|
| `make dev` | Debug build + bundle + sign, for fast iteration. Does **not** install. |
|
||||||
|
| `make test` | `swift test` |
|
||||||
|
| `make uninstall` | Remove the installed app and symlink |
|
||||||
|
| `make clean` | Remove `.build/` |
|
||||||
|
| `make help` | List the targets |
|
||||||
|
|
||||||
|
The three files under `Contents/Resources/` are not decoration. `image build`
|
||||||
|
uploads `provision.sh` into the guest, `service install` renders
|
||||||
|
`launchd.plist.template`, and `config init` writes `config.example.json`. The
|
||||||
|
code looks in `Contents/Resources` first and only then falls back to
|
||||||
|
repo-relative paths, so an installed `.app` missing them is a working binary
|
||||||
|
with three broken commands.
|
||||||
|
|
||||||
|
If `/usr/local/bin` isn't writable, `make install` says so and prints the `sudo ln -sf …` command to
|
||||||
|
run yourself.
|
||||||
|
|
||||||
|
Never run the raw binary from `.build/release/` — it is outside the signed bundle, so every VM
|
||||||
|
operation fails with an entitlement error. Always invoke the symlink (or
|
||||||
|
`~/Applications/GiteaMacosRunner.app/Contents/MacOS/gitea-macos-runner`).
|
||||||
|
|
||||||
|
### 2.3 Create the configuration file
|
||||||
|
|
||||||
|
```sh
|
||||||
|
gitea-macos-runner config init # add --force to overwrite an existing file
|
||||||
|
$EDITOR "$(gitea-macos-runner config path)"
|
||||||
|
gitea-macos-runner config show # parse + validate, printing secrets redacted
|
||||||
|
```
|
||||||
|
|
||||||
|
`config init` writes the annotated example shipped in the app bundle, so the
|
||||||
|
file you land in has a `_comment` key explaining each section; those keys are
|
||||||
|
ignored when the config is read back. Pass `--instance-url https://…` to seed
|
||||||
|
`gitea.instanceURL` instead of the placeholder. Every subcommand takes
|
||||||
|
`--config PATH` (`-c`) if you keep the file somewhere else, and `--verbose`.
|
||||||
|
|
||||||
|
A minimal working config:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"gitea": {
|
||||||
|
"instanceURL": "https://gitea.example.com",
|
||||||
|
"adminTokenFile": "~/.config/gitea-macos-runner/admin-token",
|
||||||
|
"registrationTokenFile": "~/.config/gitea-macos-runner/registration-token"
|
||||||
|
},
|
||||||
|
"runner": {
|
||||||
|
"labels": ["macos-arm64"],
|
||||||
|
"namePrefix": "macos-vm-"
|
||||||
|
},
|
||||||
|
"scheduler": {
|
||||||
|
"maxConcurrentVMs": 2
|
||||||
|
},
|
||||||
|
"guest": {
|
||||||
|
"username": "admin",
|
||||||
|
"password": "CHANGE_ME",
|
||||||
|
"cpuCount": 4,
|
||||||
|
"memoryGB": 8,
|
||||||
|
"diskGB": 64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Configuration reference
|
||||||
|
|
||||||
|
**`gitea`**
|
||||||
|
|
||||||
|
| Key | Default | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `instanceURL` | — (required) | Base URL of the Gitea instance, e.g. `https://gitea.example.com`. Must be `http://` or `https://` with a host. A trailing slash is harmless; a subpath is preserved. |
|
||||||
|
| `adminToken` | — | Site-admin PAT with `read:admin` + `write:admin`, inline. |
|
||||||
|
| `adminTokenFile` | — | Path to a file containing the PAT (tilde-expanded). Should be `chmod 600`. |
|
||||||
|
| `registrationToken` | — | Runner registration token, inline. Prefer `registrationTokenFile`. |
|
||||||
|
| `registrationTokenFile` | — | Path to a file containing the registration token (tilde-expanded). Takes precedence over `registrationToken` if both are set. |
|
||||||
|
| `fetchRegistrationTokenViaAPI` | `false` | Fetch a registration token with the admin PAT when no static one is configured. A *fallback*, not an override: a configured token still wins. |
|
||||||
|
|
||||||
|
Set **exactly one** of `adminToken` and `adminTokenFile`. Setting both is a
|
||||||
|
validation error — a stale inline token next to a live token file is precisely
|
||||||
|
the ambiguity that turns into a baffling 401 later — and setting neither is too.
|
||||||
|
|
||||||
|
For the registration token the rule is looser: configure `registrationToken`,
|
||||||
|
`registrationTokenFile`, or `fetchRegistrationTokenViaAPI: true`. At least one
|
||||||
|
is required; the file form wins over the inline form when both are present.
|
||||||
|
|
||||||
|
**`runner`**
|
||||||
|
|
||||||
|
| Key | Default | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `labels` | `["macos-arm64"]` | Labels this runner offers. A queued job runs here only if its `runs-on` labels are all in this set. **Bare names only** — a `:schema` suffix here is a validation error; the `:host` suffix is appended automatically at registration. |
|
||||||
|
| `namePrefix` | `"macos-vm-"` | Prefix for generated runner names in the Gitea UI; a unique suffix is appended per VM. |
|
||||||
|
| `runnerDownloadURL` | `https://gitea.com/gitea/runner/releases/download/v{version}/gitea-runner-{version}-darwin-arm64` | Release asset installed into the guest. `{version}` is substituted with `version`. |
|
||||||
|
| `version` | `"3.0.2"` | The `gitea-runner` version to install. |
|
||||||
|
|
||||||
|
**`scheduler`**
|
||||||
|
|
||||||
|
| Key | Default | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `maxConcurrentVMs` | `2` | Simultaneous VMs. **Hard-capped at 2 by macOS.** A larger value is silently clamped to 2 at load rather than rejected; the kernel is not something a config file gets to negotiate. |
|
||||||
|
| `pollIntervalSeconds` | `5` | How often to poll the queued-jobs API. |
|
||||||
|
| `reconcileIntervalSeconds` | `300` | How often to sweep Gitea for orphaned runner registrations from uncleanly-killed VMs. |
|
||||||
|
| `jobTimeoutMinutes` | `120` | Wall-clock limit for one job; the VM is destroyed when exceeded. |
|
||||||
|
| `bootTimeoutSeconds` | `300` | Time allowed from VM start to a usable SSH connection. |
|
||||||
|
|
||||||
|
**`guest`**
|
||||||
|
|
||||||
|
| Key | Default | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `username` | `"admin"` | Guest admin account created during image build. Has passwordless sudo. |
|
||||||
|
| `password` | — (required) | Password for that account. Also used for SSH if key auth is unavailable. |
|
||||||
|
| `cpuCount` | `4` | vCPUs per guest. |
|
||||||
|
| `memoryGB` | `8` | RAM per guest. Two concurrent guests at 8 GB need a 16 GB+ host with headroom. |
|
||||||
|
| `diskGB` | `64` | Guest disk size. Sparse, so this is a ceiling, not immediate consumption. Raise for Xcode. |
|
||||||
|
|
||||||
|
**`storage`**
|
||||||
|
|
||||||
|
| Key | Default | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `storeDir` | `~/Library/Application Support/gitea-macos-runner` | Base images (`images/`) and running clones (`vms/`) live here. |
|
||||||
|
| `minFreeDiskGB` | `20` | The scheduler refuses to start a VM below this free-space threshold rather than filling the disk mid-job. |
|
||||||
|
|
||||||
|
### 2.4 Build the base image
|
||||||
|
|
||||||
|
Download a macOS **27 or newer** IPSW for Apple Silicon (Apple's restore images; the URL for the
|
||||||
|
current release is also discoverable from Virtualization.framework's latest-supported-restore-image
|
||||||
|
endpoint), then:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
gitea-macos-runner image build \
|
||||||
|
--ipsw ~/Downloads/UniversalMac_27.0_XXXXX_Restore.ipsw \
|
||||||
|
--disk-gb 64
|
||||||
|
```
|
||||||
|
|
||||||
|
`--name` defaults to `default`, which is also what `daemon` and `vm boot` look
|
||||||
|
for. If you name the image something else, pass the same name to
|
||||||
|
`daemon --image NAME` (and to `vm boot --image NAME`) or the daemon will not
|
||||||
|
find it. `--ipsw` is optional: omit it and the latest supported restore image is
|
||||||
|
downloaded into `storeDir/ipsw/` first, which is most of the build's wall-clock
|
||||||
|
time. `--disk-gb` overrides `guest.diskGB` for this image only.
|
||||||
|
|
||||||
|
This takes a long time — macOS installs from the IPSW, boots, and is then provisioned over SSH.
|
||||||
|
**Do not interrupt it during the install phase.** Stopping a VM mid-install leaves the disk image
|
||||||
|
in an undefined state; delete the image and start over rather than trying to resume.
|
||||||
|
|
||||||
|
The finished image contains:
|
||||||
|
|
||||||
|
- macOS installed from the IPSW.
|
||||||
|
- The `guest.username` admin account, auto-created via provisioning options, with SSH (Remote
|
||||||
|
Login) enabled and **passwordless sudo**.
|
||||||
|
- Sleep, screensaver, and Spotlight indexing disabled — a sleeping guest stalls a job, and Spotlight
|
||||||
|
wastes I/O on a throwaway machine.
|
||||||
|
- Raised file-descriptor limits, which Node- and Xcode-based builds routinely exhaust at the
|
||||||
|
default.
|
||||||
|
- **Node.js — required, not optional.** Gitea Actions' JavaScript actions (`actions/checkout` and
|
||||||
|
most of the ecosystem) are executed by spawning `node` in the guest. Without it, every workflow
|
||||||
|
fails on its first step.
|
||||||
|
- `git`, verified present.
|
||||||
|
- The `gitea-runner` v3.x binary.
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
gitea-macos-runner image list
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Adding Xcode (optional)
|
||||||
|
|
||||||
|
Xcode is not installed automatically. Apple requires an authenticated Apple ID for the download and
|
||||||
|
its download endpoints are impractical to drive unattended, so you fetch the `.xip` yourself from
|
||||||
|
[developer.apple.com/download](https://developer.apple.com/download/) and hand it to the
|
||||||
|
provisioner:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
gitea-macos-runner image provision default --xcode-xip ~/Downloads/Xcode_XX.xip
|
||||||
|
```
|
||||||
|
|
||||||
|
Budget disk accordingly: **~60 GB free is enough for a vanilla image, but plan on 140 GB+ with
|
||||||
|
Xcode**, and remember each running VM is a copy-on-write clone whose divergence from the base
|
||||||
|
consumes additional space while it runs. Raise `guest.diskGB` (e.g. to 120) before building if the
|
||||||
|
image will carry Xcode.
|
||||||
|
|
||||||
|
### 2.5 Install the service
|
||||||
|
|
||||||
|
```sh
|
||||||
|
gitea-macos-runner service install
|
||||||
|
gitea-macos-runner service status
|
||||||
|
```
|
||||||
|
|
||||||
|
This installs a **LaunchAgent in the logged-in user's GUI session — not a LaunchDaemon.** Two
|
||||||
|
reasons this is not negotiable:
|
||||||
|
|
||||||
|
1. Virtualization.framework requires a GUI session; a system-context daemon cannot start VMs.
|
||||||
|
2. macOS 15 and later require an **unlocked `login.keychain`** for key operations the VM lifecycle
|
||||||
|
performs. In a locked or headless session these fail with `SecKeyCreateRandomKey` /
|
||||||
|
"Interaction is not allowed" errors.
|
||||||
|
|
||||||
|
**Recommended: enable auto-login for the runner user on a dedicated CI Mac.**
|
||||||
|
**System Settings → Users & Groups → Automatically log in as → \<runner user\>.** Combine with
|
||||||
|
disabling sleep (`sudo pmset -a sleep 0 disablesleep 1`) so the Mac comes back into a live session
|
||||||
|
after a power event without a human present. This does mean the disk is effectively unlocked at
|
||||||
|
boot — appropriate for a dedicated CI machine, not for a shared workstation.
|
||||||
|
|
||||||
|
`service uninstall` removes the LaunchAgent; it does not delete images or config.
|
||||||
|
|
||||||
|
### 2.6 macOS 15+ Local Network privacy prompt
|
||||||
|
|
||||||
|
Starting with macOS 15, a process that contacts other hosts on the local network triggers a
|
||||||
|
one-time Local Network permission prompt. A LaunchAgent that is denied (or that never gets a human
|
||||||
|
to click Allow) cannot reach the guest's NAT address, so VMs boot but SSH never connects.
|
||||||
|
|
||||||
|
Grant it interactively the first time — run `gitea-macos-runner vm boot` from a
|
||||||
|
Terminal in the GUI session and click **Allow** — or pre-authorize the VM subnet:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo defaults write com.apple.network.local-network \
|
||||||
|
AllowedEthernetLocalNetworkAddresses -array "192.168.0.0/16"
|
||||||
|
```
|
||||||
|
|
||||||
|
Adjust the range to match the subnet Virtualization.framework's NAT hands out on your host (check
|
||||||
|
`/var/db/dhcpd_leases` after a VM boots). Reboot, or restart the service, for the change to take
|
||||||
|
effect. Also confirm the runner is enabled under **System Settings → Privacy & Security → Local
|
||||||
|
Network**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Verification
|
||||||
|
|
||||||
|
### 3.1 Preflight
|
||||||
|
|
||||||
|
```sh
|
||||||
|
gitea-macos-runner doctor
|
||||||
|
```
|
||||||
|
|
||||||
|
It reports one line per check, each `✓` pass, `✗` fail, `!` warn, or `·` note,
|
||||||
|
with a remediation hint under anything that is not a pass, and exits non-zero if
|
||||||
|
anything failed. `--json` emits the same results machine-readably; `--no-fail`
|
||||||
|
exits zero regardless, which is what you want when running it from a script that
|
||||||
|
handles the results itself.
|
||||||
|
|
||||||
|
The checks, in order:
|
||||||
|
|
||||||
|
| Check | What it looks at |
|
||||||
|
| --- | --- |
|
||||||
|
| `host capability` | Apple Silicon, and host macOS ≥ 26 |
|
||||||
|
| `Virtualization.framework` | `VZVirtualMachine.isSupported` |
|
||||||
|
| `virtualization entitlement` | `com.apple.security.virtualization` on the *running* executable — this is the check that catches running from `.build/` instead of the signed `.app` |
|
||||||
|
| `configuration` | The config file loads, parses, and passes validation |
|
||||||
|
| `free disk space` | Free space on the `storeDir` volume against `storage.minFreeDiskGB` |
|
||||||
|
| `login.keychain unlocked` | `security show-keychain-info login.keychain` |
|
||||||
|
| `gitea admin token` / `gitea admin api` | The admin token resolves, and an admin-only endpoint answers with it (so a non-admin PAT fails here rather than at 3am) |
|
||||||
|
| `registration token` | A static token resolves, or one can be fetched when `fetchRegistrationTokenViaAPI` is on |
|
||||||
|
| `runner download url` | The `gitea-runner` release asset is reachable |
|
||||||
|
| `token file permissions` | Warns — not fails — when a token file is group- or world-readable |
|
||||||
|
| `local network access` | An informational note about the macOS 15+ Local Network prompt |
|
||||||
|
|
||||||
|
If the config file is missing or invalid, the host checks still run and the rest
|
||||||
|
are skipped — which is exactly the state a first-time operator is in. Resolve
|
||||||
|
everything it reports before going further.
|
||||||
|
|
||||||
|
There is deliberately **no** "a base image exists" check; use `image list`.
|
||||||
|
|
||||||
|
### 3.2 Boot a VM by hand
|
||||||
|
|
||||||
|
```sh
|
||||||
|
gitea-macos-runner vm boot
|
||||||
|
```
|
||||||
|
|
||||||
|
This clones the base image and boots it without registering a runner — the fastest way to confirm
|
||||||
|
that virtualization, networking, and SSH all work. Once it is up, check that the guest got a lease:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cat /var/db/dhcpd_leases
|
||||||
|
```
|
||||||
|
|
||||||
|
and that you can reach it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ssh admin@<guest-ip> 'sw_vers; node --version; git --version; which gitea-runner'
|
||||||
|
```
|
||||||
|
|
||||||
|
All four should answer. A guest at Setup Assistant instead of a login window means the image was
|
||||||
|
built from a pre-27 IPSW; rebuild.
|
||||||
|
|
||||||
|
### 3.3 Watch a real job
|
||||||
|
|
||||||
|
Start the service, push the example workflow from §1.4, and watch both sides:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Host: daemon logs
|
||||||
|
log stream --predicate 'process == "gitea-macos-runner"' --info
|
||||||
|
|
||||||
|
# Host: VM lifecycle
|
||||||
|
gitea-macos-runner service status
|
||||||
|
```
|
||||||
|
|
||||||
|
In the Gitea UI, the job should move from queued to running within roughly one poll interval plus
|
||||||
|
boot time; a runner named `macos-vm-<suffix>` appears under **Site Administration → Actions →
|
||||||
|
Runners** while the job runs and disappears when it finishes. That disappearance is Gitea deleting
|
||||||
|
the ephemeral registration itself, and it is the signal that the whole loop worked.
|
||||||
|
|
||||||
|
If the job stays queued, or the runner appears but the job fails immediately, go to
|
||||||
|
[troubleshooting.md](troubleshooting.md).
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
# Troubleshooting
|
||||||
|
|
||||||
|
Start with `gitea-macos-runner doctor` — it catches most misconfiguration before you go
|
||||||
|
symptom-hunting. Then find your symptom below.
|
||||||
|
|
||||||
|
Useful log commands throughout:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Daemon logs, live
|
||||||
|
log stream --predicate 'process == "gitea-macos-runner"' --info
|
||||||
|
|
||||||
|
# Daemon logs, last hour
|
||||||
|
log show --predicate 'process == "gitea-macos-runner"' --info --last 1h
|
||||||
|
|
||||||
|
gitea-macos-runner service status
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick reference
|
||||||
|
|
||||||
|
| Symptom | Cause | Fix |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| VM won't start; entitlement / `com.apple.security.virtualization` error | Running an unsigned binary, or one outside the signed `.app` bundle | `make sign` (or re-run `make install`); invoke the installed bundle, never `.build/release/…` |
|
||||||
|
| `virtualMachineLimitExceeded` at boot | macOS allows at most **2** concurrent macOS VMs | Set `scheduler.maxConcurrentVMs` ≤ 2; kill stray VMs from earlier runs |
|
||||||
|
| VM boots but never gets an IP | DHCP lease not yet written, or Local Network privacy denial (macOS 15+) | Check `/var/db/dhcpd_leases`; grant Local Network permission or pre-authorize the subnet |
|
||||||
|
| SSH times out on a freshly built image | Guest macOS < 27, so provisioning options were ignored and Setup Assistant is waiting | Rebuild the image from a macOS **27+** IPSW |
|
||||||
|
| `SecKeyCreateRandomKey` / "Interaction is not allowed" | `login.keychain` is locked — no GUI session | Run as a LaunchAgent in an unlocked GUI session; enable auto-login |
|
||||||
|
| Job stays queued forever | Label mismatch, or the daemon isn't running/reaching Gitea | Use bare label names in `runs-on`; match `runner.labels`; check daemon logs |
|
||||||
|
| `actions/checkout` fails instantly | Node.js missing from the guest image | `gitea-macos-runner image provision <name>` |
|
||||||
|
| Runner rows piling up in the Gitea UI | VMs killed uncleanly; registrations orphaned | Reconcile loop cleans them; force it by restarting the daemon; delete manually if needed |
|
||||||
|
| Disk filling up | Copy-on-write clones grow as jobs write | Raise `storage.minFreeDiskGB`; delete stale clones in `storeDir/vms` |
|
||||||
|
| `image build` appears to hang during install | Normal — macOS install is slow | Wait. **Do not stop the VM mid-install**; if you did, delete the image and rebuild |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## VM won't start — entitlement error
|
||||||
|
|
||||||
|
**Symptom.** Any VM operation fails immediately with an error naming
|
||||||
|
`com.apple.security.virtualization`, or a generic "operation not permitted" from
|
||||||
|
Virtualization.framework.
|
||||||
|
|
||||||
|
**Cause.** Virtualization.framework checks the entitlement on the calling binary, and entitlements
|
||||||
|
are only honoured on a signed binary inside a proper `.app` bundle. The raw product of
|
||||||
|
`swift build` has neither.
|
||||||
|
|
||||||
|
**Fix.**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make install # build → bundle → sign → install (make sign alone re-signs in place)
|
||||||
|
codesign -d --entitlements - ~/Applications/GiteaMacosRunner.app # verify
|
||||||
|
```
|
||||||
|
|
||||||
|
The output must list `com.apple.security.virtualization`. Then confirm the command you're running
|
||||||
|
resolves to the installed bundle's binary — `which -a gitea-macos-runner` should point at
|
||||||
|
`~/Applications/GiteaMacosRunner.app/Contents/MacOS/gitea-macos-runner`, not at
|
||||||
|
`.build/release/gitea-macos-runner`. Ad-hoc signing is sufficient; you do not need a paid developer
|
||||||
|
account.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `virtualMachineLimitExceeded`
|
||||||
|
|
||||||
|
**Symptom.** The first VM boots fine; a second or third fails with `virtualMachineLimitExceeded`.
|
||||||
|
|
||||||
|
**Cause.** macOS permits **two** concurrent macOS guests per host. This is an Apple kernel and
|
||||||
|
licensing limit, not a resource constraint — more RAM will not raise it.
|
||||||
|
|
||||||
|
**Fix.** Set `scheduler.maxConcurrentVMs` to 2 or less. If you're already at 2 and still hitting
|
||||||
|
the limit, a VM from a previous run is still alive — check for stray processes and for leftover
|
||||||
|
directories under `storeDir/vms`, then restart the daemon so it starts from a clean state.
|
||||||
|
|
||||||
|
To handle more macOS jobs in parallel, add another Mac.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## VM starts but never gets an IP
|
||||||
|
|
||||||
|
**Symptom.** The VM boots (you can see it progress if you use `vm boot`), but the daemon reports
|
||||||
|
that it could not resolve the guest address, or gives up at `scheduler.bootTimeoutSeconds`.
|
||||||
|
|
||||||
|
**Cause.** The daemon resolves the guest's NAT address from the host's DHCP lease file, which is
|
||||||
|
only written once the guest requests a lease — several seconds after the boot screen appears. If the
|
||||||
|
address never appears at all, the usual culprit on macOS 15+ is the **Local Network privacy
|
||||||
|
prompt**: a LaunchAgent that was never granted permission (or was denied) cannot talk to the guest.
|
||||||
|
|
||||||
|
**Fix.**
|
||||||
|
|
||||||
|
1. Check the lease file after the guest has been up for ~30 seconds:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cat /var/db/dhcpd_leases
|
||||||
|
```
|
||||||
|
|
||||||
|
An entry with a recent `lease` timestamp and the guest's MAC means networking is fine and the
|
||||||
|
problem is timing — raise `scheduler.bootTimeoutSeconds`.
|
||||||
|
|
||||||
|
2. No entry at all: check **System Settings → Privacy & Security → Local Network** and enable the
|
||||||
|
runner. If it isn't listed, trigger the prompt interactively from a Terminal in the GUI session:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
gitea-macos-runner vm boot
|
||||||
|
```
|
||||||
|
|
||||||
|
and click **Allow**.
|
||||||
|
|
||||||
|
3. Or pre-authorize the VM subnet, then reboot:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo defaults write com.apple.network.local-network \
|
||||||
|
AllowedEthernetLocalNetworkAddresses -array "192.168.0.0/16"
|
||||||
|
```
|
||||||
|
|
||||||
|
Match the range to what your host's NAT actually hands out.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SSH times out on a freshly built image
|
||||||
|
|
||||||
|
**Symptom.** The VM boots and gets an IP, but SSH never connects. Attaching a display to the guest
|
||||||
|
shows **Setup Assistant** — the region/Apple ID welcome flow — rather than a login window.
|
||||||
|
|
||||||
|
**Cause.** The image builder uses `VZMacGuestProvisioningOptions` to create the admin account, enable
|
||||||
|
Remote Login, and skip Setup Assistant. That API requires **macOS 27 or newer in the guest as well
|
||||||
|
as the host**. An older guest **silently ignores** the options: no error, no account, no SSH server
|
||||||
|
— it just sits at first-run setup forever.
|
||||||
|
|
||||||
|
**Fix.** Rebuild the base image from a macOS 27+ IPSW:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
gitea-macos-runner image delete default
|
||||||
|
gitea-macos-runner image build --ipsw ~/Downloads/UniversalMac_27.0_XXXXX_Restore.ipsw
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify the host is also 27+ (`sw_vers`). There is no way to make a pre-27 guest work unattended
|
||||||
|
with this builder.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `SecKeyCreateRandomKey` / "Interaction is not allowed"
|
||||||
|
|
||||||
|
**Symptom.** The daemon starts but fails during VM setup with a Security-framework error mentioning
|
||||||
|
`SecKeyCreateRandomKey`, `errSecInteractionNotAllowed`, or "Interaction is not allowed". Often it
|
||||||
|
works when you run the daemon by hand in Terminal and fails under launchd.
|
||||||
|
|
||||||
|
**Cause.** The **`login.keychain` is locked.** macOS 15+ requires it unlocked for key operations the
|
||||||
|
VM lifecycle performs, and it is only unlocked inside a live, logged-in GUI session. A LaunchDaemon,
|
||||||
|
an SSH-only session, or a Mac sitting at the login window all fail this.
|
||||||
|
|
||||||
|
**Fix.**
|
||||||
|
|
||||||
|
1. Confirm the service is installed as a **LaunchAgent**, not a LaunchDaemon:
|
||||||
|
`gitea-macos-runner service install` does the right thing; a hand-written plist in
|
||||||
|
`/Library/LaunchDaemons` does not.
|
||||||
|
2. Ensure the runner user is actually logged in with the desktop loaded. Enable auto-login:
|
||||||
|
**System Settings → Users & Groups → Automatically log in as**.
|
||||||
|
3. Prevent the machine from returning to a locked state:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo pmset -a sleep 0 disablesleep 1
|
||||||
|
```
|
||||||
|
|
||||||
|
and disable "Require password after screen saver begins" for the runner user.
|
||||||
|
|
||||||
|
Connecting over Screen Sharing to a Mac at the login window does not unlock `login.keychain` for
|
||||||
|
launchd's session — auto-login is the reliable answer on a dedicated CI Mac.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Job stays queued and no VM boots
|
||||||
|
|
||||||
|
**Symptom.** The workflow shows as queued in Gitea indefinitely. Nothing appears in the daemon logs
|
||||||
|
about it.
|
||||||
|
|
||||||
|
**Causes and fixes, in the order worth checking:**
|
||||||
|
|
||||||
|
1. **Label mismatch.** `runs-on` must use **bare label names** (`macos-arm64`), and every label
|
||||||
|
listed must appear in the host config's `runner.labels`. The `:host` suffix used at registration
|
||||||
|
is runner-side only and must never appear in workflow YAML. A single typo produces exactly this
|
||||||
|
symptom with no error anywhere.
|
||||||
|
|
||||||
|
2. **Daemon not running or not polling.**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
gitea-macos-runner service status
|
||||||
|
log show --predicate 'process == "gitea-macos-runner"' --info --last 15m
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see a poll every `scheduler.pollIntervalSeconds`.
|
||||||
|
|
||||||
|
3. **Gitea too old.** The queued-jobs API with the `labels` field requires **Gitea ≥ 1.25**. On an
|
||||||
|
older instance the daemon can never see jobs. `doctor` reports the server version.
|
||||||
|
|
||||||
|
4. **Admin PAT wrong or under-scoped.** The token must belong to a **site admin** and carry
|
||||||
|
`read:admin` + `write:admin`. Test it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -H "Authorization: token $TOKEN" \
|
||||||
|
"https://gitea.example.com/api/v1/admin/actions/jobs?status=queued"
|
||||||
|
```
|
||||||
|
|
||||||
|
A 403 means scope or admin status; a 404 usually means the Gitea version predates the endpoint.
|
||||||
|
|
||||||
|
5. **A VM booted but its runner never came online.** Then the job is queued *and* you see VM
|
||||||
|
activity in the logs. Look at registration failures — most often an invalid or invalidated
|
||||||
|
registration token (see below).
|
||||||
|
|
||||||
|
6. **Job expired.** Gitea abandons a job after `ABANDONED_JOB_TIMEOUT` (default 24h). If the daemon
|
||||||
|
was down longer than that, the job is gone; re-run it.
|
||||||
|
|
||||||
|
### Registration fails with an invalid token
|
||||||
|
|
||||||
|
Registration tokens are reusable, but **creating a new token for a scope invalidates the previous
|
||||||
|
one**. If someone clicked "create new registration token" in the Gitea UI, the token in your
|
||||||
|
`registrationTokenFile` is now dead. Re-seed
|
||||||
|
`GITEA_RUNNER_REGISTRATION_TOKEN` on the server (and restart Gitea), or switch to
|
||||||
|
`fetchRegistrationTokenViaAPI: true`. See
|
||||||
|
[setup.md §1.3](setup.md#13-choose-a-registration-token-strategy).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `actions/checkout` fails instantly
|
||||||
|
|
||||||
|
**Symptom.** The job starts, the runner connects, and the very first step fails immediately —
|
||||||
|
typically a spawn error naming `node`, or an unhelpful non-zero exit before any output.
|
||||||
|
|
||||||
|
**Cause.** Gitea Actions' JavaScript actions (`actions/checkout` and most of the ecosystem) run by
|
||||||
|
spawning `node` inside the guest. **Node.js is required in the image**, and if provisioning was
|
||||||
|
interrupted it may be absent.
|
||||||
|
|
||||||
|
**Fix.** Confirm, then reprovision:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
gitea-macos-runner vm boot
|
||||||
|
ssh admin@<guest-ip> 'node --version && git --version'
|
||||||
|
|
||||||
|
gitea-macos-runner image provision default
|
||||||
|
```
|
||||||
|
|
||||||
|
If `git` is also missing, the provisioning step failed early — check the build log and rerun
|
||||||
|
provisioning.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Runner rows piling up in the Gitea UI
|
||||||
|
|
||||||
|
**Symptom.** **Site Administration → Actions → Runners** accumulates offline `macos-vm-…` entries.
|
||||||
|
|
||||||
|
**Cause.** Gitea deletes an ephemeral registration when its job completes normally. A VM that is
|
||||||
|
killed uncleanly — daemon crash, host power loss, `jobTimeoutMinutes` kill — never reaches that
|
||||||
|
point, so the row is orphaned.
|
||||||
|
|
||||||
|
**Fix.** Usually nothing: the daemon's reconcile loop sweeps orphaned registrations every
|
||||||
|
`scheduler.reconcileIntervalSeconds` (default 300) via
|
||||||
|
`DELETE /api/v1/admin/actions/runners/{id}`, plus a daily midnight sweep. Orphans should clear
|
||||||
|
within a few minutes.
|
||||||
|
|
||||||
|
If they persist, the daemon's admin PAT probably lacks `write:admin` — check the logs for delete
|
||||||
|
failures. To clear them by hand, delete the rows in the Gitea UI; they are inert (offline
|
||||||
|
registrations that have already been spent cannot receive jobs).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Disk filling up
|
||||||
|
|
||||||
|
**Symptom.** Free space falls steadily; the daemon starts refusing to launch VMs, citing
|
||||||
|
`storage.minFreeDiskGB`.
|
||||||
|
|
||||||
|
**Cause.** Each VM is an APFS copy-on-write clone of the base image. The clone is free at creation
|
||||||
|
but **grows as the job writes** — dependency caches, build outputs, Xcode's derived data. Clones
|
||||||
|
from uncleanly-killed VMs are not reclaimed automatically.
|
||||||
|
|
||||||
|
**Fix.**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# See what's there.
|
||||||
|
du -sh ~/Library/Application\ Support/gitea-macos-runner/*
|
||||||
|
ls -la ~/Library/Application\ Support/gitea-macos-runner/vms
|
||||||
|
|
||||||
|
# With the daemon stopped, remove stale clones.
|
||||||
|
gitea-macos-runner service uninstall # or stop the daemon
|
||||||
|
rm -rf ~/Library/Application\ Support/gitea-macos-runner/vms/<stale-clone>
|
||||||
|
gitea-macos-runner service install
|
||||||
|
```
|
||||||
|
|
||||||
|
Only delete entries under `vms/` — `images/` holds the base images you'd otherwise have to rebuild.
|
||||||
|
Longer term: raise `storage.minFreeDiskGB` so the guard trips earlier, delete unused base images
|
||||||
|
with `image delete`, and remember an Xcode image needs 140 GB+ of headroom, more with two
|
||||||
|
concurrent clones diverging.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `image build` hangs at install
|
||||||
|
|
||||||
|
**Symptom.** `image build` sits for a long time at the macOS install phase with little visible
|
||||||
|
progress.
|
||||||
|
|
||||||
|
**Cause.** Usually none — installing macOS from an IPSW genuinely takes a long time (tens of
|
||||||
|
minutes, longer on slower storage). The install phase is largely silent.
|
||||||
|
|
||||||
|
**Fix.** **Wait, and do not stop the VM mid-install.** Interrupting the installer leaves the disk
|
||||||
|
image in an undefined state; the resulting image may boot and then fail in confusing ways later.
|
||||||
|
There is no resume.
|
||||||
|
|
||||||
|
If you did interrupt it, or the build genuinely failed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
gitea-macos-runner image delete <name>
|
||||||
|
gitea-macos-runner image build --ipsw <path> --name <name>
|
||||||
|
```
|
||||||
|
|
||||||
|
Before rebuilding, verify the IPSW is complete and matches your host architecture (Apple Silicon)
|
||||||
|
and version requirement (macOS 27+ for unattended provisioning), and that you have enough free disk
|
||||||
|
for the IPSW plus the target disk size.
|
||||||
Reference in New Issue
Block a user