Skip to content

Commit f718560

Browse files
authored
Merge pull request #702 from mikenairn/feat/ns-record-type-support
feat: Add NS record type support for multi-cluster delegation
2 parents 30b5217 + bfd710b commit f718560

File tree

9 files changed

+1187
-3
lines changed

9 files changed

+1187
-3
lines changed

internal/controller/base_dnsrecord_reconciler.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ func (r *BaseDNSRecordReconciler) applyChanges(ctx context.Context, dnsRecord DN
133133
//ToDo We can't use GetRootHost() here as it currently removes any wildcard prefix which needs to be maintained in this scenario.
134134
rootDomainName := dnsRecord.GetSpec().RootHost
135135
zoneDomainFilter := externaldnsendpoint.NewDomainFilter([]string{dnsRecord.GetZoneDomainName()})
136-
managedDNSRecordTypes := []string{externaldnsendpoint.RecordTypeA, externaldnsendpoint.RecordTypeAAAA, externaldnsendpoint.RecordTypeCNAME}
136+
managedDNSRecordTypes := []string{externaldnsendpoint.RecordTypeA, externaldnsendpoint.RecordTypeAAAA, externaldnsendpoint.RecordTypeCNAME, externaldnsendpoint.RecordTypeNS}
137137
var excludeDNSRecordTypes []string
138138

139139
var recordRegistry externaldnsregistry.Registry

internal/controller/dnsrecord_controller_delegation_test.go

Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1754,6 +1754,301 @@ var _ = Describe("DNSRecordReconciler", func() {
17541754
g.Expect(err).To(MatchError(ContainSubstring("not found")))
17551755
}, TestTimeoutMedium, time.Second, ctx).Should(Succeed())
17561756
})
1757+
1758+
// Test that NS records from multiple primaries are correctly merged into authoritative record
1759+
It("should merge NS records from multiple primaries into authoritative record", Labels{"primary", "multi-primary", "ns-records"}, func(ctx SpecContext) {
1760+
var primary1AuthRecord, primary2AuthRecord *v1alpha1.DNSRecord
1761+
1762+
// Create delegating NS records on both primaries
1763+
primary1NSRecord := &v1alpha1.DNSRecord{
1764+
ObjectMeta: metav1.ObjectMeta{
1765+
Name: "ns-" + testHostname,
1766+
Namespace: testNamespace,
1767+
},
1768+
Spec: v1alpha1.DNSRecordSpec{
1769+
RootHost: testHostname,
1770+
Endpoints: []*externaldnsendpoint.Endpoint{
1771+
{
1772+
DNSName: testHostname,
1773+
Targets: []string{"ns1.primary1.example.com"},
1774+
RecordType: "NS",
1775+
RecordTTL: 300,
1776+
Labels: nil,
1777+
ProviderSpecific: nil,
1778+
},
1779+
},
1780+
Delegate: true,
1781+
},
1782+
}
1783+
1784+
primary2NSRecord := &v1alpha1.DNSRecord{
1785+
ObjectMeta: metav1.ObjectMeta{
1786+
Name: "ns-" + testHostname,
1787+
Namespace: testNamespace,
1788+
},
1789+
Spec: v1alpha1.DNSRecordSpec{
1790+
RootHost: testHostname,
1791+
Endpoints: []*externaldnsendpoint.Endpoint{
1792+
{
1793+
DNSName: testHostname,
1794+
Targets: []string{"ns1.primary2.example.com"},
1795+
RecordType: "NS",
1796+
RecordTTL: 300,
1797+
Labels: nil,
1798+
ProviderSpecific: nil,
1799+
},
1800+
},
1801+
Delegate: true,
1802+
},
1803+
}
1804+
1805+
By("creating delegating NS record on primary-1")
1806+
Expect(primaryK8sClient.Create(ctx, primary1NSRecord)).To(Succeed())
1807+
1808+
By("creating delegating NS record on primary-2")
1809+
Expect(primary2K8sClient.Create(ctx, primary2NSRecord)).To(Succeed())
1810+
1811+
By("verifying the status of primary-1 and primary-2 NS records")
1812+
Eventually(func(g Gomega) {
1813+
// Find the primary-1 NS record
1814+
g.Expect(primaryK8sClient.Get(ctx, client.ObjectKeyFromObject(primary1NSRecord), primary1NSRecord)).To(Succeed())
1815+
// Find the primary-2 NS record
1816+
g.Expect(primary2K8sClient.Get(ctx, client.ObjectKeyFromObject(primary2NSRecord), primary2NSRecord)).To(Succeed())
1817+
1818+
// Verify the expected state of the primary records
1819+
g.Expect(primary1NSRecord.Status.Conditions).To(
1820+
ContainElement(MatchFields(IgnoreExtras, Fields{
1821+
"Type": Equal(string(v1alpha1.ConditionTypeReady)),
1822+
"Status": Equal(metav1.ConditionTrue),
1823+
"Reason": Equal("ProviderSuccess"),
1824+
"Message": Equal("Provider ensured the dns record"),
1825+
"ObservedGeneration": Equal(primary1NSRecord.Generation),
1826+
})),
1827+
)
1828+
g.Expect(primary1NSRecord.IsDelegating()).To(BeTrue())
1829+
g.Expect(primary1NSRecord.Status.DomainOwners).To(ConsistOf(primary1NSRecord.GetUIDHash(), primary2NSRecord.GetUIDHash()))
1830+
1831+
g.Expect(primary2NSRecord.Status.Conditions).To(
1832+
ContainElement(MatchFields(IgnoreExtras, Fields{
1833+
"Type": Equal(string(v1alpha1.ConditionTypeReady)),
1834+
"Status": Equal(metav1.ConditionTrue),
1835+
"Reason": Equal("ProviderSuccess"),
1836+
"Message": Equal("Provider ensured the dns record"),
1837+
"ObservedGeneration": Equal(primary2NSRecord.Generation),
1838+
})),
1839+
)
1840+
g.Expect(primary2NSRecord.IsDelegating()).To(BeTrue())
1841+
g.Expect(primary2NSRecord.Status.DomainOwners).To(ConsistOf(primary1NSRecord.GetUIDHash(), primary2NSRecord.GetUIDHash()))
1842+
}, TestTimeoutLong, time.Second).Should(Succeed())
1843+
1844+
By("verifying an authoritative record exists for the test host on both primary-1 and primary-2")
1845+
Eventually(func(g Gomega) {
1846+
// Find the authoritative record on primary-1
1847+
authRecords := &v1alpha1.DNSRecordList{}
1848+
g.Expect(primaryK8sClient.List(ctx, authRecords, client.InNamespace(testNamespace), client.MatchingLabels{v1alpha1.AuthoritativeRecordLabel: "true", v1alpha1.AuthoritativeRecordHashLabel: common.HashRootHost(testHostname)})).To(Succeed())
1849+
g.Expect(authRecords.Items).To(HaveLen(1))
1850+
primary1AuthRecord = &authRecords.Items[0]
1851+
1852+
// Find the authoritative record on primary-2
1853+
g.Expect(primary2K8sClient.List(ctx, authRecords, client.InNamespace(testNamespace), client.MatchingLabels{v1alpha1.AuthoritativeRecordLabel: "true", v1alpha1.AuthoritativeRecordHashLabel: common.HashRootHost(testHostname)})).To(Succeed())
1854+
g.Expect(authRecords.Items).To(HaveLen(1))
1855+
primary2AuthRecord = &authRecords.Items[0]
1856+
}, TestTimeoutMedium, time.Second).Should(Succeed())
1857+
1858+
By(fmt.Sprintf("setting the inmemory dns provider as the default in the '%s' test namespace on primary-1", testNamespace))
1859+
// Set the default-provider label on the provider secret for primary-1
1860+
labels := primary1DNSProviderSecret.GetLabels()
1861+
if labels == nil {
1862+
labels = map[string]string{}
1863+
}
1864+
labels[v1alpha1.DefaultProviderSecretLabel] = "true"
1865+
primary1DNSProviderSecret.SetLabels(labels)
1866+
Expect(primaryK8sClient.Update(ctx, primary1DNSProviderSecret)).To(Succeed())
1867+
1868+
By(fmt.Sprintf("setting the inmemory dns provider as the default in the '%s' test namespace on primary-2", testNamespace))
1869+
// Set the default-provider label on the provider secret for primary-2
1870+
labels = primary2DNSProviderSecret.GetLabels()
1871+
if labels == nil {
1872+
labels = map[string]string{}
1873+
}
1874+
labels[v1alpha1.DefaultProviderSecretLabel] = "true"
1875+
primary2DNSProviderSecret.SetLabels(labels)
1876+
Expect(primary2K8sClient.Update(ctx, primary2DNSProviderSecret)).To(Succeed())
1877+
1878+
By("verifying the authoritative records have the correct merged NS endpoints")
1879+
Eventually(func(g Gomega) {
1880+
// Get the authoritative record on primary-1
1881+
g.Expect(primaryK8sClient.Get(ctx, client.ObjectKeyFromObject(primary1AuthRecord), primary1AuthRecord)).To(Succeed())
1882+
1883+
// Verify the expected state of the authoritative record
1884+
g.Expect(primary1AuthRecord.Name).To(Equal(fmt.Sprintf("authoritative-record-%s", common.HashRootHost(testHostname))))
1885+
g.Expect(primary1AuthRecord.IsDelegating()).To(BeFalse())
1886+
g.Expect(primary1AuthRecord.Spec.RootHost).To(Equal(testHostname))
1887+
g.Expect(primary1AuthRecord.Labels).Should(HaveKeyWithValue("kuadrant.io/dns-provider-name", "inmemory"))
1888+
g.Expect(primary1AuthRecord.Status.Conditions).To(
1889+
ContainElement(MatchFields(IgnoreExtras, Fields{
1890+
"Type": Equal(string(v1alpha1.ConditionTypeReady)),
1891+
"Status": Equal(metav1.ConditionTrue),
1892+
"Reason": Equal("ProviderSuccess"),
1893+
"Message": Equal("Provider ensured the dns record"),
1894+
})),
1895+
)
1896+
1897+
// Authoritative record should contain the merged NS endpoints from both primaries
1898+
g.Expect(primary1AuthRecord.Spec.Endpoints).To(HaveLen(3))
1899+
g.Expect(primary1AuthRecord.Spec.Endpoints).To(ContainElements(
1900+
PointTo(MatchFields(IgnoreExtras, Fields{
1901+
"DNSName": Equal(testHostname),
1902+
"Targets": ConsistOf("ns1.primary1.example.com", "ns1.primary2.example.com"),
1903+
"RecordType": Equal("NS"),
1904+
"RecordTTL": Equal(externaldnsendpoint.TTL(300)),
1905+
})),
1906+
PointTo(MatchFields(IgnoreExtras, Fields{
1907+
"DNSName": HaveSuffix(testHostname),
1908+
"Targets": ConsistOf("\"heritage=external-dns,external-dns/owner=" + primary1NSRecord.Status.OwnerID + ",external-dns/version=1\""),
1909+
"RecordType": Equal("TXT"),
1910+
"RecordTTL": Equal(externaldnsendpoint.TTL(0)),
1911+
})),
1912+
PointTo(MatchFields(IgnoreExtras, Fields{
1913+
"DNSName": HaveSuffix(testHostname),
1914+
"Targets": ConsistOf("\"heritage=external-dns,external-dns/owner=" + primary2NSRecord.Status.OwnerID + ",external-dns/version=1\""),
1915+
"RecordType": Equal("TXT"),
1916+
"RecordTTL": Equal(externaldnsendpoint.TTL(0)),
1917+
})),
1918+
))
1919+
1920+
// Get the authoritative record on primary-2
1921+
g.Expect(primary2K8sClient.Get(ctx, client.ObjectKeyFromObject(primary2AuthRecord), primary2AuthRecord)).To(Succeed())
1922+
1923+
// Verify the expected state of the authoritative record on primary-2
1924+
g.Expect(primary2AuthRecord.Name).To(Equal(fmt.Sprintf("authoritative-record-%s", common.HashRootHost(testHostname))))
1925+
g.Expect(primary2AuthRecord.IsDelegating()).To(BeFalse())
1926+
g.Expect(primary2AuthRecord.Spec.RootHost).To(Equal(testHostname))
1927+
g.Expect(primary2AuthRecord.Labels).Should(HaveKeyWithValue("kuadrant.io/dns-provider-name", "inmemory"))
1928+
g.Expect(primary2AuthRecord.Status.Conditions).To(
1929+
ContainElement(MatchFields(IgnoreExtras, Fields{
1930+
"Type": Equal(string(v1alpha1.ConditionTypeReady)),
1931+
"Status": Equal(metav1.ConditionTrue),
1932+
"Reason": Equal("ProviderSuccess"),
1933+
"Message": Equal("Provider ensured the dns record"),
1934+
})),
1935+
)
1936+
1937+
// Authoritative record on primary-2 should also contain the merged NS endpoints
1938+
g.Expect(primary2AuthRecord.Spec.Endpoints).To(HaveLen(3))
1939+
g.Expect(primary2AuthRecord.Spec.Endpoints).To(ContainElements(
1940+
PointTo(MatchFields(IgnoreExtras, Fields{
1941+
"DNSName": Equal(testHostname),
1942+
"Targets": ConsistOf("ns1.primary1.example.com", "ns1.primary2.example.com"),
1943+
"RecordType": Equal("NS"),
1944+
"RecordTTL": Equal(externaldnsendpoint.TTL(300)),
1945+
})),
1946+
PointTo(MatchFields(IgnoreExtras, Fields{
1947+
"DNSName": HaveSuffix(testHostname),
1948+
"Targets": ConsistOf("\"heritage=external-dns,external-dns/owner=" + primary1NSRecord.Status.OwnerID + ",external-dns/version=1\""),
1949+
"RecordType": Equal("TXT"),
1950+
"RecordTTL": Equal(externaldnsendpoint.TTL(0)),
1951+
})),
1952+
PointTo(MatchFields(IgnoreExtras, Fields{
1953+
"DNSName": HaveSuffix(testHostname),
1954+
"Targets": ConsistOf("\"heritage=external-dns,external-dns/owner=" + primary2NSRecord.Status.OwnerID + ",external-dns/version=1\""),
1955+
"RecordType": Equal("TXT"),
1956+
"RecordTTL": Equal(externaldnsendpoint.TTL(0)),
1957+
})),
1958+
))
1959+
}, TestTimeoutMedium, time.Second).Should(Succeed())
1960+
1961+
By("updating NS record on primary-1 to add an additional nameserver")
1962+
Eventually(func(g Gomega) {
1963+
g.Expect(primaryK8sClient.Get(ctx, client.ObjectKeyFromObject(primary1NSRecord), primary1NSRecord)).To(Succeed())
1964+
primary1NSRecord.Spec.Endpoints = []*externaldnsendpoint.Endpoint{
1965+
{
1966+
DNSName: testHostname,
1967+
Targets: []string{"ns1.primary1.example.com", "ns2.primary1.example.com"},
1968+
RecordType: "NS",
1969+
RecordTTL: 300,
1970+
Labels: nil,
1971+
ProviderSpecific: nil,
1972+
},
1973+
}
1974+
g.Expect(primaryK8sClient.Update(ctx, primary1NSRecord)).To(Succeed())
1975+
}, TestTimeoutShort, time.Second).Should(Succeed())
1976+
1977+
By("verifying the authoritative record is updated with the new nameserver")
1978+
Eventually(func(g Gomega) {
1979+
// Get the authoritative record on primary-1
1980+
g.Expect(primaryK8sClient.Get(ctx, client.ObjectKeyFromObject(primary1AuthRecord), primary1AuthRecord)).To(Succeed())
1981+
1982+
// Authoritative record should now contain all three nameservers
1983+
g.Expect(primary1AuthRecord.Spec.Endpoints).To(ContainElement(
1984+
PointTo(MatchFields(IgnoreExtras, Fields{
1985+
"DNSName": Equal(testHostname),
1986+
"Targets": ConsistOf("ns1.primary1.example.com", "ns2.primary1.example.com", "ns1.primary2.example.com"),
1987+
"RecordType": Equal("NS"),
1988+
"RecordTTL": Equal(externaldnsendpoint.TTL(300)),
1989+
})),
1990+
))
1991+
1992+
// Get the authoritative record on primary-2
1993+
g.Expect(primary2K8sClient.Get(ctx, client.ObjectKeyFromObject(primary2AuthRecord), primary2AuthRecord)).To(Succeed())
1994+
1995+
// Authoritative record on primary-2 should also be updated
1996+
g.Expect(primary2AuthRecord.Spec.Endpoints).To(ContainElement(
1997+
PointTo(MatchFields(IgnoreExtras, Fields{
1998+
"DNSName": Equal(testHostname),
1999+
"Targets": ConsistOf("ns1.primary1.example.com", "ns2.primary1.example.com", "ns1.primary2.example.com"),
2000+
"RecordType": Equal("NS"),
2001+
"RecordTTL": Equal(externaldnsendpoint.TTL(300)),
2002+
})),
2003+
))
2004+
}, TestTimeoutMedium, time.Second).Should(Succeed())
2005+
2006+
By("deleting NS record on primary-1")
2007+
Expect(primaryK8sClient.Delete(ctx, primary1NSRecord)).To(Succeed())
2008+
2009+
By("verifying the authoritative record is updated to remove primary-1 nameservers")
2010+
Eventually(func(g Gomega) {
2011+
// Get the authoritative record on primary-1
2012+
g.Expect(primaryK8sClient.Get(ctx, client.ObjectKeyFromObject(primary1AuthRecord), primary1AuthRecord)).To(Succeed())
2013+
2014+
// Authoritative record should only contain primary-2's nameserver
2015+
g.Expect(primary1AuthRecord.Spec.Endpoints).To(HaveLen(2))
2016+
g.Expect(primary1AuthRecord.Spec.Endpoints).To(ContainElements(
2017+
PointTo(MatchFields(IgnoreExtras, Fields{
2018+
"DNSName": Equal(testHostname),
2019+
"Targets": ConsistOf("ns1.primary2.example.com"),
2020+
"RecordType": Equal("NS"),
2021+
"RecordTTL": Equal(externaldnsendpoint.TTL(300)),
2022+
})),
2023+
PointTo(MatchFields(IgnoreExtras, Fields{
2024+
"DNSName": HaveSuffix(testHostname),
2025+
"Targets": ConsistOf("\"heritage=external-dns,external-dns/owner=" + primary2NSRecord.Status.OwnerID + ",external-dns/version=1\""),
2026+
"RecordType": Equal("TXT"),
2027+
"RecordTTL": Equal(externaldnsendpoint.TTL(0)),
2028+
})),
2029+
))
2030+
2031+
// Get the authoritative record on primary-2
2032+
g.Expect(primary2K8sClient.Get(ctx, client.ObjectKeyFromObject(primary2AuthRecord), primary2AuthRecord)).To(Succeed())
2033+
2034+
// Authoritative record on primary-2 should also be updated
2035+
g.Expect(primary2AuthRecord.Spec.Endpoints).To(HaveLen(2))
2036+
g.Expect(primary2AuthRecord.Spec.Endpoints).To(ContainElements(
2037+
PointTo(MatchFields(IgnoreExtras, Fields{
2038+
"DNSName": Equal(testHostname),
2039+
"Targets": ConsistOf("ns1.primary2.example.com"),
2040+
"RecordType": Equal("NS"),
2041+
"RecordTTL": Equal(externaldnsendpoint.TTL(300)),
2042+
})),
2043+
PointTo(MatchFields(IgnoreExtras, Fields{
2044+
"DNSName": HaveSuffix(testHostname),
2045+
"Targets": ConsistOf("\"heritage=external-dns,external-dns/owner=" + primary2NSRecord.Status.OwnerID + ",external-dns/version=1\""),
2046+
"RecordType": Equal("TXT"),
2047+
"RecordTTL": Equal(externaldnsendpoint.TTL(0)),
2048+
})),
2049+
))
2050+
}, TestTimeoutMedium, time.Second).Should(Succeed())
2051+
})
17572052
})
17582053
})
17592054

internal/external-dns/plan/plan.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -502,8 +502,10 @@ func (e *managedRecordSetChanges) calculateDesired(update *endpointUpdate) {
502502
currentCopy := update.current.DeepCopy()
503503
desiredCopy := update.desired.DeepCopy()
504504

505-
// A Records can be merged, but we remove the known previous target values first in order to ensure potentially stale values are removed
506-
if update.current.RecordType == endpoint.RecordTypeA {
505+
// A, AAAA, and NS Records support multi-owner merging by combining targets from different owners.
506+
// This enables multiple clusters to contribute IPs (A/AAAA) or nameservers (NS) to the same DNS record.
507+
// We remove the known previous target values first to ensure stale values from updates are cleaned up.
508+
if update.current.RecordType == endpoint.RecordTypeA || update.current.RecordType == endpoint.RecordTypeAAAA || update.current.RecordType == endpoint.RecordTypeNS {
507509
if update.previous != nil {
508510
removeEndpointTargets(update.previous.Targets, currentCopy)
509511
}

0 commit comments

Comments
 (0)