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}