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::{finish_batch, SshCliError};
16use crate::output;
17use crate::ssh::client::{SshClient, SshClientTrait};
18use anyhow::Result;
19use secrecy::SecretString;
20use std::path::PathBuf;
21
22pub struct HealthCheckRequest {
32 pub selection: HostSelection,
34 pub config_override: Option<PathBuf>,
36 pub format: OutputFormat,
38 pub json_local: bool,
40 pub password_override: Option<SecretString>,
42 pub timeout_override: Option<crate::domain::TimeoutMs>,
44 pub key_override: Option<String>,
46 pub key_passphrase_override: Option<SecretString>,
48 pub replace_host_key: bool,
50 pub host_source: crate::json_wire::TargetSource,
57}
58
59pub async fn run_health_check(req: HealthCheckRequest) -> Result<()> {
65 let HealthCheckRequest {
66 selection,
67 config_override,
68 format,
69 json_local,
70 password_override,
71 timeout_override,
72 key_override,
73 key_passphrase_override,
74 replace_host_key,
75 host_source,
76 } = req;
77 if json_local || format == OutputFormat::Json {
79 crate::output::set_json_errors(true);
80 }
81 if crate::signals::should_stop() {
82 return Err(anyhow::anyhow!(crate::constants::OPERATION_CANCELLED_MSG));
83 }
84 if selection.is_batch() {
85 return run_health_check_all(HealthCheckRequest {
86 selection,
87 config_override,
88 format,
89 json_local,
90 password_override,
91 timeout_override,
92 key_override,
93 key_passphrase_override,
94 replace_host_key,
95 host_source,
96 })
97 .await;
98 }
99 let HostSelection::Single(resolved_name) = selection else {
100 return Err(SshCliError::InvalidArgument(
102 "internal: expected single-host selection for non-batch health-check".into(),
103 )
104 .into());
105 };
106 let resolved_key = resolved_name.as_str().to_owned();
107 let path = resolve_config_path(config_override.as_deref())?;
108 let mut file = load(&path)?;
109 let mut vps = file
110 .hosts
111 .remove(&resolved_key)
112 .ok_or_else(|| SshCliError::VpsNotFound(resolved_key.clone()))?;
113
114 apply_overrides(
117 &mut vps,
118 crate::vps::AuthOverrides {
119 password: password_override,
120 timeout: timeout_override,
121 key_path: key_override,
122 key_passphrase: key_passphrase_override,
123 ..Default::default()
124 },
125 );
126 let cfg = build_connection_config(&vps, Some(&path), replace_host_key);
128 let start = std::time::Instant::now();
129 let client: Box<dyn SshClientTrait> = <SshClient as SshClientTrait>::connect(cfg).await?;
130 let latency_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
131 client.disconnect().await?;
132
133 if use_json(json_local, format) {
134 output::print_health_check_json(&resolved_key, latency_ms, host_source)?;
135 } else {
136 output::print_health_check(&resolved_key, latency_ms);
137 }
138 Ok(())
139}
140
141pub(super) async fn collect_health_check_batch(
143 selection: &HostSelection,
144 config_override: Option<PathBuf>,
145) -> Result<(Vec<HostHealthResult>, usize)> {
146 collect_health_check_batch_with_opts(selection, config_override, None, None, None, None, false)
147 .await
148}
149
150async fn collect_health_check_batch_with_opts(
152 selection: &HostSelection,
153 config_override: Option<PathBuf>,
154 password_override: Option<SecretString>,
155 timeout_override: Option<crate::domain::TimeoutMs>,
156 key_override: Option<String>,
157 key_passphrase_override: Option<SecretString>,
158 replace_host_key: bool,
159) -> Result<(Vec<HostHealthResult>, usize)> {
160 let path = resolve_config_path(config_override.as_deref())?;
161 let file = load(&path)?;
162 let jobs = resolve_host_jobs(selection, &file)?;
163 let limit = crate::concurrency::effective_limit();
164 let path_c = path.clone();
165
166 tracing::info!(
167 hosts = jobs.len(),
168 max_concurrency = limit,
169 "multi-host health-check fan-out"
170 );
171
172 let pw = password_override;
173 let to = timeout_override;
174 let key = key_override;
175 let kp = key_passphrase_override;
176
177 let results = crate::concurrency::map_bounded(jobs, limit, move |(name, mut vps)| {
178 let path_c = path_c.clone();
179 let pw = pw.clone();
180 let key = key.clone();
181 let kp = kp.clone();
182 async move {
183 if crate::signals::should_stop() {
184 return HostHealthResult {
185 name,
186 ok: false,
187 latency_ms: None,
188 error: Some("operation cancelled by signal".into()),
189 };
190 }
191 apply_overrides(
192 &mut vps,
193 crate::vps::AuthOverrides {
194 password: pw,
195 timeout: to,
196 key_path: key,
197 key_passphrase: kp,
198 ..Default::default()
199 },
200 );
201 let start = std::time::Instant::now();
202 let cfg = build_connection_config(&vps, Some(&path_c), replace_host_key);
203 match <SshClient as SshClientTrait>::connect(cfg).await {
204 Ok(client) => {
205 let latency_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
206 let _ = client.disconnect().await;
207 HostHealthResult {
208 name,
209 ok: true,
210 latency_ms: Some(latency_ms),
211 error: None,
212 }
213 }
214 Err(e) => HostHealthResult {
215 name,
216 ok: false,
217 latency_ms: Some(
218 u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
219 ),
220 error: Some(e.to_string()),
221 },
222 }
223 }
224 })
225 .await;
226
227 let mut host_results = Vec::with_capacity(results.len());
228 for r in results {
229 match r.outcome {
230 Ok(h) => host_results.push(h),
231 Err(e) if e.is_panic() => std::panic::resume_unwind(e.into_panic()),
232 Err(e) => {
233 host_results.push(HostHealthResult {
234 name: format!("task-{}", r.index),
235 ok: false,
236 latency_ms: None,
237 error: Some(e.to_string()),
238 });
239 }
240 }
241 }
242 Ok((host_results, limit))
243}
244
245async fn run_health_check_all(req: HealthCheckRequest) -> Result<()> {
247 let HealthCheckRequest {
248 selection,
249 config_override,
250 format,
251 json_local,
252 password_override,
253 timeout_override,
254 key_override,
255 key_passphrase_override,
256 replace_host_key,
257 host_source: _,
260 } = req;
261 let (host_results, limit) = collect_health_check_batch_with_opts(
262 &selection,
263 config_override,
264 password_override,
265 timeout_override,
266 key_override,
267 key_passphrase_override,
268 replace_host_key,
269 )
270 .await?;
271
272 let failures = host_results.iter().filter(|h| !h.ok).count();
273 let as_json = use_json(json_local, format);
274 output::print_health_batch(&host_results, limit, as_json)?;
275 finish_batch(failures, host_results.len(), "health-check")?;
276 Ok(())
277}
278
279#[derive(Debug, Clone)]
281pub struct HostHealthResult {
282 pub name: String,
284 pub ok: bool,
286 pub latency_ms: Option<u64>,
288 pub error: Option<String>,
290}