Skip to main content

ssh_cli/vps/
health.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// G-COMP-04: health-check fan-out extracted from `vps/mod` (SRP + parallelism).
3#![forbid(unsafe_code)]
4//! SSH health-check (single host + multi-host bounded fan-out).
5//!
6//! Workload: **I/O-bound** connect probe. Multi-host uses
7//! [`crate::concurrency::map_bounded`] (Semaphore + JoinSet). Doctor reuses
8//! [`collect_health_check_batch`] for `--probe-ssh`.
9
10use 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/// Health-check SSH (single host or multi-host bounded fan-out).
23///
24/// Workload: **I/O-bound** connect probe. One-shot auth parity (GAP-SSH-CLI-006)
25/// and TOFU (M1). Multi-host saturates sockets/auth — gated by concurrency budget.
26/// Batch JSON when [`HostSelection::is_batch`] (G-PAR-36).
27#[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    // M2: local --json or global format → JSON error envelope on failure.
40    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        // G-SEC-08: fail closed instead of panic on invariant slip.
64        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    // GAP-SSH-CLI-004: --timeout; GAP-SSH-CLI-006: key + passphrase.
78    // Ordem: password, sudo, su, timeout, key_path, key_passphrase.
79    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    // M1: honra --replace-host-key global (paridade exec/scp/tunnel).
91    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
105/// Collect multi-host health results without printing (doctor envelope + health-check).
106pub(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/// Parallel health-check fan-out (I/O-bound, map_bounded); returns results + limit.
123#[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/// Parallel health-check for `--all` / `--hosts` (I/O-bound, map_bounded).
211#[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/// Per-host health-check outcome for batch output.
248#[derive(Debug, Clone)]
249pub struct HostHealthResult {
250    /// VPS name.
251    pub name: String,
252    /// Whether connect+auth succeeded.
253    pub ok: bool,
254    /// Latency when measured.
255    pub latency_ms: Option<u64>,
256    /// Error text when not ok.
257    pub error: Option<String>,
258}
259