1#![forbid(unsafe_code)]
4use super::{
11 apply_overrides, build_connection_config, load, resolve_config_path, resolve_host_jobs,
12 use_json, HostSelection,
13};
14use crate::cli::OutputFormat;
15use crate::errors::SshCliError;
16use crate::output;
17use crate::ssh::client::{SshClient, SshClientTrait};
18use anyhow::Result;
19use secrecy::SecretString;
20use std::path::PathBuf;
21
22#[allow(clippy::too_many_arguments)]
28pub async fn run_health_check(
29 selection: HostSelection,
30 config_override: Option<PathBuf>,
31 format: OutputFormat,
32 json_local: bool,
33 password_override: Option<SecretString>,
34 timeout_override: Option<crate::domain::TimeoutMs>,
35 key_override: Option<String>,
36 key_passphrase_override: Option<SecretString>,
37 replace_host_key: bool,
38) -> Result<()> {
39 if json_local || format == OutputFormat::Json {
41 crate::output::set_json_errors(true);
42 }
43 if crate::signals::should_stop() {
44 return Err(anyhow::anyhow!(crate::i18n::t(
45 crate::i18n::Message::OperationCancelled
46 )));
47 }
48 if selection.is_batch() {
49 return run_health_check_all(
50 &selection,
51 config_override,
52 format,
53 json_local,
54 password_override,
55 timeout_override,
56 key_override,
57 key_passphrase_override,
58 replace_host_key,
59 )
60 .await;
61 }
62 let HostSelection::Single(resolved_name) = selection else {
63 return Err(SshCliError::InvalidArgument(
65 "internal: expected single-host selection for non-batch health-check".into(),
66 )
67 .into());
68 };
69 let resolved_key = resolved_name.as_str().to_owned();
70 let path = resolve_config_path(config_override.as_deref())?;
71 let mut file = load(&path)?;
72 let mut vps = file
73 .hosts
74 .remove(&resolved_key)
75 .ok_or_else(|| SshCliError::VpsNotFound(resolved_key.clone()))?;
76
77 apply_overrides(
80 &mut vps,
81 password_override,
82 None,
83 None,
84 timeout_override,
85 key_override,
86 key_passphrase_override,
87 false,
88 None,
89 );
90 let cfg = build_connection_config(&vps, Some(&path), replace_host_key);
92 let start = std::time::Instant::now();
93 let client: Box<dyn SshClientTrait> = <SshClient as SshClientTrait>::connect(cfg).await?;
94 let latency_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
95 client.disconnect().await?;
96
97 if use_json(json_local, format) {
98 output::print_health_check_json(&resolved_key, latency_ms)?;
99 } else {
100 output::print_health_check(&resolved_key, latency_ms);
101 }
102 Ok(())
103}
104
105pub(super) async fn collect_health_check_batch(
107 selection: &HostSelection,
108 config_override: Option<PathBuf>,
109) -> Result<(Vec<HostHealthResult>, usize)> {
110 collect_health_check_batch_with_opts(selection, config_override, None, None, None, None, false)
111 .await
112}
113
114#[allow(clippy::too_many_arguments)]
116async fn collect_health_check_batch_with_opts(
117 selection: &HostSelection,
118 config_override: Option<PathBuf>,
119 password_override: Option<SecretString>,
120 timeout_override: Option<crate::domain::TimeoutMs>,
121 key_override: Option<String>,
122 key_passphrase_override: Option<SecretString>,
123 replace_host_key: bool,
124) -> Result<(Vec<HostHealthResult>, usize)> {
125 let path = resolve_config_path(config_override.as_deref())?;
126 let file = load(&path)?;
127 let jobs = resolve_host_jobs(selection, &file)?;
128 let limit = crate::concurrency::effective_limit();
129 let path_c = path.clone();
130
131 tracing::info!(
132 hosts = jobs.len(),
133 max_concurrency = limit,
134 "multi-host health-check fan-out"
135 );
136
137 let pw = password_override;
138 let to = timeout_override;
139 let key = key_override;
140 let kp = key_passphrase_override;
141
142 let results = crate::concurrency::map_bounded(jobs, limit, move |(name, mut vps)| {
143 let path_c = path_c.clone();
144 let pw = pw.clone();
145 let key = key.clone();
146 let kp = kp.clone();
147 async move {
148 if crate::signals::should_stop() {
149 return HostHealthResult {
150 name,
151 ok: false,
152 latency_ms: None,
153 error: Some("operation cancelled by signal".into()),
154 };
155 }
156 apply_overrides(&mut vps, pw, None, None, to, key, kp, false, None);
157 let start = std::time::Instant::now();
158 let cfg = build_connection_config(&vps, Some(&path_c), replace_host_key);
159 match <SshClient as SshClientTrait>::connect(cfg).await {
160 Ok(client) => {
161 let latency_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
162 let _ = client.disconnect().await;
163 HostHealthResult {
164 name,
165 ok: true,
166 latency_ms: Some(latency_ms),
167 error: None,
168 }
169 }
170 Err(e) => HostHealthResult {
171 name,
172 ok: false,
173 latency_ms: Some(
174 u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
175 ),
176 error: Some(e.to_string()),
177 },
178 }
179 }
180 })
181 .await;
182
183 let mut host_results = Vec::with_capacity(results.len());
184 for r in results {
185 match r.outcome {
186 Ok(h) => host_results.push(h),
187 Err(e) if e.is_panic() => std::panic::resume_unwind(e.into_panic()),
188 Err(e) => {
189 host_results.push(HostHealthResult {
190 name: format!("task-{}", r.index),
191 ok: false,
192 latency_ms: None,
193 error: Some(e.to_string()),
194 });
195 }
196 }
197 }
198 Ok((host_results, limit))
199}
200
201#[allow(clippy::too_many_arguments)]
203async fn run_health_check_all(
204 selection: &HostSelection,
205 config_override: Option<PathBuf>,
206 format: OutputFormat,
207 json_local: bool,
208 password_override: Option<SecretString>,
209 timeout_override: Option<crate::domain::TimeoutMs>,
210 key_override: Option<String>,
211 key_passphrase_override: Option<SecretString>,
212 replace_host_key: bool,
213) -> Result<()> {
214 let (host_results, limit) = collect_health_check_batch_with_opts(
215 selection,
216 config_override,
217 password_override,
218 timeout_override,
219 key_override,
220 key_passphrase_override,
221 replace_host_key,
222 )
223 .await?;
224
225 let failures = host_results.iter().filter(|h| !h.ok).count();
226 let as_json = use_json(json_local, format);
227 output::print_health_batch(&host_results, limit, as_json)?;
228 if failures > 0 {
229 return Err(SshCliError::Config(format!(
230 "{failures}/{} hosts failed health-check",
231 host_results.len()
232 ))
233 .into());
234 }
235 Ok(())
236}
237
238#[derive(Debug, Clone)]
240pub struct HostHealthResult {
241 pub name: String,
243 pub ok: bool,
245 pub latency_ms: Option<u64>,
247 pub error: Option<String>,
249}