horto_os_ui_shared/remote/runner/
options.rs1use super::super::ecosystem::{EcosystemInstallChoice, DEFAULT_INSTALL_DIR as ECO_INSTALL_DIR};
4use super::super::host::parse_host_spec;
5use super::super::process::{ProcessRunner, StdioMode};
6use super::super::ssh::{SshEnv, SshSession};
7use crate::error::Result;
8use crate::VERSION;
9use std::path::PathBuf;
10
11pub const DEFAULT_GITHUB_REPO: &str = "Hortos-Network/horto-os-ui";
13
14pub const DEFAULT_REMOTE_AGENT_DIR: &str = "/tmp/horto-os-ui-remote";
16
17pub const DEFAULT_INSTALL_DIR: &str = ECO_INSTALL_DIR;
19
20#[derive(Debug, Clone)]
22pub struct RemoteOptions {
23 pub host: String,
25 pub install_ssh_key: bool,
27 pub bin_dir: Option<PathBuf>,
29 pub version: String,
31 pub release_tag: String,
33 pub github_repo: String,
35 pub force_askpass: bool,
37 pub cache_root: PathBuf,
39 pub remote_agent_dir: String,
41 pub install_dir: String,
43 pub ssh_config_file: Option<PathBuf>,
45}
46
47impl Default for RemoteOptions {
48 fn default() -> Self {
49 let version = VERSION.to_owned();
50 let release_tag = super::super::bins::default_release_tag(&version);
51 Self {
52 host: String::new(),
53 install_ssh_key: false,
54 bin_dir: None,
55 version,
56 release_tag,
57 github_repo: DEFAULT_GITHUB_REPO.to_owned(),
58 force_askpass: false,
59 cache_root: super::super::bins::default_cache_root(),
60 remote_agent_dir: DEFAULT_REMOTE_AGENT_DIR.to_owned(),
61 install_dir: DEFAULT_INSTALL_DIR.to_owned(),
62 ssh_config_file: None,
63 }
64 }
65}
66
67#[derive(Debug, Clone, Default)]
69pub struct RemoteOptionsInput {
70 pub host: String,
72 pub install_ssh_key: bool,
74 pub bin_dir: Option<PathBuf>,
76 pub release_tag: Option<String>,
78 pub force_askpass: bool,
80}
81
82impl RemoteOptions {
83 #[must_use]
85 pub fn from_input(input: RemoteOptionsInput) -> Self {
86 let mut opts = Self {
87 host: input.host,
88 install_ssh_key: input.install_ssh_key,
89 bin_dir: input.bin_dir,
90 force_askpass: input.force_askpass,
91 ..Self::default()
92 };
93 if let Some(tag) = input
94 .release_tag
95 .as_ref()
96 .map(|t| t.trim())
97 .filter(|t| !t.is_empty())
98 {
99 tag.clone_into(&mut opts.release_tag);
100 }
101 opts
102 }
103}
104
105#[allow(clippy::struct_excessive_bools)]
107#[derive(Debug, Clone, Copy, Default)]
108pub struct RemoteRunFlags {
109 pub use_sudo: bool,
111 pub install_payload_on_success: bool,
113 pub offer_reboot_on_success: bool,
115 pub capture_output: bool,
120}
121
122#[derive(Debug, Clone)]
124pub struct RemoteRunRequest {
125 pub options: RemoteOptions,
127 pub cli_args: Vec<String>,
129 pub flags: RemoteRunFlags,
131 pub ecosystem: EcosystemInstallChoice,
133}
134
135impl std::ops::Deref for RemoteRunRequest {
136 type Target = RemoteRunFlags;
137
138 fn deref(&self) -> &Self::Target {
139 &self.flags
140 }
141}
142
143pub fn session_from(opts: &RemoteOptions) -> Result<SshSession> {
144 Ok(SshSession {
145 host: parse_host_spec(&opts.host)?,
146 env: SshEnv {
147 force_askpass: opts.force_askpass,
148 },
149 config_file: opts.ssh_config_file.clone(),
150 })
151}
152
153pub fn shell_quote(arg: &str) -> String {
154 if arg.is_empty() {
155 return "''".to_owned();
156 }
157 if arg
158 .chars()
159 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/' | '=' | ':' | '+'))
160 {
161 return arg.to_owned();
162 }
163 format!("'{}'", arg.replace('\'', "'\\''"))
164}
165
166#[must_use]
168pub fn remote_progress_message(host: &str, detail: &str) -> String {
169 format!("[horto remote] PC → box '{host}': {detail}")
170}
171
172#[must_use]
174pub fn remote_run_banner_detail(remote_cmd: &str, use_sudo: bool) -> String {
175 if use_sudo {
176 format!("run `{remote_cmd}` (SSH + sudo; may ask password)")
177 } else {
178 format!("run `{remote_cmd}` (SSH; may ask password)")
179 }
180}
181
182pub fn remote_progress(host: &str, detail: &str) {
183 tracing::info!("{}", remote_progress_message(host, detail));
184}
185
186pub fn remote_doctor_report_banner() {
188 tracing::info!("[horto remote] doctor report from box (JSON):");
189}
190
191pub fn remote_agent_bin(opts: &RemoteOptions) -> String {
192 format!(
193 "{}/horto-os-ui",
194 opts.remote_agent_dir.trim_end_matches('/')
195 )
196}
197
198pub fn remote_install_bin(opts: &RemoteOptions) -> String {
199 format!("{}/horto-os-ui", opts.install_dir.trim_end_matches('/'))
200}
201
202pub fn remote_cli_candidates(opts: &RemoteOptions) -> [String; 2] {
203 [remote_install_bin(opts), remote_agent_bin(opts)]
204}
205
206pub fn build_remote_command_at(bin: &str, cli_args: &[String], use_sudo: bool) -> String {
207 let mut parts = Vec::new();
208 if use_sudo {
209 parts.push("sudo".to_owned());
210 }
211 parts.push(shell_quote(bin));
212 for a in cli_args {
213 parts.push(shell_quote(a));
214 }
215 parts.join(" ")
216}
217
218pub fn merge_command_log(out: &super::super::process::CommandOutput, remote_cmd: &str) -> String {
219 let mut log = out.stdout.clone();
220 if !out.stderr.trim().is_empty() {
221 if !log.is_empty() {
222 log.push('\n');
223 }
224 log.push_str(&out.stderr);
225 }
226 if log.trim().is_empty() {
227 format!("remote command finished: {remote_cmd}")
228 } else {
229 log
230 }
231}
232
233pub fn exec_remote_cli_captured(
235 runner: &dyn ProcessRunner,
236 session: &SshSession,
237 remote_bin: &str,
238 opts: &RemoteOptions,
239 cli_args: &[String],
240) -> Result<String> {
241 let remote_cmd = build_remote_command_at(remote_bin, cli_args, false);
242 remote_progress(&opts.host, &remote_run_banner_detail(&remote_cmd, false));
243 let out = session.exec(runner, &remote_cmd, StdioMode::Capture)?;
244 Ok(merge_command_log(&out, &remote_cmd))
245}