diff --git a/api/v1alpha1/dnsrecord_types.go b/api/v1alpha1/dnsrecord_types.go index 2cdf2b5e..a113424c 100644 --- a/api/v1alpha1/dnsrecord_types.go +++ b/api/v1alpha1/dnsrecord_types.go @@ -141,6 +141,9 @@ type DNSRecordStatus struct { // endpoints are the last endpoints that were successfully published to the provider zone Endpoints []*externaldns.Endpoint `json:"endpoints,omitempty"` + // ZoneEndpoints are all the endpoints for the DNSRecordSpec.RootHost that are present in the provider + ZoneEndpoints []*externaldns.Endpoint `json:"relatedEndpoints,omitempty"` + HealthCheck *HealthCheckStatus `json:"healthCheck,omitempty"` // ownerID is a unique string used to identify the owner of this record. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 6a574d4f..5d58febc 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -188,6 +188,17 @@ func (in *DNSRecordStatus) DeepCopyInto(out *DNSRecordStatus) { } } } + if in.ZoneEndpoints != nil { + in, out := &in.ZoneEndpoints, &out.ZoneEndpoints + *out = make([]*endpoint.Endpoint, len(*in)) + for i := range *in { + if (*in)[i] != nil { + in, out := &(*in)[i], &(*out)[i] + *out = new(endpoint.Endpoint) + (*in).DeepCopyInto(*out) + } + } + } if in.HealthCheck != nil { in, out := &in.HealthCheck, &out.HealthCheck *out = new(HealthCheckStatus) diff --git a/bundle/manifests/kuadrant.io_dnsrecords.yaml b/bundle/manifests/kuadrant.io_dnsrecords.yaml index e1c7ab96..0b08ffaf 100644 --- a/bundle/manifests/kuadrant.io_dnsrecords.yaml +++ b/bundle/manifests/kuadrant.io_dnsrecords.yaml @@ -493,6 +493,53 @@ spec: reconciliation format: date-time type: string + relatedEndpoints: + description: ZoneEndpoints are all the endpoints for the DNSRecordSpec.RootHost + that are present in the provider + items: + description: Endpoint is a high-level way of a connection between + a service and an IP + properties: + dnsName: + description: The hostname of the DNS record + type: string + labels: + additionalProperties: + type: string + description: Labels stores labels defined for the Endpoint + type: object + providerSpecific: + description: ProviderSpecific stores provider specific config + items: + description: ProviderSpecificProperty holds the name and value + of a configuration which is specific to individual DNS providers + properties: + name: + type: string + value: + type: string + type: object + type: array + recordTTL: + description: TTL for the record + format: int64 + type: integer + recordType: + description: RecordType type of record, e.g. CNAME, A, AAAA, + SRV, TXT etc + type: string + setIdentifier: + description: Identifier to distinguish multiple records with + the same name and type (e.g. Route53 records with routing + policies other than 'simple') + type: string + targets: + description: The targets the DNS record points to + items: + type: string + type: array + type: object + type: array validFor: description: ValidFor indicates duration since the last reconciliation we consider data in the record to be valid diff --git a/charts/dns-operator/templates/manifests.yaml b/charts/dns-operator/templates/manifests.yaml index a5d720e8..d50c8a3a 100644 --- a/charts/dns-operator/templates/manifests.yaml +++ b/charts/dns-operator/templates/manifests.yaml @@ -505,6 +505,53 @@ spec: reconciliation format: date-time type: string + relatedEndpoints: + description: ZoneEndpoints are all the endpoints for the DNSRecordSpec.RootHost + that are present in the provider + items: + description: Endpoint is a high-level way of a connection between + a service and an IP + properties: + dnsName: + description: The hostname of the DNS record + type: string + labels: + additionalProperties: + type: string + description: Labels stores labels defined for the Endpoint + type: object + providerSpecific: + description: ProviderSpecific stores provider specific config + items: + description: ProviderSpecificProperty holds the name and value + of a configuration which is specific to individual DNS providers + properties: + name: + type: string + value: + type: string + type: object + type: array + recordTTL: + description: TTL for the record + format: int64 + type: integer + recordType: + description: RecordType type of record, e.g. CNAME, A, AAAA, + SRV, TXT etc + type: string + setIdentifier: + description: Identifier to distinguish multiple records with + the same name and type (e.g. Route53 records with routing + policies other than 'simple') + type: string + targets: + description: The targets the DNS record points to + items: + type: string + type: array + type: object + type: array validFor: description: ValidFor indicates duration since the last reconciliation we consider data in the record to be valid diff --git a/config/crd/bases/kuadrant.io_dnsrecords.yaml b/config/crd/bases/kuadrant.io_dnsrecords.yaml index 26c131be..7a470154 100644 --- a/config/crd/bases/kuadrant.io_dnsrecords.yaml +++ b/config/crd/bases/kuadrant.io_dnsrecords.yaml @@ -493,6 +493,53 @@ spec: reconciliation format: date-time type: string + relatedEndpoints: + description: ZoneEndpoints are all the endpoints for the DNSRecordSpec.RootHost + that are present in the provider + items: + description: Endpoint is a high-level way of a connection between + a service and an IP + properties: + dnsName: + description: The hostname of the DNS record + type: string + labels: + additionalProperties: + type: string + description: Labels stores labels defined for the Endpoint + type: object + providerSpecific: + description: ProviderSpecific stores provider specific config + items: + description: ProviderSpecificProperty holds the name and value + of a configuration which is specific to individual DNS providers + properties: + name: + type: string + value: + type: string + type: object + type: array + recordTTL: + description: TTL for the record + format: int64 + type: integer + recordType: + description: RecordType type of record, e.g. CNAME, A, AAAA, + SRV, TXT etc + type: string + setIdentifier: + description: Identifier to distinguish multiple records with + the same name and type (e.g. Route53 records with routing + policies other than 'simple') + type: string + targets: + description: The targets the DNS record points to + items: + type: string + type: array + type: object + type: array validFor: description: ValidFor indicates duration since the last reconciliation we consider data in the record to be valid diff --git a/internal/controller/dnsrecord_controller.go b/internal/controller/dnsrecord_controller.go index 3d4aef2a..9d2b2255 100644 --- a/internal/controller/dnsrecord_controller.go +++ b/internal/controller/dnsrecord_controller.go @@ -504,6 +504,11 @@ func (r *DNSRecordReconciler) applyChanges(ctx context.Context, dnsRecord *v1alp return false, fmt.Errorf("adjusting statusEndpoints: %w", err) } + // add related endpoints to the record + dnsRecord.Status.ZoneEndpoints = mergeZoneEndpoints( + dnsRecord.Status.ZoneEndpoints, + filterEndpoints(rootDomainName, zoneEndpoints)) + //Note: All endpoint lists should be in the same provider specific format at this point logger.V(1).Info("applyChanges", "zoneEndpoints", zoneEndpoints, "specEndpoints", specEndpoints, "statusEndpoints", statusEndpoints) @@ -525,3 +530,46 @@ func (r *DNSRecordReconciler) applyChanges(ctx context.Context, dnsRecord *v1alp } return false, nil } + +// filterEndpoints takes a list of zoneEndpoints and removes from it all endpoints +// that do not belong to the rootDomainName (some.example.com does belong to the example.com domain). +// it is not using ownerID of this record as well as domainOwners from the status for filtering +func filterEndpoints(rootDomainName string, zoneEndpoints []*externaldnsendpoint.Endpoint) []*externaldnsendpoint.Endpoint { + // these are records that share domain but are not defined in the spec of DNSRecord + var filteredEndpoints []*externaldnsendpoint.Endpoint + + // setup domain filter since we can't be sure that zone records are sharing domain with DNSRecord + rootDomain, _ := strings.CutPrefix(rootDomainName, v1alpha1.WildcardPrefix) + rootDomainFilter := externaldnsendpoint.NewDomainFilter([]string{rootDomain}) + + // go through all EPs in the zone + for _, zoneEndpoint := range zoneEndpoints { + // if zoneEndpoint matches domain filter, it must be added to related EPs + if rootDomainFilter.Match(zoneEndpoint.DNSName) { + filteredEndpoints = append(filteredEndpoints, zoneEndpoint) + } + } + return filteredEndpoints +} + +// mergeZoneEndpoints merges existing endpoints with new and ensures there are no duplicates +func mergeZoneEndpoints(currentEndpoints, newEndpoints []*externaldnsendpoint.Endpoint) []*externaldnsendpoint.Endpoint { + // map to use as filter + combinedMap := make(map[string]*externaldnsendpoint.Endpoint) + // return struct + var combinedEndpoints []*externaldnsendpoint.Endpoint + + // Use DNSName of EP as unique key. Ensures no duplicates + for _, endpoint := range currentEndpoints { + combinedMap[endpoint.DNSName] = endpoint + } + for _, endpoint := range newEndpoints { + combinedMap[endpoint.DNSName] = endpoint + } + + // Convert a map into an array + for _, endpoint := range combinedMap { + combinedEndpoints = append(combinedEndpoints, endpoint) + } + return combinedEndpoints +} diff --git a/internal/controller/dnsrecord_controller_test.go b/internal/controller/dnsrecord_controller_test.go index c40dba43..6fe780e8 100644 --- a/internal/controller/dnsrecord_controller_test.go +++ b/internal/controller/dnsrecord_controller_test.go @@ -363,6 +363,118 @@ var _ = Describe("DNSRecordReconciler", func() { }, TestTimeoutMedium, time.Second).Should(Succeed()) }) + It("should report related endpoints correctly", func() { + // This will come in play only for the lb strategy + // in this test I simulate 3 possible scenarios using hand-made simple endpoints + // scenarios: + // 1. Record A in a subdomain of record B. Record B should have endpoints of record A and record B + // 2. Record A and record B share domain. Endpoints should be in Spec.ZoneEndpoints as they will be in the Spec.Endpoints + // 3. Record A and record B does not share domain in the zone. They should not have each other's endpoints + + // record for testHostname + dnsRecord1 := &v1alpha1.DNSRecord{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo-record-1", + Namespace: testNamespace, + }, + Spec: v1alpha1.DNSRecordSpec{ + RootHost: testHostname, + ProviderRef: v1alpha1.ProviderRef{ + Name: dnsProviderSecret.Name, + }, + Endpoints: getTestEndpoints(testHostname, "127.0.0.1"), + }, + } + + // record for sub.testHostname + dnsRecord2 := &v1alpha1.DNSRecord{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo-record-2", + Namespace: testNamespace, + }, + Spec: v1alpha1.DNSRecordSpec{ + RootHost: "sub." + testHostname, + ProviderRef: v1alpha1.ProviderRef{ + Name: dnsProviderSecret.Name, + }, + Endpoints: getTestEndpoints("sub."+testHostname, "127.0.0.2"), + }, + } + + // record for testHostname + dnsRecord3 := &v1alpha1.DNSRecord{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo-record-3", + Namespace: testNamespace, + }, + Spec: v1alpha1.DNSRecordSpec{ + RootHost: testHostname, + ProviderRef: v1alpha1.ProviderRef{ + Name: dnsProviderSecret.Name, + }, + Endpoints: getTestEndpoints(testHostname, "127.0.0.1"), + }, + } + + // record for testHostname2 + testHostname2 := strings.Join([]string{"bar", testZoneDomainName}, ".") + dnsRecord4 := &v1alpha1.DNSRecord{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo-record-4", + Namespace: testNamespace, + }, + Spec: v1alpha1.DNSRecordSpec{ + RootHost: testHostname2, + ProviderRef: v1alpha1.ProviderRef{ + Name: dnsProviderSecret.Name, + }, + Endpoints: getTestEndpoints(testHostname2, "127.0.0.1"), + }, + } + + // create all records + Expect(k8sClient.Create(ctx, dnsRecord1)).To(Succeed()) + Expect(k8sClient.Create(ctx, dnsRecord2)).To(Succeed()) + Expect(k8sClient.Create(ctx, dnsRecord3)).To(Succeed()) + Expect(k8sClient.Create(ctx, dnsRecord4)).To(Succeed()) + + // check first record to have EP from second record and not have EPs from third + Eventually(func(g Gomega) { + g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(dnsRecord1), dnsRecord1)).To(Succeed()) + g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(dnsRecord2), dnsRecord2)).To(Succeed()) + g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(dnsRecord3), dnsRecord3)).To(Succeed()) + g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(dnsRecord4), dnsRecord4)).To(Succeed()) + + g.Expect(dnsRecord1.Status.ZoneEndpoints).ToNot(BeNil()) + + // Scenario 1 + // endpoints from the record2 should be present in zone EPs as record2 in subdomain of record 1 rootDomain + // record must have it's own endpoints (that are identical to the record3 endpoints) + g.Expect(dnsRecord1.Status.ZoneEndpoints).To(And( + ContainElements(dnsRecord2.Status.Endpoints), + ContainElements(dnsRecord1.Status.Endpoints))) + // record1 and 3 share root domain - all of the above should also apply to this record + g.Expect(dnsRecord3.Status.ZoneEndpoints).To(And( + ContainElements(dnsRecord2.Status.Endpoints), + ContainElements(dnsRecord3.Status.Endpoints))) + + // Scenario 2 + // endpoints from the third record should be present in ZoneEndpoints as it is in the same rootDomain + g.Expect(dnsRecord1.Status.ZoneEndpoints).To(ContainElements(dnsRecord3.Status.Endpoints)) + // the same true to record 3 as well + g.Expect(dnsRecord3.Status.ZoneEndpoints).To(ContainElements(dnsRecord1.Status.Endpoints)) + // also check equality of status.Endpoints + g.Expect(dnsRecord1.Status.Endpoints).To(ConsistOf(dnsRecord3.Status.Endpoints)) + + // Scenario 3 + // endpoints from the forth record should not be present as record 4 have unique rootHosts + g.Expect(dnsRecord1.Status.ZoneEndpoints).ToNot(ContainElements(dnsRecord4.Status.Endpoints)) + g.Expect(dnsRecord2.Status.ZoneEndpoints).ToNot(ContainElements(dnsRecord4.Status.Endpoints)) + g.Expect(dnsRecord3.Status.ZoneEndpoints).ToNot(ContainElements(dnsRecord4.Status.Endpoints)) + + }, TestTimeoutMedium, time.Second).Should(Succeed()) + }) + It("should detect a conflict and the resolution of a conflict", func() { dnsRecord = &v1alpha1.DNSRecord{ ObjectMeta: metav1.ObjectMeta{