feat(service): agent status plus launchctl load/unload
This commit is contained in:
+153
-1
@@ -3,9 +3,10 @@
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use service_manager::{
|
use service_manager::{
|
||||||
LaunchdServiceManager, RestartPolicy, ServiceInstallCtx, ServiceLabel, ServiceManager,
|
LaunchdServiceManager, RestartPolicy, ServiceInstallCtx, ServiceLabel, ServiceManager,
|
||||||
ServiceUninstallCtx,
|
ServiceStatus, ServiceStatusCtx, ServiceUninstallCtx,
|
||||||
};
|
};
|
||||||
use std::path::{Component, Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
use std::time::SystemTime;
|
||||||
|
|
||||||
pub(crate) const DEFAULT_LABEL: &str = "se.aceofba.xy";
|
pub(crate) const DEFAULT_LABEL: &str = "se.aceofba.xy";
|
||||||
|
|
||||||
@@ -115,6 +116,124 @@ pub(crate) fn uninstall(label: &str) -> Result<()> {
|
|||||||
.context("uninstall launchd agent")
|
.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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -200,4 +319,37 @@ mod tests {
|
|||||||
|
|
||||||
assert!(err.contains("macOS-only"));
|
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