Skip to main content

horto_os_ui_shared/remote/runner/
probe.rs

1//! Remote CLI probe, arch detection, and box snapshot.
2
3use super::super::arch::{box_arch_from_uname, BoxArch};
4use super::super::process::{ProcessRunner, StdioMode};
5use super::super::ssh::SshSession;
6use super::options::{
7    exec_remote_cli_captured, remote_cli_candidates, remote_install_bin, remote_progress,
8    session_from, shell_quote, RemoteOptions,
9};
10use super::status::{parse_remote_json, parse_setup_status_text};
11use crate::error::Result;
12use crate::LONG_VERSION;
13
14/// First line of `horto-os-ui --version` / long-version output.
15#[must_use]
16pub fn normalize_cli_version(raw: &str) -> String {
17    raw.lines()
18        .map(str::trim)
19        .find(|l| !l.is_empty())
20        .unwrap_or("")
21        .to_owned()
22}
23
24/// Whether a remote `--version` line matches this PC's baked [`LONG_VERSION`].
25#[must_use]
26pub fn remote_cli_version_is_current(remote_version: &str) -> bool {
27    let remote = normalize_cli_version(remote_version);
28    if remote.is_empty() {
29        return false;
30    }
31    remote == LONG_VERSION || remote == format!("horto-os-ui {LONG_VERSION}")
32}
33
34/// Probe `uname -m` on the remote host.
35///
36/// # Errors
37///
38/// Returns [`crate::HortoError`] when SSH fails or the arch is unsupported.
39pub fn remote_probe_arch(runner: &dyn ProcessRunner, opts: &RemoteOptions) -> Result<BoxArch> {
40    let session = session_from(opts)?;
41    remote_progress(&opts.host, "probe arch (SSH; may ask password)");
42    let out = session.exec(runner, "uname -m", StdioMode::Capture)?;
43    box_arch_from_uname(&out.stdout)
44}
45
46/// Result of probing CLI binaries already on the box (no SCP).
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct RemoteCliProbe {
49    /// Absolute path of the binary that answered `--version`, when any.
50    pub path: Option<String>,
51    /// Normalized version line from the box, when any.
52    pub version: Option<String>,
53    /// True when [`version`](Self::version) matches this PC's [`LONG_VERSION`].
54    pub current: bool,
55    /// Operator-facing box CLI label after a probe (never "not yet refreshed").
56    pub status: RemoteBoxCliStatus,
57}
58
59/// How to show the box CLI after SSH was attempted.
60#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub enum RemoteBoxCliStatus {
63    /// SSH worked; no `horto-os-ui --version` on install/agent paths.
64    Missing,
65    /// SSH authentication failed (password/key).
66    AuthFailed,
67    /// Host unreachable / DNS / connection refused / timeout.
68    Unreachable,
69    /// Version line from the box (current or stale).
70    Found(String),
71}
72
73impl RemoteBoxCliStatus {
74    /// Compact footer/overview label (`missing`, `auth failed`, version, …).
75    #[must_use]
76    pub const fn as_label(&self) -> &str {
77        match self {
78            Self::Missing => "missing",
79            Self::AuthFailed => "auth failed",
80            Self::Unreachable => "unreachable",
81            Self::Found(v) => v.as_str(),
82        }
83    }
84}
85
86/// Classify OpenSSH failure text into auth vs unreachable (best-effort).
87#[must_use]
88pub fn classify_ssh_failure(detail: &str) -> RemoteBoxCliStatus {
89    let d = detail.to_ascii_lowercase();
90    if d.contains("permission denied")
91        || d.contains("authentication failed")
92        || d.contains("auth failed")
93        || d.contains("too many authentication")
94        || d.contains("no supported authentication")
95        || d.contains("connection closed by remote host")
96    {
97        return RemoteBoxCliStatus::AuthFailed;
98    }
99    RemoteBoxCliStatus::Unreachable
100}
101
102pub fn ssh_command_detail(err: &crate::error::HortoError) -> Option<&str> {
103    match err {
104        crate::error::HortoError::CommandFailed { program, detail }
105            if program == "ssh" || program == "scp" =>
106        {
107            Some(detail.as_str())
108        }
109        _ => None,
110    }
111}
112
113pub const fn probe_from_status(status: RemoteBoxCliStatus) -> RemoteCliProbe {
114    RemoteCliProbe {
115        path: None,
116        version: None,
117        current: false,
118        status,
119    }
120}
121
122pub fn probe_found(path: String, version: String, current: bool) -> RemoteCliProbe {
123    RemoteCliProbe {
124        path: Some(path),
125        version: Some(version.clone()),
126        current,
127        status: RemoteBoxCliStatus::Found(version),
128    }
129}
130
131/// Probe install-dir then agent-dir CLI versions over SSH (never SCP).
132///
133/// # Errors
134///
135/// Returns [`crate::HortoError`] only for unexpected non-SSH failures. Auth /
136/// unreachable / missing CLI are returned as [`Ok`] with [`RemoteCliProbe::status`].
137pub fn probe_remote_cli(
138    runner: &dyn ProcessRunner,
139    session: &SshSession,
140    opts: &RemoteOptions,
141) -> Result<RemoteCliProbe> {
142    remote_progress(&opts.host, "probe CLI version on box (SSH; no upload)");
143    let mut last_version = None;
144    let mut last_path = None;
145    let mut saw_remote_cmd = false;
146    for path in remote_cli_candidates(opts) {
147        let cmd = format!("test -x {bin} && {bin} --version", bin = shell_quote(&path));
148        match session.exec(runner, &cmd, StdioMode::Capture) {
149            Ok(out) => {
150                saw_remote_cmd = true;
151                let ver = normalize_cli_version(&out.stdout);
152                if ver.is_empty() {
153                    continue;
154                }
155                if remote_cli_version_is_current(&ver) {
156                    return Ok(probe_found(path, ver, true));
157                }
158                last_version = Some(ver);
159                last_path = Some(path);
160            }
161            Err(e) => {
162                if let Some(detail) = ssh_command_detail(&e) {
163                    // Remote `test -x` failure is typically `exit 1:…` (SSH reached the box).
164                    let low = detail.to_ascii_lowercase();
165                    if low.contains("exit 1") || low.contains("exit 127") {
166                        saw_remote_cmd = true;
167                        continue;
168                    }
169                    return Ok(probe_from_status(classify_ssh_failure(detail)));
170                }
171                return Ok(probe_from_status(RemoteBoxCliStatus::Unreachable));
172            }
173        }
174    }
175    if let (Some(path), Some(ver)) = (last_path, last_version) {
176        return Ok(probe_found(path, ver, false));
177    }
178    Ok(probe_from_status(if saw_remote_cmd {
179        RemoteBoxCliStatus::Missing
180    } else {
181        RemoteBoxCliStatus::Unreachable
182    }))
183}
184
185/// Setup status + doctor from the box without uploading (TUI `r`).
186#[derive(Debug, Clone)]
187pub struct RemoteBoxSnapshot {
188    /// Whether box CLI long-version matches this PC.
189    pub cli_current: bool,
190    /// Box CLI version line when a binary answered `--version`.
191    pub cli_version: Option<String>,
192    /// Operator-facing box CLI status after the probe.
193    pub cli_status: RemoteBoxCliStatus,
194    /// Pipeline step rows from the box (only when [`cli_current`](Self::cli_current)).
195    pub setup: Option<crate::ops::status::SetupStatusReport>,
196    /// Doctor JSON from the box (only when current).
197    pub doctor: Option<crate::ops::doctor::DoctorReport>,
198}
199
200/// Probe CLI version, then fetch setup status and doctor when current. Never SCP.
201///
202/// # Errors
203///
204/// Returns [`crate::HortoError`] when SSH fails in an unexpected way or remote
205/// output cannot be parsed. Auth / unreachable / missing CLI are [`Ok`] with
206/// [`RemoteBoxSnapshot::cli_status`] set accordingly.
207pub fn remote_box_snapshot(
208    runner: &dyn ProcessRunner,
209    opts: &RemoteOptions,
210    full: bool,
211) -> Result<RemoteBoxSnapshot> {
212    let session = session_from(opts)?;
213    let probe = probe_remote_cli(runner, &session, opts)?;
214    if !probe.current {
215        return Ok(RemoteBoxSnapshot {
216            cli_current: false,
217            cli_version: probe.version,
218            cli_status: probe.status,
219            setup: None,
220            doctor: None,
221        });
222    }
223    // `probe_remote_cli` always sets `path` when `current`; keep a safe fallback.
224    let remote_bin = probe.path.unwrap_or_else(|| remote_install_bin(opts));
225
226    let kind = if full { "--full" } else { "--minimal" };
227    let json_log = exec_remote_cli_captured(
228        runner,
229        &session,
230        &remote_bin,
231        opts,
232        &[
233            "setup".into(),
234            "status".into(),
235            kind.into(),
236            "--json".into(),
237        ],
238    );
239    let setup = if let Some(report) = json_log.ok().and_then(|log| parse_remote_json(&log).ok()) {
240        report
241    } else {
242        let text_log = exec_remote_cli_captured(
243            runner,
244            &session,
245            &remote_bin,
246            opts,
247            &["setup".into(), "status".into(), kind.into()],
248        )?;
249        parse_setup_status_text(&text_log)?
250    };
251
252    let doctor_log =
253        exec_remote_cli_captured(runner, &session, &remote_bin, opts, &["doctor".into()])?;
254    let doctor = parse_remote_json(&doctor_log)?;
255    Ok(RemoteBoxSnapshot {
256        cli_current: true,
257        cli_version: probe.version.clone(),
258        cli_status: probe.status,
259        setup: Some(setup),
260        doctor: Some(doctor),
261    })
262}