Skip to content

Commit 6cf08de

Browse files
Add a reconcile for template-owned zone records
Zone records are written once, when the zone is first created, and never again - the generate and import tasks are gated on the zone being absent from SQLite. Editing vars.yaml afterwards changes nothing while the deploy still reports success. That is how _health.eu-central pointed at an address cdn-1 had left behind for seven months, and why the pdns1 -> pdns nameserver fix deployed on 2026-07-28 had no effect and had to be repeated by hand the next day. Removing the gate is not the fix. The import is `pdnsutil load-zone`, which replaces the whole zone, so running it every deploy would destroy the ~170 app A records and any _acme-challenge TXT certbot has in flight. The second is the worse of the two: those records exist only during an issuance, so a whole-zone rewrite tests clean and breaks renewal on an unlucky run. This reconciles individual records through the API instead. It owns an enumerated set - apex NS, SOA MNAME and RNAME, _health.<region> TXT - and compares before writing, so a zone that already matches produces no PATCH, no serial bump and no notify. A blind write would bump the serial on every deploy and teach everyone to ignore "changed". LUA records are compared but never written. Their content is only a pointer to a script file, and that file is already templated on every run, so the part that actually changes is reconciled. What is left is the apex A serving all geo traffic and the wildcard CNAME serving every Flux app: rarest to change, fiddliest to quote, and an outage rather than a stale TXT when wrong. Drift there stops the deploy and gets a human. Deletion is bounded three ways in code - by the _health prefix, by record type, and by zone - rather than by trusting the diff. _acme-challenge cannot be selected by construction. The tests confirm each bound independently: removing any one of the three fails a different test, and dropping the embedded quotes from TXT rdata fails fifteen. The diff is a pure function so these properties are testable with no PowerDNS running, which keeps the docker harness for the things that genuinely need a live server. Fixtures are copied from what the production API returns rather than invented, because the failure this guards against is a quoting or trailing dot mismatch that makes a correct zone report a change forever. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent d9946f1 commit 6cf08de

3 files changed

Lines changed: 766 additions & 0 deletions

File tree

scripts/reconcile_zone_records.py

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
#!/usr/bin/env python3
2+
"""Reconcile template-owned zone records against vars.yaml, through the PowerDNS API.
3+
4+
Zone records are written once, when powerdns_setup.yaml first creates the zone,
5+
and never again - the generate/import tasks are gated on the zone being absent
6+
from SQLite. Editing vars.yaml afterwards changes nothing while the deploy still
7+
reports success. That is how _health.eu-central pointed at a decommissioned
8+
address for seven months, and how the pdns1 -> pdns nameserver fix deployed on
9+
2026-07-28 did nothing at all.
10+
11+
The gate cannot simply be removed, because the import is `pdnsutil load-zone`,
12+
which replaces the whole zone. Doing that on every run would destroy every
13+
record written out of band: the ~170 app A records, and any _acme-challenge TXT
14+
that certbot's DNS-01 flow has in flight. The second is the more dangerous of
15+
the two, since those records exist only during an issuance - a whole-zone
16+
rewrite tests clean and breaks renewal on an unlucky run.
17+
18+
So this reconciles individual records instead, and owns an enumerated set:
19+
20+
written apex NS
21+
apex SOA MNAME and RNAME (never the serial)
22+
_health.<region> TXT
23+
24+
detected LUA records - compared and reported, never written
25+
26+
ignored everything else, which is neither read nor written
27+
28+
LUA records are deliberately read-only. Their content is a pointer to a script
29+
file, and that file is templated to disk on every run, so the part that actually
30+
changes is already reconciled. What is left is the apex A that serves all geo
31+
traffic and the wildcard CNAME that serves every Flux app: the highest
32+
consequence records in the zones, the rarest to change, and the fiddliest to
33+
quote. Drift there should stop a deploy and get a human, not be silently
34+
rewritten.
35+
36+
Exit codes:
37+
0 completed; see "changed" in the JSON report on stdout
38+
1 error
39+
2 LUA record drift; nothing was written
40+
"""
41+
42+
from __future__ import annotations
43+
44+
import argparse
45+
import json
46+
import os
47+
import sys
48+
import urllib.error
49+
import urllib.request
50+
51+
# Deletion candidates are bounded by this prefix, by record type, and by zone -
52+
# in code, not by trusting the diff to be correct. _acme-challenge cannot be
53+
# selected by construction rather than by intent.
54+
HEALTH_PREFIX = "_health."
55+
56+
SOA_FIELDS = 7
57+
58+
59+
class ReconcileError(RuntimeError):
60+
"""Anything that should fail the play rather than be worked around."""
61+
62+
63+
def canonical(name: str) -> str:
64+
"""PowerDNS canonicalises names and name-valued rdata to a trailing dot.
65+
66+
vars.yaml already writes nameservers and soa_nameserver that way, so the two
67+
compare directly. (`pdnsutil list-zone` renders the SOA MNAME *without* the
68+
dot, so a reconcile built on its output would report a change forever.)
69+
"""
70+
return name if name.endswith(".") else name + "."
71+
72+
73+
def txt_rdata(value: str) -> str:
74+
"""TXT rdata carries its own quotes, inside the JSON string.
75+
76+
The API returns '"Germany EU - cdn-1..."' including the quote characters.
77+
Building the desired value without them makes every run report a change.
78+
"""
79+
return '"{}"'.format(value)
80+
81+
82+
def index_rrsets(zone_data: dict) -> dict:
83+
"""Key the zone's rrsets by (name, type) for lookup."""
84+
return {(r["name"], r["type"]): r for r in zone_data.get("rrsets", [])}
85+
86+
87+
def _replace(name: str, rtype: str, ttl: int, contents: list) -> dict:
88+
return {
89+
"name": name,
90+
"type": rtype,
91+
"ttl": ttl,
92+
"changetype": "REPLACE",
93+
"records": [{"content": c, "disabled": False} for c in contents],
94+
}
95+
96+
97+
def find_lua_drift(existing: dict, desired: dict) -> list:
98+
"""Compare LUA records without proposing any write.
99+
100+
Content only - a TTL difference on a LUA record is harmless and is not worth
101+
failing a deploy over.
102+
"""
103+
drift = []
104+
for want in desired.get("lua_records", []):
105+
name = canonical(want["name"])
106+
rrset = existing.get((name, "LUA"))
107+
if rrset is None:
108+
drift.append({"name": name, "expected": want["content"], "actual": None})
109+
continue
110+
actual = [r["content"] for r in rrset.get("records", [])]
111+
if actual != [want["content"]]:
112+
drift.append({"name": name, "expected": want["content"], "actual": actual})
113+
return drift
114+
115+
116+
def compute_changes(zone: str, existing: dict, desired: dict) -> list:
117+
"""Pure function: current state + desired state -> list of PATCH rrsets.
118+
119+
No I/O, so the properties that make this safe are unit-testable without a
120+
running PowerDNS.
121+
"""
122+
zone = canonical(zone)
123+
apex_ttl = int(desired["apex_ttl"])
124+
patch = []
125+
126+
# --- apex SOA: MNAME and RNAME only -------------------------------------
127+
# The serial is never sent. Every zone carries SOA-EDIT-API DEFAULT, so
128+
# PowerDNS rewrites it on write; supplying one would only fight that.
129+
soa = existing.get((zone, "SOA"))
130+
if soa is None:
131+
raise ReconcileError("zone {} has no SOA record".format(zone))
132+
fields = soa["records"][0]["content"].split()
133+
if len(fields) != SOA_FIELDS:
134+
raise ReconcileError(
135+
"zone {} has a malformed SOA ({} fields, expected {}): {!r}".format(
136+
zone, len(fields), SOA_FIELDS, soa["records"][0]["content"]
137+
)
138+
)
139+
want_mname = canonical(desired["soa_mname"])
140+
want_rname = canonical(desired["soa_rname"])
141+
if fields[0] != want_mname or fields[1] != want_rname or soa["ttl"] != apex_ttl:
142+
rebuilt = " ".join([want_mname, want_rname] + fields[2:])
143+
patch.append(_replace(zone, "SOA", apex_ttl, [rebuilt]))
144+
145+
# --- apex NS ------------------------------------------------------------
146+
ns = existing.get((zone, "NS"))
147+
want_ns = sorted(canonical(n) for n in desired["nameservers"])
148+
have_ns = sorted(r["content"] for r in ns["records"]) if ns else []
149+
if have_ns != want_ns or (ns is not None and ns["ttl"] != apex_ttl):
150+
patch.append(_replace(zone, "NS", apex_ttl, want_ns))
151+
152+
# --- _health.<region> TXT ------------------------------------------------
153+
# TTL is 300 in zone.template.j2 independently of the zone default, so it is
154+
# carried separately rather than derived from apex_ttl.
155+
health_ttl = int(desired.get("health_ttl", 300))
156+
want_health = {
157+
canonical("_health.{}.{}".format(r["region"], zone)): txt_rdata(r["content"])
158+
for r in desired.get("health_records", [])
159+
}
160+
161+
for name in sorted(want_health):
162+
rrset = existing.get((name, "TXT"))
163+
have = [r["content"] for r in rrset["records"]] if rrset else None
164+
if have != [want_health[name]] or (rrset is not None and rrset["ttl"] != health_ttl):
165+
patch.append(_replace(name, "TXT", health_ttl, [want_health[name]]))
166+
167+
# A region dropped from vars.yaml takes its record with it - that is how
168+
# us-west would have cleaned itself up instead of being deleted by hand.
169+
# Three independent bounds, all checked here rather than inferred:
170+
for name, rtype in sorted(existing):
171+
if rtype != "TXT":
172+
continue
173+
if not name.startswith(HEALTH_PREFIX):
174+
continue
175+
if not name.endswith("." + zone):
176+
continue
177+
if name in want_health:
178+
continue
179+
patch.append({"name": name, "type": "TXT", "changetype": "DELETE"})
180+
181+
return patch
182+
183+
184+
def describe(patch: list) -> list:
185+
"""Render the patch as readable lines for the deploy log."""
186+
lines = []
187+
for rrset in patch:
188+
if rrset["changetype"] == "DELETE":
189+
lines.append("delete {} {}".format(rrset["type"], rrset["name"]))
190+
else:
191+
contents = ", ".join(r["content"] for r in rrset["records"])
192+
lines.append(
193+
"set {} {} ttl={} -> {}".format(
194+
rrset["type"], rrset["name"], rrset["ttl"], contents
195+
)
196+
)
197+
return lines
198+
199+
200+
def api_request(base: str, key: str, path: str, method: str = "GET", body=None):
201+
url = "{}/api/v1/servers/localhost{}".format(base.rstrip("/"), path)
202+
data = json.dumps(body).encode() if body is not None else None
203+
request = urllib.request.Request(url, data=data, method=method)
204+
request.add_header("X-API-Key", key)
205+
if data is not None:
206+
request.add_header("Content-Type", "application/json")
207+
try:
208+
with urllib.request.urlopen(request, timeout=30) as response:
209+
raw = response.read()
210+
return json.loads(raw) if raw else None
211+
except urllib.error.HTTPError as exc:
212+
detail = exc.read().decode("utf-8", "replace")[:500]
213+
raise ReconcileError(
214+
"{} {} -> HTTP {}: {}".format(method, url, exc.code, detail)
215+
) from exc
216+
except urllib.error.URLError as exc:
217+
raise ReconcileError("{} {} -> {}".format(method, url, exc.reason)) from exc
218+
219+
220+
def main() -> int:
221+
parser = argparse.ArgumentParser(description=__doc__)
222+
parser.add_argument("--zone", required=True)
223+
parser.add_argument("--api-url", required=True, help="e.g. http://10.100.0.153:8081")
224+
parser.add_argument(
225+
"--desired", required=True, help="JSON file keyed by canonical zone name"
226+
)
227+
parser.add_argument("--dry-run", action="store_true")
228+
args = parser.parse_args()
229+
230+
# Read from the environment, never argv - argv is world-readable in /proc.
231+
key = os.environ.get("PDNS_API_KEY", "")
232+
if not key:
233+
raise ReconcileError("PDNS_API_KEY is empty; refusing to run")
234+
235+
zone = canonical(args.zone)
236+
with open(args.desired) as handle:
237+
all_desired = json.load(handle)
238+
if zone not in all_desired:
239+
raise ReconcileError(
240+
"no desired state for {} in {}".format(zone, args.desired)
241+
)
242+
desired = all_desired[zone]
243+
244+
zone_data = api_request(args.api_url, key, "/zones/{}".format(zone))
245+
existing = index_rrsets(zone_data)
246+
247+
drift = find_lua_drift(existing, desired)
248+
if drift:
249+
# Stop before writing anything. LUA drift means the zone is not in the
250+
# state we think it is, and a human should look before we mutate it.
251+
print(
252+
json.dumps(
253+
{
254+
"zone": zone,
255+
"changed": False,
256+
"lua_drift": drift,
257+
"message": "LUA record drift detected; no records were written",
258+
},
259+
indent=2,
260+
)
261+
)
262+
return 2
263+
264+
patch = compute_changes(zone, existing, desired)
265+
report = {
266+
"zone": zone,
267+
"changed": bool(patch) and not args.dry_run,
268+
"dry_run": args.dry_run,
269+
"changes": describe(patch),
270+
}
271+
272+
if patch and not args.dry_run:
273+
# One PATCH for the whole zone: one serial bump, one notify, however
274+
# many records moved.
275+
api_request(args.api_url, key, "/zones/{}".format(zone), "PATCH", {"rrsets": patch})
276+
api_request(args.api_url, key, "/zones/{}/notify".format(zone), "PUT")
277+
278+
print(json.dumps(report, indent=2))
279+
return 0
280+
281+
282+
if __name__ == "__main__":
283+
try:
284+
sys.exit(main())
285+
except ReconcileError as error:
286+
print(json.dumps({"error": str(error)}, indent=2), file=sys.stderr)
287+
sys.exit(1)

tests/conftest.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import pathlib
2+
import sys
3+
4+
# scripts/ is a directory of standalone executables, not an installed package.
5+
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "scripts"))

0 commit comments

Comments
 (0)