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    /// The derived end-to-end delivery guarantee for this invocation (#292),
60    /// e.g. `"effectively-once (atomic watermark)"`. `None` for synthetic
61    /// entries (lineage transport).
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub delivery: Option<String>,
64    // Connector kinds for the human header; not part of the JSON contract.
65    #[serde(skip)]
66    pub source_kind: String,
67    #[serde(skip)]
68    pub sink_kind: String,
69}
70
71/// Execute the `doctor` subcommand.
72pub async fn run(args: DoctorArgs) -> CliResult<()> {
73    let overall = Instant::now();
74    let cwd = std::env::current_dir()?;
75    let env_path =
76        crate::env_loader::resolve_env_file(args.env_file.as_deref(), args.no_env_file, &cwd)?;
77    crate::env_loader::load_env_file_if_present(env_path.as_deref())?;
78    let path = match args.config {
79        Some(p) => p,
80        None => crate::env_loader::discover_config_path(&cwd).ok_or(CliError::NoConfigOrFromEnv)?,
81    };
82
83    let t_cfg = Instant::now();
84    let cfg = PipelineConfig::from_path_async(&path, args.profile.as_deref()).await?;
85    let cfg_ms = t_cfg.elapsed().as_millis();
86
87    let nodes = expand(&cfg)?;
88    let auth = build_auth_catalog(cfg.auth.as_ref())?;
89    let ctx = CheckContext {
90        timeout: Duration::from_secs(args.timeout_secs),
91    };
92    // Same derivation as `faucet run`, so the SLA probes read the same
93    // state keys the executor writes.
94    let pipeline_name = cfg.name.clone().unwrap_or_else(|| {
95        path.file_stem()
96            .and_then(|s| s.to_str())
97            .unwrap_or("pipeline")
98            .to_owned()
99    });
100
101    let roots: Vec<&ExpandedNode> = nodes
102        .iter()
103        .filter(|n| matches!(n.role, NodeRole::Root))
104        .collect();
105    let n_children = nodes.len() - roots.len();
106
107    let mut invocations = probe_roots(&nodes, &auth, &ctx, cfg.sla.as_ref(), &pipeline_name).await;
108
109    // Lineage transport reachability — one pipeline-wide probe (the `lineage:`
110    // block is top-level, not per-row), rendered as its own invocation entry so
111    // it isn't duplicated across roots.
112    #[cfg(feature = "lineage")]
113    if let Some(inv) = probe_lineage(cfg.lineage.as_ref()).await {
114        invocations.push(inv);
115    }
116
117    redact_invocations(&mut invocations);
118    let (_passed, failed, _skipped) = tally(&invocations);
119
120    if args.json {
121        let v = build_json(&path, overall.elapsed().as_millis(), &invocations);
122        println!(
123            "{}",
124            serde_json::to_string_pretty(&v).expect("doctor json serializes")
125        );
126    } else {
127        render_human(
128            cfg_ms,
129            roots.len(),
130            n_children,
131            overall.elapsed(),
132            &invocations,
133        );
134    }
135
136    if failed > 0 {
137        return Err(CliError::DoctorFailed { failed });
138    }
139    Ok(())
140}
141
142/// Build the three connectors for one invocation and run their probes. When an
143/// `sla:` block is configured, `sla` carries the spec plus the invocation's
144/// base state key so the persisted SLA history can be probed read-only
145/// (staleness vs `max_staleness_secs`, volume-baseline warm-up).
146pub async fn probe_invocation(
147    id: String,
148    source: ConnectorSpec,
149    sink: ConnectorSpec,
150    state: Option<StateStoreSpec>,
151    auth: &AuthCatalog,
152    ctx: &CheckContext,
153    sla: Option<(crate::sla::SlaSpec, String)>,
154) -> InvocationOut {
155    let mut probes = Vec::new();
156
157    match build_source(&source.kind, source.config.clone(), auth, None).await {
158        Ok(src) => {
159            probes.extend(collect_probes("source", src.connector_name(), ctx, src.check(ctx)).await)
160        }
161        Err(e) => probes.push(construct_fail("source", &source.kind, &e)),
162    }
163
164    match build_sink(&sink.kind, sink.config.clone(), auth).await {
165        Ok(snk) => {
166            probes.extend(collect_probes("sink", snk.connector_name(), ctx, snk.check(ctx)).await)
167        }
168        Err(e) => probes.push(construct_fail("sink", &sink.kind, &e)),
169    }
170
171    let mut store = None;
172    if let Some(spec) = state {
173        match build_state_store(&spec).await {
174            Ok(st) => {
175                probes.extend(collect_probes("state", &spec.kind, ctx, st.check(ctx)).await);
176                store = Some(st);
177            }
178            Err(e) => probes.push(construct_fail("state", &spec.kind, &e)),
179        }
180    }
181
182    if let Some((spec, base_key)) = sla {
183        let now = chrono::Utc::now().timestamp();
184        let sla_probes = tokio::time::timeout(
185            ctx.timeout,
186            crate::sla::doctor_probes(&spec, store.as_ref(), &base_key, now),
187        )
188        .await
189        .unwrap_or_else(|_| {
190            vec![Probe::fail(
191                "history",
192                ctx.timeout,
193                "SLA state read timed out",
194            )]
195        });
196        probes.extend(
197            sla_probes
198                .into_iter()
199                .map(|p| ProbeOut::from_probe("sla", "sla".to_string(), p)),
200        );
201    }
202
203    InvocationOut {
204        id,
205        probes,
206        delivery: None,
207        source_kind: source.kind,
208        sink_kind: sink.kind,
209    }
210}
211
212/// Probe the pipeline-wide `lineage:` transport reachability, returning a
213/// single-probe invocation (or `None` when no `lineage:` block is configured).
214/// A failed probe is diagnostic only — lineage emission never blocks a run.
215#[cfg(feature = "lineage")]
216pub async fn probe_lineage(
217    lineage: Option<&faucet_lineage::LineageConfig>,
218) -> Option<InvocationOut> {
219    let lc = lineage?;
220    let start = Instant::now();
221    let probe = match crate::lineage_glue::check_transport(lc).await {
222        Ok(_) => Probe::pass("reachable", start.elapsed()),
223        Err(reason) => Probe::fail("reachable", start.elapsed(), reason),
224    };
225    Some(InvocationOut {
226        id: "lineage".to_string(),
227        delivery: None,
228        probes: vec![ProbeOut::from_probe(
229            "lineage",
230            "openlineage".to_string(),
231            probe,
232        )],
233        source_kind: "—".to_string(),
234        sink_kind: "—".to_string(),
235    })
236}
237
238/// Probe every *root* invocation's source/sink/state concurrently (bounded).
239/// Child invocations are skipped — their configs need parent records. Reused by
240/// `faucet doctor` and serve's `doctor_first` preflight. `sla` /
241/// `pipeline_name` come from the top-level config; when set, each root also
242/// gets read-only SLA staleness/baseline probes against its state store.
243pub async fn probe_roots(
244    nodes: &[ExpandedNode],
245    auth: &AuthCatalog,
246    ctx: &CheckContext,
247    sla: Option<&crate::sla::SlaSpec>,
248    pipeline_name: &str,
249) -> Vec<InvocationOut> {
250    let sem = Arc::new(Semaphore::new(8));
251    let mut handles = Vec::new();
252    for node in nodes.iter().filter(|n| matches!(n.role, NodeRole::Root)) {
253        let id = node.id.clone();
254        let source = node.source.clone();
255        let sink = node.sink.clone();
256        let state = node.state.clone();
257        let auth = auth.clone();
258        let ctx = ctx.clone();
259        let sem = sem.clone();
260        let sla = sla.map(|s| {
261            (
262                s.clone(),
263                crate::executor::build_state_key(pipeline_name, &node.id, None),
264            )
265        });
266        let guarantee = node.delivery_guarantee.to_string();
267        handles.push(tokio::spawn(async move {
268            let _permit = sem.acquire_owned().await.expect("semaphore not closed");
269            let mut inv = probe_invocation(id, source, sink, state, &auth, &ctx, sla).await;
270            inv.delivery = Some(guarantee);
271            inv
272        }));
273    }
274    let mut out = Vec::with_capacity(handles.len());
275    for h in handles {
276        out.push(h.await.expect("doctor probe task panicked"));
277    }
278    out
279}
280
281/// Total number of failed probes across all invocations.
282pub fn count_failures(invs: &[InvocationOut]) -> usize {
283    invs.iter()
284        .flat_map(|i| &i.probes)
285        .filter(|p| matches!(p.status, ProbeStatus::Fail { .. }))
286        .count()
287}
288
289/// Run one connector's `check()` future under the timeout, mapping the report
290/// (or an outer error / timeout) into role-tagged [`ProbeOut`]s.
291async fn collect_probes(
292    role: &'static str,
293    connector: &str,
294    ctx: &CheckContext,
295    fut: impl std::future::Future<Output = Result<CheckReport, faucet_core::FaucetError>>,
296) -> Vec<ProbeOut> {
297    let start = Instant::now();
298    let report = match tokio::time::timeout(ctx.timeout, fut).await {
299        Err(_) => CheckReport::single(Probe::fail("timeout", start.elapsed(), "check timed out")),
300        Ok(Ok(r)) => r,
301        Ok(Err(e)) => CheckReport::single(Probe::fail("check", start.elapsed(), e.to_string())),
302    };
303    report
304        .probes
305        .into_iter()
306        .map(|p| ProbeOut::from_probe(role, connector.to_string(), p))
307        .collect()
308}
309
310/// A `construct` failure: the connector could not even be built from its config.
311/// Accepts any `Display` error (build errors are `CliError`; the unit test uses
312/// `FaucetError`).
313fn construct_fail(role: &'static str, kind: &str, e: impl std::fmt::Display) -> ProbeOut {
314    ProbeOut::from_probe(
315        role,
316        kind.to_string(),
317        Probe::fail("construct", Duration::ZERO, e.to_string()),
318    )
319}
320
321/// Scrub resolved secrets out of every probe `reason` / `hint`.
322pub fn redact_invocations(invs: &mut [InvocationOut]) {
323    for inv in invs.iter_mut() {
324        for p in inv.probes.iter_mut() {
325            match &mut p.status {
326                ProbeStatus::Fail { reason } | ProbeStatus::Skip { reason } => {
327                    *reason = redact(reason).into_owned();
328                }
329                ProbeStatus::Pass => {}
330            }
331            if let Some(h) = &mut p.hint {
332                *h = redact(h).into_owned();
333            }
334        }
335    }
336}
337
338/// Count (passed, failed, skipped) probes across all invocations.
339fn tally(invs: &[InvocationOut]) -> (usize, usize, usize) {
340    let (mut p, mut f, mut s) = (0usize, 0usize, 0usize);
341    for inv in invs {
342        for pr in &inv.probes {
343            match pr.status {
344                ProbeStatus::Pass => p += 1,
345                ProbeStatus::Fail { .. } => f += 1,
346                ProbeStatus::Skip { .. } => s += 1,
347            }
348        }
349    }
350    (p, f, s)
351}
352
353/// Build the `--json` envelope.
354fn build_json(config: &Path, total_ms: u128, invs: &[InvocationOut]) -> serde_json::Value {
355    let (passed, failed, skipped) = tally(invs);
356    serde_json::json!({
357        "config": config.display().to_string(),
358        "invocations": invs,
359        "summary": {
360            "passed": passed,
361            "failed": failed,
362            "skipped": skipped,
363            "elapsed_ms": total_ms,
364        }
365    })
366}
367
368/// Render the human checklist to stdout.
369fn render_human(
370    cfg_ms: u128,
371    n_roots: usize,
372    n_children: usize,
373    total: Duration,
374    invs: &[InvocationOut],
375) {
376    println!("✓ Config parses and interpolates{:>34} ms", cfg_ms);
377    println!(
378        "✓ Matrix expands to {} invocation{}{:>22} skipped (children)",
379        n_roots,
380        if n_roots == 1 { "" } else { "s" },
381        n_children
382    );
383
384    for inv in invs {
385        println!();
386        println!(
387            "▸ Invocation {}  (source={}, sink={}{})",
388            inv.id,
389            inv.source_kind,
390            inv.sink_kind,
391            inv.delivery
392                .as_deref()
393                .map(|d| format!(", delivery={d}"))
394                .unwrap_or_default()
395        );
396        for p in &inv.probes {
397            let (sym, extra) = match &p.status {
398                ProbeStatus::Pass => ("✓", String::new()),
399                ProbeStatus::Fail { reason } => ("✗", format!(" ({reason})")),
400                ProbeStatus::Skip { reason } => ("•", format!(" (skip: {reason})")),
401            };
402            println!(
403                "  {} {:6} [{}] {}{}{:>8} ms",
404                sym, p.role, p.connector, p.name, extra, p.elapsed_ms
405            );
406            if let Some(hint) = &p.hint {
407                println!("        hint: {hint}");
408            }
409        }
410    }
411
412    let (passed, failed, skipped) = tally(invs);
413    println!();
414    println!(
415        "Summary: {passed} passed, {failed} failed, {skipped} skipped       total elapsed {:.1}s",
416        total.as_secs_f64()
417    );
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423
424    fn probe_out(role: &'static str, name: &'static str, status: ProbeStatus) -> ProbeOut {
425        ProbeOut {
426            role,
427            connector: "x".into(),
428            name,
429            status,
430            elapsed_ms: 1,
431            hint: None,
432        }
433    }
434
435    fn inv(probes: Vec<ProbeOut>) -> InvocationOut {
436        InvocationOut {
437            id: "default".into(),
438            probes,
439            delivery: None,
440            source_kind: "rest".into(),
441            sink_kind: "stdout".into(),
442        }
443    }
444
445    #[test]
446    fn tally_counts_each_status() {
447        let invs = vec![inv(vec![
448            probe_out("source", "read", ProbeStatus::Pass),
449            probe_out("sink", "auth", ProbeStatus::Fail { reason: "x".into() }),
450            probe_out(
451                "state",
452                "sentinel",
453                ProbeStatus::Skip {
454                    reason: "n/a".into(),
455                },
456            ),
457            probe_out("sink", "schema", ProbeStatus::Fail { reason: "y".into() }),
458        ])];
459        assert_eq!(tally(&invs), (1, 2, 1));
460    }
461
462    #[test]
463    fn json_has_summary_and_invocations() {
464        let invs = vec![inv(vec![probe_out("source", "read", ProbeStatus::Pass)])];
465        let v = build_json(Path::new("pipeline.yaml"), 100, &invs);
466        assert_eq!(v["config"], "pipeline.yaml");
467        assert_eq!(v["invocations"][0]["id"], "default");
468        assert_eq!(v["invocations"][0]["probes"][0]["role"], "source");
469        assert_eq!(v["invocations"][0]["probes"][0]["status"], "pass");
470        assert_eq!(v["summary"]["passed"], 1);
471        assert_eq!(v["summary"]["failed"], 0);
472        assert_eq!(v["summary"]["elapsed_ms"], 100);
473    }
474
475    #[test]
476    fn redaction_scrubs_secret_in_reason_and_hint() {
477        crate::secrets::registry::register("supersecretvalue");
478        let mut invs = vec![inv(vec![ProbeOut {
479            role: "sink",
480            connector: "postgres".into(),
481            name: "auth",
482            status: ProbeStatus::Fail {
483                reason: "login failed for supersecretvalue".into(),
484            },
485            elapsed_ms: 5,
486            hint: Some("token supersecretvalue rejected".into()),
487        }])];
488        redact_invocations(&mut invs);
489        if let ProbeStatus::Fail { reason } = &invs[0].probes[0].status {
490            assert!(
491                !reason.contains("supersecretvalue"),
492                "reason not redacted: {reason}"
493            );
494        } else {
495            panic!("expected fail");
496        }
497        assert!(
498            !invs[0].probes[0]
499                .hint
500                .as_ref()
501                .unwrap()
502                .contains("supersecretvalue")
503        );
504    }
505
506    #[test]
507    fn construct_fail_is_a_fail_probe() {
508        let e = faucet_core::FaucetError::Config("bad".into());
509        let p = construct_fail("sink", "postgres", &e);
510        assert_eq!(p.name, "construct");
511        assert!(matches!(p.status, ProbeStatus::Fail { .. }));
512        assert_eq!(p.connector, "postgres");
513    }
514
515    #[test]
516    fn count_failures_sums_across_invocations() {
517        let invs = vec![
518            inv(vec![
519                probe_out("source", "read", ProbeStatus::Pass),
520                probe_out("sink", "auth", ProbeStatus::Fail { reason: "x".into() }),
521            ]),
522            inv(vec![probe_out(
523                "sink",
524                "auth",
525                ProbeStatus::Fail { reason: "y".into() },
526            )]),
527        ];
528        assert_eq!(count_failures(&invs), 2);
529    }
530}