horto_os_ui_shared/remote/runner/
probe.rs1use 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#[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#[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
34pub 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#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct RemoteCliProbe {
49 pub path: Option<String>,
51 pub version: Option<String>,
53 pub current: bool,
55 pub status: RemoteBoxCliStatus,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub enum RemoteBoxCliStatus {
63 Missing,
65 AuthFailed,
67 Unreachable,
69 Found(String),
71}
72
73impl RemoteBoxCliStatus {
74 #[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#[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
131pub 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 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#[derive(Debug, Clone)]
187pub struct RemoteBoxSnapshot {
188 pub cli_current: bool,
190 pub cli_version: Option<String>,
192 pub cli_status: RemoteBoxCliStatus,
194 pub setup: Option<crate::ops::status::SetupStatusReport>,
196 pub doctor: Option<crate::ops::doctor::DoctorReport>,
198}
199
200pub 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 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}