Practical infra coding

Reconcile DNS records against a desired state

hardAPIs and HTTP

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

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)

TEXT
id name type value ttl managed_by
r1 api.example.com A 10.0.1.10 300 sync
r2 www.example.com CNAME lb.example.com. 60 sync
r3 old.example.com A 10.0.9.9 300 sync
r4 mail.example.com MX 10 mx1.example.com. 3600 sync
r5 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 value or ttl differ are updated; unchanged records are left alone.
  • Only records with managed_by: sync may 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 ttl that is not a positive integer, and duplicate (name, type) pairs are errors (exit 2).
  • --dry-run (the default) prints the plan and changes nothing. --apply makes the calls, then runs the sync again to prove the second run finds no 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.

  1. Validate first. validate checks types, TTLs (rejecting strings and True, which is an int in Python) and duplicate keys after normalization, and returns all errors at once. Nothing touches the API if the input is bad.
  2. Read everything. list_all follows next_page_token until it is empty. Planning against the first page only would delete records that are merely on page 2, so this step is not optional.
  3. Plan. Build key -> record dicts for both sides. For each desired key: missing means create; present but unmanaged means conflict; present with a different (value, ttl) means update. For each current key not desired, delete it only if it carries the managed_by: sync marker. That ownership check is what stops the tool from wiping records someone created by hand.
  4. Print, then maybe apply. Sorting keys makes the plan diff-able in CI logs. apply maps each action to exactly one API call, using the current record's id for updates and deletes.
  5. 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.

ComplexityTime O((d + c) log(d + c))Space O(d + c)
Python
import json
import 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 N that 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.