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(selection, config_override, None, None, None, None, false)
111        .await
112}
113
114/// Parallel health-check fan-out (I/O-bound, map_bounded); returns results + limit.
115#[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/// Parallel health-check for `--all` / `--hosts` (I/O-bound, map_bounded).
202#[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/// Per-host health-check outcome for batch output.
239#[derive(Debug, Clone)]
240pub struct HostHealthResult {
241    /// VPS name.
242    pub name: String,
243    /// Whether connect+auth succeeded.
244    pub ok: bool,
245    /// Latency when measured.
246    pub latency_ms: Option<u64>,
247    /// Error text when not ok.
248    pub error: Option<String>,
249}