Skip to main content

trusty_console/detect/
search.rs

1//! `ServiceConnector` implementation for `trusty-search`.
2//!
3//! Why: trusty-search served TCP loopback HTTP and published its bound address
4//! in `~/.trusty-search/http_addr`; this connector read that file and probed the
5//! port. #6285 (ADR-0032) moves the daemon onto a hardened Unix socket, so both
6//! are gone — there is no port and no discovery file, and the file that is still
7//! on disk from before the migration names 7878, which any process can now hold.
8//! The socket path is derived, and the daemon and this connector resolve it
9//! through the same `trusty_common::daemon_socket_path` call.
10//!
11//! What: `SearchConnector::detect()` dials `search.health` over the socket and
12//! reads `version` off the answer.
13//! Test: `search_connector_reports_available_when_nothing_is_serving`,
14//! `search_connector_reads_the_version_off_a_live_socket`,
15//! `search_connector_surfaces_an_unresolvable_socket_path_as_a_hint`,
16//! `search_connector_reports_an_error_frame_as_not_running`.
17
18use std::path::{Path, PathBuf};
19
20use crate::connector::{ServiceConnector, ServiceInfo, ServiceLifecycle, ServiceStatus};
21use crate::search_uds::{HEALTH_TIMEOUT, METHOD_HEALTH, SEARCH_SERVICE};
22
23use super::helpers::binary_on_path;
24
25/// The `result` half of a `search.health` response, as far as the console reads
26/// it.
27///
28/// Only `version` is consumed — the card renders it. `status` is deserialised
29/// too so a body carrying neither is refused as not-a-health-envelope rather
30/// than silently rendering a versionless Running card.
31#[derive(Debug, serde::Deserialize)]
32struct HealthEnvelope {
33    /// `"ok"` or `"degraded"`. Presence is what makes this a health answer.
34    #[allow(dead_code)]
35    status: String,
36    /// The daemon's own version, rendered on the service card.
37    version: Option<String>,
38}
39
40/// ServiceConnector for `trusty-search`.
41///
42/// Why: the dashboard needs to know whether the search daemon is running, and
43/// since #6285 that question is answered by dialling its socket.
44/// What: implements `detect()` — binary on PATH, then one `search.health` call.
45/// Test: see the module docs.
46pub struct SearchConnector {
47    /// Override for the socket path (used in tests).
48    ///
49    /// Before #6285 this was a HOME override, because the discovery file lived
50    /// under `~`. The socket path comes from the data directory now, which
51    /// `TRUSTY_DATA_DIR_OVERRIDE` already redirects — but that variable is
52    /// process-global and this connector runs beside five others in one poll,
53    /// so a path override keeps a test from redirecting its siblings too.
54    socket: Option<PathBuf>,
55}
56
57impl SearchConnector {
58    /// Create a new `SearchConnector`.
59    pub fn new() -> Self {
60        Self { socket: None }
61    }
62
63    /// Create a connector that dials `socket` instead of the resolved path.
64    ///
65    /// Why: unit tests must not dial the real user's running daemon.
66    /// Test: `search_connector_reports_available_when_nothing_is_serving`.
67    pub fn with_socket(socket: PathBuf) -> Self {
68        Self {
69            socket: Some(socket),
70        }
71    }
72
73    /// The socket this connector dials, or why it could not be resolved.
74    ///
75    /// Why the error is carried rather than discarded: a data directory that
76    /// cannot be resolved or created is operator-fixable (permissions, a
77    /// `TRUSTY_DATA_DIR_OVERRIDE` pointing somewhere unusable), and it is
78    /// indistinguishable on the dashboard from a daemon that is simply not
79    /// running. `detect()` still reports `Available` — nothing was observed, so
80    /// claiming otherwise would be a guess — but puts the reason in `hint`.
81    fn socket_path(&self) -> Result<PathBuf, String> {
82        match &self.socket {
83            Some(p) => Ok(p.clone()),
84            None => crate::search_uds::socket_path(),
85        }
86    }
87}
88
89impl Default for SearchConnector {
90    fn default() -> Self {
91        Self::new()
92    }
93}
94
95/// Dial `search.health` and return the envelope, or `None` if nothing answered.
96///
97/// Why a dedicated thread: `ServiceConnector::detect` is synchronous — the
98/// poller calls it inside `spawn_blocking` — and the shared UDS client is async.
99/// The exchange runs on its own current-thread runtime rather than through
100/// `Handle::block_on`, for the reason `trusty-installer`'s
101/// `probe_member_http_blocking` records: building a runtime and blocking on it
102/// from inside another runtime's worker panics, and this way the call is safe
103/// from any caller regardless of what it is running on. The same shape
104/// `detect::AnalyzeConnector` uses.
105///
106/// What: one [`crate::search_uds::call`] bounded by [`HEALTH_TIMEOUT`]. A
107/// response carrying an `error` is `None`: the daemon answered, but not with
108/// health, and the console has nothing to render.
109///
110/// Test: `search_connector_reports_an_error_frame_as_not_running`.
111fn probe_health(socket: &Path) -> Option<HealthEnvelope> {
112    let socket = socket.to_path_buf();
113    let handle = std::thread::Builder::new()
114        .name("console-search-probe".to_owned())
115        .spawn(move || {
116            let rt = tokio::runtime::Builder::new_current_thread()
117                .enable_all()
118                .build()
119                .ok()?;
120            rt.block_on(async {
121                let result = crate::search_uds::call(
122                    &socket,
123                    METHOD_HEALTH,
124                    serde_json::json!({}),
125                    HEALTH_TIMEOUT,
126                )
127                .await
128                .ok()?;
129                serde_json::from_value::<HealthEnvelope>(result).ok()
130            })
131        })
132        .ok()?;
133    handle.join().ok()?
134}
135
136impl ServiceConnector for SearchConnector {
137    fn id(&self) -> &'static str {
138        SEARCH_SERVICE
139    }
140
141    fn display_name(&self) -> &'static str {
142        "Trusty Search"
143    }
144
145    /// Detect trusty-search status.
146    ///
147    /// Why: the dashboard needs to know whether the daemon is up, and `tctl`'s
148    /// probe asks the same question — so the two must agree, which they do by
149    /// dialling the same method on the same derived path (#6285).
150    /// What: binary check → `search.health` over the socket → status. `url` is
151    /// deliberately `None`: a UDS daemon has no URL, and ADR-0032 makes
152    /// trusty-console the only HTTP surface in the workspace, so a synthesised
153    /// `http://` address would be a link that cannot work. The dashboard reaches
154    /// the daemon's own UI at `/tools/search/` instead (#6155).
155    /// Test: see the module docs.
156    fn detect(&self) -> ServiceInfo {
157        self.detect_from(self.socket_path())
158    }
159}
160
161impl SearchConnector {
162    /// [`ServiceConnector::detect`]'s body, over an already-resolved path.
163    ///
164    /// Why separate: the unresolvable-path arm is only reachable when
165    /// `trusty_common::daemon_socket_path` fails, and the only way to make it
166    /// fail from a test is to set `TRUSTY_DATA_DIR_OVERRIDE` — which is
167    /// process-global and, in this crate's test binary, is read by five sibling
168    /// connectors running in parallel. Taking the resolved result as a parameter
169    /// makes the arm assertable with no global state at all.
170    /// What: binary check, then the three verdicts. `Absent` means the binary is
171    /// not installed; `Available` means installed with nothing answering on the
172    /// socket; `Running` means the daemon answered `search.health`.
173    /// Test: `search_connector_surfaces_an_unresolvable_socket_path_as_a_hint`.
174    fn detect_from(&self, socket: Result<PathBuf, String>) -> ServiceInfo {
175        let base =
176            |status: ServiceStatus, version: Option<String>, hint: Option<String>| ServiceInfo {
177                id: self.id().to_string(),
178                display_name: self.display_name().to_string(),
179                status,
180                version,
181                url: None,
182                hint,
183                // #6416: trusty-search is a resident daemon; `Available` means stopped.
184                lifecycle: ServiceLifecycle::Daemon,
185            };
186
187        if !binary_on_path(SEARCH_SERVICE) {
188            return base(ServiceStatus::Absent, None, None);
189        }
190
191        let socket = match socket {
192            Ok(p) => p,
193            Err(reason) => return base(ServiceStatus::Available, None, Some(reason)),
194        };
195
196        match probe_health(&socket) {
197            Some(health) => base(ServiceStatus::Running, health.version, None),
198            None => base(ServiceStatus::Available, None, None),
199        }
200    }
201}
202
203// ─── tests ────────────────────────────────────────────────────────────────────
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use std::path::PathBuf;
209
210    fn installed() -> ServiceStatus {
211        if which::which(SEARCH_SERVICE).is_ok() {
212            ServiceStatus::Available
213        } else {
214            ServiceStatus::Absent
215        }
216    }
217
218    /// Bind a socket that answers exactly one framed request with `reply`.
219    ///
220    /// Must be called from inside a tokio runtime — `bind_hardened` registers
221    /// the listener with the reactor. `detect()` itself is blocking and runs its
222    /// dial on its own thread, so it is safe to call from a `#[tokio::test]`.
223    fn stub_daemon(dir: &Path, reply: impl Into<String>) -> PathBuf {
224        let socket = dir.join("sockets").join("search.sock");
225        let reply = reply.into();
226        let listener = trusty_common::uds::bind_hardened(&socket).expect("bind");
227        tokio::spawn(async move {
228            use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
229            let Ok((mut conn, _)) = listener.accept().await else {
230                return;
231            };
232            let mut sink = Vec::new();
233            let _ = conn.read_to_end(&mut sink).await;
234            let _ = conn.write_all(reply.as_bytes()).await;
235            let _ = conn.write_all(b"\n").await;
236            let _ = conn.flush().await;
237        });
238        socket
239    }
240
241    /// Run `detect()` off the runtime's worker so the blocking probe inside it
242    /// cannot stall the stub task that has to answer it.
243    async fn detect_against(socket: PathBuf) -> ServiceInfo {
244        tokio::task::spawn_blocking(move || SearchConnector::with_socket(socket).detect())
245            .await
246            .expect("detect")
247    }
248
249    /// Why (#6285): the pre-migration connector read `~/.trusty-search/http_addr`
250    /// and probed the port it named, so once the daemon stops writing that file
251    /// any process holding 7878 would make this report a trusty-search that is
252    /// not there. The file path is gone, and this is what keeps it gone: an
253    /// absent socket is `Available`, never `Running`, whatever else is listening
254    /// on the machine.
255    /// Test: this is the test.
256    #[test]
257    fn search_connector_reports_available_when_nothing_is_serving() {
258        let tmp = tempfile::TempDir::new().expect("tempdir");
259        let connector = SearchConnector::with_socket(tmp.path().join("absent.sock"));
260        let info = connector.detect();
261
262        assert_eq!(info.status, installed());
263        assert_eq!(info.id, SEARCH_SERVICE);
264        assert_eq!(info.display_name, "Trusty Search");
265        assert!(
266            info.url.is_none(),
267            "a UDS daemon has no URL to link to: {info:?}"
268        );
269    }
270
271    /// Why: the service card renders the daemon's version, so a live socket has
272    /// to produce `Running` with that version rather than a bare liveness bit.
273    /// Test: this is the test.
274    #[tokio::test(flavor = "multi_thread")]
275    async fn search_connector_reads_the_version_off_a_live_socket() {
276        if which::which(SEARCH_SERVICE).is_err() {
277            // The binary check short-circuits to Absent before any dial, so
278            // there is nothing to assert on a machine without it installed.
279            return;
280        }
281        let tmp = tempfile::TempDir::new().expect("tempdir");
282        let socket = stub_daemon(
283            tmp.path(),
284            r#"{"jsonrpc":"2.0","id":1,"result":{"status":"ok","version":"0.49.6","indexes":3}}"#,
285        );
286        let info = detect_against(socket).await;
287        assert_eq!(info.status, ServiceStatus::Running);
288        assert_eq!(info.version.as_deref(), Some("0.49.6"));
289    }
290
291    /// Why: the fail-open arm. A daemon that answers a JSON-RPC `error` has told
292    /// us nothing about its health, and rendering `Running` off the fact that
293    /// SOMETHING replied is exactly the false-healthy card #6285 must not
294    /// introduce.
295    /// Test: this is the test.
296    #[tokio::test(flavor = "multi_thread")]
297    async fn search_connector_reports_an_error_frame_as_not_running() {
298        if which::which(SEARCH_SERVICE).is_err() {
299            return;
300        }
301        let tmp = tempfile::TempDir::new().expect("tempdir");
302        let socket = stub_daemon(
303            tmp.path(),
304            r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"method not found"}}"#,
305        );
306        let info = detect_against(socket).await;
307        assert_eq!(info.status, ServiceStatus::Available);
308        assert!(info.version.is_none(), "{info:?}");
309    }
310
311    /// Why: an answer that is not a health envelope must not render a
312    /// versionless Running card — the daemon replied, but not with health.
313    /// Test: this is the test.
314    #[tokio::test(flavor = "multi_thread")]
315    async fn search_connector_reports_a_non_health_answer_as_not_running() {
316        if which::which(SEARCH_SERVICE).is_err() {
317            return;
318        }
319        let tmp = tempfile::TempDir::new().expect("tempdir");
320        let socket = stub_daemon(tmp.path(), r#"{"jsonrpc":"2.0","id":1,"result":{"hi":1}}"#);
321        let info = detect_against(socket).await;
322        assert_eq!(info.status, ServiceStatus::Available);
323    }
324
325    /// Why: an unusable data directory is operator-fixable and looks identical
326    /// on the dashboard to a daemon that is not running, so the reason has to
327    /// reach the card rather than being swallowed.
328    /// Test: this is the test.
329    #[test]
330    fn search_connector_surfaces_an_unresolvable_socket_path_as_a_hint() {
331        let connector = SearchConnector::new();
332        let info = connector.detect_from(Err("no data directory".to_string()));
333        if info.status == ServiceStatus::Absent {
334            // No binary on PATH: the check short-circuits before the path arm.
335            return;
336        }
337        assert_eq!(info.status, ServiceStatus::Available);
338        assert_eq!(info.hint.as_deref(), Some("no data directory"));
339    }
340}