trusty_console/detect/agents.rs
1//! `ServiceConnector` implementation for `trusty-agents` (#3331).
2//!
3//! Why: the loopback-only doctrine (#3328) makes the trusty-console reverse
4//! proxy the intended remote path to the trusty-agents API (which now binds
5//! `127.0.0.1` by default, #3329). For the `/api/agents/*` proxy route to
6//! resolve an upstream URL, the connector must surface the daemon's live base
7//! URL — exactly as the trusty-mpm connector does.
8//! What: `AgentsConnector` implements `ServiceConnector::detect()` using the
9//! standard `trusty-common` `http_addr` discovery file written by the daemon
10//! after bind (`serve_with_config` calls `write_daemon_addr("trusty-agents")`).
11//! Path: binary check (`tagent`) → `resolve_data_dir("trusty-agents")/http_addr`
12//! → TCP probe → `Running`/`Available`/`Absent`. The daemon writes no TOML
13//! lock file, so there is no lock-file fallback (unlike the mpm connector).
14//! Test: `agents_connector_absent_binary`, `agents_connector_no_addr_file`,
15//! `agents_connector_surfaces_url_via_http_addr` below.
16
17use crate::connector::{ServiceConnector, ServiceInfo, ServiceLifecycle, ServiceStatus};
18
19use super::helpers::{binary_on_path, detect_service};
20
21/// ServiceConnector for `trusty-agents`.
22///
23/// Why: surfaces the running trusty-agents API daemon in the console Overview
24/// and enables the `/api/agents/*` reverse-proxy route by providing the
25/// daemon's live base URL via the standard `http_addr` discovery file (#3331).
26/// What: implements `detect()` using the standard `trusty-common` data-dir
27/// discovery path (`resolve_data_dir("trusty-agents")/http_addr`) written by
28/// the daemon on bind. The primary (`tagent`) binary is the presence gate.
29/// Test: unit tests below; run with `cargo test -p trusty-console`.
30pub struct AgentsConnector {
31 _priv: (),
32}
33
34impl AgentsConnector {
35 /// Create a new `AgentsConnector`.
36 ///
37 /// Why: production callers use `new()`; there is no home override because
38 /// detection reads the OS data dir via `resolve_data_dir` (overridable in
39 /// tests through `TRUSTY_DATA_DIR_OVERRIDE`).
40 /// What: stores no state.
41 /// Test: created in `all_connectors()` and in unit tests.
42 pub fn new() -> Self {
43 Self { _priv: () }
44 }
45}
46
47impl Default for AgentsConnector {
48 fn default() -> Self {
49 Self::new()
50 }
51}
52
53impl ServiceConnector for AgentsConnector {
54 fn id(&self) -> &'static str {
55 "trusty-agents"
56 }
57
58 fn display_name(&self) -> &'static str {
59 "Trusty Agents"
60 }
61
62 /// Detect trusty-agents status, surfacing the daemon URL when reachable.
63 ///
64 /// Why: the `/api/agents/*` proxy handler resolves the daemon base URL from
65 /// this connector's `ServiceInfo.url`, so `detect()` must surface a URL when
66 /// the daemon is reachable via the standard `http_addr` discovery file.
67 /// What: binary check (`tagent`) →
68 /// `resolve_data_dir("trusty-agents")/http_addr` → TCP probe → `Running`
69 /// with `url: Some(base_url)`; binary present but no reachable addr file →
70 /// `Available`; binary absent → `Absent`. Delegates to the shared
71 /// `detect_service()` helper (addr-file read, TCP probe, version fetch).
72 /// Test: `agents_connector_surfaces_url_via_http_addr` (primary path),
73 /// `agents_connector_no_addr_file`, `agents_connector_absent_binary`.
74 fn detect(&self) -> ServiceInfo {
75 // `resolve_data_dir` is infallible in practice; if the data directory
76 // cannot be resolved, report status purely on binary presence.
77 if let Ok(dir) = trusty_common::resolve_data_dir("trusty-agents") {
78 return detect_service(
79 self.id(),
80 self.display_name(),
81 "tagent",
82 dir.join("http_addr"),
83 );
84 }
85
86 ServiceInfo {
87 id: self.id().to_string(),
88 display_name: self.display_name().to_string(),
89 status: if binary_on_path("tagent") {
90 ServiceStatus::Available
91 } else {
92 ServiceStatus::Absent
93 },
94 version: None,
95 url: None,
96 hint: None,
97 // #6416: trusty-agents is a resident daemon — `Available` here means
98 // installed but stopped, which is what the card should say.
99 lifecycle: ServiceLifecycle::Daemon,
100 }
101 }
102}
103
104// ─── tests ────────────────────────────────────────────────────────────────────
105
106#[cfg(test)]
107mod tests {
108 use super::super::ENV_LOCK;
109 use super::*;
110 use std::fs;
111 use std::net::TcpListener;
112 use tempfile::TempDir;
113 use trusty_common::DATA_DIR_OVERRIDE_ENV;
114
115 /// Why: with no binary on PATH the connector must report Absent regardless
116 /// of any stale discovery file.
117 /// Test: this test.
118 #[test]
119 fn agents_connector_absent_binary() {
120 // Only meaningful when the binary is genuinely not installed (CI).
121 if which::which("tagent").is_ok() {
122 return;
123 }
124 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
125 let data_tmp = TempDir::new().expect("data-tempdir");
126 unsafe {
127 std::env::set_var(DATA_DIR_OVERRIDE_ENV, data_tmp.path());
128 }
129 let info = AgentsConnector::new().detect();
130 unsafe {
131 std::env::remove_var(DATA_DIR_OVERRIDE_ENV);
132 }
133 assert_eq!(info.status, ServiceStatus::Absent);
134 assert_eq!(info.id, "trusty-agents");
135 assert_eq!(info.display_name, "Trusty Agents");
136 }
137
138 /// Why: no http_addr file with the binary present must yield Available (not
139 /// Running); binary absent yields Absent.
140 /// Test: this test.
141 #[test]
142 fn agents_connector_no_addr_file() {
143 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
144 let data_tmp = TempDir::new().expect("data-tempdir");
145 unsafe {
146 std::env::set_var(DATA_DIR_OVERRIDE_ENV, data_tmp.path());
147 }
148 let info = AgentsConnector::new().detect();
149 unsafe {
150 std::env::remove_var(DATA_DIR_OVERRIDE_ENV);
151 }
152 assert!(info.url.is_none());
153 if which::which("tagent").is_ok() {
154 assert_eq!(info.status, ServiceStatus::Available);
155 } else {
156 assert_eq!(info.status, ServiceStatus::Absent);
157 }
158 }
159
160 /// Why: the primary path (#3331) must surface `url: Some(base_url)` when the
161 /// http_addr file exists and the port is reachable — this is what the proxy
162 /// handler reads to forward `/api/agents/*` requests.
163 /// What: writes a valid addr to the standard http_addr file under a temp
164 /// TRUSTY_DATA_DIR_OVERRIDE, binds a real listening port so tcp_probe passes,
165 /// calls detect(), and asserts the url and Running status.
166 /// Test: this test (key regression guard for #3331).
167 #[test]
168 fn agents_connector_surfaces_url_via_http_addr() {
169 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
170 let data_tmp = TempDir::new().expect("data-tempdir");
171 unsafe {
172 std::env::set_var(DATA_DIR_OVERRIDE_ENV, data_tmp.path());
173 }
174 // Write the http_addr file with a listening port so tcp_probe passes.
175 let agents_dir = data_tmp.path().join("trusty-agents");
176 fs::create_dir_all(&agents_dir).expect("mkdir");
177 let listener = TcpListener::bind("127.0.0.1:0").expect("bind free port");
178 let addr = listener.local_addr().expect("local_addr").to_string();
179 fs::write(agents_dir.join("http_addr"), &addr).expect("write addr");
180
181 let info = AgentsConnector::new().detect();
182
183 // Drop listener after detect() so the port is open during the probe.
184 drop(listener);
185 unsafe {
186 std::env::remove_var(DATA_DIR_OVERRIDE_ENV);
187 }
188
189 if which::which("tagent").is_ok() {
190 assert_eq!(
191 info.status,
192 ServiceStatus::Running,
193 "http_addr present + port open must yield Running, got: {info:?}"
194 );
195 assert_eq!(
196 info.url,
197 Some(format!("http://{addr}")),
198 "Running status must include daemon base URL for proxy routing"
199 );
200 } else {
201 assert_eq!(info.status, ServiceStatus::Absent);
202 }
203 }
204}