chore(source/node): template expansion (#5498)

This commit is contained in:
Ivan Ka 2025-06-06 19:28:39 +01:00 committed by GitHub
parent 93d4d47bff
commit f55be38b45
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 473 additions and 48 deletions

View File

@ -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.

View File

@ -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)
}

322
source/node_fqdn_test.go Normal file
View File

@ -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)
})
}
}

View File

@ -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}
}