mirror of
https://github.com/kubernetes-sigs/external-dns.git
synced 2026-04-19 23:11:00 +02:00
* 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>
395 lines
14 KiB
Go
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
|
|
})
|
|
}
|