use std::collections::HashMap; use crate::cloudflare::LiveRecord; use crate::dns::Record; pub enum Action { Create(Record), // An update is always a TTL change; an rdata change is delete + create. Update { id: String, from_ttl: u32, to: Record, }, Delete(LiveRecord), } impl Action { pub fn describe(&self) -> String { match self { Action::Create(r) => format!("+ {r}"), Action::Update { from_ttl, to, .. } => { format!("~ {to} (ttl {from_ttl} -> {})", to.ttl) } Action::Delete(live) => format!("- {}", live.record), } } } /// Diff desired (on disk) against live (at the provider). /// Pure set reconciliation on (name, type, rdata): a live record with no /// desired counterpart is a deletion, and vice versa; equal keys with /// different TTLs are updates. pub fn plan(desired: &[Record], live: &[LiveRecord]) -> Vec { let mut unmatched: HashMap<_, &LiveRecord> = live.iter().map(|l| (l.record.key(), l)).collect(); let mut actions = Vec::new(); for d in desired { match unmatched.remove(&d.key()) { Some(l) if l.record.ttl != d.ttl => actions.push(Action::Update { id: l.id.clone(), from_ttl: l.record.ttl, to: d.clone(), }), Some(_) => {} None => actions.push(Action::Create(d.clone())), } } actions.extend(unmatched.into_values().cloned().map(Action::Delete)); actions.sort_by_key(Action::describe); actions } #[cfg(test)] mod tests { use super::*; fn live(id: &str, name: &str, rtype: &str, ttl: u32, content: &str) -> LiveRecord { LiveRecord { id: id.to_string(), record: Record::parse(name, rtype, ttl, content).unwrap(), } } fn desired(name: &str, rtype: &str, ttl: u32, content: &str) -> Record { Record::parse(name, rtype, ttl, content).unwrap() } #[test] fn in_sync_means_no_actions() { let desired = vec![desired("www.example.com", "A", 300, "1.2.3.4")]; let live = vec![live("1", "www.example.com", "A", 300, "1.2.3.4")]; assert!(plan(&desired, &live).is_empty()); } #[test] fn plans_create_update_delete() { let desired = vec![ desired("www.example.com", "A", 300, "1.2.3.4"), // create desired("api.example.com", "A", 60, "5.6.7.8"), // ttl update ]; let live = vec![ live("1", "api.example.com", "A", 300, "5.6.7.8"), live("2", "old.example.com", "TXT", 300, "stale"), // delete ]; let actions = plan(&desired, &live); assert!(matches!(actions[0], Action::Create(_))); assert!(matches!(actions[1], Action::Delete(_))); assert!(matches!(actions[2], Action::Update { .. })); assert_eq!(actions.len(), 3); } #[test] fn txt_quoting_differences_are_not_changes() { let desired = vec![desired("example.com", "TXT", 300, "v=spf1 -all")]; let live = vec![live("1", "example.com", "TXT", 300, "\"v=spf1 -all\"")]; assert!(plan(&desired, &live).is_empty()); } }