202 lines
8.9 KiB
Swift
202 lines
8.9 KiB
Swift
import ArgumentParser
|
|
import Foundation
|
|
import RunnerCore
|
|
import RunnerHost
|
|
|
|
/// `gitea-macos-runner service …` — manage the `launchd` LaunchAgent.
|
|
///
|
|
/// - Important: This installs a **LaunchAgent** in the logged-in user's session,
|
|
/// never a LaunchDaemon. Virtualization needs a GUI session, and macOS 15+
|
|
/// additionally needs an unlocked `login.keychain` to start a VM — neither of
|
|
/// which exists in the system context. The host should be set to log in
|
|
/// automatically.
|
|
struct ServiceCommand: AsyncParsableCommand {
|
|
static let configuration = CommandConfiguration(
|
|
commandName: "service",
|
|
abstract: "Install, remove, or inspect the launchd LaunchAgent.",
|
|
subcommands: [Install.self, Uninstall.self, Status.self]
|
|
)
|
|
|
|
/// `service install` — write the plist and load the job.
|
|
struct Install: AsyncParsableCommand {
|
|
static let configuration = CommandConfiguration(
|
|
commandName: "install",
|
|
abstract: "Write ~/Library/LaunchAgents/\(LaunchdService.label).plist and load it.",
|
|
discussion: """
|
|
Points the agent at the installed, signed .app bundle — not at a bare \
|
|
binary. The com.apple.security.virtualization entitlement only survives \
|
|
on the signed bundle, so a daemon started from .build/ cannot start VMs.
|
|
"""
|
|
)
|
|
|
|
@OptionGroup var options: GlobalOptions
|
|
|
|
/// Path to the installed executable inside the signed `.app`.
|
|
@Option(name: .long, help: "Path to the installed executable (default: ~/Applications/GiteaMacosRunner.app/Contents/MacOS/gitea-macos-runner).")
|
|
var executable: String?
|
|
|
|
/// How to configure Local Network access, if it is not already.
|
|
///
|
|
/// Unset means "decide at run time": ask on a terminal, skip with a
|
|
/// pointer otherwise. `none` suppresses the question outright, for a
|
|
/// scripted install that has its own arrangements.
|
|
@Option(
|
|
name: .customLong("grant-local-network"),
|
|
help: "Configure macOS Local Network access during install: allowlist, prompt, or none.")
|
|
var grantLocalNetwork: LocalNetworkGrantChoice?
|
|
|
|
func run() async throws {
|
|
let executablePath = executable ?? LaunchdService.defaultExecutablePath
|
|
|
|
// Only pass --config when it is not the default; a plist that
|
|
// hard-codes the default path is one more thing to keep in sync.
|
|
let configPath = options.configPath == RunnerConfig.defaultPath ? nil : options.configPath
|
|
|
|
if (try? options.loadConfig()) == nil {
|
|
CLI.note("warning: \(RunnerConfig.expandTilde(options.configPath)) is missing or invalid; the agent will fail to start until it is fixed")
|
|
}
|
|
|
|
// Done before install (which also does it) purely so the operator
|
|
// is told: an agent silently vanishing from launchctl is alarming
|
|
// if you do not know a rename happened.
|
|
for legacy in LaunchdService.removeLegacyAgents() {
|
|
CLI.note("removed legacy agent \(legacy) (renamed to \(LaunchdService.label))")
|
|
}
|
|
|
|
try LaunchdService.install(executablePath: executablePath, configPath: configPath)
|
|
|
|
print("installed \(LaunchdService.agentPlistURL.path)")
|
|
print("program: \(RunnerConfig.expandTilde(executablePath)) daemon")
|
|
print("logs: \(LaunchdService.logDirectoryURL.path)")
|
|
print("")
|
|
|
|
offerLocalNetworkGrant()
|
|
|
|
print("check it with: gitea-macos-runner service status")
|
|
}
|
|
|
|
/// Offers to configure Local Network access, if it is not already.
|
|
///
|
|
/// This is where the question belongs. The agent that was just
|
|
/// installed is the process that will be blocked, it has no UI to ask
|
|
/// with, and the symptom when it is blocked — every guest boots, no job
|
|
/// starts, `No route to host` — points nowhere near the cause. Asking
|
|
/// now costs one prompt; not asking costs a debugging session.
|
|
///
|
|
/// Never fatal: a failed or declined grant leaves a perfectly good
|
|
/// installed agent, so this reports and returns rather than throwing.
|
|
private func offerLocalNetworkGrant() {
|
|
guard grantLocalNetwork != .skip else { return }
|
|
guard !LocalNetworkPolicy.status().coversGuestRange else { return }
|
|
|
|
let method: LocalNetworkPermission.Method
|
|
switch grantLocalNetwork {
|
|
case .allowlist: method = .allowlist
|
|
case .prompt: method = .prompt
|
|
case .skip: return // handled above; here for exhaustiveness
|
|
case nil:
|
|
// Not asked for either way: decide from the terminal. A piped
|
|
// or launchd-driven install must not stop on a question, so it
|
|
// gets the pointer and carries on.
|
|
guard isatty(fileno(stdin)) == 1 else {
|
|
CLI.note("""
|
|
note: macOS Local Network access is not configured. Until it is, guests \
|
|
boot but SSH fails with "No route to host". Configure it with \
|
|
`gitea-macos-runner permissions grant`.
|
|
""")
|
|
print("")
|
|
return
|
|
}
|
|
CLI.note("""
|
|
macOS Local Network access is not configured. Without it the agent starts \
|
|
guests fine but cannot reach them, and every job fails with "No route to \
|
|
host". Granting it writes a subnet allowlist with sudo and needs a reboot.
|
|
""")
|
|
guard CLI.confirm("configure it now?") else {
|
|
CLI.note("skipped; run `gitea-macos-runner permissions grant` later")
|
|
print("")
|
|
return
|
|
}
|
|
method = .allowlist
|
|
}
|
|
|
|
// Deliberately swallowed. The agent is installed and correct at
|
|
// this point; a declined sudo password should not turn a successful
|
|
// install into a failure.
|
|
do {
|
|
try LocalNetworkGrantFlow.run(method: method)
|
|
} catch {
|
|
CLI.note("could not configure it: \(error)")
|
|
CLI.note("the agent is installed; run `gitea-macos-runner permissions grant` to retry")
|
|
}
|
|
print("")
|
|
}
|
|
}
|
|
|
|
/// `--grant-local-network`'s values: the two grant methods plus an explicit
|
|
/// opt-out, which the method enum itself has no business carrying.
|
|
///
|
|
/// The opt-out case is spelled `skip` rather than `none` so that
|
|
/// `choice == .skip` cannot be read as `Optional.none` — the option is
|
|
/// itself optional, and "not passed" means something different from
|
|
/// "passed `none`".
|
|
enum LocalNetworkGrantChoice: String, ExpressibleByArgument, CaseIterable {
|
|
case allowlist
|
|
case prompt
|
|
case skip = "none"
|
|
}
|
|
|
|
/// `service uninstall` — unload and remove the plist.
|
|
struct Uninstall: AsyncParsableCommand {
|
|
static let configuration = CommandConfiguration(
|
|
commandName: "uninstall",
|
|
abstract: "Unload the LaunchAgent and remove its plist."
|
|
)
|
|
|
|
@OptionGroup var options: GlobalOptions
|
|
|
|
func run() async throws {
|
|
let path = LaunchdService.agentPlistURL.path
|
|
let existed = FileManager.default.fileExists(atPath: path)
|
|
let legacy = LaunchdService.removeLegacyAgents()
|
|
try LaunchdService.uninstall()
|
|
for label in legacy {
|
|
print("removed legacy agent \(label)")
|
|
}
|
|
print(existed ? "removed \(path)" : "not installed (\(path))")
|
|
}
|
|
}
|
|
|
|
/// `service status` — report whether the agent is installed and running.
|
|
struct Status: AsyncParsableCommand {
|
|
static let configuration = CommandConfiguration(
|
|
commandName: "status",
|
|
abstract: "Report LaunchAgent installation and run state."
|
|
)
|
|
|
|
@OptionGroup var options: GlobalOptions
|
|
|
|
func run() async throws {
|
|
let status = try LaunchdService.status()
|
|
|
|
print("label: \(LaunchdService.label)")
|
|
print("plist: \(status.plistPath)")
|
|
print("installed: \(status.installed ? "yes" : "no")")
|
|
print("loaded: \(status.loaded ? "yes" : "no")")
|
|
if let pid = status.pid {
|
|
print("pid: \(pid)")
|
|
}
|
|
if let lastExitStatus = status.lastExitStatus {
|
|
print("last exit: \(lastExitStatus)")
|
|
}
|
|
print("logs: \(LaunchdService.logDirectoryURL.path)")
|
|
|
|
if status.installed, !status.loaded {
|
|
print("")
|
|
CLI.note("installed but not loaded — reinstall with `service install`, or check the logs above")
|
|
throw ExitCode(1)
|
|
}
|
|
}
|
|
}
|
|
}
|