From 27c562a85eb5b9a2174aa80e60ab45dc2593f3bc Mon Sep 17 00:00:00 2001 From: Natalie Perret <11332444+natalie-o-perret@users.noreply.github.com> Date: Tue, 14 Apr 2026 12:11:57 +0200 Subject: [PATCH] fix(exoscale): handle apex DNS records in provider EndpointZoneID only matched records where the DNS name had a dot-prefixed zone suffix (e.g. sub.example.com matches .example.com). Apex records like example.com do not have that prefix, so they were silently skipped, causing external-dns to log CREATE but never call the API. Fix EndpointZoneID to also match when the DNS name equals the zone name exactly, returning an empty record name for apex records. Fix Records() to build the correct FQDN for apex records returned by the API (Name == ""), which previously produced .example.com instead of example.com, breaking the idempotency check and causing repeated CREATE attempts. Also fix TestExoscaleApplyChanges to reset all global state slices before running to avoid test-order-dependent failures. --- provider/exoscale/exoscale.go | 11 ++++- provider/exoscale/exoscale_test.go | 68 +++++++++++++++++++++++++++++- 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/provider/exoscale/exoscale.go b/provider/exoscale/exoscale.go index 853c4306c9..0e0e25c6ac 100644 --- a/provider/exoscale/exoscale.go +++ b/provider/exoscale/exoscale.go @@ -253,7 +253,11 @@ func (ep *ExoscaleProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, continue } - e := endpoint.NewEndpointWithTTL((*record.Name)+"."+(*domain.UnicodeName), *record.Type, endpoint.TTL(*record.TTL), *record.Content) + fqdn := *domain.UnicodeName + if *record.Name != "" { + fqdn = (*record.Name) + "." + (*domain.UnicodeName) + } + e := endpoint.NewEndpointWithTTL(fqdn, *record.Type, endpoint.TTL(*record.TTL), *record.Content) endpoints = append(endpoints, e) } } @@ -313,6 +317,11 @@ func (f *zoneFilter) EndpointZoneID(endpoint *endpoint.Endpoint, zones map[strin matchZoneName = zoneName matchZoneID = zoneID name = strings.TrimSuffix(endpoint.DNSName, "."+zoneName) + } else if endpoint.DNSName == zoneName && len(zoneName) > len(matchZoneName) { + // apex record: the DNS name is exactly the zone name, record name is empty + matchZoneName = zoneName + matchZoneID = zoneID + name = "" } } return matchZoneID, name diff --git a/provider/exoscale/exoscale_test.go b/provider/exoscale/exoscale_test.go index 4c8a435442..3be4996378 100644 --- a/provider/exoscale/exoscale_test.go +++ b/provider/exoscale/exoscale_test.go @@ -55,10 +55,12 @@ var ( var defaultTTL int64 = 3600 var domainIDs = []string{uuid.New().String(), uuid.New().String(), uuid.New().String(), uuid.New().String()} +var apexRecordID = uuid.New().String() var groups = map[string][]egoscale.DNSDomainRecord{ domainIDs[0]: { {ID: new(uuid.New().String()), Name: new("v1"), Type: new("TXT"), Content: new("test"), TTL: &defaultTTL}, {ID: new(uuid.New().String()), Name: new("v2"), Type: new("CNAME"), Content: new("test"), TTL: &defaultTTL}, + {ID: &apexRecordID, Name: new(""), Type: new("A"), Content: new("1.2.3.4"), TTL: &defaultTTL}, }, domainIDs[1]: { {ID: new(uuid.New().String()), Name: new("v2"), Type: new("A"), Content: new("test"), TTL: &defaultTTL}, @@ -120,10 +122,12 @@ func TestExoscaleGetRecords(t *testing.T) { recs, err := provider.Records(t.Context()) if err == nil { - assert.Len(t, recs, 3) + assert.Len(t, recs, 4) assert.True(t, contains(recs, "v1.foo.com")) assert.True(t, contains(recs, "v2.bar.com")) assert.True(t, contains(recs, "v2.foo.com")) + assert.True(t, contains(recs, "foo.com")) // apex record + assert.False(t, contains(recs, ".foo.com")) // old broken form must not appear assert.False(t, contains(recs, "v3.bar.com")) assert.False(t, contains(recs, "v1.foobar.com")) } else { @@ -131,6 +135,67 @@ func TestExoscaleGetRecords(t *testing.T) { } } +func TestExoscaleApplyChanges_ApexRecord(t *testing.T) { + provider := NewExoscaleProviderWithClient(NewExoscaleClientStub(), "", "", false) + + changes := &plan.Changes{ + Create: []*endpoint.Endpoint{ + {DNSName: "foo.com", RecordType: "A", Targets: []string{"5.6.7.8"}}, + }, + Delete: []*endpoint.Endpoint{ + {DNSName: "foo.com", RecordType: "A", Targets: []string{"1.2.3.4"}}, + }, + UpdateNew: []*endpoint.Endpoint{ + {DNSName: "foo.com", RecordType: "A", Targets: []string{"5.6.7.8"}}, + }, + UpdateOld: []*endpoint.Endpoint{ + {DNSName: "foo.com", RecordType: "A", Targets: []string{"1.2.3.4"}}, + }, + } + createExoscale = make([]createRecordExoscale, 0) + deleteExoscale = make([]deleteRecordExoscale, 0) + updateExoscale = make([]updateRecordExoscale, 0) + + err := provider.ApplyChanges(t.Context(), changes) + assert.NoError(t, err) + + // create: apex record should be sent with empty name + assert.Len(t, createExoscale, 1) + assert.Equal(t, domainIDs[0], createExoscale[0].domainID) + assert.Equal(t, "", *createExoscale[0].record.Name) + + // delete: apex record should be matched and deleted + assert.Len(t, deleteExoscale, 1) + assert.Equal(t, apexRecordID, deleteExoscale[0].recordID) + + // update: apex record should be matched and updated + assert.Len(t, updateExoscale, 1) + assert.Equal(t, domainIDs[0], updateExoscale[0].domainID) +} + +func TestEndpointZoneID_ApexRecord(t *testing.T) { + zones := map[string]string{ + "zone-1": "example.com", + "zone-2": "other.com", + } + f := &zoneFilter{} + + // subdomain: existing behavior + zoneID, name := f.EndpointZoneID(&endpoint.Endpoint{DNSName: "sub.example.com"}, zones) + assert.Equal(t, "zone-1", zoneID) + assert.Equal(t, "sub", name) + + // apex: DNS name exactly equals zone name + zoneID, name = f.EndpointZoneID(&endpoint.Endpoint{DNSName: "example.com"}, zones) + assert.Equal(t, "zone-1", zoneID) + assert.Equal(t, "", name) + + // no match + zoneID, name = f.EndpointZoneID(&endpoint.Endpoint{DNSName: "notexist.com"}, zones) + assert.Equal(t, "", zoneID) + assert.Equal(t, "", name) +} + func TestExoscaleApplyChanges(t *testing.T) { provider := NewExoscaleProviderWithClient(NewExoscaleClientStub(), "", "", false) @@ -186,6 +251,7 @@ func TestExoscaleApplyChanges(t *testing.T) { } createExoscale = make([]createRecordExoscale, 0) deleteExoscale = make([]deleteRecordExoscale, 0) + updateExoscale = make([]updateRecordExoscale, 0) provider.ApplyChanges(t.Context(), plan)