use std::collections::HashMap;
use std::net::IpAddr;
use crate::control_plane::config::AppConfig;
use crate::core::dns::records::RecordData;
use crate::core::dns::records::query::{extract_zone_names, resolve_fqdn};
use crate::core::dns::responses::{AnyRecordData, ListRecordsResponse};
use crate::core::dns::service::{ListRecordsOptions, RecordWrite, ZoneRead};
use crate::core::error::{Error, Result};
use crate::vendors::runtime::VendorClient;
const DEFAULT_TTL: u32 = 3600;
#[derive(Debug, Clone)]
struct PlannedRecord {
fqdn: String,
rtype: String,
ttl: u32,
record: RecordData,
}
#[derive(Debug, Default)]
struct Diff {
adds: Vec<PlannedRecord>,
deletes: Vec<PlannedRecord>,
unchanged: usize,
untouched: usize,
}
#[derive(Debug)]
struct ZonePlan {
zone: String,
adds: Vec<PlannedRecord>,
deletes: Vec<PlannedRecord>,
unchanged: usize,
untouched: usize,
skipped: usize,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct SyncApplySummary {
pub applied: usize,
pub failures: usize,
}
#[allow(clippy::too_many_arguments)]
pub async fn run_sync(
app_config: Option<&AppConfig>,
profile: Option<&str>,
from: Option<&str>,
to: Option<&str>,
zones: &[String],
maps: &[String],
apply: bool,
json: bool,
) -> Result<()> {
let (from_id, to_id, plans) =
build_sync_plan(app_config, profile, from, to, zones, maps).await?;
if json {
let out = sync_plan_json(&from_id, &to_id, &plans, apply);
let pretty = serde_json::to_string_pretty(&out)
.map_err(|e| Error::parse(format!("could not serialise sync plan: {e}")))?;
println!("{pretty}");
} else {
render_table(&from_id, &to_id, &plans, apply);
}
let has_changes = plans
.iter()
.any(|p| !p.adds.is_empty() || !p.deletes.is_empty());
if !apply || !has_changes {
return Ok(());
}
let summary = apply_plans(
&VendorClient::from_server(
app_config
.and_then(|cfg| cfg.selected_server(Some(&to_id)).ok())
.ok_or_else(|| Error::config("sync destination server disappeared"))?,
)?,
&plans,
)
.await?;
println!("\nApplied {} change(s).", summary.applied);
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub async fn run_sync_json(
app_config: Option<&AppConfig>,
profile: Option<&str>,
from: Option<&str>,
to: Option<&str>,
zones: &[String],
maps: &[String],
apply: bool,
) -> Result<serde_json::Value> {
let (from_id, to_id, plans) =
build_sync_plan(app_config, profile, from, to, zones, maps).await?;
let mut out = sync_plan_json(&from_id, &to_id, &plans, apply);
let has_changes = plans
.iter()
.any(|p| !p.adds.is_empty() || !p.deletes.is_empty());
if apply && has_changes {
let cfg = app_config.expect("build_sync_plan already required config");
let to_server = cfg.selected_server(Some(&to_id))?;
let summary = apply_plans(&VendorClient::from_server(to_server)?, &plans).await?;
out["apply_summary"] = serde_json::to_value(summary)
.map_err(|e| Error::parse(format!("could not serialise sync summary: {e}")))?;
}
Ok(out)
}
#[allow(clippy::too_many_arguments)]
async fn build_sync_plan(
app_config: Option<&AppConfig>,
profile: Option<&str>,
from: Option<&str>,
to: Option<&str>,
zones: &[String],
maps: &[String],
) -> Result<(String, String, Vec<ZonePlan>)> {
let Some(cfg) = app_config else {
return Err(Error::config(
"sync requires a config file defining the source and destination servers",
));
};
let profile = match profile {
Some(name) => Some(
cfg.sync
.iter()
.find(|p| p.name.eq_ignore_ascii_case(name))
.ok_or_else(|| {
Error::config(format!(
"config does not define a sync profile named '{name}'"
))
})?,
),
None => None,
};
let from_id = from
.or_else(|| profile.map(|p| p.from.as_str()))
.ok_or_else(|| {
Error::parse("sync requires a source server: name a profile or pass --from")
})?;
let to_id = to
.or_else(|| profile.map(|p| p.to.as_str()))
.ok_or_else(|| {
Error::parse("sync requires a destination server: name a profile or pass --to")
})?;
let mut ip_map: HashMap<IpAddr, IpAddr> = HashMap::new();
if let Some(p) = profile {
for (src, dst) in &p.ip_map {
let (s, d) = parse_ip_pair(&format!("{src}={dst}"))?;
ip_map.insert(s, d);
}
}
for spec in maps {
let (s, d) = parse_ip_pair(spec)?;
ip_map.insert(s, d);
}
let from_server = cfg.selected_server(Some(from_id))?;
let to_server = cfg.selected_server(Some(to_id))?;
let from_client = VendorClient::from_server(from_server)?;
let to_client = VendorClient::from_server(to_server)?;
let zone_list: Vec<String> = if !zones.is_empty() {
zones.to_vec()
} else if let Some(p) = profile.filter(|p| !p.zones.is_empty()) {
p.zones.clone()
} else {
const PAGE_SIZE: u32 = 1000;
let mut page = 1;
let mut names = Vec::new();
loop {
let value = from_client.list_zones(page, PAGE_SIZE).await?;
let batch = extract_zone_names(&value);
let batch_len = batch.len();
names.extend(batch);
if batch_len < PAGE_SIZE as usize {
break;
}
page += 1;
}
if names.is_empty() {
return Err(Error::parse(format!(
"no zones found on source server '{from_id}'; specify one with --zone"
)));
}
names
};
let mut plans = Vec::with_capacity(zone_list.len());
for zone in &zone_list {
plans.push(plan_zone(&from_client, &to_client, zone, &ip_map).await?);
}
Ok((from_id.to_string(), to_id.to_string(), plans))
}
async fn plan_zone(
from_client: &VendorClient,
to_client: &VendorClient,
zone: &str,
ip_map: &HashMap<IpAddr, IpAddr>,
) -> Result<ZonePlan> {
plan_zone_with_clients(from_client, to_client, zone, ip_map).await
}
async fn plan_zone_with_clients<F, T>(
from_client: &F,
to_client: &T,
zone: &str,
ip_map: &HashMap<IpAddr, IpAddr>,
) -> Result<ZonePlan>
where
F: ZoneRead + ?Sized,
T: ZoneRead + ?Sized,
{
let opts = ListRecordsOptions {
all_subdomains: true,
..ListRecordsOptions::default()
};
let source = from_client
.list_records(zone, Some(zone), opts)
.await
.map_err(|e| Error::parse(format!("source: listing records for zone '{zone}': {e}")))?;
let dest = to_client
.list_records(zone, Some(zone), opts)
.await
.map_err(|e| {
Error::parse(format!(
"destination: listing records for zone '{zone}' \
(does the zone exist on the destination?): {e}"
))
})?;
let (source_records, skipped) = collect_records(&source, zone, Some(ip_map));
let (dest_records, _) = collect_records(&dest, zone, None);
let mut diff = diff_records(source_records, dest_records);
diff.adds.sort_by_key(sort_key);
diff.deletes.sort_by_key(sort_key);
Ok(ZonePlan {
zone: zone.to_string(),
adds: diff.adds,
deletes: diff.deletes,
unchanged: diff.unchanged,
untouched: diff.untouched,
skipped,
})
}
fn collect_records(
response: &ListRecordsResponse,
zone: &str,
ip_map: Option<&HashMap<IpAddr, IpAddr>>,
) -> (Vec<PlannedRecord>, usize) {
let mut out = Vec::new();
let mut skipped = 0;
for zone_records in &response.zones {
for record in &zone_records.records {
if record.disabled {
skipped += 1;
continue;
}
let Some(AnyRecordData::Writable(rd)) = record.typed() else {
skipped += 1;
continue;
};
let rd = match ip_map {
Some(map) => apply_ip_map(rd, map),
None => rd,
};
let fqdn = resolve_fqdn(&record.name, Some(zone));
if fqdn.eq_ignore_ascii_case(zone) && rd.type_name() == "NS" {
skipped += 1;
continue;
}
out.push(PlannedRecord {
fqdn,
rtype: rd.type_name().to_string(),
ttl: if record.ttl == 0 {
DEFAULT_TTL
} else {
record.ttl
},
record: rd,
});
}
}
(out, skipped)
}
fn diff_records(source: Vec<PlannedRecord>, dest: Vec<PlannedRecord>) -> Diff {
let group = |records: Vec<PlannedRecord>| {
let mut groups: HashMap<(String, String), Vec<PlannedRecord>> = HashMap::new();
for r in records {
groups
.entry((r.fqdn.to_lowercase(), r.rtype.clone()))
.or_default()
.push(r);
}
groups
};
let source_groups = group(source);
let dest_groups = group(dest);
let mut diff = Diff::default();
let match_key = |r: &PlannedRecord| (canonical(&r.record), r.ttl);
for (key, src_recs) in &source_groups {
let dest_recs = dest_groups.get(key);
let dest_keys: Vec<(String, u32)> = dest_recs
.map(|recs| recs.iter().map(match_key).collect())
.unwrap_or_default();
let src_keys: Vec<(String, u32)> = src_recs.iter().map(match_key).collect();
for r in src_recs {
if dest_keys.contains(&match_key(r)) {
diff.unchanged += 1;
} else {
diff.adds.push(r.clone());
}
}
if let Some(dest_recs) = dest_recs {
for r in dest_recs {
if !src_keys.contains(&match_key(r)) {
diff.deletes.push(r.clone());
}
}
}
}
diff.untouched = dest_groups
.iter()
.filter(|(key, _)| !source_groups.contains_key(*key))
.map(|(_, recs)| recs.len())
.sum();
diff
}
async fn apply_plans(to_client: &VendorClient, plans: &[ZonePlan]) -> Result<SyncApplySummary> {
apply_plans_with_client(to_client, plans).await
}
async fn apply_plans_with_client<C>(to_client: &C, plans: &[ZonePlan]) -> Result<SyncApplySummary>
where
C: RecordWrite + ?Sized,
{
let mut applied = 0;
let mut failures = 0;
for plan in plans {
let mut zone_add_failed = false;
for rec in &plan.adds {
match to_client
.add_record(&plan.zone, &rec.fqdn, rec.ttl, &rec.record)
.await
{
Ok(_) => applied += 1,
Err(e) => {
failures += 1;
zone_add_failed = true;
eprintln!(" ! add {} {} failed: {e}", rec.fqdn, rec.rtype);
}
}
}
if zone_add_failed {
eprintln!(
" ! skipping removals for zone '{}' because one or more additions failed",
plan.zone
);
continue;
}
for rec in &plan.deletes {
let params = rec.record.to_api_params();
match to_client
.delete_record(&plan.zone, &rec.fqdn, ¶ms)
.await
{
Ok(_) => applied += 1,
Err(e) => {
failures += 1;
eprintln!(" ! remove {} {} failed: {e}", rec.fqdn, rec.rtype);
}
}
}
}
if failures > 0 {
return Err(Error::api(format!("{failures} sync change(s) failed")));
}
Ok(SyncApplySummary { applied, failures })
}
fn apply_ip_map(record: RecordData, map: &HashMap<IpAddr, IpAddr>) -> RecordData {
match record {
RecordData::A { ip } => match map.get(&IpAddr::V4(ip)) {
Some(IpAddr::V4(mapped)) => RecordData::A { ip: *mapped },
_ => RecordData::A { ip },
},
RecordData::Aaaa { ip } => match map.get(&IpAddr::V6(ip)) {
Some(IpAddr::V6(mapped)) => RecordData::Aaaa { ip: *mapped },
_ => RecordData::Aaaa { ip },
},
other => other,
}
}
fn parse_ip_pair(spec: &str) -> Result<(IpAddr, IpAddr)> {
let (src, dst) = spec
.split_once('=')
.ok_or_else(|| Error::parse(format!("invalid IP mapping '{spec}': expected SRC=DST")))?;
let src = src.trim();
let dst = dst.trim();
let source: IpAddr = src
.parse()
.map_err(|_| Error::parse(format!("invalid IP mapping '{spec}': '{src}' is not an IP")))?;
let dest: IpAddr = dst
.parse()
.map_err(|_| Error::parse(format!("invalid IP mapping '{spec}': '{dst}' is not an IP")))?;
if source.is_ipv4() != dest.is_ipv4() {
return Err(Error::parse(format!(
"invalid IP mapping '{spec}': mixes IPv4 and IPv6"
)));
}
Ok((source, dest))
}
fn canonical(record: &RecordData) -> String {
record
.to_api_params()
.into_iter()
.map(|(key, value)| format!("{key}\u{1}{value}"))
.collect::<Vec<_>>()
.join("\u{2}")
}
fn sort_key(record: &PlannedRecord) -> (String, String, String) {
(
record.fqdn.to_lowercase(),
record.rtype.clone(),
canonical(&record.record),
)
}
fn value_display(record: &RecordData) -> String {
record
.to_api_params()
.into_iter()
.skip(1) .map(|(_, value)| value)
.collect::<Vec<_>>()
.join(" ")
}
fn render_table(from_id: &str, to_id: &str, plans: &[ZonePlan], apply: bool) {
let mode = if apply { "apply" } else { "dry run" };
println!("Sync plan: {from_id} -> {to_id} ({mode})");
let mut adds = 0;
let mut deletes = 0;
let mut unchanged = 0;
let mut skipped = 0;
let mut untouched = 0;
for plan in plans {
adds += plan.adds.len();
deletes += plan.deletes.len();
unchanged += plan.unchanged;
skipped += plan.skipped;
untouched += plan.untouched;
if plan.adds.is_empty() && plan.deletes.is_empty() {
continue;
}
println!("\nZone: {}", plan.zone);
for rec in &plan.adds {
println!(
" + {:<28} {:<6} {}",
rec.fqdn,
rec.rtype,
value_display(&rec.record)
);
}
for rec in &plan.deletes {
println!(
" - {:<28} {:<6} {}",
rec.fqdn,
rec.rtype,
value_display(&rec.record)
);
}
}
println!(
"\n{adds} to add, {deletes} to remove, {unchanged} unchanged, \
{skipped} skipped (not syncable)."
);
if untouched > 0 {
println!("{untouched} destination record(s) absent from the source were left untouched.");
}
if adds + deletes == 0 {
println!("Already in sync — nothing to do.");
} else if !apply {
println!("Dry run — no changes written. Re-run with --apply to commit.");
}
}
fn sync_plan_json(
from_id: &str,
to_id: &str,
plans: &[ZonePlan],
apply: bool,
) -> serde_json::Value {
let rec_json = |rec: &PlannedRecord| {
serde_json::json!({
"name": rec.fqdn,
"type": rec.rtype,
"ttl": rec.ttl,
"value": value_display(&rec.record),
})
};
let zones: Vec<_> = plans
.iter()
.map(|plan| {
serde_json::json!({
"zone": plan.zone,
"add": plan.adds.iter().map(rec_json).collect::<Vec<_>>(),
"remove": plan.deletes.iter().map(rec_json).collect::<Vec<_>>(),
"unchanged": plan.unchanged,
"untouched": plan.untouched,
"skipped": plan.skipped,
})
})
.collect();
serde_json::json!({
"from": from_id,
"to": to_id,
"applied": apply,
"zones": zones,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::dns::responses::{ZoneInfo, ZoneRecord, ZoneRecords};
use rstest::rstest;
use serde_json::{Value, json};
use std::sync::{Arc, Mutex};
fn ip_map(pairs: &[(&str, &str)]) -> HashMap<IpAddr, IpAddr> {
pairs
.iter()
.map(|(s, d)| (s.parse().unwrap(), d.parse().unwrap()))
.collect()
}
fn a(name: &str, ip: &str) -> PlannedRecord {
PlannedRecord {
fqdn: name.to_string(),
rtype: "A".to_string(),
ttl: 3600,
record: RecordData::A {
ip: ip.parse().unwrap(),
},
}
}
fn zone_info(name: &str) -> ZoneInfo {
ZoneInfo {
id: Some(name.to_string()),
name: name.to_string(),
zone_type: "Primary".to_string(),
disabled: false,
dnssec_status: None,
}
}
fn zone_record(name: &str, record_type: &str, ttl: u32, data: Value) -> ZoneRecord {
let mut record = ZoneRecord {
name: name.to_string(),
record_type: record_type.to_string(),
ttl,
disabled: false,
comments: String::new(),
expiry_ttl: 0,
data,
parsed: None,
};
record.parsed = record.typed();
record
}
fn sync_test_response(zone: &str, records: Vec<ZoneRecord>) -> ListRecordsResponse {
ListRecordsResponse {
zones: vec![ZoneRecords {
zone: zone_info(zone),
records,
}],
}
}
#[derive(Clone)]
struct FakeZoneRead {
response: ListRecordsResponse,
calls: Arc<Mutex<Vec<(String, Option<String>, ListRecordsOptions)>>>,
}
impl FakeZoneRead {
fn new(response: ListRecordsResponse) -> Self {
Self {
response,
calls: Arc::new(Mutex::new(Vec::new())),
}
}
}
impl ZoneRead for FakeZoneRead {
async fn list_zones(&self, _page: u32, _per_page: u32) -> Result<Value> {
Ok(json!({ "response": { "zones": [] } }))
}
async fn list_records(
&self,
domain: &str,
zone: Option<&str>,
options: ListRecordsOptions,
) -> Result<ListRecordsResponse> {
self.calls.lock().unwrap().push((
domain.to_string(),
zone.map(ToOwned::to_owned),
options,
));
Ok(self.response.clone())
}
}
#[derive(Default)]
struct FakeRecordWrite {
adds: Mutex<Vec<(String, String, u32, RecordData)>>,
deletes: Mutex<Vec<(String, String, Vec<(String, String)>)>>,
}
impl RecordWrite for FakeRecordWrite {
async fn add_record(
&self,
zone: &str,
domain: &str,
ttl: u32,
record: &RecordData,
) -> Result<Value> {
self.adds.lock().unwrap().push((
zone.to_string(),
domain.to_string(),
ttl,
record.clone(),
));
Ok(json!({ "status": "ok" }))
}
async fn delete_record(
&self,
zone: &str,
domain: &str,
type_params: &[(&str, String)],
) -> Result<Value> {
self.deletes.lock().unwrap().push((
zone.to_string(),
domain.to_string(),
type_params
.iter()
.map(|(key, value)| ((*key).to_string(), value.clone()))
.collect(),
));
Ok(json!({ "status": "ok" }))
}
}
#[test]
fn ip_map_rewrites_mapped_a_record() {
let map = ip_map(&[("203.0.113.10", "192.168.1.10")]);
let mapped = apply_ip_map(
RecordData::A {
ip: "203.0.113.10".parse().unwrap(),
},
&map,
);
match mapped {
RecordData::A { ip } => assert_eq!(ip.to_string(), "192.168.1.10"),
other => panic!("expected A, got {other:?}"),
}
}
#[test]
fn ip_map_leaves_unmapped_a_record_untouched() {
let map = ip_map(&[("203.0.113.10", "192.168.1.10")]);
let mapped = apply_ip_map(
RecordData::A {
ip: "8.8.8.8".parse().unwrap(),
},
&map,
);
match mapped {
RecordData::A { ip } => assert_eq!(ip.to_string(), "8.8.8.8"),
other => panic!("expected A, got {other:?}"),
}
}
#[test]
fn ip_map_rewrites_mapped_aaaa_record() {
let map = ip_map(&[("2001:db8::1", "fd00::1")]);
let mapped = apply_ip_map(
RecordData::Aaaa {
ip: "2001:db8::1".parse().unwrap(),
},
&map,
);
match mapped {
RecordData::Aaaa { ip } => assert_eq!(ip.to_string(), "fd00::1"),
other => panic!("expected AAAA, got {other:?}"),
}
}
#[test]
fn ip_map_leaves_non_address_records_untouched() {
let map = ip_map(&[("203.0.113.10", "192.168.1.10")]);
let mapped = apply_ip_map(
RecordData::Cname {
target: "example.com".to_string(),
},
&map,
);
assert!(matches!(mapped, RecordData::Cname { .. }));
}
#[tokio::test]
async fn plan_zone_lists_entire_zone_and_includes_child_records() {
let zone = "dnsync-sync-test.example";
let source = FakeZoneRead::new(sync_test_response(
zone,
vec![
zone_record(zone, "SOA", 3600, json!({})),
zone_record(zone, "NS", 3600, json!({ "nameServer": "dns1.hankin.io" })),
zone_record(
&format!("www.{zone}"),
"A",
3600,
json!({ "ipAddress": "203.0.113.10" }),
),
zone_record(
&format!("api.{zone}"),
"CNAME",
3600,
json!({ "cname": format!("www.{zone}") }),
),
],
));
let dest = FakeZoneRead::new(sync_test_response(
zone,
vec![
zone_record(zone, "SOA", 3600, json!({})),
zone_record(zone, "NS", 3600, json!({ "nameServer": "dns2.hankin.io" })),
],
));
let plan = plan_zone_with_clients(&source, &dest, zone, &HashMap::new())
.await
.unwrap();
assert!(source.calls.lock().unwrap()[0].2.all_subdomains);
assert!(dest.calls.lock().unwrap()[0].2.all_subdomains);
assert_eq!(plan.adds.len(), 2);
assert!(plan.adds.iter().any(|r| {
r.fqdn == format!("www.{zone}")
&& r.rtype == "A"
&& value_display(&r.record) == "203.0.113.10"
}));
assert!(plan.adds.iter().any(|r| {
r.fqdn == format!("api.{zone}")
&& r.rtype == "CNAME"
&& value_display(&r.record) == format!("www.{zone}")
}));
assert_eq!(plan.skipped, 2);
}
#[tokio::test]
async fn apply_writes_missing_child_records_to_destination() {
let zone = "dnsync-sync-test.example";
let writer = FakeRecordWrite::default();
let plan = ZonePlan {
zone: zone.to_string(),
adds: vec![
PlannedRecord {
fqdn: format!("www.{zone}"),
rtype: "A".to_string(),
ttl: 3600,
record: RecordData::A {
ip: "203.0.113.10".parse().unwrap(),
},
},
PlannedRecord {
fqdn: format!("api.{zone}"),
rtype: "CNAME".to_string(),
ttl: 3600,
record: RecordData::Cname {
target: format!("www.{zone}"),
},
},
],
deletes: vec![],
unchanged: 0,
untouched: 0,
skipped: 0,
};
apply_plans_with_client(&writer, &[plan]).await.unwrap();
let adds = writer.adds.lock().unwrap();
assert_eq!(adds.len(), 2);
assert_eq!(adds[0].0, zone);
assert_eq!(adds[0].1, format!("www.{zone}"));
assert!(matches!(adds[0].3, RecordData::A { .. }));
assert_eq!(adds[1].1, format!("api.{zone}"));
assert!(matches!(adds[1].3, RecordData::Cname { .. }));
}
#[tokio::test]
async fn plan_zone_applies_ip_mapping_to_child_address_records() {
let zone = "dnsync-sync-test.example";
let source = FakeZoneRead::new(sync_test_response(
zone,
vec![zone_record(
&format!("www.{zone}"),
"A",
3600,
json!({ "ipAddress": "203.0.113.10" }),
)],
));
let dest = FakeZoneRead::new(sync_test_response(zone, vec![]));
let map = ip_map(&[("203.0.113.10", "192.0.2.10")]);
let plan = plan_zone_with_clients(&source, &dest, zone, &map)
.await
.unwrap();
assert_eq!(plan.adds.len(), 1);
assert_eq!(value_display(&plan.adds[0].record), "192.0.2.10");
}
#[test]
fn parse_ip_pair_accepts_valid_pair() {
let (s, d) = parse_ip_pair("203.0.113.10 = 192.168.1.10").unwrap();
assert_eq!(s.to_string(), "203.0.113.10");
assert_eq!(d.to_string(), "192.168.1.10");
}
#[rstest]
#[case::missing_separator("203.0.113.10")]
#[case::bad_address("203.0.113.10=not-an-ip")]
#[case::family_mismatch("203.0.113.10=fd00::1")]
fn parse_ip_pair_rejects_bad_input(#[case] spec: &str) {
assert!(parse_ip_pair(spec).is_err());
}
#[test]
fn canonical_equal_for_same_value_differs_for_others() {
let one = RecordData::A {
ip: "1.2.3.4".parse().unwrap(),
};
let same = RecordData::A {
ip: "1.2.3.4".parse().unwrap(),
};
let other = RecordData::A {
ip: "1.2.3.5".parse().unwrap(),
};
assert_eq!(canonical(&one), canonical(&same));
assert_ne!(canonical(&one), canonical(&other));
}
#[test]
fn diff_adds_record_set_missing_on_destination() {
let diff = diff_records(vec![a("www.example.com", "1.1.1.1")], vec![]);
assert_eq!(diff.adds.len(), 1);
assert_eq!(diff.deletes.len(), 0);
assert_eq!(diff.unchanged, 0);
}
#[test]
fn diff_updates_changed_value_with_add_and_remove() {
let diff = diff_records(
vec![a("www.example.com", "2.2.2.2")],
vec![a("www.example.com", "1.1.1.1")],
);
assert_eq!(diff.adds.len(), 1);
assert_eq!(diff.deletes.len(), 1);
assert_eq!(diff.unchanged, 0);
match &diff.adds[0].record {
RecordData::A { ip } => assert_eq!(ip.to_string(), "2.2.2.2"),
other => panic!("expected A, got {other:?}"),
}
}
#[test]
fn diff_reports_identical_records_as_unchanged() {
let diff = diff_records(
vec![a("www.example.com", "1.1.1.1")],
vec![a("www.example.com", "1.1.1.1")],
);
assert_eq!(diff.adds.len(), 0);
assert_eq!(diff.deletes.len(), 0);
assert_eq!(diff.unchanged, 1);
}
#[test]
fn diff_treats_ttl_difference_as_update() {
let mut src = a("www.example.com", "1.1.1.1");
src.ttl = 300;
let mut dst = a("www.example.com", "1.1.1.1");
dst.ttl = 3600;
let diff = diff_records(vec![src], vec![dst]);
assert_eq!(diff.adds.len(), 1);
assert_eq!(diff.deletes.len(), 1);
assert_eq!(diff.unchanged, 0);
assert_eq!(diff.adds[0].ttl, 300);
}
#[test]
fn diff_never_prunes_destination_only_names() {
let diff = diff_records(
vec![a("a.example.com", "1.1.1.1")],
vec![a("a.example.com", "1.1.1.1"), a("b.example.com", "2.2.2.2")],
);
assert_eq!(diff.adds.len(), 0);
assert_eq!(diff.deletes.len(), 0);
assert_eq!(diff.unchanged, 1);
assert_eq!(diff.untouched, 1);
}
}