diff --git a/source/contour_httpproxy.go b/source/contour_httpproxy.go index d40580de64..7697204932 100644 --- a/source/contour_httpproxy.go +++ b/source/contour_httpproxy.go @@ -157,17 +157,14 @@ func (sc *httpProxySource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, e } // apply template if fqdn is missing on HTTPProxy - if (sc.combineFQDNAnnotation || len(hpEndpoints) == 0) && sc.fqdnTemplate != nil { - tmplEndpoints, err := sc.endpointsFromTemplate(hp) - if err != nil { - return nil, fmt.Errorf("failed to get endpoints from template: %w", err) - } - - if sc.combineFQDNAnnotation { - hpEndpoints = append(hpEndpoints, tmplEndpoints...) - } else { - hpEndpoints = tmplEndpoints - } + hpEndpoints, err = fqdn.CombineWithTemplatedEndpoints( + hpEndpoints, + sc.fqdnTemplate, + sc.combineFQDNAnnotation, + func() ([]*endpoint.Endpoint, error) { return sc.endpointsFromTemplate(hp) }, + ) + if err != nil { + return nil, err } if len(hpEndpoints) == 0 { diff --git a/source/fqdn/fqdn.go b/source/fqdn/fqdn.go index 0bf422f2da..243b8bbbef 100644 --- a/source/fqdn/fqdn.go +++ b/source/fqdn/fqdn.go @@ -25,6 +25,8 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + + "sigs.k8s.io/external-dns/endpoint" ) func ParseTemplate(input string) (*template.Template, error) { @@ -94,3 +96,36 @@ func isIPv4String(target string) bool { } return netIP.Is4() } + +// CombineWithTemplatedEndpoints merges annotation-based endpoints with template-based endpoints +// according to the FQDN template configuration. +// +// Logic: +// - If fqdnTemplate is nil, returns original endpoints unchanged +// - If combineFQDNAnnotation is true, appends templated endpoints to existing +// - If combineFQDNAnnotation is false and endpoints is empty, uses templated endpoints +// - If combineFQDNAnnotation is false and endpoints exist, returns original unchanged +func CombineWithTemplatedEndpoints( + endpoints []*endpoint.Endpoint, + fqdnTemplate *template.Template, + combineFQDNAnnotation bool, + templateFunc func() ([]*endpoint.Endpoint, error), +) ([]*endpoint.Endpoint, error) { + if fqdnTemplate == nil { + return endpoints, nil + } + + if !combineFQDNAnnotation && len(endpoints) > 0 { + return endpoints, nil + } + + templatedEndpoints, err := templateFunc() + if err != nil { + return nil, fmt.Errorf("failed to get endpoints from template: %w", err) + } + + if combineFQDNAnnotation { + return append(endpoints, templatedEndpoints...), nil + } + return templatedEndpoints, nil +} diff --git a/source/fqdn/fqdn_test.go b/source/fqdn/fqdn_test.go index 103e17f011..ba5bd1cfb7 100644 --- a/source/fqdn/fqdn_test.go +++ b/source/fqdn/fqdn_test.go @@ -17,12 +17,16 @@ limitations under the License. package fqdn import ( + "errors" "testing" + "text/template" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + + "sigs.k8s.io/external-dns/endpoint" ) func TestParseTemplate(t *testing.T) { @@ -431,3 +435,103 @@ func TestExecTemplateExecutionError(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "failed to apply template on TestKind default/test-name") } + +func TestCombineWithTemplatedEndpoints(t *testing.T) { + // Create a dummy template for tests that need one + dummyTemplate := template.Must(template.New("test").Parse("{{.Name}}")) + + annotationEndpoints := []*endpoint.Endpoint{ + endpoint.NewEndpoint("annotation.example.com", endpoint.RecordTypeA, "1.2.3.4"), + } + templatedEndpoints := []*endpoint.Endpoint{ + endpoint.NewEndpoint("template.example.com", endpoint.RecordTypeA, "5.6.7.8"), + } + + successTemplateFunc := func() ([]*endpoint.Endpoint, error) { + return templatedEndpoints, nil + } + errorTemplateFunc := func() ([]*endpoint.Endpoint, error) { + return nil, errors.New("template error") + } + + tests := []struct { + name string + endpoints []*endpoint.Endpoint + fqdnTemplate *template.Template + combineFQDNAnnotation bool + templateFunc func() ([]*endpoint.Endpoint, error) + want []*endpoint.Endpoint + wantErr bool + }{ + { + name: "nil template returns original endpoints", + endpoints: annotationEndpoints, + fqdnTemplate: nil, + templateFunc: successTemplateFunc, + want: annotationEndpoints, + }, + { + name: "combine=false with existing endpoints returns original", + endpoints: annotationEndpoints, + fqdnTemplate: dummyTemplate, + templateFunc: successTemplateFunc, + want: annotationEndpoints, + }, + { + name: "combine=false with empty endpoints returns templated", + endpoints: []*endpoint.Endpoint{}, + fqdnTemplate: dummyTemplate, + templateFunc: successTemplateFunc, + want: templatedEndpoints, + }, + { + name: "combine=true appends templated to existing", + endpoints: annotationEndpoints, + fqdnTemplate: dummyTemplate, + combineFQDNAnnotation: true, + templateFunc: successTemplateFunc, + want: append(annotationEndpoints, templatedEndpoints...), + }, + { + name: "combine=true with empty endpoints returns templated", + endpoints: []*endpoint.Endpoint{}, + fqdnTemplate: dummyTemplate, + combineFQDNAnnotation: true, + templateFunc: successTemplateFunc, + want: templatedEndpoints, + }, + { + name: "template error is propagated", + endpoints: []*endpoint.Endpoint{}, + fqdnTemplate: dummyTemplate, + templateFunc: errorTemplateFunc, + want: nil, + wantErr: true, + }, + { + name: "nil endpoints with combine=false returns templated", + endpoints: nil, + fqdnTemplate: dummyTemplate, + templateFunc: successTemplateFunc, + want: templatedEndpoints, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := CombineWithTemplatedEndpoints( + tt.endpoints, + tt.fqdnTemplate, + tt.combineFQDNAnnotation, + tt.templateFunc, + ) + if tt.wantErr { + require.Error(t, err) + require.ErrorContains(t, err, "failed to get endpoints from template") + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/source/ingress.go b/source/ingress.go index 8a2d133fc8..fd9a513c67 100644 --- a/source/ingress.go +++ b/source/ingress.go @@ -162,13 +162,14 @@ func (sc *ingressSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, err ingEndpoints := endpointsFromIngress(ing, sc.ignoreHostnameAnnotation, sc.ignoreIngressTLSSpec, sc.ignoreIngressRulesSpec) // apply template if host is missing on ingress - if (sc.combineFQDNAnnotation || len(ingEndpoints) == 0) && sc.fqdnTemplate != nil { - iEndpoints, err := sc.endpointsFromTemplate(ing) - if err != nil { - return nil, err - } - - ingEndpoints = append(ingEndpoints, iEndpoints...) + ingEndpoints, err = fqdn.CombineWithTemplatedEndpoints( + ingEndpoints, + sc.fqdnTemplate, + sc.combineFQDNAnnotation, + func() ([]*endpoint.Endpoint, error) { return sc.endpointsFromTemplate(ing) }, + ) + if err != nil { + return nil, err } if len(ingEndpoints) == 0 { diff --git a/source/istio_gateway.go b/source/istio_gateway.go index 4a6723fbd8..5f5a35cf93 100644 --- a/source/istio_gateway.go +++ b/source/istio_gateway.go @@ -166,28 +166,26 @@ func (sc *gatewaySource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, e return nil, err } - // apply template if host is missing on gateway - if (sc.combineFQDNAnnotation || len(gwHostnames) == 0) && sc.fqdnTemplate != nil { - iHostnames, err := fqdn.ExecTemplate(sc.fqdnTemplate, gateway) - if err != nil { - return nil, err - } - - if sc.combineFQDNAnnotation { - gwHostnames = append(gwHostnames, iHostnames...) - } else { - gwHostnames = iHostnames - } - } - log.Debugf("Processing gateway '%s/%s.%s' and hosts %q", gateway.Namespace, gateway.APIVersion, gateway.Name, strings.Join(gwHostnames, ",")) - if len(gwHostnames) == 0 { - log.Debugf("No hostnames could be generated from gateway %s/%s", gateway.Namespace, gateway.Name) - continue + gwEndpoints, err := sc.endpointsFromGateway(ctx, gwHostnames, gateway) + if err != nil { + return nil, err } - gwEndpoints, err := sc.endpointsFromGateway(ctx, gwHostnames, gateway) + // apply template if host is missing on gateway + gwEndpoints, err = fqdn.CombineWithTemplatedEndpoints( + gwEndpoints, + sc.fqdnTemplate, + sc.combineFQDNAnnotation, + func() ([]*endpoint.Endpoint, error) { + hostnames, err := fqdn.ExecTemplate(sc.fqdnTemplate, gateway) + if err != nil { + return nil, err + } + return sc.endpointsFromGateway(ctx, hostnames, gateway) + }, + ) if err != nil { return nil, err } diff --git a/source/istio_virtualservice.go b/source/istio_virtualservice.go index 6f42e15fad..372a8e09de 100644 --- a/source/istio_virtualservice.go +++ b/source/istio_virtualservice.go @@ -171,17 +171,14 @@ func (sc *virtualServiceSource) Endpoints(ctx context.Context) ([]*endpoint.Endp } // apply template if host is missing on VirtualService - if (sc.combineFQDNAnnotation || len(gwEndpoints) == 0) && sc.fqdnTemplate != nil { - iEndpoints, err := sc.endpointsFromTemplate(ctx, vService) - if err != nil { - return nil, err - } - - if sc.combineFQDNAnnotation { - gwEndpoints = append(gwEndpoints, iEndpoints...) - } else { - gwEndpoints = iEndpoints - } + gwEndpoints, err = fqdn.CombineWithTemplatedEndpoints( + gwEndpoints, + sc.fqdnTemplate, + sc.combineFQDNAnnotation, + func() ([]*endpoint.Endpoint, error) { return sc.endpointsFromTemplate(ctx, vService) }, + ) + if err != nil { + return nil, err } if len(gwEndpoints) == 0 { diff --git a/source/node.go b/source/node.go index 1df49982d8..342b306471 100644 --- a/source/node.go +++ b/source/node.go @@ -108,7 +108,7 @@ func (ns *nodeSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) return nil, err } - endpoints := map[endpoint.EndpointKey]*endpoint.Endpoint{} + endpoints := make([]*endpoint.Endpoint, 0) // create endpoints for all nodes for _, node := range nodes { @@ -126,56 +126,82 @@ func (ns *nodeSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) log.Debugf("creating endpoint for node %s", node.Name) - ttl := annotations.TTLFromAnnotations(node.Annotations, fmt.Sprintf("node/%s", node.Name)) - - addrs := annotations.TargetsFromTargetAnnotation(node.Annotations) - - if len(addrs) == 0 { - addrs, err = ns.nodeAddresses(node) + // Only generate node name endpoints when there's no template or when combining + var nodeEndpoints []*endpoint.Endpoint + if ns.fqdnTemplate == nil || ns.combineFQDNAnnotation { + nodeEndpoints, err = ns.endpointsForDNSNames(node, []string{node.Name}) if err != nil { - return nil, fmt.Errorf("failed to get node address from %s: %w", node.Name, err) + return nil, err } } - dnsNames, err := ns.collectDNSNames(node) + nodeEndpoints, err = fqdn.CombineWithTemplatedEndpoints( + nodeEndpoints, + ns.fqdnTemplate, + ns.combineFQDNAnnotation, + func() ([]*endpoint.Endpoint, error) { return ns.endpointsFromNodeTemplate(node) }, + ) if err != nil { return nil, err } - for dns := range dnsNames { - log.Debugf("adding endpoint with %d targets", len(addrs)) - - for _, addr := range addrs { - ep := endpoint.NewEndpointWithTTL(dns, suitableType(addr), ttl) - ep.WithLabel(endpoint.ResourceLabelKey, fmt.Sprintf("node/%s", node.Name)) - - log.Debugf("adding endpoint %s target %s", ep, addr) - key := endpoint.EndpointKey{ - DNSName: ep.DNSName, - RecordType: ep.RecordType, - } - if _, ok := endpoints[key]; !ok { - epCopy := *ep - epCopy.RecordType = key.RecordType - endpoints[key] = &epCopy - } - endpoints[key].Targets = append(endpoints[key].Targets, addr) - } + if len(nodeEndpoints) == 0 { + log.Debugf("No endpoints could be generated from node %s", node.Name) + continue } - } - endpointsSlice := []*endpoint.Endpoint{} - for _, ep := range endpoints { - endpointsSlice = append(endpointsSlice, ep) + endpoints = append(endpoints, nodeEndpoints...) } - return endpointsSlice, nil + return MergeEndpoints(endpoints), nil } func (ns *nodeSource) AddEventHandler(_ context.Context, handler func()) { _, _ = ns.nodeInformer.Informer().AddEventHandler(eventHandlerFunc(handler)) } +// endpointsFromNodeTemplate creates endpoints using DNS names from the FQDN template. +func (ns *nodeSource) endpointsFromNodeTemplate(node *v1.Node) ([]*endpoint.Endpoint, error) { + names, err := fqdn.ExecTemplate(ns.fqdnTemplate, node) + if err != nil { + return nil, err + } + + for _, name := range names { + log.Debugf("applied template for %s, converting to %s", node.Name, name) + } + + return ns.endpointsForDNSNames(node, names) +} + +// endpointsForDNSNames creates endpoints for the given DNS names using the node's addresses. +func (ns *nodeSource) endpointsForDNSNames(node *v1.Node, dnsNames []string) ([]*endpoint.Endpoint, error) { + ttl := annotations.TTLFromAnnotations(node.Annotations, fmt.Sprintf("node/%s", node.Name)) + + addrs := annotations.TargetsFromTargetAnnotation(node.Annotations) + if len(addrs) == 0 { + var err error + addrs, err = ns.nodeAddresses(node) + if err != nil { + return nil, fmt.Errorf("failed to get node address from %s: %w", node.Name, err) + } + } + + var endpoints []*endpoint.Endpoint + for _, dns := range dnsNames { + log.Debugf("adding endpoint with %d targets", len(addrs)) + + for _, addr := range addrs { + ep := endpoint.NewEndpointWithTTL(dns, suitableType(addr), ttl, addr) + ep.WithLabel(endpoint.ResourceLabelKey, fmt.Sprintf("node/%s", node.Name)) + log.Debugf("adding endpoint %s target %s", ep, addr) + endpoints = append(endpoints, ep) + } + } + + return endpoints, nil +} + // nodeAddress returns the node's externalIP and if that's not found, the node's internalIP // basically what k8s.io/kubernetes/pkg/util/node.GetPreferredNodeAddress does func (ns *nodeSource) nodeAddresses(node *v1.Node) ([]string, error) { @@ -207,36 +233,3 @@ func (ns *nodeSource) nodeAddresses(node *v1.Node) ([]string, error) { return nil, fmt.Errorf("could not find node address for %s", node.Name) } - -// collectDNSNames returns a set of DNS names associated with the given Kubernetes Node. -// If an FQDN template is configured, it renders the template using the Node object -// to generate one or more DNS names. -// If combineFQDNAnnotation is enabled, the Node's name is also included alongside -// the templated names. If no FQDN template is provided, the result will include only -// the Node's name. -// -// Returns an error if template rendering fails. -func (ns *nodeSource) collectDNSNames(node *v1.Node) (map[string]bool, error) { - dnsNames := make(map[string]bool) - // If no FQDN template is configured, fallback to the node name - if ns.fqdnTemplate == nil { - dnsNames[node.Name] = true - return dnsNames, nil - } - - names, err := fqdn.ExecTemplate(ns.fqdnTemplate, node) - if err != nil { - return nil, err - } - - for _, name := range names { - dnsNames[name] = true - log.Debugf("applied template for %s, converting to %s", node.Name, name) - } - - if ns.combineFQDNAnnotation { - dnsNames[node.Name] = true - } - - return dnsNames, nil -} diff --git a/source/openshift_route.go b/source/openshift_route.go index fbcb5c28ef..4721e13fde 100644 --- a/source/openshift_route.go +++ b/source/openshift_route.go @@ -149,17 +149,14 @@ func (ors *ocpRouteSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, e orEndpoints := ors.endpointsFromOcpRoute(ocpRoute, ors.ignoreHostnameAnnotation) // apply template if host is missing on OpenShift Route - if (ors.combineFQDNAnnotation || len(orEndpoints) == 0) && ors.fqdnTemplate != nil { - oEndpoints, err := ors.endpointsFromTemplate(ocpRoute) - if err != nil { - return nil, err - } - - if ors.combineFQDNAnnotation { - orEndpoints = append(orEndpoints, oEndpoints...) - } else { - orEndpoints = oEndpoints - } + orEndpoints, err = fqdn.CombineWithTemplatedEndpoints( + orEndpoints, + ors.fqdnTemplate, + ors.combineFQDNAnnotation, + func() ([]*endpoint.Endpoint, error) { return ors.endpointsFromTemplate(ocpRoute) }, + ) + if err != nil { + return nil, err } if len(orEndpoints) == 0 { diff --git a/source/pod.go b/source/pod.go index 5d90140b81..50310fc204 100644 --- a/source/pod.go +++ b/source/pod.go @@ -19,7 +19,6 @@ package source import ( "context" "fmt" - "maps" "text/template" log "github.com/sirupsen/logrus" @@ -154,30 +153,52 @@ func (ps *podSource) AddEventHandler(_ context.Context, handler func()) { func (ps *podSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) { indexKeys := ps.podInformer.Informer().GetIndexer().ListIndexFuncValues(informers.IndexWithSelectors) - endpointMap := make(map[endpoint.EndpointKey][]string) + endpoints := make([]*endpoint.Endpoint, 0) for _, key := range indexKeys { pod, err := informers.GetByKey[*corev1.Pod](ps.podInformer.Informer().GetIndexer(), key) if err != nil { continue } - if ps.fqdnTemplate == nil || ps.combineFQDNAnnotation { - ps.addPodEndpointsToEndpointMap(endpointMap, pod) - } + podEndpoints := ps.endpointsFromPodAnnotations(pod) - if ps.fqdnTemplate != nil { - fqdnHosts, err := ps.hostsFromTemplate(pod) - if err != nil { - return nil, err - } - maps.Copy(endpointMap, fqdnHosts) + podEndpoints, err = fqdn.CombineWithTemplatedEndpoints( + podEndpoints, + ps.fqdnTemplate, + ps.combineFQDNAnnotation, + func() ([]*endpoint.Endpoint, error) { return ps.endpointsFromPodTemplate(pod) }, + ) + if err != nil { + return nil, err } + + endpoints = append(endpoints, podEndpoints...) } + return MergeEndpoints(endpoints), nil +} + +func (ps *podSource) endpointsFromPodAnnotations(pod *corev1.Pod) []*endpoint.Endpoint { + endpointMap := make(map[endpoint.EndpointKey][]string) + ps.addPodEndpointsToEndpointMap(endpointMap, pod) + var endpoints []*endpoint.Endpoint for key, targets := range endpointMap { endpoints = append(endpoints, endpoint.NewEndpointWithTTL(key.DNSName, key.RecordType, key.RecordTTL, targets...)) } + return endpoints +} + +func (ps *podSource) endpointsFromPodTemplate(pod *corev1.Pod) ([]*endpoint.Endpoint, error) { + hostsMap, err := ps.hostsFromTemplate(pod) + if err != nil { + return nil, err + } + + var endpoints []*endpoint.Endpoint + for key, targets := range hostsMap { + endpoints = append(endpoints, endpoint.NewEndpointWithTTL(key.DNSName, key.RecordType, key.RecordTTL, targets...)) + } return endpoints, nil } diff --git a/source/service.go b/source/service.go index 2910b54ac3..07f563586e 100644 --- a/source/service.go +++ b/source/service.go @@ -273,17 +273,14 @@ func (sc *serviceSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, err } // apply template if none of the above is found - if (sc.combineFQDNAnnotation || len(svcEndpoints) == 0) && sc.fqdnTemplate != nil { - sEndpoints, err := sc.endpointsFromTemplate(svc) - if err != nil { - return nil, err - } - - if sc.combineFQDNAnnotation { - svcEndpoints = append(svcEndpoints, sEndpoints...) - } else { - svcEndpoints = sEndpoints - } + svcEndpoints, err = fqdn.CombineWithTemplatedEndpoints( + svcEndpoints, + sc.fqdnTemplate, + sc.combineFQDNAnnotation, + func() ([]*endpoint.Endpoint, error) { return sc.endpointsFromTemplate(svc) }, + ) + if err != nil { + return nil, err } if len(svcEndpoints) == 0 { diff --git a/source/skipper_routegroup.go b/source/skipper_routegroup.go index 2906944770..629c4aeebb 100644 --- a/source/skipper_routegroup.go +++ b/source/skipper_routegroup.go @@ -271,17 +271,14 @@ func (sc *routeGroupSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint eps := sc.endpointsFromRouteGroup(rg) - if (sc.combineFQDNAnnotation || len(eps) == 0) && sc.fqdnTemplate != nil { - tmplEndpoints, err := sc.endpointsFromTemplate(rg) - if err != nil { - return nil, err - } - - if sc.combineFQDNAnnotation { - eps = append(eps, tmplEndpoints...) - } else { - eps = tmplEndpoints - } + eps, err = fqdn.CombineWithTemplatedEndpoints( + eps, + sc.fqdnTemplate, + sc.combineFQDNAnnotation, + func() ([]*endpoint.Endpoint, error) { return sc.endpointsFromTemplate(rg) }, + ) + if err != nil { + return nil, err } if len(eps) == 0 { diff --git a/source/utils.go b/source/utils.go index 3eb303ed07..035ca74123 100644 --- a/source/utils.go +++ b/source/utils.go @@ -16,6 +16,7 @@ package source import ( "fmt" "net/netip" + "sort" "strings" "sigs.k8s.io/external-dns/endpoint" @@ -69,3 +70,34 @@ func MatchesServiceSelector(selector, svcSelector map[string]string) bool { } return true } + +// MergeEndpoints merges endpoints with the same key (DNSName + RecordType + RecordTTL) +// by combining their targets. This is useful when multiple resources (e.g., pods, nodes) +// contribute targets to the same DNS record. +// +// TODO: move this to endpoint/utils.go +// TODO: apply to all sources that generate endpoints (e.g., service, ingress, etc.) +func MergeEndpoints(endpoints []*endpoint.Endpoint) []*endpoint.Endpoint { + endpointMap := make(map[endpoint.EndpointKey]*endpoint.Endpoint) + + for _, ep := range endpoints { + key := endpoint.EndpointKey{ + DNSName: ep.DNSName, + RecordType: ep.RecordType, + RecordTTL: ep.RecordTTL, + } + if existing, ok := endpointMap[key]; ok { + existing.Targets = append(existing.Targets, ep.Targets...) + } else { + endpointMap[key] = ep + } + } + + result := make([]*endpoint.Endpoint, 0, len(endpointMap)) + for _, ep := range endpointMap { + sort.Sort(ep.Targets) + result = append(result, ep) + } + + return result +} diff --git a/source/utils_test.go b/source/utils_test.go index ba6447c2eb..251d0fc77f 100644 --- a/source/utils_test.go +++ b/source/utils_test.go @@ -149,3 +149,99 @@ func TestSelectorMatchesService(t *testing.T) { }) } } + +func TestMergeEndpoints(t *testing.T) { + tests := []struct { + name string + input []*endpoint.Endpoint + expected []*endpoint.Endpoint + }{ + { + name: "empty input", + input: []*endpoint.Endpoint{}, + expected: []*endpoint.Endpoint{}, + }, + { + name: "single endpoint unchanged", + input: []*endpoint.Endpoint{ + {DNSName: "example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, + }, + expected: []*endpoint.Endpoint{ + {DNSName: "example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, + }, + }, + { + name: "different keys not merged", + input: []*endpoint.Endpoint{ + {DNSName: "a.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, + {DNSName: "b.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"5.6.7.8"}}, + }, + expected: []*endpoint.Endpoint{ + {DNSName: "a.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, + {DNSName: "b.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"5.6.7.8"}}, + }, + }, + { + name: "same DNSName different RecordType not merged", + input: []*endpoint.Endpoint{ + {DNSName: "example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, + {DNSName: "example.com", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::1"}}, + }, + expected: []*endpoint.Endpoint{ + {DNSName: "example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, + {DNSName: "example.com", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::1"}}, + }, + }, + { + name: "same key merged with sorted targets", + input: []*endpoint.Endpoint{ + {DNSName: "example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"5.6.7.8"}}, + {DNSName: "example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, + }, + expected: []*endpoint.Endpoint{ + {DNSName: "example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4", "5.6.7.8"}}, + }, + }, + { + name: "multiple endpoints same key merged", + input: []*endpoint.Endpoint{ + {DNSName: "example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"3.3.3.3"}}, + {DNSName: "example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1"}}, + {DNSName: "example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"2.2.2.2"}}, + }, + expected: []*endpoint.Endpoint{ + {DNSName: "example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1", "2.2.2.2", "3.3.3.3"}}, + }, + }, + { + name: "mixed merge and no merge", + input: []*endpoint.Endpoint{ + {DNSName: "a.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1"}}, + {DNSName: "b.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"2.2.2.2"}}, + {DNSName: "a.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"3.3.3.3"}}, + }, + expected: []*endpoint.Endpoint{ + {DNSName: "a.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1", "3.3.3.3"}}, + {DNSName: "b.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"2.2.2.2"}}, + }, + }, + { + name: "same key with different TTL not merged", + input: []*endpoint.Endpoint{ + {DNSName: "example.com", RecordType: endpoint.RecordTypeA, RecordTTL: 300, Targets: endpoint.Targets{"1.2.3.4"}}, + {DNSName: "example.com", RecordType: endpoint.RecordTypeA, RecordTTL: 600, Targets: endpoint.Targets{"5.6.7.8"}}, + }, + expected: []*endpoint.Endpoint{ + {DNSName: "example.com", RecordType: endpoint.RecordTypeA, RecordTTL: 300, Targets: endpoint.Targets{"1.2.3.4"}}, + {DNSName: "example.com", RecordType: endpoint.RecordTypeA, RecordTTL: 600, Targets: endpoint.Targets{"5.6.7.8"}}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := MergeEndpoints(tt.input) + assert.ElementsMatch(t, tt.expected, result) + }) + } +}