use std::os::unix::fs::MetadataExt as _;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::Duration;
use crate::connector::{ServiceConnector, ServiceInfo, ServiceLifecycle, ServiceStatus};
use super::helpers::{VersionProbe, binary_on_path, binary_version};
const BINARY: &str = "trusty-analyze";
const HEALTH_TIMEOUT: Duration = Duration::from_secs(3);
const METHOD_HEALTH: &str = "analyze.health";
#[derive(Debug, serde::Deserialize)]
struct HealthEnvelope {
#[allow(dead_code)]
status: String,
version: Option<String>,
}
pub struct AnalyzeConnector {
socket: Option<PathBuf>,
version: Mutex<Option<VersionForSocket>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct SocketIdentity {
dev: u64,
ino: u64,
}
#[derive(Debug)]
struct VersionForSocket {
identity: SocketIdentity,
version: Option<String>,
}
fn socket_identity(socket: &Path) -> Option<SocketIdentity> {
let meta = std::fs::metadata(socket).ok()?;
Some(SocketIdentity {
dev: meta.dev(),
ino: meta.ino(),
})
}
impl AnalyzeConnector {
pub fn new() -> Self {
Self {
socket: None,
version: Mutex::new(None),
}
}
pub fn with_socket(socket: PathBuf) -> Self {
Self {
socket: Some(socket),
version: Mutex::new(None),
}
}
fn socket_path(&self) -> Result<PathBuf, String> {
match &self.socket {
Some(p) => Ok(p.clone()),
None => trusty_common::daemon_socket_path("trusty-analyze")
.map_err(|e| format!("could not resolve the trusty-analyze socket path: {e:#}")),
}
}
}
impl Default for AnalyzeConnector {
fn default() -> Self {
Self::new()
}
}
fn blocking_probe<T, F>(socket: &Path, probe: F) -> Option<T>
where
T: Send + 'static,
F: FnOnce(PathBuf) -> std::pin::Pin<Box<dyn std::future::Future<Output = Option<T>>>>
+ Send
+ 'static,
{
let socket = socket.to_path_buf();
let handle = std::thread::Builder::new()
.name("console-analyze-probe".to_owned())
.spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.ok()?;
rt.block_on(probe(socket))
})
.ok()?;
handle.join().ok()?
}
fn probe_is_serving(socket: &Path) -> bool {
blocking_probe(socket, |socket| {
Box::pin(async move {
trusty_common::uds::socket_is_serving(&socket, HEALTH_TIMEOUT)
.await
.then_some(())
})
})
.is_some()
}
fn probe_health(socket: &Path) -> Option<HealthEnvelope> {
blocking_probe(socket, |socket| {
Box::pin(async move {
let request = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": METHOD_HEALTH,
"params": {},
});
let response: trusty_common::uds::server::RpcResponse =
trusty_common::uds::send_framed_request(&socket, &request, HEALTH_TIMEOUT)
.await
.ok()?;
serde_json::from_value::<HealthEnvelope>(response.result?).ok()
})
})
}
impl ServiceConnector for AnalyzeConnector {
fn id(&self) -> &'static str {
"trusty-analyze"
}
fn display_name(&self) -> &'static str {
"Trusty Analyze"
}
fn lifecycle(&self) -> ServiceLifecycle {
ServiceLifecycle::OnDemand
}
fn detect(&self) -> ServiceInfo {
self.detect_from(self.socket_path())
}
}
impl AnalyzeConnector {
fn version_for(&self, socket: &Path) -> Option<String> {
let identity = socket_identity(socket);
let mut cached = self.version.lock().unwrap_or_else(|e| e.into_inner());
if let (Some(identity), Some(entry)) = (identity, cached.as_ref())
&& entry.identity == identity
{
return entry.version.clone();
}
let version = probe_health(socket).and_then(|health| health.version);
*cached = identity.map(|identity| VersionForSocket {
identity,
version: version.clone(),
});
version
}
fn detect_from(&self, socket: Result<PathBuf, String>) -> ServiceInfo {
let base =
|status: ServiceStatus, version: Option<String>, hint: Option<String>| ServiceInfo {
id: self.id().to_string(),
display_name: self.display_name().to_string(),
status,
version,
url: None,
hint,
lifecycle: self.lifecycle(),
};
if !binary_on_path(BINARY) {
return base(ServiceStatus::Absent, None, None);
}
let (socket_hint, running) = match socket {
Ok(path) if probe_is_serving(&path) => (None, Some(self.version_for(&path))),
Ok(_) => (None, None),
Err(reason) => (Some(reason), None),
};
if let Some(version) = running {
return base(ServiceStatus::Running, version, None);
}
match binary_version(BINARY) {
VersionProbe::Ran(version) => base(ServiceStatus::Available, version, socket_hint),
VersionProbe::CannotExecute(why) => base(
ServiceStatus::Degraded,
None,
Some(format!(
"{BINARY} is on PATH but did not run: {why}. Reinstall it \
with `cargo install {BINARY}`."
)),
),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
#[test]
fn analyze_connector_reports_available_when_nothing_is_serving() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let connector = AnalyzeConnector::with_socket(tmp.path().join("absent.sock"));
let info = connector.detect();
let expected = if which::which("trusty-analyze").is_ok() {
ServiceStatus::Available
} else {
ServiceStatus::Absent
};
assert_eq!(info.status, expected);
assert_eq!(info.id, "trusty-analyze");
assert_eq!(info.display_name, "Trusty Analyze");
assert!(info.url.is_none(), "a UDS daemon has no URL to render");
assert!(
info.status != ServiceStatus::Absent || info.version.is_none(),
"Absent must have no version"
);
}
#[test]
fn analyze_connector_surfaces_an_unresolvable_socket_path_as_a_hint() {
if which::which("trusty-analyze").is_err() {
eprintln!("skip: trusty-analyze is not on PATH, so detect() short-circuits to Absent");
return;
}
let info = AnalyzeConnector::new().detect_from(Err(
"could not resolve the trusty-analyze socket path: nope".to_string(),
));
assert_eq!(
info.status,
ServiceStatus::Available,
"nothing was observed, so the verdict must not claim more than that"
);
let hint = info.hint.expect("an unresolvable path must explain itself");
assert!(
hint.contains("socket path"),
"the hint must name what could not be resolved: {hint}"
);
}
#[test]
fn analyze_connector_attaches_no_hint_when_the_path_resolves() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let info = AnalyzeConnector::new().detect_from(Ok(tmp.path().join("absent.sock")));
assert!(
info.hint.is_none(),
"a resolvable path must not carry a remediation hint: {:?}",
info.hint
);
}
fn spawn_fake_analyzer(socket: &Path) -> Arc<AtomicUsize> {
let listener = trusty_common::uds::bind_hardened(socket).expect("bind");
let frames = Arc::new(AtomicUsize::new(0));
let counted = Arc::clone(&frames);
tokio::spawn(async move {
use tokio::io::{AsyncBufReadExt as _, AsyncWriteExt as _};
loop {
let Ok((conn, _)) = listener.accept().await else {
return;
};
let counted = Arc::clone(&counted);
tokio::spawn(async move {
let mut reader = tokio::io::BufReader::new(conn);
let mut frame = String::new();
if reader.read_line(&mut frame).await.unwrap_or(0) == 0 {
return;
}
counted.fetch_add(1, Ordering::SeqCst);
let reply = br#"{"jsonrpc":"2.0","id":1,"result":{"status":"ok","version":"9.9.9","search_reachable":true}}"#;
let conn = reader.get_mut();
let _ = conn.write_all(reply).await;
let _ = conn.write_all(b"\n").await;
let _ = conn.flush().await;
});
}
});
frames
}
#[tokio::test(flavor = "multi_thread")]
async fn analyze_connector_reads_the_version_off_a_live_socket() {
if which::which("trusty-analyze").is_err() {
eprintln!("skip: trusty-analyze is not on PATH, so detect() short-circuits to Absent");
return;
}
let tmp = tempfile::TempDir::new().expect("tempdir");
let socket = tmp.path().join("sockets").join("analyze.sock");
spawn_fake_analyzer(&socket);
let connector = AnalyzeConnector::with_socket(socket);
let info = tokio::task::spawn_blocking(move || connector.detect())
.await
.expect("detect");
assert_eq!(info.status, ServiceStatus::Running);
assert_eq!(info.version.as_deref(), Some("9.9.9"));
}
#[tokio::test(flavor = "multi_thread")]
async fn analyze_detect_dials_health_once_per_socket_identity() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let socket = tmp.path().join("sockets").join("analyze.sock");
let frames = spawn_fake_analyzer(&socket);
let connector = Arc::new(AnalyzeConnector::with_socket(socket.clone()));
for pass in 0..3 {
let connector = Arc::clone(&connector);
let dialled = socket.clone();
let version = tokio::task::spawn_blocking(move || connector.version_for(&dialled))
.await
.expect("probe");
assert_eq!(version.as_deref(), Some("9.9.9"), "pass {pass}");
}
assert_eq!(
frames.load(Ordering::SeqCst),
1,
"monitoring must not issue an idle-re-arming call on every poll"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn analyze_version_cache_is_reread_when_the_socket_is_replaced() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let socket = tmp.path().join("sockets").join("analyze.sock");
let first = spawn_fake_analyzer(&socket);
let connector = Arc::new(AnalyzeConnector::with_socket(socket.clone()));
let probe = |connector: Arc<AnalyzeConnector>, socket: PathBuf| async move {
tokio::task::spawn_blocking(move || connector.version_for(&socket))
.await
.expect("probe")
};
assert_eq!(
probe(Arc::clone(&connector), socket.clone())
.await
.as_deref(),
Some("9.9.9")
);
assert_eq!(first.load(Ordering::SeqCst), 1);
std::fs::remove_file(&socket).expect("unlink");
let second = spawn_fake_analyzer(&socket);
assert_eq!(
probe(connector, socket).await.as_deref(),
Some("9.9.9"),
"a replaced socket is a new process, so its version is read again"
);
assert_eq!(
second.load(Ordering::SeqCst),
1,
"the successor must have been dialled exactly once"
);
}
#[test]
fn detect_never_starts_a_server() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let socket = tmp.path().join("must-stay-absent.sock");
let info = AnalyzeConnector::with_socket(socket.clone()).detect();
assert_ne!(
info.status,
ServiceStatus::Running,
"nothing was serving that path, so no verdict may claim it was"
);
assert!(
!socket.exists(),
"detect must observe, never start: {} was created",
socket.display()
);
}
#[test]
fn analyze_reports_an_on_demand_lifecycle_on_every_verdict() {
let tmp = tempfile::TempDir::new().expect("tempdir");
for socket in [Ok(tmp.path().join("absent.sock")), Err("nope".to_string())] {
let payload =
serde_json::to_value(AnalyzeConnector::new().detect_from(socket)).expect("json");
assert_eq!(
payload.get("lifecycle"),
Some(&serde_json::json!("on_demand")),
"the card branches on this key: {payload}"
);
}
}
#[test]
fn analyze_reads_a_version_off_the_binary_when_nothing_is_serving() {
if which::which(BINARY).is_err() {
eprintln!("skip: trusty-analyze is not on PATH, so detect() short-circuits to Absent");
return;
}
let tmp = tempfile::TempDir::new().expect("tempdir");
let info = AnalyzeConnector::new().detect_from(Ok(tmp.path().join("absent.sock")));
assert_eq!(info.status, ServiceStatus::Available);
assert!(
info.version.is_some(),
"an installed on-demand member renders the version it prints"
);
}
}