1use 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#[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#[derive(Debug, Serialize)]
56pub struct InvocationOut {
57 pub id: String,
58 pub probes: Vec<ProbeOut>,
59 #[serde(skip_serializing_if = "Option::is_none")]
63 pub delivery: Option<String>,
64 #[serde(skip)]
66 pub source_kind: String,
67 #[serde(skip)]
68 pub sink_kind: String,
69}
70
71pub 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 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 #[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
142pub 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#[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
238pub 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
281pub 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
289async 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
310fn 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
321pub 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
338fn 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
353fn 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
368fn 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}