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