Skip to main content

alembic_engine/
drift.rs

1//! read-only drift report: desired-vs-observed, surfaced from a plan.
2//!
3//! a [`DriftReport`] is the [`Plan`] diff (declared intent vs observed backend
4//! state) presented as a standalone, human- and machine-readable report. it is
5//! one-way by construction: it only ever describes how observed state diverges
6//! from intent and never writes observed state back into the inventory or state
7//! store. there is deliberately no "adopt observed" mode.
8
9use crate::types::{FieldChange, Op, Plan};
10use alembic_core::{key_string, Key, TypeName};
11use serde::Serialize;
12use std::fmt;
13
14/// an object present in both intent and backend, but with diverging fields.
15#[derive(Debug, Clone, PartialEq, Serialize)]
16pub struct ChangedEntry {
17    /// object type.
18    pub type_name: TypeName,
19    /// human key identifying the object.
20    pub key: Key,
21    /// per-field divergences (field, observed `from`, desired `to`).
22    pub changes: Vec<FieldChange>,
23}
24
25/// an object that exists on only one side of the diff (missing or extra).
26#[derive(Debug, Clone, PartialEq, Serialize)]
27pub struct DriftEntry {
28    /// object type.
29    pub type_name: TypeName,
30    /// human key identifying the object.
31    pub key: Key,
32}
33
34/// read-only report of how observed backend state diverges from declared intent.
35///
36/// built from a [`Plan`] (the diff already computed by `plan()`): updates become
37/// [`changed`](DriftReport::changed), creates become
38/// [`missing`](DriftReport::missing) (declared but absent from the backend), and
39/// deletes become [`extra`](DriftReport::extra) (present on the backend but not
40/// declared). it is a one-way projection only, never a write path.
41#[derive(Debug, Clone, Default, PartialEq, Serialize)]
42pub struct DriftReport {
43    /// objects present in both intent and backend but with diverging fields.
44    pub changed: Vec<ChangedEntry>,
45    /// objects declared in intent but absent from the backend.
46    pub missing: Vec<DriftEntry>,
47    /// objects present in the backend but not declared in intent.
48    pub extra: Vec<DriftEntry>,
49}
50
51impl DriftReport {
52    /// build a drift report from a plan's operations.
53    ///
54    /// this reads the plan only; it never mutates the plan, inventory, or any
55    /// backend/state, honoring the one-way invariant.
56    pub fn from_plan(plan: &Plan) -> Self {
57        let mut report = DriftReport::default();
58        for op in &plan.ops {
59            match op {
60                Op::Update {
61                    type_name,
62                    desired,
63                    changes,
64                    ..
65                } => report.changed.push(ChangedEntry {
66                    type_name: type_name.clone(),
67                    key: desired.key.clone(),
68                    changes: changes.clone(),
69                }),
70                Op::Create {
71                    type_name, desired, ..
72                } => report.missing.push(DriftEntry {
73                    type_name: type_name.clone(),
74                    key: desired.key.clone(),
75                }),
76                Op::Delete { type_name, key, .. } => report.extra.push(DriftEntry {
77                    type_name: type_name.clone(),
78                    key: key.clone(),
79                }),
80            }
81        }
82        report
83    }
84
85    /// total number of drifted objects across all categories.
86    pub fn len(&self) -> usize {
87        self.changed.len() + self.missing.len() + self.extra.len()
88    }
89
90    /// true when observed state matches declared intent (no drift in any category).
91    pub fn is_empty(&self) -> bool {
92        self.changed.is_empty() && self.missing.is_empty() && self.extra.is_empty()
93    }
94}
95
96impl From<&Plan> for DriftReport {
97    fn from(plan: &Plan) -> Self {
98        DriftReport::from_plan(plan)
99    }
100}
101
102impl fmt::Display for DriftReport {
103    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104        if self.is_empty() {
105            return write!(
106                f,
107                "no drift: observed backend state matches declared intent"
108            );
109        }
110
111        write!(
112            f,
113            "drift report: {} changed, {} missing, {} extra",
114            self.changed.len(),
115            self.missing.len(),
116            self.extra.len()
117        )?;
118
119        if !self.changed.is_empty() {
120            write!(f, "\n\nchanged (present but diverged):")?;
121            for entry in &self.changed {
122                write!(f, "\n  {} {}", entry.type_name, key_string(&entry.key))?;
123                for change in &entry.changes {
124                    write!(
125                        f,
126                        "\n    {}: {} -> {}",
127                        change.field, change.from, change.to
128                    )?;
129                }
130            }
131        }
132
133        if !self.missing.is_empty() {
134            write!(f, "\n\nmissing (declared in intent, absent from backend):")?;
135            for entry in &self.missing {
136                write!(f, "\n  {} {}", entry.type_name, key_string(&entry.key))?;
137            }
138        }
139
140        if !self.extra.is_empty() {
141            write!(f, "\n\nextra (present in backend, not declared in intent):")?;
142            for entry in &self.extra {
143                write!(f, "\n  {} {}", entry.type_name, key_string(&entry.key))?;
144            }
145        }
146
147        Ok(())
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use crate::types::{BackendId, Op, Plan};
155    use alembic_core::{JsonMap, Key, Object, Schema, TypeName, Uid};
156    use serde_json::json;
157    use std::collections::BTreeMap;
158
159    fn make_key(slug: &str) -> Key {
160        let mut k = BTreeMap::new();
161        k.insert("slug".to_string(), json!(slug));
162        Key::from(k)
163    }
164
165    fn make_attrs(pairs: &[(&str, serde_json::Value)]) -> JsonMap {
166        let mut m = BTreeMap::new();
167        for (k, v) in pairs {
168            m.insert(k.to_string(), v.clone());
169        }
170        JsonMap::from(m)
171    }
172
173    fn make_object(uid: u128, type_name: &str, slug: &str, attrs: JsonMap) -> Object {
174        Object::new(
175            Uid::from_u128(uid),
176            TypeName::new(type_name),
177            make_key(slug),
178            attrs,
179        )
180        .unwrap()
181    }
182
183    fn empty_schema() -> Schema {
184        Schema {
185            types: BTreeMap::new(),
186        }
187    }
188
189    fn plan_with(ops: Vec<Op>) -> Plan {
190        let mut plan = Plan {
191            schema: empty_schema(),
192            ops,
193            summary: None,
194            schema_preview: None,
195        };
196        plan.summary = Some(plan.summary());
197        plan
198    }
199
200    fn create_op(uid: u128, type_name: &str, slug: &str) -> Op {
201        Op::Create {
202            uid: Uid::from_u128(uid),
203            type_name: TypeName::new(type_name),
204            desired: make_object(uid, type_name, slug, make_attrs(&[("name", json!("X"))])),
205        }
206    }
207
208    fn update_op(uid: u128, type_name: &str, slug: &str, changes: Vec<FieldChange>) -> Op {
209        Op::Update {
210            uid: Uid::from_u128(uid),
211            type_name: TypeName::new(type_name),
212            desired: make_object(uid, type_name, slug, make_attrs(&[("name", json!("new"))])),
213            changes,
214            backend_id: Some(BackendId::Int(100)),
215        }
216    }
217
218    fn delete_op(uid: u128, type_name: &str, slug: &str) -> Op {
219        Op::Delete {
220            uid: Uid::from_u128(uid),
221            type_name: TypeName::new(type_name),
222            key: make_key(slug),
223            backend_id: Some(BackendId::Int(200)),
224        }
225    }
226
227    fn name_change() -> FieldChange {
228        FieldChange {
229            field: "name".to_string(),
230            from: json!("old"),
231            to: json!("new"),
232        }
233    }
234
235    // --- construction ---
236
237    #[test]
238    fn empty_plan_yields_empty_report() {
239        let report = DriftReport::from_plan(&plan_with(vec![]));
240        assert!(report.is_empty());
241        assert_eq!(report.len(), 0);
242        assert!(report.changed.is_empty());
243        assert!(report.missing.is_empty());
244        assert!(report.extra.is_empty());
245    }
246
247    #[test]
248    fn only_changed() {
249        let report = DriftReport::from_plan(&plan_with(vec![update_op(
250            1,
251            "dcim.site",
252            "fra1",
253            vec![name_change()],
254        )]));
255        assert!(!report.is_empty());
256        assert_eq!(report.len(), 1);
257        assert_eq!(report.changed.len(), 1);
258        assert!(report.missing.is_empty());
259        assert!(report.extra.is_empty());
260
261        let entry = &report.changed[0];
262        assert_eq!(entry.type_name, TypeName::new("dcim.site"));
263        assert_eq!(entry.key, make_key("fra1"));
264        assert_eq!(entry.changes.len(), 1);
265        assert_eq!(entry.changes[0].field, "name");
266        assert_eq!(entry.changes[0].from, json!("old"));
267        assert_eq!(entry.changes[0].to, json!("new"));
268    }
269
270    #[test]
271    fn only_missing() {
272        let report = DriftReport::from_plan(&plan_with(vec![create_op(1, "dcim.site", "ams1")]));
273        assert_eq!(report.len(), 1);
274        assert!(report.changed.is_empty());
275        assert_eq!(report.missing.len(), 1);
276        assert!(report.extra.is_empty());
277
278        let entry = &report.missing[0];
279        assert_eq!(entry.type_name, TypeName::new("dcim.site"));
280        assert_eq!(entry.key, make_key("ams1"));
281    }
282
283    #[test]
284    fn only_extra() {
285        let report =
286            DriftReport::from_plan(&plan_with(vec![delete_op(1, "dcim.device", "leaf01")]));
287        assert_eq!(report.len(), 1);
288        assert!(report.changed.is_empty());
289        assert!(report.missing.is_empty());
290        assert_eq!(report.extra.len(), 1);
291
292        let entry = &report.extra[0];
293        assert_eq!(entry.type_name, TypeName::new("dcim.device"));
294        assert_eq!(entry.key, make_key("leaf01"));
295    }
296
297    #[test]
298    fn mixed_categories() {
299        let report = DriftReport::from_plan(&plan_with(vec![
300            create_op(1, "dcim.site", "ams1"),
301            update_op(2, "dcim.site", "fra1", vec![name_change()]),
302            delete_op(3, "dcim.device", "leaf01"),
303        ]));
304        assert_eq!(report.len(), 3);
305        assert_eq!(report.changed.len(), 1);
306        assert_eq!(report.missing.len(), 1);
307        assert_eq!(report.extra.len(), 1);
308    }
309
310    #[test]
311    fn no_drift_when_plan_has_only_noop_updates() {
312        // an update with no field changes still surfaces as a changed entry with
313        // an empty change list; the report mirrors the plan exactly.
314        let report =
315            DriftReport::from_plan(&plan_with(vec![update_op(1, "dcim.site", "fra1", vec![])]));
316        assert_eq!(report.changed.len(), 1);
317        assert!(report.changed[0].changes.is_empty());
318    }
319
320    // --- display ---
321
322    #[test]
323    fn display_empty_is_human_readable() {
324        let report = DriftReport::default();
325        assert_eq!(
326            report.to_string(),
327            "no drift: observed backend state matches declared intent"
328        );
329    }
330
331    #[test]
332    fn display_groups_by_category() {
333        let report = DriftReport::from_plan(&plan_with(vec![
334            create_op(1, "dcim.site", "ams1"),
335            update_op(2, "dcim.site", "fra1", vec![name_change()]),
336            delete_op(3, "dcim.device", "leaf01"),
337        ]));
338        let text = report.to_string();
339        assert!(text.starts_with("drift report: 1 changed, 1 missing, 1 extra"));
340        assert!(text.contains("changed (present but diverged):"));
341        assert!(text.contains("missing (declared in intent, absent from backend):"));
342        assert!(text.contains("extra (present in backend, not declared in intent):"));
343        // field diff is rendered as `field: from -> to` with json-rendered values.
344        assert!(text.contains("name: \"old\" -> \"new\""));
345        // keys are rendered alongside their type.
346        assert!(text.contains("dcim.device"));
347        assert!(text.contains("leaf01"));
348        // no trailing newline, so the cli can println! cleanly.
349        assert!(!text.ends_with('\n'));
350    }
351
352    #[test]
353    fn display_omits_empty_categories() {
354        let report = DriftReport::from_plan(&plan_with(vec![create_op(1, "dcim.site", "ams1")]));
355        let text = report.to_string();
356        assert!(text.contains("missing (declared in intent, absent from backend):"));
357        assert!(!text.contains("changed (present but diverged):"));
358        assert!(!text.contains("extra (present in backend, not declared in intent):"));
359    }
360
361    // --- serialization (machine-readable) ---
362
363    #[test]
364    fn serializes_to_json() {
365        let report = DriftReport::from_plan(&plan_with(vec![
366            create_op(1, "dcim.site", "ams1"),
367            update_op(2, "dcim.site", "fra1", vec![name_change()]),
368            delete_op(3, "dcim.device", "leaf01"),
369        ]));
370        let value: serde_json::Value = serde_json::to_value(&report).unwrap();
371        assert_eq!(value["changed"].as_array().unwrap().len(), 1);
372        assert_eq!(value["missing"].as_array().unwrap().len(), 1);
373        assert_eq!(value["extra"].as_array().unwrap().len(), 1);
374        assert_eq!(value["changed"][0]["type_name"], "dcim.site");
375        assert_eq!(value["changed"][0]["changes"][0]["field"], "name");
376        assert_eq!(value["changed"][0]["changes"][0]["from"], "old");
377        assert_eq!(value["changed"][0]["changes"][0]["to"], "new");
378        assert_eq!(value["missing"][0]["key"]["slug"], "ams1");
379        assert_eq!(value["extra"][0]["key"]["slug"], "leaf01");
380    }
381}