Skip to content

Commit 95cab94

Browse files
Run the zone record reconcile from the playbook
Wires scripts/reconcile_zone_records.py into powerdns_setup.yaml, after the service is up because it works through the HTTP API, and before the DNS verification tests so those check the reconciled state. Primary only: secondaries have api=no and receive these changes by AXFR. templates/reconcile_desired.json.j2 renders the desired state from vars.yaml and mirrors zone.template.j2. The two have to agree - one writes a zone at creation, the other holds it there afterwards - so a test renders this template against the real vars.yaml, feeds the result to the diff against a zone captured from the production API, and requires it to propose nothing. The LUA content strings are pinned against what production actually returns rather than regenerated from the template, since those records are detect-only and a mismatched expectation would fail the play on a zone that is entirely correct. Idempotency is the property that makes this safe to run unconditionally, so it is tested rather than assumed: - a clean zone produces no PATCH, no serial bump and no notify - applying the patch and recomputing proposes nothing, from three different starting states, and again on a third pass to rule out a two-cycle oscillation - a serial rewritten by SOA-EDIT-API does not read as drift, which it would if the reconcile compared a field PowerDNS always chooses itself - the rendered desired-state file is byte-stable, or the template task would report changed on every deploy Two hardening changes came out of writing those. A _health string over 255 bytes is now refused: PowerDNS would split it into chunks in the rdata, so what we sent and what came back could never compare equal and the reconcile would write on every run forever. And the template test mirrors Ansible's to_nice_json exactly, sort_keys included, so it is testing the bytes Ansible will really produce. Reconcile failures are collected and raised after every zone is attempted, so the report is printed before the play stops. rc 2 is LUA drift, where nothing was written; rc 1 is an error. Neither is carried past. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 6cf08de commit 95cab94

5 files changed

Lines changed: 495 additions & 4 deletions

File tree

powerdns_setup.yaml

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -885,6 +885,102 @@
885885
hour: "0"
886886
minute: "0"
887887

888+
# Zone record reconcile
889+
#
890+
# The zone generation and import tasks earlier in this play are gated on the
891+
# zone being ABSENT from SQLite, so everything zone.template.j2 writes -
892+
# _health.* TXT, apex NS, SOA MNAME - lands once, at creation, and never
893+
# again. Editing vars.yaml afterwards changes nothing while the run still
894+
# reports success. That gate cannot simply be dropped, because the import is
895+
# `pdnsutil load-zone`, which replaces the entire zone and would take the
896+
# ~170 app A records and any in-flight _acme-challenge TXT with it.
897+
#
898+
# These tasks reconcile the template-owned records individually instead, on
899+
# every run, and leave everything else alone. They come after the service is
900+
# up because they work through the HTTP API, and run only on the primary -
901+
# secondaries have api=no and receive these changes by AXFR.
902+
- name: Wait for the PowerDNS API to accept connections
903+
ansible.builtin.wait_for:
904+
host: "{{ ansible_host }}"
905+
port: 8081
906+
timeout: 30
907+
when: pdns_role == 'master'
908+
909+
- name: Install the zone record reconcile script
910+
ansible.builtin.copy:
911+
src: scripts/reconcile_zone_records.py
912+
dest: /opt/pdns/scripts/reconcile_zone_records.py
913+
owner: root
914+
group: root
915+
mode: "0755"
916+
when: pdns_role == 'master'
917+
918+
- name: Render desired state for template-owned zone records
919+
ansible.builtin.template:
920+
src: templates/reconcile_desired.json.j2
921+
dest: /run/pdns-reconcile-desired.json
922+
owner: root
923+
group: root
924+
mode: "0600"
925+
when: pdns_role == 'master'
926+
927+
# no_log because the API key is passed in the environment. The script itself
928+
# never prints it, so the report and any error are surfaced by the two tasks
929+
# below without exposing anything.
930+
- name: Reconcile template-owned zone records
931+
ansible.builtin.command:
932+
argv: "{{ reconcile_argv + (['--dry-run'] if ansible_check_mode else []) }}"
933+
vars:
934+
reconcile_argv:
935+
- /opt/pdns/scripts/reconcile_zone_records.py
936+
- "--zone"
937+
- "{{ item.domain }}"
938+
- "--api-url"
939+
- "http://{{ ansible_host }}:8081"
940+
- "--desired"
941+
- /run/pdns-reconcile-desired.json
942+
environment:
943+
PDNS_API_KEY: "{{ powerdns[env].api_key }}"
944+
loop: "{{ powerdns[env].zone_configs }}"
945+
loop_control:
946+
label: "{{ item.domain }}"
947+
register: reconcile_result
948+
# Collected and raised below, so that every zone is attempted and the
949+
# report is printed before the play stops.
950+
failed_when: false
951+
changed_when:
952+
- reconcile_result.rc == 0
953+
- (reconcile_result.stdout | from_json).changed | default(false)
954+
check_mode: false
955+
no_log: true
956+
when: pdns_role == 'master'
957+
958+
- name: Report zone record reconcile
959+
ansible.builtin.debug:
960+
msg: "{{ item.stdout | from_json }}"
961+
loop: "{{ reconcile_result.results | default([]) }}"
962+
loop_control:
963+
label: "{{ item.item.domain }}"
964+
when:
965+
- pdns_role == 'master'
966+
- item.stdout | default('') != ''
967+
968+
# rc 2 is LUA record drift, where nothing was written and a human should
969+
# look before anything is. rc 1 is an error. Neither is something to carry
970+
# on past: every bug in the 2026-07-29 outage was a conditional that quietly
971+
# did the wrong thing while the run reported success.
972+
- name: Fail if any zone did not reconcile
973+
ansible.builtin.fail:
974+
msg: >-
975+
Zone record reconcile failed for {{ item.item.domain }} (rc={{ item.rc }}).
976+
{{ item.stdout | default('') }} {{ item.stderr | default('') }}
977+
loop: "{{ reconcile_result.results | default([]) }}"
978+
loop_control:
979+
label: "{{ item.item.domain }}"
980+
when:
981+
- pdns_role == 'master'
982+
- item.rc | default(0) != 0
983+
888984
# Post-deployment verification
889985

890986
- name: Test DNS resolution for geo-routing domain

scripts/reconcile_zone_records.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@
5555

5656
SOA_FIELDS = 7
5757

58+
# A TXT string longer than this is split into several quoted chunks in the
59+
# rdata, so what we sent and what comes back would never compare equal and the
60+
# reconcile would report a change on every single run. Refuse instead.
61+
MAX_TXT_STRING = 255
62+
5863

5964
class ReconcileError(RuntimeError):
6065
"""Anything that should fail the play rather than be worked around."""
@@ -153,10 +158,17 @@ def compute_changes(zone: str, existing: dict, desired: dict) -> list:
153158
# TTL is 300 in zone.template.j2 independently of the zone default, so it is
154159
# carried separately rather than derived from apex_ttl.
155160
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-
}
161+
want_health = {}
162+
for region in desired.get("health_records", []):
163+
if len(region["content"]) > MAX_TXT_STRING:
164+
raise ReconcileError(
165+
"_health.{} content is {} bytes, over the {}-byte TXT string limit; "
166+
"PowerDNS would split it into chunks and this would never converge".format(
167+
region["region"], len(region["content"]), MAX_TXT_STRING
168+
)
169+
)
170+
name = canonical("_health.{}.{}".format(region["region"], zone))
171+
want_health[name] = txt_rdata(region["content"])
160172

161173
for name in sorted(want_health):
162174
rrset = existing.get((name, "TXT"))
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
{#
2+
Desired state for the template-owned records of every zone, consumed by
3+
scripts/reconcile_zone_records.py. Keyed by canonical (trailing dot) zone name.
4+
5+
This mirrors zone.template.j2. The two must agree, because that template is
6+
what writes a zone at creation and this is what holds it there afterwards - if
7+
they disagree, a freshly created zone reports drift on its very first run.
8+
9+
Note health_ttl is carried separately rather than derived from default_ttl:
10+
zone.template.j2 hardcodes 300 on the _health records while the apex records
11+
take the zone default.
12+
13+
The lua_records entries are compared and never written. Their content strings
14+
must match what the API returns byte for byte, including the record type
15+
prefix and the inner quoting, or the reconcile fails the play on a zone that
16+
is in fact correct. tests/test_reconcile_desired_template.py renders this file
17+
and asserts exactly that against captured production output.
18+
#}
19+
{% set env = 'staging' if DEPLOY_ENV == 'staging' else 'production' %}
20+
{% set desired = {} %}
21+
{% for zone in powerdns[env].zone_configs %}
22+
{% set tv = zone.template_vars %}
23+
{% set health = [] %}
24+
{% set lua = [] %}
25+
{% if tv.geo_routing | default(false) %}
26+
{% set _ = lua.append({
27+
'name': '_config.' ~ zone.domain ~ '.',
28+
'content': 'LUA "dofile(\'/opt/pdns/scripts/geo_routing.lua\')"'}) %}
29+
{% set _ = lua.append({
30+
'name': zone.domain ~ '.',
31+
'content': 'A ";include(\'_config\'); return geoRoute()"'}) %}
32+
{% for region in tv.geo_regions | default([]) %}
33+
{% set _ = health.append({
34+
'region': region.name,
35+
'content': region.description ~ ' - ' ~ region.server ~ ' - ' ~ region.ip}) %}
36+
{% endfor %}
37+
{% if tv.status_endpoint | default(false) %}
38+
{% set _ = lua.append({
39+
'name': '_status.' ~ zone.domain ~ '.',
40+
'content': 'TXT ";include(\'_config\'); return getServerStatus()"'}) %}
41+
{% endif %}
42+
{% endif %}
43+
{% if tv.lua_routing | default(false) %}
44+
{% set _ = lua.append({
45+
'name': '_config.' ~ zone.domain ~ '.',
46+
'content': 'LUA "dofile(\'/opt/pdns/scripts/' ~ tv.routing_script | default('app_routing.lua') ~ '\')"'}) %}
47+
{% if tv.routing_function is defined %}
48+
{% set _ = lua.append({
49+
'name': '*.' ~ zone.domain ~ '.',
50+
'content': 'CNAME ";include(\'_config\'); return ' ~ tv.routing_function ~ '(qname)"'}) %}
51+
{% endif %}
52+
{% if tv.debug_function is defined %}
53+
{% set _ = lua.append({
54+
'name': '_debug.' ~ zone.domain ~ '.',
55+
'content': 'TXT ";include(\'_config\'); return ' ~ tv.debug_function ~ '(qname)"'}) %}
56+
{% endif %}
57+
{% endif %}
58+
{% set _ = desired.update({zone.domain ~ '.': {
59+
'apex_ttl': tv.default_ttl | default('3600') | int,
60+
'health_ttl': 300,
61+
'nameservers': powerdns[env].nameservers,
62+
'soa_mname': powerdns[env].soa_nameserver,
63+
'soa_rname': powerdns[env].soa_email,
64+
'health_records': health,
65+
'lua_records': lua}}) %}
66+
{% endfor %}
67+
{{ desired | to_nice_json }}

0 commit comments

Comments
 (0)