external-dns/source/unstructured_test.go
Ivan Ka d217706973
refactor(fqdn): encapsulate FQDN template logic into TemplateEngine (#6292)
* refactor(source): extract FQDN template logic into fqdn.TemplateEngine

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(source): extract FQDN template logic into fqdn.TemplateEngine

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(source): extract FQDN template logic into fqdn.TemplateEngine

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(source): extract FQDN template logic into fqdn.TemplateEngine

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(source): extract FQDN template logic into fqdn.TemplateEngine

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(source): extract FQDN template logic into fqdn.TemplateEngine

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(source): extract FQDN template logic into fqdn.TemplateEngine

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(source): extract FQDN template logic into fqdn.TemplateEngine

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(source): extract FQDN template logic into fqdn.TemplateEngine

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(source): extract FQDN template logic into fqdn.TemplateEngine

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(source): extract FQDN template logic into fqdn.TemplateEngine

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(source): extract FQDN template logic into fqdn.TemplateEngine

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(fqdn): encapsulate FQDN template logic into TemplateEngine

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(fqdn): encapsulate FQDN template logic into TemplateEngine

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* efactor(fqdn): encapsulate FQDN template logic into TemplateEngine

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(fqdn): encapsulate FQDN template logic into TemplateEngine

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(fqdn): encapsulate FQDN template logic into TemplateEngine

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(fqdn): encapsulate FQDN template logic into TemplateEngine

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(fqdn): encapsulate FQDN template logic into TemplateEngine

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(fqdn): encapsulate FQDN template logic into TemplateEngine

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(fqdn): encapsulate FQDN template logic into TemplateEngine

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(fqdn): encapsulate FQDN template logic into TemplateEngine

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

---------

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>
2026-03-23 19:40:19 +05:30

643 lines
19 KiB
Go

/*
Copyright 2026 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 (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
discoveryfake "k8s.io/client-go/discovery/fake"
"k8s.io/client-go/dynamic"
dynamicfake "k8s.io/client-go/dynamic/fake"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/kubernetes/fake"
"sigs.k8s.io/external-dns/internal/testutils"
"sigs.k8s.io/external-dns/source/types"
"sigs.k8s.io/external-dns/endpoint"
"sigs.k8s.io/external-dns/source/annotations"
templatetest "sigs.k8s.io/external-dns/source/template/testutil"
)
func TestUnstructuredWrapperImplementsKubeObject(t *testing.T) {
u := &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "kubevirt.io/v1",
"kind": "VirtualMachineInstance",
"metadata": map[string]any{
"name": "test-vm",
"namespace": "default",
"labels": map[string]any{
"app": "test",
},
},
},
}
wrapped := newUnstructuredWrapper(u)
assert.Equal(t, "test-vm", wrapped.Name)
assert.Equal(t, "default", wrapped.Namespace)
assert.Equal(t, "VirtualMachineInstance", wrapped.Kind)
assert.Equal(t, "kubevirt.io/v1", wrapped.APIVersion)
assert.Equal(t, map[string]string{"app": "test"}, wrapped.Labels)
assert.Equal(t, "test-vm", wrapped.GetName())
assert.Equal(t, "default", wrapped.GetNamespace())
assert.Same(t, u, wrapped.Unstructured)
// Verify it implements runtime.Object via embedding
gvk := wrapped.GetObjectKind().GroupVersionKind()
assert.Equal(t, "VirtualMachineInstance", gvk.Kind)
}
func TestUnstructured_DifferentScenarios(t *testing.T) {
type cfg struct {
resources []string
labelSelector string
annotationFilter string
combine bool
}
for _, tt := range []struct {
title string
cfg cfg
objects []*unstructured.Unstructured
expected []*endpoint.Endpoint
}{
{
title: "read from annotations with IPv6 target",
cfg: cfg{
resources: []string{"virtualmachineinstances.v1.kubevirt.io"},
},
objects: []*unstructured.Unstructured{
{
Object: map[string]any{
"apiVersion": "kubevirt.io/v1",
"kind": "VirtualMachineInstance",
"metadata": map[string]any{
"name": "my-vm",
"namespace": "default",
"annotations": map[string]any{
annotations.HostnameKey: "my-vm.example.com",
annotations.TargetKey: "::1234:5678",
},
},
},
},
},
expected: []*endpoint.Endpoint{
endpoint.NewEndpoint("my-vm.example.com", endpoint.RecordTypeAAAA, "::1234:5678").
WithLabel(endpoint.ResourceLabelKey, "virtualmachineinstance/default/my-vm"),
},
},
{
title: "rancher node with ttl",
cfg: cfg{
resources: []string{"nodes.v3.management.cattle.io"},
},
objects: []*unstructured.Unstructured{
{
Object: map[string]any{
"apiVersion": "management.cattle.io/v3",
"kind": "Node",
"metadata": map[string]any{
"name": "my-node-1",
"namespace": "cattle-system",
"labels": map[string]any{
"cattle.io/creator": "norman",
"node-role.kubernetes.io/controlplane": "true",
},
"annotations": map[string]any{
annotations.HostnameKey: "my-node-1.nodes.example.com",
annotations.TargetKey: "203.0.113.10",
annotations.TtlKey: "300",
},
},
"spec": map[string]any{
"clusterName": "c-abcde",
"hostname": "my-node-1",
},
},
},
},
expected: []*endpoint.Endpoint{
endpoint.NewEndpointWithTTL("my-node-1.nodes.example.com", endpoint.RecordTypeA, 300, "203.0.113.10").
WithLabel(endpoint.ResourceLabelKey, "node/cattle-system/my-node-1"),
},
},
{
title: "with controller annotations match",
cfg: cfg{
resources: []string{"replicationgroups.v1.elasticache.upbound.io"},
},
objects: []*unstructured.Unstructured{
{
Object: map[string]any{
"apiVersion": "elasticache.upbound.io/v1",
"kind": "ReplicationGroup",
"metadata": map[string]any{
"name": "cache",
"namespace": "default",
"annotations": map[string]any{
annotations.HostnameKey: "my-vm.redis.tld",
annotations.TargetKey: "1.1.1.0",
annotations.ControllerKey: annotations.ControllerValue,
},
},
},
},
},
expected: []*endpoint.Endpoint{
endpoint.NewEndpoint("my-vm.redis.tld", endpoint.RecordTypeA, "1.1.1.0").
WithLabel(endpoint.ResourceLabelKey, "replicationgroup/default/cache"),
},
},
{
title: "with controller annotations do not match",
cfg: cfg{
resources: []string{"replicationgroups.v1.elasticache.upbound.io"},
},
objects: []*unstructured.Unstructured{
{
Object: map[string]any{
"apiVersion": "elasticache.upbound.io/v1",
"kind": "ReplicationGroup",
"metadata": map[string]any{
"name": "my-vm",
"namespace": "default",
"annotations": map[string]any{
annotations.HostnameKey: "my-vm.redis.tld",
annotations.TargetKey: "10.10.10.0",
annotations.ControllerKey: "custom-controller",
},
},
},
},
},
expected: []*endpoint.Endpoint{},
},
{
title: "labelSelector matches",
cfg: cfg{
resources: []string{"virtualmachineinstances.v1.kubevirt.io"},
labelSelector: "env=prod",
},
objects: []*unstructured.Unstructured{
{
Object: map[string]any{
"apiVersion": "kubevirt.io/v1",
"kind": "VirtualMachineInstance",
"metadata": map[string]any{
"name": "prod-vm",
"namespace": "default",
"labels": map[string]any{
"env": "prod",
},
"annotations": map[string]any{
annotations.HostnameKey: "prod-vm.example.com",
annotations.TargetKey: "10.0.0.1",
},
},
},
},
{
Object: map[string]any{
"apiVersion": "kubevirt.io/v1",
"kind": "VirtualMachineInstance",
"metadata": map[string]any{
"name": "dev-vm",
"namespace": "default",
"labels": map[string]any{
"env": "dev",
},
"annotations": map[string]any{
annotations.HostnameKey: "dev-vm.example.com",
annotations.TargetKey: "10.0.0.2",
},
},
},
},
},
expected: []*endpoint.Endpoint{
endpoint.NewEndpoint("prod-vm.example.com", endpoint.RecordTypeA, "10.0.0.1").
WithLabel(endpoint.ResourceLabelKey, "virtualmachineinstance/default/prod-vm"),
},
},
{
title: "labelSelector no match",
cfg: cfg{
resources: []string{"virtualmachineinstances.v1.kubevirt.io"},
labelSelector: "env=staging",
},
objects: []*unstructured.Unstructured{
{
Object: map[string]any{
"apiVersion": "kubevirt.io/v1",
"kind": "VirtualMachineInstance",
"metadata": map[string]any{
"name": "prod-vm",
"namespace": "default",
"labels": map[string]any{
"env": "prod",
},
"annotations": map[string]any{
annotations.HostnameKey: "prod-vm.example.com",
annotations.TargetKey: "10.0.0.1",
},
},
},
},
},
expected: []*endpoint.Endpoint{},
},
{
title: "annotationFilter matches",
cfg: cfg{
resources: []string{"virtualmachineinstances.v1.kubevirt.io"},
annotationFilter: "team=platform",
},
objects: []*unstructured.Unstructured{
{
Object: map[string]any{
"apiVersion": "kubevirt.io/v1",
"kind": "VirtualMachineInstance",
"metadata": map[string]any{
"name": "platform-vm",
"namespace": "default",
"annotations": map[string]any{
"team": "platform",
annotations.HostnameKey: "platform-vm.example.com",
annotations.TargetKey: "10.0.0.1",
},
},
},
},
{
Object: map[string]any{
"apiVersion": "kubevirt.io/v1",
"kind": "VirtualMachineInstance",
"metadata": map[string]any{
"name": "backend-vm",
"namespace": "default",
"annotations": map[string]any{
"team": "backend",
annotations.HostnameKey: "backend-vm.example.com",
annotations.TargetKey: "10.0.0.2",
},
},
},
},
},
expected: []*endpoint.Endpoint{
endpoint.NewEndpoint("platform-vm.example.com", endpoint.RecordTypeA, "10.0.0.1").
WithLabel(endpoint.ResourceLabelKey, "virtualmachineinstance/default/platform-vm"),
},
},
{
title: "annotationFilter no match",
cfg: cfg{
resources: []string{"virtualmachineinstances.v1.kubevirt.io"},
annotationFilter: "team=security",
},
objects: []*unstructured.Unstructured{
{
Object: map[string]any{
"apiVersion": "kubevirt.io/v1",
"kind": "VirtualMachineInstance",
"metadata": map[string]any{
"name": "platform-vm",
"namespace": "default",
"annotations": map[string]any{
"team": "platform",
annotations.HostnameKey: "platform-vm.example.com",
annotations.TargetKey: "10.0.0.1",
},
},
},
},
},
expected: []*endpoint.Endpoint{},
},
{
title: "labelSelector and annotationFilter combined",
cfg: cfg{
resources: []string{"virtualmachineinstances.v1.kubevirt.io"},
labelSelector: "env=prod",
annotationFilter: "team=platform",
},
objects: []*unstructured.Unstructured{
{
Object: map[string]any{
"apiVersion": "kubevirt.io/v1",
"kind": "VirtualMachineInstance",
"metadata": map[string]any{
"name": "prod-platform-vm",
"namespace": "default",
"labels": map[string]any{
"env": "prod",
},
"annotations": map[string]any{
"team": "platform",
annotations.HostnameKey: "prod-platform-vm.example.com",
annotations.TargetKey: "10.0.0.1",
},
},
},
},
{
Object: map[string]any{
"apiVersion": "kubevirt.io/v1",
"kind": "VirtualMachineInstance",
"metadata": map[string]any{
"name": "prod-backend-vm",
"namespace": "default",
"labels": map[string]any{
"env": "prod",
},
"annotations": map[string]any{
"team": "backend",
annotations.HostnameKey: "prod-backend-vm.example.com",
annotations.TargetKey: "10.0.0.2",
},
},
},
},
{
Object: map[string]any{
"apiVersion": "kubevirt.io/v1",
"kind": "VirtualMachineInstance",
"metadata": map[string]any{
"name": "dev-platform-vm",
"namespace": "default",
"labels": map[string]any{
"env": "dev",
},
"annotations": map[string]any{
"team": "platform",
annotations.HostnameKey: "dev-platform-vm.example.com",
annotations.TargetKey: "10.0.0.3",
},
},
},
},
},
expected: []*endpoint.Endpoint{
endpoint.NewEndpoint("prod-platform-vm.example.com", endpoint.RecordTypeA, "10.0.0.1").
WithLabel(endpoint.ResourceLabelKey, "virtualmachineinstance/default/prod-platform-vm"),
},
},
{
title: "provider-specific annotation is not supported and is ignored",
cfg: cfg{
resources: []string{"machines.v1beta1.cluster.x-k8s.io"},
},
objects: []*unstructured.Unstructured{
{
Object: map[string]any{
"apiVersion": "cluster.x-k8s.io/v1beta1",
"kind": "Machine",
"metadata": map[string]any{
"name": "control-plane",
"namespace": "default",
"labels": map[string]any{
"cluster.x-k8s.io/cluster-name": "test-cluster",
"cluster.x-k8s.io/control-plane": "",
},
"annotations": map[string]any{
annotations.HostnameKey: "control-plane.example.com",
annotations.TargetKey: "10.0.0.1",
annotations.CloudflarePrefix: "cloudflare-specific-annotation",
},
},
"spec": map[string]any{
"clusterName": "test-cluster",
"bootstrap": map[string]any{
"dataSecretName": "control-plane-bootstrap",
},
"version": "v1.26.0",
},
},
},
},
expected: []*endpoint.Endpoint{
endpoint.NewEndpoint("control-plane.example.com", endpoint.RecordTypeA, "10.0.0.1").
WithLabel(endpoint.ResourceLabelKey, "machine/default/control-plane"),
},
},
} {
t.Run(tt.title, func(t *testing.T) {
kubeClient, dynamicClient := setupUnstructuredTestClients(t, tt.cfg.resources, tt.objects)
labelSelector := labels.Everything()
if tt.cfg.labelSelector != "" {
var err error
labelSelector, err = labels.Parse(tt.cfg.labelSelector)
require.NoError(t, err)
}
src, err := NewUnstructuredFQDNSource(
t.Context(),
dynamicClient,
kubeClient,
&Config{
AnnotationFilter: tt.cfg.annotationFilter,
LabelFilter: labelSelector,
UnstructuredResources: tt.cfg.resources,
TemplateEngine: templatetest.MustEngine(t, "", "", "", tt.cfg.combine),
},
)
require.NoError(t, err)
endpoints, err := src.Endpoints(t.Context())
require.NoError(t, err)
testutils.ValidateEndpoints(t, endpoints, tt.expected)
for _, ep := range endpoints {
require.Contains(t, ep.Labels, endpoint.ResourceLabelKey)
}
})
}
}
func TestProcessEndpoint_Unstructured_RefObjectExist(t *testing.T) {
resources := []string{"virtualmachineinstances.v1.kubevirt.io"}
objects := []*unstructured.Unstructured{
{
Object: map[string]any{
"apiVersion": "kubevirt.io/v1",
"kind": "VirtualMachineInstance",
"metadata": map[string]any{
"name": "prod-platform-vm",
"namespace": "default",
"labels": map[string]any{
"env": "prod",
},
"annotations": map[string]any{
"team": "platform",
annotations.HostnameKey: "prod-platform-vm.example.com",
annotations.TargetKey: "10.0.0.1",
},
"uid": "12345",
},
},
},
}
kubeClient, dynamicClient := setupUnstructuredTestClients(t, resources, objects)
src, err := NewUnstructuredFQDNSource(
t.Context(),
dynamicClient,
kubeClient,
&Config{
LabelFilter: labels.Everything(),
UnstructuredResources: resources,
},
)
require.NoError(t, err)
endpoints, err := src.Endpoints(t.Context())
require.NoError(t, err)
testutils.AssertEndpointsHaveRefObject(t, endpoints, types.Unstructured, len(objects))
}
func TestEndpointsForHostsAndTargets(t *testing.T) {
tests := []struct {
name string
hostnames []string
targets []string
expected []*endpoint.Endpoint
}{
{
name: "empty hostnames returns nil",
hostnames: []string{},
targets: []string{"192.168.1.1"},
expected: nil,
},
{
name: "empty targets returns nil",
hostnames: []string{"example.com"},
targets: []string{},
expected: nil,
},
{
name: "duplicate hostname with IPv4 and IPv6 targets",
hostnames: []string{"example.com", "example.com"},
targets: []string{"192.168.1.1", "192.168.1.1", "2001:db8::1"},
expected: []*endpoint.Endpoint{
endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "192.168.1.1"),
endpoint.NewEndpoint("example.com", endpoint.RecordTypeAAAA, "2001:db8::1"),
},
},
{
name: "multiple hostnames with single target",
hostnames: []string{"example.com", "www.example.com"},
targets: []string{"192.168.1.1"},
expected: []*endpoint.Endpoint{
endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "192.168.1.1"),
endpoint.NewEndpoint("www.example.com", endpoint.RecordTypeA, "192.168.1.1"),
},
},
{
name: "multiple of each type maintains grouping",
hostnames: []string{"example.com"},
targets: []string{"192.168.1.1", "192.168.1.2", "2001:db8::1", "2001:db8::2", "a.example.com", "b.example.com"},
expected: []*endpoint.Endpoint{
endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "192.168.1.1", "192.168.1.2"),
endpoint.NewEndpoint("example.com", endpoint.RecordTypeAAAA, "2001:db8::1", "2001:db8::2"),
endpoint.NewEndpoint("example.com", endpoint.RecordTypeCNAME, "a.example.com", "b.example.com"),
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := EndpointsForHostsAndTargets(tc.hostnames, tc.targets)
if tc.expected == nil {
assert.Nil(t, result)
return
}
testutils.ValidateEndpoints(t, result, tc.expected)
})
}
}
// setupUnstructuredTestClients creates fake kube and dynamic clients with the given resources and objects.
func setupUnstructuredTestClients(t *testing.T, resources []string, objects []*unstructured.Unstructured) (
kubernetes.Interface, dynamic.Interface,
) {
t.Helper()
// Parse all resource identifiers and build apiVersion → GVR map in one pass
gvrs := make([]schema.GroupVersionResource, 0, len(resources))
apiVersionToGVR := make(map[string]schema.GroupVersionResource, len(resources))
for _, res := range resources {
if strings.Count(res, ".") == 1 {
res += "."
}
gvr, _ := schema.ParseResourceArg(res)
require.NotNil(t, gvr, "invalid resource identifier: %s", res)
gvrs = append(gvrs, *gvr)
apiVersionToGVR[gvr.GroupVersion().String()] = *gvr
}
// Derive kind and list kind from objects
gvrToKind := make(map[schema.GroupVersionResource]string, len(gvrs))
gvrToListKind := make(map[schema.GroupVersionResource]string, len(gvrs))
for _, obj := range objects {
if gvr, ok := apiVersionToGVR[obj.GetAPIVersion()]; ok {
gvrToKind[gvr] = obj.GetKind()
gvrToListKind[gvr] = obj.GetKind() + "List"
}
}
// Build discovery resource lists
apiResourceLists := make([]*metav1.APIResourceList, 0, len(gvrs))
for _, gvr := range gvrs {
apiResourceLists = append(apiResourceLists, &metav1.APIResourceList{
GroupVersion: gvr.GroupVersion().String(),
APIResources: []metav1.APIResource{{
Name: gvr.Resource,
Namespaced: true,
Kind: gvrToKind[gvr],
}},
})
}
kubeClient := fake.NewClientset()
kubeClient.Discovery().(*discoveryfake.FakeDiscovery).Resources = apiResourceLists
dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), gvrToListKind)
for _, obj := range objects {
gvr, ok := apiVersionToGVR[obj.GetAPIVersion()]
require.True(t, ok, "no resource found for apiVersion %s", obj.GetAPIVersion())
_, err := dynamicClient.Resource(gvr).Namespace(obj.GetNamespace()).Create(
t.Context(), obj, metav1.CreateOptions{})
require.NoError(t, err)
}
return kubeClient, dynamicClient
}