Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 107 additions & 6 deletions pkg/operator/controller/dns/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ const (
kubeCloudConfigName = "kube-cloud-config"
// cloudCABundleKey is the key in the kube cloud config ConfigMap where the custom CA bundle is located
cloudCABundleKey = "ca-bundle.pem"
// dnsRecordIndexFieldName is the key for the DNSRecord index, used to identify conflicting domain names.
dnsRecordIndexFieldName = "Spec.DNSName"
)

var log = logf.Logger.WithName(controllerName)
Expand All @@ -81,6 +83,17 @@ func New(mgr manager.Manager, config Config) (runtimecontroller.Controller, erro
if err := c.Watch(source.Kind[client.Object](operatorCache, &iov1.DNSRecord{}, &handler.EnqueueRequestForObject{}, predicate.GenerationChangedPredicate{})); err != nil {
return nil, err
}
// When a DNS record is deleted, there may be a conflicting record that should be published. Add a second watch
// exclusively for deletes so that in addition to the normal on-delete cleanup, queue a reconcile for the next
// record with the same domain name (if one exists) so that it can be published.
if err := c.Watch(source.Kind[client.Object](operatorCache, &iov1.DNSRecord{}, handler.EnqueueRequestsFromMapFunc(reconciler.mapOnRecordDelete), predicate.Funcs{
CreateFunc: func(e event.CreateEvent) bool { return false },
DeleteFunc: func(e event.DeleteEvent) bool { return true },
UpdateFunc: func(e event.UpdateEvent) bool { return false },
GenericFunc: func(e event.GenericEvent) bool { return false },
})); err != nil {
return nil, err
}
if err := c.Watch(source.Kind[client.Object](operatorCache, &configv1.DNS{}, handler.EnqueueRequestsFromMapFunc(reconciler.ToDNSRecords))); err != nil {
return nil, err
}
Expand All @@ -102,6 +115,12 @@ func New(mgr manager.Manager, config Config) (runtimecontroller.Controller, erro
})); err != nil {
return nil, err
}
if err := operatorCache.IndexField(context.Background(), &iov1.DNSRecord{}, dnsRecordIndexFieldName, func(o client.Object) []string {
dnsRecord := o.(*iov1.DNSRecord)
return []string{dnsRecord.Spec.DNSName}
}); err != nil {
return nil, err
}
return c, nil
}

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

var err error
var condition iov1.DNSZoneCondition
var isDomainPublished bool
if dnsPolicy == iov1.UnmanagedDNS {
log.Info("DNS record not published", "record", record.Spec)
log.Info("DNS record not published: DNS management policy is unmanaged", "record", record.Spec)
condition = iov1.DNSZoneCondition{
Message: "DNS record is currently not being managed by the operator",
Reason: "UnmanagedDNS",
Status: string(operatorv1.ConditionUnknown),
Type: iov1.DNSRecordPublishedConditionType,
LastTransitionTime: metav1.Now(),
Message: "DNS record is currently not being managed by the operator",
Reason: "UnmanagedDNS",
Status: string(operatorv1.ConditionUnknown),
Type: iov1.DNSRecordPublishedConditionType,
}
} else if isRecordPublished {
condition, err = r.replacePublishedRecord(zones[i], record)
} else if isDomainPublished, err = domainIsAlreadyPublishedToZone(context.Background(), r.cache, record, &zones[i]); err != nil {
log.Error(err, "failed to validate DNS record", "record", record.Spec)
condition = iov1.DNSZoneCondition{
Message: "Pre-publish validation failed",
Reason: "InternalError",
Status: string(operatorv1.ConditionFalse),
Type: iov1.DNSRecordPublishedConditionType,
}
} else if isDomainPublished {
log.Info("DNS record not published: domain name already used by another DNS record", "record", record.Spec)
condition = iov1.DNSZoneCondition{
Message: "Domain name is already in use",
Reason: "DomainAlreadyInUse",
Status: string(operatorv1.ConditionFalse),
Type: iov1.DNSRecordPublishedConditionType,
}
} else {
condition, err = r.publishRecord(zones[i], record)
}
Expand Down Expand Up @@ -386,6 +421,34 @@ func recordIsAlreadyPublishedToZone(record *iov1.DNSRecord, zoneToPublish *confi
return false
}

// domainIsAlreadyPublishedToZone returns true if the domain name in the provided DNSRecord is already published by
// another existing dnsRecord.
func domainIsAlreadyPublishedToZone(ctx context.Context, cache cache.Cache, record *iov1.DNSRecord, zone *configv1.DNSZone) (bool, error) {
records := iov1.DNSRecordList{}
if err := cache.List(ctx, &records, client.MatchingFields{dnsRecordIndexFieldName: record.Spec.DNSName}); err != nil {
return false, err
}

if len(records.Items) == 0 {
return false, nil
}

for _, existingRecord := range records.Items {
// we only care if the domain name is published by a different record, so ignore the matching record if it
// already exists.
if record.UID == existingRecord.UID {
continue
}
if record.Spec.DNSName != existingRecord.Spec.DNSName {
continue
}
if recordIsAlreadyPublishedToZone(&existingRecord, zone) {
return true, nil
}
}
return false, nil
}

func (r *reconciler) delete(record *iov1.DNSRecord) error {
var errs []error
for i := range record.Status.Zones {
Expand Down Expand Up @@ -576,6 +639,44 @@ func (r *reconciler) ToDNSRecords(ctx context.Context, o client.Object) []reconc
return requests
}

// mapOnRecordDelete finds another DNSRecord (if any) that uses the same domain name as the deleted record, and queues a
// reconcile for that record, so that it can be published. If multiple matching records are found, a request for the
// oldest record is returned.
func (r *reconciler) mapOnRecordDelete(ctx context.Context, o client.Object) []reconcile.Request {
deletedRecord, ok := o.(*iov1.DNSRecord)
if !ok {
log.Error(nil, "Got unexpected type of object", "expected", "DNSRecord", "actual", fmt.Sprintf("%T", o))
return []reconcile.Request{}
}
otherRecords := iov1.DNSRecordList{}
if err := r.cache.List(ctx, &otherRecords, client.MatchingFields{dnsRecordIndexFieldName: deletedRecord.Spec.DNSName}); err != nil {
log.Error(err, "failed to list DNS records")
return []reconcile.Request{}
}
if len(otherRecords.Items) == 0 {
// Nothing to do.
return []reconcile.Request{}
}
oldestExistingRecord := iov1.DNSRecord{}
for _, existingRecord := range otherRecords.Items {
// Exclude records that are marked for deletion.
if !existingRecord.DeletionTimestamp.IsZero() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just for safety (sorry for realizing this just now): DeletionTimestamp is a nullable field / a pointer (https://github.com/kubernetes/apimachinery/blob/d74026bbe3beeff64c3dc7259a29be7708aa834f/pkg/apis/meta/v1/types.go#L209) and as so, I would recommend checking if it is null, and then checking if it is zero.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The IsZero method has an nil check on its receiver, so I think the caller can omit the nil check?

// IsZero returns true if the value is nil or time is zero.
func (t *Time) IsZero() bool {
if t == nil {
return true

I would be happy with a unit test case in lieu of a nil check.

continue
}
if oldestExistingRecord.CreationTimestamp.IsZero() || existingRecord.CreationTimestamp.Before(&oldestExistingRecord.CreationTimestamp) {
oldestExistingRecord = existingRecord
}
}
return []reconcile.Request{
{
NamespacedName: types.NamespacedName{
Name: oldestExistingRecord.Name,
Namespace: oldestExistingRecord.Namespace,
},
},
}
}

// createDNSProvider creates a DNS manager compatible with the given cluster
// configuration.
func (r *reconciler) createDNSProvider(dnsConfig *configv1.DNS, platformStatus *configv1.PlatformStatus, infraStatus *configv1.InfrastructureStatus, creds *corev1.Secret, AzureWorkloadIdentityEnabled bool) (dns.Provider, error) {
Expand Down
38 changes: 35 additions & 3 deletions pkg/operator/controller/dns/controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,12 @@ import (
operatorv1 "github.com/openshift/api/operator/v1"
iov1 "github.com/openshift/api/operatoringress/v1"
"github.com/openshift/cluster-ingress-operator/pkg/dns"
testutil "github.com/openshift/cluster-ingress-operator/pkg/operator/controller/test/util"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/cache/informertest"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
)

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

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

// TestPublishRecordToZonesMergesStatus verifies that publishRecordToZones
// Test_publishRecordToZonesMergesStatus verifies that publishRecordToZones
// correctly merges status updates.
func TestPublishRecordToZonesMergesStatus(t *testing.T) {
func Test_publishRecordToZonesMergesStatus(t *testing.T) {
Copy link
Contributor

@Miciah Miciah May 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TestPublishRecordToZonesMergesStatus is an appropriate name for the test as there is no publishRecordToZonesMergesStatus function.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably I am also missing something here, but is there a new test case that will check if the condition is set?

testCases := []struct {
description string
oldZoneStatuses []iov1.DNSZoneStatus
Expand Down Expand Up @@ -215,7 +219,10 @@ func TestPublishRecordToZonesMergesStatus(t *testing.T) {
},
Status: iov1.DNSRecordStatus{Zones: tc.oldZoneStatuses},
}
r := &reconciler{dnsProvider: &dns.FakeProvider{}}
r := &reconciler{
dnsProvider: &dns.FakeProvider{},
cache: buildFakeCache(t),
}
zone := []configv1.DNSZone{{ID: "zone2"}}
oldStatuses := record.Status.DeepCopy().Zones
_, newStatuses := r.publishRecordToZones(zone, record)
Expand Down Expand Up @@ -840,3 +847,28 @@ func Test_customCABundle(t *testing.T) {
})
}
}

// buildFakeCache returns a fake cache, with the necessary schema and index function(s) to mimic the parts of the cache
// that the dns controller interacts with.
func buildFakeCache(t *testing.T) *testutil.FakeCache {
t.Helper()
scheme := runtime.NewScheme()
iov1.AddToScheme(scheme)
fakeClient := fake.NewClientBuilder().
WithScheme(scheme).
WithIndex(&iov1.DNSRecord{}, dnsRecordIndexFieldName, func(o client.Object) []string {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit (no need to fix now!): do we care about making this indexer function some sort of utils/specific function that can be used both on the operatorCache.IndexField and on fakeCache to keep consistency?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mean a util function that the controller logic and test logic would share? That does make sense, though I do caution against re-using controller logic in tests if doing so could mask a defect in the controller logic.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, the idea was to make some util/shared function, but also I see your concerns here, so makes sense also to not share and in case something changes on the main reconciliation logic, the test that has a different cache logic will catch the regression.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The intent here is to use the same index function that was added in lines 117-122. Since the fake cache doesn't go through all the setup steps that the actual one does, it needed to be added manually. In this case, having the logic match what's used in the actual controller probably is the way to go.

dnsRecord := o.(*iov1.DNSRecord)
return []string{dnsRecord.Spec.DNSName}
}).
Build()
cl := &testutil.FakeClientRecorder{
Client: fakeClient,
T: t,
Added: []client.Object{},
Updated: []client.Object{},
Deleted: []client.Object{},
}
informer := informertest.FakeInformers{Scheme: scheme}
cache := testutil.FakeCache{Informers: &informer, Reader: cl}
return &cache
}