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