trusty_console/detect/review.rs
1//! `ServiceConnector` implementation for `trusty-review`.
2//!
3//! Why (#6290): trusty-review has no daemon. #6277 moved it from a TCP port to
4//! a Unix socket and this connector followed; ADR-0032's review lane retired
5//! the listener outright, so there is nothing left to dial. A connector that
6//! kept dialling would spend its 3-second budget on a socket nobody binds,
7//! once per detection pass, and report `Available` at the end of it — the same
8//! answer it reaches immediately by asking whether the binary is installed.
9//!
10//! What: `detect()` resolves `trusty-review` on PATH and reads the version off
11//! `trusty-review --version`. `Running` is unreachable for this member and that
12//! is correct, not a gap: a per-invocation tool is installed or it is not.
13//!
14//! The webhook path is unaffected and is NOT what this connector reports on.
15//! Console still spawns `trusty-review webhook-listen` on demand for a relayed
16//! GitHub delivery and SIGTERMs it (ADR-0034 §1); that process's health is
17//! metered by `webhook::health` off the inbox backlog, not by a service card.
18//!
19//! Test: `review_connector_reports_available_when_the_binary_is_present`,
20//! `review_connector_reports_absent_when_the_binary_is_missing`,
21//! `review_connector_never_reaches_running`,
22//! `review_reports_an_on_demand_lifecycle_on_every_verdict`.
23
24use crate::connector::{ServiceConnector, ServiceInfo, ServiceLifecycle, ServiceStatus};
25
26use super::helpers::{VersionProbe, binary_on_path, binary_version};
27
28/// ServiceConnector for `trusty-review`.
29///
30/// Why: the console dashboard's Review tab still needs to say whether review is
31/// usable on this machine. Since #6290 that question is "is the binary there
32/// and does it run", not "is a daemon up".
33/// What: implements `detect()` — binary on PATH, then one `--version` spawn.
34/// Test: see the module docs.
35pub struct ReviewConnector {
36 /// Override for the binary name (used in tests).
37 ///
38 /// Why a name and not a path: `detect()` resolves through `PATH` exactly as
39 /// production does, so a test that points this at a name nothing provides
40 /// exercises the real resolution rather than a stubbed one. Before #6290
41 /// this field was a socket path; the socket is gone with the daemon.
42 binary: Option<String>,
43}
44
45impl ReviewConnector {
46 /// Create a new `ReviewConnector`.
47 pub fn new() -> Self {
48 Self { binary: None }
49 }
50
51 /// Create a connector that probes `binary` instead of `trusty-review`.
52 ///
53 /// Why: the absent-binary verdict is otherwise unreachable on a developer
54 /// machine, which has trusty-review installed. Overriding the NAME keeps
55 /// the test free of environment variables — five sibling connectors run in
56 /// the same pass and share this process's `PATH`.
57 /// What: stores `binary` for use by `detect()`.
58 /// Test: `review_connector_reports_absent_when_the_binary_is_missing`.
59 pub fn with_binary(binary: impl Into<String>) -> Self {
60 Self {
61 binary: Some(binary.into()),
62 }
63 }
64
65 /// The binary this connector looks for.
66 fn binary(&self) -> &str {
67 self.binary.as_deref().unwrap_or("trusty-review")
68 }
69}
70
71impl Default for ReviewConnector {
72 fn default() -> Self {
73 Self::new()
74 }
75}
76
77impl ServiceConnector for ReviewConnector {
78 fn id(&self) -> &'static str {
79 "trusty-review"
80 }
81
82 fn display_name(&self) -> &'static str {
83 "Trusty Review"
84 }
85
86 // #6416: #6290 retired the daemon, so installed IS the healthy state.
87 fn lifecycle(&self) -> ServiceLifecycle {
88 ServiceLifecycle::OnDemand
89 }
90
91 /// Detect trusty-review status.
92 ///
93 /// Why: `tctl` asks the same question for a different reason, and the two
94 /// must agree — since #6290 both ask it by presence
95 /// (`trusty_installer::commands::probe_http::probe_presence`), not by
96 /// dialling.
97 /// What: not on PATH → `Absent`. On PATH but not runnable → `Degraded` with
98 /// the reason as its `hint`, matching `tctl`'s `ProbeFailed` for the same
99 /// host. Otherwise `Available` carrying whatever `--version` printed. `url`
100 /// is `None`: there is no address, and ADR-0032 makes trusty-console the
101 /// only HTTP surface in the workspace, so a synthesised one would be a link
102 /// that cannot work. Every verdict carries
103 /// [`ServiceLifecycle::OnDemand`](crate::connector::ServiceLifecycle) so the
104 /// card stops offering to start a daemon that does not exist (#6416).
105 /// Test: see the module docs;
106 /// `helpers::tests::a_binary_that_cannot_execute_is_not_available`.
107 fn detect(&self) -> ServiceInfo {
108 let binary = self.binary();
109 if !binary_on_path(binary) {
110 return ServiceInfo {
111 id: self.id().to_string(),
112 display_name: self.display_name().to_string(),
113 status: ServiceStatus::Absent,
114 version: None,
115 url: None,
116 hint: None,
117 // #6416: even the not-installed row must say this is not a daemon
118 // — the card's remediation text branches on it.
119 lifecycle: self.lifecycle(),
120 };
121 }
122
123 // #6290: a binary that is present but will not run is NOT available.
124 // Reporting it as such is what put the console at odds with `tctl`,
125 // which calls the same host `ProbeFailed`.
126 let (status, version, hint) = match binary_version(binary) {
127 VersionProbe::Ran(version) => (ServiceStatus::Available, version, None),
128 VersionProbe::CannotExecute(why) => (
129 ServiceStatus::Degraded,
130 None,
131 Some(format!(
132 "{binary} is on PATH but did not run: {why}. Reinstall it \
133 with `cargo install {binary}`."
134 )),
135 ),
136 };
137
138 ServiceInfo {
139 id: self.id().to_string(),
140 display_name: self.display_name().to_string(),
141 status,
142 version,
143 url: None,
144 hint,
145 // #6416: `Available` is trusty-review's healthy resting state, not a
146 // stopped daemon; this is what stops the card rendering it as a fault.
147 lifecycle: self.lifecycle(),
148 }
149 }
150}
151
152// ─── tests ────────────────────────────────────────────────────────────────────
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157 use std::time::Duration;
158
159 /// REGRESSION (#6290): the console must answer for trusty-review with NO
160 /// trusty-review process anywhere, and must not hang doing it.
161 ///
162 /// Why: nothing binds the review socket any more. A connector that still
163 /// dialled would burn its full 3-second budget on every detection pass and
164 /// arrive at exactly the verdict presence gives immediately. This test runs
165 /// in a process where no review daemon exists — which is every process, now
166 /// — and asserts a real answer comes back.
167 /// What: detects against this workspace's own binary (present on any machine
168 /// running this test), asserts `Available` with a version, and bounds the
169 /// call so a reintroduced dial would fail rather than merely be slow.
170 /// Test: this is the test.
171 #[test]
172 fn review_connector_reports_available_when_the_binary_is_present() {
173 if which::which("cargo").is_err() {
174 eprintln!("skip: no cargo on PATH to probe as a stand-in binary");
175 return;
176 }
177 let started = std::time::Instant::now();
178 let info = ReviewConnector::with_binary("cargo").detect();
179 let elapsed = started.elapsed();
180
181 assert_eq!(
182 info.status,
183 ServiceStatus::Available,
184 "a present per-invocation binary is Available"
185 );
186 assert!(
187 info.version.is_some(),
188 "the card renders the version read off `--version`"
189 );
190 assert_eq!(info.id, "trusty-review");
191 assert_eq!(info.display_name, "Trusty Review");
192 assert!(info.url.is_none(), "a per-invocation tool has no URL");
193 assert!(
194 elapsed < Duration::from_secs(3),
195 "the detect must not dial anything — a socket dial's own budget is \
196 3 s, so this bound is what a reintroduced dial would trip: {elapsed:?}"
197 );
198 }
199
200 /// Why: `Absent` is the one verdict that must stay reachable — an operator
201 /// whose install failed needs the card to say so rather than to say
202 /// `Available` with no version.
203 /// What: probes a name no binary can have.
204 /// Test: this is the test.
205 #[test]
206 fn review_connector_reports_absent_when_the_binary_is_missing() {
207 let info = ReviewConnector::with_binary("trusty-review-does-not-exist-9f3a").detect();
208 assert_eq!(info.status, ServiceStatus::Absent);
209 assert!(info.version.is_none());
210 assert!(info.hint.is_none());
211 }
212
213 /// Why: `Running` means "a daemon answered a health check", and trusty-review
214 /// has no daemon. Reporting it would tell an operator a process exists that
215 /// they could stop, restart or find in `ps` — none of which is true. This is
216 /// what keeps a future edit from reaching for the more reassuring word.
217 /// What: neither the present nor the absent path may produce `Running` or
218 /// `Degraded`.
219 /// Test: this is the test.
220 #[test]
221 fn review_connector_never_reaches_running() {
222 for connector in [
223 ReviewConnector::new(),
224 ReviewConnector::with_binary("trusty-review-does-not-exist-9f3a"),
225 ] {
226 let status = connector.detect().status;
227 assert!(
228 matches!(status, ServiceStatus::Available | ServiceStatus::Absent),
229 "a per-invocation member has only two honest verdicts, got {status:?}"
230 );
231 }
232 }
233
234 /// REGRESSION (#6416): the dashboard read "Binary found but daemon is not
235 /// running" over a Trusty Review card that also showed a version — the card
236 /// renders that sentence for every `Available` row, and before this the
237 /// payload carried nothing saying trusty-review has no daemon to run.
238 ///
239 /// Why: `status` alone cannot distinguish a stopped daemon from an installed
240 /// per-invocation tool, so the presentation has to read `lifecycle`. The
241 /// assertion is on the SERIALISED payload because that JSON, not the Rust
242 /// struct, is what the Svelte card branches on.
243 /// What: both the installed and the missing-binary verdicts must serialise
244 /// `"lifecycle": "on_demand"`.
245 /// Test: this is the test.
246 #[test]
247 fn review_reports_an_on_demand_lifecycle_on_every_verdict() {
248 for connector in [
249 ReviewConnector::new(),
250 ReviewConnector::with_binary("trusty-review-does-not-exist-9f3a"),
251 ] {
252 let payload = serde_json::to_value(connector.detect()).expect("serialise");
253 assert_eq!(
254 payload.get("lifecycle"),
255 Some(&serde_json::json!("on_demand")),
256 "the card branches on this key: {payload}"
257 );
258 }
259 }
260}