Reconcile DNS records against a desired state
Problem statement
Write a small sync tool that makes a DNS zone match a desired list of records, in the style of a Terraform plan and apply. It talks to a DNS provider's REST API, represented here by an in-memory FakeDNSAPI with four methods: list_records(page_token) (paginated, 2 records per page), create(name, type, value, ttl), update(id, value, ttl) and delete(id). The fake logs every write call so you can see exactly what would hit the provider.
desired.json
[ {"name": "api.example.com", "type": "A", "value": "10.0.1.20", "ttl": 300}, {"name": "www.example.com", "type": "CNAME", "value": "lb.example.com.", "ttl": 300}, {"name": "status.example.com", "type": "CNAME", "value": "pages.example.net.", "ttl": 3600}, {"name": "mail.example.com", "type": "MX", "value": "10 mx1.example.com.", "ttl": 3600}]Current zone (as returned by list_records, shown as a table)
id name type value ttl managed_byr1 api.example.com A 10.0.1.10 300 syncr2 www.example.com CNAME lb.example.com. 60 syncr3 old.example.com A 10.0.9.9 300 syncr4 mail.example.com MX 10 mx1.example.com. 3600 syncr5 vpn.example.com A 10.0.5.5 300 (none, created by hand)Rules:
- Records are identified by
(name, type). Compare names case-insensitively and ignore a trailing dot. - Missing records are created; records whose
valueorttldiffer are updated; unchanged records are left alone. - Only records with
managed_by: syncmay be changed or deleted. A managed record not in the desired list is deleted. A desired record that collides with an unmanaged one is reported as a conflict and skipped, and the tool exits 1. - Validate the desired list before calling the API: unknown types, a
ttlthat is not a positive integer, and duplicate(name, type)pairs are errors (exit 2). --dry-run(the default) prints the plan and changes nothing.--applymakes the calls, then runs the sync again to prove the second run findsno changes.- Print the plan in a stable order: desired keys sorted, then deletions sorted.
Examples
Example 1
Input: python solution.py --dry-run; echo "exit: $?"
Output: == plan ==
~ update A api.example.com: value 10.0.1.10 -> 10.0.1.20
+ create CNAME status.example.com -> pages.example.net. (ttl 3600)
~ update CNAME www.example.com: ttl 60 -> 300
- delete A old.example.com (r3)
dry run: 4 change(s) not applied
exit: 0
Explanation: mail already matches, so it is not in the plan. vpn is not managed by the tool, so it is never deleted even though it is not in desired.json.
Example 2
Input: python solution.py --apply; echo "exit: $?"
Output: == plan ==
~ update A api.example.com: value 10.0.1.10 -> 10.0.1.20
+ create CNAME status.example.com -> pages.example.net. (ttl 3600)
~ update CNAME www.example.com: ttl 60 -> 300
- delete A old.example.com (r3)
applied 4 change(s)
calls:
PATCH r1
POST CNAME status.example.com
PATCH r2
DELETE r3
== second run ==
no changes
exit: 0
Explanation: The second run proves the sync is idempotent: applying the same desired state twice makes no further calls.
Example 3
Input: Desired list plus `API.example.com.` (type A) and an `SRV` record with `"ttl": "300"`
Output: invalid: desired[4]: duplicate A api.example.com
invalid: desired[5]: unsupported type 'SRV'
invalid: desired[5]: ttl must be a positive integer
exit: 2
Explanation: API.example.com. normalizes to the same key as api.example.com. Nothing is sent to the API when validation fails.
Hints
Approach
Optimal
This is the reconcile loop behind most infrastructure-as-code tools: read the current state, compute the difference to the desired state, then make only the calls needed to close it.
- Validate first.
validatechecks types, TTLs (rejecting strings andTrue, which is anintin Python) and duplicate keys after normalization, and returns all errors at once. Nothing touches the API if the input is bad. - Read everything.
list_allfollowsnext_page_tokenuntil it is empty. Planning against the first page only would delete records that are merely on page 2, so this step is not optional. - Plan. Build
key -> recorddicts for both sides. For each desired key: missing meanscreate; present but unmanaged meansconflict; present with a different(value, ttl)meansupdate. For each current key not desired, delete it only if it carries themanaged_by: syncmarker. That ownership check is what stops the tool from wiping records someone created by hand. - Print, then maybe apply. Sorting keys makes the plan diff-able in CI logs.
applymaps each action to exactly one API call, using the current record'sidfor updates and deletes. - Exit codes. 0 for success, 1 if a conflict was skipped (the zone does not fully match), 2 for invalid input, so a pipeline can tell these apart.
Running the sync a second time after --apply shows no changes. Idempotency is the property interviewers ask about most: a reconcile tool must be safe to run on a schedule.
d and c are the number of desired and current records; sorting dominates.
O((d + c) log(d + c))Space O(d + c)import jsonimport sys DESIRED = [ {"name": "api.example.com", "type": "A", "value": "10.0.1.20", "ttl": 300}, {"name": "www.example.com", "type": "CNAME", "value": "lb.example.com.", "ttl": 300}, {"name": "status.example.com", "type": "CNAME", "value": "pages.example.net.", "ttl": 3600}, {"name": "mail.example.com", "type": "MX", "value": "10 mx1.example.com.", "ttl": 3600},]VALID_TYPES = {"A", "AAAA", "CNAME", "MX", "TXT"}MARKER = "sync" class FakeDNSAPI: # In-memory stand-in for a DNS provider's REST API. Records every write call. def __init__(self, records): self.records = {r["id"]: dict(r) for r in records} self.calls = [] self.next_id = 100 def list_records(self, page_token=None): ids = sorted(self.records) start = int(page_token or 0) chunk = ids[start:start + 2] # tiny pages on purpose nxt = str(start + 2) if start + 2 < len(ids) else None return {"records": [dict(self.records[i]) for i in chunk], "next_page_token": nxt} def create(self, name, type, value, ttl): self.calls.append(f"POST {type} {name}") rid = f"r{self.next_id}" self.next_id += 1 self.records[rid] = {"id": rid, "name": name, "type": type, "value": value, "ttl": ttl, "managed_by": MARKER} def update(self, rid, value, ttl): self.calls.append(f"PATCH {rid}") self.records[rid].update(value=value, ttl=ttl) def delete(self, rid): self.calls.append(f"DELETE {rid}") del self.records[rid] def list_all(api): out, token = [], None while True: page = api.list_records(page_token=token) out.extend(page["records"]) token = page.get("next_page_token") if not token: return out def validate(desired): errors, seen = [], set() for i, r in enumerate(desired): key = (r.get("name", "").lower().rstrip("."), r.get("type")) if r.get("type") not in VALID_TYPES: errors.append(f"desired[{i}]: unsupported type {r.get('type')!r}") if not isinstance(r.get("ttl"), int) or isinstance(r.get("ttl"), bool) or r["ttl"] < 1: errors.append(f"desired[{i}]: ttl must be a positive integer") if key in seen: errors.append(f"desired[{i}]: duplicate {key[1]} {key[0]}") seen.add(key) return errors def plan(desired, current): def key(r): return (r["name"].lower().rstrip("."), r["type"]) have = {key(r): r for r in current} want = {key(r): r for r in desired} actions = [] for k in sorted(want): d = want[k] c = have.get(k) if c is None: actions.append(("create", k, d, None)) elif c.get("managed_by") != MARKER: actions.append(("conflict", k, d, c)) elif (c["value"], c["ttl"]) != (d["value"], d["ttl"]): actions.append(("update", k, d, c)) for k in sorted(have): c = have[k] if k not in want and c.get("managed_by") == MARKER: actions.append(("delete", k, None, c)) return actions def describe(action): kind, (name, rtype), d, c = action if kind == "create": return f"+ create {rtype:<5} {name} -> {d['value']} (ttl {d['ttl']})" if kind == "update": changes = [] if c["value"] != d["value"]: changes.append(f"value {c['value']} -> {d['value']}") if c["ttl"] != d["ttl"]: changes.append(f"ttl {c['ttl']} -> {d['ttl']}") return f"~ update {rtype:<5} {name}: {', '.join(changes)}" if kind == "delete": return f"- delete {rtype:<5} {name} ({c['id']})" return f"! skip {rtype:<5} {name}: exists but not managed by {MARKER}" def apply(api, actions): for kind, _, d, c in actions: if kind == "create": api.create(d["name"], d["type"], d["value"], d["ttl"]) elif kind == "update": api.update(c["id"], d["value"], d["ttl"]) elif kind == "delete": api.delete(c["id"]) def sync(api, desired, dry_run): errors = validate(desired) if errors: for e in errors: print("invalid:", e) return 2 actions = plan(desired, list_all(api)) changes = [a for a in actions if a[0] != "conflict"] for a in actions: print(describe(a)) if not changes: print("no changes") elif dry_run: print(f"dry run: {len(changes)} change(s) not applied") else: apply(api, changes) print(f"applied {len(changes)} change(s)") return 1 if any(a[0] == "conflict" for a in actions) else 0 api = FakeDNSAPI([ {"id": "r1", "name": "api.example.com", "type": "A", "value": "10.0.1.10", "ttl": 300, "managed_by": "sync"}, {"id": "r2", "name": "www.example.com", "type": "CNAME", "value": "lb.example.com.", "ttl": 60, "managed_by": "sync"}, {"id": "r3", "name": "old.example.com", "type": "A", "value": "10.0.9.9", "ttl": 300, "managed_by": "sync"}, {"id": "r4", "name": "mail.example.com", "type": "MX", "value": "10 mx1.example.com.", "ttl": 3600, "managed_by": "sync"}, {"id": "r5", "name": "vpn.example.com", "type": "A", "value": "10.0.5.5", "ttl": 300},]) mode = sys.argv[1] if len(sys.argv) > 1 else "--dry-run"if mode == "--bad-input": bad = DESIRED + [{"name": "API.example.com.", "type": "A", "value": "10.0.1.21", "ttl": 300}, {"name": "x.example.com", "type": "SRV", "value": "0 5 443 x.", "ttl": "300"}] sys.exit(sync(api, bad, dry_run=True))print("== plan ==")rc = sync(api, DESIRED, dry_run=(mode != "--apply"))if mode == "--apply": print("calls:", *api.calls, sep="\n ") print("== second run ==") rc = sync(api, DESIRED, dry_run=False)sys.exit(rc)Follow-up questions
- Order the calls so that a CNAME target is created before the CNAME that points to it.
- Add
--max-deletes Nthat refuses to apply a plan deleting more than N records. - Support several values per
(name, type), for example two A records for round-robin.
Frequently asked questions
Kubernetes controllers, Terraform and most internal automation are reconcile loops: desired state in, minimal set of changes out. Being able to write one from scratch, with ownership rules and a dry-run mode, shows you understand how those tools behave and why they are safe to re-run.
A zone is often shared: some records come from this tool, some from other systems or from people. Deleting anything not in your file is correct only if the tool owns the whole zone. An ownership marker (a tag, a comment field, or a separate state file like Terraform's) limits the blast radius to what the tool created.
Some changes are applied and some are not. Because the plan is recomputed from the live state on every run, simply running the sync again finishes the job. This is another reason to plan from the current state rather than from a saved list of steps.