Skip to main content

xbp_cli/
dns_inventory_cache.rs

1use crate::provider_support::{
2    CloudflareDnsRecord, CloudflareDnsSettings, CloudflareDnssec, CloudflareZone,
3    DomainAvailability, RegisteredDomain,
4};
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8
9#[derive(Debug, Clone, Serialize, Deserialize, Default)]
10pub struct DnsInventoryCache {
11    #[serde(default)]
12    pub cloudflare: Option<CloudflareDnsInventoryCache>,
13}
14
15#[derive(Debug, Clone, Serialize, Deserialize, Default)]
16pub struct CloudflareDnsInventoryCache {
17    #[serde(default)]
18    pub account_id: Option<String>,
19    #[serde(default)]
20    pub last_updated_at: Option<DateTime<Utc>>,
21    #[serde(default)]
22    pub zones: Vec<CloudflareZone>,
23    #[serde(default)]
24    pub records_by_zone: BTreeMap<String, Vec<CloudflareDnsRecord>>,
25    #[serde(default)]
26    pub dnssec_by_zone: BTreeMap<String, CloudflareDnssec>,
27    #[serde(default)]
28    pub settings_by_zone: BTreeMap<String, CloudflareDnsSettings>,
29    #[serde(default)]
30    pub domain_availability_by_name: BTreeMap<String, DomainAvailability>,
31    #[serde(default)]
32    pub registered_domains: Vec<RegisteredDomain>,
33}
34
35impl DnsInventoryCache {
36    pub fn record_cloudflare_zones(
37        &mut self,
38        account_id: Option<String>,
39        zones: &[CloudflareZone],
40    ) {
41        let cache = self.cloudflare_mut();
42        cache.update_context(account_id);
43        for zone in zones {
44            upsert_zone(&mut cache.zones, zone.clone());
45        }
46        sort_zones(&mut cache.zones);
47    }
48
49    pub fn record_cloudflare_zone(&mut self, account_id: Option<String>, zone: &CloudflareZone) {
50        let cache = self.cloudflare_mut();
51        cache.update_context(account_id);
52        upsert_zone(&mut cache.zones, zone.clone());
53        sort_zones(&mut cache.zones);
54    }
55
56    pub fn remove_cloudflare_zone(&mut self, zone_id: &str) {
57        let cache = self.cloudflare_mut();
58        cache.touch();
59        cache.zones.retain(|zone| zone.id != zone_id);
60        cache.records_by_zone.remove(zone_id);
61        cache.dnssec_by_zone.remove(zone_id);
62        cache.settings_by_zone.remove(zone_id);
63    }
64
65    pub fn record_cloudflare_records(
66        &mut self,
67        account_id: Option<String>,
68        zone_id: &str,
69        records: &[CloudflareDnsRecord],
70    ) {
71        let cache = self.cloudflare_mut();
72        cache.update_context(account_id);
73        let mut next = records.to_vec();
74        sort_records(&mut next);
75        cache.records_by_zone.insert(zone_id.to_string(), next);
76    }
77
78    pub fn record_cloudflare_record(
79        &mut self,
80        account_id: Option<String>,
81        record: &CloudflareDnsRecord,
82    ) {
83        let cache = self.cloudflare_mut();
84        cache.update_context(account_id);
85        let records = cache
86            .records_by_zone
87            .entry(record.zone_id.clone())
88            .or_default();
89        upsert_record(records, record.clone());
90        sort_records(records);
91    }
92
93    pub fn remove_cloudflare_record(&mut self, zone_id: &str, record_id: &str) {
94        let cache = self.cloudflare_mut();
95        cache.touch();
96        if let Some(records) = cache.records_by_zone.get_mut(zone_id) {
97            records.retain(|record| record.id != record_id);
98        }
99    }
100
101    pub fn record_cloudflare_dnssec(
102        &mut self,
103        account_id: Option<String>,
104        zone_id: &str,
105        dnssec: &CloudflareDnssec,
106    ) {
107        let cache = self.cloudflare_mut();
108        cache.update_context(account_id);
109        cache
110            .dnssec_by_zone
111            .insert(zone_id.to_string(), dnssec.clone());
112    }
113
114    pub fn record_cloudflare_settings(
115        &mut self,
116        account_id: Option<String>,
117        zone_id: &str,
118        settings: &CloudflareDnsSettings,
119    ) {
120        let cache = self.cloudflare_mut();
121        cache.update_context(account_id);
122        cache
123            .settings_by_zone
124            .insert(zone_id.to_string(), settings.clone());
125    }
126
127    pub fn record_cloudflare_domain_availability(
128        &mut self,
129        account_id: Option<String>,
130        domains: &[DomainAvailability],
131    ) {
132        let cache = self.cloudflare_mut();
133        cache.update_context(account_id);
134        for domain in domains {
135            cache
136                .domain_availability_by_name
137                .insert(domain.name.clone(), domain.clone());
138        }
139    }
140
141    pub fn record_cloudflare_registered_domains(
142        &mut self,
143        account_id: Option<String>,
144        domains: &[RegisteredDomain],
145    ) {
146        let cache = self.cloudflare_mut();
147        cache.update_context(account_id);
148        for domain in domains {
149            upsert_registered_domain(&mut cache.registered_domains, domain.clone());
150        }
151        sort_registered_domains(&mut cache.registered_domains);
152    }
153
154    fn cloudflare_mut(&mut self) -> &mut CloudflareDnsInventoryCache {
155        self.cloudflare
156            .get_or_insert_with(CloudflareDnsInventoryCache::default)
157    }
158}
159
160impl CloudflareDnsInventoryCache {
161    fn touch(&mut self) {
162        self.last_updated_at = Some(Utc::now());
163    }
164
165    fn update_context(&mut self, account_id: Option<String>) {
166        if let Some(account_id) = account_id.filter(|value| !value.trim().is_empty()) {
167            self.account_id = Some(account_id);
168        }
169        self.touch();
170    }
171}
172
173fn upsert_zone(zones: &mut Vec<CloudflareZone>, zone: CloudflareZone) {
174    if let Some(existing) = zones.iter_mut().find(|existing| existing.id == zone.id) {
175        *existing = zone;
176    } else {
177        zones.push(zone);
178    }
179}
180
181fn upsert_record(records: &mut Vec<CloudflareDnsRecord>, record: CloudflareDnsRecord) {
182    if let Some(existing) = records.iter_mut().find(|existing| existing.id == record.id) {
183        *existing = record;
184    } else {
185        records.push(record);
186    }
187}
188
189fn upsert_registered_domain(domains: &mut Vec<RegisteredDomain>, domain: RegisteredDomain) {
190    if let Some(existing) = domains
191        .iter_mut()
192        .find(|existing| existing.name == domain.name)
193    {
194        *existing = domain;
195    } else {
196        domains.push(domain);
197    }
198}
199
200fn sort_zones(zones: &mut [CloudflareZone]) {
201    zones.sort_by(|left, right| {
202        left.name
203            .cmp(&right.name)
204            .then_with(|| left.id.cmp(&right.id))
205    });
206}
207
208fn sort_records(records: &mut [CloudflareDnsRecord]) {
209    records.sort_by(|left, right| {
210        left.name
211            .cmp(&right.name)
212            .then_with(|| left.record_type.cmp(&right.record_type))
213            .then_with(|| left.id.cmp(&right.id))
214    });
215}
216
217fn sort_registered_domains(domains: &mut [RegisteredDomain]) {
218    domains.sort_by(|left, right| left.name.cmp(&right.name));
219}
220
221#[cfg(test)]
222mod tests {
223    use super::DnsInventoryCache;
224    use crate::provider_support::{CloudflareDnsRecord, CloudflareZone, RegisteredDomain};
225
226    #[test]
227    fn record_upserts_zone_and_record_entries() {
228        let mut cache = DnsInventoryCache::default();
229        let zone = CloudflareZone {
230            id: "zone_1".to_string(),
231            name: "example.com".to_string(),
232            status: None,
233            r#type: None,
234            paused: None,
235            account: None,
236            owner: None,
237            tenant: None,
238            tenant_unit: None,
239            plan: None,
240            name_servers: Vec::new(),
241            vanity_name_servers: None,
242            original_dnshost: None,
243            original_name_servers: None,
244            original_registrar: None,
245            activated_on: None,
246            created_on: "2026-01-01T00:00:00Z".to_string(),
247            modified_on: "2026-01-01T00:00:00Z".to_string(),
248            development_mode: None,
249            cname_suffix: None,
250            verification_key: None,
251            permissions: None,
252            meta: Default::default(),
253        };
254        let record = CloudflareDnsRecord {
255            id: "rec_1".to_string(),
256            zone_id: "zone_1".to_string(),
257            zone_name: Some("example.com".to_string()),
258            name: "api.example.com".to_string(),
259            record_type: "A".to_string(),
260            content: Some("127.0.0.1".to_string()),
261            ttl: Some(1),
262            proxied: Some(false),
263            priority: None,
264            comment: None,
265            tags: None,
266            data: None,
267            settings: None,
268            meta: None,
269            proxiable: None,
270            created_on: None,
271            modified_on: None,
272        };
273
274        cache.record_cloudflare_zone(Some("acc_1".to_string()), &zone);
275        cache.record_cloudflare_record(Some("acc_1".to_string()), &record);
276        cache.record_cloudflare_record(Some("acc_1".to_string()), &record);
277
278        let cloudflare = cache.cloudflare.expect("cloudflare cache");
279        assert_eq!(cloudflare.zones.len(), 1);
280        assert_eq!(cloudflare.records_by_zone["zone_1"].len(), 1);
281        assert_eq!(cloudflare.account_id.as_deref(), Some("acc_1"));
282    }
283
284    #[test]
285    fn registered_domains_upsert_by_name() {
286        let mut cache = DnsInventoryCache::default();
287        let domain = RegisteredDomain {
288            name: "example.com".to_string(),
289            account_id: Some("acc_1".to_string()),
290            auto_renew: Some(true),
291            expires_at: None,
292            registered_at: None,
293            registration: None,
294            workflow: None,
295            extra: Default::default(),
296        };
297
298        cache.record_cloudflare_registered_domains(None, &[domain.clone(), domain]);
299
300        let cloudflare = cache.cloudflare.expect("cloudflare cache");
301        assert_eq!(cloudflare.registered_domains.len(), 1);
302    }
303}