/* Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package source import ( "testing" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/internal/testutils" logtest "sigs.k8s.io/external-dns/internal/testutils/log" "sigs.k8s.io/external-dns/source/types" ) func TestParseIngress(t *testing.T) { tests := []struct { name string ingress string wantNS string wantName string wantError bool }{ { name: "valid namespace and name", ingress: "default/test-ingress", wantNS: "default", wantName: "test-ingress", wantError: false, }, { name: "only name provided", ingress: "test-ingress", wantNS: "", wantName: "test-ingress", wantError: false, }, { name: "invalid format", ingress: "default/test/ingress", wantNS: "", wantName: "", wantError: true, }, { name: "empty string", ingress: "", wantNS: "", wantName: "", wantError: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { gotNS, gotName, err := ParseIngress(tt.ingress) if tt.wantError { assert.Error(t, err) } else { assert.NoError(t, err) } assert.Equal(t, tt.wantNS, gotNS) assert.Equal(t, tt.wantName, gotName) }) } } func TestMergeEndpoints(t *testing.T) { tests := []struct { name string input []*endpoint.Endpoint expected []*endpoint.Endpoint }{ { name: "nil input returns nil", input: nil, expected: nil, }, { name: "empty input returns empty", 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: "duplicate targets deduplicated", input: []*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "1.2.3.4", "1.2.3.4", "5.6.7.8"), }, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "1.2.3.4", "5.6.7.8"), }, }, { name: "duplicate targets across merged endpoints deduplicated", input: []*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "1.2.3.4"), endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "1.2.3.4", "5.6.7.8"), }, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "1.2.3.4", "5.6.7.8"), }, }, { name: "CNAME endpoints not merged", input: []*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeCNAME, "a.elb.com"), endpoint.NewEndpoint("example.com", endpoint.RecordTypeCNAME, "b.elb.com"), }, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeCNAME, "a.elb.com"), endpoint.NewEndpoint("example.com", endpoint.RecordTypeCNAME, "b.elb.com"), }, }, { name: "CNAME with no targets is skipped", input: []*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeCNAME), endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "1.2.3.4"), }, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "1.2.3.4"), }, }, { name: "identical CNAME endpoints deduplicated", input: []*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeCNAME, "a.elb.com"), endpoint.NewEndpoint("example.com", endpoint.RecordTypeCNAME, "a.elb.com"), }, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeCNAME, "a.elb.com"), }, }, { name: "same key with different TTL not merged", input: []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeA, 300, "1.2.3.4"), endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeA, 600, "5.6.7.8"), }, expected: []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeA, 300, "1.2.3.4"), endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeA, 600, "5.6.7.8"), }, }, { name: "same DNSName and RecordType with different SetIdentifier not merged", input: []*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "1.2.3.4").WithSetIdentifier("us-east-1"), endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "5.6.7.8").WithSetIdentifier("eu-west-1"), }, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "1.2.3.4").WithSetIdentifier("us-east-1"), endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "5.6.7.8").WithSetIdentifier("eu-west-1"), }, }, { name: "same DNSName, RecordType and SetIdentifier targets are merged", input: []*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "1.2.3.4").WithSetIdentifier("us-east-1"), endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "5.6.7.8").WithSetIdentifier("us-east-1"), }, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "1.2.3.4", "5.6.7.8").WithSetIdentifier("us-east-1"), }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := MergeEndpoints(tt.input) assert.ElementsMatch(t, tt.expected, result) }) } } func TestMergeEndpoints_RefObjects(t *testing.T) { tests := []struct { name string input func() []*endpoint.Endpoint expected func(*testing.T, []*endpoint.Endpoint) }{ { name: "empty input", input: func() []*endpoint.Endpoint { return []*endpoint.Endpoint{} }, expected: func(t *testing.T, ep []*endpoint.Endpoint) { assert.Empty(t, ep) }, }, { name: "single endpoint", input: func() []*endpoint.Endpoint { return []*endpoint.Endpoint{ testutils.NewEndpointWithRef("example.com", "1.2.3.4", &v1.Service{ ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "default", UID: "123"}, }, types.Service), } }, expected: func(t *testing.T, ep []*endpoint.Endpoint) { assert.Len(t, ep, 1) assert.Equal(t, types.Service, ep[0].RefObject().Source) assert.Equal(t, "foo", ep[0].RefObject().Name) assert.Equal(t, "123", string(ep[0].RefObject().UID)) }, }, { name: "two endpoints merged and only single refObject preserved", input: func() []*endpoint.Endpoint { return []*endpoint.Endpoint{ testutils.NewEndpointWithRef("a.example.com", "1.1.1.1", &v1.Service{ ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "default", UID: "123"}, }, types.Service), testutils.NewEndpointWithRef("a.example.com", "1.1.1.1", &v1.Service{ ObjectMeta: metav1.ObjectMeta{Name: "bar", Namespace: "ns", UID: "345"}, }, types.Service), } }, expected: func(t *testing.T, ep []*endpoint.Endpoint) { assert.Len(t, ep, 1) assert.Equal(t, types.Service, ep[0].RefObject().Source) assert.Equal(t, "foo", ep[0].RefObject().Name) assert.Equal(t, "123", string(ep[0].RefObject().UID)) assert.NotEqual(t, "345", string(ep[0].RefObject().UID)) }, }, { name: "two endpoints not merged and two refObject preserved", input: func() []*endpoint.Endpoint { return []*endpoint.Endpoint{ testutils.NewEndpointWithRef("a.example.com", "1.1.1.1", &v1.Service{ ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "default", UID: "123"}, }, types.Service), testutils.NewEndpointWithRef("b.example.com", "1.1.1.2", &v1.Service{ ObjectMeta: metav1.ObjectMeta{Name: "bar", Namespace: "ns", UID: "345"}, }, types.Service), } }, expected: func(t *testing.T, ep []*endpoint.Endpoint) { assert.Len(t, ep, 2) assert.NotEqual(t, ep[0], ep[1]) for _, el := range ep { assert.Equal(t, types.Service, el.RefObject().Source) assert.Contains(t, []string{"foo", "bar"}, el.RefObject().Name) assert.Contains(t, []string{"123", "345"}, string(el.RefObject().UID)) } }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := MergeEndpoints(tt.input()) tt.expected(t, result) }) } } func TestMergeEndpointsLogging(t *testing.T) { t.Run("warns on CNAME conflict", func(t *testing.T) { hook := logtest.LogsUnderTestWithLogLevel(log.WarnLevel, t) MergeEndpoints([]*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeCNAME, "a.elb.com"), endpoint.NewEndpoint("example.com", endpoint.RecordTypeCNAME, "b.elb.com"), }) logtest.TestHelperLogContainsWithLogLevel("Only one CNAME per name", log.WarnLevel, hook, t) logtest.TestHelperLogContains("example.com CNAME a.elb.com", hook, t) logtest.TestHelperLogContains("example.com CNAME b.elb.com", hook, t) }) t.Run("no warning for identical CNAMEs", func(t *testing.T) { hook := logtest.LogsUnderTestWithLogLevel(log.WarnLevel, t) MergeEndpoints([]*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeCNAME, "a.elb.com"), endpoint.NewEndpoint("example.com", endpoint.RecordTypeCNAME, "a.elb.com"), }) logtest.TestHelperLogNotContains("Only one CNAME per name", hook, t) }) t.Run("no warning for same DNSName with different SetIdentifier", func(t *testing.T) { hook := logtest.LogsUnderTestWithLogLevel(log.WarnLevel, t) MergeEndpoints([]*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeCNAME, "a.elb.com").WithSetIdentifier("weight-1"), endpoint.NewEndpoint("example.com", endpoint.RecordTypeCNAME, "b.elb.com").WithSetIdentifier("weight-2"), }) logtest.TestHelperLogNotContains("Only one CNAME per name", hook, t) }) t.Run("debug log for CNAME with no targets", func(t *testing.T) { hook := logtest.LogsUnderTestWithLogLevel(log.DebugLevel, t) MergeEndpoints([]*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeCNAME), }) logtest.TestHelperLogContainsWithLogLevel("Skipping CNAME endpoint", log.DebugLevel, hook, t) logtest.TestHelperLogContains("example.com", hook, t) }) }