feat(service): agent status plus launchctl load/unload
This commit is contained in:
+153
-1
@@ -3,9 +3,10 @@
|
||||
use anyhow::{Context, Result};
|
||||
use service_manager::{
|
||||
LaunchdServiceManager, RestartPolicy, ServiceInstallCtx, ServiceLabel, ServiceManager,
|
||||
ServiceUninstallCtx,
|
||||
ServiceStatus, ServiceStatusCtx, ServiceUninstallCtx,
|
||||
};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::time::SystemTime;
|
||||
|
||||
pub(crate) const DEFAULT_LABEL: &str = "se.aceofba.xy";
|
||||
|
||||
@@ -115,6 +116,124 @@ pub(crate) fn uninstall(label: &str) -> Result<()> {
|
||||
.context("uninstall launchd agent")
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) enum AgentState {
|
||||
NotInstalled,
|
||||
Stopped,
|
||||
Running,
|
||||
}
|
||||
|
||||
pub(crate) struct AgentStatus {
|
||||
pub label: String,
|
||||
pub plist: PathBuf,
|
||||
pub state: AgentState,
|
||||
pub program: Option<PathBuf>,
|
||||
pub path_env: Option<String>,
|
||||
pub snapshotted: Option<SystemTime>,
|
||||
pub pid: Option<u32>,
|
||||
}
|
||||
|
||||
pub(crate) fn read_pid(pidfile: &Path) -> Option<u32> {
|
||||
std::fs::read_to_string(pidfile)
|
||||
.ok()?
|
||||
.trim()
|
||||
.parse::<u32>()
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub(crate) fn status(label: &str, pidfile: &Path) -> Result<AgentStatus> {
|
||||
let plist = plist_path_for(label)?;
|
||||
|
||||
if !plist.exists() {
|
||||
return Ok(AgentStatus {
|
||||
label: label.to_string(),
|
||||
plist,
|
||||
state: AgentState::NotInstalled,
|
||||
program: None,
|
||||
path_env: None,
|
||||
snapshotted: None,
|
||||
pid: None,
|
||||
});
|
||||
}
|
||||
|
||||
let snapshotted = std::fs::metadata(&plist)
|
||||
.and_then(|meta| meta.modified())
|
||||
.ok();
|
||||
|
||||
let parsed: ServiceLabel = label.parse().context("parse service label")?;
|
||||
|
||||
let state = match manager().status(ServiceStatusCtx { label: parsed })? {
|
||||
ServiceStatus::Running => AgentState::Running,
|
||||
ServiceStatus::Stopped(_) => AgentState::Stopped,
|
||||
ServiceStatus::NotInstalled => AgentState::NotInstalled,
|
||||
};
|
||||
|
||||
let pid = if state == AgentState::Running {
|
||||
read_pid(pidfile)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let (program, path_env) = read_plist_fields(&plist);
|
||||
|
||||
Ok(AgentStatus {
|
||||
label: label.to_string(),
|
||||
plist,
|
||||
state,
|
||||
program,
|
||||
path_env,
|
||||
snapshotted,
|
||||
pid,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_plist_fields(plist: &Path) -> (Option<PathBuf>, Option<String>) {
|
||||
let Ok(contents) = std::fs::read_to_string(plist) else {
|
||||
return (None, None);
|
||||
};
|
||||
|
||||
let program = contents
|
||||
.split("<key>ProgramArguments</key>")
|
||||
.nth(1)
|
||||
.and_then(|rest| rest.split("<string>").nth(1))
|
||||
.and_then(|rest| rest.split("</string>").next())
|
||||
.map(PathBuf::from);
|
||||
|
||||
let path_env = contents
|
||||
.split("<key>PATH</key>")
|
||||
.nth(1)
|
||||
.and_then(|rest| rest.split("<string>").nth(1))
|
||||
.and_then(|rest| rest.split("</string>").next())
|
||||
.map(str::to_string);
|
||||
|
||||
(program, path_env)
|
||||
}
|
||||
|
||||
fn launchctl(verb: &str, plist: &Path) -> Result<()> {
|
||||
let output = std::process::Command::new("launchctl")
|
||||
.arg(verb)
|
||||
.arg(plist)
|
||||
.output()
|
||||
.with_context(|| format!("run launchctl {verb}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"launchctl {verb} failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn start(label: &str) -> Result<()> {
|
||||
launchctl("load", &plist_path_for(label)?)
|
||||
}
|
||||
|
||||
pub(crate) fn stop(label: &str) -> Result<()> {
|
||||
launchctl("unload", &plist_path_for(label)?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -200,4 +319,37 @@ mod tests {
|
||||
|
||||
assert!(err.contains("macOS-only"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_reports_not_installed_when_plist_is_absent() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
let status = status("se.aceofba.xy-absent", &tmp.path().join("xy.pid")).unwrap();
|
||||
|
||||
assert!(matches!(status.state, AgentState::NotInstalled));
|
||||
assert!(status.program.is_none());
|
||||
assert!(status.pid.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_pid_parses_a_pidfile() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let pidfile = tmp.path().join("xy.pid");
|
||||
|
||||
std::fs::write(&pidfile, "4821\n").unwrap();
|
||||
|
||||
assert_eq!(read_pid(&pidfile), Some(4821));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_pid_returns_none_for_missing_or_garbage() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let missing = tmp.path().join("nope.pid");
|
||||
let garbage = tmp.path().join("garbage.pid");
|
||||
|
||||
std::fs::write(&garbage, "not-a-pid").unwrap();
|
||||
|
||||
assert_eq!(read_pid(&missing), None);
|
||||
assert_eq!(read_pid(&garbage), None);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user