Skip to main content

faucet_cli/catalog/
snapshot.rs

1//! Config-change preview (#374): build a redacted snapshot of the resolved +
2//! expanded config, and diff the current config against the last recorded one.
3//!
4//! `faucet run` / `replicate` / `schedule` record a [`ConfigSnapshot`] on every
5//! successful invocation (best-effort — see [`super::record_config_snapshot`]).
6//! `faucet plan --diff` re-expands the current config, loads the last snapshot,
7//! and renders a **semantic, per-row** diff (rows created / changed / removed,
8//! and within each changed row the exact fields that differ).
9//!
10//! Two properties make this trustworthy where a raw YAML text-diff is not:
11//!
12//! - **Resolved, not textual.** The snapshot is built from the *expanded* nodes
13//!   (post `extends`/`vars`/`${env:}` interpolation and matrix fan-out), so a
14//!   one-line `${vars.x}` edit that fans out across many rows shows up as the
15//!   real per-row effect, and two textually-different files that resolve to the
16//!   same movement show no diff.
17//! - **Secret-safe.** Every secret-sourced value is replaced with a stable
18//!   `<secret:sha256:…>` token before storage (see [`redact_value`]). No secret
19//!   material is ever persisted, and a rotated secret surfaces as a changed hash
20//!   ("secret rotated") rather than printing either value.
21
22use 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
34/// The canonical pipeline name used to key snapshots — identical logic to the
35/// `run` / `replicate` / `schedule` observability label, so the record side and
36/// the `plan --diff` side always agree on the key: the explicit `name:`, else
37/// the config file stem, else `"pipeline"`.
38pub 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
48/// The `execution.on_error` policy as a stable string (`"stop"` / `"continue"`).
49pub 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
56/// Build a redacted snapshot from the resolved + expanded nodes. `pipeline` and
57/// `on_error` are passed in (via [`resolve_name`] / [`on_error_str`]) so every
58/// call site keys snapshots identically; `clock` is the record time (passed in
59/// so callers stay deterministic in tests).
60pub 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
107/// Build + record the config snapshot for a run that finished cleanly, when a
108/// catalog is configured. Best-effort — never fails the run (mirrors
109/// [`super::record`]). This is the single place `run` / `replicate` /
110/// `schedule` funnel through, so the record logic is exercised by one test
111/// rather than three untested call sites.
112pub 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
130/// Recursively replace every secret-sourced string in `value` with a stable
131/// `<secret:sha256:…>` token. Non-secret strings pass through verbatim, so real
132/// config changes (paths, table names, page sizes) stay visible in the diff.
133pub 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
148/// The stable, non-reversible token a secret value is replaced with. The 12-hex
149/// prefix of sha256 is enough to detect rotation without bloating the snapshot.
150fn 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// ── Diff ─────────────────────────────────────────────────────────────────────
157
158/// Per-row status in a config diff, `terraform plan`-style.
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
160#[serde(rename_all = "snake_case")]
161pub enum RowStatus {
162    /// Present now, absent in the last snapshot — will be created.
163    New,
164    /// Present in both, with field-level differences.
165    Changed,
166    /// Present in the last snapshot, absent now — no longer part of the run set.
167    Removed,
168    /// Present in both, identical.
169    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/// One field-level change within a changed row.
192#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
193pub struct FieldChange {
194    /// Dotted path within the row snapshot (e.g. `source.config.page_size`).
195    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    /// True when both sides are secret tokens that differ (a rotation), so the
201    /// renderer can say "secret rotated" instead of printing hashes.
202    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
203    pub secret_rotated: bool,
204}
205
206/// One row's entry in the diff.
207#[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/// Roll-up counts, `terraform plan`-style.
216#[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/// The full config diff, serialized verbatim by `--json`.
225#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
226pub struct SnapshotDiff {
227    pub pipeline: String,
228    /// When the compared snapshot was recorded (`None` on a first-ever diff).
229    #[serde(skip_serializing_if = "Option::is_none")]
230    pub previous_recorded_at: Option<DateTime<Utc>>,
231    /// True when no prior snapshot existed — every row is `New`.
232    pub first_run: bool,
233    pub rows: Vec<RowDiff>,
234    pub summary: DiffSummary,
235}
236
237/// Diff `current` against the last recorded snapshot (`previous`). With no
238/// previous snapshot every row is `New` (first-run, like `terraform plan` on
239/// fresh state).
240pub 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
299/// Flatten a row to `dotted.path -> scalar` and compare, producing one
300/// [`FieldChange`] per differing leaf.
301fn 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
327/// Serialize a row and flatten every leaf to a `dotted.path -> String` map.
328fn 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            // Empty arrays are a meaningful leaf (e.g. `transforms: []`).
349            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
366// ── Render ───────────────────────────────────────────────────────────────────
367
368/// Render the diff as human-readable text (the default `plan --diff` output).
369pub 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"}))), // NEW
463            (
464                "payroll",
465                row(json!({"path": "/v1/payroll", "page_size": 500})),
466            ), // CHANGED
467            ("employees", row(json!({"path": "/v1/emp"}))), // unchanged
468                                                            // benefits REMOVED
469        ]);
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        // Stable: same value → same token.
517        let again = redact_value(&json!("supersecrettoken"));
518        assert_eq!(again.as_str().unwrap(), token);
519    }
520
521    /// Write a config to a temp file and return (config, expanded nodes, path).
522    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); // keep the temp file alive for the returned path
535        (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"); // default
562
563        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        // No `name:` → file stem.
582        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"); // p.yaml
586        assert_eq!(resolve_name(&cfg, None), "pipeline"); // nothing → default
587    }
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        // Failure → nothing recorded.
600        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        // No catalog → no-op (must not panic).
610        record_if_ok(None, "mypipe", "stop", &nodes, true, Utc::now()).await;
611        // Success + catalog → recorded.
612        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}