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        };
195        plan.summary = Some(plan.summary());
196        plan
197    }
198
199    fn create_op(uid: u128, type_name: &str, slug: &str) -> Op {
200        Op::Create {
201            uid: Uid::from_u128(uid),
202            type_name: TypeName::new(type_name),
203            desired: make_object(uid, type_name, slug, make_attrs(&[("name", json!("X"))])),
204        }
205    }
206
207    fn update_op(uid: u128, type_name: &str, slug: &str, changes: Vec<FieldChange>) -> Op {
208        Op::Update {
209            uid: Uid::from_u128(uid),
210            type_name: TypeName::new(type_name),
211            desired: make_object(uid, type_name, slug, make_attrs(&[("name", json!("new"))])),
212            changes,
213            backend_id: Some(BackendId::Int(100)),
214        }
215    }
216
217    fn delete_op(uid: u128, type_name: &str, slug: &str) -> Op {
218        Op::Delete {
219            uid: Uid::from_u128(uid),
220            type_name: TypeName::new(type_name),
221            key: make_key(slug),
222            backend_id: Some(BackendId::Int(200)),
223        }
224    }
225
226    fn name_change() -> FieldChange {
227        FieldChange {
228            field: "name".to_string(),
229            from: json!("old"),
230            to: json!("new"),
231        }
232    }
233
234    // --- construction ---
235
236    #[test]
237    fn empty_plan_yields_empty_report() {
238        let report = DriftReport::from_plan(&plan_with(vec![]));
239        assert!(report.is_empty());
240        assert_eq!(report.len(), 0);
241        assert!(report.changed.is_empty());
242        assert!(report.missing.is_empty());
243        assert!(report.extra.is_empty());
244    }
245
246    #[test]
247    fn only_changed() {
248        let report = DriftReport::from_plan(&plan_with(vec![update_op(
249            1,
250            "dcim.site",
251            "fra1",
252            vec![name_change()],
253        )]));
254        assert!(!report.is_empty());
255        assert_eq!(report.len(), 1);
256        assert_eq!(report.changed.len(), 1);
257        assert!(report.missing.is_empty());
258        assert!(report.extra.is_empty());
259
260        let entry = &report.changed[0];
261        assert_eq!(entry.type_name, TypeName::new("dcim.site"));
262        assert_eq!(entry.key, make_key("fra1"));
263        assert_eq!(entry.changes.len(), 1);
264        assert_eq!(entry.changes[0].field, "name");
265        assert_eq!(entry.changes[0].from, json!("old"));
266        assert_eq!(entry.changes[0].to, json!("new"));
267    }
268
269    #[test]
270    fn only_missing() {
271        let report = DriftReport::from_plan(&plan_with(vec![create_op(1, "dcim.site", "ams1")]));
272        assert_eq!(report.len(), 1);
273        assert!(report.changed.is_empty());
274        assert_eq!(report.missing.len(), 1);
275        assert!(report.extra.is_empty());
276
277        let entry = &report.missing[0];
278        assert_eq!(entry.type_name, TypeName::new("dcim.site"));
279        assert_eq!(entry.key, make_key("ams1"));
280    }
281
282    #[test]
283    fn only_extra() {
284        let report =
285            DriftReport::from_plan(&plan_with(vec![delete_op(1, "dcim.device", "leaf01")]));
286        assert_eq!(report.len(), 1);
287        assert!(report.changed.is_empty());
288        assert!(report.missing.is_empty());
289        assert_eq!(report.extra.len(), 1);
290
291        let entry = &report.extra[0];
292        assert_eq!(entry.type_name, TypeName::new("dcim.device"));
293        assert_eq!(entry.key, make_key("leaf01"));
294    }
295
296    #[test]
297    fn mixed_categories() {
298        let report = DriftReport::from_plan(&plan_with(vec![
299            create_op(1, "dcim.site", "ams1"),
300            update_op(2, "dcim.site", "fra1", vec![name_change()]),
301            delete_op(3, "dcim.device", "leaf01"),
302        ]));
303        assert_eq!(report.len(), 3);
304        assert_eq!(report.changed.len(), 1);
305        assert_eq!(report.missing.len(), 1);
306        assert_eq!(report.extra.len(), 1);
307    }
308
309    #[test]
310    fn no_drift_when_plan_has_only_noop_updates() {
311        // an update with no field changes still surfaces as a changed entry with
312        // an empty change list; the report mirrors the plan exactly.
313        let report =
314            DriftReport::from_plan(&plan_with(vec![update_op(1, "dcim.site", "fra1", vec![])]));
315        assert_eq!(report.changed.len(), 1);
316        assert!(report.changed[0].changes.is_empty());
317    }
318
319    #[test]
320    fn from_ref_matches_from_plan() {
321        let plan = plan_with(vec![create_op(1, "dcim.site", "ams1")]);
322        let via_from: DriftReport = (&plan).into();
323        assert_eq!(via_from, DriftReport::from_plan(&plan));
324    }
325
326    // --- display ---
327
328    #[test]
329    fn display_empty_is_human_readable() {
330        let report = DriftReport::default();
331        assert_eq!(
332            report.to_string(),
333            "no drift: observed backend state matches declared intent"
334        );
335    }
336
337    #[test]
338    fn display_groups_by_category() {
339        let report = DriftReport::from_plan(&plan_with(vec![
340            create_op(1, "dcim.site", "ams1"),
341            update_op(2, "dcim.site", "fra1", vec![name_change()]),
342            delete_op(3, "dcim.device", "leaf01"),
343        ]));
344        let text = report.to_string();
345        assert!(text.starts_with("drift report: 1 changed, 1 missing, 1 extra"));
346        assert!(text.contains("changed (present but diverged):"));
347        assert!(text.contains("missing (declared in intent, absent from backend):"));
348        assert!(text.contains("extra (present in backend, not declared in intent):"));
349        // field diff is rendered as `field: from -> to` with json-rendered values.
350        assert!(text.contains("name: \"old\" -> \"new\""));
351        // keys are rendered alongside their type.
352        assert!(text.contains("dcim.device"));
353        assert!(text.contains("leaf01"));
354        // no trailing newline, so the cli can println! cleanly.
355        assert!(!text.ends_with('\n'));
356    }
357
358    #[test]
359    fn display_omits_empty_categories() {
360        let report = DriftReport::from_plan(&plan_with(vec![create_op(1, "dcim.site", "ams1")]));
361        let text = report.to_string();
362        assert!(text.contains("missing (declared in intent, absent from backend):"));
363        assert!(!text.contains("changed (present but diverged):"));
364        assert!(!text.contains("extra (present in backend, not declared in intent):"));
365    }
366
367    // --- serialization (machine-readable) ---
368
369    #[test]
370    fn serializes_to_json() {
371        let report = DriftReport::from_plan(&plan_with(vec![
372            create_op(1, "dcim.site", "ams1"),
373            update_op(2, "dcim.site", "fra1", vec![name_change()]),
374            delete_op(3, "dcim.device", "leaf01"),
375        ]));
376        let value: serde_json::Value = serde_json::to_value(&report).unwrap();
377        assert_eq!(value["changed"].as_array().unwrap().len(), 1);
378        assert_eq!(value["missing"].as_array().unwrap().len(), 1);
379        assert_eq!(value["extra"].as_array().unwrap().len(), 1);
380        assert_eq!(value["changed"][0]["type_name"], "dcim.site");
381        assert_eq!(value["changed"][0]["changes"][0]["field"], "name");
382        assert_eq!(value["changed"][0]["changes"][0]["from"], "old");
383        assert_eq!(value["changed"][0]["changes"][0]["to"], "new");
384        assert_eq!(value["missing"][0]["key"]["slug"], "ams1");
385        assert_eq!(value["extra"][0]["key"]["slug"], "leaf01");
386    }
387}