Merge nucleic/upbeat-opal-viper-cc8l into main
build / build (push) Canceled after 0s

This commit is contained in:
2026-08-07 05:11:01 -07:00
parent ee19744496
commit 6b0c01b74b
8 changed files with 395 additions and 28 deletions
+130 -1
View File
@@ -92,7 +92,11 @@ public enum Doctor {
/// same verb the real download uses, because the presigned redirect
/// target is signed per method. Catches a version bump that no longer
/// has a darwin-arm64 asset.
/// 10. **Local Network privacy**. Passes when a subnet allowlist is set in
/// 10. **Guest SSH**, against whichever slot currently holds a DHCP lease —
/// the one check that exercises host → vmnet → guest `sshd` → password
/// auth end to end. Informational when no guest is up, since `doctor`
/// will not boot one.
/// 11. **Local Network privacy**. Passes when a subnet allowlist is set in
/// `com.apple.network.local-network`; otherwise informational. On
/// macOS 15+ the first attempt to reach a guest over the NAT link can
/// be blocked by the Local Network permission prompt, which a
@@ -107,6 +111,7 @@ public enum Doctor {
checks.append(checkLoginKeychain())
checks.append(contentsOf: await checkGitea(config: config))
checks.append(await checkRunnerDownloadURL(config: config))
checks.append(await checkGuestSSH(config: config))
checks.append(localNetworkNote())
return checks
}
@@ -148,6 +153,7 @@ public enum Doctor {
checks.append(checkLoginKeychain())
checks.append(contentsOf: await checkGitea(config: loaded))
checks.append(await checkRunnerDownloadURL(config: loaded))
checks.append(await checkGuestSSH(config: loaded))
checks.append(contentsOf: checkTokenFilePermissions(config: loaded))
checks.append(localNetworkNote())
return checks
@@ -523,6 +529,129 @@ public enum Doctor {
]
}
/// Whether a guest that is up right now actually accepts an SSH session.
///
/// Every other check in this file inspects the host. This one exercises the
/// exact path the boot sequence depends on and that nothing else proves:
/// host → vmnet → guest `sshd` → password auth with `guest.username` /
/// `guest.password`. It is the difference between "the daemon never got a
/// runner online" and a named cause — wrong credentials, Local Network
/// privacy blocking the link, or a guest image whose Remote Login is off.
///
/// Read-only with respect to host state: it uses the MACs already persisted
/// in `state.json` and never generates them, so running `doctor` on a fresh
/// host does not quietly create the slot address table.
///
/// - Note: Informational when no guest is currently leased. `doctor` must not
/// boot a VM — that costs minutes and a slot out of the host's hard cap of
/// two — so with nothing running there is simply nothing to probe. To make
/// this check meaningful, leave a guest up (`gitea-macos-runner vm boot`)
/// and run `doctor` again.
public static func checkGuestSSH(config: RunnerConfig) async -> DoctorCheck {
let name = "guest ssh"
let macs: [String]
do {
macs = try VMStore(config: config).loadState().slotMACAddresses
} catch {
return DoctorCheck(
name: name,
result: .warn,
detail: "could not read host state: \(error)",
remediation: "check that \(config.storeDirectoryURL.path) is readable"
)
}
guard !macs.isEmpty else {
return DoctorCheck(
name: name,
result: .info,
detail: "no slot MAC addresses assigned yet; skipped",
remediation: nil
)
}
// Newest lease wins if a slot somehow holds more than one: that is the
// guest currently on the link.
let leases = DHCPLeaseParser.parseFile()
guard let (mac, lease) = macs.lazy
.compactMap({ mac in DHCPLeaseParser.lease(forMAC: mac, in: leases).map { (mac, $0) } })
.first
else {
return DoctorCheck(
name: name,
result: .info,
detail: "no guest currently holds a DHCP lease; skipped",
remediation: """
this check only runs against a guest that is already up. To exercise the \
host→guest SSH path, run `gitea-macos-runner vm boot --image default` and \
then `doctor` again.
"""
)
}
// Short and fixed rather than derived from `scheduler.bootTimeoutSeconds`:
// this probes a guest that is already booted, so a slow answer is a
// finding, not something to wait fifteen minutes for.
do {
try await waitForSSH(
host: lease.ipAddress,
username: config.guest.username,
password: config.guest.password,
timeout: .seconds(20),
pollInterval: .seconds(2)
)
return DoctorCheck(
name: name,
result: .pass,
detail: "authenticated to \(config.guest.username)@\(lease.ipAddress) (\(mac))"
)
} catch let error as CoreError {
let detail = "\(config.guest.username)@\(lease.ipAddress) (\(mac)): \(error)"
switch error {
case .timeout:
// Nothing answered. bootpd leases last 24 hours and the slot MACs
// are persistent, so on any host that has ever run the daemon the
// most likely explanation is a lease outliving the guest that held
// it — not a broken host. Calling that `.fail` would make `doctor`
// cry wolf on a perfectly healthy idle machine.
return DoctorCheck(
name: name,
result: .warn,
detail: detail,
remediation: """
most likely a stale lease: bootpd keeps leases for 24 hours, so this \
address may belong to a guest that has already been torn down. If a \
guest really is up at this address, the daemon cannot reach it either — \
check that the host's Local Network permission is not dropping the \
connection (see the "local network access" check).
"""
)
default:
// Authentication reached the guest and was refused: the guest is up
// and the credentials are wrong. Nothing about that improves on its
// own, and every boot will fail the same way.
return DoctorCheck(
name: name,
result: .fail,
detail: detail,
remediation: """
the daemon authenticates over this exact path, so no boot can succeed \
while it fails. Check that guest.username and guest.password match an \
account in the guest image, and that Remote Login is enabled there.
"""
)
}
} catch {
return DoctorCheck(
name: name,
result: .warn,
detail: "\(config.guest.username)@\(lease.ipAddress) (\(mac)): \(error)",
remediation: nil
)
}
}
/// The macOS 15+ Local Network permission note.
///
/// Reports `.pass` when the host carries a subnet allowlist that actually