Skip to main content

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}