Skip to main content

faucet_cli/commands/
doctor.rs

1//! `faucet doctor` — preflight probes for every connector in a config (#126).
2//!
3//! Expands the config, builds each **root** invocation's source / sink / state
4//! store, and runs their non-mutating `check()` probes concurrently (bounded by
5//! a semaphore, each wrapped in a per-probe timeout). Prints a green/red
6//! checklist (or `--json`) and exits with the number of failed probes
7//! (clamped to 255).
8//!
9//! Child invocations are listed but not probed: their configs depend on parent
10//! records that only exist at run time (same limitation as `faucet preview`).
11
12use crate::auth_catalog::{AuthCatalog, build_auth_catalog};
13use crate::cli::DoctorArgs;
14use crate::config::{ConnectorSpec, PipelineConfig, StateStoreSpec};
15use crate::error::{CliError, CliResult};
16use crate::expand::{ExpandedNode, NodeRole, expand};
17use crate::registry::{build_sink, build_source};
18use crate::secrets::registry::redact;
19use crate::state::build_state_store;
20use faucet_core::check::{CheckContext, CheckReport, Probe, ProbeStatus};
21use serde::Serialize;
22use std::path::Path;
23use std::sync::Arc;
24use std::time::{Duration, Instant};
25use tokio::sync::Semaphore;
26
27/// A single probe enriched with the role + connector it came from. This is the
28/// `--json` shape for each probe.
29#[derive(Debug, Serialize)]
30pub struct ProbeOut {
31    pub role: &'static str,
32    pub connector: String,
33    pub name: &'static str,
34    #[serde(flatten)]
35    pub status: ProbeStatus,
36    pub elapsed_ms: u64,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub hint: Option<String>,
39}
40
41impl ProbeOut {
42    fn from_probe(role: &'static str, connector: String, p: Probe) -> Self {
43        Self {
44            role,
45            connector,
46            name: p.name,
47            status: p.status,
48            elapsed_ms: p.elapsed_ms,
49            hint: p.hint,
50        }
51    }
52}
53
54/// One expanded invocation and its probes (the `--json` per-invocation shape).
55#[derive(Debug, Serialize)]
56pub struct InvocationOut {
57    pub id: String,
58    pub probes: Vec<ProbeOut>,
59    // Connector kinds for the human header; not part of the JSON contract.
60    #[serde(skip)]
61    pub source_kind: String,
62    #[serde(skip)]
63    pub sink_kind: String,
64}
65
66/// Execute the `doctor` subcommand.
67pub async fn run(args: DoctorArgs) -> CliResult<()> {
68    let overall = Instant::now();
69    let cwd = std::env::current_dir()?;
70    let env_path =
71        crate::env_loader::resolve_env_file(args.env_file.as_deref(), args.no_env_file, &cwd)?;
72    crate::env_loader::load_env_file_if_present(env_path.as_deref())?;
73    let path = match args.config {
74        Some(p) => p,
75        None => crate::env_loader::discover_config_path(&cwd).ok_or(CliError::NoConfigOrFromEnv)?,
76    };
77
78    let t_cfg = Instant::now();
79    let cfg = PipelineConfig::from_path_async(&path).await?;
80    let cfg_ms = t_cfg.elapsed().as_millis();
81
82    let nodes = expand(&cfg)?;
83    let auth = build_auth_catalog(cfg.auth.as_ref())?;
84    let ctx = CheckContext {
85        timeout: Duration::from_secs(args.timeout_secs),
86    };
87
88    let roots: Vec<&ExpandedNode> = nodes
89        .iter()
90        .filter(|n| matches!(n.role, NodeRole::Root))
91        .collect();
92    let n_children = nodes.len() - roots.len();
93
94    let mut invocations = probe_roots(&nodes, &auth, &ctx).await;
95
96    redact_invocations(&mut invocations);
97    let (_passed, failed, _skipped) = tally(&invocations);
98
99    if args.json {
100        let v = build_json(&path, overall.elapsed().as_millis(), &invocations);
101        println!(
102            "{}",
103            serde_json::to_string_pretty(&v).expect("doctor json serializes")
104        );
105    } else {
106        render_human(
107            cfg_ms,
108            roots.len(),
109            n_children,
110            overall.elapsed(),
111            &invocations,
112        );
113    }
114
115    if failed > 0 {
116        return Err(CliError::DoctorFailed { failed });
117    }
118    Ok(())
119}
120
121/// Build the three connectors for one invocation and run their probes.
122pub async fn probe_invocation(
123    id: String,
124    source: ConnectorSpec,
125    sink: ConnectorSpec,
126    state: Option<StateStoreSpec>,
127    auth: &AuthCatalog,
128    ctx: &CheckContext,
129) -> InvocationOut {
130    let mut probes = Vec::new();
131
132    match build_source(&source.kind, source.config.clone(), auth).await {
133        Ok(src) => {
134            probes.extend(collect_probes("source", src.connector_name(), ctx, src.check(ctx)).await)
135        }
136        Err(e) => probes.push(construct_fail("source", &source.kind, &e)),
137    }
138
139    match build_sink(&sink.kind, sink.config.clone(), auth).await {
140        Ok(snk) => {
141            probes.extend(collect_probes("sink", snk.connector_name(), ctx, snk.check(ctx)).await)
142        }
143        Err(e) => probes.push(construct_fail("sink", &sink.kind, &e)),
144    }
145
146    if let Some(spec) = state {
147        match build_state_store(&spec).await {
148            Ok(st) => probes.extend(collect_probes("state", &spec.kind, ctx, st.check(ctx)).await),
149            Err(e) => probes.push(construct_fail("state", &spec.kind, &e)),
150        }
151    }
152
153    InvocationOut {
154        id,
155        probes,
156        source_kind: source.kind,
157        sink_kind: sink.kind,
158    }
159}
160
161/// Probe every *root* invocation's source/sink/state concurrently (bounded).
162/// Child invocations are skipped — their configs need parent records. Reused by
163/// `faucet doctor` and serve's `doctor_first` preflight.
164pub async fn probe_roots(
165    nodes: &[ExpandedNode],
166    auth: &AuthCatalog,
167    ctx: &CheckContext,
168) -> Vec<InvocationOut> {
169    let sem = Arc::new(Semaphore::new(8));
170    let mut handles = Vec::new();
171    for node in nodes.iter().filter(|n| matches!(n.role, NodeRole::Root)) {
172        let id = node.id.clone();
173        let source = node.source.clone();
174        let sink = node.sink.clone();
175        let state = node.state.clone();
176        let auth = auth.clone();
177        let ctx = ctx.clone();
178        let sem = sem.clone();
179        handles.push(tokio::spawn(async move {
180            let _permit = sem.acquire_owned().await.expect("semaphore not closed");
181            probe_invocation(id, source, sink, state, &auth, &ctx).await
182        }));
183    }
184    let mut out = Vec::with_capacity(handles.len());
185    for h in handles {
186        out.push(h.await.expect("doctor probe task panicked"));
187    }
188    out
189}
190
191/// Total number of failed probes across all invocations.
192pub fn count_failures(invs: &[InvocationOut]) -> usize {
193    invs.iter()
194        .flat_map(|i| &i.probes)
195        .filter(|p| matches!(p.status, ProbeStatus::Fail { .. }))
196        .count()
197}
198
199/// Run one connector's `check()` future under the timeout, mapping the report
200/// (or an outer error / timeout) into role-tagged [`ProbeOut`]s.
201async fn collect_probes(
202    role: &'static str,
203    connector: &str,
204    ctx: &CheckContext,
205    fut: impl std::future::Future<Output = Result<CheckReport, faucet_core::FaucetError>>,
206) -> Vec<ProbeOut> {
207    let start = Instant::now();
208    let report = match tokio::time::timeout(ctx.timeout, fut).await {
209        Err(_) => CheckReport::single(Probe::fail("timeout", start.elapsed(), "check timed out")),
210        Ok(Ok(r)) => r,
211        Ok(Err(e)) => CheckReport::single(Probe::fail("check", start.elapsed(), e.to_string())),
212    };
213    report
214        .probes
215        .into_iter()
216        .map(|p| ProbeOut::from_probe(role, connector.to_string(), p))
217        .collect()
218}
219
220/// A `construct` failure: the connector could not even be built from its config.
221/// Accepts any `Display` error (build errors are `CliError`; the unit test uses
222/// `FaucetError`).
223fn construct_fail(role: &'static str, kind: &str, e: impl std::fmt::Display) -> ProbeOut {
224    ProbeOut::from_probe(
225        role,
226        kind.to_string(),
227        Probe::fail("construct", Duration::ZERO, e.to_string()),
228    )
229}
230
231/// Scrub resolved secrets out of every probe `reason` / `hint`.
232pub fn redact_invocations(invs: &mut [InvocationOut]) {
233    for inv in invs.iter_mut() {
234        for p in inv.probes.iter_mut() {
235            match &mut p.status {
236                ProbeStatus::Fail { reason } | ProbeStatus::Skip { reason } => {
237                    *reason = redact(reason).into_owned();
238                }
239                ProbeStatus::Pass => {}
240            }
241            if let Some(h) = &mut p.hint {
242                *h = redact(h).into_owned();
243            }
244        }
245    }
246}
247
248/// Count (passed, failed, skipped) probes across all invocations.
249fn tally(invs: &[InvocationOut]) -> (usize, usize, usize) {
250    let (mut p, mut f, mut s) = (0usize, 0usize, 0usize);
251    for inv in invs {
252        for pr in &inv.probes {
253            match pr.status {
254                ProbeStatus::Pass => p += 1,
255                ProbeStatus::Fail { .. } => f += 1,
256                ProbeStatus::Skip { .. } => s += 1,
257            }
258        }
259    }
260    (p, f, s)
261}
262
263/// Build the `--json` envelope.
264fn build_json(config: &Path, total_ms: u128, invs: &[InvocationOut]) -> serde_json::Value {
265    let (passed, failed, skipped) = tally(invs);
266    serde_json::json!({
267        "config": config.display().to_string(),
268        "invocations": invs,
269        "summary": {
270            "passed": passed,
271            "failed": failed,
272            "skipped": skipped,
273            "elapsed_ms": total_ms,
274        }
275    })
276}
277
278/// Render the human checklist to stdout.
279fn render_human(
280    cfg_ms: u128,
281    n_roots: usize,
282    n_children: usize,
283    total: Duration,
284    invs: &[InvocationOut],
285) {
286    println!("✓ Config parses and interpolates{:>34} ms", cfg_ms);
287    println!(
288        "✓ Matrix expands to {} invocation{}{:>22} skipped (children)",
289        n_roots,
290        if n_roots == 1 { "" } else { "s" },
291        n_children
292    );
293
294    for inv in invs {
295        println!();
296        println!(
297            "▸ Invocation {}  (source={}, sink={})",
298            inv.id, inv.source_kind, inv.sink_kind
299        );
300        for p in &inv.probes {
301            let (sym, extra) = match &p.status {
302                ProbeStatus::Pass => ("✓", String::new()),
303                ProbeStatus::Fail { reason } => ("✗", format!(" ({reason})")),
304                ProbeStatus::Skip { reason } => ("•", format!(" (skip: {reason})")),
305            };
306            println!(
307                "  {} {:6} [{}] {}{}{:>8} ms",
308                sym, p.role, p.connector, p.name, extra, p.elapsed_ms
309            );
310            if let Some(hint) = &p.hint {
311                println!("        hint: {hint}");
312            }
313        }
314    }
315
316    let (passed, failed, skipped) = tally(invs);
317    println!();
318    println!(
319        "Summary: {passed} passed, {failed} failed, {skipped} skipped       total elapsed {:.1}s",
320        total.as_secs_f64()
321    );
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    fn probe_out(role: &'static str, name: &'static str, status: ProbeStatus) -> ProbeOut {
329        ProbeOut {
330            role,
331            connector: "x".into(),
332            name,
333            status,
334            elapsed_ms: 1,
335            hint: None,
336        }
337    }
338
339    fn inv(probes: Vec<ProbeOut>) -> InvocationOut {
340        InvocationOut {
341            id: "default".into(),
342            probes,
343            source_kind: "rest".into(),
344            sink_kind: "stdout".into(),
345        }
346    }
347
348    #[test]
349    fn tally_counts_each_status() {
350        let invs = vec![inv(vec![
351            probe_out("source", "read", ProbeStatus::Pass),
352            probe_out("sink", "auth", ProbeStatus::Fail { reason: "x".into() }),
353            probe_out(
354                "state",
355                "sentinel",
356                ProbeStatus::Skip {
357                    reason: "n/a".into(),
358                },
359            ),
360            probe_out("sink", "schema", ProbeStatus::Fail { reason: "y".into() }),
361        ])];
362        assert_eq!(tally(&invs), (1, 2, 1));
363    }
364
365    #[test]
366    fn json_has_summary_and_invocations() {
367        let invs = vec![inv(vec![probe_out("source", "read", ProbeStatus::Pass)])];
368        let v = build_json(Path::new("pipeline.yaml"), 100, &invs);
369        assert_eq!(v["config"], "pipeline.yaml");
370        assert_eq!(v["invocations"][0]["id"], "default");
371        assert_eq!(v["invocations"][0]["probes"][0]["role"], "source");
372        assert_eq!(v["invocations"][0]["probes"][0]["status"], "pass");
373        assert_eq!(v["summary"]["passed"], 1);
374        assert_eq!(v["summary"]["failed"], 0);
375        assert_eq!(v["summary"]["elapsed_ms"], 100);
376    }
377
378    #[test]
379    fn redaction_scrubs_secret_in_reason_and_hint() {
380        crate::secrets::registry::register("supersecretvalue");
381        let mut invs = vec![inv(vec![ProbeOut {
382            role: "sink",
383            connector: "postgres".into(),
384            name: "auth",
385            status: ProbeStatus::Fail {
386                reason: "login failed for supersecretvalue".into(),
387            },
388            elapsed_ms: 5,
389            hint: Some("token supersecretvalue rejected".into()),
390        }])];
391        redact_invocations(&mut invs);
392        if let ProbeStatus::Fail { reason } = &invs[0].probes[0].status {
393            assert!(
394                !reason.contains("supersecretvalue"),
395                "reason not redacted: {reason}"
396            );
397        } else {
398            panic!("expected fail");
399        }
400        assert!(
401            !invs[0].probes[0]
402                .hint
403                .as_ref()
404                .unwrap()
405                .contains("supersecretvalue")
406        );
407    }
408
409    #[test]
410    fn construct_fail_is_a_fail_probe() {
411        let e = faucet_core::FaucetError::Config("bad".into());
412        let p = construct_fail("sink", "postgres", &e);
413        assert_eq!(p.name, "construct");
414        assert!(matches!(p.status, ProbeStatus::Fail { .. }));
415        assert_eq!(p.connector, "postgres");
416    }
417
418    #[test]
419    fn count_failures_sums_across_invocations() {
420        let invs = vec![
421            inv(vec![
422                probe_out("source", "read", ProbeStatus::Pass),
423                probe_out("sink", "auth", ProbeStatus::Fail { reason: "x".into() }),
424            ]),
425            inv(vec![probe_out(
426                "sink",
427                "auth",
428                ProbeStatus::Fail { reason: "y".into() },
429            )]),
430        ];
431        assert_eq!(count_failures(&invs), 2);
432    }
433}