trusty_console/service.rs
1//! Handler for `trusty-console service` (macOS launchd integration).
2//!
3//! Why: `stable_set()` in trusty-installer marks trusty-console
4//! `ManageStrategy::Launchd`, so `tctl start trusty-console` calls
5//! `launchctl bootstrap … com.trusty.trusty-console.plist` — but no code path
6//! in the repository ever WROTE that plist, so a fresh-machine `tctl start`
7//! hard-failed for the console daemon (#2557). This subcommand supplies the
8//! missing launchd install/uninstall/status/logs mechanics, mirroring the
9//! `trusty-search` / `trusty-analyze` `service` pattern.
10//!
11//! Note (design decision, umbrella #2555 open question): whether the console
12//! SHOULD be launchd-managed or process-managed (like trusty-mpm's own
13//! `start`/`stop` verbs) is an owner decision left open. The console exposes a
14//! real long-lived HTTP daemon (`trusty-console serve`, graceful SIGTERM
15//! shutdown), which launchd supervises correctly, and `stable_set()` already
16//! classifies it `Launchd`; this module makes that existing classification
17//! FUNCTION rather than deciding it is the right lifecycle model. If the owner
18//! later chooses process-management, the console would need its own
19//! `start`/`stop` verbs and the stable-set strategy would flip to `OwnVerb`.
20//!
21//! What: on macOS routes `ServiceAction` to a single launchd operation via the
22//! shared `trusty_common::launchd` module; the agent runs `trusty-console
23//! serve` (the dashboard HTTP daemon). On non-macOS the entry point returns a
24//! clear error.
25//! Test: `service_label_matches_tctl_convention` and `serve_args_are_serve`
26//! (macOS-only, pure) pin the load-bearing label + args; install/uninstall are
27//! side-effecting `launchctl` calls exercised manually (never in tests).
28
29use anyhow::Result;
30use clap::Subcommand;
31
32/// Subcommand actions for `trusty-console service`.
33///
34/// Why: launchd keeps the long-lived console dashboard daemon alive on macOS;
35/// wrapping the plist mechanics in `service` subcommands gives `tctl` a stable
36/// `trusty-console service install` hook and spares operators hand-editing XML.
37/// What: each variant maps to one launchd operation (or `tail -F` for Logs).
38/// Test: `cargo run -p trusty-console -- service --help` lists the four
39/// actions; on Linux any action returns Err with the platform message.
40#[derive(Debug, Clone, Subcommand)]
41pub enum ServiceAction {
42 /// Install the LaunchAgent plist and load it.
43 Install,
44 /// Unload the LaunchAgent and remove the plist.
45 Uninstall,
46 /// Show launchd status for the agent.
47 Status,
48 /// Tail the launchd stdout / stderr logs.
49 Logs,
50}
51
52/// Reverse-DNS label for the LaunchAgent.
53///
54/// Why: this MUST equal the label `tctl start`/`tctl stop` targets, or those
55/// commands drive a launchd job that `service install` never created. Making
56/// both read the same registry constant is what turns "must equal" from a
57/// comment into a fact.
58///
59/// #4868: was the literal `"com.trusty.trusty-console"` while the unit launchd
60/// actually has loaded is `com.trusty.console`, so `service status` queried a
61/// label that does not exist — the same divergence that broke trusty-search.
62/// What: the `Label` key value and the `<label>.plist` base name.
63/// Test: `service_label_matches_tctl_convention`.
64#[cfg(target_os = "macos")]
65pub const LAUNCHD_LABEL: &str = trusty_common::launchd_labels::CONSOLE;
66
67/// Dispatch a `trusty-console service <action>` invocation.
68///
69/// Why: launchd is macOS-specific; on other platforms we return a clear error.
70/// What: macOS routes to install / uninstall / status / logs. Non-macOS bails.
71/// Test: on Linux every action returns Err; the macOS paths are side-effecting
72/// and validated manually.
73pub fn run_service_action(action: &ServiceAction) -> Result<()> {
74 #[cfg(target_os = "macos")]
75 {
76 match action {
77 ServiceAction::Install => service_install(),
78 ServiceAction::Uninstall => service_uninstall(),
79 ServiceAction::Status => service_status(),
80 ServiceAction::Logs => service_logs(),
81 }
82 }
83 #[cfg(not(target_os = "macos"))]
84 {
85 let _ = action;
86 anyhow::bail!(
87 "`trusty-console service` is only supported on macOS — \
88 use your distro's service manager (systemd, OpenRC, etc.) directly."
89 );
90 }
91}
92
93/// The daemon serve args embedded in the launchd plist.
94///
95/// Why: the launchd agent must start the dashboard HTTP daemon, which is
96/// `trusty-console serve`.
97/// What: returns `["serve"]`.
98/// Test: `serve_args_are_serve`.
99#[cfg(target_os = "macos")]
100fn serve_args() -> Vec<String> {
101 vec!["serve".to_string()]
102}
103
104/// Resolve the log directory for the console launchd agent.
105///
106/// Why: align with the other trusty-* daemons (`~/.trusty-<name>/logs`).
107/// What: returns `~/.trusty-console/logs`, creating it on demand.
108/// Test: side-effecting; exercised transitively by `service install`.
109#[cfg(target_os = "macos")]
110fn launchd_log_dir() -> Result<std::path::PathBuf> {
111 let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("could not resolve $HOME"))?;
112 let dir = home.join(".trusty-console").join("logs");
113 std::fs::create_dir_all(&dir)?;
114 Ok(dir)
115}
116
117/// Build the shared `LaunchdConfig` for the console daemon.
118///
119/// Why: install / uninstall / status all need the same label, exe path, args,
120/// and log directory; building it once keeps them in agreement.
121/// What: resolves the current executable and log dir and returns a
122/// `LaunchdConfig` that runs `trusty-console serve`, kept alive always with a
123/// 10-second restart throttle and a seeded daemon `PATH` (#1298 — the console
124/// shells out to `tailscale`).
125/// Test: side-effecting resolution; exercised transitively by every macOS
126/// `service` subcommand.
127#[cfg(target_os = "macos")]
128fn launchd_config() -> Result<trusty_common::launchd::LaunchdConfig> {
129 use trusty_common::launchd::{KeepAlive, LaunchdConfig};
130
131 let exe = std::env::current_exe()
132 .map_err(|e| anyhow::anyhow!("could not resolve current exe: {e}"))?;
133 let log_dir = launchd_log_dir()?;
134 Ok(LaunchdConfig {
135 label: LAUNCHD_LABEL.to_string(),
136 exe_path: exe,
137 args: serve_args(),
138 log_dir,
139 keep_alive: KeepAlive::Always,
140 throttle_interval: 10,
141 env_vars: Vec::new(),
142 fd_limit: None,
143 working_directory: None,
144 }
145 .with_daemon_path())
146}
147
148/// Install the LaunchAgent and load it.
149///
150/// #4868: console's label genuinely CHANGES with this fix
151/// (`com.trusty.trusty-console` → `com.trusty.console`), which makes eviction
152/// mandatory rather than tidy. A bare `install()` + `bootstrap()` on a host that
153/// ran the older installer would leave the old unit loaded AND add the new one —
154/// two console daemons on one port, the exact #2938 condition. Routing through
155/// `install_and_activate` boots the old label out first, skips the reload when
156/// nothing changed, and rolls back rather than leaving the dashboard down.
157#[cfg(target_os = "macos")]
158fn service_install() -> Result<()> {
159 let cfg = launchd_config()?;
160 let plist_path = cfg.plist_path()?;
161 let outcome = cfg
162 .install_and_activate(trusty_common::launchd_labels::legacy_labels_for(
163 LAUNCHD_LABEL,
164 ))
165 .map_err(|e| anyhow::anyhow!("install LaunchAgent: {e}"))?;
166 for label in outcome.evicted() {
167 println!(
168 "[warn] Evicted the stale LaunchAgent {label} — it named this daemon under an old label."
169 );
170 }
171 let domain = format!("gui/{}", trusty_common::launchd::current_uid());
172 if matches!(
173 outcome,
174 trusty_common::launchd_activate::Activation::AlreadyCurrent { .. }
175 ) {
176 println!(
177 "[ok] {LAUNCHD_LABEL} is already loaded in {domain} with this exact unit — left running."
178 );
179 println!(
180 " Logs: {}\n Status: trusty-console service status",
181 cfg.log_dir.display()
182 );
183 return Ok(());
184 }
185 println!("[ok] Wrote LaunchAgent plist: {}", plist_path.display());
186 println!(
187 "[ok] trusty-console service installed and started ({LAUNCHD_LABEL} loaded into {domain})."
188 );
189 println!(
190 " Logs: {}\n Status: trusty-console service status",
191 cfg.log_dir.display()
192 );
193 Ok(())
194}
195
196#[cfg(target_os = "macos")]
197fn service_uninstall() -> Result<()> {
198 let cfg = launchd_config()?;
199 let plist_path = cfg.plist_path()?;
200 // #4868: a host that never ran the migrating install still has the unit
201 // under its old label. Removing only the canonical plist printed "nothing
202 // to do" while leaving that one loaded.
203 for label in cfg.evict_legacy(trusty_common::launchd_labels::legacy_labels_for(
204 LAUNCHD_LABEL,
205 )) {
206 println!("[ok] Unloaded and removed the stale LaunchAgent {label}");
207 }
208 if plist_path.exists() {
209 let _ = cfg.bootout();
210 std::fs::remove_file(&plist_path)
211 .map_err(|e| anyhow::anyhow!("remove {}: {e}", plist_path.display()))?;
212 println!(
213 "[ok] trusty-console service uninstalled ({} removed).",
214 plist_path.display()
215 );
216 } else {
217 println!(
218 "[skip] {} not installed — nothing to do",
219 plist_path.display()
220 );
221 }
222 Ok(())
223}
224
225#[cfg(target_os = "macos")]
226fn service_status() -> Result<()> {
227 let uid = trusty_common::launchd::current_uid();
228 let target = format!("gui/{uid}/{LAUNCHD_LABEL}");
229 let output = std::process::Command::new("launchctl")
230 .args(["print", &target])
231 .output()
232 .map_err(|e| anyhow::anyhow!("launchctl print failed: {e}"))?;
233 if output.status.success() {
234 println!("{}", String::from_utf8_lossy(&output.stdout));
235 Ok(())
236 } else {
237 eprintln!(" Install with: trusty-console service install");
238 anyhow::bail!(
239 "{target} is not loaded ({})",
240 String::from_utf8_lossy(&output.stderr).trim()
241 );
242 }
243}
244
245#[cfg(target_os = "macos")]
246fn service_logs() -> Result<()> {
247 let log_dir = launchd_log_dir()?;
248 let stdout_log = log_dir.join("stdout.log");
249 let stderr_log = log_dir.join("stderr.log");
250 if !stdout_log.exists() && !stderr_log.exists() {
251 eprintln!(
252 "[skip] No logs at {} yet — start the service first.",
253 log_dir.display()
254 );
255 return Ok(());
256 }
257 let status = std::process::Command::new("tail")
258 .arg("-F")
259 .arg(&stdout_log)
260 .arg(&stderr_log)
261 .status()
262 .map_err(|e| anyhow::anyhow!("tail failed: {e}"))?;
263 if !status.success() {
264 anyhow::bail!("tail exited with {status}");
265 }
266 Ok(())
267}
268
269#[cfg(all(test, target_os = "macos"))]
270mod tests {
271 use super::*;
272
273 /// Why: the label is a cross-crate contract — `tctl` resolves it through
274 /// `plist_label_for` and bootstraps THAT plist, so drift silently breaks
275 /// `tctl start trusty-console`. #4868: asserting against a re-typed literal
276 /// is what made the old version of this test agree with the wrong answer
277 /// (`com.trusty.trusty-console`, while launchd has `com.trusty.console`);
278 /// it now asserts against the registry both sides read.
279 /// What: the constant equals the canonical registry label, and is NOT the
280 /// pre-#4868 full-name form.
281 /// Test: this is the test.
282 #[test]
283 fn service_label_matches_tctl_convention() {
284 assert_eq!(LAUNCHD_LABEL, trusty_common::launchd_labels::CONSOLE);
285 assert_ne!(
286 LAUNCHD_LABEL, "com.trusty.trusty-console",
287 "the full-name form is a legacy alias, not a unit launchd has"
288 );
289 }
290
291 /// Why: the launchd agent must start the dashboard HTTP daemon, i.e.
292 /// `trusty-console serve`.
293 /// What: asserts the embedded args are exactly `["serve"]`.
294 /// Test: this is the test.
295 #[test]
296 fn serve_args_are_serve() {
297 assert_eq!(serve_args(), vec!["serve".to_string()]);
298 }
299
300 /// Cross-crate port-uniqueness contract (#2566, extended by #2573).
301 ///
302 /// Why: trusty-review's original `DEFAULT_PORT` (7880) silently collided
303 /// with trusty-mpm's live `DEFAULT_DAEMON_ADDR`, crash-looping a launchd
304 /// agent on install. This mirrors that fix's guard for the console's own
305 /// default (`crate::DEFAULT_PORT`), pointer-commented to each sibling's
306 /// real source constant, so a future edit here that reintroduces a
307 /// collision fails this test instead of shipping a crash-loop. #2573
308 /// extended this table to also cover trusty-embedderd's `--http` mode
309 /// default, which the original table omitted because it is a manual/
310 /// dev-run listener rather than a `tctl`-managed daemon.
311 /// What: asserts `crate::DEFAULT_PORT` is absent from the known-sibling
312 /// ports list.
313 /// Test: this is the test.
314 #[test]
315 fn default_port_does_not_collide_with_known_siblings() {
316 // (binary, port, source-of-truth pointer)
317 let known_siblings: &[(&str, u16, &str)] = &[
318 (
319 "trusty-memory",
320 7070,
321 "trusty-memory/src/http_server.rs::DEFAULT_HTTP_PORT",
322 ),
323 (
324 "trusty-search",
325 7878,
326 "trusty-search/src/service/constants.rs::DEFAULT_PORT",
327 ),
328 (
329 "trusty-analyze",
330 7879,
331 "trusty-analyze/src/service/events.rs::DEFAULT_PORT",
332 ),
333 (
334 "trusty-review",
335 7891,
336 "trusty-review/src/service/mod.rs::DEFAULT_PORT",
337 ),
338 (
339 "trusty-mpm",
340 7880,
341 "trusty-mpm/src/core/discovery.rs::DEFAULT_DAEMON_ADDR",
342 ),
343 (
344 "trusty-embedderd",
345 7890,
346 "trusty-embedderd/src/lib.rs::Args::http_addr (--http default_value, manual/dev-run only)",
347 ),
348 (
349 // #3331: trusty-agents joined the proxied-sibling set; its API
350 // server default port must not collide with the console's.
351 "trusty-agents",
352 8080,
353 "trusty-agents/src/runtime/mode_dispatch.rs (--port default 8080)",
354 ),
355 (
356 // #3364: trusty-mpm's supervisor metrics listener — a distinct
357 // process/port from the `tm` daemon (7880) above, deployed via
358 // launchd and easy to miss since it isn't `tctl`-managed.
359 "trusty-mpm-supervisor",
360 7881,
361 "trusty-mpm/src/supervisor/config.rs::DEFAULT_METRICS_ADDR",
362 ),
363 (
364 // #3364: trusty-code's own default HTTP port, which previously
365 // reused 7881 and collided with the supervisor entry above.
366 "trusty-code",
367 7882,
368 "trusty-code/src/serve/mod.rs::DEFAULT_HTTP_PORT",
369 ),
370 ];
371 for (binary, port, source) in known_siblings {
372 assert_ne!(
373 crate::DEFAULT_PORT,
374 *port,
375 "trusty-console DEFAULT_PORT {} collides with {binary}'s {port} ({source})",
376 crate::DEFAULT_PORT
377 );
378 }
379 }
380}