Skip to content

Commit 4822baa

Browse files
committed
Don't publish duplicate DNS records
Before attempting to publish a domain to a zone, check if that domain is already being published to the same zone.
1 parent 2371120 commit 4822baa

File tree

2 files changed

+142
-9
lines changed

2 files changed

+142
-9
lines changed

pkg/operator/controller/dns/controller.go

Lines changed: 107 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ const (
6262
kubeCloudConfigName = "kube-cloud-config"
6363
// cloudCABundleKey is the key in the kube cloud config ConfigMap where the custom CA bundle is located
6464
cloudCABundleKey = "ca-bundle.pem"
65+
// dnsRecordIndexFieldName is the key for the DNSRecord index, used to identify conflicting domain names.
66+
dnsRecordIndexFieldName = "Spec.DNSName"
6567
)
6668

6769
var log = logf.Logger.WithName(controllerName)
@@ -81,6 +83,17 @@ func New(mgr manager.Manager, config Config) (runtimecontroller.Controller, erro
8183
if err := c.Watch(source.Kind[client.Object](operatorCache, &iov1.DNSRecord{}, &handler.EnqueueRequestForObject{}, predicate.GenerationChangedPredicate{})); err != nil {
8284
return nil, err
8385
}
86+
// When a DNS record is deleted, there may be a conflicting record that should be published. Add a second watch
87+
// exclusively for deletes so that in addition to the normal on-delete cleanup, queue a reconcile for the next
88+
// record with the same domain name (if one exists) so that it can be published.
89+
if err := c.Watch(source.Kind[client.Object](operatorCache, &iov1.DNSRecord{}, handler.EnqueueRequestsFromMapFunc(reconciler.mapOnRecordDelete), predicate.Funcs{
90+
CreateFunc: func(e event.CreateEvent) bool { return false },
91+
DeleteFunc: func(e event.DeleteEvent) bool { return true },
92+
UpdateFunc: func(e event.UpdateEvent) bool { return false },
93+
GenericFunc: func(e event.GenericEvent) bool { return false },
94+
})); err != nil {
95+
return nil, err
96+
}
8497
if err := c.Watch(source.Kind[client.Object](operatorCache, &configv1.DNS{}, handler.EnqueueRequestsFromMapFunc(reconciler.ToDNSRecords))); err != nil {
8598
return nil, err
8699
}
@@ -102,6 +115,12 @@ func New(mgr manager.Manager, config Config) (runtimecontroller.Controller, erro
102115
})); err != nil {
103116
return nil, err
104117
}
118+
if err := operatorCache.IndexField(context.Background(), &iov1.DNSRecord{}, dnsRecordIndexFieldName, func(o client.Object) []string {
119+
dnsRecord := o.(*iov1.DNSRecord)
120+
return []string{dnsRecord.Spec.DNSName}
121+
}); err != nil {
122+
return nil, err
123+
}
105124
return c, nil
106125
}
107126

@@ -337,17 +356,33 @@ func (r *reconciler) publishRecordToZones(zones []configv1.DNSZone, record *iov1
337356

338357
var err error
339358
var condition iov1.DNSZoneCondition
359+
var isDomainPublished bool
340360
if dnsPolicy == iov1.UnmanagedDNS {
341-
log.Info("DNS record not published", "record", record.Spec)
361+
log.Info("DNS record not published: DNS management policy is unmanaged", "record", record.Spec)
342362
condition = iov1.DNSZoneCondition{
343-
Message: "DNS record is currently not being managed by the operator",
344-
Reason: "UnmanagedDNS",
345-
Status: string(operatorv1.ConditionUnknown),
346-
Type: iov1.DNSRecordPublishedConditionType,
347-
LastTransitionTime: metav1.Now(),
363+
Message: "DNS record is currently not being managed by the operator",
364+
Reason: "UnmanagedDNS",
365+
Status: string(operatorv1.ConditionUnknown),
366+
Type: iov1.DNSRecordPublishedConditionType,
348367
}
349368
} else if isRecordPublished {
350369
condition, err = r.replacePublishedRecord(zones[i], record)
370+
} else if isDomainPublished, err = domainIsAlreadyPublishedToZone(context.Background(), r.cache, record, &zones[i]); err != nil {
371+
log.Error(err, "failed to validate DNS record", "record", record.Spec)
372+
condition = iov1.DNSZoneCondition{
373+
Message: "Pre-publish validation failed",
374+
Reason: "InternalError",
375+
Status: string(operatorv1.ConditionFalse),
376+
Type: iov1.DNSRecordPublishedConditionType,
377+
}
378+
} else if isDomainPublished {
379+
log.Info("DNS record not published: domain name already used by another DNS record", "record", record.Spec)
380+
condition = iov1.DNSZoneCondition{
381+
Message: "Domain name is already in use",
382+
Reason: "DomainAlreadyInUse",
383+
Status: string(operatorv1.ConditionFalse),
384+
Type: iov1.DNSRecordPublishedConditionType,
385+
}
351386
} else {
352387
condition, err = r.publishRecord(zones[i], record)
353388
}
@@ -386,6 +421,34 @@ func recordIsAlreadyPublishedToZone(record *iov1.DNSRecord, zoneToPublish *confi
386421
return false
387422
}
388423

424+
// domainIsAlreadyPublishedToZone returns true if the domain name in the provided DNSRecord is already published by
425+
// another existing dnsRecord.
426+
func domainIsAlreadyPublishedToZone(ctx context.Context, cache cache.Cache, record *iov1.DNSRecord, zone *configv1.DNSZone) (bool, error) {
427+
records := iov1.DNSRecordList{}
428+
if err := cache.List(ctx, &records, client.MatchingFields{dnsRecordIndexFieldName: record.Spec.DNSName}); err != nil {
429+
return false, err
430+
}
431+
432+
if len(records.Items) == 0 {
433+
return false, nil
434+
}
435+
436+
for _, existingRecord := range records.Items {
437+
// we only care if the domain name is published by a different record, so ignore the matching record if it
438+
// already exists.
439+
if record.UID == existingRecord.UID {
440+
continue
441+
}
442+
if record.Spec.DNSName != existingRecord.Spec.DNSName {
443+
continue
444+
}
445+
if recordIsAlreadyPublishedToZone(&existingRecord, zone) {
446+
return true, nil
447+
}
448+
}
449+
return false, nil
450+
}
451+
389452
func (r *reconciler) delete(record *iov1.DNSRecord) error {
390453
var errs []error
391454
for i := range record.Status.Zones {
@@ -576,6 +639,44 @@ func (r *reconciler) ToDNSRecords(ctx context.Context, o client.Object) []reconc
576639
return requests
577640
}
578641

642+
// mapOnRecordDelete finds another DNSRecord (if any) that uses the same domain name as the deleted record, and queues a
643+
// reconcile for that record, so that it can be published. If multiple matching records are found, a request for the
644+
// oldest record is returned.
645+
func (r *reconciler) mapOnRecordDelete(ctx context.Context, o client.Object) []reconcile.Request {
646+
deletedRecord, ok := o.(*iov1.DNSRecord)
647+
if !ok {
648+
log.Error(nil, "Got unexpected type of object", "expected", "DNSRecord", "actual", fmt.Sprintf("%T", o))
649+
return []reconcile.Request{}
650+
}
651+
otherRecords := iov1.DNSRecordList{}
652+
if err := r.cache.List(ctx, &otherRecords, client.MatchingFields{dnsRecordIndexFieldName: deletedRecord.Spec.DNSName}); err != nil {
653+
log.Error(err, "failed to list DNS records")
654+
return []reconcile.Request{}
655+
}
656+
if len(otherRecords.Items) == 0 {
657+
// Nothing to do.
658+
return []reconcile.Request{}
659+
}
660+
oldestExistingRecord := iov1.DNSRecord{}
661+
for _, existingRecord := range otherRecords.Items {
662+
// Exclude records that are marked for deletion.
663+
if !existingRecord.DeletionTimestamp.IsZero() {
664+
continue
665+
}
666+
if oldestExistingRecord.CreationTimestamp.IsZero() || existingRecord.CreationTimestamp.Before(&oldestExistingRecord.CreationTimestamp) {
667+
oldestExistingRecord = existingRecord
668+
}
669+
}
670+
return []reconcile.Request{
671+
{
672+
NamespacedName: types.NamespacedName{
673+
Name: oldestExistingRecord.Name,
674+
Namespace: oldestExistingRecord.Namespace,
675+
},
676+
},
677+
}
678+
}
679+
579680
// createDNSProvider creates a DNS manager compatible with the given cluster
580681
// configuration.
581682
func (r *reconciler) createDNSProvider(dnsConfig *configv1.DNS, platformStatus *configv1.PlatformStatus, infraStatus *configv1.InfrastructureStatus, creds *corev1.Secret, AzureWorkloadIdentityEnabled bool) (dns.Provider, error) {

pkg/operator/controller/dns/controller_test.go

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,12 @@ import (
99
operatorv1 "github.com/openshift/api/operator/v1"
1010
iov1 "github.com/openshift/api/operatoringress/v1"
1111
"github.com/openshift/cluster-ingress-operator/pkg/dns"
12+
testutil "github.com/openshift/cluster-ingress-operator/pkg/operator/controller/test/util"
1213
corev1 "k8s.io/api/core/v1"
1314
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1415
"k8s.io/apimachinery/pkg/runtime"
16+
"sigs.k8s.io/controller-runtime/pkg/cache/informertest"
17+
"sigs.k8s.io/controller-runtime/pkg/client"
1518
"sigs.k8s.io/controller-runtime/pkg/client/fake"
1619
)
1720

@@ -117,6 +120,7 @@ func Test_publishRecordToZones(t *testing.T) {
117120
r := &reconciler{
118121
// TODO To write a fake provider that can return errors and add more test cases.
119122
dnsProvider: &dns.FakeProvider{},
123+
cache: buildFakeCache(t),
120124
}
121125

122126
_, actual := r.publishRecordToZones(test.zones, record)
@@ -128,9 +132,9 @@ func Test_publishRecordToZones(t *testing.T) {
128132
}
129133
}
130134

131-
// TestPublishRecordToZonesMergesStatus verifies that publishRecordToZones
135+
// Test_publishRecordToZonesMergesStatus verifies that publishRecordToZones
132136
// correctly merges status updates.
133-
func TestPublishRecordToZonesMergesStatus(t *testing.T) {
137+
func Test_publishRecordToZonesMergesStatus(t *testing.T) {
134138
testCases := []struct {
135139
description string
136140
oldZoneStatuses []iov1.DNSZoneStatus
@@ -215,7 +219,10 @@ func TestPublishRecordToZonesMergesStatus(t *testing.T) {
215219
},
216220
Status: iov1.DNSRecordStatus{Zones: tc.oldZoneStatuses},
217221
}
218-
r := &reconciler{dnsProvider: &dns.FakeProvider{}}
222+
r := &reconciler{
223+
dnsProvider: &dns.FakeProvider{},
224+
cache: buildFakeCache(t),
225+
}
219226
zone := []configv1.DNSZone{{ID: "zone2"}}
220227
oldStatuses := record.Status.DeepCopy().Zones
221228
_, newStatuses := r.publishRecordToZones(zone, record)
@@ -840,3 +847,28 @@ func Test_customCABundle(t *testing.T) {
840847
})
841848
}
842849
}
850+
851+
// buildFakeCache returns a fake cache, with the necessary schema and index function(s) to mimic the parts of the cache
852+
// that the dns controller interacts with.
853+
func buildFakeCache(t *testing.T) *testutil.FakeCache {
854+
t.Helper()
855+
scheme := runtime.NewScheme()
856+
iov1.AddToScheme(scheme)
857+
fakeClient := fake.NewClientBuilder().
858+
WithScheme(scheme).
859+
WithIndex(&iov1.DNSRecord{}, dnsRecordIndexFieldName, func(o client.Object) []string {
860+
dnsRecord := o.(*iov1.DNSRecord)
861+
return []string{dnsRecord.Spec.DNSName}
862+
}).
863+
Build()
864+
cl := &testutil.FakeClientRecorder{
865+
Client: fakeClient,
866+
T: t,
867+
Added: []client.Object{},
868+
Updated: []client.Object{},
869+
Deleted: []client.Object{},
870+
}
871+
informer := informertest.FakeInformers{Scheme: scheme}
872+
cache := testutil.FakeCache{Informers: &informer, Reader: cl}
873+
return &cache
874+
}

0 commit comments

Comments
 (0)