1use crate::config::{ExecutionSpec, OnError, PipelineConfig};
23use crate::expand::ExpandedNode;
24use crate::serve::history::catalog::{
25 ConfigSnapshot, ConnectorSnapshot, RowSnapshot, TransformSnapshot,
26};
27use chrono::{DateTime, Utc};
28use serde::Serialize;
29use serde_json::Value;
30use sha2::{Digest, Sha256};
31use std::collections::{BTreeMap, BTreeSet};
32use std::path::Path;
33
34pub fn resolve_name(cfg: &PipelineConfig, config_path: Option<&Path>) -> String {
39 cfg.name.clone().unwrap_or_else(|| {
40 config_path
41 .and_then(|p| p.file_stem())
42 .and_then(|s| s.to_str())
43 .unwrap_or("pipeline")
44 .to_owned()
45 })
46}
47
48pub fn on_error_str(execution: &Option<ExecutionSpec>) -> &'static str {
50 match execution.as_ref().map(|e| e.on_error).unwrap_or_default() {
51 OnError::Stop => "stop",
52 OnError::Continue => "continue",
53 }
54}
55
56pub fn build_snapshot(
61 pipeline: String,
62 on_error: &str,
63 nodes: &[ExpandedNode],
64 clock: DateTime<Utc>,
65) -> ConfigSnapshot {
66 let mut rows = BTreeMap::new();
67 for node in nodes {
68 let state_key = node
69 .state
70 .as_ref()
71 .map(|_| format!("{pipeline}::{}", node.id));
72 rows.insert(
73 node.id.clone(),
74 RowSnapshot {
75 source: connector_snapshot(&node.source.kind, &node.source.config),
76 sink: connector_snapshot(&node.sink.kind, &node.sink.config),
77 transforms: node
78 .transforms
79 .iter()
80 .map(|t| TransformSnapshot {
81 kind: t.kind.clone(),
82 config: redact_value(&t.config),
83 })
84 .collect(),
85 state_key,
86 delivery_guarantee: format!("{:?}", node.delivery_guarantee),
87 on_error: on_error.to_owned(),
88 dlq: node.dlq.is_some(),
89 },
90 );
91 }
92 ConfigSnapshot {
93 pipeline,
94 recorded_at: clock,
95 faucet_version: env!("CARGO_PKG_VERSION").to_owned(),
96 rows,
97 }
98}
99
100fn connector_snapshot(kind: &str, config: &Value) -> ConnectorSnapshot {
101 ConnectorSnapshot {
102 kind: kind.to_owned(),
103 config: redact_value(config),
104 }
105}
106
107pub async fn record_if_ok(
113 catalog: Option<&super::CatalogHandle>,
114 pipeline: &str,
115 on_error: &str,
116 nodes: &[ExpandedNode],
117 succeeded: bool,
118 clock: DateTime<Utc>,
119) {
120 if !succeeded {
121 return;
122 }
123 let Some(handle) = catalog else {
124 return;
125 };
126 let snapshot = build_snapshot(pipeline.to_owned(), on_error, nodes, clock);
127 super::record_config_snapshot(handle, &snapshot).await;
128}
129
130pub fn redact_value(value: &Value) -> Value {
134 match value {
135 Value::String(s) => {
136 Value::String(crate::secrets::registry::redact_with(s, secret_token).into_owned())
137 }
138 Value::Array(items) => Value::Array(items.iter().map(redact_value).collect()),
139 Value::Object(map) => Value::Object(
140 map.iter()
141 .map(|(k, v)| (k.clone(), redact_value(v)))
142 .collect(),
143 ),
144 other => other.clone(),
145 }
146}
147
148fn secret_token(secret: &str) -> String {
151 let digest = Sha256::digest(secret.as_bytes());
152 let hex: String = digest.iter().take(6).map(|b| format!("{b:02x}")).collect();
153 format!("<secret:sha256:{hex}>")
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
160#[serde(rename_all = "snake_case")]
161pub enum RowStatus {
162 New,
164 Changed,
166 Removed,
168 Unchanged,
170}
171
172impl RowStatus {
173 fn glyph(self) -> char {
174 match self {
175 Self::New => '+',
176 Self::Changed => '~',
177 Self::Removed => '-',
178 Self::Unchanged => '=',
179 }
180 }
181 fn label(self) -> &'static str {
182 match self {
183 Self::New => "NEW ROW — will be created",
184 Self::Changed => "CHANGED",
185 Self::Removed => "REMOVED — no longer in the run set",
186 Self::Unchanged => "unchanged",
187 }
188 }
189}
190
191#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
193pub struct FieldChange {
194 pub path: String,
196 #[serde(skip_serializing_if = "Option::is_none")]
197 pub before: Option<String>,
198 #[serde(skip_serializing_if = "Option::is_none")]
199 pub after: Option<String>,
200 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
203 pub secret_rotated: bool,
204}
205
206#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
208pub struct RowDiff {
209 pub id: String,
210 pub status: RowStatus,
211 #[serde(skip_serializing_if = "Vec::is_empty")]
212 pub changes: Vec<FieldChange>,
213}
214
215#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)]
217pub struct DiffSummary {
218 pub create: usize,
219 pub change: usize,
220 pub remove: usize,
221 pub unchanged: usize,
222}
223
224#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
226pub struct SnapshotDiff {
227 pub pipeline: String,
228 #[serde(skip_serializing_if = "Option::is_none")]
230 pub previous_recorded_at: Option<DateTime<Utc>>,
231 pub first_run: bool,
233 pub rows: Vec<RowDiff>,
234 pub summary: DiffSummary,
235}
236
237pub fn diff(previous: Option<&ConfigSnapshot>, current: &ConfigSnapshot) -> SnapshotDiff {
241 let first_run = previous.is_none();
242 let empty = BTreeMap::new();
243 let prev_rows = previous.map(|p| &p.rows).unwrap_or(&empty);
244
245 let ids: BTreeSet<&String> = prev_rows.keys().chain(current.rows.keys()).collect();
246 let mut rows = Vec::new();
247 let mut summary = DiffSummary::default();
248
249 for id in ids {
250 let diff = match (prev_rows.get(id), current.rows.get(id)) {
251 (None, Some(_)) => {
252 summary.create += 1;
253 RowDiff {
254 id: id.clone(),
255 status: RowStatus::New,
256 changes: Vec::new(),
257 }
258 }
259 (Some(_), None) => {
260 summary.remove += 1;
261 RowDiff {
262 id: id.clone(),
263 status: RowStatus::Removed,
264 changes: Vec::new(),
265 }
266 }
267 (Some(prev), Some(curr)) => {
268 let changes = field_changes(prev, curr);
269 if changes.is_empty() {
270 summary.unchanged += 1;
271 RowDiff {
272 id: id.clone(),
273 status: RowStatus::Unchanged,
274 changes,
275 }
276 } else {
277 summary.change += 1;
278 RowDiff {
279 id: id.clone(),
280 status: RowStatus::Changed,
281 changes,
282 }
283 }
284 }
285 (None, None) => unreachable!("id came from the union of both maps"),
286 };
287 rows.push(diff);
288 }
289
290 SnapshotDiff {
291 pipeline: current.pipeline.clone(),
292 previous_recorded_at: previous.map(|p| p.recorded_at),
293 first_run,
294 rows,
295 summary,
296 }
297}
298
299fn field_changes(prev: &RowSnapshot, curr: &RowSnapshot) -> Vec<FieldChange> {
302 let a = flatten_row(prev);
303 let b = flatten_row(curr);
304 let paths: BTreeSet<&String> = a.keys().chain(b.keys()).collect();
305 let mut out = Vec::new();
306 for path in paths {
307 let before = a.get(path);
308 let after = b.get(path);
309 if before != after {
310 let secret_rotated = matches!((before, after), (Some(x), Some(y))
311 if is_secret_token(x) && is_secret_token(y));
312 out.push(FieldChange {
313 path: path.clone(),
314 before: before.cloned(),
315 after: after.cloned(),
316 secret_rotated,
317 });
318 }
319 }
320 out
321}
322
323fn is_secret_token(s: &str) -> bool {
324 s.starts_with("<secret:sha256:")
325}
326
327fn flatten_row(row: &RowSnapshot) -> BTreeMap<String, String> {
329 let mut out = BTreeMap::new();
330 let value = serde_json::to_value(row).unwrap_or(Value::Null);
331 flatten_value("", &value, &mut out);
332 out
333}
334
335fn flatten_value(prefix: &str, value: &Value, out: &mut BTreeMap<String, String>) {
336 match value {
337 Value::Object(map) => {
338 for (k, v) in map {
339 let path = if prefix.is_empty() {
340 k.clone()
341 } else {
342 format!("{prefix}.{k}")
343 };
344 flatten_value(&path, v, out);
345 }
346 }
347 Value::Array(items) => {
348 if items.is_empty() {
350 out.insert(prefix.to_owned(), "[]".to_owned());
351 } else {
352 for (i, v) in items.iter().enumerate() {
353 flatten_value(&format!("{prefix}[{i}]"), v, out);
354 }
355 }
356 }
357 Value::String(s) => {
358 out.insert(prefix.to_owned(), s.clone());
359 }
360 other => {
361 out.insert(prefix.to_owned(), other.to_string());
362 }
363 }
364}
365
366pub fn render_human(d: &SnapshotDiff) -> String {
370 let mut s = String::new();
371 let when = match d.previous_recorded_at {
372 Some(ts) => format!("last run {}", ts.format("%Y-%m-%d %H:%M UTC")),
373 None => "nothing recorded yet — first diff".to_owned(),
374 };
375 s.push_str(&format!("Pipeline: {} ({when})\n\n", d.pipeline));
376
377 if d.first_run {
378 s.push_str(
379 " No prior snapshot. The next `faucet run` will record one; every row below is new.\n\n",
380 );
381 }
382
383 for row in &d.rows {
384 s.push_str(&format!(
385 " {} {:<18} {}\n",
386 row.status.glyph(),
387 row.id,
388 row.status.label()
389 ));
390 for c in &row.changes {
391 if c.secret_rotated {
392 s.push_str(&format!(" {:<28} (secret rotated)\n", c.path));
393 } else {
394 let before = c.before.as_deref().unwrap_or("(absent)");
395 let after = c.after.as_deref().unwrap_or("(absent)");
396 s.push_str(&format!(" {:<28} {before} -> {after}\n", c.path));
397 }
398 }
399 }
400
401 let sm = &d.summary;
402 s.push_str(&format!(
403 "\nSummary: {} to create, {} to change, {} removed, {} unchanged.\n",
404 sm.create, sm.change, sm.remove, sm.unchanged
405 ));
406 s
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412 use crate::serve::history::catalog::ConnectorSnapshot;
413 use serde_json::json;
414
415 fn conn(kind: &str, cfg: Value) -> ConnectorSnapshot {
416 ConnectorSnapshot {
417 kind: kind.into(),
418 config: cfg,
419 }
420 }
421
422 fn row(source_cfg: Value) -> RowSnapshot {
423 RowSnapshot {
424 source: conn("rest", source_cfg),
425 sink: conn("jsonl", json!({"path": "out.jsonl"})),
426 transforms: vec![],
427 state_key: None,
428 delivery_guarantee: "AtLeastOnce".into(),
429 on_error: "stop".into(),
430 dlq: false,
431 }
432 }
433
434 fn snap(rows: Vec<(&str, RowSnapshot)>) -> ConfigSnapshot {
435 ConfigSnapshot {
436 pipeline: "p".into(),
437 recorded_at: DateTime::parse_from_rfc3339("2026-07-18T00:00:00Z")
438 .unwrap()
439 .to_utc(),
440 faucet_version: "0.0.0".into(),
441 rows: rows.into_iter().map(|(k, v)| (k.to_owned(), v)).collect(),
442 }
443 }
444
445 #[test]
446 fn first_run_marks_every_row_new() {
447 let curr = snap(vec![("a", row(json!({"path": "/v1"})))]);
448 let d = diff(None, &curr);
449 assert!(d.first_run);
450 assert_eq!(d.summary.create, 1);
451 assert_eq!(d.rows[0].status, RowStatus::New);
452 }
453
454 #[test]
455 fn detects_added_removed_changed_and_unchanged() {
456 let prev = snap(vec![
457 ("payroll", row(json!({"path": "/v1/pay", "page_size": 100}))),
458 ("benefits", row(json!({"path": "/v1/benefits"}))),
459 ("employees", row(json!({"path": "/v1/emp"}))),
460 ]);
461 let curr = snap(vec![
462 ("people", row(json!({"path": "/v1/people"}))), (
464 "payroll",
465 row(json!({"path": "/v1/payroll", "page_size": 500})),
466 ), ("employees", row(json!({"path": "/v1/emp"}))), ]);
470 let d = diff(Some(&prev), &curr);
471 assert_eq!(d.summary.create, 1);
472 assert_eq!(d.summary.change, 1);
473 assert_eq!(d.summary.remove, 1);
474 assert_eq!(d.summary.unchanged, 1);
475
476 let payroll = d.rows.iter().find(|r| r.id == "payroll").unwrap();
477 assert_eq!(payroll.status, RowStatus::Changed);
478 let paths: Vec<&str> = payroll.changes.iter().map(|c| c.path.as_str()).collect();
479 assert!(paths.contains(&"source.config.path"));
480 assert!(paths.contains(&"source.config.page_size"));
481 let ps = payroll
482 .changes
483 .iter()
484 .find(|c| c.path == "source.config.page_size")
485 .unwrap();
486 assert_eq!(ps.before.as_deref(), Some("100"));
487 assert_eq!(ps.after.as_deref(), Some("500"));
488 }
489
490 #[test]
491 fn secret_rotation_is_surfaced_not_printed() {
492 let prev = snap(vec![(
493 "r",
494 row(json!({"token": "<secret:sha256:aaaaaaaaaaaa>"})),
495 )]);
496 let curr = snap(vec![(
497 "r",
498 row(json!({"token": "<secret:sha256:bbbbbbbbbbbbb>"})),
499 )]);
500 let d = diff(Some(&prev), &curr);
501 let r = &d.rows[0];
502 assert_eq!(r.status, RowStatus::Changed);
503 assert!(r.changes[0].secret_rotated);
504 let text = render_human(&d);
505 assert!(text.contains("secret rotated"), "{text}");
506 assert!(!text.contains("bbbbbbbbbbbb"), "hash should not be printed");
507 }
508
509 #[test]
510 fn redact_value_replaces_registered_secret_with_stable_token() {
511 crate::secrets::registry::register("supersecrettoken");
512 let redacted = redact_value(&json!({"auth": "supersecrettoken", "path": "/v1"}));
513 let token = redacted["auth"].as_str().unwrap();
514 assert!(token.starts_with("<secret:sha256:"), "{token}");
515 assert_eq!(redacted["path"], json!("/v1"));
516 let again = redact_value(&json!("supersecrettoken"));
518 assert_eq!(again.as_str().unwrap(), token);
519 }
520
521 fn expand_config(
523 yaml: &str,
524 ) -> (
525 crate::config::PipelineConfig,
526 Vec<ExpandedNode>,
527 std::path::PathBuf,
528 ) {
529 let dir = tempfile::tempdir().unwrap();
530 let path = dir.path().join("p.yaml");
531 std::fs::write(&path, yaml).unwrap();
532 let cfg = crate::config::PipelineConfig::from_path_tolerating_secrets(&path, None).unwrap();
533 let nodes = crate::expand::expand(&cfg).unwrap();
534 std::mem::forget(dir); (cfg, nodes, path)
536 }
537
538 const REST_TO_JSONL: &str = r#"
539version: 1
540name: mypipe
541pipeline:
542 source:
543 type: rest
544 config:
545 url: https://api.example.com/v1
546 auth: { type: bearer, config: { token: topsecretvalue12345 } }
547 sink:
548 type: jsonl
549 config:
550 path: out.jsonl
551 transforms:
552 - type: flatten
553 config: {}
554"#;
555
556 #[test]
557 fn build_snapshot_shapes_rows_and_redacts_secrets() {
558 crate::secrets::registry::register("topsecretvalue12345");
559 let (cfg, nodes, path) = expand_config(REST_TO_JSONL);
560 assert_eq!(resolve_name(&cfg, Some(&path)), "mypipe");
561 assert_eq!(on_error_str(&cfg.execution), "continue"); let snap = build_snapshot(
564 resolve_name(&cfg, Some(&path)),
565 on_error_str(&cfg.execution),
566 &nodes,
567 Utc::now(),
568 );
569 assert_eq!(snap.pipeline, "mypipe");
570 let r = snap.rows.values().next().unwrap();
571 assert_eq!(r.source.kind, "rest");
572 assert_eq!(r.sink.kind, "jsonl");
573 assert_eq!(r.transforms.len(), 1);
574 let src = serde_json::to_string(&r.source.config).unwrap();
575 assert!(!src.contains("topsecretvalue12345"), "secret leaked: {src}");
576 assert!(src.contains("api.example.com"), "non-secret url must show");
577 }
578
579 #[test]
580 fn resolve_name_falls_back_to_file_stem_then_default() {
581 let (cfg, _n, path) = expand_config(
583 "version: 1\npipeline:\n source: { type: rest, config: { url: https://x/y } }\n sink: { type: jsonl, config: { path: o.jsonl } }\n",
584 );
585 assert_eq!(resolve_name(&cfg, Some(&path)), "p"); assert_eq!(resolve_name(&cfg, None), "pipeline"); }
588
589 #[tokio::test]
590 async fn record_if_ok_records_only_on_success_with_a_catalog() {
591 let (_cfg, nodes, _p) = expand_config(REST_TO_JSONL);
592 let handle = crate::catalog::connect_from_spec(&crate::catalog::CatalogSpec {
593 url: "memory".into(),
594 sample_records: 10,
595 })
596 .await
597 .unwrap();
598
599 record_if_ok(Some(&handle), "mypipe", "stop", &nodes, false, Utc::now()).await;
601 assert!(
602 handle
603 .store
604 .catalog_last_config_snapshot("mypipe")
605 .await
606 .unwrap()
607 .is_none()
608 );
609 record_if_ok(None, "mypipe", "stop", &nodes, true, Utc::now()).await;
611 record_if_ok(Some(&handle), "mypipe", "stop", &nodes, true, Utc::now()).await;
613 let got = handle
614 .store
615 .catalog_last_config_snapshot("mypipe")
616 .await
617 .unwrap()
618 .unwrap();
619 assert_eq!(got.pipeline, "mypipe");
620 assert!(!got.rows.is_empty());
621 }
622}