external-dns/source/informers/transformers_test.go
Ivan Ka ea4d2d1681
refactor(source/crd): migrate CRD source to controller-runtime cache (#6312)
* refactor(source/crd): migrate CRD source to controller-runtime cache

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

* refactor(source/crd): migrate CRD source to controller-runtime cache

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

* refactor(source/crd): migrate CRD source to controller-runtime cache

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

* refactor(source/crd): migrate CRD source to controller-runtime cache

Co-authored-by: vflaux <38909103+vflaux@users.noreply.github.com>

* refactor(source/crd): migrate CRD source to controller-runtime cache

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

* refactor(source/crd): migrate CRD source to controller-runtime cache

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

* refactor(source/crd): migrate CRD source to controller-runtime cache

Co-authored-by: vflaux <38909103+vflaux@users.noreply.github.com>

* refactor(source/crd): migrate CRD source to controller-runtime cache

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

* refactor(source/crd): migrate CRD source to controller-runtime cache

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

* refactor(source/crd): migrate CRD source to controller-runtime cache

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

* refactor(source/crd): migrate CRD source to controller-runtime cache

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

* refactor(source/crd): migrate CRD source to controller-runtime cache

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

* refactor(source/crd): migrate CRD source to controller-runtime cache

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

* refactor(source/crd): migrate CRD source to controller-runtime cache

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

* refactor(source/crd): migrate CRD source to controller-runtime cache

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

* refactor(source/crd): migrate CRD source to controller-runtime cache

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

* refactor(source/crd): migrate CRD source to controller-runtime cache

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

* refactor(source/crd): migrate CRD source to controller-runtime cache

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

---------

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>
Co-authored-by: vflaux <38909103+vflaux@users.noreply.github.com>
2026-03-29 00:14:12 +05:30

395 lines
14 KiB
Go

/*
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 informers
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
networkingv1beta1 "istio.io/client-go/pkg/apis/networking/v1beta1"
corev1 "k8s.io/api/core/v1"
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/schema"
kubeinformers "k8s.io/client-go/informers"
"k8s.io/client-go/kubernetes/fake"
)
func TestTransformRemoveManagedFields(t *testing.T) {
t.Run("removes managed fields from Service", func(t *testing.T) {
svc := fakeService()
require.NotEmpty(t, svc.ManagedFields)
transform := TransformerWithOptions[*corev1.Service](TransformRemoveManagedFields())
got, err := transform(svc)
require.NoError(t, err)
result := got.(*corev1.Service)
assert.Empty(t, result.ManagedFields)
// unrelated fields must be preserved
assert.NotEmpty(t, result.Name)
assert.NotEmpty(t, result.Spec.Selector)
assert.NotEmpty(t, result.Status.LoadBalancer.Ingress)
})
t.Run("removes managed fields from Pod", func(t *testing.T) {
pod := fakePod()
require.NotEmpty(t, pod.ManagedFields)
transform := TransformerWithOptions[*corev1.Pod](TransformRemoveManagedFields())
got, err := transform(pod)
require.NoError(t, err)
result := got.(*corev1.Pod)
assert.Empty(t, result.ManagedFields)
assert.NotEmpty(t, result.Name)
assert.NotEmpty(t, result.Spec.NodeName)
})
t.Run("idempotent when managed fields already nil", func(t *testing.T) {
svc := fakeService()
svc.ManagedFields = nil
transform := TransformerWithOptions[*corev1.Service](TransformRemoveManagedFields())
got, err := transform(svc)
require.NoError(t, err)
assert.Empty(t, got.(*corev1.Service).ManagedFields)
})
}
func TestTransformRemoveLastAppliedConfig(t *testing.T) {
t.Run("removes last-applied-configuration annotation", func(t *testing.T) {
svc := fakeService()
require.Contains(t, svc.Annotations, corev1.LastAppliedConfigAnnotation)
transform := TransformerWithOptions[*corev1.Service](TransformRemoveLastAppliedConfig())
got, err := transform(svc)
require.NoError(t, err)
result := got.(*corev1.Service)
assert.NotContains(t, result.Annotations, corev1.LastAppliedConfigAnnotation)
// other annotations must survive
assert.Contains(t, result.Annotations, "description")
assert.Contains(t, result.Annotations, "external-dns.alpha.kubernetes.io/hostname")
})
t.Run("idempotent when annotation is absent", func(t *testing.T) {
svc := fakeService()
delete(svc.Annotations, corev1.LastAppliedConfigAnnotation)
transform := TransformerWithOptions[*corev1.Service](TransformRemoveLastAppliedConfig())
got, err := transform(svc)
require.NoError(t, err)
assert.NotContains(t, got.(*corev1.Service).Annotations, corev1.LastAppliedConfigAnnotation)
})
}
func TestTransformRemoveStatusConditions(t *testing.T) {
t.Run("removes conditions from Service", func(t *testing.T) {
svc := fakeService()
require.NotEmpty(t, svc.Status.Conditions)
transform := TransformerWithOptions[*corev1.Service](TransformRemoveStatusConditions())
got, err := transform(svc)
require.NoError(t, err)
result := got.(*corev1.Service)
assert.Empty(t, result.Status.Conditions)
// unrelated status fields must be preserved
assert.NotEmpty(t, result.Status.LoadBalancer.Ingress)
})
t.Run("removes conditions from Pod", func(t *testing.T) {
pod := fakePod()
pod.Status.Conditions = []corev1.PodCondition{
{Type: corev1.PodReady, Status: corev1.ConditionTrue},
}
require.NotEmpty(t, pod.Status.Conditions)
transform := TransformerWithOptions[*corev1.Pod](TransformRemoveStatusConditions())
got, err := transform(pod)
require.NoError(t, err)
assert.Empty(t, got.(*corev1.Pod).Status.Conditions)
})
t.Run("removes conditions from Node", func(t *testing.T) {
node := fakeNode()
require.NotEmpty(t, node.Status.Conditions)
transform := TransformerWithOptions[*corev1.Node](TransformRemoveStatusConditions())
got, err := transform(node)
require.NoError(t, err)
result := got.(*corev1.Node)
assert.Empty(t, result.Status.Conditions)
// Status.Addresses must be preserved
assert.NotEmpty(t, result.Status.Addresses)
})
t.Run("no-op when conditions are already empty", func(t *testing.T) {
svc := fakeService()
svc.Status.Conditions = nil
transform := TransformerWithOptions[*corev1.Service](TransformRemoveStatusConditions())
got, err := transform(svc)
require.NoError(t, err)
assert.Empty(t, got.(*corev1.Service).Status.Conditions)
})
}
func TestTransformKeepAnnotationPrefix(t *testing.T) {
t.Run("keeps only matching prefix", func(t *testing.T) {
pod := fakePod()
require.Len(t, pod.Annotations, 3)
transform := TransformerWithOptions[*corev1.Pod](TransformKeepAnnotationPrefix("external-dns.alpha.kubernetes.io/"))
got, err := transform(pod)
require.NoError(t, err)
result := got.(*corev1.Pod)
assert.Equal(t, map[string]string{
"external-dns.alpha.kubernetes.io/hostname": "pod.example.com",
}, result.Annotations)
})
t.Run("multiple prefixes use OR logic", func(t *testing.T) {
pod := fakePod()
transform := TransformerWithOptions[*corev1.Pod](
TransformKeepAnnotationPrefix("external-dns.alpha.kubernetes.io/"),
TransformKeepAnnotationPrefix("unrelated.io/"),
)
got, err := transform(pod)
require.NoError(t, err)
result := got.(*corev1.Pod)
assert.Contains(t, result.Annotations, "external-dns.alpha.kubernetes.io/hostname")
assert.Contains(t, result.Annotations, "unrelated.io/annotation")
assert.NotContains(t, result.Annotations, corev1.LastAppliedConfigAnnotation)
})
t.Run("nil annotations map is left unchanged", func(t *testing.T) {
pod := fakePod()
pod.Annotations = nil
transform := TransformerWithOptions[*corev1.Pod](TransformKeepAnnotationPrefix("external-dns.alpha.kubernetes.io/"))
got, err := transform(pod)
require.NoError(t, err)
assert.Nil(t, got.(*corev1.Pod).Annotations)
})
}
func TestTransformRequireAnnotation(t *testing.T) {
t.Run("matching selector keeps object", func(t *testing.T) {
svc := fakeService() // annotations include external-dns.alpha.kubernetes.io/hostname=example.com
sel, err := labels.Parse("external-dns.alpha.kubernetes.io/hostname=example.com")
require.NoError(t, err)
transform := TransformerWithOptions[*corev1.Service](TransformRequireAnnotation(sel))
got, err := transform(svc)
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, svc.Name, got.(*corev1.Service).Name)
})
t.Run("non-matching selector drops object", func(t *testing.T) {
svc := fakeService()
sel, err := labels.Parse("external-dns.alpha.kubernetes.io/hostname=other.com")
require.NoError(t, err)
transform := TransformerWithOptions[*corev1.Service](TransformRequireAnnotation(sel))
got, err := transform(svc)
require.NoError(t, err)
assert.Nil(t, got)
})
t.Run("nil selector is a no-op", func(t *testing.T) {
svc := fakeService()
transform := TransformerWithOptions[*corev1.Service](TransformRequireAnnotation(nil))
got, err := transform(svc)
require.NoError(t, err)
assert.NotNil(t, got)
})
t.Run("empty selector is a no-op", func(t *testing.T) {
svc := fakeService()
transform := TransformerWithOptions[*corev1.Service](TransformRequireAnnotation(labels.Everything()))
got, err := transform(svc)
require.NoError(t, err)
assert.NotNil(t, got)
})
t.Run("drops object after annotation mutation (simulates MODIFIED event)", func(t *testing.T) {
sel, err := labels.Parse("external-dns.alpha.kubernetes.io/hostname=example.com")
require.NoError(t, err)
transform := TransformerWithOptions[*corev1.Service](TransformRequireAnnotation(sel))
// First call: annotation matches, object is admitted.
svc := fakeService() // annotations include external-dns.alpha.kubernetes.io/hostname=example.com
got, err := transform(svc)
require.NoError(t, err)
require.NotNil(t, got)
// Annotation mutates — simulate MODIFIED event with new value.
svc.Annotations["external-dns.alpha.kubernetes.io/hostname"] = "other.com"
got, err = transform(svc)
require.NoError(t, err)
assert.Nil(t, got, "mutated object must be dropped as a local guard")
})
}
func TestTransformerWithOptions_Combined(t *testing.T) {
svc := fakeService()
transform := TransformerWithOptions[*corev1.Service](
TransformRemoveManagedFields(),
TransformRemoveLastAppliedConfig(),
TransformRemoveStatusConditions(),
TransformKeepAnnotationPrefix("external-dns.alpha.kubernetes.io/"),
)
got, err := transform(svc)
require.NoError(t, err)
result := got.(*corev1.Service)
assert.Empty(t, result.ManagedFields)
assert.Empty(t, result.Status.Conditions)
assert.NotContains(t, result.Annotations, corev1.LastAppliedConfigAnnotation)
assert.NotContains(t, result.Annotations, "description")
assert.Contains(t, result.Annotations, "external-dns.alpha.kubernetes.io/hostname")
// Spec and remaining Status fields are fully preserved
assert.NotEmpty(t, result.Spec.Selector)
assert.NotEmpty(t, result.Spec.ExternalIPs)
assert.NotEmpty(t, result.Status.LoadBalancer.Ingress)
}
func TestTransformerWithOptions_TypeMismatch(t *testing.T) {
t.Run("non-matching type returns nil", func(t *testing.T) {
transform := TransformerWithOptions[*corev1.Service](TransformRemoveManagedFields())
got, err := transform(fakePod())
require.NoError(t, err)
assert.Nil(t, got)
})
t.Run("non-matching primitive returns nil", func(t *testing.T) {
transform := TransformerWithOptions[*corev1.Service]()
got, err := transform("not-a-service")
require.NoError(t, err)
assert.Nil(t, got)
})
}
func TestTransformerWithOptions_Idempotent(t *testing.T) {
svc := fakeService()
transform := TransformerWithOptions[*corev1.Service](
TransformRemoveManagedFields(),
TransformRemoveLastAppliedConfig(),
TransformRemoveStatusConditions(),
)
first, err := transform(svc)
require.NoError(t, err)
second, err := transform(first)
require.NoError(t, err)
r1 := first.(*corev1.Service)
r2 := second.(*corev1.Service)
assert.Empty(t, r1.ManagedFields)
assert.Empty(t, r2.ManagedFields)
assert.NotContains(t, r1.Annotations, corev1.LastAppliedConfigAnnotation)
assert.NotContains(t, r2.Annotations, corev1.LastAppliedConfigAnnotation)
assert.Empty(t, r1.Status.Conditions)
assert.Empty(t, r2.Status.Conditions)
}
func TestTransformerWithOptions_WithFakeClient(t *testing.T) {
ctx := t.Context()
svc := fakeService()
fakeClient := fake.NewClientset()
_, err := fakeClient.CoreV1().Services(svc.Namespace).Create(ctx, svc, metav1.CreateOptions{})
require.NoError(t, err)
factory := kubeinformers.NewSharedInformerFactoryWithOptions(fakeClient, 0, kubeinformers.WithNamespace(svc.Namespace))
serviceInformer := factory.Core().V1().Services()
err = serviceInformer.Informer().SetTransform(TransformerWithOptions[*corev1.Service](
TransformRemoveManagedFields(),
TransformRemoveLastAppliedConfig(),
TransformRemoveStatusConditions(),
))
require.NoError(t, err)
factory.Start(ctx.Done())
err = WaitForCacheSync(ctx, factory)
require.NoError(t, err)
got, err := serviceInformer.Lister().Services(svc.Namespace).Get(svc.Name)
require.NoError(t, err)
assert.Empty(t, got.ManagedFields)
assert.Empty(t, got.Status.Conditions)
assert.NotContains(t, got.Annotations, corev1.LastAppliedConfigAnnotation)
// TypeMeta is populated by the transformer
assert.Equal(t, "Service", got.Kind)
assert.NotEmpty(t, got.APIVersion)
// Spec and remaining Status are preserved
assert.Equal(t, svc.Spec.Selector, got.Spec.Selector)
assert.Equal(t, svc.Spec.ExternalIPs, got.Spec.ExternalIPs)
assert.Equal(t, svc.Status.LoadBalancer.Ingress, got.Status.LoadBalancer.Ingress)
}
func TestPopulateGVK(t *testing.T) {
t.Run("populates Kind and APIVersion on Service", func(t *testing.T) {
svc := fakeService()
require.Empty(t, svc.Kind)
populateGVK(svc)
assert.Equal(t, "Service", svc.Kind)
assert.NotEmpty(t, svc.APIVersion)
})
t.Run("populates Kind and APIVersion on Node", func(t *testing.T) {
node := fakeNode()
require.Empty(t, node.Kind)
populateGVK(node)
assert.Equal(t, "Node", node.Kind)
assert.NotEmpty(t, node.APIVersion)
})
t.Run("idempotent when GVK already set", func(t *testing.T) {
svc := fakeService()
svc.Kind = "Service"
svc.APIVersion = "v1"
populateGVK(svc)
assert.Equal(t, "Service", svc.Kind)
assert.Equal(t, "v1", svc.APIVersion)
})
t.Run("unstructured object retains its own GVK unchanged", func(t *testing.T) {
gvk := schema.GroupVersionKind{Group: "example.com", Version: "v1alpha1", Kind: "MyResource"}
obj := &unstructured.Unstructured{}
obj.SetGroupVersionKind(gvk)
populateGVK(obj)
assert.Equal(t, gvk, obj.GroupVersionKind())
})
t.Run("Istio Gateway not in k8s scheme, Kind populated via reflection fallback", func(t *testing.T) {
// Istio types are not registered in k8s.io/client-go/kubernetes/scheme, so the
// scheme lookup fails. populateGVK falls back to reflection and derives Kind
// from the Go struct name. Group and Version remain empty.
gw := &networkingv1beta1.Gateway{}
require.Empty(t, gw.Kind)
populateGVK(gw)
assert.Equal(t, "Gateway", gw.Kind)
assert.Empty(t, gw.APIVersion) // Group/Version unknown without the Istio scheme
})
}