Skip to main content

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}