Skip to content

Commit b24eb8a

Browse files
Publish the CDN node list as SRV records
Clients fall back to individual CDN nodes when the geo-routed name is unusable, so that list cannot be discovered through the CDN - it would be circular. Today it is a compile-time constant in a pinned wheel (flux-bootstrap data_structures.py DIRECT_CDN_URLS, duplicated twice more in cdn_manager.py) and in flux_iso_updater's backup_hosts. Retiring a node therefore needs a client release rolled out to every Flux node, which is why cdn-2.runonflux.io - shut down in July, its address since re-leased to another customer - is still in the failover set of two separate consumers. DNS is the right channel for it. It is a different failure domain from HTTP, which is what a fallback path needs, and it is not a new dependency: the entries in those hardcoded lists are already hostnames, so the client cannot use them without a resolver either. Publishing the set here rather than the members is strictly better than the status quo. It also holds up in the failure that matters - if geo routing hands out a bad answer for cdn.runonflux.io, a plain non-geo lookup against the same authoritative servers still resolves. SRV rather than several A records at a well-known name, because A returns bare addresses and connecting by IP defeats certificate verification - the very check that would have caught cdn-2's address changing hands. An SRV target is a hostname, so the client resolves it and verifies normally. In the zone rather than in Cloudflare because records there are managed by nothing, which is precisely how cdn-2's A record survived being cleaned out of everywhere else. Here the reconcile owns them, templated from geo_regions - the same source of truth that already drives geo routing and the _health records - so retiring a region updates routing and the node list together and neither can rot. Only geo zones have regions, so only they advertise a list. One rrset per zone holding every target: a changed set is a single REPLACE, an emptied one a single DELETE bound to that one name. Mutation testing found a real gap while writing this. Comparing the records within an rrset unsorted passed the whole suite, because every fixture happened to be in sorted order already - PowerDNS promises no ordering, so that would have rewritten the rrset on every run. Both SRV and the apex NS set now have a test with deliberately unsorted input, and each fails if its sort is removed. Note "pdns-b." sorts before "pdns." there; listing them the other way round proves nothing. The first production run will create the SRV rrset and change nothing else, which is asserted directly rather than left to be discovered on deploy. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 373dafd commit b24eb8a

4 files changed

Lines changed: 264 additions & 4 deletions

File tree

scripts/reconcile_zone_records.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
written apex NS
2121
apex SOA MNAME and RNAME (never the serial)
2222
_health.<region> TXT
23+
_cdn._tcp SRV (the directly-reachable node list)
2324
2425
detected LUA records - compared and reported, never written
2526
@@ -190,6 +191,45 @@ def compute_changes(zone: str, existing: dict, desired: dict) -> list:
190191
continue
191192
patch.append({"name": name, "type": "TXT", "changetype": "DELETE"})
192193

194+
# --- _cdn._tcp SRV: the directly-reachable node list ----------------------
195+
# Clients fall back to individual nodes when the geo-routed name is unusable,
196+
# so that list cannot be discovered through the CDN itself - it would be
197+
# circular. It lives in DNS instead, which is a different failure domain from
198+
# HTTP and one the client already depends on: the per-node names it uses today
199+
# are themselves hostnames, so publishing the *set* here adds no new
200+
# dependency. If geo routing hands out a bad answer, this plain non-geo lookup
201+
# against the same authoritative servers still resolves.
202+
#
203+
# SRV rather than several A records at a well-known name, because A returns
204+
# bare addresses and connecting by IP defeats TLS verification - which is the
205+
# thing that would have caught cdn-2's address being re-leased. An SRV target
206+
# is a hostname, so the client resolves it and verifies the certificate
207+
# normally.
208+
#
209+
# One rrset per zone holding every target, so a changed set is a single
210+
# REPLACE and an emptied one is a single DELETE.
211+
srv_name = canonical("_cdn._tcp.{}".format(zone))
212+
srv_ttl = int(desired.get("srv_ttl", apex_ttl))
213+
want_srv = sorted(
214+
"{} {} {} {}".format(
215+
target.get("priority", 0),
216+
target.get("weight", 5),
217+
target.get("port", 443),
218+
canonical(target["server"]),
219+
)
220+
for target in desired.get("srv_targets", [])
221+
)
222+
srv_rrset = existing.get((srv_name, "SRV"))
223+
have_srv = sorted(r["content"] for r in srv_rrset["records"]) if srv_rrset else []
224+
225+
if want_srv:
226+
if have_srv != want_srv or (srv_rrset is not None and srv_rrset["ttl"] != srv_ttl):
227+
patch.append(_replace(srv_name, "SRV", srv_ttl, want_srv))
228+
elif srv_rrset is not None:
229+
# No targets configured - the zone should not advertise a node list at
230+
# all. Scoped to this one name, so nothing else can be caught by it.
231+
patch.append({"name": srv_name, "type": "SRV", "changetype": "DELETE"})
232+
193233
return patch
194234

195235

templates/reconcile_desired.json.j2

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
{% set tv = zone.template_vars %}
2323
{% set health = [] %}
2424
{% set lua = [] %}
25+
{% set srv = [] %}
2526
{% if tv.geo_routing | default(false) %}
2627
{% set _ = lua.append({
2728
'name': '_config.' ~ zone.domain ~ '.',
@@ -33,6 +34,16 @@
3334
{% set _ = health.append({
3435
'region': region.name,
3536
'content': region.description ~ ' - ' ~ region.server ~ ' - ' ~ region.ip}) %}
37+
{# The same regions, published as the directly-reachable node list. All
38+
equal priority and weight: this is a fallback set for when the
39+
geo-routed name is unusable, so there is no ordering to express - the
40+
client picks. region.server already holds the per-node hostname, which
41+
is what an SRV target must be. #}
42+
{% set _ = srv.append({
43+
'server': region.server,
44+
'priority': 0,
45+
'weight': 5,
46+
'port': 443}) %}
3647
{% endfor %}
3748
{% if tv.status_endpoint | default(false) %}
3849
{% set _ = lua.append({
@@ -58,10 +69,12 @@
5869
{% set _ = desired.update({zone.domain ~ '.': {
5970
'apex_ttl': tv.default_ttl | default('3600') | int,
6071
'health_ttl': 300,
72+
'srv_ttl': tv.default_ttl | default('3600') | int,
6173
'nameservers': powerdns[env].nameservers,
6274
'soa_mname': powerdns[env].soa_nameserver,
6375
'soa_rname': powerdns[env].soa_email,
6476
'health_records': health,
77+
'srv_targets': srv,
6578
'lua_records': lua}}) %}
6679
{% endfor %}
6780
{{ desired | to_nice_json }}

tests/test_reconcile_desired_template.py

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -147,13 +147,44 @@ def test_ttls_follow_the_zone_template():
147147
assert production["app.runonflux.io."]["apex_ttl"] == 3600
148148

149149

150+
def test_first_deploy_creates_only_the_srv_rrset():
151+
"""cdn-geo as captured on 2026-07-30, before SRV records existed.
152+
153+
Publishing them is the one change the next production run should make. If
154+
this ever grows a second entry, something else has drifted and wants looking
155+
at before deploying.
156+
"""
157+
zone_name = "cdn-geo.runonflux.io."
158+
patch = compute_changes(
159+
zone_name,
160+
index_rrsets(_captured_production_zone(with_srv=False)),
161+
render("production")[zone_name],
162+
)
163+
assert len(patch) == 1
164+
assert patch[0]["name"] == "_cdn._tcp." + zone_name
165+
assert patch[0]["type"] == "SRV"
166+
assert sorted(r["content"] for r in patch[0]["records"]) == [
167+
"0 5 443 cdn-1.runonflux.io.",
168+
"0 5 443 cdn-3.runonflux.io.",
169+
]
170+
171+
150172
def test_round_trip_against_production_zone_is_a_no_op():
151173
"""The end-to-end property: template and script agree on a live zone.
152174
153-
This zone was captured from the production API. Reconciling it against the
154-
rendered desired state must propose nothing.
175+
The steady state once SRV is published - reconciling must propose nothing.
155176
"""
156177
zone_name = "cdn-geo.runonflux.io."
178+
desired = render("production")[zone_name]
179+
assert compute_changes(
180+
zone_name, index_rrsets(_captured_production_zone(with_srv=True)), desired
181+
) == []
182+
183+
184+
def _captured_production_zone(with_srv):
185+
"""cdn-geo as the production API returns it, optionally including the SRV
186+
rrset this change introduces."""
187+
zone_name = "cdn-geo.runonflux.io."
157188
captured = {
158189
"rrsets": [
159190
{
@@ -200,5 +231,16 @@ def test_round_trip_against_production_zone_is_a_no_op():
200231
},
201232
]
202233
}
203-
desired = render("production")[zone_name]
204-
assert compute_changes(zone_name, index_rrsets(captured), desired) == []
234+
if with_srv:
235+
captured["rrsets"].append(
236+
{
237+
"name": "_cdn._tcp." + zone_name,
238+
"type": "SRV",
239+
"ttl": 300,
240+
"records": [
241+
{"content": "0 5 443 cdn-1.runonflux.io.", "disabled": False},
242+
{"content": "0 5 443 cdn-3.runonflux.io.", "disabled": False},
243+
],
244+
}
245+
)
246+
return captured

tests/test_reconcile_zone_records.py

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,15 @@ def geo_zone_data():
7272
}
7373
],
7474
},
75+
{
76+
"name": "_cdn._tcp." + GEO_ZONE,
77+
"type": "SRV",
78+
"ttl": 300,
79+
"records": [
80+
{"content": "0 5 443 cdn-1.runonflux.io.", "disabled": False},
81+
{"content": "0 5 443 cdn-3.runonflux.io.", "disabled": False},
82+
],
83+
},
7584
{
7685
"name": "_config." + GEO_ZONE,
7786
"type": "LUA",
@@ -102,6 +111,11 @@ def geo_desired():
102111
return {
103112
"apex_ttl": 300,
104113
"health_ttl": 300,
114+
"srv_ttl": 300,
115+
"srv_targets": [
116+
{"server": "cdn-1.runonflux.io", "priority": 0, "weight": 5, "port": 443},
117+
{"server": "cdn-3.runonflux.io", "priority": 0, "weight": 5, "port": 443},
118+
],
105119
"nameservers": ["pdns.runonflux.io."],
106120
"soa_mname": "pdns.runonflux.io.",
107121
"soa_rname": "hostmaster.runonflux.io.",
@@ -494,6 +508,157 @@ def test_undotted_vars_still_compare_equal():
494508
assert compute_changes(GEO_ZONE, index_rrsets(geo_zone_data()), desired) == []
495509

496510

511+
# --------------------------------------------------------------------------
512+
# _cdn._tcp SRV - the directly-reachable node list
513+
# --------------------------------------------------------------------------
514+
515+
516+
def test_srv_matching_vars_produces_no_change():
517+
assert compute_changes(GEO_ZONE, index_rrsets(geo_zone_data()), geo_desired()) == []
518+
519+
520+
def test_retired_node_is_dropped_from_the_srv_set():
521+
"""The property that makes a client release unnecessary to retire a node."""
522+
zone = geo_zone_data()
523+
for rrset in zone["rrsets"]:
524+
if rrset["type"] == "SRV":
525+
rrset["records"].append(
526+
{"content": "0 5 443 cdn-2.runonflux.io.", "disabled": False}
527+
)
528+
529+
patch = compute_changes(GEO_ZONE, index_rrsets(zone), geo_desired())
530+
531+
assert len(patch) == 1
532+
assert patch[0]["type"] == "SRV"
533+
assert patch[0]["changetype"] == "REPLACE"
534+
contents = [r["content"] for r in patch[0]["records"]]
535+
assert contents == ["0 5 443 cdn-1.runonflux.io.", "0 5 443 cdn-3.runonflux.io."]
536+
assert not any("cdn-2" in c for c in contents)
537+
538+
539+
def test_missing_srv_rrset_is_created():
540+
zone = geo_zone_data()
541+
zone["rrsets"] = [r for r in zone["rrsets"] if r["type"] != "SRV"]
542+
patch = compute_changes(GEO_ZONE, index_rrsets(zone), geo_desired())
543+
assert len(patch) == 1
544+
assert patch[0]["name"] == "_cdn._tcp." + GEO_ZONE
545+
assert patch[0]["ttl"] == 300
546+
547+
548+
def test_srv_removed_entirely_when_no_targets():
549+
desired = geo_desired()
550+
desired["srv_targets"] = []
551+
patch = compute_changes(GEO_ZONE, index_rrsets(geo_zone_data()), desired)
552+
assert patch == [
553+
{"name": "_cdn._tcp." + GEO_ZONE, "type": "SRV", "changetype": "DELETE"}
554+
]
555+
556+
557+
def test_srv_target_without_trailing_dot_still_compares_equal():
558+
"""vars.yaml holds bare hostnames; the API canonicalises. A mismatch here
559+
would rewrite the rrset on every single run."""
560+
desired = geo_desired()
561+
for target in desired["srv_targets"]:
562+
assert not target["server"].endswith(".")
563+
assert compute_changes(GEO_ZONE, index_rrsets(geo_zone_data()), desired) == []
564+
565+
566+
def test_srv_target_order_in_vars_does_not_matter():
567+
desired = geo_desired()
568+
desired["srv_targets"].reverse()
569+
assert compute_changes(GEO_ZONE, index_rrsets(geo_zone_data()), desired) == []
570+
571+
572+
def test_srv_record_order_from_the_api_does_not_matter():
573+
"""PowerDNS does not promise an order for the records within an rrset.
574+
575+
Comparing them unsorted would rewrite the rrset on every run - and every
576+
fixture here happens to be pre-sorted, so nothing else would notice.
577+
"""
578+
zone = geo_zone_data()
579+
for rrset in zone["rrsets"]:
580+
if rrset["type"] == "SRV":
581+
rrset["records"].reverse()
582+
assert compute_changes(GEO_ZONE, index_rrsets(zone), geo_desired()) == []
583+
584+
585+
def test_ns_record_order_from_the_api_does_not_matter():
586+
"""Same property for the apex NS set, which is compared the same way.
587+
588+
Note "pdns-b." sorts before "pdns." ('-' is 0x2D, '.' is 0x2E), so the
589+
records below are deliberately in unsorted order - listing them the other way
590+
round would be pre-sorted and would prove nothing.
591+
"""
592+
zone = geo_zone_data()
593+
desired = geo_desired()
594+
desired["nameservers"] = ["pdns.runonflux.io.", "pdns-b.runonflux.io."]
595+
for rrset in zone["rrsets"]:
596+
if rrset["type"] == "NS":
597+
rrset["records"] = [
598+
{"content": "pdns.runonflux.io.", "disabled": False},
599+
{"content": "pdns-b.runonflux.io.", "disabled": False},
600+
]
601+
assert compute_changes(GEO_ZONE, index_rrsets(zone), desired) == []
602+
603+
604+
def test_srv_deletion_cannot_reach_another_name():
605+
"""The DELETE branch is bound to the single _cdn._tcp name for this zone."""
606+
zone = geo_zone_data()
607+
zone["rrsets"].append(
608+
{
609+
"name": "_sip._tcp." + GEO_ZONE,
610+
"type": "SRV",
611+
"ttl": 300,
612+
"records": [{"content": "0 5 5060 pbx.example.com.", "disabled": False}],
613+
}
614+
)
615+
desired = geo_desired()
616+
desired["srv_targets"] = []
617+
patch = compute_changes(GEO_ZONE, index_rrsets(zone), desired)
618+
assert [(r["name"], r["type"]) for r in patch] == [
619+
("_cdn._tcp." + GEO_ZONE, "SRV")
620+
]
621+
622+
623+
def test_app_zone_gets_no_srv():
624+
"""Only geo zones have regions, so only they advertise a node list."""
625+
zone = {
626+
"name": APP_ZONE,
627+
"rrsets": [
628+
{
629+
"name": APP_ZONE,
630+
"type": "SOA",
631+
"ttl": 3600,
632+
"records": [
633+
{
634+
"content": (
635+
"pdns.runonflux.io. hostmaster.runonflux.io. "
636+
"2026073722 3600 600 86400 3600"
637+
),
638+
"disabled": False,
639+
}
640+
],
641+
},
642+
{
643+
"name": APP_ZONE,
644+
"type": "NS",
645+
"ttl": 3600,
646+
"records": [{"content": "pdns.runonflux.io.", "disabled": False}],
647+
},
648+
],
649+
}
650+
desired = {
651+
"apex_ttl": 3600,
652+
"nameservers": ["pdns.runonflux.io."],
653+
"soa_mname": "pdns.runonflux.io.",
654+
"soa_rname": "hostmaster.runonflux.io.",
655+
"health_records": [],
656+
"srv_targets": [],
657+
"lua_records": [],
658+
}
659+
assert compute_changes(APP_ZONE, index_rrsets(zone), desired) == []
660+
661+
497662
# --------------------------------------------------------------------------
498663
# Convergence. A reconcile that runs on every deploy must settle after one
499664
# pass - otherwise it bumps the serial and notifies the secondaries forever.

0 commit comments

Comments
 (0)