Skip to main content

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}