char/dns-ez

cloudflare dns management via plaintext zonefiles

git clone https://git.t4t.associates/char/dns-ez

Charlotte Sominitial commitcb198f4

main
3.2 KiB97 linesraw
1use std::collections::HashMap;
2
3use crate::cloudflare::LiveRecord;
4use crate::dns::Record;
5
6pub enum Action {
7    Create(Record),
8    // An update is always a TTL change; an rdata change is delete + create.
9    Update {
10        id: String,
11        from_ttl: u32,
12        to: Record,
13    },
14    Delete(LiveRecord),
15}
16
17impl Action {
18    pub fn describe(&self) -> String {
19        match self {
20            Action::Create(r) => format!("+ {r}"),
21            Action::Update { from_ttl, to, .. } => {
22                format!("~ {to} (ttl {from_ttl} -> {})", to.ttl)
23            }
24            Action::Delete(live) => format!("- {}", live.record),
25        }
26    }
27}
28
29/// Diff desired (on disk) against live (at the provider).
30/// Pure set reconciliation on (name, type, rdata): a live record with no
31/// desired counterpart is a deletion, and vice versa; equal keys with
32/// different TTLs are updates.
33pub fn plan(desired: &[Record], live: &[LiveRecord]) -> Vec<Action> {
34    let mut unmatched: HashMap<_, &LiveRecord> = live.iter().map(|l| (l.record.key(), l)).collect();
35    let mut actions = Vec::new();
36    for d in desired {
37        match unmatched.remove(&d.key()) {
38            Some(l) if l.record.ttl != d.ttl => actions.push(Action::Update {
39                id: l.id.clone(),
40                from_ttl: l.record.ttl,
41                to: d.clone(),
42            }),
43            Some(_) => {}
44            None => actions.push(Action::Create(d.clone())),
45        }
46    }
47    actions.extend(unmatched.into_values().cloned().map(Action::Delete));
48    actions.sort_by_key(Action::describe);
49    actions
50}
51
52#[cfg(test)]
53mod tests {
54    use super::*;
55
56    fn live(id: &str, name: &str, rtype: &str, ttl: u32, content: &str) -> LiveRecord {
57        LiveRecord {
58            id: id.to_string(),
59            record: Record::parse(name, rtype, ttl, content).unwrap(),
60        }
61    }
62
63    fn desired(name: &str, rtype: &str, ttl: u32, content: &str) -> Record {
64        Record::parse(name, rtype, ttl, content).unwrap()
65    }
66
67    #[test]
68    fn in_sync_means_no_actions() {
69        let desired = vec![desired("www.example.com", "A", 300, "1.2.3.4")];
70        let live = vec![live("1", "www.example.com", "A", 300, "1.2.3.4")];
71        assert!(plan(&desired, &live).is_empty());
72    }
73
74    #[test]
75    fn plans_create_update_delete() {
76        let desired = vec![
77            desired("www.example.com", "A", 300, "1.2.3.4"), // create
78            desired("api.example.com", "A", 60, "5.6.7.8"),  // ttl update
79        ];
80        let live = vec![
81            live("1", "api.example.com", "A", 300, "5.6.7.8"),
82            live("2", "old.example.com", "TXT", 300, "stale"), // delete
83        ];
84        let actions = plan(&desired, &live);
85        assert!(matches!(actions[0], Action::Create(_)));
86        assert!(matches!(actions[1], Action::Delete(_)));
87        assert!(matches!(actions[2], Action::Update { .. }));
88        assert_eq!(actions.len(), 3);
89    }
90
91    #[test]
92    fn txt_quoting_differences_are_not_changes() {
93        let desired = vec![desired("example.com", "TXT", 300, "v=spf1 -all")];
94        let live = vec![live("1", "example.com", "TXT", 300, "\"v=spf1 -all\"")];
95        assert!(plan(&desired, &live).is_empty());
96    }
97}