diff --git a/docs/advanced/fqdn-templating.md b/docs/advanced/fqdn-templating.md index 9993708a9..8189c8c30 100644 --- a/docs/advanced/fqdn-templating.md +++ b/docs/advanced/fqdn-templating.md @@ -316,7 +316,7 @@ By setting the hostname annotation in the ingress resource, ExternalDNS construc ```yml args: - - --fqdn-template="{{range .Status.Addresses}}{{if and (eq .Type \"ExternalIP\") (isIPv4 .Address)}}{{.Address | replace \".\" \"-\"}}{{break}}{{end}}{{end}}.example.com + - --fqdn-template="{{range .Status.Addresses}}{{if and (eq .Type \"ExternalIP\") (isIPv4 .Address)}}{{.Address | replace \".\" \"-\"}}{{break}}{{end}}{{end}}.example.com" ``` This is a complex template that iternates through a list of a Node's Addresses and creates a FQDN with public IPv4 addresses. diff --git a/source/node.go b/source/node.go index 33e7ea69e..a8435b9ac 100644 --- a/source/node.go +++ b/source/node.go @@ -48,7 +48,7 @@ type nodeSource struct { } // NewNodeSource creates a new nodeSource with the given config. -func NewNodeSource(ctx context.Context, kubeClient kubernetes.Interface, annotationFilter, fqdnTemplate string, labelSelector labels.Selector, exposeInternalIPv6 bool, excludeUnschedulable bool) (Source, error) { +func NewNodeSource(ctx context.Context, kubeClient kubernetes.Interface, annotationFilter, fqdnTemplate string, labelSelector labels.Selector, exposeInternalIPv6, excludeUnschedulable bool) (Source, error) { tmpl, err := fqdn.ParseTemplate(fqdnTemplate) if err != nil { return nil, err @@ -103,8 +103,7 @@ func (ns *nodeSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, erro // create endpoints for all nodes for _, node := range nodes { // Check controller annotation to see if we are responsible. - controller, ok := node.Annotations[controllerAnnotationKey] - if ok && controller != controllerAnnotationValue { + if controller, ok := node.Annotations[controllerAnnotationKey]; ok && controller != controllerAnnotationValue { log.Debugf("Skipping node %s because controller value does not match, found: %s, required: %s", node.Name, controller, controllerAnnotationValue) continue @@ -119,28 +118,8 @@ func (ns *nodeSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, erro ttl := annotations.TTLFromAnnotations(node.Annotations, fmt.Sprintf("node/%s", node.Name)) - // create new endpoint with the information we already have - ep := &endpoint.Endpoint{ - RecordTTL: ttl, - } - - if ns.fqdnTemplate != nil { - hostnames, err := fqdn.ExecTemplate(ns.fqdnTemplate, node) - if err != nil { - return nil, err - } - hostname := "" - if len(hostnames) > 0 { - hostname = hostnames[0] - } - ep.DNSName = hostname - log.Debugf("applied template for %s, converting to %s", node.Name, ep.DNSName) - } else { - ep.DNSName = node.Name - log.Debugf("not applying template for %s", node.Name) - } - addrs := annotations.TargetsFromTargetAnnotation(node.Annotations) + if len(addrs) == 0 { addrs, err = ns.nodeAddresses(node) if err != nil { @@ -148,19 +127,40 @@ func (ns *nodeSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, erro } } - ep.Labels = endpoint.NewLabels() - for _, addr := range addrs { - log.Debugf("adding endpoint %s target %s", ep, addr) - key := endpoint.EndpointKey{ - DNSName: ep.DNSName, - RecordType: suitableType(addr), + dnsNames := make(map[string]bool) + + if ns.fqdnTemplate != nil { + hostnames, err := fqdn.ExecTemplate(ns.fqdnTemplate, node) + if err != nil { + return nil, err } - if _, ok := endpoints[key]; !ok { - epCopy := *ep - epCopy.RecordType = key.RecordType - endpoints[key] = &epCopy + + for _, name := range hostnames { + dnsNames[name] = true + log.Debugf("applied template for %s, converting to %s", node.Name, name) + } + } else { + dnsNames[node.Name] = true + log.Debugf("not applying template for %s", node.Name) + } + + for dns := range dnsNames { + log.Debugf("adding endpoint with %d targets", len(addrs)) + + for _, addr := range addrs { + ep := endpoint.NewEndpointWithTTL(dns, suitableType(addr), ttl) + 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) } - endpoints[key].Targets = append(endpoints[key].Targets, addr) } } @@ -172,10 +172,10 @@ func (ns *nodeSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, erro return endpointsSlice, nil } -func (ns *nodeSource) AddEventHandler(ctx context.Context, handler func()) { +func (ns *nodeSource) AddEventHandler(_ context.Context, _ func()) { } -// nodeAddress returns node's externalIP and if that's not found, node's internalIP +// 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) { addresses := map[v1.NodeAddressType][]string{ @@ -223,7 +223,7 @@ func (ns *nodeSource) filterByAnnotations(nodes []*v1.Node) ([]*v1.Node, error) var filteredList []*v1.Node for _, node := range nodes { - // include node if its annotations match the selector + // include a node if its annotations match the selector if selector.Matches(labels.Set(node.Annotations)) { filteredList = append(filteredList, node) } diff --git a/source/node_fqdn_test.go b/source/node_fqdn_test.go new file mode 100644 index 000000000..5d93b90c7 --- /dev/null +++ b/source/node_fqdn_test.go @@ -0,0 +1,322 @@ +/* +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" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/kubernetes/fake" + "sigs.k8s.io/external-dns/endpoint" +) + +func TestNodeSourceNewNodeSourceWithFqdn(t *testing.T) { + for _, tt := range []struct { + title string + annotationFilter string + fqdnTemplate string + expectError bool + }{ + { + title: "invalid template", + expectError: true, + fqdnTemplate: "{{.Name", + }, + { + title: "valid empty template", + expectError: false, + }, + { + title: "valid template", + expectError: false, + fqdnTemplate: "{{.Name}}-{{.Namespace}}.ext-dns.test.com", + }, + { + title: "complex template", + expectError: false, + fqdnTemplate: "{{range .Status.Addresses}}{{if and (eq .Type \"ExternalIP\") (isIPv4 .Address)}}{{.Address | replace \".\" \"-\"}}{{break}}{{end}}{{end}}.ext-dns.test.com", + }, + } { + t.Run(tt.title, func(t *testing.T) { + _, err := NewNodeSource( + t.Context(), + fake.NewClientset(), + tt.annotationFilter, + tt.fqdnTemplate, + labels.Everything(), + true, + true, + ) + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestNodeSourceFqdnTemplatingExamples(t *testing.T) { + for _, tt := range []struct { + title string + nodes []*v1.Node + fqdnTemplate string + expected []*endpoint.Endpoint + }{ + { + title: "templating expansion with multiple domains", + nodes: []*v1.Node{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "ip-10-1-176-5.internal", + }, + Status: v1.NodeStatus{ + Addresses: []v1.NodeAddress{ + {Type: v1.NodeInternalIP, Address: "10.1.176.1"}, + {Type: v1.NodeInternalIP, Address: "fc00:f853:ccd:e793::1"}, + }, + }, + }, + }, + fqdnTemplate: "{{.Name}}.domainA.com,{{.Name}}.domainB.com", + expected: []*endpoint.Endpoint{ + {DNSName: "ip-10-1-176-5.internal.domainA.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.1.176.1"}}, + {DNSName: "ip-10-1-176-5.internal.domainA.com", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"fc00:f853:ccd:e793::1"}}, + {DNSName: "ip-10-1-176-5.internal.domainB.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.1.176.1"}}, + {DNSName: "ip-10-1-176-5.internal.domainB.com", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"fc00:f853:ccd:e793::1"}}, + }, + }, + { + title: "templating contains namespace when node namespace is not a valid variable", + nodes: []*v1.Node{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "node-name", + }, + Status: v1.NodeStatus{ + Addresses: []v1.NodeAddress{ + {Type: v1.NodeInternalIP, Address: "10.1.176.1"}, + }, + }, + }, + }, + fqdnTemplate: "{{.Name}}.domainA.com,{{ .Name }}.{{ .Namespace }}.example.tld", + expected: []*endpoint.Endpoint{ + {DNSName: "node-name.domainA.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.1.176.1"}}, + {DNSName: "node-name..example.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.1.176.1"}}, + }, + }, + { + title: "templating with external IP and range of addresses", + nodes: []*v1.Node{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "ip-10-1-176-1", + }, + Status: v1.NodeStatus{ + Addresses: []v1.NodeAddress{ + {Type: v1.NodeExternalIP, Address: "243.186.136.160"}, + {Type: v1.NodeInternalIP, Address: "fc00:f853:ccd:e793::1"}, + }, + }, + }, + }, + fqdnTemplate: "{{ range .Status.Addresses }}{{if and (eq .Type \"ExternalIP\") (isIPv4 .Address)}}ip-{{ .Address | replace \".\" \"-\" }}{{ break }}{{ end }}{{ end }}.example.com", + expected: []*endpoint.Endpoint{ + {DNSName: "ip-243-186-136-160.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"243.186.136.160"}}, + {DNSName: "ip-243-186-136-160.example.com", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"fc00:f853:ccd:e793::1"}}, + }, + }, + { + title: "templating with name definition and ipv4 check", + nodes: []*v1.Node{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "node-name-ip", + }, + Status: v1.NodeStatus{ + Addresses: []v1.NodeAddress{ + {Type: v1.NodeExternalIP, Address: "243.186.136.160"}, + {Type: v1.NodeInternalIP, Address: "fc00:f853:ccd:e793::1"}, + }, + }, + }, + }, + fqdnTemplate: "{{ $name := .Name }}{{ range .Status.Addresses }}{{if (isIPv4 .Address)}}{{ $name }}.ipv4{{ break }}{{ end }}{{ end }}.example.com", + expected: []*endpoint.Endpoint{ + {DNSName: "node-name-ip.ipv4.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"243.186.136.160"}}, + {DNSName: "node-name-ip.ipv4.example.com", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"fc00:f853:ccd:e793::1"}}, + }, + }, + { + title: "templating with hostname annotation", + nodes: []*v1.Node{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "ip-10-1-176-1", + Annotations: map[string]string{ + "external-dns.alpha.kubernetes.io/hostname": "ip-10-1-176-1.internal.domain.com", + }, + }, + Status: v1.NodeStatus{ + Addresses: []v1.NodeAddress{ + {Type: v1.NodeExternalIP, Address: "243.186.136.160"}, + {Type: v1.NodeInternalIP, Address: "fc00:f853:ccd:e793::1"}, + }, + }, + }, + }, + fqdnTemplate: "{{.Name}}.example.com", + expected: []*endpoint.Endpoint{ + {DNSName: "ip-10-1-176-1.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"243.186.136.160"}}, + {DNSName: "ip-10-1-176-1.example.com", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"fc00:f853:ccd:e793::1"}}, + }, + }, + { + title: "templating when target annotation and no external IP", + nodes: []*v1.Node{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "node-name", + Labels: nil, + Annotations: map[string]string{ + "external-dns.alpha.kubernetes.io/target": "203.2.45.22", + }, + }, + Status: v1.NodeStatus{ + Addresses: []v1.NodeAddress{ + {Type: v1.NodeExternalIP, Address: "243.186.136.160"}, + {Type: v1.NodeInternalIP, Address: "fc00:f853:ccd:e793::1"}, + }, + }, + }, + }, + fqdnTemplate: "{{.Name}}.example.com", + expected: []*endpoint.Endpoint{ + {DNSName: "node-name.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"203.2.45.22"}}, + }, + }, + { + title: "templating with simple annotation expansion", + nodes: []*v1.Node{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "node-name", + Annotations: map[string]string{ + "workload": "cluster-resources", + }, + }, + Status: v1.NodeStatus{ + Addresses: []v1.NodeAddress{ + {Type: v1.NodeExternalIP, Address: "243.186.136.160"}, + }, + }, + }, + }, + fqdnTemplate: "{{ .Name }}.{{ .Annotations.workload }}.domain.tld", + expected: []*endpoint.Endpoint{ + {DNSName: "node-name.cluster-resources.domain.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"243.186.136.160"}}, + }, + }, + { + title: "templating with complex labels expansion", + nodes: []*v1.Node{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "node-name", + Labels: map[string]string{ + "topology.kubernetes.io/region": "eu-west-1", + }, + Annotations: nil, + }, + Spec: v1.NodeSpec{ + Unschedulable: false, + }, + Status: v1.NodeStatus{ + Addresses: []v1.NodeAddress{ + {Type: v1.NodeExternalIP, Address: "243.186.136.160"}, + }, + }, + }, + }, + fqdnTemplate: "{{ .Name }}.{{ index .ObjectMeta.Labels \"topology.kubernetes.io/region\" }}.domain.tld", + expected: []*endpoint.Endpoint{ + {DNSName: "node-name.eu-west-1.domain.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"243.186.136.160"}}, + }, + }, + { + title: "templating with shared all domain", + nodes: []*v1.Node{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "node-name-1", + }, + Status: v1.NodeStatus{ + Addresses: []v1.NodeAddress{ + {Type: v1.NodeExternalIP, Address: "243.186.136.160"}, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "node-name-2", + }, + Status: v1.NodeStatus{ + Addresses: []v1.NodeAddress{ + {Type: v1.NodeExternalIP, Address: "243.186.136.178"}, + }, + }, + }, + }, + fqdnTemplate: "{{ .Name }}.domain.tld,all.example.com", + expected: []*endpoint.Endpoint{ + {DNSName: "all.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"243.186.136.160", "243.186.136.178"}}, + {DNSName: "node-name-1.domain.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"243.186.136.160"}}, + {DNSName: "node-name-2.domain.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"243.186.136.178"}}, + }, + }, + } { + t.Run(tt.title, func(t *testing.T) { + kubeClient := fake.NewClientset() + + for _, node := range tt.nodes { + _, err := kubeClient.CoreV1().Nodes().Create(t.Context(), node, metav1.CreateOptions{}) + require.NoError(t, err) + } + + src, err := NewNodeSource( + t.Context(), + kubeClient, + "", + tt.fqdnTemplate, + labels.Everything(), + true, + true, + ) + require.NoError(t, err) + + endpoints, err := src.Endpoints(t.Context()) + require.NoError(t, err) + + validateEndpoints(t, endpoints, tt.expected) + }) + } +} diff --git a/source/node_test.go b/source/node_test.go index 30c4c30e1..de8ed9b9d 100644 --- a/source/node_test.go +++ b/source/node_test.go @@ -18,10 +18,15 @@ package source import ( "context" + "fmt" + "maps" + "math/rand" "testing" + "time" log "github.com/sirupsen/logrus" "github.com/sirupsen/logrus/hooks/test" + "k8s.io/client-go/kubernetes" "sigs.k8s.io/external-dns/internal/testutils" @@ -84,7 +89,7 @@ func testNodeSourceNewNodeSource(t *testing.T) { _, err := NewNodeSource( context.TODO(), - fake.NewSimpleClientset(), + fake.NewClientset(), ti.annotationFilter, ti.fqdnTemplate, labels.Everything(), @@ -407,7 +412,7 @@ func testNodeSourceEndpoints(t *testing.T) { } // Create a Kubernetes testing client - kubernetes := fake.NewSimpleClientset() + kubeClient := fake.NewClientset() node := &v1.Node{ ObjectMeta: metav1.ObjectMeta{ @@ -423,13 +428,13 @@ func testNodeSourceEndpoints(t *testing.T) { }, } - _, err := kubernetes.CoreV1().Nodes().Create(context.Background(), node, metav1.CreateOptions{}) + _, err := kubeClient.CoreV1().Nodes().Create(context.Background(), node, metav1.CreateOptions{}) require.NoError(t, err) // Create our object under test and get the endpoints. client, err := NewNodeSource( context.TODO(), - kubernetes, + kubeClient, tc.annotationFilter, tc.fqdnTemplate, labelSelector, @@ -519,7 +524,7 @@ func testNodeEndpointsWithIPv6(t *testing.T) { } // Create a Kubernetes testing client - kubernetes := fake.NewSimpleClientset() + kubeClient := fake.NewClientset() node := &v1.Node{ ObjectMeta: metav1.ObjectMeta{ @@ -535,7 +540,7 @@ func testNodeEndpointsWithIPv6(t *testing.T) { }, } - _, err := kubernetes.CoreV1().Nodes().Create(context.Background(), node, metav1.CreateOptions{}) + _, err := kubeClient.CoreV1().Nodes().Create(t.Context(), node, metav1.CreateOptions{}) require.NoError(t, err) var hook *test.Hook @@ -545,8 +550,8 @@ func testNodeEndpointsWithIPv6(t *testing.T) { // Create our object under test and get the endpoints. client, err := NewNodeSource( - context.TODO(), - kubernetes, + t.Context(), + kubeClient, tc.annotationFilter, tc.fqdnTemplate, labelSelector, @@ -555,7 +560,7 @@ func testNodeEndpointsWithIPv6(t *testing.T) { ) require.NoError(t, err) - endpoints, err := client.Endpoints(context.Background()) + endpoints, err := client.Endpoints(t.Context()) if tc.expectError { require.Error(t, err) } else { @@ -570,3 +575,101 @@ func testNodeEndpointsWithIPv6(t *testing.T) { validateEndpoints(t, endpoints, tc.expected) } } + +func TestResourceLabelIsSetForEachNodeEndpoint(t *testing.T) { + kubeClient := fake.NewClientset() + + nodes := helperNodeBuilder(). + withNode(nil). + withNode(nil). + withNode(nil). + withNode(nil). + build() + + for _, node := range nodes.Items { + _, err := kubeClient.CoreV1().Nodes().Create(t.Context(), &node, metav1.CreateOptions{}) + require.NoError(t, err, "Failed to create node %s", node.Name) + } + + client, err := NewNodeSource( + t.Context(), + kubeClient, + "", + "", + labels.Everything(), + false, + true, + ) + require.NoError(t, err) + + got, err := client.Endpoints(t.Context()) + require.NoError(t, err) + for _, ep := range got { + // TODO: node source should always set the resource label key. currently not supported by the node source. + assert.Empty(t, ep.Labels, "Labels should not be empty for endpoint %s", ep.DNSName) + assert.NotContains(t, ep.Labels, endpoint.ResourceLabelKey) + } +} + +type nodeListBuilder struct { + nodes []v1.Node +} + +func helperNodeBuilder() *nodeListBuilder { + return &nodeListBuilder{nodes: []v1.Node{}} +} + +func (b *nodeListBuilder) withNode(labels map[string]string) *nodeListBuilder { + idx := len(b.nodes) + 1 + nodeName := fmt.Sprintf("ip-10-1-176-%d.internal", idx) + b.nodes = append(b.nodes, v1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: nodeName, + Labels: func() map[string]string { + base := map[string]string{ + "test-label": "test-value", + "name": nodeName, + "topology.kubernetes.io/region": "eu-west-1", + "node.kubernetes.io/lifecycle": "spot", + } + maps.Copy(base, labels) + return base + }(), + Annotations: map[string]string{ + "volumes.kubernetes.io/controller-managed-attach-detach": "true", + "alpha.kubernetes.io/provided-node-ip": fmt.Sprintf("10.1.176.%d", idx), + "external-dns.alpha.kubernetes.io/hostname": fmt.Sprintf("node-%d.example.com", idx), + }, + }, + Spec: v1.NodeSpec{ + Unschedulable: false, + }, + Status: v1.NodeStatus{ + Addresses: []v1.NodeAddress{ + {Type: v1.NodeInternalIP, Address: fmt.Sprintf("10.1.176.%d", idx)}, + {Type: v1.NodeInternalIP, Address: fmt.Sprintf("fc00:f853:ccd:e793::%d", idx)}, + }, + }, + }) + + return b +} + +func (b *nodeListBuilder) build() v1.NodeList { + if len(b.nodes) > 1 { + // Shuffle the result to ensure randomness in the order. + rand.New(rand.NewSource(time.Now().UnixNano())) + rand.Shuffle(len(b.nodes), func(i, j int) { + b.nodes[i], b.nodes[j] = b.nodes[j], b.nodes[i] + }) + } + return v1.NodeList{Items: b.nodes} +} + +func (b *nodeListBuilder) apply(t *testing.T, kubeClient kubernetes.Interface) v1.NodeList { + for _, node := range b.nodes { + _, err := kubeClient.CoreV1().Nodes().Create(t.Context(), &node, metav1.CreateOptions{}) + require.NoError(t, err, "Failed to create node %s", node.Name) + } + return v1.NodeList{Items: b.nodes} +}