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    /// How the checked host was designated.
51    ///
52    /// Since the `--use-active` opt-in landed, a nameless `health-check` is a usage
53    /// error rather than a silent fallback. Provenance is still reported: it is what
54    /// separates "I checked the host you named" from "I checked whatever `connect`
55    /// last wrote", and only the field distinguishes those after the run.
56    pub host_source: crate::json_wire::TargetSource,
57}
58
59/// Health-check SSH (single host or multi-host bounded fan-out).
60///
61/// Workload: **I/O-bound** connect probe. One-shot auth parity (GAP-SSH-CLI-006)
62/// and TOFU (M1). Multi-host saturates sockets/auth — gated by concurrency budget.
63/// Batch JSON when [`HostSelection::is_batch`] (G-PAR-36).
64pub 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    // M2: local --json or global format → JSON error envelope on failure.
78    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        // G-SEC-08: fail closed instead of panic on invariant slip.
101        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    // GAP-SSH-CLI-004: --timeout; GAP-SSH-CLI-006: key + passphrase.
115    // Ordem: password, sudo, su, timeout, key_path, key_passphrase.
116    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    // M1: honra --replace-host-key global (paridade exec/scp/tunnel).
127    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
141/// Collect multi-host health results without printing (doctor envelope + health-check).
142pub(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
150/// Parallel health-check fan-out (I/O-bound, map_bounded); returns results + limit.
151async 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
245/// Parallel health-check for `--all` / `--hosts` (I/O-bound, map_bounded).
246async 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        // Deliberately unused: the batch envelope already names every host per
258        // entry, and a fan-out can only have come from a selector.
259        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/// Per-host health-check outcome for batch output.
280#[derive(Debug, Clone)]
281pub struct HostHealthResult {
282    /// VPS name.
283    pub name: String,
284    /// Whether connect+auth succeeded.
285    pub ok: bool,
286    /// Latency when measured.
287    pub latency_ms: Option<u64>,
288    /// Error text when not ok.
289    pub error: Option<String>,
290}