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::{finish_batch, SshCliError};
16use crate::output;
17use crate::ssh::client::{SshClient, SshClientTrait};
18use anyhow::Result;
19use secrecy::SecretString;
20use std::path::PathBuf;
21
22/// Everything one `health-check` invocation needs.
23///
24/// # Why a struct (B3)
25///
26/// The single-host and fan-out entry points each took nine positional
27/// parameters, four of them `Option<String>` / `Option<SecretString>` in a row.
28/// A transposed key path and passphrase compiles and only fails against a live
29/// host. `too_many_arguments` — the one lint that measures this — was suppressed
30/// on both, so the coupling never showed up in a green gate.
31pub struct HealthCheckRequest {
32    /// Single host, explicit list, tag set or the whole registry.
33    pub selection: HostSelection,
34    /// Alternate config directory.
35    pub config_override: Option<PathBuf>,
36    /// Global output format.
37    pub format: OutputFormat,
38    /// Subcommand-local `--json`.
39    pub json_local: bool,
40    /// SSH password override.
41    pub password_override: Option<SecretString>,
42    /// Connect timeout override.
43    pub timeout_override: Option<crate::domain::TimeoutMs>,
44    /// Private key path override.
45    pub key_override: Option<String>,
46    /// Key passphrase override.
47    pub key_passphrase_override: Option<SecretString>,
48    /// Replace a diverging host key in TOFU `known_hosts`.
49    pub replace_host_key: bool,
50}
51
52/// Health-check SSH (single host or multi-host bounded fan-out).
53///
54/// Workload: **I/O-bound** connect probe. One-shot auth parity (GAP-SSH-CLI-006)
55/// and TOFU (M1). Multi-host saturates sockets/auth — gated by concurrency budget.
56/// Batch JSON when [`HostSelection::is_batch`] (G-PAR-36).
57pub 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    // M2: local --json or global format → JSON error envelope on failure.
70    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        // G-SEC-08: fail closed instead of panic on invariant slip.
94        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    // GAP-SSH-CLI-004: --timeout; GAP-SSH-CLI-006: key + passphrase.
108    // Ordem: password, sudo, su, timeout, key_path, key_passphrase.
109    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    // M1: honra --replace-host-key global (paridade exec/scp/tunnel).
120    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
134/// Collect multi-host health results without printing (doctor envelope + health-check).
135pub(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
143/// Parallel health-check fan-out (I/O-bound, map_bounded); returns results + limit.
144async 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
238/// Parallel health-check for `--all` / `--hosts` (I/O-bound, map_bounded).
239async 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/// Per-host health-check outcome for batch output.
270#[derive(Debug, Clone)]
271pub struct HostHealthResult {
272    /// VPS name.
273    pub name: String,
274    /// Whether connect+auth succeeded.
275    pub ok: bool,
276    /// Latency when measured.
277    pub latency_ms: Option<u64>,
278    /// Error text when not ok.
279    pub error: Option<String>,
280}