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(
111 selection,
112 config_override,
113 None,
114 None,
115 None,
116 None,
117 false,
118 )
119 .await
120}
121
122#[allow(clippy::too_many_arguments)]
124async fn collect_health_check_batch_with_opts(
125 selection: &HostSelection,
126 config_override: Option<PathBuf>,
127 password_override: Option<SecretString>,
128 timeout_override: Option<crate::domain::TimeoutMs>,
129 key_override: Option<String>,
130 key_passphrase_override: Option<SecretString>,
131 replace_host_key: bool,
132) -> Result<(Vec<HostHealthResult>, usize)> {
133 let path = resolve_config_path(config_override.as_deref())?;
134 let file = load(&path)?;
135 let jobs = resolve_host_jobs(selection, &file)?;
136 let limit = crate::concurrency::effective_limit();
137 let path_c = path.clone();
138
139 tracing::info!(
140 hosts = jobs.len(),
141 max_concurrency = limit,
142 "multi-host health-check fan-out"
143 );
144
145 let pw = password_override;
146 let to = timeout_override;
147 let key = key_override;
148 let kp = key_passphrase_override;
149
150 let results = crate::concurrency::map_bounded(jobs, limit, move |(name, mut vps)| {
151 let path_c = path_c.clone();
152 let pw = pw.clone();
153 let key = key.clone();
154 let kp = kp.clone();
155 async move {
156 if crate::signals::should_stop() {
157 return HostHealthResult {
158 name,
159 ok: false,
160 latency_ms: None,
161 error: Some("operation cancelled by signal".into()),
162 };
163 }
164 apply_overrides(&mut vps, pw, None, None, to, key, kp, false, None);
165 let start = std::time::Instant::now();
166 let cfg = build_connection_config(&vps, Some(&path_c), replace_host_key);
167 match <SshClient as SshClientTrait>::connect(cfg).await {
168 Ok(client) => {
169 let latency_ms =
170 u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
171 let _ = client.disconnect().await;
172 HostHealthResult {
173 name,
174 ok: true,
175 latency_ms: Some(latency_ms),
176 error: None,
177 }
178 }
179 Err(e) => HostHealthResult {
180 name,
181 ok: false,
182 latency_ms: Some(
183 u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
184 ),
185 error: Some(e.to_string()),
186 },
187 }
188 }
189 })
190 .await;
191
192 let mut host_results = Vec::with_capacity(results.len());
193 for r in results {
194 match r.outcome {
195 Ok(h) => host_results.push(h),
196 Err(e) if e.is_panic() => std::panic::resume_unwind(e.into_panic()),
197 Err(e) => {
198 host_results.push(HostHealthResult {
199 name: format!("task-{}", r.index),
200 ok: false,
201 latency_ms: None,
202 error: Some(e.to_string()),
203 });
204 }
205 }
206 }
207 Ok((host_results, limit))
208}
209
210#[allow(clippy::too_many_arguments)]
212async fn run_health_check_all(
213 selection: &HostSelection,
214 config_override: Option<PathBuf>,
215 format: OutputFormat,
216 json_local: bool,
217 password_override: Option<SecretString>,
218 timeout_override: Option<crate::domain::TimeoutMs>,
219 key_override: Option<String>,
220 key_passphrase_override: Option<SecretString>,
221 replace_host_key: bool,
222) -> Result<()> {
223 let (host_results, limit) = collect_health_check_batch_with_opts(
224 selection,
225 config_override,
226 password_override,
227 timeout_override,
228 key_override,
229 key_passphrase_override,
230 replace_host_key,
231 )
232 .await?;
233
234 let failures = host_results.iter().filter(|h| !h.ok).count();
235 let as_json = use_json(json_local, format);
236 output::print_health_batch(&host_results, limit, as_json)?;
237 if failures > 0 {
238 return Err(SshCliError::Config(format!(
239 "{failures}/{} hosts failed health-check",
240 host_results.len()
241 ))
242 .into());
243 }
244 Ok(())
245}
246
247#[derive(Debug, Clone)]
249pub struct HostHealthResult {
250 pub name: String,
252 pub ok: bool,
254 pub latency_ms: Option<u64>,
256 pub error: Option<String>,
258}
259