Compare commits
18
Commits
8c410cf841
...
af0369d443
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af0369d443
|
||
|
|
042bd813a8
|
||
|
|
b84835649c
|
||
|
|
c71a457bcf
|
||
|
|
adb7dedd80
|
||
|
|
bddb120a56
|
||
|
|
748bc7e5e5
|
||
|
|
e7163de22f
|
||
|
|
3b4f631dd8
|
||
|
|
b2f15883d8
|
||
|
|
d64b3f3e9b
|
||
|
|
ab4ed9c8a1
|
||
|
|
697a4cdcd0
|
||
|
|
975cf8c6ea
|
||
|
|
40697b8329
|
||
|
|
9edaa3e409
|
||
|
|
11019d498b
|
||
|
|
33f299396a
|
@@ -0,0 +1,74 @@
|
|||||||
|
# Builds gitea-macos-runner on the macOS runners gitea-macos-runner itself
|
||||||
|
# provides. The repo is its own integration test: if this workflow goes green,
|
||||||
|
# a guest image really can check out a repo, run a Swift toolchain, and produce
|
||||||
|
# a signed .app.
|
||||||
|
name: build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
# The bare label the daemon registers with. There is no container image
|
||||||
|
# here: jobs run in `:host` mode, directly in an ephemeral macOS 27 guest
|
||||||
|
# as the admin account, and the guest is destroyed afterwards.
|
||||||
|
runs-on: macos-arm64
|
||||||
|
|
||||||
|
# Well under the scheduler's 120-minute job ceiling. A clean release build
|
||||||
|
# plus the test suite is a few minutes; anything approaching an hour means
|
||||||
|
# something is wedged and the VM should be reclaimed rather than left
|
||||||
|
# holding one of the host's two guest slots.
|
||||||
|
timeout-minutes: 60
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# Needs Node.js in the guest, which the base image provisions.
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# First thing in the log, deliberately. Every plausible failure of this
|
||||||
|
# workflow that is not the code's fault is a toolchain that did not make
|
||||||
|
# it into the image — Command Line Tools missing, or present but not
|
||||||
|
# selected. Printing this up front turns "swift: command not found"
|
||||||
|
# forty lines down into a one-glance diagnosis.
|
||||||
|
- name: toolchain versions
|
||||||
|
run: |
|
||||||
|
sw_vers
|
||||||
|
swift --version
|
||||||
|
xcode-select -p
|
||||||
|
|
||||||
|
# RunnerCoreTests only. RunnerHost is compiled (it is a dependency) but
|
||||||
|
# never exercised: nothing here touches Virtualization.framework at
|
||||||
|
# runtime, which matters because the guest cannot nest VMs.
|
||||||
|
- name: unit tests
|
||||||
|
run: swift test
|
||||||
|
|
||||||
|
# Release build, .app assembly, ad-hoc signature. `codesign --sign -`
|
||||||
|
# needs no signing identity and no keychain, so it works unattended in a
|
||||||
|
# throwaway guest — which is the same property that makes it the
|
||||||
|
# project's shipping signature.
|
||||||
|
- name: build and sign the app bundle
|
||||||
|
run: make all
|
||||||
|
|
||||||
|
# Proves the two things a bare `swift build` cannot: that the entitlement
|
||||||
|
# survived signing, and that the runtime resources were copied in. An
|
||||||
|
# .app missing either compiles perfectly and then fails at the first
|
||||||
|
# `image build` — exactly the class of breakage worth catching in CI.
|
||||||
|
- name: verify the bundle
|
||||||
|
# `grep … && echo` would be a check that cannot fail: bash exempts
|
||||||
|
# every command in an `&&` list except the last one from `set -e`, so a
|
||||||
|
# bundle signed with no entitlements at all would sail through. The
|
||||||
|
# grep therefore stands alone, as the step's own pass/fail.
|
||||||
|
run: |
|
||||||
|
codesign -d --entitlements - --xml .build/GiteaMacosRunner.app | grep -q virtualization
|
||||||
|
echo "entitlement OK"
|
||||||
|
ls .build/GiteaMacosRunner.app/Contents/Resources/
|
||||||
|
|
||||||
|
# Deliberately absent: `doctor`, `vm`, `daemon`, and `image` steps.
|
||||||
|
#
|
||||||
|
# All four either start a VM or check the host's ability to start one, and this
|
||||||
|
# job is already running inside a guest. Virtualization.framework does not
|
||||||
|
# nest, so those steps would not be a stricter test — they would be a
|
||||||
|
# guaranteed failure that says nothing about the code. The host-side behaviour
|
||||||
|
# they cover is verified on a real host, not here.
|
||||||
@@ -105,6 +105,25 @@ jobs:
|
|||||||
The daemon picks the job up within one poll interval, boots a VM, and tears it down when the job
|
The daemon picks the job up within one poll interval, boots a VM, and tears it down when the job
|
||||||
finishes.
|
finishes.
|
||||||
|
|
||||||
|
## CI
|
||||||
|
|
||||||
|
This repo builds itself. [`.gitea/workflows/build.yml`](.gitea/workflows/build.yml) runs on
|
||||||
|
`macos-arm64` — the very runners this project provides — and does a full `swift test`, `make all`,
|
||||||
|
and a check that the resulting `.app` carries the virtualization entitlement and its runtime
|
||||||
|
resources. A green run is also an end-to-end test of the runner: it means a guest image really can
|
||||||
|
check out a repo, run the Swift toolchain, and produce a signed bundle.
|
||||||
|
|
||||||
|
For it to run at all you need:
|
||||||
|
|
||||||
|
- the repo pushed to a Gitea **1.25 or newer** instance with Actions enabled
|
||||||
|
(`[actions] ENABLED = true`),
|
||||||
|
- `gitea-macos-runner daemon` running on an Apple silicon host and registered with that instance,
|
||||||
|
- a base image built and provisioned (`image build`) — `image list` should show `PROVISIONED: yes`.
|
||||||
|
|
||||||
|
The workflow deliberately runs no `doctor`, `vm`, `daemon`, or `image` steps. Those start a VM or
|
||||||
|
probe the host's ability to start one, and the job is already inside a guest; Virtualization
|
||||||
|
does not nest, so they would fail for reasons that say nothing about the code.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [docs/setup.md](docs/setup.md) — full Gitea-side and host-side walkthrough, config reference,
|
- [docs/setup.md](docs/setup.md) — full Gitea-side and host-side walkthrough, config reference,
|
||||||
|
|||||||
+10
-2
@@ -124,8 +124,16 @@ fi
|
|||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
log "ensuring /usr/local/bin exists and is on PATH for non-login shells"
|
log "ensuring /usr/local/bin exists and is on PATH for non-login shells"
|
||||||
mkdir -p /usr/local/bin
|
mkdir -p /usr/local/bin
|
||||||
chown root:wheel /usr/local /usr/local/bin
|
# Best-effort, deliberately: on a stock macOS 27 install /usr/local already
|
||||||
chmod 755 /usr/local /usr/local/bin
|
# exists, already is root:wheel 755, and is SIP-protected — so chown and chmod
|
||||||
|
# on it fail with "Operation not permitted" even as root. The directory is
|
||||||
|
# already exactly as we want it, so treating that refusal as a build failure
|
||||||
|
# would abort provisioning over a no-op. When /usr/local really was ours to
|
||||||
|
# create, these succeed.
|
||||||
|
chown root:wheel /usr/local /usr/local/bin 2>/dev/null \
|
||||||
|
|| warn "could not chown /usr/local (system-owned and SIP-protected; already correct)"
|
||||||
|
chmod 755 /usr/local /usr/local/bin 2>/dev/null \
|
||||||
|
|| warn "could not chmod /usr/local (system-owned and SIP-protected; already correct)"
|
||||||
|
|
||||||
ZSHENV_MARKER="# gitea-macos-runner: ensure /usr/local/bin on PATH"
|
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
|
if [ ! -f /etc/zshenv ] || ! grep -qF "$ZSHENV_MARKER" /etc/zshenv 2>/dev/null; then
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
#if canImport(Darwin)
|
||||||
|
import Darwin
|
||||||
|
#elseif canImport(Glibc)
|
||||||
|
import Glibc
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// Resolves a path typed on the command line into a single concrete path.
|
||||||
|
///
|
||||||
|
/// The problem this exists for: an operator writes
|
||||||
|
/// `--ipsw ~/Downloads/UniversalMac_27.0_*.ipsw`. Unquoted, the shell may expand
|
||||||
|
/// the glob before we ever run — or, in `zsh`, refuse to run the command at all
|
||||||
|
/// with `no matches found`. Quoted, we receive the pattern verbatim, tilde and
|
||||||
|
/// asterisk included, and a plain `expandingTildeInPath` leaves a `*` sitting in
|
||||||
|
/// the middle of a path that will never exist. Either way the operator sees a
|
||||||
|
/// tool that "only works with quotes" (or only without them).
|
||||||
|
///
|
||||||
|
/// So the CLI resolves the argument itself and stops depending on which shell
|
||||||
|
/// ran it. This is deliberately a **CLI-argument affordance only**: values read
|
||||||
|
/// out of `config.json` get tilde expansion (see
|
||||||
|
/// ``RunnerConfig/expandTilde(_:)``) and nothing more, because a config file is
|
||||||
|
/// not typed at a prompt and a stray `*` there is a mistake, not a pattern.
|
||||||
|
public enum PathResolution {
|
||||||
|
|
||||||
|
/// Characters that make a string a pattern rather than a path.
|
||||||
|
///
|
||||||
|
/// `~` is not one of them: it is expanded unconditionally, pattern or not.
|
||||||
|
private static let metacharacters: Set<Character> = ["*", "?", "["]
|
||||||
|
|
||||||
|
/// Whether `path` should be treated as a glob pattern.
|
||||||
|
public static func isPattern(_ path: String) -> Bool {
|
||||||
|
path.contains(where: metacharacters.contains)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expands a leading `~`, then resolves any glob pattern to one path.
|
||||||
|
///
|
||||||
|
/// A string with no metacharacters passes through with only tilde
|
||||||
|
/// expansion — in particular it is *not* checked for existence, so the
|
||||||
|
/// caller's own error (which knows what the file was for) is what an
|
||||||
|
/// operator sees for an ordinary typo.
|
||||||
|
///
|
||||||
|
/// A string that does contain metacharacters is matched with `glob(3)`. If
|
||||||
|
/// it matches nothing but a file exists at that literal name, the literal
|
||||||
|
/// wins: `report[1].ipsw` is a legal filename, and only failing to match
|
||||||
|
/// tells us it was meant as one rather than as a character class.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - path: The raw argument, as typed.
|
||||||
|
/// - label: What the argument names, for error messages (`"--ipsw"`).
|
||||||
|
/// - Returns: A single concrete path.
|
||||||
|
/// - Throws: ``CoreError/notFound(_:)`` when a pattern matches nothing, or
|
||||||
|
/// ``CoreError/configInvalid(_:)`` when it matches more than one file —
|
||||||
|
/// picking one arbitrarily would silently build the wrong image.
|
||||||
|
public static func resolve(_ path: String, label: String) throws -> String {
|
||||||
|
let expanded = RunnerConfig.expandTilde(path)
|
||||||
|
guard isPattern(expanded) else { return expanded }
|
||||||
|
|
||||||
|
let matches = glob(pattern: expanded)
|
||||||
|
|
||||||
|
switch matches.count {
|
||||||
|
case 1:
|
||||||
|
return matches[0]
|
||||||
|
case 0:
|
||||||
|
if FileManager.default.fileExists(atPath: expanded) { return expanded }
|
||||||
|
throw CoreError.notFound("\(label): no file matches \(expanded)")
|
||||||
|
default:
|
||||||
|
let listed = matches.map { " \($0)" }.joined(separator: "\n")
|
||||||
|
throw CoreError.configInvalid(
|
||||||
|
"\(label): \(matches.count) files match \(expanded):\n\(listed)\n"
|
||||||
|
+ "name exactly one of them"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All paths matching `pattern`, sorted.
|
||||||
|
///
|
||||||
|
/// Sorted explicitly rather than relying on `glob(3)`'s own ordering, which
|
||||||
|
/// is locale-dependent — the error message above lists these, and a listing
|
||||||
|
/// that reorders between runs is a poor thing to ask someone to read.
|
||||||
|
static func glob(pattern: String) -> [String] {
|
||||||
|
var result = glob_t()
|
||||||
|
defer { globfree(&result) }
|
||||||
|
|
||||||
|
guard Glibc_glob(pattern, &result) == 0 else { return [] }
|
||||||
|
guard let paths = result.gl_pathv else { return [] }
|
||||||
|
|
||||||
|
var found: [String] = []
|
||||||
|
for index in 0..<Int(result.gl_pathc) {
|
||||||
|
guard let entry = paths[index] else { continue }
|
||||||
|
found.append(String(cString: entry))
|
||||||
|
}
|
||||||
|
return found.sorted()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Thin shim so the call above reads the same on both platforms; `glob(3)`
|
||||||
|
/// is otherwise shadowed by the ``glob(pattern:)`` above.
|
||||||
|
private static func Glibc_glob(
|
||||||
|
_ pattern: String,
|
||||||
|
_ result: UnsafeMutablePointer<glob_t>
|
||||||
|
) -> Int32 {
|
||||||
|
#if canImport(Darwin)
|
||||||
|
return Darwin.glob(pattern, 0, nil, result)
|
||||||
|
#elseif canImport(Glibc)
|
||||||
|
return Glibc.glob(pattern, 0, nil, result)
|
||||||
|
#else
|
||||||
|
return -1
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cheap sanity checks on a `.ipsw` before Virtualization.framework sees it.
|
||||||
|
///
|
||||||
|
/// Lives beside ``PathResolution`` because it is the other half of the same
|
||||||
|
/// job — what the CLI does with a path an operator typed — and because
|
||||||
|
/// `RunnerCore` is the only target that unit-tests on both platforms.
|
||||||
|
///
|
||||||
|
/// The checks earn their place: `VZMacOSRestoreImage.image(from:)` reports no
|
||||||
|
/// progress at all while it works, and on a partial download it can sit for a
|
||||||
|
/// very long time rather than failing. Without these, "I pointed it at the
|
||||||
|
/// wrong file" and "my 21 GB download stopped at 4 GB" both present to the
|
||||||
|
/// operator as an unexplained hang.
|
||||||
|
public enum IPSWFile {
|
||||||
|
/// The floor a real macOS restore image clears by an order of magnitude —
|
||||||
|
/// they run ~15-22 GB. Anything under this is a truncated download, a
|
||||||
|
/// placeholder, or the wrong file entirely.
|
||||||
|
public static let minimumBytes: Int64 = 1_000_000_000
|
||||||
|
|
||||||
|
/// The first two bytes of every `.ipsw`: an IPSW is a zip archive.
|
||||||
|
static let zipMagic = Data([0x50, 0x4B]) // "PK"
|
||||||
|
|
||||||
|
/// Fails fast if `path` cannot be a usable restore image.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - path: An already-resolved absolute path (see ``PathResolution``).
|
||||||
|
/// - label: What the file is, for error messages.
|
||||||
|
/// - Throws: ``CoreError/notFound(_:)`` if it is not there or unreadable,
|
||||||
|
/// ``CoreError/configInvalid(_:)`` if it is there but cannot be an IPSW.
|
||||||
|
public static func validate(path: String, label: String = "restore image") throws {
|
||||||
|
var isDirectory: ObjCBool = false
|
||||||
|
guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) else {
|
||||||
|
throw CoreError.notFound("\(label) not found at \(path)")
|
||||||
|
}
|
||||||
|
guard !isDirectory.boolValue else {
|
||||||
|
throw CoreError.configInvalid(
|
||||||
|
"\(label) at \(path) is a directory, not an .ipsw file"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let attributes = try? FileManager.default.attributesOfItem(atPath: path)
|
||||||
|
let size = (attributes?[.size] as? NSNumber)?.int64Value ?? 0
|
||||||
|
guard size >= minimumBytes else {
|
||||||
|
throw CoreError.configInvalid(
|
||||||
|
"\(label) at \(path) is only \(describeSize(size)) — a macOS restore image is "
|
||||||
|
+ "~15-22 GB. The download is most likely incomplete; delete the file and "
|
||||||
|
+ "fetch it again."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let handle = FileHandle(forReadingAtPath: path) else {
|
||||||
|
throw CoreError.notFound("\(label) at \(path) could not be opened for reading")
|
||||||
|
}
|
||||||
|
defer { try? handle.close() }
|
||||||
|
|
||||||
|
let magic = (try? handle.read(upToCount: zipMagic.count)) ?? Data()
|
||||||
|
guard magic == zipMagic else {
|
||||||
|
let found = magic.map { String(format: "%02x", $0) }.joined()
|
||||||
|
throw CoreError.configInvalid(
|
||||||
|
"\(label) at \(path) does not look like an .ipsw: expected a zip archive "
|
||||||
|
+ "(magic \"PK\", 504b) but the file starts with \(found.isEmpty ? "nothing" : found). "
|
||||||
|
+ "Check the path, or re-download the file."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A byte count an operator can compare against "~15 GB" at a glance.
|
||||||
|
static func describeSize(_ 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)) B" : String(format: "%.1f %@", value, units[unit])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -313,11 +313,48 @@ enum SSHTransportError: Error {
|
|||||||
var asCoreError: CoreError {
|
var asCoreError: CoreError {
|
||||||
switch self {
|
switch self {
|
||||||
case .connectFailed(let host, let port, let underlying):
|
case .connectFailed(let host, let port, let underlying):
|
||||||
return .sshFailed("cannot connect to \(host):\(port): \(underlying)")
|
return .sshFailed(
|
||||||
|
"cannot connect to \(host):\(port): \(underlying)"
|
||||||
|
+ Self.localNetworkHint(for: underlying)
|
||||||
|
)
|
||||||
case .authenticationFailed(let host, let username):
|
case .authenticationFailed(let host, let username):
|
||||||
return .sshFailed("authentication failed for \(username)@\(host)")
|
return .sshFailed("authentication failed for \(username)@\(host)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extra guidance for the one connect failure that is usually not a network
|
||||||
|
/// problem at all.
|
||||||
|
///
|
||||||
|
/// macOS 15 and newer filter local-network traffic per app, and a blocked
|
||||||
|
/// flow is not reported as "denied": the filter answers `EHOSTUNREACH`
|
||||||
|
/// (errno 65, "No route to host"), which is exactly what a guest that is
|
||||||
|
/// genuinely off the network looks like. Guests here sit on a host-private
|
||||||
|
/// NAT link that is reachable whenever the VM is up, so on this code path
|
||||||
|
/// that errno is more often the privacy filter than a routing failure —
|
||||||
|
/// worth naming rather than leaving the operator to guess.
|
||||||
|
///
|
||||||
|
/// It matters most right after a rebuild. Per
|
||||||
|
/// [TN3179](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy)
|
||||||
|
/// the grant "uses your main executable UUID", and the linker mints a fresh
|
||||||
|
/// `LC_UUID` on essentially every build — so `make install` can present a
|
||||||
|
/// program macOS has never seen, whose permission is undetermined again,
|
||||||
|
/// even though the previous binary worked minutes earlier.
|
||||||
|
static func localNetworkHint(for underlying: any Error) -> String {
|
||||||
|
let text = "\(underlying)".lowercased()
|
||||||
|
guard text.contains("errno: 65") || text.contains("no route to host")
|
||||||
|
|| text.contains("host is unreachable")
|
||||||
|
else { return "" }
|
||||||
|
|
||||||
|
return """
|
||||||
|
(on macOS 15+ this is also what Local Network privacy returns when \
|
||||||
|
it blocks an app — and the grant is keyed on the executable's UUID, \
|
||||||
|
so every rebuild withdraws it. Pre-authorize the guest subnet \
|
||||||
|
instead: sudo defaults write com.apple.network.local-network \
|
||||||
|
AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/18" — same \
|
||||||
|
for AllowedWiFiLocalNetworkAddresses — then reboot. \
|
||||||
|
See docs/troubleshooting.md)
|
||||||
|
"""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shared, thread-safe record of whether the server rejected our password.
|
/// Shared, thread-safe record of whether the server rejected our password.
|
||||||
@@ -584,6 +621,15 @@ public func waitForSSH(
|
|||||||
guard ContinuousClock.now - started < timeout else { break }
|
guard ContinuousClock.now - started < timeout else { break }
|
||||||
}
|
}
|
||||||
|
|
||||||
let detail = lastError.map { "; last error: \($0)" } ?? ""
|
// Rendered through `asCoreError` rather than interpolated raw: a connect
|
||||||
|
// failure is where the Local Network privacy hint lives, and the timeout
|
||||||
|
// message is the *only* place most operators will ever see the last error.
|
||||||
|
let detail: String
|
||||||
|
if let lastError {
|
||||||
|
let rendered = (lastError as? SSHTransportError).map { "\($0.asCoreError)" } ?? "\(lastError)"
|
||||||
|
detail = "; last error: \(rendered)"
|
||||||
|
} else {
|
||||||
|
detail = ""
|
||||||
|
}
|
||||||
throw CoreError.timeout("ssh on \(host):\(port)\(detail)")
|
throw CoreError.timeout("ssh on \(host):\(port)\(detail)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The decisions in an Xcode install that are pure string and arithmetic work.
|
||||||
|
///
|
||||||
|
/// Installing Xcode into a guest is a long chain of SSH commands, and the parts
|
||||||
|
/// of it that are easy to get wrong — which `.app` came out of the archive, how
|
||||||
|
/// much disk the expansion is going to want, what `df` actually said — are all
|
||||||
|
/// decidable from text. They live here so they can be tested without a VM,
|
||||||
|
/// which is the only way they ever get tested: the surrounding code takes forty
|
||||||
|
/// minutes and a 12 GB file to run once.
|
||||||
|
public enum XcodeInstall {
|
||||||
|
|
||||||
|
// MARK: - Which app came out of the archive
|
||||||
|
|
||||||
|
/// Picks the expanded application bundle out of an `ls -d …/*.app` listing.
|
||||||
|
///
|
||||||
|
/// The archive's payload is *not* reliably named `Xcode.app`. Beta releases
|
||||||
|
/// expand to `Xcode-beta.app`, and Apple has shipped version-qualified names
|
||||||
|
/// before, so the name has to be discovered rather than assumed — hardcoding
|
||||||
|
/// it is what made a successful 12 GB upload and a 30-minute expansion fail
|
||||||
|
/// on the very last `mv`.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - listing: Standard output of `ls -d <staging>/*.app`.
|
||||||
|
/// - staging: The directory that was listed, named in error messages.
|
||||||
|
/// - Returns: The full path of the single matching bundle.
|
||||||
|
/// - Throws: ``CoreError/provisioningFailed(_:)`` for zero or several
|
||||||
|
/// matches. Both are ambiguous rather than recoverable: picking one of two
|
||||||
|
/// candidates would install something the operator did not ask for.
|
||||||
|
public static func expandedAppPath(fromListing listing: String, staging: String) throws -> String {
|
||||||
|
let candidates = listing
|
||||||
|
.split(separator: "\n")
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
// An unmatched glob is echoed back verbatim by /bin/sh, so the
|
||||||
|
// no-match case arrives looking like a path that ends in `*.app`.
|
||||||
|
.filter { !$0.contains("*") }
|
||||||
|
|
||||||
|
switch candidates.count {
|
||||||
|
case 1:
|
||||||
|
return candidates[0]
|
||||||
|
case 0:
|
||||||
|
throw CoreError.provisioningFailed(
|
||||||
|
"the Xcode archive expanded but produced no .app in \(staging). "
|
||||||
|
+ "The download may be truncated — check the .xip and try again."
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
let names = candidates.map { ($0 as NSString).lastPathComponent }
|
||||||
|
throw CoreError.provisioningFailed(
|
||||||
|
"the Xcode archive expanded to \(candidates.count) applications in \(staging) "
|
||||||
|
+ "(\(names.joined(separator: ", "))), so it is not clear which to install. "
|
||||||
|
+ "Expand the .xip by hand and pass a single-application archive."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Disk
|
||||||
|
|
||||||
|
/// How much room the expansion of an archive is expected to need, excluding
|
||||||
|
/// the archive itself.
|
||||||
|
///
|
||||||
|
/// A `.xip` is an LZMA-compressed cpio of the whole application, and Xcode
|
||||||
|
/// compresses well: recent releases land near 3.5× on expansion. This is an
|
||||||
|
/// estimate used for a pre-flight, so it is deliberately the ratio at the
|
||||||
|
/// pessimistic end of what has been observed rather than an average — the
|
||||||
|
/// cost of overestimating is a clear error message, and the cost of
|
||||||
|
/// underestimating is a guest that runs out of disk 35 minutes in.
|
||||||
|
public static func expansionEstimateBytes(xipBytes: Int) -> Int {
|
||||||
|
(xipBytes * 7) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total free space the guest needs before the upload starts: the uploaded
|
||||||
|
/// archive plus everything it expands into.
|
||||||
|
///
|
||||||
|
/// Both have to coexist — `xip --expand` reads the archive while it writes —
|
||||||
|
/// and the archive is deleted as soon as the expansion succeeds, before the
|
||||||
|
/// move, which is a same-volume rename that needs no headroom of its own.
|
||||||
|
public static func requiredFreeBytes(xipBytes: Int) -> Int {
|
||||||
|
xipBytes + expansionEstimateBytes(xipBytes: xipBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the available-bytes column out of `df -Pk` output.
|
||||||
|
///
|
||||||
|
/// `-P` matters: without it `df` wraps a long device name onto its own line
|
||||||
|
/// and the columns stop lining up. `-k` fixes the block size at 1024, so the
|
||||||
|
/// value does not depend on the guest's `BLOCKSIZE`.
|
||||||
|
///
|
||||||
|
/// - Returns: Free bytes, or `nil` if the output was not in the expected
|
||||||
|
/// shape — the caller treats that as "could not check" rather than as a
|
||||||
|
/// failure, since refusing to install because `df` was unparseable would
|
||||||
|
/// be worse than the risk it guards against.
|
||||||
|
public static func availableBytes(dfOutput: String) -> Int? {
|
||||||
|
for line in dfOutput.split(separator: "\n") {
|
||||||
|
let fields = line.split(whereSeparator: \.isWhitespace)
|
||||||
|
guard fields.count >= 4, fields[0] != "Filesystem",
|
||||||
|
let kilobytes = Int(fields[3])
|
||||||
|
else { continue }
|
||||||
|
return kilobytes * 1024
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders a byte count the way the progress lines do, e.g. `12.4 GB`.
|
||||||
|
///
|
||||||
|
/// Decimal gigabytes, matching how the archives are advertised and how
|
||||||
|
/// Finder reports them, so the number in an error message is the number the
|
||||||
|
/// operator can see on their own disk.
|
||||||
|
public static func formatGB(_ bytes: Int) -> String {
|
||||||
|
String(format: "%.1f GB", Double(bytes) / 1_000_000_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The message shown when the guest cannot fit the install.
|
||||||
|
///
|
||||||
|
/// Built here, with the numbers spelled out, because "no space left on
|
||||||
|
/// device" 35 minutes into an expansion tells the operator nothing about how
|
||||||
|
/// much bigger the image needed to be.
|
||||||
|
public static func insufficientDiskMessage(xipBytes: Int, availableBytes: Int) -> String {
|
||||||
|
let needed = requiredFreeBytes(xipBytes: xipBytes)
|
||||||
|
return """
|
||||||
|
not enough free disk in the guest to install Xcode: \
|
||||||
|
\(formatGB(availableBytes)) available, about \(formatGB(needed)) needed \
|
||||||
|
(\(formatGB(xipBytes)) for the archive plus roughly \
|
||||||
|
\(formatGB(expansionEstimateBytes(xipBytes: xipBytes))) once expanded).
|
||||||
|
|
||||||
|
Rebuild the base image with a larger disk, e.g.:
|
||||||
|
gitea-macos-runner image delete <name>
|
||||||
|
gitea-macos-runner image build --ipsw <path> --disk-gb 200
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -525,19 +525,39 @@ public enum Doctor {
|
|||||||
|
|
||||||
/// The macOS 15+ Local Network permission note.
|
/// The macOS 15+ Local Network permission note.
|
||||||
///
|
///
|
||||||
/// Reports `.pass` when the host carries a subnet allowlist, because that
|
/// Reports `.pass` when the host carries a subnet allowlist that actually
|
||||||
/// bypasses the prompt entirely. Otherwise it stays informational: we
|
/// covers where guests turn up, because that bypasses the prompt entirely.
|
||||||
/// cannot see the grant itself, since Local Network privacy is a Network
|
/// An allowlist that names some *other* subnet is worse than none, since it
|
||||||
/// Extension packet filter rather than a TCC entry, so there is no
|
/// looks configured while blocking every guest, so it warns rather than
|
||||||
/// database to query and `tccutil` does not apply (Apple, TN3179).
|
/// passing. Without one this stays informational: we cannot see the grant
|
||||||
|
/// itself, since Local Network privacy is a Network Extension packet filter
|
||||||
|
/// rather than a TCC entry, so there is no database to query and `tccutil`
|
||||||
|
/// does not apply (Apple, TN3179).
|
||||||
public static func localNetworkNote() -> DoctorCheck {
|
public static func localNetworkNote() -> DoctorCheck {
|
||||||
let name = "local network access"
|
let name = "local network access"
|
||||||
let allowed = localNetworkAllowlist()
|
let allowed = localNetworkAllowlist()
|
||||||
if !allowed.isEmpty {
|
if !allowed.isEmpty {
|
||||||
|
if allowed.contains(where: coversVMNetRange) {
|
||||||
|
return DoctorCheck(
|
||||||
|
name: name,
|
||||||
|
result: .pass,
|
||||||
|
detail: "subnet allowlist set: \(allowed.joined(separator: ", "))"
|
||||||
|
)
|
||||||
|
}
|
||||||
return DoctorCheck(
|
return DoctorCheck(
|
||||||
name: name,
|
name: name,
|
||||||
result: .pass,
|
result: .warn,
|
||||||
detail: "subnet allowlist set: \(allowed.joined(separator: ", "))"
|
detail: "subnet allowlist set but does not cover the guest range: "
|
||||||
|
+ allowed.joined(separator: ", "),
|
||||||
|
remediation: """
|
||||||
|
Virtualization.framework's NAT does not stay on 192.168.64.0/24 — it moves \
|
||||||
|
to the next free /24 (192.168.65.x, .66.x, …) when one is taken, so \
|
||||||
|
an allowlist pinned to a single /24 stops working the day the subnet shifts \
|
||||||
|
and every guest connection then fails with "No route to host". Widen it to \
|
||||||
|
cover the whole span: sudo defaults write com.apple.network.local-network \
|
||||||
|
AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/18" (same for \
|
||||||
|
AllowedWiFiLocalNetworkAddresses), then reboot. See docs/setup.md §2.6.
|
||||||
|
"""
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -554,11 +574,51 @@ public enum Doctor {
|
|||||||
Terminal in the GUI session. On an unattended CI host prefer the subnet \
|
Terminal in the GUI session. On an unattended CI host prefer the subnet \
|
||||||
allowlist, which needs no prompt and survives rebuilds: sudo defaults write \
|
allowlist, which needs no prompt and survives rebuilds: sudo defaults write \
|
||||||
com.apple.network.local-network AllowedEthernetLocalNetworkAddresses -array \
|
com.apple.network.local-network AllowedEthernetLocalNetworkAddresses -array \
|
||||||
"192.168.64.0/24" (then reboot). See docs/setup.md §2.6.
|
"192.168.64.0/18" (then reboot). See docs/setup.md §2.6.
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The span of addresses a vmnet NAT link can plausibly use.
|
||||||
|
///
|
||||||
|
/// `192.168.64.0/24` is only the *first* choice: the subnet is picked at
|
||||||
|
/// runtime and steps to the next free /24 when that one is already in use,
|
||||||
|
/// which is why a host that worked yesterday can hand out `192.168.65.x`
|
||||||
|
/// today. Everything from 192.168.64.0 to 192.168.127.255 — a /18 — is
|
||||||
|
/// treated as guest territory so the allowlist survives that drift.
|
||||||
|
static let vmNetFirstAddress: UInt32 = 0xC0A8_4000 // 192.168.64.0
|
||||||
|
static let vmNetLastAddress: UInt32 = 0xC0A8_7FFF // 192.168.127.255
|
||||||
|
|
||||||
|
/// Whether one allowlist entry covers the whole guest range.
|
||||||
|
///
|
||||||
|
/// Deliberately all-or-nothing: partial cover is the failure mode being
|
||||||
|
/// warned about, so an entry that contains today's subnet but not
|
||||||
|
/// tomorrow's is not treated as good enough.
|
||||||
|
static func coversVMNetRange(_ entry: String) -> Bool {
|
||||||
|
let parts = entry.split(separator: "/", maxSplits: 1)
|
||||||
|
guard let base = ipv4Value(String(parts[0])) else { return false }
|
||||||
|
let prefix = parts.count == 2 ? Int(parts[1]) : 32
|
||||||
|
guard let prefix, (0...32).contains(prefix) else { return false }
|
||||||
|
|
||||||
|
let mask: UInt32 = prefix == 0 ? 0 : ~UInt32(0) << (32 - prefix)
|
||||||
|
let network = base & mask
|
||||||
|
let broadcast = network | ~mask
|
||||||
|
return network <= vmNetFirstAddress && broadcast >= vmNetLastAddress
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Packs dotted-quad IPv4 into a comparable integer; nil for anything else
|
||||||
|
/// (an IPv6 entry, a hostname, a typo).
|
||||||
|
static func ipv4Value(_ text: String) -> UInt32? {
|
||||||
|
let octets = text.split(separator: ".", omittingEmptySubsequences: false)
|
||||||
|
guard octets.count == 4 else { return nil }
|
||||||
|
var value: UInt32 = 0
|
||||||
|
for octet in octets {
|
||||||
|
guard let number = UInt32(octet), number <= 255 else { return nil }
|
||||||
|
value = value << 8 | number
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
/// Subnets pre-authorized for local network access on this host, if any.
|
/// Subnets pre-authorized for local network access on this host, if any.
|
||||||
///
|
///
|
||||||
/// Best effort and never fatal: an unreadable or absent preferences file
|
/// Best effort and never fatal: an unreadable or absent preferences file
|
||||||
|
|||||||
@@ -304,53 +304,134 @@ public struct GuestProvisioner: Sendable {
|
|||||||
/// running `xcodebuild -runFirstLaunch` so the first job does not pay for
|
/// running `xcodebuild -runFirstLaunch` so the first job does not pay for
|
||||||
/// component installation.
|
/// component installation.
|
||||||
///
|
///
|
||||||
|
/// The application's name is **discovered, not assumed**. A release archive
|
||||||
|
/// expands to `Xcode.app`, a beta to `Xcode-beta.app`, and Apple has shipped
|
||||||
|
/// version-qualified names too; whatever comes out keeps its name under
|
||||||
|
/// `/Applications`, because `xcode-select -s` makes the name irrelevant to
|
||||||
|
/// anything that builds.
|
||||||
|
///
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - executor: A connected guest executor.
|
/// - executor: A connected guest executor.
|
||||||
/// - xipPath: Path to the `.xip` **on the host**; it is uploaded.
|
/// - xipPath: Path to the `.xip` **on the host**; it is uploaded.
|
||||||
public func installXcode(executor: any GuestExecutor, xipPath: String) async throws {
|
/// - progress: Optional stage callback. Every phase here runs for tens of
|
||||||
|
/// minutes, so silence is indistinguishable from a hang — this is the
|
||||||
|
/// only thing that says otherwise.
|
||||||
|
public func installXcode(
|
||||||
|
executor: any GuestExecutor,
|
||||||
|
xipPath: String,
|
||||||
|
progress: (@Sendable (String) -> Void)? = nil
|
||||||
|
) async throws {
|
||||||
let localURL = URL(fileURLWithPath: (xipPath as NSString).expandingTildeInPath)
|
let localURL = URL(fileURLWithPath: (xipPath as NSString).expandingTildeInPath)
|
||||||
guard FileManager.default.fileExists(atPath: localURL.path) else {
|
guard FileManager.default.fileExists(atPath: localURL.path) else {
|
||||||
throw CoreError.notFound("Xcode .xip not found at \(localURL.path)")
|
throw CoreError.notFound("Xcode .xip not found at \(localURL.path)")
|
||||||
}
|
}
|
||||||
|
let localBytes =
|
||||||
|
(try? FileManager.default.attributesOfItem(atPath: localURL.path))?[.size] as? Int ?? 0
|
||||||
|
|
||||||
let remoteXIP = "/tmp/Xcode.xip"
|
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"
|
let staging = "/tmp/xcode-expand"
|
||||||
// `xip --expand` writes into the current directory and needs no sudo, but
|
let quotedXIP = Self.shellQuote(remoteXIP)
|
||||||
// /tmp is small on some layouts; staging under /tmp keeps it beside the
|
let quotedStaging = Self.shellQuote(staging)
|
||||||
// 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.
|
// Is a previous run's expansion still sitting there, complete? Then the
|
||||||
try await executor.runChecked(
|
// upload and the expansion — between them the entire cost of this
|
||||||
"cd \(Self.shellQuote(staging)) && sudo -n /usr/bin/xip --expand \(Self.shellQuote(remoteXIP))",
|
// function — are already paid for. Opportunistic only: macOS clears /tmp
|
||||||
timeout: .seconds(5400)
|
// on boot, so after the guest has been power-cycled this finds nothing,
|
||||||
)
|
// which is fine.
|
||||||
|
var expandedApp = try await Self.reusableExpandedApp(executor: executor, staging: staging)
|
||||||
|
|
||||||
|
if let expandedApp {
|
||||||
|
progress?("reusing expanded \((expandedApp as NSString).lastPathComponent)")
|
||||||
|
} else {
|
||||||
|
try await Self.checkGuestDisk(executor: executor, xipBytes: localBytes, progress: progress)
|
||||||
|
|
||||||
|
if try await Self.hasMatchingUpload(
|
||||||
|
executor: executor, remotePath: remoteXIP, expectedBytes: localBytes)
|
||||||
|
{
|
||||||
|
progress?("reusing uploaded xip (\(XcodeInstall.formatGB(localBytes)))")
|
||||||
|
} else {
|
||||||
|
// 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.
|
||||||
|
let headline = "uploading Xcode (\(XcodeInstall.formatGB(localBytes)))"
|
||||||
|
progress?(headline + " 0%")
|
||||||
|
try await executor.runChecked("rm -f \(quotedXIP)", timeout: .seconds(120))
|
||||||
|
try await Self.uploadLargeFile(
|
||||||
|
executor: executor,
|
||||||
|
localURL: localURL,
|
||||||
|
remotePath: remoteXIP,
|
||||||
|
progress: { fraction in
|
||||||
|
progress?(headline + " \(Int(fraction * 100))%")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
progress?(headline + " 100%")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A half-finished expansion from an earlier attempt would leave
|
||||||
|
// `ls *.app` ambiguous, or leave a truncated bundle to be installed.
|
||||||
|
// Clear it before, not after.
|
||||||
|
try await executor.runChecked(
|
||||||
|
"rm -rf \(quotedStaging) && mkdir -p \(quotedStaging)", timeout: .seconds(600))
|
||||||
|
|
||||||
|
// `xip --expand` writes into the current directory and needs no sudo,
|
||||||
|
// but staging beside the archive keeps the later move a rename within
|
||||||
|
// one volume. Expansion of a full Xcode takes 20–45 minutes on
|
||||||
|
// VM-backed storage.
|
||||||
|
progress?("expanding xip (takes 15-40 min)…")
|
||||||
|
try await executor.runChecked(
|
||||||
|
"cd \(quotedStaging) && sudo -n /usr/bin/xip --expand \(quotedXIP)",
|
||||||
|
timeout: .seconds(5400)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Immediately, and unconditionally on success: the archive is dead
|
||||||
|
// weight from here on, and the guest is at its tightest right now
|
||||||
|
// holding both copies. Deleting it as part of a success-only `&&`
|
||||||
|
// chain at the very end — which is what this used to do — means a
|
||||||
|
// failure anywhere later strands 12 GB in /tmp.
|
||||||
|
_ = try? await executor.run("rm -f \(quotedXIP)", timeout: .seconds(300))
|
||||||
|
|
||||||
|
let listing = try await executor.run(
|
||||||
|
"ls -d \(quotedStaging)/*.app 2>/dev/null", timeout: .seconds(300))
|
||||||
|
expandedApp = try XcodeInstall.expandedAppPath(
|
||||||
|
fromListing: listing.stdout, staging: staging)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let sourceApp = expandedApp else {
|
||||||
|
throw CoreError.provisioningFailed("could not locate the expanded Xcode in \(staging)")
|
||||||
|
}
|
||||||
|
let appName = (sourceApp as NSString).lastPathComponent
|
||||||
|
let destination = "/Applications/" + appName
|
||||||
|
let quotedDestination = Self.shellQuote(destination)
|
||||||
|
|
||||||
|
// Whatever is already there loses. This is a golden image being built to
|
||||||
|
// a specification, not a user's Mac, and leaving the old copy would both
|
||||||
|
// fail the move and waste tens of gigabytes in every clone.
|
||||||
|
let existing = try await executor.run(
|
||||||
|
"test -e \(quotedDestination) && echo present", timeout: .seconds(120))
|
||||||
|
if existing.stdout.contains("present") {
|
||||||
|
progress?("replacing existing \(appName) in the guest")
|
||||||
|
try await executor.runChecked(
|
||||||
|
"sudo -n rm -rf \(quotedDestination)", timeout: .seconds(1800))
|
||||||
|
}
|
||||||
|
|
||||||
// Writing into /Applications needs root.
|
// Writing into /Applications needs root.
|
||||||
|
progress?("installing \(appName)…")
|
||||||
try await executor.runChecked(
|
try await executor.runChecked(
|
||||||
"sudo -n mv \(Self.shellQuote(staging + "/Xcode.app")) /Applications/Xcode.app "
|
"sudo -n mv \(Self.shellQuote(sourceApp)) \(quotedDestination)",
|
||||||
+ "&& sudo -n rm -rf \(Self.shellQuote(staging)) \(Self.shellQuote(remoteXIP))",
|
|
||||||
timeout: .seconds(1800)
|
timeout: .seconds(1800)
|
||||||
)
|
)
|
||||||
|
_ = try? await executor.run("rm -rf \(quotedStaging)", timeout: .seconds(600))
|
||||||
|
|
||||||
// xcode-select writes /var/db/xcode_select_link — root only.
|
// xcode-select writes /var/db/xcode_select_link — root only. Pointing it
|
||||||
|
// at the discovered path is what makes the bundle's name a non-issue:
|
||||||
|
// `xcodebuild`, `swift`, and every `xcrun` shim resolve through this.
|
||||||
try await executor.runChecked(
|
try await executor.runChecked(
|
||||||
"sudo -n /usr/bin/xcode-select -s /Applications/Xcode.app/Contents/Developer",
|
"sudo -n /usr/bin/xcode-select -s "
|
||||||
|
+ Self.shellQuote(destination + "/Contents/Developer"),
|
||||||
timeout: .seconds(300)
|
timeout: .seconds(300)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -361,6 +442,7 @@ public struct GuestProvisioner: Sendable {
|
|||||||
"sudo -n /usr/bin/xcodebuild -license accept",
|
"sudo -n /usr/bin/xcodebuild -license accept",
|
||||||
timeout: .seconds(600)
|
timeout: .seconds(600)
|
||||||
)
|
)
|
||||||
|
progress?("running xcodebuild -runFirstLaunch (installs simulators; 10-30 min)…")
|
||||||
try await executor.runChecked(
|
try await executor.runChecked(
|
||||||
"sudo -n /usr/bin/xcodebuild -runFirstLaunch",
|
"sudo -n /usr/bin/xcodebuild -runFirstLaunch",
|
||||||
timeout: .seconds(3600)
|
timeout: .seconds(3600)
|
||||||
@@ -373,6 +455,82 @@ public struct GuestProvisioner: Sendable {
|
|||||||
+ Self.tail(check.stderr.isEmpty ? check.stdout : check.stderr)
|
+ Self.tail(check.stderr.isEmpty ? check.stdout : check.stderr)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Proof, in the operator's log, that the thing they waited an hour for
|
||||||
|
// is actually there and selected — on one line, since `-version` prints
|
||||||
|
// two.
|
||||||
|
let version = check.stdout
|
||||||
|
.split(separator: "\n")
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
.joined(separator: " — ")
|
||||||
|
progress?("Xcode ready: \(version) at \(destination)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An already-expanded application left by an earlier attempt, if one is
|
||||||
|
/// there and looks complete.
|
||||||
|
///
|
||||||
|
/// "Complete" is `Contents/MacOS` existing: an expansion killed part-way
|
||||||
|
/// leaves a directory tree that `ls` is perfectly happy to list, and
|
||||||
|
/// installing that would produce an Xcode that fails at first use rather
|
||||||
|
/// than at install time. Never throws — a guest with nothing staged is the
|
||||||
|
/// normal case, and an ambiguous listing here just means "do it properly".
|
||||||
|
static func reusableExpandedApp(
|
||||||
|
executor: any GuestExecutor,
|
||||||
|
staging: String
|
||||||
|
) async throws -> String? {
|
||||||
|
let listing = try await executor.run(
|
||||||
|
"ls -d \(shellQuote(staging))/*.app 2>/dev/null", timeout: .seconds(120))
|
||||||
|
guard let app = try? XcodeInstall.expandedAppPath(fromListing: listing.stdout, staging: staging)
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
let complete = try await executor.run(
|
||||||
|
"test -d \(shellQuote(app + "/Contents/MacOS")) && echo ok", timeout: .seconds(120))
|
||||||
|
return complete.stdout.contains("ok") ? app : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the guest already holds a byte-for-byte-sized copy of the upload.
|
||||||
|
///
|
||||||
|
/// Size only — hashing 12 GB over an exec channel would cost more than the
|
||||||
|
/// upload it is trying to avoid. The archive is written by this code alone,
|
||||||
|
/// to a fixed path, so a size match is strong enough evidence; a partial
|
||||||
|
/// upload from an interrupted run is shorter and fails the check.
|
||||||
|
static func hasMatchingUpload(
|
||||||
|
executor: any GuestExecutor,
|
||||||
|
remotePath: String,
|
||||||
|
expectedBytes: Int
|
||||||
|
) async throws -> Bool {
|
||||||
|
guard expectedBytes > 0 else { return false }
|
||||||
|
let result = try await executor.run(
|
||||||
|
"stat -f %z \(shellQuote(remotePath)) 2>/dev/null", timeout: .seconds(120))
|
||||||
|
let reported = Int(result.stdout.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||||
|
return reported == expectedBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refuses the install before the upload when the guest cannot hold it.
|
||||||
|
///
|
||||||
|
/// The failure this replaces is the worst kind: `xip --expand` fills the
|
||||||
|
/// disk half an hour in, and the error names neither how much was needed nor
|
||||||
|
/// what to do about it. Unparseable `df` output is treated as "cannot check"
|
||||||
|
/// and allowed through — a pre-flight that blocks the install because it did
|
||||||
|
/// not recognise the output is worse than the problem.
|
||||||
|
static func checkGuestDisk(
|
||||||
|
executor: any GuestExecutor,
|
||||||
|
xipBytes: Int,
|
||||||
|
progress: (@Sendable (String) -> Void)?
|
||||||
|
) async throws {
|
||||||
|
guard xipBytes > 0 else { return }
|
||||||
|
let result = try await executor.run("df -Pk /", timeout: .seconds(120))
|
||||||
|
guard let available = XcodeInstall.availableBytes(dfOutput: result.stdout) else { return }
|
||||||
|
|
||||||
|
let needed = XcodeInstall.requiredFreeBytes(xipBytes: xipBytes)
|
||||||
|
guard available >= needed else {
|
||||||
|
throw CoreError.provisioningFailed(
|
||||||
|
XcodeInstall.insufficientDiskMessage(xipBytes: xipBytes, availableBytes: available)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
progress?(
|
||||||
|
"guest disk: \(XcodeInstall.formatGB(available)) free, "
|
||||||
|
+ "\(XcodeInstall.formatGB(needed)) needed")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The Node.js version installed when none is specified.
|
/// The Node.js version installed when none is specified.
|
||||||
|
|||||||
@@ -155,12 +155,10 @@ public struct IPSWProvider: Sendable {
|
|||||||
// not a Swift error — when handed a non-file or missing path, and an
|
// 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
|
// 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.
|
// politeness; it is the only thing standing between a typo and a crash.
|
||||||
var isDirectory: ObjCBool = false
|
// The size and magic-byte checks alongside it cover the other way this
|
||||||
guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory),
|
// call goes wrong: handed a partial download it neither fails nor
|
||||||
!isDirectory.boolValue
|
// reports progress, it simply stops responding.
|
||||||
else {
|
try IPSWFile.validate(path: url.path)
|
||||||
throw CoreError.notFound("restore image not found at \(url.path)")
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
return try await VZMacOSRestoreImage.image(from: url)
|
return try await VZMacOSRestoreImage.image(from: url)
|
||||||
|
|||||||
@@ -6,10 +6,16 @@ import Virtualization
|
|||||||
public enum ImageBuildStage: Sendable, Equatable {
|
public enum ImageBuildStage: Sendable, Equatable {
|
||||||
/// Downloading the IPSW.
|
/// Downloading the IPSW.
|
||||||
case downloadingIPSW(fraction: Double)
|
case downloadingIPSW(fraction: Double)
|
||||||
/// Reading the restore image and deriving a hardware configuration.
|
/// Working out which file the operator meant and whether it is usable.
|
||||||
case preparing
|
case preparing
|
||||||
|
/// Inside `VZMacOSRestoreImage.image(from:)`, which reports no progress of
|
||||||
|
/// its own and is the longest silent stretch of a local-IPSW build.
|
||||||
|
case loadingRestoreImage
|
||||||
/// Creating the disk, NVRAM, and bundle metadata.
|
/// Creating the disk, NVRAM, and bundle metadata.
|
||||||
case creatingBundle
|
case creatingBundle(diskGB: Int)
|
||||||
|
/// An out-of-band remark about the stage in flight — printed on its own
|
||||||
|
/// line rather than replacing the status line.
|
||||||
|
case note(String)
|
||||||
/// `VZMacOSInstaller` is writing macOS onto the disk.
|
/// `VZMacOSInstaller` is writing macOS onto the disk.
|
||||||
case installing(fraction: Double)
|
case installing(fraction: Double)
|
||||||
/// First boot; waiting for Setup Assistant, a DHCP lease, and SSH.
|
/// First boot; waiting for Setup Assistant, a DHCP lease, and SSH.
|
||||||
@@ -73,9 +79,29 @@ public struct ImageBuilder: Sendable {
|
|||||||
|
|
||||||
// Checked against the filesystem rather than `store.image(named:)`: a
|
// Checked against the filesystem rather than `store.image(named:)`: a
|
||||||
// half-built bundle from a previous failed run is exactly the thing this
|
// 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.
|
// needs to look at, and it would not read back as a valid image.
|
||||||
let bundleURL = store.imagesDir.appendingPathComponent(name, isDirectory: true)
|
let bundleURL = store.imagesDir.appendingPathComponent(name, isDirectory: true)
|
||||||
if FileManager.default.fileExists(atPath: bundleURL.path) {
|
if FileManager.default.fileExists(atPath: bundleURL.path) {
|
||||||
|
let existing = VMBundle(rootURL: bundleURL)
|
||||||
|
let existingConfig = try? existing.loadConfig()
|
||||||
|
|
||||||
|
// Installed but never provisioned: resume rather than throw away an
|
||||||
|
// hour of installing. This is safe precisely because provisioning is
|
||||||
|
// what got skipped — the guest has never been booted, so its *true*
|
||||||
|
// first boot is still ahead of it and `VZMacGuestProvisioningOptions`
|
||||||
|
// will still be evaluated. (macOS only honours those options on the
|
||||||
|
// first boot after a restore; a guest that already booted once has
|
||||||
|
// consumed that chance, which is the ambiguous case handled by the
|
||||||
|
// SSH probe in `bootProvisionAndSeal`.)
|
||||||
|
if existing.isComplete(), let existingConfig, !existingConfig.provisioned {
|
||||||
|
progress?(
|
||||||
|
.note("image '\(name)' already installed — resuming first boot + provisioning"))
|
||||||
|
try await firstBootAndProvision(
|
||||||
|
bundle: existing, config: config, isResume: true, progress: progress)
|
||||||
|
progress?(.done)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
throw CoreError.configInvalid(
|
throw CoreError.configInvalid(
|
||||||
"image '\(name)' already exists at \(bundleURL.path). "
|
"image '\(name)' already exists at \(bundleURL.path). "
|
||||||
+ "Delete it first (`image delete \(name)`), or build under a different --name."
|
+ "Delete it first (`image delete \(name)`), or build under a different --name."
|
||||||
@@ -89,7 +115,28 @@ public struct ImageBuilder: Sendable {
|
|||||||
let provider = IPSWProvider(downloadDirectory: store.ipswDir)
|
let provider = IPSWProvider(downloadDirectory: store.ipswDir)
|
||||||
let restoreImage: VZMacOSRestoreImage
|
let restoreImage: VZMacOSRestoreImage
|
||||||
if let ipswPath {
|
if let ipswPath {
|
||||||
progress?(.downloadingIPSW(fraction: 1.0))
|
progress?(.preparing)
|
||||||
|
progress?(.loadingRestoreImage)
|
||||||
|
|
||||||
|
// Reading a 21 GB archive's metadata takes a while and the
|
||||||
|
// framework says nothing while it does. A build that looks frozen
|
||||||
|
// is the single most-reported symptom of this command, so say out
|
||||||
|
// loud what the other likely explanation is rather than letting the
|
||||||
|
// operator guess.
|
||||||
|
let watchdog = Task {
|
||||||
|
try? await Task.sleep(for: .seconds(60))
|
||||||
|
// Cancelling is how a *fast* load ends this task, and a
|
||||||
|
// cancelled sleep returns rather than throwing past `try?` — so
|
||||||
|
// without this the note prints on every quick failure, which is
|
||||||
|
// precisely when it is misleading.
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
progress?(
|
||||||
|
.note(
|
||||||
|
"still loading — a truncated or partially downloaded .ipsw can block here; "
|
||||||
|
+ "verify the download completed"))
|
||||||
|
}
|
||||||
|
defer { watchdog.cancel() }
|
||||||
|
|
||||||
restoreImage = try await provider.load(localPath: ipswPath)
|
restoreImage = try await provider.load(localPath: ipswPath)
|
||||||
} else {
|
} else {
|
||||||
progress?(.downloadingIPSW(fraction: 0))
|
progress?(.downloadingIPSW(fraction: 0))
|
||||||
@@ -100,8 +147,7 @@ public struct ImageBuilder: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2/3. Hardware model and bundle.
|
// 2/3. Hardware model and bundle.
|
||||||
progress?(.preparing)
|
progress?(.creatingBundle(diskGB: config.guest.diskGB))
|
||||||
progress?(.creatingBundle)
|
|
||||||
let bundle = try await createBundle(name: name, restoreImage: restoreImage, config: config)
|
let bundle = try await createBundle(name: name, restoreImage: restoreImage, config: config)
|
||||||
|
|
||||||
// 4. Install.
|
// 4. Install.
|
||||||
@@ -344,19 +390,47 @@ public struct ImageBuilder: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
installer.install { result in
|
installer.install { result in
|
||||||
|
// `VZVirtualMachine` holds an exclusive lock on the bundle's
|
||||||
|
// auxiliary storage (nvram.bin) for its whole lifetime, and
|
||||||
|
// releases it in `dealloc`. The next thing the caller does is
|
||||||
|
// build a *second* VM over the same bundle for first boot, so
|
||||||
|
// if this one is still alive at that moment the new one fails
|
||||||
|
// validation with "Failed to lock auxiliary storage" — which
|
||||||
|
// is exactly what operators hit.
|
||||||
|
//
|
||||||
|
// Hence: drop every strong reference here, on the queue that
|
||||||
|
// owns these objects...
|
||||||
|
session.observation?.invalidate()
|
||||||
session.observation = nil
|
session.observation = nil
|
||||||
session.installer = nil
|
session.installer = nil
|
||||||
session.virtualMachine = nil
|
session.virtualMachine = nil
|
||||||
switch result {
|
|
||||||
case .success:
|
// ...and resume the caller only from a *later* block on that
|
||||||
progress?(1.0)
|
// same serial queue. Returning from this handler is what lets
|
||||||
continuation.resume()
|
// the framework's own frame unwind and release its references,
|
||||||
case .failure(let error):
|
// and a serial queue guarantees that has happened before the
|
||||||
continuation.resume(throwing: VMInstance.mapVZError(error))
|
// block below runs. Resuming inline instead would race the
|
||||||
|
// deallocation against the first-boot VM.
|
||||||
|
let boxedResult = UncheckedBox(result)
|
||||||
|
queue.async {
|
||||||
|
switch boxedResult.value {
|
||||||
|
case .success:
|
||||||
|
progress?(1.0)
|
||||||
|
continuation.resume()
|
||||||
|
case .failure(let error):
|
||||||
|
continuation.resume(throwing: VMInstance.mapVZError(error))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One more hop to the back of the same queue: by the time an empty block
|
||||||
|
// gets to run, everything enqueued above it — including the release of
|
||||||
|
// the last reference to the VM — has finished.
|
||||||
|
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
||||||
|
queue.async { continuation.resume() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Boots the freshly installed guest, gets it onto the network, and hands it
|
/// Boots the freshly installed guest, gets it onto the network, and hands it
|
||||||
@@ -390,10 +464,14 @@ public struct ImageBuilder: Sendable {
|
|||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - bundle: The installed bundle.
|
/// - bundle: The installed bundle.
|
||||||
/// - config: Guest credentials and timeouts.
|
/// - config: Guest credentials and timeouts.
|
||||||
|
/// - isResume: `true` when this is picking up a bundle that was installed
|
||||||
|
/// by an earlier run. Only affects the advice given if the guest never
|
||||||
|
/// answers on SSH — see ``bootProvisionAndSeal(bundle:config:startOptions:isFirstBoot:isResume:xcodeXIPPath:progress:)``.
|
||||||
/// - progress: Stage callback.
|
/// - progress: Stage callback.
|
||||||
public func firstBootAndProvision(
|
public func firstBootAndProvision(
|
||||||
bundle: VMBundle,
|
bundle: VMBundle,
|
||||||
config: RunnerConfig,
|
config: RunnerConfig,
|
||||||
|
isResume: Bool = false,
|
||||||
progress: (@Sendable (ImageBuildStage) -> Void)? = nil
|
progress: (@Sendable (ImageBuildStage) -> Void)? = nil
|
||||||
) async throws {
|
) async throws {
|
||||||
guard #available(macOS 27.0, *) else {
|
guard #available(macOS 27.0, *) else {
|
||||||
@@ -434,6 +512,7 @@ public struct ImageBuilder: Sendable {
|
|||||||
config: config,
|
config: config,
|
||||||
startOptions: startOptions,
|
startOptions: startOptions,
|
||||||
isFirstBoot: true,
|
isFirstBoot: true,
|
||||||
|
isResume: isResume,
|
||||||
xcodeXIPPath: nil,
|
xcodeXIPPath: nil,
|
||||||
progress: progress
|
progress: progress
|
||||||
)
|
)
|
||||||
@@ -478,6 +557,7 @@ public struct ImageBuilder: Sendable {
|
|||||||
config: config,
|
config: config,
|
||||||
startOptions: nil,
|
startOptions: nil,
|
||||||
isFirstBoot: false,
|
isFirstBoot: false,
|
||||||
|
isResume: false,
|
||||||
xcodeXIPPath: xcodeXIPPath,
|
xcodeXIPPath: xcodeXIPPath,
|
||||||
progress: progress
|
progress: progress
|
||||||
)
|
)
|
||||||
@@ -493,6 +573,7 @@ public struct ImageBuilder: Sendable {
|
|||||||
config: RunnerConfig,
|
config: RunnerConfig,
|
||||||
startOptions: VZMacOSVirtualMachineStartOptions?,
|
startOptions: VZMacOSVirtualMachineStartOptions?,
|
||||||
isFirstBoot: Bool,
|
isFirstBoot: Bool,
|
||||||
|
isResume: Bool,
|
||||||
xcodeXIPPath: String?,
|
xcodeXIPPath: String?,
|
||||||
progress: (@Sendable (ImageBuildStage) -> Void)?
|
progress: (@Sendable (ImageBuildStage) -> Void)?
|
||||||
) async throws {
|
) async throws {
|
||||||
@@ -501,15 +582,11 @@ public struct ImageBuilder: Sendable {
|
|||||||
let bootTimeout = Duration.seconds(max(60, config.scheduler.bootTimeoutSeconds))
|
let bootTimeout = Duration.seconds(max(60, config.scheduler.bootTimeoutSeconds))
|
||||||
|
|
||||||
progress?(.firstBoot)
|
progress?(.firstBoot)
|
||||||
let instance = try VMInstance(bundle: bundle, label: "image:\(bundle.name)", headless: true)
|
let instance = try await Self.bootRetryingAuxStorageLock(
|
||||||
|
bundle: bundle,
|
||||||
do {
|
startOptions: startOptions,
|
||||||
try await instance.start(options: startOptions)
|
progress: progress
|
||||||
} catch {
|
)
|
||||||
throw CoreError.provisioningFailed(
|
|
||||||
"could not boot image '\(bundle.name)': \(error)"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
let address: String
|
let address: String
|
||||||
do {
|
do {
|
||||||
@@ -526,6 +603,37 @@ public struct ImageBuilder: Sendable {
|
|||||||
} catch {
|
} catch {
|
||||||
_ = await instance.requestStopThenForce()
|
_ = await instance.requestStopThenForce()
|
||||||
if isFirstBoot {
|
if isFirstBoot {
|
||||||
|
// A resumed build has a second candidate cause, and it is
|
||||||
|
// unrecoverable rather than merely slow: macOS evaluates
|
||||||
|
// `VZMacGuestProvisioningOptions` only on the first boot after a
|
||||||
|
// restore. If the earlier run got far enough to boot the guest —
|
||||||
|
// which the bundle on disk cannot tell us — that chance is spent,
|
||||||
|
// and no amount of retrying will produce an account or sshd.
|
||||||
|
// Reaching SSH is the only way to distinguish the two, so this is
|
||||||
|
// said here, after the probe has failed, rather than refusing to
|
||||||
|
// resume in the first place.
|
||||||
|
if isResume {
|
||||||
|
throw CoreError.provisioningFailed(
|
||||||
|
"""
|
||||||
|
the resumed guest never became reachable over SSH within \
|
||||||
|
\(config.scheduler.bootTimeoutSeconds)s.
|
||||||
|
|
||||||
|
Either the guest is older than macOS 27 (see below), or an earlier run \
|
||||||
|
already consumed its first boot — macOS applies automated Setup Assistant \
|
||||||
|
provisioning only once, on the first boot after a restore, so a guest that \
|
||||||
|
has booted before can no longer be provisioned unattended.
|
||||||
|
|
||||||
|
There is no way to re-arm it: delete the image and build again with a \
|
||||||
|
macOS 27 or newer restore image.
|
||||||
|
|
||||||
|
gitea-macos-runner image delete \(bundle.name)
|
||||||
|
gitea-macos-runner image build --ipsw <path>
|
||||||
|
|
||||||
|
Underlying error: \(error)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// The most likely cause by far, and the one with no diagnostic of
|
// The most likely cause by far, and the one with no diagnostic of
|
||||||
// its own: a pre-27 guest accepts the provisioning options and
|
// its own: a pre-27 guest accepts the provisioning options and
|
||||||
// ignores them, so it sits at Setup Assistant with no account and
|
// ignores them, so it sits at Setup Assistant with no account and
|
||||||
@@ -564,10 +672,14 @@ public struct ImageBuilder: Sendable {
|
|||||||
|
|
||||||
if let xcodeXIPPath {
|
if let xcodeXIPPath {
|
||||||
progress?(.provisioning(step: "Xcode"))
|
progress?(.provisioning(step: "Xcode"))
|
||||||
|
// Xcode is the one step measured in hours, so it reports its own
|
||||||
|
// sub-stages rather than going quiet behind a single headline.
|
||||||
try await provisioner.installXcode(
|
try await provisioner.installXcode(
|
||||||
executor: executor,
|
executor: executor,
|
||||||
xipPath: (xcodeXIPPath as NSString).expandingTildeInPath
|
xipPath: (xcodeXIPPath as NSString).expandingTildeInPath
|
||||||
)
|
) { step in
|
||||||
|
progress?(.provisioning(step: step))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
await executor.close()
|
await executor.close()
|
||||||
@@ -598,6 +710,64 @@ public struct ImageBuilder: Sendable {
|
|||||||
/// Full name for the account Setup Assistant automation creates.
|
/// Full name for the account Setup Assistant automation creates.
|
||||||
static let guestAccountFullName = "Gitea Runner"
|
static let guestAccountFullName = "Gitea Runner"
|
||||||
|
|
||||||
|
/// Creates and starts the VM, tolerating a still-held auxiliary-storage lock.
|
||||||
|
///
|
||||||
|
/// `VZVirtualMachine` takes an exclusive lock on the bundle's `nvram.bin`
|
||||||
|
/// and gives it up only when the object deallocates. `install(bundle:…)`
|
||||||
|
/// now drains its queue before returning, so its installer VM is gone by
|
||||||
|
/// the time we get here — but "gone" is an ARC and Objective-C runtime
|
||||||
|
/// property, and a stray autorelease pool or a framework thread that has
|
||||||
|
/// not yet unwound can still be holding the last reference for a moment.
|
||||||
|
/// The failure that produces is not ambiguous and not persistent:
|
||||||
|
///
|
||||||
|
/// Invalid virtual machine configuration. Failed to lock auxiliary storage.
|
||||||
|
///
|
||||||
|
/// So it is retried, briefly and only for that message. Anything else fails
|
||||||
|
/// on the first attempt, because a genuinely invalid configuration does not
|
||||||
|
/// become valid by waiting.
|
||||||
|
static func bootRetryingAuxStorageLock(
|
||||||
|
bundle: VMBundle,
|
||||||
|
startOptions: VZMacOSVirtualMachineStartOptions?,
|
||||||
|
progress: (@Sendable (ImageBuildStage) -> Void)?,
|
||||||
|
timeout: Duration = .seconds(30),
|
||||||
|
pollInterval: Duration = .seconds(2)
|
||||||
|
) async throws -> VMInstance {
|
||||||
|
let started = ContinuousClock.now
|
||||||
|
var announced = false
|
||||||
|
|
||||||
|
while true {
|
||||||
|
do {
|
||||||
|
let instance = try VMInstance(
|
||||||
|
bundle: bundle, label: "image:\(bundle.name)", headless: true)
|
||||||
|
try await instance.start(options: startOptions)
|
||||||
|
return instance
|
||||||
|
} catch {
|
||||||
|
guard isAuxiliaryStorageLockFailure(error),
|
||||||
|
ContinuousClock.now - started < timeout
|
||||||
|
else {
|
||||||
|
throw CoreError.provisioningFailed(
|
||||||
|
"could not boot image '\(bundle.name)': \(error)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if !announced {
|
||||||
|
announced = true
|
||||||
|
progress?(.note("waiting for installer to release the VM bundle…"))
|
||||||
|
}
|
||||||
|
try await Task.sleep(for: pollInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether an error is the transient "someone else still has nvram.bin".
|
||||||
|
///
|
||||||
|
/// Matched on the message because the framework reports it as a generic
|
||||||
|
/// `VZError.invalidVirtualMachineConfiguration` with the detail only in the
|
||||||
|
/// description — there is no distinct code to switch on.
|
||||||
|
static func isAuxiliaryStorageLockFailure(_ error: any Error) -> Bool {
|
||||||
|
let text = "\(error)".lowercased()
|
||||||
|
return text.contains("auxiliary storage") && text.contains("lock")
|
||||||
|
}
|
||||||
|
|
||||||
/// Polls `/var/db/dhcpd_leases` until the guest's MAC appears.
|
/// Polls `/var/db/dhcpd_leases` until the guest's MAC appears.
|
||||||
///
|
///
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
|
|||||||
@@ -110,9 +110,10 @@ struct DaemonCommand: AsyncParsableCommand {
|
|||||||
// Expected on shutdown.
|
// Expected on shutdown.
|
||||||
} catch {
|
} catch {
|
||||||
logger.critical("daemon stopped", metadata: ["error": .string("\(error)")])
|
logger.critical("daemon stopped", metadata: ["error": .string("\(error)")])
|
||||||
// Fully qualified: inside a ParsableCommand a bare `exit`
|
// Not `MainActor.run`: the main actor is parked inside
|
||||||
// resolves to ParsableCommand.exit(withError:).
|
// `app.run()` for the life of the process, so hopping onto
|
||||||
await MainActor.run { Foundation.exit(1) }
|
// it to exit is its own deadlock. See `VZAppRuntime.run`.
|
||||||
|
VZAppRuntime.flushAndExit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -123,18 +124,40 @@ struct DaemonCommand: AsyncParsableCommand {
|
|||||||
/// run loop it requires, while the real work runs in a `Task`.
|
/// 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.
|
/// Shared by `daemon` and `vm boot`: any command that starts a VM needs this.
|
||||||
@MainActor
|
|
||||||
enum VZAppRuntime {
|
enum VZAppRuntime {
|
||||||
/// Signal sources have to outlive the call that creates them or they are
|
/// Signal sources have to outlive the call that creates them or they are
|
||||||
/// cancelled on deinit and the signals go nowhere.
|
/// cancelled on deinit and the signals go nowhere.
|
||||||
private static var signalSources: [DispatchSourceSignal] = []
|
private nonisolated(unsafe) static var signalSources: [DispatchSourceSignal] = []
|
||||||
private static var isTerminating = false
|
private nonisolated(unsafe) static var isTerminating = false
|
||||||
|
private static let stateLock = NSLock()
|
||||||
|
|
||||||
|
/// Signals land here rather than on `.main`. See ``run(onSignal:body:)``.
|
||||||
|
private static let signalQueue = DispatchQueue(
|
||||||
|
label: "xyz.blakeslee.gitea-macos-runner.signals")
|
||||||
|
|
||||||
/// Starts the run loop and runs `body` alongside it. Never returns.
|
/// Starts the run loop and runs `body` alongside it. Never returns.
|
||||||
///
|
///
|
||||||
|
/// ## Nothing here may touch the main queue
|
||||||
|
///
|
||||||
|
/// This is reached from Swift's async `main`, so the frame that calls
|
||||||
|
/// `app.run()` is *itself* a block executing on the main dispatch queue —
|
||||||
|
/// and it never returns. libdispatch will not re-enter a serial queue that
|
||||||
|
/// already has a block in flight, so from this moment the main queue is
|
||||||
|
/// closed for business: a plain `Task { }` inheriting a `@MainActor`
|
||||||
|
/// context, a `DispatchSource` handler on `.main`, or an
|
||||||
|
/// `await MainActor.run { … }` all enqueue work that can never be drained.
|
||||||
|
///
|
||||||
|
/// The symptom is exact and was reported as a hang in `image build`: a live
|
||||||
|
/// run loop, zero CPU, and no output past the last line printed before this
|
||||||
|
/// call — `body` had been enqueued behind `app.run()` and never got a first
|
||||||
|
/// tick. Hence `Task.detached`, a private signal queue, and ``exit(_:)``
|
||||||
|
/// called straight from whichever thread reaches it. A normal AppKit app
|
||||||
|
/// does not hit this because its `main()` is not a main-queue block.
|
||||||
|
///
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - onSignal: Cleanup to perform on `SIGINT`/`SIGTERM` before exiting.
|
/// - onSignal: Cleanup to perform on `SIGINT`/`SIGTERM` before exiting.
|
||||||
/// - body: The work to run. When it returns, the process exits zero.
|
/// - body: The work to run. When it returns, the process exits zero.
|
||||||
|
@MainActor
|
||||||
static func run(
|
static func run(
|
||||||
onSignal: @escaping @Sendable () async -> Void,
|
onSignal: @escaping @Sendable () async -> Void,
|
||||||
body: @escaping @Sendable () async -> Void
|
body: @escaping @Sendable () async -> Void
|
||||||
@@ -149,30 +172,48 @@ enum VZAppRuntime {
|
|||||||
// DispatchSourceSignal only observes; the default disposition still
|
// DispatchSourceSignal only observes; the default disposition still
|
||||||
// kills the process unless it is ignored first.
|
// kills the process unless it is ignored first.
|
||||||
signal(signalNumber, SIG_IGN)
|
signal(signalNumber, SIG_IGN)
|
||||||
let source = DispatchSource.makeSignalSource(signal: signalNumber, queue: .main)
|
let source = DispatchSource.makeSignalSource(signal: signalNumber, queue: signalQueue)
|
||||||
source.setEventHandler {
|
source.setEventHandler {
|
||||||
Task { @MainActor in
|
guard beginTerminating() else { return }
|
||||||
guard !isTerminating else { return }
|
Task.detached {
|
||||||
isTerminating = true
|
|
||||||
CLI.note("received signal; shutting down…")
|
CLI.note("received signal; shutting down…")
|
||||||
await onSignal()
|
await onSignal()
|
||||||
NSApp.terminate(nil)
|
flushAndExit(0)
|
||||||
exit(0)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
source.resume()
|
source.resume()
|
||||||
|
stateLock.lock()
|
||||||
signalSources.append(source)
|
signalSources.append(source)
|
||||||
|
stateLock.unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
Task {
|
// Detached on purpose: an inheriting `Task { }` would be queued behind
|
||||||
|
// the `app.run()` below and never start. See the note above.
|
||||||
|
Task.detached {
|
||||||
await body()
|
await body()
|
||||||
await MainActor.run {
|
flushAndExit(0)
|
||||||
NSApp.terminate(nil)
|
|
||||||
exit(0)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
app.run()
|
app.run()
|
||||||
exit(0)
|
flushAndExit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wins the race to shut down, exactly once.
|
||||||
|
private static func beginTerminating() -> Bool {
|
||||||
|
stateLock.lock()
|
||||||
|
defer { stateLock.unlock() }
|
||||||
|
guard !isTerminating else { return false }
|
||||||
|
isTerminating = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exits from any thread, without hopping to the unusable main actor.
|
||||||
|
///
|
||||||
|
/// `NSApp.terminate(nil)` is deliberately not called: it requires the main
|
||||||
|
/// actor, which is exactly what is not available here.
|
||||||
|
nonisolated static func flushAndExit(_ code: Int32) -> Never {
|
||||||
|
fflush(stdout)
|
||||||
|
fflush(stderr)
|
||||||
|
exit(code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,13 @@ struct ImageCommand: AsyncParsableCommand {
|
|||||||
var name: String = "default"
|
var name: String = "default"
|
||||||
|
|
||||||
/// A local `.ipsw`; omit to download the latest supported image.
|
/// A local `.ipsw`; omit to download the latest supported image.
|
||||||
@Option(name: .long, help: "Path to a local .ipsw (default: download the latest supported).")
|
///
|
||||||
|
/// Resolved through ``PathResolution`` so it works whether or not the
|
||||||
|
/// shell got to the glob first: `--ipsw '~/Downloads/UniversalMac_27*.ipsw'`
|
||||||
|
/// and the unquoted form both land on the same file.
|
||||||
|
@Option(
|
||||||
|
name: .long,
|
||||||
|
help: "Path to a local .ipsw; may be a glob (default: download the latest supported).")
|
||||||
var ipsw: String?
|
var ipsw: String?
|
||||||
|
|
||||||
/// Nominal guest disk size, overriding `guest.diskGB`.
|
/// Nominal guest disk size, overriding `guest.diskGB`.
|
||||||
@@ -44,6 +50,12 @@ struct ImageCommand: AsyncParsableCommand {
|
|||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
CLI.bootstrapLogging(verbose: options.verbose)
|
CLI.bootstrapLogging(verbose: options.verbose)
|
||||||
|
|
||||||
|
// Before anything else touches the disk: an unresolvable --ipsw is
|
||||||
|
// an argument error, and an argument error should not first make the
|
||||||
|
// operator wait on a config load and a free-space check.
|
||||||
|
let resolvedIPSW = try ipsw.map { try PathResolution.resolve($0, label: "--ipsw") }
|
||||||
|
|
||||||
var config = try options.loadConfig()
|
var config = try options.loadConfig()
|
||||||
if let diskGB {
|
if let diskGB {
|
||||||
config.guest.diskGB = diskGB
|
config.guest.diskGB = diskGB
|
||||||
@@ -52,7 +64,12 @@ struct ImageCommand: AsyncParsableCommand {
|
|||||||
let store = VMStore(config: config)
|
let store = VMStore(config: config)
|
||||||
try store.ensureLayout()
|
try store.ensureLayout()
|
||||||
|
|
||||||
if try store.image(named: name) != nil {
|
// Only a *finished* image blocks a rebuild. An installed but
|
||||||
|
// unprovisioned bundle is an hour of work that `ImageBuilder.build`
|
||||||
|
// knows how to resume, so it must get the chance to say so.
|
||||||
|
if let existing = try store.image(named: name),
|
||||||
|
(try? existing.loadConfig())?.provisioned == true
|
||||||
|
{
|
||||||
throw ValidationError(
|
throw ValidationError(
|
||||||
"image '\(name)' already exists — delete it first with `image delete \(name)`"
|
"image '\(name)' already exists — delete it first with `image delete \(name)`"
|
||||||
)
|
)
|
||||||
@@ -64,7 +81,7 @@ struct ImageCommand: AsyncParsableCommand {
|
|||||||
let printer = ProgressPrinter()
|
let printer = ProgressPrinter()
|
||||||
let builder = ImageBuilder(store: store)
|
let builder = ImageBuilder(store: store)
|
||||||
let imageName = name
|
let imageName = name
|
||||||
let ipswPath = ipsw
|
let ipswPath = resolvedIPSW
|
||||||
let frozenConfig = config
|
let frozenConfig = config
|
||||||
|
|
||||||
// `image build` runs `VZMacOSInstaller` and then boots the guest, so
|
// `image build` runs `VZMacOSInstaller` and then boots the guest, so
|
||||||
@@ -79,16 +96,20 @@ struct ImageCommand: AsyncParsableCommand {
|
|||||||
name: imageName,
|
name: imageName,
|
||||||
ipswPath: ipswPath,
|
ipswPath: ipswPath,
|
||||||
config: frozenConfig,
|
config: frozenConfig,
|
||||||
progress: { stage in printer.update(ImageCommand.describe(stage)) }
|
progress: { stage in ImageCommand.report(stage, to: printer) }
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
printer.finish()
|
printer.finish()
|
||||||
CLI.error("\(error)")
|
CLI.error("\(error)")
|
||||||
// Fully qualified: inside a ParsableCommand a bare `exit`
|
// Not `MainActor.run`: the main actor is parked inside
|
||||||
// resolves to ParsableCommand.exit(withError:).
|
// `app.run()` for the life of the process, so hopping onto
|
||||||
await MainActor.run { Foundation.exit(1) }
|
// it to exit is its own deadlock. See `VZAppRuntime.run`.
|
||||||
|
VZAppRuntime.flushAndExit(1)
|
||||||
}
|
}
|
||||||
printer.finish("done")
|
// Just seals the line: the builder's own `.done` stage has
|
||||||
|
// already printed it, and saying it twice down a pipe reads
|
||||||
|
// like something ran twice.
|
||||||
|
printer.finish()
|
||||||
|
|
||||||
print("built image '\(imageName)'")
|
print("built image '\(imageName)'")
|
||||||
print("next: gitea-macos-runner vm boot --image \(imageName)")
|
print("next: gitea-macos-runner vm boot --image \(imageName)")
|
||||||
@@ -201,20 +222,28 @@ struct ImageCommand: AsyncParsableCommand {
|
|||||||
|
|
||||||
/// Optional Xcode `.xip` to install into the guest. Adds tens of
|
/// Optional Xcode `.xip` to install into the guest. Adds tens of
|
||||||
/// gigabytes; omitted by default.
|
/// gigabytes; omitted by default.
|
||||||
@Option(name: .customLong("xcode-xip"), help: "Path to an Xcode .xip to install into the guest.")
|
@Option(
|
||||||
|
name: .customLong("xcode-xip"),
|
||||||
|
help: "Path to an Xcode .xip to install into the guest; may be a glob.")
|
||||||
var xcodeXIP: String?
|
var xcodeXIP: String?
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
CLI.bootstrapLogging(verbose: options.verbose)
|
CLI.bootstrapLogging(verbose: options.verbose)
|
||||||
|
|
||||||
|
// Same treatment as `image build --ipsw`, and for the same reason:
|
||||||
|
// this is a long path to a big file that people reach for with a
|
||||||
|
// glob. Resolved first so a bad one costs nothing.
|
||||||
|
let resolvedXIP = try xcodeXIP.map { try PathResolution.resolve($0, label: "--xcode-xip") }
|
||||||
|
if let resolvedXIP, !FileManager.default.fileExists(atPath: resolvedXIP) {
|
||||||
|
throw ValidationError("no file at \(resolvedXIP)")
|
||||||
|
}
|
||||||
|
|
||||||
let config = try options.loadConfig()
|
let config = try options.loadConfig()
|
||||||
let store = VMStore(config: config)
|
let store = VMStore(config: config)
|
||||||
|
|
||||||
guard try store.image(named: name) != nil else {
|
guard try store.image(named: name) != nil else {
|
||||||
throw ValidationError("no image named '\(name)'")
|
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")
|
CLI.note("provisioning base image '\(name)' in place — stop the daemon before doing this")
|
||||||
|
|
||||||
@@ -222,7 +251,7 @@ struct ImageCommand: AsyncParsableCommand {
|
|||||||
let builder = ImageBuilder(store: store)
|
let builder = ImageBuilder(store: store)
|
||||||
let imageName = name
|
let imageName = name
|
||||||
let frozenConfig = config
|
let frozenConfig = config
|
||||||
let xipPath = xcodeXIP.map(RunnerConfig.expandTilde)
|
let xipPath = resolvedXIP
|
||||||
|
|
||||||
// Boots the image to run provision.sh in it, so it needs the run
|
// Boots the image to run provision.sh in it, so it needs the run
|
||||||
// loop for exactly the reason `image build` does.
|
// loop for exactly the reason `image build` does.
|
||||||
@@ -234,14 +263,15 @@ struct ImageCommand: AsyncParsableCommand {
|
|||||||
name: imageName,
|
name: imageName,
|
||||||
config: frozenConfig,
|
config: frozenConfig,
|
||||||
xcodeXIPPath: xipPath,
|
xcodeXIPPath: xipPath,
|
||||||
progress: { stage in printer.update(ImageCommand.describe(stage)) }
|
progress: { stage in ImageCommand.report(stage, to: printer) }
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
printer.finish()
|
printer.finish()
|
||||||
CLI.error("\(error)")
|
CLI.error("\(error)")
|
||||||
// Fully qualified: inside a ParsableCommand a bare `exit`
|
// Not `MainActor.run`: the main actor is parked inside
|
||||||
// resolves to ParsableCommand.exit(withError:).
|
// `app.run()` for the life of the process, so hopping onto
|
||||||
await MainActor.run { Foundation.exit(1) }
|
// it to exit is its own deadlock. See `VZAppRuntime.run`.
|
||||||
|
VZAppRuntime.flushAndExit(1)
|
||||||
}
|
}
|
||||||
printer.finish("done")
|
printer.finish("done")
|
||||||
print("provisioned image '\(imageName)'")
|
print("provisioned image '\(imageName)'")
|
||||||
@@ -250,19 +280,69 @@ struct ImageCommand: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Routes a stage to the progress printer.
|
||||||
|
///
|
||||||
|
/// Notes get a line of their own: they are the reason the operator is still
|
||||||
|
/// watching, and a status line that is about to be overwritten is no place
|
||||||
|
/// to put "this may be a truncated download".
|
||||||
|
static func report(_ stage: ImageBuildStage, to printer: ProgressPrinter) {
|
||||||
|
if case .note(let text) = stage {
|
||||||
|
printer.line(text)
|
||||||
|
} else {
|
||||||
|
printer.update(describe(stage), group: group(of: stage))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stage a status line belongs to, ignoring its varying payload.
|
||||||
|
///
|
||||||
|
/// Two lines share a group exactly when one is meant to overwrite the
|
||||||
|
/// other. Crossing a group boundary seals the previous line instead, which
|
||||||
|
/// is why `installing macOS … 100%` survives into scrollback rather than
|
||||||
|
/// being replaced by `first boot + guest provisioning…`.
|
||||||
|
static func group(of stage: ImageBuildStage) -> String {
|
||||||
|
switch stage {
|
||||||
|
case .downloadingIPSW: return "download"
|
||||||
|
case .preparing: return "preparing"
|
||||||
|
case .loadingRestoreImage: return "loading"
|
||||||
|
case .creatingBundle: return "bundle"
|
||||||
|
case .note: return "note"
|
||||||
|
case .installing: return "install"
|
||||||
|
case .firstBoot: return "firstBoot"
|
||||||
|
// Each provisioning step is its own headline — "installing Node.js"
|
||||||
|
// should not erase "downloading gitea-runner" — but a step that carries
|
||||||
|
// a live percentage keeps rewriting one line rather than scrolling a
|
||||||
|
// hundred of them, so only the part before the payload identifies it.
|
||||||
|
case .provisioning(let step): return "provisioning:\(Self.stableHead(of: step))"
|
||||||
|
case .finalizing: return "finalizing"
|
||||||
|
case .done: return "done"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fixed part of a status line: everything before the two-space run that
|
||||||
|
/// separates a headline from its payload, following the same convention as
|
||||||
|
/// `installing macOS [====]`. A step with no payload is its own head.
|
||||||
|
static func stableHead(of step: String) -> String {
|
||||||
|
guard let separator = step.range(of: " ") else { return step }
|
||||||
|
return String(step[step.startIndex..<separator.lowerBound])
|
||||||
|
}
|
||||||
|
|
||||||
/// Renders a build stage as one status line.
|
/// Renders a build stage as one status line.
|
||||||
static func describe(_ stage: ImageBuildStage) -> String {
|
static func describe(_ stage: ImageBuildStage) -> String {
|
||||||
switch stage {
|
switch stage {
|
||||||
case .downloadingIPSW(let fraction):
|
case .downloadingIPSW(let fraction):
|
||||||
return "downloading IPSW " + CLI.progressBar(fraction)
|
return "downloading IPSW " + CLI.progressBar(fraction)
|
||||||
case .preparing:
|
case .preparing:
|
||||||
return "preparing"
|
return "resolving restore image…"
|
||||||
case .creatingBundle:
|
case .loadingRestoreImage:
|
||||||
return "creating bundle"
|
return "loading restore image metadata…"
|
||||||
|
case .creatingBundle(let diskGB):
|
||||||
|
return "creating VM bundle (disk \(diskGB) GB)…"
|
||||||
|
case .note(let text):
|
||||||
|
return text
|
||||||
case .installing(let fraction):
|
case .installing(let fraction):
|
||||||
return "installing macOS " + CLI.progressBar(fraction)
|
return "installing macOS " + CLI.progressBar(fraction)
|
||||||
case .firstBoot:
|
case .firstBoot:
|
||||||
return "first boot (Setup Assistant)"
|
return "first boot + guest provisioning…"
|
||||||
case .provisioning(let step):
|
case .provisioning(let step):
|
||||||
return "provisioning: \(step)"
|
return "provisioning: \(step)"
|
||||||
case .finalizing:
|
case .finalizing:
|
||||||
|
|||||||
@@ -85,9 +85,10 @@ struct VMCommand: AsyncParsableCommand {
|
|||||||
} catch {
|
} catch {
|
||||||
CLI.error("\(error)")
|
CLI.error("\(error)")
|
||||||
await session.teardown()
|
await session.teardown()
|
||||||
// Fully qualified: inside a ParsableCommand a bare `exit`
|
// Not `MainActor.run`: the main actor is parked inside
|
||||||
// resolves to ParsableCommand.exit(withError:).
|
// `app.run()` for the life of the process, so hopping onto
|
||||||
await MainActor.run { Foundation.exit(1) }
|
// it to exit is its own deadlock. See `VZAppRuntime.run`.
|
||||||
|
VZAppRuntime.flushAndExit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -136,15 +136,55 @@ final class OnceFlag: @unchecked Sendable {
|
|||||||
final class ProgressPrinter: @unchecked Sendable {
|
final class ProgressPrinter: @unchecked Sendable {
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private var lastLine = ""
|
private var lastLine = ""
|
||||||
|
private var lastGroup: String?
|
||||||
|
|
||||||
|
/// Whether carriage-return rewriting means anything here.
|
||||||
|
///
|
||||||
|
/// Piped to a file or captured by `launchd`, `\r` produces one unreadable
|
||||||
|
/// mega-line, so each update becomes its own line instead. `FileHandle`
|
||||||
|
/// writes go straight to the descriptor either way — there is no buffer to
|
||||||
|
/// flush, which is what makes a stall attributable to the stage last
|
||||||
|
/// printed rather than to output sitting unwritten.
|
||||||
|
private let isInteractive = isatty(fileno(stderr)) == 1
|
||||||
|
|
||||||
/// Rewrites the current line.
|
/// Rewrites the current line.
|
||||||
func update(_ line: String) {
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - line: The text to show.
|
||||||
|
/// - group: Names the stage this line belongs to. When it changes, the
|
||||||
|
/// outgoing stage's final line is sealed with a newline rather than
|
||||||
|
/// overwritten — so `installing macOS [####] 100%` is still on screen
|
||||||
|
/// when the operator scrolls back to work out where the last hour went,
|
||||||
|
/// instead of being replaced by whatever came next.
|
||||||
|
func update(_ line: String, group: String? = nil) {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
|
|
||||||
|
if let group, let lastGroup, group != lastGroup, !lastLine.isEmpty, isInteractive {
|
||||||
|
emit("\n")
|
||||||
|
lastLine = ""
|
||||||
|
}
|
||||||
|
if let group { lastGroup = group }
|
||||||
|
|
||||||
guard line != lastLine else { return }
|
guard line != lastLine else { return }
|
||||||
lastLine = line
|
lastLine = line
|
||||||
let padding = String(repeating: " ", count: max(0, 78 - line.count))
|
guard isInteractive else {
|
||||||
FileHandle.standardError.write(Data(("\r" + line + padding).utf8))
|
emit(line + "\n")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit("\r" + line + pad(line))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emits a standalone line without losing the status line under it.
|
||||||
|
func line(_ text: String) {
|
||||||
|
lock.lock()
|
||||||
|
let carried = lastLine
|
||||||
|
lock.unlock()
|
||||||
|
|
||||||
|
finish(text)
|
||||||
|
if !carried.isEmpty {
|
||||||
|
update(carried)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ends the line so subsequent output starts cleanly.
|
/// Ends the line so subsequent output starts cleanly.
|
||||||
@@ -152,11 +192,20 @@ final class ProgressPrinter: @unchecked Sendable {
|
|||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
if let line {
|
if let line {
|
||||||
let padding = String(repeating: " ", count: max(0, 78 - line.count))
|
emit((isInteractive ? "\r" : "") + line + (isInteractive ? pad(line) : "") + "\n")
|
||||||
FileHandle.standardError.write(Data(("\r" + line + padding + "\n").utf8))
|
} else if !lastLine.isEmpty, isInteractive {
|
||||||
} else if !lastLine.isEmpty {
|
emit("\n")
|
||||||
FileHandle.standardError.write(Data("\n".utf8))
|
|
||||||
}
|
}
|
||||||
lastLine = ""
|
lastLine = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Trailing blanks that erase whatever the previous, longer line left behind.
|
||||||
|
private func pad(_ line: String) -> String {
|
||||||
|
String(repeating: " ", count: max(0, 78 - line.count))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes straight to the descriptor. Call with ``lock`` held.
|
||||||
|
private func emit(_ text: String) {
|
||||||
|
FileHandle.standardError.write(Data(text.utf8))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import RunnerCore
|
||||||
|
|
||||||
|
/// Tests for ``PathResolution`` — the CLI's defence against shell quoting.
|
||||||
|
///
|
||||||
|
/// The bug these exist for: `--ipsw ~/Downloads/UniversalMac_27.0_*.ipsw`
|
||||||
|
/// behaves differently depending on whether the shell expanded the glob, so the
|
||||||
|
/// tool has to resolve the argument itself and stop caring.
|
||||||
|
@Suite("PathResolution")
|
||||||
|
struct PathResolutionTests {
|
||||||
|
|
||||||
|
/// A scratch directory holding `names`, deleted when `body` returns.
|
||||||
|
private func withFiles(_ names: [String], _ body: (String) throws -> Void) throws {
|
||||||
|
let dir = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("gmr-path-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
defer { try? FileManager.default.removeItem(at: dir) }
|
||||||
|
|
||||||
|
for name in names {
|
||||||
|
let path = dir.appendingPathComponent(name)
|
||||||
|
#expect(FileManager.default.createFile(atPath: path.path, contents: Data()))
|
||||||
|
}
|
||||||
|
try body(dir.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Pattern detection
|
||||||
|
|
||||||
|
@Test("only glob metacharacters make a string a pattern")
|
||||||
|
func patternDetection() {
|
||||||
|
#expect(!PathResolution.isPattern("/tmp/UniversalMac_27.0.ipsw"))
|
||||||
|
// A tilde is expanded either way; it does not make this a glob.
|
||||||
|
#expect(!PathResolution.isPattern("~/Downloads/x.ipsw"))
|
||||||
|
#expect(PathResolution.isPattern("/tmp/a_*.ipsw"))
|
||||||
|
#expect(PathResolution.isPattern("/tmp/a_?.ipsw"))
|
||||||
|
#expect(PathResolution.isPattern("/tmp/a_[12].ipsw"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Passthrough
|
||||||
|
|
||||||
|
@Test("a plain path is returned untouched")
|
||||||
|
func plainPathPassesThrough() throws {
|
||||||
|
// Deliberately not checked for existence: the caller's own error knows
|
||||||
|
// what the file was for and says something more useful than we could.
|
||||||
|
#expect(try PathResolution.resolve("/tmp/nope.ipsw", label: "--ipsw") == "/tmp/nope.ipsw")
|
||||||
|
#expect(try PathResolution.resolve("relative/x.ipsw", label: "--ipsw") == "relative/x.ipsw")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("a leading tilde is expanded")
|
||||||
|
func tildeIsExpanded() throws {
|
||||||
|
let home = NSHomeDirectory()
|
||||||
|
#expect(try PathResolution.resolve("~/Downloads/x.ipsw", label: "--ipsw") == home + "/Downloads/x.ipsw")
|
||||||
|
// Only leading: a tilde inside a path component is an ordinary character.
|
||||||
|
#expect(try PathResolution.resolve("/tmp/~x.ipsw", label: "--ipsw") == "/tmp/~x.ipsw")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Globbing
|
||||||
|
|
||||||
|
@Test("a pattern matching exactly one file resolves to it")
|
||||||
|
func singleMatchResolves() throws {
|
||||||
|
try withFiles(["UniversalMac_27.0_ABC.ipsw", "notes.txt"]) { dir in
|
||||||
|
let resolved = try PathResolution.resolve("\(dir)/UniversalMac_27.0_*.ipsw", label: "--ipsw")
|
||||||
|
#expect(resolved == "\(dir)/UniversalMac_27.0_ABC.ipsw")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("a pattern matching nothing is a clear notFound")
|
||||||
|
func zeroMatchesThrows() throws {
|
||||||
|
try withFiles(["a_1.ipsw"]) { dir in
|
||||||
|
#expect(throws: CoreError.self) {
|
||||||
|
try PathResolution.resolve("\(dir)/nope_*.ipsw", label: "--ipsw")
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
_ = try PathResolution.resolve("\(dir)/nope_*.ipsw", label: "--ipsw")
|
||||||
|
Issue.record("expected a throw")
|
||||||
|
} catch let error as CoreError {
|
||||||
|
guard case .notFound(let message) = error else {
|
||||||
|
Issue.record("expected .notFound, got \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect(message.contains("--ipsw"))
|
||||||
|
#expect(message.contains("no file matches"))
|
||||||
|
#expect(message.contains("\(dir)/nope_*.ipsw"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("a pattern matching several files lists them, sorted, and refuses")
|
||||||
|
func multipleMatchesThrowsAndLists() throws {
|
||||||
|
// Created out of order: the message must not depend on creation order,
|
||||||
|
// because the operator is being asked to read it and pick one.
|
||||||
|
try withFiles(["a_2.ipsw", "a_10.ipsw", "a_1.ipsw"]) { dir in
|
||||||
|
do {
|
||||||
|
_ = try PathResolution.resolve("\(dir)/a_*.ipsw", label: "--ipsw")
|
||||||
|
Issue.record("expected a throw")
|
||||||
|
} catch let error as CoreError {
|
||||||
|
guard case .configInvalid(let message) = error else {
|
||||||
|
Issue.record("expected .configInvalid, got \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect(message.contains("--ipsw"))
|
||||||
|
#expect(message.contains("3 files match"))
|
||||||
|
for name in ["a_1.ipsw", "a_2.ipsw", "a_10.ipsw"] {
|
||||||
|
#expect(message.contains("\(dir)/\(name)"))
|
||||||
|
}
|
||||||
|
// Sorted, so two runs read identically.
|
||||||
|
let one = message.range(of: "a_1.ipsw")!.lowerBound
|
||||||
|
let ten = message.range(of: "a_10.ipsw")!.lowerBound
|
||||||
|
let two = message.range(of: "a_2.ipsw")!.lowerBound
|
||||||
|
#expect(one < ten)
|
||||||
|
#expect(ten < two)
|
||||||
|
#expect(message.contains("name exactly one of them"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("brackets are a character class when they match, and a filename when they do not")
|
||||||
|
func bracketsAreGlobOnlyWhenTheyAreOne() throws {
|
||||||
|
// Used as a class: `[12]` selects the one file that exists.
|
||||||
|
try withFiles(["build_1.ipsw"]) { dir in
|
||||||
|
let asClass = try PathResolution.resolve("\(dir)/build_[12].ipsw", label: "--ipsw")
|
||||||
|
#expect(asClass == "\(dir)/build_1.ipsw")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A real file whose name contains brackets. Read as a class it matches
|
||||||
|
// nothing, so the literal has to win — otherwise a legal filename is
|
||||||
|
// unreachable through this flag.
|
||||||
|
try withFiles(["report[1].ipsw"]) { dir in
|
||||||
|
let asLiteral = try PathResolution.resolve("\(dir)/report[1].ipsw", label: "--ipsw")
|
||||||
|
#expect(asLiteral == "\(dir)/report[1].ipsw")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("a pattern that resolves is not confused by neighbours of another extension")
|
||||||
|
func matchingIsScopedToThePattern() throws {
|
||||||
|
try withFiles(["a_1.ipsw", "a_1.ipsw.part", "a_1.txt"]) { dir in
|
||||||
|
let resolved = try PathResolution.resolve("\(dir)/a_*.ipsw", label: "--ipsw")
|
||||||
|
#expect(resolved == "\(dir)/a_1.ipsw")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - IPSW pre-validation
|
||||||
|
|
||||||
|
/// Writes `bytes` at the front of a sparse file of `size` bytes.
|
||||||
|
///
|
||||||
|
/// Sparse because a valid-size fixture is a gigabyte and nobody should wait
|
||||||
|
/// for a gigabyte of zeroes to be written to test a two-byte check.
|
||||||
|
private func withIPSW(bytes: [UInt8], size: UInt64, _ body: (String) throws -> Void) throws {
|
||||||
|
let dir = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("gmr-ipsw-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
defer { try? FileManager.default.removeItem(at: dir) }
|
||||||
|
|
||||||
|
let path = dir.appendingPathComponent("image.ipsw").path
|
||||||
|
#expect(FileManager.default.createFile(atPath: path, contents: Data(bytes)))
|
||||||
|
let handle = try FileHandle(forWritingTo: URL(fileURLWithPath: path))
|
||||||
|
try handle.truncate(atOffset: size)
|
||||||
|
try handle.close()
|
||||||
|
|
||||||
|
try body(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
private let pk: [UInt8] = [0x50, 0x4B]
|
||||||
|
private let validSize: UInt64 = 2_000_000_000
|
||||||
|
|
||||||
|
@Test("a plausible restore image passes")
|
||||||
|
func healthyIPSWValidates() throws {
|
||||||
|
try withIPSW(bytes: pk, size: validSize) { path in
|
||||||
|
try IPSWFile.validate(path: path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("a missing file is notFound, not a hang")
|
||||||
|
func missingIPSWThrows() throws {
|
||||||
|
#expect(throws: CoreError.self) {
|
||||||
|
try IPSWFile.validate(path: "/tmp/gmr-definitely-absent.ipsw")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("a directory is rejected before the framework sees it")
|
||||||
|
func directoryIsRejected() throws {
|
||||||
|
try withFiles([]) { dir in
|
||||||
|
do {
|
||||||
|
try IPSWFile.validate(path: dir)
|
||||||
|
Issue.record("expected a throw")
|
||||||
|
} catch let error as CoreError {
|
||||||
|
#expect("\(error)".contains("directory"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("a truncated download names its own size and says what happened")
|
||||||
|
func truncatedIPSWIsRejected() throws {
|
||||||
|
// 4 MB: the shape of a download that stopped early — right magic bytes,
|
||||||
|
// nowhere near the right length.
|
||||||
|
try withIPSW(bytes: pk, size: 4_000_000) { path in
|
||||||
|
do {
|
||||||
|
try IPSWFile.validate(path: path)
|
||||||
|
Issue.record("expected a throw")
|
||||||
|
} catch let error as CoreError {
|
||||||
|
guard case .configInvalid(let message) = error else {
|
||||||
|
Issue.record("expected .configInvalid, got \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect(message.contains("3.8 MB"))
|
||||||
|
#expect(message.contains("incomplete"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("an empty file is rejected on size, before anything reads it")
|
||||||
|
func emptyIPSWIsRejected() throws {
|
||||||
|
try withIPSW(bytes: [], size: 0) { path in
|
||||||
|
#expect(throws: CoreError.self) { try IPSWFile.validate(path: path) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("a big file that is not a zip is rejected on its magic bytes")
|
||||||
|
func wrongMagicIsRejected() throws {
|
||||||
|
try withIPSW(bytes: [0x00, 0x01], size: validSize) { path in
|
||||||
|
do {
|
||||||
|
try IPSWFile.validate(path: path)
|
||||||
|
Issue.record("expected a throw")
|
||||||
|
} catch let error as CoreError {
|
||||||
|
guard case .configInvalid(let message) = error else {
|
||||||
|
Issue.record("expected .configInvalid, got \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect(message.contains("PK"))
|
||||||
|
#expect(message.contains("0001"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("sizes are rendered the way the operator will compare them")
|
||||||
|
func sizeDescriptions() {
|
||||||
|
#expect(IPSWFile.describeSize(0) == "0 B")
|
||||||
|
#expect(IPSWFile.describeSize(512) == "512 B")
|
||||||
|
#expect(IPSWFile.describeSize(22_567_352_533) == "21.0 GB")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("the label names the offending flag so the operator knows what to fix")
|
||||||
|
func labelAppearsInErrors() throws {
|
||||||
|
try withFiles([]) { dir in
|
||||||
|
do {
|
||||||
|
_ = try PathResolution.resolve("\(dir)/*.xip", label: "--xcode-xip")
|
||||||
|
Issue.record("expected a throw")
|
||||||
|
} catch let error as CoreError {
|
||||||
|
#expect("\(error)".contains("--xcode-xip"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import RunnerCore
|
||||||
|
|
||||||
|
/// Tests for ``XcodeInstall`` — the decidable parts of installing Xcode.
|
||||||
|
///
|
||||||
|
/// The bug these exist for: the installer assumed the archive expanded to
|
||||||
|
/// `Xcode.app`. A beta expands to `Xcode-beta.app`, so a 12 GB upload and a
|
||||||
|
/// half-hour expansion both succeeded and then the final `mv` failed with
|
||||||
|
/// "No such file or directory". Nothing about that is worth discovering from a
|
||||||
|
/// forty-minute live run twice.
|
||||||
|
@Suite("XcodeInstall")
|
||||||
|
struct XcodeInstallTests {
|
||||||
|
|
||||||
|
// MARK: - Discovering the expanded app
|
||||||
|
|
||||||
|
@Test("a release archive's Xcode.app is found")
|
||||||
|
func findsReleaseApp() throws {
|
||||||
|
let path = try XcodeInstall.expandedAppPath(
|
||||||
|
fromListing: "/tmp/xcode-expand/Xcode.app\n", staging: "/tmp/xcode-expand")
|
||||||
|
#expect(path == "/tmp/xcode-expand/Xcode.app")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The reported failure, in one line.
|
||||||
|
@Test("a beta archive's Xcode-beta.app is found")
|
||||||
|
func findsBetaApp() throws {
|
||||||
|
let path = try XcodeInstall.expandedAppPath(
|
||||||
|
fromListing: "/tmp/xcode-expand/Xcode-beta.app", staging: "/tmp/xcode-expand")
|
||||||
|
#expect(path == "/tmp/xcode-expand/Xcode-beta.app")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("a version-qualified name is found")
|
||||||
|
func findsVersionQualifiedApp() throws {
|
||||||
|
let path = try XcodeInstall.expandedAppPath(
|
||||||
|
fromListing: " /tmp/xcode-expand/Xcode_16.2.app \n\n", staging: "/tmp/xcode-expand")
|
||||||
|
#expect(path == "/tmp/xcode-expand/Xcode_16.2.app")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `/bin/sh` echoes an unmatched glob back verbatim, so "no match" arrives
|
||||||
|
/// as a plausible-looking path rather than as empty output.
|
||||||
|
@Test("an unmatched glob reads as no match, not as a path")
|
||||||
|
func unmatchedGlobIsNotAPath() {
|
||||||
|
#expect(throws: CoreError.self) {
|
||||||
|
try XcodeInstall.expandedAppPath(
|
||||||
|
fromListing: "/tmp/xcode-expand/*.app\n", staging: "/tmp/xcode-expand")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("empty output is an error naming the staging directory")
|
||||||
|
func emptyListingThrows() {
|
||||||
|
do {
|
||||||
|
_ = try XcodeInstall.expandedAppPath(fromListing: "\n \n", staging: "/tmp/xcode-expand")
|
||||||
|
Issue.record("expected a failure")
|
||||||
|
} catch {
|
||||||
|
#expect("\(error)".contains("/tmp/xcode-expand"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Guessing between two candidates would install something the operator did
|
||||||
|
/// not ask for, so this stops instead.
|
||||||
|
@Test("several apps is an error listing them")
|
||||||
|
func ambiguousListingThrows() {
|
||||||
|
do {
|
||||||
|
_ = try XcodeInstall.expandedAppPath(
|
||||||
|
fromListing: "/tmp/x/Xcode.app\n/tmp/x/Xcode-beta.app\n", staging: "/tmp/x")
|
||||||
|
Issue.record("expected a failure")
|
||||||
|
} catch {
|
||||||
|
let text = "\(error)"
|
||||||
|
#expect(text.contains("Xcode.app"))
|
||||||
|
#expect(text.contains("Xcode-beta.app"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Disk arithmetic
|
||||||
|
|
||||||
|
@Test("the requirement covers the archive and its expansion")
|
||||||
|
func requiredFreeSpaceCoversBoth() {
|
||||||
|
let xip = 12_000_000_000
|
||||||
|
#expect(XcodeInstall.expansionEstimateBytes(xipBytes: xip) == 42_000_000_000)
|
||||||
|
#expect(XcodeInstall.requiredFreeBytes(xipBytes: xip) == 54_000_000_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("df -Pk output yields available bytes")
|
||||||
|
func parsesDF() {
|
||||||
|
let output = """
|
||||||
|
Filesystem 1024-blocks Used Available Capacity Mounted on
|
||||||
|
/dev/disk3s5 488245288 120000000 62914560 66% /
|
||||||
|
"""
|
||||||
|
#expect(XcodeInstall.availableBytes(dfOutput: output) == 62_914_560 * 1024)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unparseable output means "could not check", not "no space" — the caller
|
||||||
|
/// must not refuse to install because `df` printed something unexpected.
|
||||||
|
@Test("unparseable df output yields nil rather than zero")
|
||||||
|
func unparseableDFIsNil() {
|
||||||
|
#expect(XcodeInstall.availableBytes(dfOutput: "df: /nope: No such file or directory") == nil)
|
||||||
|
#expect(XcodeInstall.availableBytes(dfOutput: "") == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("the shortfall message names every number and a remedy")
|
||||||
|
func messageNamesTheNumbers() {
|
||||||
|
let text = XcodeInstall.insufficientDiskMessage(
|
||||||
|
xipBytes: 12_000_000_000, availableBytes: 20_000_000_000)
|
||||||
|
#expect(text.contains("20.0 GB")) // available
|
||||||
|
#expect(text.contains("54.0 GB")) // needed
|
||||||
|
#expect(text.contains("12.0 GB")) // the archive
|
||||||
|
#expect(text.contains("--disk-gb"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("byte counts render as decimal gigabytes")
|
||||||
|
func formatsGigabytes() {
|
||||||
|
#expect(XcodeInstall.formatGB(12_400_000_000) == "12.4 GB")
|
||||||
|
#expect(XcodeInstall.formatGB(0) == "0.0 GB")
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
-8
@@ -315,6 +315,17 @@ 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
|
downloaded into `storeDir/ipsw/` first, which is most of the build's wall-clock
|
||||||
time. `--disk-gb` overrides `guest.diskGB` for this image only.
|
time. `--disk-gb` overrides `guest.diskGB` for this image only.
|
||||||
|
|
||||||
|
`--ipsw` (and `image provision --xcode-xip`) accept a `~` and a glob, quoted or not — these are
|
||||||
|
equivalent, and neither depends on what your shell did with the pattern first:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
gitea-macos-runner image build --ipsw ~/Downloads/UniversalMac_27.0_*.ipsw
|
||||||
|
gitea-macos-runner image build --ipsw '~/Downloads/UniversalMac_27.0_*.ipsw'
|
||||||
|
```
|
||||||
|
|
||||||
|
A pattern must identify exactly one file. If it matches several, the build stops before doing any
|
||||||
|
work and lists them so you can name the one you meant; if it matches none, it says so.
|
||||||
|
|
||||||
This takes a long time — macOS installs from the IPSW, boots, and is then provisioned over SSH.
|
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
|
**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.
|
in an undefined state; delete the image and start over rather than trying to resume.
|
||||||
@@ -390,16 +401,22 @@ session, and nothing to redo after a rebuild:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo defaults write com.apple.network.local-network \
|
sudo defaults write com.apple.network.local-network \
|
||||||
AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/24"
|
AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/18"
|
||||||
sudo defaults write com.apple.network.local-network \
|
sudo defaults write com.apple.network.local-network \
|
||||||
AllowedWiFiLocalNetworkAddresses -array "192.168.64.0/24"
|
AllowedWiFiLocalNetworkAddresses -array "192.168.64.0/18"
|
||||||
```
|
```
|
||||||
|
|
||||||
Then **reboot** — these are read at boot, so restarting the service alone is not enough. Adjust the
|
Then **reboot** — these are read at boot, so restarting the service alone is not enough.
|
||||||
range to match the subnet Virtualization.framework's NAT hands out on your host (check
|
|
||||||
`/var/db/dhcpd_leases` after a VM boots); if you would rather not pin it, the RFC 1918 set
|
**Do not pin this to a single /24.** Virtualization.framework's NAT starts at `192.168.64.0/24` but
|
||||||
`"10.0.0.0/8" "172.16.0.0/12" "192.168.0.0/16"` also works. `doctor` reports `local network access`
|
picks the subnet at runtime and steps to the next free one when that range is already in use, so the
|
||||||
as a **pass** once it can see an allowlist. Both keys are documented by Apple in
|
same host can hand out `192.168.65.x` tomorrow. An allowlist naming only `192.168.64.0/24` then
|
||||||
|
looks configured while silently blocking every guest — the failure surfaces as `No route to host`
|
||||||
|
(errno 65) on the SSH connection, not as a permission error. The `/18` above spans
|
||||||
|
`192.168.64.0`–`192.168.127.255`, which covers the drift; if you would rather not think about
|
||||||
|
ranges at all, the RFC 1918 set `"10.0.0.0/8" "172.16.0.0/12" "192.168.0.0/16"` also works. `doctor`
|
||||||
|
reports `local network access` as a **pass** once it sees an allowlist that covers that span, and as
|
||||||
|
a **warning** when an allowlist exists but does not. Both keys are documented by Apple in
|
||||||
[TN3179](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy)
|
[TN3179](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy)
|
||||||
and are the workaround [Tart's FAQ](https://tart.run/faq/) recommends for the same problem.
|
and are the workaround [Tart's FAQ](https://tart.run/faq/) recommends for the same problem.
|
||||||
|
|
||||||
@@ -453,7 +470,7 @@ The checks, in order:
|
|||||||
| `registration token` | A static token resolves, or one can be fetched when `fetchRegistrationTokenViaAPI` is on |
|
| `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 |
|
| `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 |
|
| `token file permissions` | Warns — not fails — when a token file is group- or world-readable |
|
||||||
| `local network access` | Passes when a subnet allowlist is set; otherwise an informational note about the macOS 15+ Local Network prompt (§2.6) |
|
| `local network access` | Passes when a subnet allowlist covers `192.168.64.0/18`; warns when an allowlist exists but is scoped too narrowly; otherwise an informational note about the macOS 15+ Local Network prompt (§2.6) |
|
||||||
|
|
||||||
If the config file is missing or invalid, the host checks still run and the rest
|
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
|
are skipped — which is exactly the state a first-time operator is in. Resolve
|
||||||
|
|||||||
+198
-7
@@ -26,6 +26,8 @@ gitea-macos-runner service status
|
|||||||
| 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 |
|
| 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 |
|
||||||
| Runner not listed under Privacy & Security → Local Network | Expected — the list is populated only after the app first attempts a local connection; it cannot be pre-approved | Boot one VM by hand from a GUI Terminal to create the entry, or (better on CI) allowlist the subnet with `defaults write com.apple.network.local-network` |
|
| Runner not listed under Privacy & Security → Local Network | Expected — the list is populated only after the app first attempts a local connection; it cannot be pre-approved | Boot one VM by hand from a GUI Terminal to create the entry, or (better on CI) allowlist the subnet with `defaults write com.apple.network.local-network` |
|
||||||
| 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 |
|
| 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 |
|
||||||
|
| `ssh failed: cannot connect … No route to host) (errno: 65)` part-way through provisioning | macOS 15+ Local Network privacy blocking the app — the grant is keyed on the executable's UUID, so `make install` withdraws it | Allowlist the subnet (`192.168.64.0/18`) and **reboot**; see [SSH fails with "No route to host" mid-run](#ssh-fails-with-no-route-to-host-errno-65-mid-run) |
|
||||||
|
| Allowlist is set but guests are still unreachable | It names `192.168.64.0/24` while the NAT has moved to `192.168.65.x` | Widen it to `192.168.64.0/18` and reboot; `doctor` now warns about too-narrow allowlists |
|
||||||
| `SecKeyCreateRandomKey` / "Interaction is not allowed" | `login.keychain` is locked — no GUI session | Run as a LaunchAgent in an unlocked GUI session; enable auto-login |
|
| `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 |
|
| 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>` |
|
| `actions/checkout` fails instantly | Node.js missing from the guest image | `gitea-macos-runner image provision <name>` |
|
||||||
@@ -33,6 +35,10 @@ gitea-macos-runner service status
|
|||||||
| `doctor` says `runner download url → 403` but the URL works in a browser | Old build: the check used `HEAD`, and the presigned redirect target is signed per method | Upgrade — the check now uses a ranged `GET`. If it persists, the asset really is missing |
|
| `doctor` says `runner download url → 403` but the URL works in a browser | Old build: the check used `HEAD`, and the presigned redirect target is signed per method | Upgrade — the check now uses a ranged `GET`. If it persists, the asset really is missing |
|
||||||
| Disk filling up | Copy-on-write clones grow as jobs write | Raise `storage.minFreeDiskGB`; delete stale clones in `storeDir/vms` |
|
| 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 |
|
| `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 |
|
||||||
|
| `image build` prints its banner and then nothing, at 0% CPU | Old build: the build task was queued behind the `NSApplication` run loop and never started | Upgrade — the task is now detached. Stage lines should appear within seconds |
|
||||||
|
| `image build` stuck at `loading restore image metadata…` | A truncated or partial `.ipsw` — the framework blocks rather than failing | Upgrade (the file is now size- and magic-checked first); re-download the IPSW |
|
||||||
|
| `provisioning failed: … Failed to lock auxiliary storage` after install | The installer's VM had not yet released `nvram.bin` when first boot started | Upgrade — the install now drains its queue and first boot retries for 30 s. Re-run `image build`; it resumes |
|
||||||
|
| `image build` says `image 'default' already exists` after a failed first boot | Old build: an installed-but-unprovisioned bundle was treated as a finished image | Upgrade — `image build` now resumes it instead of refusing |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -101,13 +107,15 @@ prompt**: a LaunchAgent that was never granted permission (or was denied) cannot
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo defaults write com.apple.network.local-network \
|
sudo defaults write com.apple.network.local-network \
|
||||||
AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/24"
|
AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/18"
|
||||||
sudo defaults write com.apple.network.local-network \
|
sudo defaults write com.apple.network.local-network \
|
||||||
AllowedWiFiLocalNetworkAddresses -array "192.168.64.0/24"
|
AllowedWiFiLocalNetworkAddresses -array "192.168.64.0/18"
|
||||||
```
|
```
|
||||||
|
|
||||||
Match the range to what your host's NAT actually hands out. `doctor` reports `local network
|
The `/18` is deliberate: the NAT subnet is chosen at runtime and slides to the next free /24
|
||||||
access` as a pass once it can see this. This is the deterministic fix for an unattended host —
|
(192.168.65.x, .66.x, …) when one is taken, so a pinned `192.168.64.0/24` breaks the day it
|
||||||
|
moves. `doctor` reports `local network access` as a pass once it sees an allowlist covering that
|
||||||
|
span. This is the deterministic fix for an unattended host —
|
||||||
see [Local Network: the app is not listed in System Settings](#local-network-the-app-is-not-listed-in-system-settings)
|
see [Local Network: the app is not listed in System Settings](#local-network-the-app-is-not-listed-in-system-settings)
|
||||||
for why the interactive grant is not.
|
for why the interactive grant is not.
|
||||||
|
|
||||||
@@ -147,9 +155,9 @@ on the network rather than on the app, so no prompt is involved and nothing need
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo defaults write com.apple.network.local-network \
|
sudo defaults write com.apple.network.local-network \
|
||||||
AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/24"
|
AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/18"
|
||||||
sudo defaults write com.apple.network.local-network \
|
sudo defaults write com.apple.network.local-network \
|
||||||
AllowedWiFiLocalNetworkAddresses -array "192.168.64.0/24"
|
AllowedWiFiLocalNetworkAddresses -array "192.168.64.0/18"
|
||||||
```
|
```
|
||||||
|
|
||||||
Reboot afterwards. `doctor` then reports `local network access` as a **pass**. Both keys are
|
Reboot afterwards. `doctor` then reports `local network access` as a **pass**. Both keys are
|
||||||
@@ -363,6 +371,72 @@ concurrent clones diverging.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## `image build` prints the banner and then nothing at all
|
||||||
|
|
||||||
|
**Symptom.** `image build` prints
|
||||||
|
|
||||||
|
```
|
||||||
|
building image 'default' (this takes a while; the IPSW alone is ~15 GB)
|
||||||
|
```
|
||||||
|
|
||||||
|
and then stops — no stage lines, no progress bar, no error. Activity Monitor shows the process
|
||||||
|
using no CPU and no VM services running. Ctrl-C does nothing.
|
||||||
|
|
||||||
|
**Cause.** A bug in releases before this fix. `image build` hosts an `NSApplication` run loop
|
||||||
|
(Virtualization.framework requires one), and the work was started with a `Task` that inherited the
|
||||||
|
main actor. Because `NSApplication.run()` is itself reached from Swift's async `main`, the main
|
||||||
|
dispatch queue already had a block in flight and would not re-enter — so the build task was queued
|
||||||
|
behind a run loop that never yields and never got a first tick. Nothing ran, including the code
|
||||||
|
that would have reported the error. `SIGINT`/`SIGTERM` handling was stuck the same way, which is
|
||||||
|
why Ctrl-C did not work either.
|
||||||
|
|
||||||
|
**Fix.** Upgrade. The build task is now detached and signals are handled off the main queue. You
|
||||||
|
should see stage lines within a second or two:
|
||||||
|
|
||||||
|
```
|
||||||
|
resolving restore image…
|
||||||
|
loading restore image metadata…
|
||||||
|
creating VM bundle (disk 64 GB)…
|
||||||
|
installing macOS [########----------------------] 27%
|
||||||
|
```
|
||||||
|
|
||||||
|
If a build still goes quiet, the line last printed tells you which stage owns the silence — see
|
||||||
|
below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `image build` sits at "loading restore image metadata…"
|
||||||
|
|
||||||
|
**Symptom.** The build reaches `loading restore image metadata…` and stays there. After a minute it
|
||||||
|
adds:
|
||||||
|
|
||||||
|
```
|
||||||
|
still loading — a truncated or partially downloaded .ipsw can block here; verify the download completed
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause.** `VZMacOSRestoreImage.image(from:)` reads the whole archive's metadata and reports no
|
||||||
|
progress while it does. On a healthy ~21 GB IPSW this takes seconds to a minute or two. On a
|
||||||
|
*partial* download it can block for a very long time instead of failing.
|
||||||
|
|
||||||
|
**Fix.** The obvious checks are now made before the framework is handed the file — it must exist, be
|
||||||
|
a regular file, be at least 1 GB, and start with the zip magic `PK` — so an incomplete download now
|
||||||
|
fails immediately with its actual size rather than hanging. If you are on an older build, check by
|
||||||
|
hand:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ls -la ~/Downloads/UniversalMac_*.ipsw # ~15-22 GB, no .download/.crdownload sibling
|
||||||
|
xxd -l 2 -p ~/Downloads/UniversalMac_*.ipsw # must print 504b
|
||||||
|
```
|
||||||
|
|
||||||
|
Anything smaller, or not starting `504b`, is an incomplete or wrong file: delete it and download it
|
||||||
|
again.
|
||||||
|
|
||||||
|
> A pattern that matches **more than one** IPSW is refused outright, listing the matches — for
|
||||||
|
> example `~/Downloads/UniversalMac_27.0_*.ipsw` when two 27.0 builds are sitting in `~/Downloads`.
|
||||||
|
> Name exactly one of them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## `image build` hangs at install
|
## `image build` hangs at install
|
||||||
|
|
||||||
**Symptom.** `image build` sits for a long time at the macOS install phase with little visible
|
**Symptom.** `image build` sits for a long time at the macOS install phase with little visible
|
||||||
@@ -373,7 +447,7 @@ 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
|
**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.
|
image in an undefined state; the resulting image may boot and then fail in confusing ways later.
|
||||||
There is no resume.
|
There is no resume from a *partial* install — only from a complete one (see the next section).
|
||||||
|
|
||||||
If you did interrupt it, or the build genuinely failed:
|
If you did interrupt it, or the build genuinely failed:
|
||||||
|
|
||||||
@@ -385,3 +459,120 @@ gitea-macos-runner image build --ipsw <path> --name <name>
|
|||||||
Before rebuilding, verify the IPSW is complete and matches your host architecture (Apple Silicon)
|
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
|
and version requirement (macOS 27+ for unattended provisioning), and that you have enough free disk
|
||||||
for the IPSW plus the target disk size.
|
for the IPSW plus the target disk size.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `Failed to lock auxiliary storage` right after the install finishes
|
||||||
|
|
||||||
|
**Symptom.** The macOS install runs to 100%, then:
|
||||||
|
|
||||||
|
```
|
||||||
|
first boot + guest provisioning…
|
||||||
|
error: provisioning failed: could not boot image 'default': provisioning failed: Invalid
|
||||||
|
virtual machine configuration. Failed to lock auxiliary storage.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause.** A `VZVirtualMachine` holds an exclusive lock on its bundle's `nvram.bin` for its entire
|
||||||
|
lifetime and releases it in `dealloc`. `VZMacOSInstaller` owns a VM of its own, and older builds
|
||||||
|
resumed the caller from inside the installer's completion handler — before the framework's frame
|
||||||
|
had unwound and dropped the last reference. First boot then constructed a *second* VM over the same
|
||||||
|
bundle and lost the race.
|
||||||
|
|
||||||
|
**Fix.** Upgrade. Two changes address it:
|
||||||
|
|
||||||
|
- The install now tears down its VM on its own serial queue and resumes the caller only from a
|
||||||
|
later block on that queue, so the installer's VM is deallocated before `install()` returns.
|
||||||
|
- First boot retries specifically on this failure for up to 30 s (2 s apart), printing
|
||||||
|
`waiting for installer to release the VM bundle…`. Any other configuration error still fails
|
||||||
|
immediately — an invalid configuration does not become valid by waiting.
|
||||||
|
|
||||||
|
Nothing is lost when it does happen: the install is complete, so re-running `image build` resumes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `image build` resumes an installed-but-unprovisioned image
|
||||||
|
|
||||||
|
**Symptom.** A previous `image build` finished installing macOS and then failed at first boot or
|
||||||
|
provisioning. Re-running it prints:
|
||||||
|
|
||||||
|
```
|
||||||
|
image default already installed — resuming first boot + provisioning
|
||||||
|
```
|
||||||
|
|
||||||
|
**This is intended.** An `image build` that fails after the install has left an hour of work on
|
||||||
|
disk, and throwing it away to redo an identical install is not a reasonable default. When the
|
||||||
|
bundle exists, is complete, and is not yet marked provisioned, the install phase is skipped and the
|
||||||
|
build goes straight to first boot.
|
||||||
|
|
||||||
|
It is safe because provisioning is exactly what did *not* happen: the guest has never been booted,
|
||||||
|
so its first boot is still ahead of it and `VZMacGuestProvisioningOptions` — which macOS evaluates
|
||||||
|
only on the first boot after a restore — still applies.
|
||||||
|
|
||||||
|
**The one ambiguous case.** The bundle on disk cannot say whether an earlier run got far enough to
|
||||||
|
*boot* the guest. If it did, that single chance at automated Setup Assistant is spent. Rather than
|
||||||
|
guess, the resume boots and waits for SSH: if the guest answers, provisioning was never applied and
|
||||||
|
the build continues normally. Only if SSH times out does it stop, and it then tells you so
|
||||||
|
explicitly — that state is unrecoverable, and the fix is `image delete` followed by a fresh build.
|
||||||
|
|
||||||
|
An image that is already `provisioned` is untouched; `image build` still refuses with
|
||||||
|
`image '<name>' already exists`. To re-run provisioning on a finished image, use
|
||||||
|
`gitea-macos-runner image provision <name>` instead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SSH fails with "No route to host" (errno 65) mid-run
|
||||||
|
|
||||||
|
**Symptom.** A command that had *just* talked to the guest successfully suddenly cannot reach it —
|
||||||
|
most visibly `image provision`, which waits for SSH, reports the first provisioning step, and then
|
||||||
|
dies on the upload:
|
||||||
|
|
||||||
|
```
|
||||||
|
first boot + guest provisioning…
|
||||||
|
provisioning: system configuration (provision.sh)
|
||||||
|
error: provisioning failed: could not upload provision.sh from …/provision.sh:
|
||||||
|
ssh failed: cannot connect to 192.168.65.232:22: … No route to host) (errno: 65)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause.** On macOS 15 and newer, an app that has not been granted Local Network access does not get
|
||||||
|
a "permission denied": the packet filter answers **`EHOSTUNREACH` — errno 65, "No route to host"**,
|
||||||
|
which is indistinguishable from a guest that is genuinely off the network. Guests here live on a
|
||||||
|
host-private NAT link that is reachable whenever the VM is up, so on this path errno 65 is far more
|
||||||
|
often the privacy filter than a routing problem.
|
||||||
|
|
||||||
|
Two details make it look intermittent rather than like a permission problem:
|
||||||
|
|
||||||
|
- **The grant is keyed on the executable's UUID.** Per
|
||||||
|
[TN3179](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy),
|
||||||
|
Local Network privacy "uses your main executable UUID as part of its implementation", and the
|
||||||
|
linker mints a fresh `LC_UUID` on essentially every build. A `make install` after a code change
|
||||||
|
therefore presents a program macOS has never seen, whose permission is undetermined again — even
|
||||||
|
though the binary you ran ten minutes ago worked.
|
||||||
|
- **Processes started over SSH are exempt.** Running the same command through `ssh you@host …`
|
||||||
|
succeeds while running it from a GUI Terminal fails. A remote-shell success proves nothing about
|
||||||
|
the interactive path.
|
||||||
|
|
||||||
|
**Fix.** Allowlist the subnet — it is keyed on the network, not on the app, so no rebuild can
|
||||||
|
withdraw it and no prompt has to be answered:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo defaults write com.apple.network.local-network \
|
||||||
|
AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/18"
|
||||||
|
sudo defaults write com.apple.network.local-network \
|
||||||
|
AllowedWiFiLocalNetworkAddresses -array "192.168.64.0/18"
|
||||||
|
sudo reboot
|
||||||
|
```
|
||||||
|
|
||||||
|
The values are only read at boot, so **the reboot is not optional** — until it happens, `defaults
|
||||||
|
read com.apple.network.local-network` shows the new setting while the filter still behaves as
|
||||||
|
before.
|
||||||
|
|
||||||
|
Use `/18`, not `/24`. Virtualization.framework's NAT starts at `192.168.64.0/24` but chooses the
|
||||||
|
subnet at runtime and steps to the next free /24 when that one is in use, so hosts drift to
|
||||||
|
`192.168.65.x` and beyond. An allowlist naming a single /24 that the NAT has since moved off is the
|
||||||
|
worst case: it reads as configured, `doctor` used to call it a pass, and every guest connection
|
||||||
|
still fails with errno 65. `doctor` now warns instead when the allowlist does not cover
|
||||||
|
`192.168.64.0`–`192.168.127.255`.
|
||||||
|
|
||||||
|
**Verifying.** After the reboot, `gitea-macos-runner doctor` should show `local network access` as a
|
||||||
|
pass naming the range. Re-run the command that failed; nothing else needs redoing, and
|
||||||
|
`image provision` is idempotent, so a partially completed run is safe to repeat.
|
||||||
|
|||||||
Reference in New Issue
Block a user