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)]
61 pub source_kind: String,
62 #[serde(skip)]
63 pub sink_kind: String,
64}
65
66pub 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, args.profile.as_deref()).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 let pipeline_name = cfg.name.clone().unwrap_or_else(|| {
90 path.file_stem()
91 .and_then(|s| s.to_str())
92 .unwrap_or("pipeline")
93 .to_owned()
94 });
95
96 let roots: Vec<&ExpandedNode> = nodes
97 .iter()
98 .filter(|n| matches!(n.role, NodeRole::Root))
99 .collect();
100 let n_children = nodes.len() - roots.len();
101
102 let mut invocations = probe_roots(&nodes, &auth, &ctx, cfg.sla.as_ref(), &pipeline_name).await;
103
104 #[cfg(feature = "lineage")]
108 if let Some(inv) = probe_lineage(cfg.lineage.as_ref()).await {
109 invocations.push(inv);
110 }
111
112 redact_invocations(&mut invocations);
113 let (_passed, failed, _skipped) = tally(&invocations);
114
115 if args.json {
116 let v = build_json(&path, overall.elapsed().as_millis(), &invocations);
117 println!(
118 "{}",
119 serde_json::to_string_pretty(&v).expect("doctor json serializes")
120 );
121 } else {
122 render_human(
123 cfg_ms,
124 roots.len(),
125 n_children,
126 overall.elapsed(),
127 &invocations,
128 );
129 }
130
131 if failed > 0 {
132 return Err(CliError::DoctorFailed { failed });
133 }
134 Ok(())
135}
136
137pub async fn probe_invocation(
142 id: String,
143 source: ConnectorSpec,
144 sink: ConnectorSpec,
145 state: Option<StateStoreSpec>,
146 auth: &AuthCatalog,
147 ctx: &CheckContext,
148 sla: Option<(crate::sla::SlaSpec, String)>,
149) -> InvocationOut {
150 let mut probes = Vec::new();
151
152 match build_source(&source.kind, source.config.clone(), auth, None).await {
153 Ok(src) => {
154 probes.extend(collect_probes("source", src.connector_name(), ctx, src.check(ctx)).await)
155 }
156 Err(e) => probes.push(construct_fail("source", &source.kind, &e)),
157 }
158
159 match build_sink(&sink.kind, sink.config.clone(), auth).await {
160 Ok(snk) => {
161 probes.extend(collect_probes("sink", snk.connector_name(), ctx, snk.check(ctx)).await)
162 }
163 Err(e) => probes.push(construct_fail("sink", &sink.kind, &e)),
164 }
165
166 let mut store = None;
167 if let Some(spec) = state {
168 match build_state_store(&spec).await {
169 Ok(st) => {
170 probes.extend(collect_probes("state", &spec.kind, ctx, st.check(ctx)).await);
171 store = Some(st);
172 }
173 Err(e) => probes.push(construct_fail("state", &spec.kind, &e)),
174 }
175 }
176
177 if let Some((spec, base_key)) = sla {
178 let now = chrono::Utc::now().timestamp();
179 let sla_probes = tokio::time::timeout(
180 ctx.timeout,
181 crate::sla::doctor_probes(&spec, store.as_ref(), &base_key, now),
182 )
183 .await
184 .unwrap_or_else(|_| {
185 vec![Probe::fail(
186 "history",
187 ctx.timeout,
188 "SLA state read timed out",
189 )]
190 });
191 probes.extend(
192 sla_probes
193 .into_iter()
194 .map(|p| ProbeOut::from_probe("sla", "sla".to_string(), p)),
195 );
196 }
197
198 InvocationOut {
199 id,
200 probes,
201 source_kind: source.kind,
202 sink_kind: sink.kind,
203 }
204}
205
206#[cfg(feature = "lineage")]
210pub async fn probe_lineage(
211 lineage: Option<&faucet_lineage::LineageConfig>,
212) -> Option<InvocationOut> {
213 let lc = lineage?;
214 let start = Instant::now();
215 let probe = match crate::lineage_glue::check_transport(lc).await {
216 Ok(_) => Probe::pass("reachable", start.elapsed()),
217 Err(reason) => Probe::fail("reachable", start.elapsed(), reason),
218 };
219 Some(InvocationOut {
220 id: "lineage".to_string(),
221 probes: vec![ProbeOut::from_probe(
222 "lineage",
223 "openlineage".to_string(),
224 probe,
225 )],
226 source_kind: "—".to_string(),
227 sink_kind: "—".to_string(),
228 })
229}
230
231pub async fn probe_roots(
237 nodes: &[ExpandedNode],
238 auth: &AuthCatalog,
239 ctx: &CheckContext,
240 sla: Option<&crate::sla::SlaSpec>,
241 pipeline_name: &str,
242) -> Vec<InvocationOut> {
243 let sem = Arc::new(Semaphore::new(8));
244 let mut handles = Vec::new();
245 for node in nodes.iter().filter(|n| matches!(n.role, NodeRole::Root)) {
246 let id = node.id.clone();
247 let source = node.source.clone();
248 let sink = node.sink.clone();
249 let state = node.state.clone();
250 let auth = auth.clone();
251 let ctx = ctx.clone();
252 let sem = sem.clone();
253 let sla = sla.map(|s| {
254 (
255 s.clone(),
256 crate::executor::build_state_key(pipeline_name, &node.id, None),
257 )
258 });
259 handles.push(tokio::spawn(async move {
260 let _permit = sem.acquire_owned().await.expect("semaphore not closed");
261 probe_invocation(id, source, sink, state, &auth, &ctx, sla).await
262 }));
263 }
264 let mut out = Vec::with_capacity(handles.len());
265 for h in handles {
266 out.push(h.await.expect("doctor probe task panicked"));
267 }
268 out
269}
270
271pub fn count_failures(invs: &[InvocationOut]) -> usize {
273 invs.iter()
274 .flat_map(|i| &i.probes)
275 .filter(|p| matches!(p.status, ProbeStatus::Fail { .. }))
276 .count()
277}
278
279async fn collect_probes(
282 role: &'static str,
283 connector: &str,
284 ctx: &CheckContext,
285 fut: impl std::future::Future<Output = Result<CheckReport, faucet_core::FaucetError>>,
286) -> Vec<ProbeOut> {
287 let start = Instant::now();
288 let report = match tokio::time::timeout(ctx.timeout, fut).await {
289 Err(_) => CheckReport::single(Probe::fail("timeout", start.elapsed(), "check timed out")),
290 Ok(Ok(r)) => r,
291 Ok(Err(e)) => CheckReport::single(Probe::fail("check", start.elapsed(), e.to_string())),
292 };
293 report
294 .probes
295 .into_iter()
296 .map(|p| ProbeOut::from_probe(role, connector.to_string(), p))
297 .collect()
298}
299
300fn construct_fail(role: &'static str, kind: &str, e: impl std::fmt::Display) -> ProbeOut {
304 ProbeOut::from_probe(
305 role,
306 kind.to_string(),
307 Probe::fail("construct", Duration::ZERO, e.to_string()),
308 )
309}
310
311pub fn redact_invocations(invs: &mut [InvocationOut]) {
313 for inv in invs.iter_mut() {
314 for p in inv.probes.iter_mut() {
315 match &mut p.status {
316 ProbeStatus::Fail { reason } | ProbeStatus::Skip { reason } => {
317 *reason = redact(reason).into_owned();
318 }
319 ProbeStatus::Pass => {}
320 }
321 if let Some(h) = &mut p.hint {
322 *h = redact(h).into_owned();
323 }
324 }
325 }
326}
327
328fn tally(invs: &[InvocationOut]) -> (usize, usize, usize) {
330 let (mut p, mut f, mut s) = (0usize, 0usize, 0usize);
331 for inv in invs {
332 for pr in &inv.probes {
333 match pr.status {
334 ProbeStatus::Pass => p += 1,
335 ProbeStatus::Fail { .. } => f += 1,
336 ProbeStatus::Skip { .. } => s += 1,
337 }
338 }
339 }
340 (p, f, s)
341}
342
343fn build_json(config: &Path, total_ms: u128, invs: &[InvocationOut]) -> serde_json::Value {
345 let (passed, failed, skipped) = tally(invs);
346 serde_json::json!({
347 "config": config.display().to_string(),
348 "invocations": invs,
349 "summary": {
350 "passed": passed,
351 "failed": failed,
352 "skipped": skipped,
353 "elapsed_ms": total_ms,
354 }
355 })
356}
357
358fn render_human(
360 cfg_ms: u128,
361 n_roots: usize,
362 n_children: usize,
363 total: Duration,
364 invs: &[InvocationOut],
365) {
366 println!("✓ Config parses and interpolates{:>34} ms", cfg_ms);
367 println!(
368 "✓ Matrix expands to {} invocation{}{:>22} skipped (children)",
369 n_roots,
370 if n_roots == 1 { "" } else { "s" },
371 n_children
372 );
373
374 for inv in invs {
375 println!();
376 println!(
377 "▸ Invocation {} (source={}, sink={})",
378 inv.id, inv.source_kind, inv.sink_kind
379 );
380 for p in &inv.probes {
381 let (sym, extra) = match &p.status {
382 ProbeStatus::Pass => ("✓", String::new()),
383 ProbeStatus::Fail { reason } => ("✗", format!(" ({reason})")),
384 ProbeStatus::Skip { reason } => ("•", format!(" (skip: {reason})")),
385 };
386 println!(
387 " {} {:6} [{}] {}{}{:>8} ms",
388 sym, p.role, p.connector, p.name, extra, p.elapsed_ms
389 );
390 if let Some(hint) = &p.hint {
391 println!(" hint: {hint}");
392 }
393 }
394 }
395
396 let (passed, failed, skipped) = tally(invs);
397 println!();
398 println!(
399 "Summary: {passed} passed, {failed} failed, {skipped} skipped total elapsed {:.1}s",
400 total.as_secs_f64()
401 );
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407
408 fn probe_out(role: &'static str, name: &'static str, status: ProbeStatus) -> ProbeOut {
409 ProbeOut {
410 role,
411 connector: "x".into(),
412 name,
413 status,
414 elapsed_ms: 1,
415 hint: None,
416 }
417 }
418
419 fn inv(probes: Vec<ProbeOut>) -> InvocationOut {
420 InvocationOut {
421 id: "default".into(),
422 probes,
423 source_kind: "rest".into(),
424 sink_kind: "stdout".into(),
425 }
426 }
427
428 #[test]
429 fn tally_counts_each_status() {
430 let invs = vec![inv(vec![
431 probe_out("source", "read", ProbeStatus::Pass),
432 probe_out("sink", "auth", ProbeStatus::Fail { reason: "x".into() }),
433 probe_out(
434 "state",
435 "sentinel",
436 ProbeStatus::Skip {
437 reason: "n/a".into(),
438 },
439 ),
440 probe_out("sink", "schema", ProbeStatus::Fail { reason: "y".into() }),
441 ])];
442 assert_eq!(tally(&invs), (1, 2, 1));
443 }
444
445 #[test]
446 fn json_has_summary_and_invocations() {
447 let invs = vec![inv(vec![probe_out("source", "read", ProbeStatus::Pass)])];
448 let v = build_json(Path::new("pipeline.yaml"), 100, &invs);
449 assert_eq!(v["config"], "pipeline.yaml");
450 assert_eq!(v["invocations"][0]["id"], "default");
451 assert_eq!(v["invocations"][0]["probes"][0]["role"], "source");
452 assert_eq!(v["invocations"][0]["probes"][0]["status"], "pass");
453 assert_eq!(v["summary"]["passed"], 1);
454 assert_eq!(v["summary"]["failed"], 0);
455 assert_eq!(v["summary"]["elapsed_ms"], 100);
456 }
457
458 #[test]
459 fn redaction_scrubs_secret_in_reason_and_hint() {
460 crate::secrets::registry::register("supersecretvalue");
461 let mut invs = vec![inv(vec![ProbeOut {
462 role: "sink",
463 connector: "postgres".into(),
464 name: "auth",
465 status: ProbeStatus::Fail {
466 reason: "login failed for supersecretvalue".into(),
467 },
468 elapsed_ms: 5,
469 hint: Some("token supersecretvalue rejected".into()),
470 }])];
471 redact_invocations(&mut invs);
472 if let ProbeStatus::Fail { reason } = &invs[0].probes[0].status {
473 assert!(
474 !reason.contains("supersecretvalue"),
475 "reason not redacted: {reason}"
476 );
477 } else {
478 panic!("expected fail");
479 }
480 assert!(
481 !invs[0].probes[0]
482 .hint
483 .as_ref()
484 .unwrap()
485 .contains("supersecretvalue")
486 );
487 }
488
489 #[test]
490 fn construct_fail_is_a_fail_probe() {
491 let e = faucet_core::FaucetError::Config("bad".into());
492 let p = construct_fail("sink", "postgres", &e);
493 assert_eq!(p.name, "construct");
494 assert!(matches!(p.status, ProbeStatus::Fail { .. }));
495 assert_eq!(p.connector, "postgres");
496 }
497
498 #[test]
499 fn count_failures_sums_across_invocations() {
500 let invs = vec![
501 inv(vec![
502 probe_out("source", "read", ProbeStatus::Pass),
503 probe_out("sink", "auth", ProbeStatus::Fail { reason: "x".into() }),
504 ]),
505 inv(vec![probe_out(
506 "sink",
507 "auth",
508 ProbeStatus::Fail { reason: "y".into() },
509 )]),
510 ];
511 assert_eq!(count_failures(&invs), 2);
512 }
513}