external-dns/source/node_test.go
Aleksei Sviridkin 5a55b09f48
feat(annotations): add custom annotation prefix support for split horizon DNS (#5889)
* feat(annotations): add custom annotation prefix support for split horizon DNS

Add --annotation-prefix flag to allow customizing the annotation prefix
used by external-dns. This enables split horizon DNS scenarios where
multiple instances process different sets of annotations from the same
Kubernetes resources.

Changes:
- Add AnnotationPrefix field to Config with validation
- Convert annotation constants to variables that can be reconfigured
- Add SetAnnotationPrefix() function to rebuild annotation keys
- Integrate annotation prefix setting in controller startup
- Update Helm chart with annotationPrefix value
- Add comprehensive split horizon DNS documentation
- Update FAQ with annotation prefix examples

This maintains full backward compatibility - the default prefix remains
"external-dns.alpha.kubernetes.io/".

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(advanced): fix markdown formatting in split-horizon guide

Add blank lines before code blocks to improve markdown rendering
and comply with markdownlint rules.

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(advanced): fix markdown formatting in split-horizon guide

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(charts): regenerate Helm chart documentation

Co-Authored-By: Claude <noreply@anthropic.com>

* test: add AnnotationPrefix field to test configs

Add missing AnnotationPrefix field to minimalConfig and overriddenConfig
test configurations to match the new default value set in NewConfig().

Co-Authored-By: Claude <noreply@anthropic.com>

* test(charts): update error pattern in json-schema test

Update expected error message pattern to match current Helm validation
output format.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(annotations): remove init() for explicit initialization

- Remove init() function from annotations package
- Add explicit SetAnnotationPrefix() call in controller/execute.go
- Remove annotation key aliases from source/source.go
- Replace all alias usages with annotations.* references (348 changes in 28 files)
- Add TestMain to existing test files (service_test.go, cloudflare_test.go)

This change makes annotation initialization explicit and predictable,
avoiding hidden global state initialization at import time.

Co-Authored-By: Claude <noreply@anthropic.com>

* docs: update changelog and mkdocs to include annotationPrefix and split horizon DNS

Signed-off-by: Aleksei Sviridkin <f@lex.la>

* docs(split-horizon): fix linting

Signed-off-by: Aleksei Sviridkin <f@lex.la>

* refactor(annotations): replace hardcoded annotation prefix with constant

Replace all hardcoded "external-dns.alpha.kubernetes.io/" strings
with annotations.DefaultAnnotationPrefix constant to establish
a single source of truth.

Changes:
- Add DefaultAnnotationPrefix constant in source/annotations/annotations.go
- Replace hardcoded string in controller/execute.go with constant reference
- Replace hardcoded strings in pkg/apis/externaldns/types.go (2 occurrences)
- Add helm unit tests for annotationPrefix value

This eliminates string duplication and makes future changes easier.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Signed-off-by: Aleksei Sviridkin <f@lex.la>
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-08 03:56:52 -08:00

721 lines
24 KiB
Go

/*
Copyright 2019 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 (
"context"
"fmt"
"maps"
"math/rand"
"testing"
"time"
log "github.com/sirupsen/logrus"
"github.com/sirupsen/logrus/hooks/test"
"github.com/stretchr/testify/mock"
"k8s.io/client-go/kubernetes"
corev1lister "k8s.io/client-go/listers/core/v1"
"k8s.io/client-go/tools/cache"
"sigs.k8s.io/external-dns/internal/testutils"
"sigs.k8s.io/external-dns/source/annotations"
"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 TestNodeSource(t *testing.T) {
t.Parallel()
t.Run("NewNodeSource", testNodeSourceNewNodeSource)
t.Run("Endpoints", testNodeSourceEndpoints)
t.Run("EndpointsIPv6", testNodeEndpointsWithIPv6)
}
// testNodeSourceNewNodeSource tests that NewNodeService doesn't return an error.
func testNodeSourceNewNodeSource(t *testing.T) {
t.Parallel()
for _, ti := 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",
},
{
title: "non-empty annotation filter label",
expectError: false,
annotationFilter: "kubernetes.io/ingress.class=nginx",
},
} {
t.Run(ti.title, func(t *testing.T) {
t.Parallel()
_, err := NewNodeSource(
context.TODO(),
fake.NewClientset(),
ti.annotationFilter,
ti.fqdnTemplate,
labels.Everything(),
true,
true,
false,
)
if ti.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
// testNodeSourceEndpoints tests that various node generate the correct endpoints.
func testNodeSourceEndpoints(t *testing.T) {
for _, tc := range []struct {
title string
annotationFilter string
labelSelector string
fqdnTemplate string
nodeName string
nodeAddresses []v1.NodeAddress
labels map[string]string
annotations map[string]string
excludeUnschedulable bool // default to false
exposeInternalIPv6 bool // default to true for this version. Change later when the next minor version is released.
unschedulable bool // default to false
expected []*endpoint.Endpoint
expectError bool
expectedLogs []string
expectedAbsentLogs []string
}{
{
title: "node with short hostname returns one endpoint",
nodeName: "node1",
exposeInternalIPv6: true,
nodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "1.2.3.4"}},
expected: []*endpoint.Endpoint{
{RecordType: "A", DNSName: "node1", Targets: endpoint.Targets{"1.2.3.4"}},
},
},
{
title: "node with fqdn returns one endpoint",
nodeName: "node1.example.org",
exposeInternalIPv6: true,
nodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "1.2.3.4"}},
expected: []*endpoint.Endpoint{
{RecordType: "A", DNSName: "node1.example.org", Targets: endpoint.Targets{"1.2.3.4"}},
},
},
{
title: "ipv6 node with fqdn returns one endpoint",
nodeName: "node1.example.org",
exposeInternalIPv6: true,
nodeAddresses: []v1.NodeAddress{{Type: v1.NodeInternalIP, Address: "2001:DB8::8"}},
expected: []*endpoint.Endpoint{
{RecordType: "AAAA", DNSName: "node1.example.org", Targets: endpoint.Targets{"2001:DB8::8"}},
},
},
{
title: "node with fqdn template returns endpoint with expanded hostname",
fqdnTemplate: "{{.Name}}.example.org",
nodeName: "node1",
exposeInternalIPv6: true,
nodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "1.2.3.4"}},
expected: []*endpoint.Endpoint{
{RecordType: "A", DNSName: "node1.example.org", Targets: endpoint.Targets{"1.2.3.4"}},
},
},
{
title: "node with fqdn and fqdn template returns one endpoint",
fqdnTemplate: "{{.Name}}.example.org",
nodeName: "node1.example.org",
exposeInternalIPv6: true,
nodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "1.2.3.4"}},
expected: []*endpoint.Endpoint{
{RecordType: "A", DNSName: "node1.example.org.example.org", Targets: endpoint.Targets{"1.2.3.4"}},
},
},
{
title: "node with fqdn template returns two endpoints with multiple IP addresses and expanded hostname",
fqdnTemplate: "{{.Name}}.example.org",
nodeName: "node1",
exposeInternalIPv6: true,
nodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "1.2.3.4"}, {Type: v1.NodeExternalIP, Address: "5.6.7.8"}},
expected: []*endpoint.Endpoint{
{RecordType: "A", DNSName: "node1.example.org", Targets: endpoint.Targets{"1.2.3.4", "5.6.7.8"}},
},
},
{
title: "node with fqdn template returns two endpoints with dual-stack IP addresses and expanded hostname",
fqdnTemplate: "{{.Name}}.example.org",
nodeName: "node1",
exposeInternalIPv6: true,
nodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "1.2.3.4"}, {Type: v1.NodeInternalIP, Address: "2001:DB8::8"}},
expected: []*endpoint.Endpoint{
{RecordType: "A", DNSName: "node1.example.org", Targets: endpoint.Targets{"1.2.3.4"}},
{RecordType: "AAAA", DNSName: "node1.example.org", Targets: endpoint.Targets{"2001:DB8::8"}},
},
},
{
title: "node with both external and internal IP returns an endpoint with external IP",
nodeName: "node1",
exposeInternalIPv6: true,
nodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "1.2.3.4"}, {Type: v1.NodeInternalIP, Address: "2.3.4.5"}},
expected: []*endpoint.Endpoint{
{RecordType: "A", DNSName: "node1", Targets: endpoint.Targets{"1.2.3.4"}},
},
},
{
title: "node with both external, internal, and IPv6 IP returns endpoints with external IPs",
nodeName: "node1",
exposeInternalIPv6: true,
nodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "1.2.3.4"}, {Type: v1.NodeInternalIP, Address: "2.3.4.5"}, {Type: v1.NodeInternalIP, Address: "2001:DB8::8"}},
expected: []*endpoint.Endpoint{
{RecordType: "A", DNSName: "node1", Targets: endpoint.Targets{"1.2.3.4"}},
{RecordType: "AAAA", DNSName: "node1", Targets: endpoint.Targets{"2001:DB8::8"}},
},
},
{
title: "node with only internal IP returns an endpoint with internal IP",
nodeName: "node1",
exposeInternalIPv6: true,
nodeAddresses: []v1.NodeAddress{{Type: v1.NodeInternalIP, Address: "2.3.4.5"}},
expected: []*endpoint.Endpoint{
{RecordType: "A", DNSName: "node1", Targets: endpoint.Targets{"2.3.4.5"}},
},
},
{
title: "node with only internal IPs returns endpoints with internal IPs",
nodeName: "node1",
exposeInternalIPv6: true,
nodeAddresses: []v1.NodeAddress{{Type: v1.NodeInternalIP, Address: "2.3.4.5"}, {Type: v1.NodeInternalIP, Address: "2001:DB8::8"}},
expected: []*endpoint.Endpoint{
{RecordType: "A", DNSName: "node1", Targets: endpoint.Targets{"2.3.4.5"}},
{RecordType: "AAAA", DNSName: "node1", Targets: endpoint.Targets{"2001:DB8::8"}},
},
},
{
title: "node with only internal IPs with expose internal IP as false shouldn't return AAAA endpoints with internal IPs",
nodeName: "node1",
exposeInternalIPv6: false,
nodeAddresses: []v1.NodeAddress{{Type: v1.NodeInternalIP, Address: "2.3.4.5"}, {Type: v1.NodeInternalIP, Address: "2001:DB8::9"}},
expected: []*endpoint.Endpoint{
{RecordType: "A", DNSName: "node1", Targets: endpoint.Targets{"2.3.4.5"}},
{RecordType: "AAAA", DNSName: "node1", Targets: endpoint.Targets{"2001:DB8::9"}},
},
},
{
title: "node with neither external nor internal IP returns no endpoints",
nodeName: "node1",
exposeInternalIPv6: true,
nodeAddresses: []v1.NodeAddress{},
expectError: true,
},
{
title: "node with target annotation",
nodeName: "node1.example.org",
exposeInternalIPv6: true,
nodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "1.2.3.4"}},
annotations: map[string]string{
"external-dns.alpha.kubernetes.io/target": "203.2.45.7",
},
expected: []*endpoint.Endpoint{
{RecordType: "A", DNSName: "node1.example.org", Targets: endpoint.Targets{"203.2.45.7"}},
},
},
{
title: "annotated node without annotation filter returns endpoint",
nodeName: "node1",
exposeInternalIPv6: true,
nodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "1.2.3.4"}},
annotations: map[string]string{
"service.beta.kubernetes.io/external-traffic": "OnlyLocal",
},
expected: []*endpoint.Endpoint{
{RecordType: "A", DNSName: "node1", Targets: endpoint.Targets{"1.2.3.4"}},
},
},
{
title: "annotated node with matching annotation filter returns endpoint",
annotationFilter: "service.beta.kubernetes.io/external-traffic in (Global, OnlyLocal)",
nodeName: "node1",
exposeInternalIPv6: true,
nodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "1.2.3.4"}},
annotations: map[string]string{
"service.beta.kubernetes.io/external-traffic": "OnlyLocal",
},
expected: []*endpoint.Endpoint{
{RecordType: "A", DNSName: "node1", Targets: endpoint.Targets{"1.2.3.4"}},
},
},
{
title: "annotated node with non-matching annotation filter returns nothing",
annotationFilter: "service.beta.kubernetes.io/external-traffic in (Global, OnlyLocal)",
nodeName: "node1",
exposeInternalIPv6: true,
nodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "1.2.3.4"}},
annotations: map[string]string{
"service.beta.kubernetes.io/external-traffic": "SomethingElse",
},
expected: []*endpoint.Endpoint{},
},
{
title: "labeled node with matching label selector returns endpoint",
labelSelector: "service.beta.kubernetes.io/external-traffic in (Global, OnlyLocal)",
nodeName: "node1",
exposeInternalIPv6: true,
nodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "1.2.3.4"}},
labels: map[string]string{
"service.beta.kubernetes.io/external-traffic": "OnlyLocal",
},
expected: []*endpoint.Endpoint{
{RecordType: "A", DNSName: "node1", Targets: endpoint.Targets{"1.2.3.4"}},
},
},
{
title: "labeled node with non-matching label selector returns nothing",
labelSelector: "service.beta.kubernetes.io/external-traffic in (Global, OnlyLocal)",
nodeName: "node1",
exposeInternalIPv6: true,
nodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "1.2.3.4"}},
labels: map[string]string{
"service.beta.kubernetes.io/external-traffic": "SomethingElse",
},
expected: []*endpoint.Endpoint{},
},
{
title: "our controller type is dns-controller",
nodeName: "node1",
exposeInternalIPv6: true,
nodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "1.2.3.4"}},
annotations: map[string]string{
annotations.ControllerKey: annotations.ControllerValue,
},
expected: []*endpoint.Endpoint{
{RecordType: "A", DNSName: "node1", Targets: endpoint.Targets{"1.2.3.4"}},
},
},
{
title: "different controller types are ignored",
nodeName: "node1",
exposeInternalIPv6: true,
nodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "1.2.3.4"}},
annotations: map[string]string{
annotations.ControllerKey: "not-dns-controller",
},
expected: []*endpoint.Endpoint{},
},
{
title: "ttl not annotated should have RecordTTL.IsConfigured set to false",
nodeName: "node1",
exposeInternalIPv6: true,
nodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "1.2.3.4"}},
expected: []*endpoint.Endpoint{
{RecordType: "A", DNSName: "node1", Targets: endpoint.Targets{"1.2.3.4"}, RecordTTL: endpoint.TTL(0)},
},
},
{
title: "ttl annotated but invalid should have RecordTTL.IsConfigured set to false",
nodeName: "node1",
exposeInternalIPv6: true,
nodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "1.2.3.4"}},
annotations: map[string]string{
annotations.TtlKey: "foo",
},
expected: []*endpoint.Endpoint{
{RecordType: "A", DNSName: "node1", Targets: endpoint.Targets{"1.2.3.4"}, RecordTTL: endpoint.TTL(0)},
},
},
{
title: "ttl annotated and is valid should set Record.TTL",
nodeName: "node1",
exposeInternalIPv6: true,
nodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "1.2.3.4"}},
annotations: map[string]string{
annotations.TtlKey: "10",
},
expected: []*endpoint.Endpoint{
{RecordType: "A", DNSName: "node1", Targets: endpoint.Targets{"1.2.3.4"}, RecordTTL: endpoint.TTL(10)},
},
},
{
title: "unschedulable node return nothing with excludeUnschedulable=true",
nodeName: "node1",
exposeInternalIPv6: true,
nodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "1.2.3.4"}},
unschedulable: true,
excludeUnschedulable: true,
expected: []*endpoint.Endpoint{},
expectedLogs: []string{
"Skipping node node1 because it is unschedulable",
},
},
{
title: "unschedulable node returns node with excludeUnschedulable=false",
nodeName: "node1",
nodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "1.2.3.4"}},
unschedulable: true,
excludeUnschedulable: false,
expected: []*endpoint.Endpoint{
{RecordType: "A", DNSName: "node1", Targets: endpoint.Targets{"1.2.3.4"}},
},
expectedAbsentLogs: []string{
"Skipping node node1 because it is unschedulable",
},
},
} {
t.Run(tc.title, func(t *testing.T) {
hook := testutils.LogsUnderTestWithLogLevel(log.DebugLevel, t)
labelSelector := labels.Everything()
if tc.labelSelector != "" {
var err error
labelSelector, err = labels.Parse(tc.labelSelector)
require.NoError(t, err)
}
// Create a Kubernetes testing client
kubeClient := fake.NewClientset()
node := &v1.Node{
ObjectMeta: metav1.ObjectMeta{
Name: tc.nodeName,
Labels: tc.labels,
Annotations: tc.annotations,
},
Spec: v1.NodeSpec{
Unschedulable: tc.unschedulable,
},
Status: v1.NodeStatus{
Addresses: tc.nodeAddresses,
},
}
_, 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(),
kubeClient,
tc.annotationFilter,
tc.fqdnTemplate,
labelSelector,
tc.exposeInternalIPv6,
tc.excludeUnschedulable,
false,
)
require.NoError(t, err)
endpoints, err := client.Endpoints(context.Background())
if tc.expectError {
require.Error(t, err)
} else {
require.NoError(t, err)
}
// Validate returned endpoints against desired endpoints.
validateEndpoints(t, endpoints, tc.expected)
for _, entry := range tc.expectedLogs {
testutils.TestHelperLogContains(entry, hook, t)
}
for _, entry := range tc.expectedAbsentLogs {
testutils.TestHelperLogNotContains(entry, hook, t)
}
})
}
}
func testNodeEndpointsWithIPv6(t *testing.T) {
for _, tc := range []struct {
title string
annotationFilter string
labelSelector string
fqdnTemplate string
nodeName string
nodeAddresses []v1.NodeAddress
labels map[string]string
annotations map[string]string
excludeUnschedulable bool // defaults to false
exposeInternalIPv6 bool // default to true for this version. Change later when the next minor version is released.
unschedulable bool // default to false
expected []*endpoint.Endpoint
expectError bool
}{
{
title: "node with only internal IPs should return internal IPvs irrespective of exposeInternalIPv6",
nodeName: "node1",
exposeInternalIPv6: false,
nodeAddresses: []v1.NodeAddress{{Type: v1.NodeInternalIP, Address: "2.3.4.5"}, {Type: v1.NodeInternalIP, Address: "2001:DB8::9"}},
expected: []*endpoint.Endpoint{
{RecordType: "A", DNSName: "node1", Targets: endpoint.Targets{"2.3.4.5"}},
{RecordType: "AAAA", DNSName: "node1", Targets: endpoint.Targets{"2001:DB8::9"}},
},
},
{
title: "node with both external, internal, and IPv6 IP returns endpoints with external IPs",
nodeName: "node1",
exposeInternalIPv6: false,
nodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "1.2.3.4"}, {
Type: v1.NodeExternalIP,
Address: "2001:DB8::8",
}, {Type: v1.NodeInternalIP, Address: "2001:DB8::9"}},
expected: []*endpoint.Endpoint{
{RecordType: "A", DNSName: "node1", Targets: endpoint.Targets{"1.2.3.4"}},
{RecordType: "AAAA", DNSName: "node1", Targets: endpoint.Targets{"2001:DB8::8"}},
},
},
{
title: "node with both external and internal IPs should return internal IPv6 if exposeInternalIPv6 is true",
nodeName: "node1",
exposeInternalIPv6: true,
nodeAddresses: []v1.NodeAddress{
{Type: v1.NodeExternalIP, Address: "1.2.3.5"},
{Type: v1.NodeInternalIP, Address: "2001:DB8::9"},
},
expected: []*endpoint.Endpoint{
{RecordType: "A", DNSName: "node1", Targets: endpoint.Targets{"1.2.3.5"}},
{RecordType: "AAAA", DNSName: "node1", Targets: endpoint.Targets{"2001:DB8::9"}},
},
},
} {
labelSelector := labels.Everything()
if tc.labelSelector != "" {
var err error
labelSelector, err = labels.Parse(tc.labelSelector)
require.NoError(t, err)
}
// Create a Kubernetes testing client
kubeClient := fake.NewClientset()
node := &v1.Node{
ObjectMeta: metav1.ObjectMeta{
Name: tc.nodeName,
Labels: tc.labels,
Annotations: tc.annotations,
},
Spec: v1.NodeSpec{
Unschedulable: tc.unschedulable,
},
Status: v1.NodeStatus{
Addresses: tc.nodeAddresses,
},
}
_, err := kubeClient.CoreV1().Nodes().Create(t.Context(), node, metav1.CreateOptions{})
require.NoError(t, err)
var hook *test.Hook
if tc.exposeInternalIPv6 {
hook = testutils.LogsUnderTestWithLogLevel(log.WarnLevel, t)
}
// Create our object under test and get the endpoints.
client, err := NewNodeSource(
t.Context(),
kubeClient,
tc.annotationFilter,
tc.fqdnTemplate,
labelSelector,
tc.exposeInternalIPv6,
tc.excludeUnschedulable,
false,
)
require.NoError(t, err)
endpoints, err := client.Endpoints(t.Context())
if tc.expectError {
require.Error(t, err)
} else {
require.NoError(t, err)
if tc.exposeInternalIPv6 && hook != nil {
testutils.TestHelperLogContainsWithLogLevel(warningMsg, log.WarnLevel, hook, t)
}
}
// Validate returned endpoints against desired endpoints.
validateEndpoints(t, endpoints, tc.expected)
// TODO; when all resources have the resource label, we could add this check to the validateEndpoints function.
for _, ep := range endpoints {
require.Contains(t, ep.Labels, endpoint.ResourceLabelKey)
}
}
}
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,
false,
)
require.NoError(t, err)
got, err := client.Endpoints(t.Context())
require.NoError(t, err)
for _, ep := range got {
assert.NotEmpty(t, ep.Labels, "Labels should not be empty for endpoint %s", ep.DNSName)
assert.Contains(t, ep.Labels, endpoint.ResourceLabelKey)
}
}
func TestNodeSource_AddEventHandler(t *testing.T) {
fakeInformer := new(fakeNodeInformer)
inf := testInformer{}
fakeInformer.On("Informer").Return(&inf)
nSource := &nodeSource{
nodeInformer: fakeInformer,
}
handlerCalled := false
handler := func() { handlerCalled = true }
nSource.AddEventHandler(t.Context(), handler)
fakeInformer.AssertNumberOfCalls(t, "Informer", 1)
assert.False(t, handlerCalled)
assert.Equal(t, 1, inf.times)
}
type fakeNodeInformer struct {
mock.Mock
informer cache.SharedIndexInformer
}
func (f *fakeNodeInformer) Informer() cache.SharedIndexInformer {
args := f.Called()
return args.Get(0).(cache.SharedIndexInformer)
}
func (f *fakeNodeInformer) Lister() corev1lister.NodeLister {
return corev1lister.NewNodeLister(f.Informer().GetIndexer())
}
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}
}