From c4cdd70f821f4f5d782381b3dac7cdb89483cb26 Mon Sep 17 00:00:00 2001 From: mjannasch Date: Wed, 29 Jul 2026 14:49:00 +0200 Subject: [PATCH 1/2] fix: include AAAA recordsets in Records() Records() only ever returned A, TXT and CNAME recordsets from Designate. Any existing AAAA recordset was invisible to external-dns's planner, so it treated it as permanently missing and retried CreateRecordSet every sync interval, failing forever with 409 duplicate_recordset once the record already existed. Also adds a devstack integration check that seeds an AAAA recordset directly in Designate and asserts the webhook's GET /records reports it back. The existing checks only ever exercise A/TXT since external-dns's fake source never emits AAAA, so they wouldn't have caught this bug. --- .github/workflows/devstack.yml | 20 ++++++++++++++++++++ internal/designate/provider/provider.go | 2 +- internal/designate/provider/provider_test.go | 15 +++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/.github/workflows/devstack.yml b/.github/workflows/devstack.yml index c80f28e..1cf62ca 100644 --- a/.github/workflows/devstack.yml +++ b/.github/workflows/devstack.yml @@ -107,3 +107,23 @@ jobs: run: | if [ $(openstack recordset list all -f value | grep -c " TXT ") -ne 10 ]; then exit 1; fi if [ $(openstack recordset list all -f value | grep -c " A ") -ne 10 ]; then exit 2; fi + + # external-dns's `fake` source only ever produces A records, so the checks above never + # exercise AAAA handling. Regression test for a bug where the webhook's Records() call + # silently dropped AAAA recordsets, so external-dns would never see an AAAA record as + # already existing and would loop forever trying (and failing) to recreate it. Designate + # itself already had the record correctly, so `openstack recordset list` alone can't catch + # this - the bug is specifically in what the webhook reports back over its own API. + - name: Seed an AAAA recordset directly in Designate + run: | + openstack recordset create example.com. aaaa-webhook-test.example.com. --type AAAA --record 2001:db8::1 + + - name: Wait for AAAA recordset to become ACTIVE + run: | + while [ "$(openstack recordset list example.com. -f csv | grep PENDING)" != "" ]; do date; openstack recordset list example.com. -f value; sleep 1; done + + - name: Verify the webhook reports the AAAA recordset via GET /records + run: | + curl -sf -H "Accept: application/external.dns.webhook+json;version=1" http://127.0.0.1:8888/records -o /tmp/records.json + cat /tmp/records.json + jq -e '[.[] | select(.recordType == "AAAA" and .dnsName == "aaaa-webhook-test.example.com." and (.targets[0] == "2001:db8::1"))] | length == 1' /tmp/records.json diff --git a/internal/designate/provider/provider.go b/internal/designate/provider/provider.go index bb7a488..7dfe226 100644 --- a/internal/designate/provider/provider.go +++ b/internal/designate/provider/provider.go @@ -139,7 +139,7 @@ func (p designateProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, e for zoneID := range managedZones { err = p.client.ForEachRecordSet(ctx, zoneID, func(recordSet *recordsets.RecordSet) error { - if recordSet.Type != endpoint.RecordTypeA && recordSet.Type != endpoint.RecordTypeTXT && recordSet.Type != endpoint.RecordTypeCNAME { + if recordSet.Type != endpoint.RecordTypeA && recordSet.Type != endpoint.RecordTypeAAAA && recordSet.Type != endpoint.RecordTypeTXT && recordSet.Type != endpoint.RecordTypeCNAME { return nil } diff --git a/internal/designate/provider/provider_test.go b/internal/designate/provider/provider_test.go index b3c65d2..a82855e 100644 --- a/internal/designate/provider/provider_test.go +++ b/internal/designate/provider/provider_test.go @@ -297,6 +297,11 @@ func TestDesignateRecords(t *testing.T) { TTL: 120, Records: []string{"10.1.1.2"}, }) + rs15ID, _ := client.CreateRecordSet(ctx, zone1ID, recordsets.CreateOpts{ + Name: "www6.example.com.", + Type: endpoint.RecordTypeAAAA, + Records: []string{"2001:db8::1"}, + }) zone2ID := client.AddZone(ctx, zones.Zone{ Name: "test.net.", @@ -345,6 +350,16 @@ func TestDesignateRecords(t *testing.T) { designateOriginalRecords: "10.1.1.2", }, }, + { + DNSName: "www6.example.com", + RecordType: endpoint.RecordTypeAAAA, + Targets: endpoint.Targets{"2001:db8::1"}, + Labels: map[string]string{ + designateRecordSetID: rs15ID, + designateZoneID: zone1ID, + designateOriginalRecords: "2001:db8::1", + }, + }, { DNSName: "srv.test.net", RecordType: endpoint.RecordTypeA, From 70aba5265faf450a39f3bb86ef77ed48d9901fde Mon Sep 17 00:00:00 2001 From: mjannasch Date: Wed, 29 Jul 2026 15:31:32 +0200 Subject: [PATCH 2/2] fix: correct dnsName assertion in AAAA devstack check external-dns's endpoint.NewEndpointWithTTL trims the trailing dot from DNSName, so the /records response has "aaaa-webhook-test.example.com" without a trailing dot, not "aaaa-webhook-test.example.com.". The AAAA fix itself was correct - confirmed by the actual CI run, which showed the record present in the response before this assertion rejected it. --- .github/workflows/devstack.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/devstack.yml b/.github/workflows/devstack.yml index 1cf62ca..97a0305 100644 --- a/.github/workflows/devstack.yml +++ b/.github/workflows/devstack.yml @@ -126,4 +126,4 @@ jobs: run: | curl -sf -H "Accept: application/external.dns.webhook+json;version=1" http://127.0.0.1:8888/records -o /tmp/records.json cat /tmp/records.json - jq -e '[.[] | select(.recordType == "AAAA" and .dnsName == "aaaa-webhook-test.example.com." and (.targets[0] == "2001:db8::1"))] | length == 1' /tmp/records.json + jq -e '[.[] | select(.recordType == "AAAA" and .dnsName == "aaaa-webhook-test.example.com" and (.targets[0] == "2001:db8::1"))] | length == 1' /tmp/records.json