Nucleic: Gitea Runner macOS VM Support
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
import Foundation
|
||||
import RunnerCore
|
||||
|
||||
/// Whether the LaunchAgent is installed and running.
|
||||
public struct ServiceStatus: Sendable, Equatable {
|
||||
/// Whether the plist exists at ``LaunchdService/agentPlistURL``.
|
||||
public let installed: Bool
|
||||
/// Whether `launchctl` reports the label as loaded.
|
||||
public let loaded: Bool
|
||||
/// The running PID, when loaded and alive.
|
||||
public let pid: Int?
|
||||
/// The last exit status `launchctl` reported, when not running.
|
||||
public let lastExitStatus: Int?
|
||||
/// Path to the plist, whether or not it exists.
|
||||
public let plistPath: String
|
||||
|
||||
public init(
|
||||
installed: Bool,
|
||||
loaded: Bool,
|
||||
pid: Int? = nil,
|
||||
lastExitStatus: Int? = nil,
|
||||
plistPath: String
|
||||
) {
|
||||
self.installed = installed
|
||||
self.loaded = loaded
|
||||
self.pid = pid
|
||||
self.lastExitStatus = lastExitStatus
|
||||
self.plistPath = plistPath
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs, removes, and inspects the daemon's `launchd` job.
|
||||
///
|
||||
/// ## LaunchAgent, never LaunchDaemon
|
||||
///
|
||||
/// This is not a stylistic choice. Two hard constraints force it:
|
||||
///
|
||||
/// * Virtualization.framework needs a **GUI login session**. A LaunchDaemon runs
|
||||
/// in the system context with no session, and VM startup fails there.
|
||||
/// * From macOS 15, starting a VM requires an **unlocked `login.keychain`**.
|
||||
/// That keychain unlocks when a user logs in graphically; a LaunchDaemon never
|
||||
/// sees it.
|
||||
///
|
||||
/// So the daemon runs as a LaunchAgent in the logged-in user's session, and the
|
||||
/// host must be configured for automatic login with the screen allowed to sleep
|
||||
/// but the session never locked. `doctor` checks the keychain state precisely
|
||||
/// because this is the failure people hit first.
|
||||
public enum LaunchdService {
|
||||
/// The `launchd` label, matching `CFBundleIdentifier`.
|
||||
public static let label = "xyz.blakeslee.gitea-macos-runner"
|
||||
|
||||
/// `~/Library/LaunchAgents/xyz.blakeslee.gitea-macos-runner.plist`.
|
||||
public static var agentPlistURL: URL {
|
||||
URL(fileURLWithPath: RunnerConfig.expandTilde("~/Library/LaunchAgents/\(label).plist"))
|
||||
}
|
||||
|
||||
/// The default install location of the signed app's executable.
|
||||
///
|
||||
/// `make install` puts the bundle here; the entitlement only exists on the
|
||||
/// signed bundle, so this — not a bare binary — is what `launchd` must run.
|
||||
public static let defaultExecutablePath =
|
||||
"~/Applications/GiteaMacosRunner.app/Contents/MacOS/gitea-macos-runner"
|
||||
|
||||
/// The GUI domain target for this user, e.g. `gui/501`.
|
||||
public static var domainTarget: String { "gui/\(getuid())" }
|
||||
|
||||
/// The service target for this user's agent, e.g. `gui/501/xyz.blakeslee…`.
|
||||
public static var serviceTarget: String { "\(domainTarget)/\(label)" }
|
||||
|
||||
/// Writes the plist and loads the job.
|
||||
///
|
||||
/// `ProgramArguments` is the **installed app bundle's** executable followed
|
||||
/// by `daemon` — not `.build/…` and not a bare binary, because the
|
||||
/// entitlement only exists on the signed bundle. `RunAtLoad` and `KeepAlive`
|
||||
/// are both set so the daemon survives crashes and logins.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - executablePath: Absolute path to the installed binary, e.g.
|
||||
/// `~/Applications/GiteaMacosRunner.app/Contents/MacOS/gitea-macos-runner`.
|
||||
/// - configPath: Optional `--config` argument for a non-default location.
|
||||
/// - Throws: ``CoreError/notFound(_:)`` when the executable or the template
|
||||
/// is missing, ``CoreError/processFailed(command:exitCode:output:)`` when
|
||||
/// `launchctl` refuses the job.
|
||||
public static func install(executablePath: String, configPath: String? = nil) throws {
|
||||
let executable = RunnerConfig.expandTilde(executablePath)
|
||||
guard FileManager.default.isExecutableFile(atPath: executable) else {
|
||||
throw CoreError.notFound(
|
||||
"""
|
||||
no executable at \(executable) — run `make install` to build, sign, \
|
||||
and install the app bundle first
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
var arguments = ["daemon"]
|
||||
if let configPath {
|
||||
arguments += ["--config", RunnerConfig.expandTilde(configPath)]
|
||||
}
|
||||
|
||||
let xml = try renderPlist(executablePath: executable, arguments: arguments)
|
||||
|
||||
let fm = FileManager.default
|
||||
try fm.createDirectory(at: logDirectoryURL, withIntermediateDirectories: true)
|
||||
try fm.createDirectory(
|
||||
at: agentPlistURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
|
||||
// A reinstall over a loaded job is the common case (upgrade, config
|
||||
// change), so unload before rewriting rather than failing on "already
|
||||
// bootstrapped".
|
||||
if fm.fileExists(atPath: agentPlistURL.path) {
|
||||
_ = try? uninstallJobOnly()
|
||||
}
|
||||
|
||||
do {
|
||||
try Data(xml.utf8).write(to: agentPlistURL, options: .atomic)
|
||||
} catch {
|
||||
throw CoreError.processFailed(
|
||||
command: "write \(agentPlistURL.path)",
|
||||
exitCode: 1,
|
||||
output: error.localizedDescription
|
||||
)
|
||||
}
|
||||
|
||||
let bootstrap = LaunchdShell.run("/bin/launchctl", ["bootstrap", domainTarget, agentPlistURL.path])
|
||||
if bootstrap.exitCode != 0 {
|
||||
// `bootstrap` is the modern verb but is unavailable in some session
|
||||
// contexts (and returns 5 for "input/output error" on odd domains);
|
||||
// the legacy loader still works there.
|
||||
let legacy = LaunchdShell.run("/bin/launchctl", ["load", "-w", agentPlistURL.path])
|
||||
if legacy.exitCode != 0 {
|
||||
throw CoreError.processFailed(
|
||||
command: "launchctl bootstrap \(domainTarget) \(agentPlistURL.path)",
|
||||
exitCode: bootstrap.exitCode,
|
||||
output: (bootstrap.output + "\n" + legacy.output).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Unloads the job and removes the plist. Safe when not installed.
|
||||
public static func uninstall() throws {
|
||||
_ = try? uninstallJobOnly()
|
||||
if FileManager.default.fileExists(atPath: agentPlistURL.path) {
|
||||
try FileManager.default.removeItem(at: agentPlistURL)
|
||||
}
|
||||
}
|
||||
|
||||
/// Unloads the job but leaves the plist on disk.
|
||||
private static func uninstallJobOnly() throws {
|
||||
let bootout = LaunchdShell.run("/bin/launchctl", ["bootout", serviceTarget])
|
||||
if bootout.exitCode != 0 {
|
||||
_ = LaunchdShell.run("/bin/launchctl", ["unload", "-w", agentPlistURL.path])
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports installation and run state.
|
||||
public static func status() throws -> ServiceStatus {
|
||||
let installed = FileManager.default.fileExists(atPath: agentPlistURL.path)
|
||||
let printed = LaunchdShell.run("/bin/launchctl", ["print", serviceTarget])
|
||||
|
||||
guard printed.exitCode == 0 else {
|
||||
// 113 (EAGAIN-ish "Could not find service") and 36 are both "not
|
||||
// loaded"; anything else is still, for our purposes, not loaded.
|
||||
return ServiceStatus(installed: installed, loaded: false, plistPath: agentPlistURL.path)
|
||||
}
|
||||
|
||||
return ServiceStatus(
|
||||
installed: installed,
|
||||
loaded: true,
|
||||
pid: firstInteger(in: printed.output, key: "pid"),
|
||||
lastExitStatus: firstInteger(in: printed.output, key: "last exit code"),
|
||||
plistPath: agentPlistURL.path
|
||||
)
|
||||
}
|
||||
|
||||
/// Extracts `key = <integer>` from `launchctl print` output.
|
||||
private static func firstInteger(in output: String, key: String) -> Int? {
|
||||
for line in output.split(separator: "\n") {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
guard trimmed.hasPrefix(key) else { continue }
|
||||
guard let equals = trimmed.firstIndex(of: "=") else { continue }
|
||||
let value = trimmed[trimmed.index(after: equals)...].trimmingCharacters(in: .whitespaces)
|
||||
return Int(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Renders `Resources/launchd.plist.template` with the given substitutions.
|
||||
///
|
||||
/// Placeholders: `{{LABEL}}`, `{{PROGRAM}}`, `{{ARGUMENTS}}`,
|
||||
/// `{{STDOUT_PATH}}`, `{{STDERR_PATH}}`.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - executablePath: Absolute path to the installed binary.
|
||||
/// - arguments: Arguments after the executable, e.g. `["daemon"]`.
|
||||
/// - Returns: The plist XML.
|
||||
public static func renderPlist(executablePath: String, arguments: [String]) throws -> String {
|
||||
let template = try loadTemplate()
|
||||
|
||||
let argumentXML = arguments
|
||||
.map { "\t\t<string>\(xmlEscape($0))</string>" }
|
||||
.joined(separator: "\n")
|
||||
|
||||
return template
|
||||
.replacingOccurrences(of: "{{LABEL}}", with: xmlEscape(label))
|
||||
.replacingOccurrences(of: "{{PROGRAM}}", with: xmlEscape(executablePath))
|
||||
.replacingOccurrences(of: "{{ARGUMENTS}}", with: argumentXML)
|
||||
.replacingOccurrences(
|
||||
of: "{{STDOUT_PATH}}",
|
||||
with: xmlEscape(logDirectoryURL.appendingPathComponent("daemon.out.log").path)
|
||||
)
|
||||
.replacingOccurrences(
|
||||
of: "{{STDERR_PATH}}",
|
||||
with: xmlEscape(logDirectoryURL.appendingPathComponent("daemon.err.log").path)
|
||||
)
|
||||
}
|
||||
|
||||
/// Locates the plist template.
|
||||
///
|
||||
/// The template is not an SPM resource bundle and `make bundle` copies only
|
||||
/// `Info.plist` into the app, so there is no single reliable location: this
|
||||
/// walks the plausible ones and falls back to a built-in copy so
|
||||
/// `service install` works from the installed app, from `swift run`, and from
|
||||
/// a checkout.
|
||||
private static func loadTemplate() throws -> String {
|
||||
var candidates: [URL] = []
|
||||
|
||||
if let resourceURL = Bundle.main.url(forResource: "launchd.plist", withExtension: "template") {
|
||||
candidates.append(resourceURL)
|
||||
}
|
||||
candidates.append(
|
||||
Bundle.main.bundleURL
|
||||
.appendingPathComponent("Contents/Resources/launchd.plist.template")
|
||||
)
|
||||
if let executableURL = Bundle.main.executableURL?.resolvingSymlinksInPath() {
|
||||
let directory = executableURL.deletingLastPathComponent()
|
||||
candidates.append(directory.appendingPathComponent("Resources/launchd.plist.template"))
|
||||
candidates.append(
|
||||
directory.deletingLastPathComponent()
|
||||
.appendingPathComponent("Resources/launchd.plist.template")
|
||||
)
|
||||
}
|
||||
// Sources/RunnerHost/LaunchdService.swift → repository root.
|
||||
let repositoryRoot = URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
candidates.append(repositoryRoot.appendingPathComponent("Resources/launchd.plist.template"))
|
||||
candidates.append(
|
||||
URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
|
||||
.appendingPathComponent("Resources/launchd.plist.template")
|
||||
)
|
||||
|
||||
for candidate in candidates {
|
||||
if let contents = try? String(contentsOf: candidate, encoding: .utf8) {
|
||||
return contents
|
||||
}
|
||||
}
|
||||
return embeddedTemplate
|
||||
}
|
||||
|
||||
/// Escapes a string for an XML text node.
|
||||
private static func xmlEscape(_ value: String) -> String {
|
||||
value
|
||||
.replacingOccurrences(of: "&", with: "&")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
}
|
||||
|
||||
/// Directory for the agent's stdout/stderr logs,
|
||||
/// `~/Library/Logs/gitea-macos-runner`.
|
||||
public static var logDirectoryURL: URL {
|
||||
URL(fileURLWithPath: RunnerConfig.expandTilde("~/Library/Logs/gitea-macos-runner"), isDirectory: true)
|
||||
}
|
||||
|
||||
/// Byte-for-byte fallback copy of `Resources/launchd.plist.template`, used
|
||||
/// when the file cannot be found next to the running binary.
|
||||
private static let embeddedTemplate = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
\t<key>Label</key>
|
||||
\t<string>{{LABEL}}</string>
|
||||
|
||||
\t<key>ProgramArguments</key>
|
||||
\t<array>
|
||||
\t\t<string>{{PROGRAM}}</string>
|
||||
{{ARGUMENTS}}
|
||||
\t</array>
|
||||
|
||||
\t<key>RunAtLoad</key>
|
||||
\t<true/>
|
||||
|
||||
\t<key>KeepAlive</key>
|
||||
\t<dict>
|
||||
\t\t<key>SuccessfulExit</key>
|
||||
\t\t<false/>
|
||||
\t</dict>
|
||||
|
||||
\t<key>ThrottleInterval</key>
|
||||
\t<integer>30</integer>
|
||||
|
||||
\t<key>ProcessType</key>
|
||||
\t<string>Interactive</string>
|
||||
|
||||
\t<key>StandardOutPath</key>
|
||||
\t<string>{{STDOUT_PATH}}</string>
|
||||
|
||||
\t<key>StandardErrorPath</key>
|
||||
\t<string>{{STDERR_PATH}}</string>
|
||||
|
||||
\t<key>EnvironmentVariables</key>
|
||||
\t<dict>
|
||||
\t\t<key>PATH</key>
|
||||
\t\t<string>/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
|
||||
\t</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
"""
|
||||
}
|
||||
|
||||
/// Minimal synchronous process runner for `launchctl`.
|
||||
private enum LaunchdShell {
|
||||
struct Output {
|
||||
let exitCode: Int32
|
||||
let output: String
|
||||
}
|
||||
|
||||
static func run(_ launchPath: String, _ arguments: [String]) -> Output {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: launchPath)
|
||||
process.arguments = arguments
|
||||
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
return Output(exitCode: 127, output: "\(error)")
|
||||
}
|
||||
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
process.waitUntilExit()
|
||||
return Output(
|
||||
exitCode: process.terminationStatus,
|
||||
output: String(data: data, encoding: .utf8) ?? ""
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user