trusty_console/detect/analyze.rs
1//! `ServiceConnector` implementation for `trusty-analyze`.
2//!
3//! Why: trusty-analyze served TCP loopback HTTP and published its bound address
4//! in `~/.trusty-analyze/http_addr`; this connector read that file, probed the
5//! port, and fell back to probing 7879 when the file was absent. #6287
6//! (ADR-0032) moved the daemon onto a hardened Unix socket, so all three of
7//! those are gone — there is no port, no discovery file, and nothing to fall
8//! back FROM: the socket path is derived, and the daemon and this connector
9//! resolve it through the same `trusty_common::daemon_socket_path` call.
10//!
11//! The retired fallback probed `127.0.0.1:7879` whenever the file was missing,
12//! which is the same shape of bug #6277 removed from the review connector: any
13//! process that took 7879 read as a healthy trusty-analyze. It is deleted rather
14//! than corrected.
15//!
16//! What: `AnalyzeConnector::detect()` dials `analyze.health` over the socket and
17//! reads `version` off the answer. When nothing answers — the resting state of
18//! an on-demand server (#6350) — the verdict comes off the binary instead, the
19//! way the trusty-review connector's has since #6290.
20//! Test: `analyze_connector_reports_available_when_nothing_is_serving`,
21//! `analyze_connector_reads_the_version_off_a_live_socket`,
22//! `analyze_reports_an_on_demand_lifecycle_on_every_verdict`.
23
24use std::path::{Path, PathBuf};
25use std::time::Duration;
26
27use crate::connector::{ServiceConnector, ServiceInfo, ServiceLifecycle, ServiceStatus};
28
29use super::helpers::{VersionProbe, binary_on_path, binary_version};
30
31/// The binary this connector reports on.
32const BINARY: &str = "trusty-analyze";
33
34/// How long one health dial may take, end to end.
35///
36/// A local socket answers in single-digit milliseconds; trusty-analyze's health
37/// handler probes trusty-search before answering, so this leaves headroom over
38/// that without letting one wedged service stall the console's whole detection
39/// pass.
40const HEALTH_TIMEOUT: Duration = Duration::from_secs(3);
41
42/// The method name `trusty-analyze`'s router registers for its health check.
43///
44/// Duplicated as a literal rather than imported: `trusty-console` has no Cargo
45/// edge on `trusty-analyze` and adding one to share a `&str` would pull a
46/// tree-sitter analysis engine into the console's build.
47/// `service::rpc::METHOD_HEALTH` is the definition; this is the client's copy,
48/// and the integration test in `trusty-analyze/tests/uds_consumer_contract.rs`
49/// is what keeps them equal.
50const METHOD_HEALTH: &str = "analyze.health";
51
52/// The `result` half of an `analyze.health` response, as far as the console
53/// reads it.
54///
55/// Only `version` is consumed — the card renders it. `status` is deserialised
56/// too so a body that carries neither is refused as not-a-health-envelope
57/// rather than silently rendering a versionless Running card.
58#[derive(Debug, serde::Deserialize)]
59struct HealthEnvelope {
60 /// `"ok"` or `"degraded"`. Presence is what makes this a health answer.
61 #[allow(dead_code)]
62 status: String,
63 /// The daemon's own version, rendered on the service card.
64 version: Option<String>,
65}
66
67/// ServiceConnector for `trusty-analyze`.
68///
69/// Why: the console's dashboard needs to know whether the analyzer daemon is
70/// running, and since #6287 that question is answered by dialling its socket.
71/// What: implements `detect()` — binary on PATH, then one `analyze.health` call.
72/// Test: see the module docs.
73pub struct AnalyzeConnector {
74 /// Override for the socket path (used in tests).
75 ///
76 /// Before #6287 this was a HOME override, because the discovery file lived
77 /// under `~`. The socket path comes from the data directory now, which
78 /// `TRUSTY_DATA_DIR_OVERRIDE` already redirects — but that variable is
79 /// process-global and this connector runs beside five others in one poll,
80 /// so a path override keeps a test from redirecting its siblings too.
81 socket: Option<PathBuf>,
82}
83
84impl AnalyzeConnector {
85 /// Create a new `AnalyzeConnector`.
86 pub fn new() -> Self {
87 Self { socket: None }
88 }
89
90 /// Create a connector that dials `socket` instead of the resolved path.
91 ///
92 /// Why: unit tests must not dial the real user's running daemon, and the
93 /// integration test needs to point this at a socket it bound itself.
94 /// Test: `analyze_connector_reports_available_when_nothing_is_serving`.
95 pub fn with_socket(socket: PathBuf) -> Self {
96 Self {
97 socket: Some(socket),
98 }
99 }
100
101 /// The socket this connector dials, or why it could not be resolved.
102 ///
103 /// Why the error is carried rather than discarded: a data directory that
104 /// cannot be resolved or created is an operator-fixable condition
105 /// (permissions, a `TRUSTY_DATA_DIR_OVERRIDE` pointing somewhere unusable),
106 /// and it is indistinguishable on the dashboard from a daemon that is simply
107 /// not running. `detect()` still reports `Available` — nothing was observed,
108 /// so claiming otherwise would be a guess — but puts the reason in `hint`, so
109 /// the card says what to fix instead of silently under-reporting.
110 fn socket_path(&self) -> Result<PathBuf, String> {
111 match &self.socket {
112 Some(p) => Ok(p.clone()),
113 None => trusty_common::daemon_socket_path("trusty-analyze")
114 .map_err(|e| format!("could not resolve the trusty-analyze socket path: {e:#}")),
115 }
116 }
117}
118
119impl Default for AnalyzeConnector {
120 fn default() -> Self {
121 Self::new()
122 }
123}
124
125/// Dial `analyze.health` and return the envelope, or `None` if nothing answered.
126///
127/// Why: `ServiceConnector::detect` is synchronous — the poller calls it inside
128/// `spawn_blocking` — and the shared UDS client is async. The exchange runs on
129/// a dedicated thread with its own current-thread runtime rather than through
130/// `Handle::block_on`, for the reason `trusty-installer`'s
131/// `probe_member_http_blocking` records: building a runtime and blocking on it
132/// from inside another runtime's worker panics, and this way the call is safe
133/// from any caller regardless of what it is running on.
134///
135/// What: one `send_framed_request` bounded by [`HEALTH_TIMEOUT`], then a
136/// JSON-RPC envelope check. A response carrying an `error` is `None`: the
137/// daemon answered, but not with health, and the console has nothing to render.
138///
139/// Test: `analyze_connector_reports_available_when_nothing_is_serving`.
140fn probe_health(socket: &Path) -> Option<HealthEnvelope> {
141 let socket = socket.to_path_buf();
142 let handle = std::thread::Builder::new()
143 .name("console-analyze-probe".to_owned())
144 .spawn(move || {
145 let rt = tokio::runtime::Builder::new_current_thread()
146 .enable_all()
147 .build()
148 .ok()?;
149 rt.block_on(async {
150 let request = serde_json::json!({
151 "jsonrpc": "2.0",
152 "id": 1,
153 "method": METHOD_HEALTH,
154 });
155 let response: trusty_common::uds::server::RpcResponse =
156 trusty_common::uds::send_framed_request(&socket, &request, HEALTH_TIMEOUT)
157 .await
158 .ok()?;
159 serde_json::from_value::<HealthEnvelope>(response.result?).ok()
160 })
161 })
162 .ok()?;
163 handle.join().ok()?
164}
165
166impl ServiceConnector for AnalyzeConnector {
167 fn id(&self) -> &'static str {
168 "trusty-analyze"
169 }
170
171 fn display_name(&self) -> &'static str {
172 "Trusty Analyze"
173 }
174
175 // #6416: #6287 moved it to a socket and #6350 made that server on-demand, so
176 // a resident process is not what healthy looks like here.
177 fn lifecycle(&self) -> ServiceLifecycle {
178 ServiceLifecycle::OnDemand
179 }
180
181 /// Detect trusty-analyze status.
182 ///
183 /// Why: the console dashboard needs to know whether the daemon is up, and
184 /// `tctl` makes the same call for a different reason — so the two must
185 /// agree, which they do by dialling the same method on the same derived
186 /// path (#6287).
187 /// What: binary check → `analyze.health` over the socket → status, falling
188 /// back to `trusty-analyze --version` when nothing answers. `url` is
189 /// deliberately `None`: a UDS daemon has no URL, and ADR-0032 makes
190 /// trusty-console the only HTTP surface in the workspace, so a synthesised
191 /// `http://` address would be a link that cannot work. A socket path that
192 /// cannot be resolved reports `Available` with the reason in `hint` — see
193 /// [`AnalyzeConnector::socket_path`].
194 /// Test: `analyze_connector_reports_available_when_nothing_is_serving`,
195 /// `analyze_connector_reads_the_version_off_a_live_socket`,
196 /// `analyze_connector_surfaces_an_unresolvable_socket_path_as_a_hint`.
197 fn detect(&self) -> ServiceInfo {
198 self.detect_from(self.socket_path())
199 }
200}
201
202impl AnalyzeConnector {
203 /// [`ServiceConnector::detect`]'s body, over an already-resolved path.
204 ///
205 /// Why: the unresolvable-path arm is only reachable when
206 /// `trusty_common::daemon_socket_path` fails, and the only way to make it
207 /// fail from a test is to set `TRUSTY_DATA_DIR_OVERRIDE` — which is
208 /// process-global and, in this crate's test binary, is read by five sibling
209 /// connectors running in parallel. Taking the resolved result as a parameter
210 /// makes the arm assertable with no global state at all.
211 /// What: binary check, then the three verdicts.
212 ///
213 /// 🔴 **This connector deliberately does not call `ensure_running`** (#6350),
214 /// unlike every other analyze client. It is a DETECTOR, and the console
215 /// renders it on a poll loop: a detector that started the service would keep
216 /// an on-demand server alive for as long as anyone had the dashboard open —
217 /// the exact outcome the idle window exists to prevent — and would then
218 /// report `Running` about a process it had just created, which is not an
219 /// observation.
220 ///
221 /// What that changes about the verdicts, now that resident is not the
222 /// healthy state: `Absent` (not installed) and `Degraded` (installed but
223 /// `--version` will not run) are the bad ones. `Available` means installed
224 /// and startable, which for an on-demand service is its correct resting
225 /// state, not a degradation. `Running` means a server happens to be up right
226 /// now — a client is using it, or one has not yet reached its idle window.
227 ///
228 /// Test: `analyze_connector_surfaces_an_unresolvable_socket_path_as_a_hint`,
229 /// `detect_never_starts_a_server`,
230 /// `analyze_reports_an_on_demand_lifecycle_on_every_verdict`,
231 /// `analyze_reads_a_version_off_the_binary_when_nothing_is_serving`.
232 fn detect_from(&self, socket: Result<PathBuf, String>) -> ServiceInfo {
233 let base =
234 |status: ServiceStatus, version: Option<String>, hint: Option<String>| ServiceInfo {
235 id: self.id().to_string(),
236 display_name: self.display_name().to_string(),
237 status,
238 version,
239 url: None,
240 hint,
241 // #6416: trusty-analyze serves on demand since #6287/#6350, so
242 // `Available` is its resting state and the card must not offer
243 // to start a daemon.
244 lifecycle: self.lifecycle(),
245 };
246
247 if !binary_on_path(BINARY) {
248 return base(ServiceStatus::Absent, None, None);
249 }
250
251 // An unresolvable socket path leaves nothing to dial, but the binary
252 // question is still answerable — so the reason rides along as the hint
253 // rather than short-circuiting the verdict.
254 let (socket_hint, dialled) = match socket {
255 Ok(path) => (None, probe_health(&path)),
256 Err(reason) => (Some(reason), None),
257 };
258
259 if let Some(health) = dialled {
260 return base(ServiceStatus::Running, health.version, None);
261 }
262
263 // #6416: nothing is serving, which for an on-demand member is healthy.
264 // The verdict comes off the binary, exactly as trusty-review's does.
265 match binary_version(BINARY) {
266 VersionProbe::Ran(version) => base(ServiceStatus::Available, version, socket_hint),
267 VersionProbe::CannotExecute(why) => base(
268 ServiceStatus::Degraded,
269 None,
270 Some(format!(
271 "{BINARY} is on PATH but did not run: {why}. Reinstall it \
272 with `cargo install {BINARY}`."
273 )),
274 ),
275 }
276 }
277}
278
279// ─── tests ────────────────────────────────────────────────────────────────────
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 /// Why (#6287): the pre-migration connector fell back to probing
286 /// `127.0.0.1:7879` when its discovery file was missing, so any process
287 /// holding that port made this report a trusty-analyze that was not there.
288 /// The fallback is gone, and this is what keeps it gone: an absent socket is
289 /// `Available`, never `Running`, whatever else is listening on the machine.
290 /// What: points the connector at a path in an empty temp dir and asserts the
291 /// verdict, branching only on whether the binary is installed.
292 /// Test: this is the test.
293 #[test]
294 fn analyze_connector_reports_available_when_nothing_is_serving() {
295 let tmp = tempfile::TempDir::new().expect("tempdir");
296 let connector = AnalyzeConnector::with_socket(tmp.path().join("absent.sock"));
297 let info = connector.detect();
298
299 let expected = if which::which("trusty-analyze").is_ok() {
300 ServiceStatus::Available
301 } else {
302 ServiceStatus::Absent
303 };
304 assert_eq!(info.status, expected);
305 assert_eq!(info.id, "trusty-analyze");
306 assert_eq!(info.display_name, "Trusty Analyze");
307 assert!(info.url.is_none(), "a UDS daemon has no URL to render");
308 assert!(
309 info.status != ServiceStatus::Absent || info.version.is_none(),
310 "Absent must have no version"
311 );
312 }
313
314 /// Why (#6287): a data directory that cannot be resolved is operator-fixable
315 /// — a permissions problem, or a `TRUSTY_DATA_DIR_OVERRIDE` pointing
316 /// somewhere unusable — but on the dashboard it looks identical to a daemon
317 /// that is merely stopped. Reporting the reason turns a silent under-report
318 /// into something actionable, without upgrading the verdict.
319 /// What: a resolution failure reports `Available` carrying the reason.
320 /// Test: this is the test.
321 #[test]
322 fn analyze_connector_surfaces_an_unresolvable_socket_path_as_a_hint() {
323 if which::which("trusty-analyze").is_err() {
324 eprintln!("skip: trusty-analyze is not on PATH, so detect() short-circuits to Absent");
325 return;
326 }
327
328 let info = AnalyzeConnector::new().detect_from(Err(
329 "could not resolve the trusty-analyze socket path: nope".to_string(),
330 ));
331
332 assert_eq!(
333 info.status,
334 ServiceStatus::Available,
335 "nothing was observed, so the verdict must not claim more than that"
336 );
337 let hint = info.hint.expect("an unresolvable path must explain itself");
338 assert!(
339 hint.contains("socket path"),
340 "the hint must name what could not be resolved: {hint}"
341 );
342 }
343
344 /// Why: the hint is for the failure case only. A connector that attached one
345 /// to a healthy or merely-stopped daemon would put a permanent "something is
346 /// wrong" note on a card where nothing is.
347 /// Test: this is the test.
348 #[test]
349 fn analyze_connector_attaches_no_hint_when_the_path_resolves() {
350 let tmp = tempfile::TempDir::new().expect("tempdir");
351 let info = AnalyzeConnector::new().detect_from(Ok(tmp.path().join("absent.sock")));
352 assert!(
353 info.hint.is_none(),
354 "a resolvable path must not carry a remediation hint: {:?}",
355 info.hint
356 );
357 }
358
359 /// Why: `Running` is the verdict that has to be earned by an ANSWER, and the
360 /// version it carries is what the card renders. A connector that reported
361 /// Running off a bare connect would have no version to show and would call a
362 /// wedged daemon healthy.
363 /// What: binds a socket that answers one `analyze.health` frame with a real
364 /// envelope, and asserts the connector reads the version off it.
365 /// Test: this is the test.
366 #[tokio::test(flavor = "multi_thread")]
367 async fn analyze_connector_reads_the_version_off_a_live_socket() {
368 if which::which("trusty-analyze").is_err() {
369 eprintln!("skip: trusty-analyze is not on PATH, so detect() short-circuits to Absent");
370 return;
371 }
372
373 let tmp = tempfile::TempDir::new().expect("tempdir");
374 let socket = tmp.path().join("sockets").join("analyze.sock");
375 let listener = trusty_common::uds::bind_hardened(&socket).expect("bind");
376
377 tokio::spawn(async move {
378 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
379 let Ok((mut conn, _)) = listener.accept().await else {
380 return;
381 };
382 let mut sink = Vec::new();
383 let _ = conn.read_to_end(&mut sink).await;
384 let reply =
385 br#"{"jsonrpc":"2.0","id":1,"result":{"status":"ok","version":"9.9.9","search_reachable":true}}"#;
386 let _ = conn.write_all(reply).await;
387 let _ = conn.write_all(b"\n").await;
388 let _ = conn.flush().await;
389 });
390
391 let connector = AnalyzeConnector::with_socket(socket);
392 let info = tokio::task::spawn_blocking(move || connector.detect())
393 .await
394 .expect("detect");
395
396 assert_eq!(info.status, ServiceStatus::Running);
397 assert_eq!(info.version.as_deref(), Some("9.9.9"));
398 }
399
400 /// Why (#6350): the console polls `detect` while a dashboard is open. If it
401 /// started trusty-analyze, an open browser tab would pin an on-demand
402 /// server resident forever — and the connector would be reporting on a
403 /// process it created rather than one it found.
404 /// What: points the connector at a socket path inside a tempdir, calls
405 /// `detect`, and asserts nothing bound it.
406 /// Test: this is the test.
407 #[test]
408 fn detect_never_starts_a_server() {
409 let tmp = tempfile::TempDir::new().expect("tempdir");
410 let socket = tmp.path().join("must-stay-absent.sock");
411 let info = AnalyzeConnector::with_socket(socket.clone()).detect();
412
413 assert_ne!(
414 info.status,
415 ServiceStatus::Running,
416 "nothing was serving that path, so no verdict may claim it was"
417 );
418 assert!(
419 !socket.exists(),
420 "detect must observe, never start: {} was created",
421 socket.display()
422 );
423 }
424
425 /// REGRESSION (#6416): the dashboard read "Binary found but daemon is not
426 /// running" over the Trusty Analyze card, in amber, for a service #6287 and
427 /// #6350 made on-demand — so "nothing is serving" is what healthy looks
428 /// like here and the card was rendering it as a fault.
429 ///
430 /// Why: the assertion is on the SERIALISED payload because that JSON, not
431 /// the Rust struct, is what the Svelte card branches on.
432 /// What: the nothing-is-serving verdict must carry `"on_demand"`, and so
433 /// must the not-installed one.
434 /// Test: this is the test.
435 #[test]
436 fn analyze_reports_an_on_demand_lifecycle_on_every_verdict() {
437 let tmp = tempfile::TempDir::new().expect("tempdir");
438 for socket in [Ok(tmp.path().join("absent.sock")), Err("nope".to_string())] {
439 let payload =
440 serde_json::to_value(AnalyzeConnector::new().detect_from(socket)).expect("json");
441 assert_eq!(
442 payload.get("lifecycle"),
443 Some(&serde_json::json!("on_demand")),
444 "the card branches on this key: {payload}"
445 );
446 }
447 }
448
449 /// Why (#6416): an on-demand row is "installed + version = healthy", and
450 /// before this the resting-state card showed no version at all — it only
451 /// ever read one off a live socket, which for an idle server is never.
452 /// What: with nothing serving, the version comes off `--version`.
453 /// Test: this is the test.
454 #[test]
455 fn analyze_reads_a_version_off_the_binary_when_nothing_is_serving() {
456 if which::which(BINARY).is_err() {
457 eprintln!("skip: trusty-analyze is not on PATH, so detect() short-circuits to Absent");
458 return;
459 }
460 let tmp = tempfile::TempDir::new().expect("tempdir");
461 let info = AnalyzeConnector::new().detect_from(Ok(tmp.path().join("absent.sock")));
462
463 assert_eq!(info.status, ServiceStatus::Available);
464 assert!(
465 info.version.is_some(),
466 "an installed on-demand member renders the version it prints"
467 );
468 }
469}