use crate::types::{FieldChange, Op, Plan};
use alembic_core::{key_string, Key, TypeName};
use serde::Serialize;
use std::fmt;
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ChangedEntry {
pub type_name: TypeName,
pub key: Key,
pub changes: Vec<FieldChange>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct DriftEntry {
pub type_name: TypeName,
pub key: Key,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct DriftReport {
pub changed: Vec<ChangedEntry>,
pub missing: Vec<DriftEntry>,
pub extra: Vec<DriftEntry>,
}
impl DriftReport {
pub fn from_plan(plan: &Plan) -> Self {
let mut report = DriftReport::default();
for op in &plan.ops {
match op {
Op::Update {
type_name,
desired,
changes,
..
} => report.changed.push(ChangedEntry {
type_name: type_name.clone(),
key: desired.key.clone(),
changes: changes.clone(),
}),
Op::Create {
type_name, desired, ..
} => report.missing.push(DriftEntry {
type_name: type_name.clone(),
key: desired.key.clone(),
}),
Op::Delete { type_name, key, .. } => report.extra.push(DriftEntry {
type_name: type_name.clone(),
key: key.clone(),
}),
}
}
report
}
pub fn len(&self) -> usize {
self.changed.len() + self.missing.len() + self.extra.len()
}
pub fn is_empty(&self) -> bool {
self.changed.is_empty() && self.missing.is_empty() && self.extra.is_empty()
}
}
impl From<&Plan> for DriftReport {
fn from(plan: &Plan) -> Self {
DriftReport::from_plan(plan)
}
}
impl fmt::Display for DriftReport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.is_empty() {
return write!(
f,
"no drift: observed backend state matches declared intent"
);
}
write!(
f,
"drift report: {} changed, {} missing, {} extra",
self.changed.len(),
self.missing.len(),
self.extra.len()
)?;
if !self.changed.is_empty() {
write!(f, "\n\nchanged (present but diverged):")?;
for entry in &self.changed {
write!(f, "\n {} {}", entry.type_name, key_string(&entry.key))?;
for change in &entry.changes {
write!(
f,
"\n {}: {} -> {}",
change.field, change.from, change.to
)?;
}
}
}
if !self.missing.is_empty() {
write!(f, "\n\nmissing (declared in intent, absent from backend):")?;
for entry in &self.missing {
write!(f, "\n {} {}", entry.type_name, key_string(&entry.key))?;
}
}
if !self.extra.is_empty() {
write!(f, "\n\nextra (present in backend, not declared in intent):")?;
for entry in &self.extra {
write!(f, "\n {} {}", entry.type_name, key_string(&entry.key))?;
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{BackendId, Op, Plan};
use alembic_core::{JsonMap, Key, Object, Schema, TypeName, Uid};
use serde_json::json;
use std::collections::BTreeMap;
fn make_key(slug: &str) -> Key {
let mut k = BTreeMap::new();
k.insert("slug".to_string(), json!(slug));
Key::from(k)
}
fn make_attrs(pairs: &[(&str, serde_json::Value)]) -> JsonMap {
let mut m = BTreeMap::new();
for (k, v) in pairs {
m.insert(k.to_string(), v.clone());
}
JsonMap::from(m)
}
fn make_object(uid: u128, type_name: &str, slug: &str, attrs: JsonMap) -> Object {
Object::new(
Uid::from_u128(uid),
TypeName::new(type_name),
make_key(slug),
attrs,
)
.unwrap()
}
fn empty_schema() -> Schema {
Schema {
types: BTreeMap::new(),
}
}
fn plan_with(ops: Vec<Op>) -> Plan {
let mut plan = Plan {
schema: empty_schema(),
ops,
summary: None,
};
plan.summary = Some(plan.summary());
plan
}
fn create_op(uid: u128, type_name: &str, slug: &str) -> Op {
Op::Create {
uid: Uid::from_u128(uid),
type_name: TypeName::new(type_name),
desired: make_object(uid, type_name, slug, make_attrs(&[("name", json!("X"))])),
}
}
fn update_op(uid: u128, type_name: &str, slug: &str, changes: Vec<FieldChange>) -> Op {
Op::Update {
uid: Uid::from_u128(uid),
type_name: TypeName::new(type_name),
desired: make_object(uid, type_name, slug, make_attrs(&[("name", json!("new"))])),
changes,
backend_id: Some(BackendId::Int(100)),
}
}
fn delete_op(uid: u128, type_name: &str, slug: &str) -> Op {
Op::Delete {
uid: Uid::from_u128(uid),
type_name: TypeName::new(type_name),
key: make_key(slug),
backend_id: Some(BackendId::Int(200)),
}
}
fn name_change() -> FieldChange {
FieldChange {
field: "name".to_string(),
from: json!("old"),
to: json!("new"),
}
}
#[test]
fn empty_plan_yields_empty_report() {
let report = DriftReport::from_plan(&plan_with(vec![]));
assert!(report.is_empty());
assert_eq!(report.len(), 0);
assert!(report.changed.is_empty());
assert!(report.missing.is_empty());
assert!(report.extra.is_empty());
}
#[test]
fn only_changed() {
let report = DriftReport::from_plan(&plan_with(vec![update_op(
1,
"dcim.site",
"fra1",
vec![name_change()],
)]));
assert!(!report.is_empty());
assert_eq!(report.len(), 1);
assert_eq!(report.changed.len(), 1);
assert!(report.missing.is_empty());
assert!(report.extra.is_empty());
let entry = &report.changed[0];
assert_eq!(entry.type_name, TypeName::new("dcim.site"));
assert_eq!(entry.key, make_key("fra1"));
assert_eq!(entry.changes.len(), 1);
assert_eq!(entry.changes[0].field, "name");
assert_eq!(entry.changes[0].from, json!("old"));
assert_eq!(entry.changes[0].to, json!("new"));
}
#[test]
fn only_missing() {
let report = DriftReport::from_plan(&plan_with(vec![create_op(1, "dcim.site", "ams1")]));
assert_eq!(report.len(), 1);
assert!(report.changed.is_empty());
assert_eq!(report.missing.len(), 1);
assert!(report.extra.is_empty());
let entry = &report.missing[0];
assert_eq!(entry.type_name, TypeName::new("dcim.site"));
assert_eq!(entry.key, make_key("ams1"));
}
#[test]
fn only_extra() {
let report =
DriftReport::from_plan(&plan_with(vec![delete_op(1, "dcim.device", "leaf01")]));
assert_eq!(report.len(), 1);
assert!(report.changed.is_empty());
assert!(report.missing.is_empty());
assert_eq!(report.extra.len(), 1);
let entry = &report.extra[0];
assert_eq!(entry.type_name, TypeName::new("dcim.device"));
assert_eq!(entry.key, make_key("leaf01"));
}
#[test]
fn mixed_categories() {
let report = DriftReport::from_plan(&plan_with(vec![
create_op(1, "dcim.site", "ams1"),
update_op(2, "dcim.site", "fra1", vec![name_change()]),
delete_op(3, "dcim.device", "leaf01"),
]));
assert_eq!(report.len(), 3);
assert_eq!(report.changed.len(), 1);
assert_eq!(report.missing.len(), 1);
assert_eq!(report.extra.len(), 1);
}
#[test]
fn no_drift_when_plan_has_only_noop_updates() {
let report =
DriftReport::from_plan(&plan_with(vec![update_op(1, "dcim.site", "fra1", vec![])]));
assert_eq!(report.changed.len(), 1);
assert!(report.changed[0].changes.is_empty());
}
#[test]
fn from_ref_matches_from_plan() {
let plan = plan_with(vec![create_op(1, "dcim.site", "ams1")]);
let via_from: DriftReport = (&plan).into();
assert_eq!(via_from, DriftReport::from_plan(&plan));
}
#[test]
fn display_empty_is_human_readable() {
let report = DriftReport::default();
assert_eq!(
report.to_string(),
"no drift: observed backend state matches declared intent"
);
}
#[test]
fn display_groups_by_category() {
let report = DriftReport::from_plan(&plan_with(vec![
create_op(1, "dcim.site", "ams1"),
update_op(2, "dcim.site", "fra1", vec![name_change()]),
delete_op(3, "dcim.device", "leaf01"),
]));
let text = report.to_string();
assert!(text.starts_with("drift report: 1 changed, 1 missing, 1 extra"));
assert!(text.contains("changed (present but diverged):"));
assert!(text.contains("missing (declared in intent, absent from backend):"));
assert!(text.contains("extra (present in backend, not declared in intent):"));
assert!(text.contains("name: \"old\" -> \"new\""));
assert!(text.contains("dcim.device"));
assert!(text.contains("leaf01"));
assert!(!text.ends_with('\n'));
}
#[test]
fn display_omits_empty_categories() {
let report = DriftReport::from_plan(&plan_with(vec![create_op(1, "dcim.site", "ams1")]));
let text = report.to_string();
assert!(text.contains("missing (declared in intent, absent from backend):"));
assert!(!text.contains("changed (present but diverged):"));
assert!(!text.contains("extra (present in backend, not declared in intent):"));
}
#[test]
fn serializes_to_json() {
let report = DriftReport::from_plan(&plan_with(vec![
create_op(1, "dcim.site", "ams1"),
update_op(2, "dcim.site", "fra1", vec![name_change()]),
delete_op(3, "dcim.device", "leaf01"),
]));
let value: serde_json::Value = serde_json::to_value(&report).unwrap();
assert_eq!(value["changed"].as_array().unwrap().len(), 1);
assert_eq!(value["missing"].as_array().unwrap().len(), 1);
assert_eq!(value["extra"].as_array().unwrap().len(), 1);
assert_eq!(value["changed"][0]["type_name"], "dcim.site");
assert_eq!(value["changed"][0]["changes"][0]["field"], "name");
assert_eq!(value["changed"][0]["changes"][0]["from"], "old");
assert_eq!(value["changed"][0]["changes"][0]["to"], "new");
assert_eq!(value["missing"][0]["key"]["slug"], "ams1");
assert_eq!(value["extra"][0]["key"]["slug"], "leaf01");
}
}