@@ -18,6 +18,7 @@ package registry
1818
1919import (
2020 "context"
21+ "fmt"
2122 "reflect"
2223 "strings"
2324 "testing"
@@ -1807,3 +1808,223 @@ func TestTXTRegistryRecordsWithEmptyTargets(t *testing.T) {
18071808
18081809 testutils .TestHelperLogContains ("TXT record has no targets empty-targets.test-zone.example.org" , hook , t )
18091810}
1811+
1812+ // TestTXTRegistryRecreatesMissingRecords reproduces issue #4914.
1813+ // It verifies that External‑DNS recreates A/CNAME records that were accidentally deleted while their corresponding TXT records remain.
1814+ // An InMemoryProvider is used because, like Route53, it throws an error when attempting to create a duplicate record.
1815+ func TestTXTRegistryRecreatesMissingRecords (t * testing.T ) {
1816+ ownerId := "owner"
1817+ tests := []struct {
1818+ name string
1819+ desired []* endpoint.Endpoint
1820+ existing []* endpoint.Endpoint
1821+ expectedCreate []* endpoint.Endpoint
1822+ }{
1823+ {
1824+ name : "Recreate missing A record when TXT exists" ,
1825+ desired : []* endpoint.Endpoint {
1826+ newEndpointWithOwner ("new-record-1.test-zone.example.org" , "1.1.1.1" , endpoint .RecordTypeA , "" ),
1827+ },
1828+ existing : []* endpoint.Endpoint {
1829+ newEndpointWithOwner ("new-record-1.test-zone.example.org" , "\" heritage=external-dns,external-dns/owner=" + ownerId + "\" " , endpoint .RecordTypeTXT , ownerId ),
1830+ newEndpointWithOwner ("a-new-record-1.test-zone.example.org" , "\" heritage=external-dns,external-dns/owner=" + ownerId + "\" " , endpoint .RecordTypeTXT , ownerId ),
1831+ },
1832+ expectedCreate : []* endpoint.Endpoint {
1833+ newEndpointWithOwner ("new-record-1.test-zone.example.org" , "1.1.1.1" , endpoint .RecordTypeA , ownerId ),
1834+ },
1835+ },
1836+ {
1837+ name : "Recreate missing AAAA record when TXT exists" ,
1838+ desired : []* endpoint.Endpoint {
1839+ newEndpointWithOwner ("new-record-1.test-zone.example.org" , "2001:db8::1" , endpoint .RecordTypeAAAA , "" ),
1840+ },
1841+ existing : []* endpoint.Endpoint {
1842+ newEndpointWithOwner ("new-record-1.test-zone.example.org" , "\" heritage=external-dns,external-dns/owner=" + ownerId + "\" " , endpoint .RecordTypeTXT , ownerId ),
1843+ newEndpointWithOwner ("aaaa-new-record-1.test-zone.example.org" , "\" heritage=external-dns,external-dns/owner=" + ownerId + "\" " , endpoint .RecordTypeTXT , ownerId ),
1844+ },
1845+ expectedCreate : []* endpoint.Endpoint {
1846+ newEndpointWithOwner ("new-record-1.test-zone.example.org" , "2001:db8::1" , endpoint .RecordTypeAAAA , ownerId ),
1847+ },
1848+ },
1849+ {
1850+ name : "Recreate missing CNAME record when TXT exists" ,
1851+ desired : []* endpoint.Endpoint {
1852+ newEndpointWithOwner ("new-record-1.test-zone.example.org" , "new-loadbalancer-1.lb.com" , endpoint .RecordTypeCNAME , "" ),
1853+ },
1854+ existing : []* endpoint.Endpoint {
1855+ newEndpointWithOwner ("new-record-1.test-zone.example.org" , "\" heritage=external-dns,external-dns/owner=" + ownerId + "\" " , endpoint .RecordTypeTXT , ownerId ),
1856+ newEndpointWithOwner ("cname-new-record-1.test-zone.example.org" , "\" heritage=external-dns,external-dns/owner=" + ownerId + "\" " , endpoint .RecordTypeTXT , ownerId ),
1857+ },
1858+ expectedCreate : []* endpoint.Endpoint {
1859+ newEndpointWithOwner ("new-record-1.test-zone.example.org" , "new-loadbalancer-1.lb.com" , endpoint .RecordTypeCNAME , ownerId )},
1860+ },
1861+ {
1862+ name : "Recreate missing A and CNAME records when TXT exists" ,
1863+ desired : []* endpoint.Endpoint {
1864+ newEndpointWithOwner ("new-record-1.test-zone.example.org" , "1.1.1.1" , endpoint .RecordTypeA , "" ),
1865+ newEndpointWithOwner ("new-record-2.test-zone.example.org" , "new-loadbalancer-1.lb.com" , endpoint .RecordTypeCNAME , "" ),
1866+ },
1867+ existing : []* endpoint.Endpoint {
1868+ newEndpointWithOwner ("new-record-1.test-zone.example.org" , "\" heritage=external-dns,external-dns/owner=" + ownerId + "\" " , endpoint .RecordTypeTXT , ownerId ),
1869+ newEndpointWithOwner ("new-record-2.test-zone.example.org" , "\" heritage=external-dns,external-dns/owner=" + ownerId + "\" " , endpoint .RecordTypeTXT , ownerId ),
1870+ newEndpointWithOwner ("a-new-record-1.test-zone.example.org" , "\" heritage=external-dns,external-dns/owner=" + ownerId + "\" " , endpoint .RecordTypeTXT , ownerId ),
1871+ newEndpointWithOwner ("cname-new-record-2.test-zone.example.org" , "\" heritage=external-dns,external-dns/owner=" + ownerId + "\" " , endpoint .RecordTypeTXT , ownerId ),
1872+ },
1873+ expectedCreate : []* endpoint.Endpoint {
1874+ newEndpointWithOwner ("new-record-1.test-zone.example.org" , "1.1.1.1" , endpoint .RecordTypeA , ownerId ),
1875+ newEndpointWithOwner ("new-record-2.test-zone.example.org" , "new-loadbalancer-1.lb.com" , endpoint .RecordTypeCNAME , ownerId ),
1876+ },
1877+ },
1878+ {
1879+ name : "Recreate missing A records when TXT and CNAME exists" ,
1880+ desired : []* endpoint.Endpoint {
1881+ newEndpointWithOwner ("new-record-1.test-zone.example.org" , "1.1.1.1" , endpoint .RecordTypeA , "" ),
1882+ newEndpointWithOwner ("new-record-2.test-zone.example.org" , "new-loadbalancer-1.lb.com" , endpoint .RecordTypeCNAME , "" ),
1883+ },
1884+ existing : []* endpoint.Endpoint {
1885+ newEndpointWithOwner ("new-record-2.test-zone.example.org" , "new-loadbalancer-1.lb.com" , endpoint .RecordTypeCNAME , ownerId ),
1886+ newEndpointWithOwner ("new-record-1.test-zone.example.org" , "\" heritage=external-dns,external-dns/owner=" + ownerId + "\" " , endpoint .RecordTypeTXT , ownerId ),
1887+ newEndpointWithOwner ("new-record-2.test-zone.example.org" , "\" heritage=external-dns,external-dns/owner=" + ownerId + "\" " , endpoint .RecordTypeTXT , ownerId ),
1888+ newEndpointWithOwner ("a-new-record-1.test-zone.example.org" , "\" heritage=external-dns,external-dns/owner=" + ownerId + "\" " , endpoint .RecordTypeTXT , ownerId ),
1889+ newEndpointWithOwner ("cname-new-record-2.test-zone.example.org" , "\" heritage=external-dns,external-dns/owner=" + ownerId + "\" " , endpoint .RecordTypeTXT , ownerId ),
1890+ },
1891+ expectedCreate : []* endpoint.Endpoint {
1892+ newEndpointWithOwner ("new-record-1.test-zone.example.org" , "1.1.1.1" , endpoint .RecordTypeA , ownerId ),
1893+ },
1894+ },
1895+ {
1896+ name : "Only one A record is missing among several existing records" ,
1897+ desired : []* endpoint.Endpoint {
1898+ newEndpointWithOwner ("record-1.test-zone.example.org" , "1.1.1.1" , endpoint .RecordTypeA , "" ),
1899+ newEndpointWithOwner ("record-2.test-zone.example.org" , "1.1.1.2" , endpoint .RecordTypeA , "" ),
1900+ newEndpointWithOwner ("record-3.test-zone.example.org" , "1.1.1.3" , endpoint .RecordTypeA , "" ),
1901+ newEndpointWithOwner ("record-4.test-zone.example.org" , "2001:db8::4" , endpoint .RecordTypeAAAA , "" ),
1902+ newEndpointWithOwner ("record-5.test-zone.example.org" , "cluster-b" , endpoint .RecordTypeCNAME , "" ),
1903+ },
1904+ existing : []* endpoint.Endpoint {
1905+ newEndpointWithOwner ("record-1.test-zone.example.org" , "\" heritage=external-dns,external-dns/owner=" + ownerId + "\" " , endpoint .RecordTypeTXT , ownerId ),
1906+ newEndpointWithOwner ("a-record-1.test-zone.example.org" , "\" heritage=external-dns,external-dns/owner=" + ownerId + "\" " , endpoint .RecordTypeTXT , ownerId ),
1907+
1908+ newEndpointWithOwner ("record-2.test-zone.example.org" , "1.1.1.2" , endpoint .RecordTypeA , ownerId ),
1909+ newEndpointWithOwner ("record-2.test-zone.example.org" , "\" heritage=external-dns,external-dns/owner=" + ownerId + "\" " , endpoint .RecordTypeTXT , ownerId ),
1910+ newEndpointWithOwner ("a-record-2.test-zone.example.org" , "\" heritage=external-dns,external-dns/owner=" + ownerId + "\" " , endpoint .RecordTypeTXT , ownerId ),
1911+
1912+ newEndpointWithOwner ("record-3.test-zone.example.org" , "1.1.1.3" , endpoint .RecordTypeA , ownerId ),
1913+ newEndpointWithOwner ("record-3.test-zone.example.org" , "\" heritage=external-dns,external-dns/owner=" + ownerId + "\" " , endpoint .RecordTypeTXT , ownerId ),
1914+ newEndpointWithOwner ("a-record-3.test-zone.example.org" , "\" heritage=external-dns,external-dns/owner=" + ownerId + "\" " , endpoint .RecordTypeTXT , ownerId ),
1915+
1916+ newEndpointWithOwner ("record-4.test-zone.example.org" , "2001:db8::4" , endpoint .RecordTypeAAAA , ownerId ),
1917+ newEndpointWithOwner ("record-4.test-zone.example.org" , "\" heritage=external-dns,external-dns/owner=" + ownerId + "\" " , endpoint .RecordTypeTXT , ownerId ),
1918+ newEndpointWithOwner ("aaaa-record-4.test-zone.example.org" , "\" heritage=external-dns,external-dns/owner=" + ownerId + "\" " , endpoint .RecordTypeTXT , ownerId ),
1919+
1920+ newEndpointWithOwner ("record-5.test-zone.example.org" , "cluster-b" , endpoint .RecordTypeCNAME , ownerId ),
1921+ newEndpointWithOwner ("record-5.test-zone.example.org" , "\" heritage=external-dns,external-dns/owner=" + ownerId + "\" " , endpoint .RecordTypeTXT , ownerId ),
1922+ newEndpointWithOwner ("cname-record-5.test-zone.example.org" , "\" heritage=external-dns,external-dns/owner=" + ownerId + "\" " , endpoint .RecordTypeTXT , ownerId ),
1923+ },
1924+ expectedCreate : []* endpoint.Endpoint {
1925+ newEndpointWithOwner ("record-1.test-zone.example.org" , "1.1.1.1" , endpoint .RecordTypeA , ownerId ),
1926+ },
1927+ },
1928+ {
1929+ name : "Should not recreate TXT records for existing A records without owner" ,
1930+ desired : []* endpoint.Endpoint {
1931+ newEndpointWithOwner ("record-1.test-zone.example.org" , "1.1.1.1" , endpoint .RecordTypeA , "" ),
1932+ },
1933+ existing : []* endpoint.Endpoint {
1934+ newEndpointWithOwner ("record-1.test-zone.example.org" , "1.1.1.1" , endpoint .RecordTypeA , ownerId ),
1935+ // Missing TXT record for the existing A record
1936+ },
1937+ expectedCreate : []* endpoint.Endpoint {},
1938+ },
1939+ {
1940+ name : "Should not recreate TXT records for existing A records with another owner" ,
1941+ desired : []* endpoint.Endpoint {
1942+ newEndpointWithOwner ("record-1.test-zone.example.org" , "1.1.1.1" , endpoint .RecordTypeA , "" ),
1943+ },
1944+ existing : []* endpoint.Endpoint {
1945+ // This test uses the `ownerId` variable, and "another-owner" simulates a different owner.
1946+ // In this case, TXT records should not be recreated.
1947+ newEndpointWithOwner ("record-1.test-zone.example.org" , "1.1.1.1" , endpoint .RecordTypeA , "another-owner" ),
1948+ newEndpointWithOwner ("a-record-1.test-zone.example.org" , "\" heritage=external-dns,external-dns/owner=" + "another-owner" + "\" " , endpoint .RecordTypeTXT , "another-owner" ),
1949+ },
1950+ expectedCreate : []* endpoint.Endpoint {},
1951+ },
1952+ }
1953+ for _ , tt := range tests {
1954+ for _ , setIdentifier := range []string {"" , "set-identifier" } {
1955+ for pName , policy := range plan .Policies {
1956+ // Clone inputs per policy to avoid data races when using t.Parallel.
1957+ desired := cloneEndpointsWithOpts (tt .desired , func (e * endpoint.Endpoint ) {
1958+ e .WithSetIdentifier (setIdentifier )
1959+ })
1960+ existing := cloneEndpointsWithOpts (tt .existing , func (e * endpoint.Endpoint ) {
1961+ e .WithSetIdentifier (setIdentifier )
1962+ })
1963+ expectedCreate := cloneEndpointsWithOpts (tt .expectedCreate , func (e * endpoint.Endpoint ) {
1964+ e .WithSetIdentifier (setIdentifier )
1965+ })
1966+
1967+ t .Run (fmt .Sprintf ("%s with %s policy and setIdentifier=%s" , tt .name , pName , setIdentifier ), func (t * testing.T ) {
1968+ t .Parallel ()
1969+ ctx := context .Background ()
1970+ p := inmemory .NewInMemoryProvider ()
1971+
1972+ // Given: Register existing records
1973+ p .CreateZone (testZone )
1974+ err := p .ApplyChanges (ctx , & plan.Changes {Create : existing })
1975+ assert .NoError (t , err )
1976+
1977+ // The first ApplyChanges call should create the expected records.
1978+ // Subsequent calls are expected to be no-ops (i.e., no additional creates).
1979+ isCalled := false
1980+ p .OnApplyChanges = func (ctx context.Context , changes * plan.Changes ) {
1981+ if isCalled {
1982+ assert .Empty (t , changes .Create , "ApplyChanges should not be called multiple times with new changes" )
1983+ } else {
1984+ assert .True (t ,
1985+ testutils .SameEndpoints (changes .Create , expectedCreate ),
1986+ "Expected create changes: %v, but got: %v" , expectedCreate , changes .Create ,
1987+ )
1988+ }
1989+ assert .Empty (t , changes .UpdateNew , "UpdateNew should be empty" )
1990+ assert .Empty (t , changes .UpdateOld , "UpdateOld should be empty" )
1991+ assert .Empty (t , changes .Delete , "Delete should be empty" )
1992+ isCalled = true
1993+ }
1994+
1995+ // When: Apply changes to recreate missing A records
1996+ managedRecords := []string {endpoint .RecordTypeA , endpoint .RecordTypeCNAME , endpoint .RecordTypeAAAA , endpoint .RecordTypeTXT }
1997+ registry , err := NewTXTRegistry (p , "" , "" , ownerId , time .Hour , "" , managedRecords , nil , false , nil )
1998+ assert .NoError (t , err )
1999+
2000+ expectedRecords := append (existing , expectedCreate ... )
2001+
2002+ // Simulate the reconciliation loop by executing multiple times
2003+ reconciliationLoops := 3
2004+ for i := range reconciliationLoops {
2005+ records , err := registry .Records (ctx )
2006+ assert .NoError (t , err )
2007+ plan := & plan.Plan {
2008+ Policies : []plan.Policy {policy },
2009+ Current : records ,
2010+ Desired : desired ,
2011+ ManagedRecords : managedRecords ,
2012+ OwnerID : ownerId ,
2013+ }
2014+ plan = plan .Calculate ()
2015+ err = registry .ApplyChanges (ctx , plan .Changes )
2016+ assert .NoError (t , err )
2017+
2018+ // Then: Verify that the missing records are recreated or the existing records are not modified
2019+ records , err = p .Records (ctx )
2020+ assert .NoError (t , err )
2021+ assert .True (t , testutils .SameEndpoints (records , expectedRecords ),
2022+ "Expected records after reconciliation loop #%d: %v, but got: %v" ,
2023+ i , expectedRecords , records ,
2024+ )
2025+ }
2026+ })
2027+ }
2028+ }
2029+ }
2030+ }
0 commit comments