mirror of
https://github.com/kubernetes-sigs/external-dns.git
synced 2025-08-05 09:06:58 +02:00
feat(source/pods): support for annotation and label filter (#5583)
* feat(source): pods added support for annotation filter and label selectors * feat(source/pods): support for annotation and label filter Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com> --------- Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>
This commit is contained in:
parent
8cc73bd1e4
commit
4fd5596601
@ -5,26 +5,26 @@ A source in ExternalDNS defines where DNS records are discovered from within you
|
|||||||
ExternalDNS watches the specified sources for hostname information and uses it to create, update, or delete DNS records accordingly. Multiple sources can be configured simultaneously to support diverse environments.
|
ExternalDNS watches the specified sources for hostname information and uses it to create, update, or delete DNS records accordingly. Multiple sources can be configured simultaneously to support diverse environments.
|
||||||
|
|
||||||
| Source | Resources | annotation-filter | label-filter |
|
| Source | Resources | annotation-filter | label-filter |
|
||||||
| --------------------------------------- | ----------------------------------------------------------------------------- | ----------------- | ------------ |
|
|-----------------------------------------|-------------------------------------------------------------------------------|:-----------------:|:------------:|
|
||||||
| ambassador-host | Host.getambassador.io | Yes | Yes |
|
| ambassador-host | Host.getambassador.io | Yes | Yes |
|
||||||
| connector | | | |
|
| connector | | | |
|
||||||
| contour-httpproxy | HttpProxy.projectcontour.io | Yes | |
|
| contour-httpproxy | HttpProxy.projectcontour.io | Yes | |
|
||||||
| cloudfoundry | | | |
|
| cloudfoundry | | | |
|
||||||
| [crd](crd.md) | DNSEndpoint.externaldns.k8s.io | Yes | Yes |
|
| [crd](crd.md) | DNSEndpoint.externaldns.k8s.io | Yes | Yes |
|
||||||
| [f5-virtualserver](f5-virtualserver.md) | VirtualServer.cis.f5.com | Yes | |
|
| [f5-virtualserver](f5-virtualserver.md) | VirtualServer.cis.f5.com | Yes | |
|
||||||
| [gateway-grpcroute](gateway.md) | GRPCRoute.gateway.networking.k8s.io | Yes | Yes |
|
| [gateway-grpcroute](gateway.md) | GRPCRoute.gateway.networking.k8s.io | Yes | Yes |
|
||||||
| [gateway-httproute](gateway.md) | HTTPRoute.gateway.networking.k8s.io | Yes | Yes |
|
| [gateway-httproute](gateway.md) | HTTPRoute.gateway.networking.k8s.io | Yes | Yes |
|
||||||
| [gateway-tcproute](gateway.md) | TCPRoute.gateway.networking.k8s.io | Yes | Yes |
|
| [gateway-tcproute](gateway.md) | TCPRoute.gateway.networking.k8s.io | Yes | Yes |
|
||||||
| [gateway-tlsroute](gateway.md) | TLSRoute.gateway.networking.k8s.io | Yes | Yes |
|
| [gateway-tlsroute](gateway.md) | TLSRoute.gateway.networking.k8s.io | Yes | Yes |
|
||||||
| [gateway-udproute](gateway.md) | UDPRoute.gateway.networking.k8s.io | Yes | Yes |
|
| [gateway-udproute](gateway.md) | UDPRoute.gateway.networking.k8s.io | Yes | Yes |
|
||||||
| [gloo-proxy](gloo-proxy.md) | Proxy.gloo.solo.io | | |
|
| [gloo-proxy](gloo-proxy.md) | Proxy.gloo.solo.io | | |
|
||||||
| [ingress](ingress.md) | Ingress.networking.k8s.io | Yes | Yes |
|
| [ingress](ingress.md) | Ingress.networking.k8s.io | Yes | Yes |
|
||||||
| [istio-gateway](istio.md) | Gateway.networking.istio.io | Yes | |
|
| [istio-gateway](istio.md) | Gateway.networking.istio.io | Yes | |
|
||||||
| [istio-virtualservice](istio.md) | VirtualService.networking.istio.io | Yes | |
|
| [istio-virtualservice](istio.md) | VirtualService.networking.istio.io | Yes | |
|
||||||
| [kong-tcpingress](kong.md) | TCPIngress.configuration.konghq.com | Yes | |
|
| [kong-tcpingress](kong.md) | TCPIngress.configuration.konghq.com | Yes | |
|
||||||
| [node](nodes.md) | Node | Yes | Yes |
|
| [node](nodes.md) | Node | Yes | Yes |
|
||||||
| [openshift-route](openshift.md) | Route.route.openshift.io | Yes | Yes |
|
| [openshift-route](openshift.md) | Route.route.openshift.io | Yes | Yes |
|
||||||
| [pod](pod.md) | Pod | | |
|
| [pod](pod.md) | Pod | Yes | Yes |
|
||||||
| [service](service.md) | Service | Yes | Yes |
|
| [service](service.md) | Service | Yes | Yes |
|
||||||
| skipper-routegroup | RouteGroup.zalando.org | Yes | |
|
| skipper-routegroup | RouteGroup.zalando.org | Yes | |
|
||||||
| [traefik-proxy](traefik-proxy.md) | IngressRoute.traefik.io IngressRouteTCP.traefik.io IngressRouteUDP.traefik.io | Yes | |
|
| [traefik-proxy](traefik-proxy.md) | IngressRoute.traefik.io IngressRouteTCP.traefik.io IngressRouteUDP.traefik.io | Yes | |
|
||||||
|
@ -47,6 +47,12 @@ func TestParseAnnotationFilter(t *testing.T) {
|
|||||||
expectedSelector: labels.Set{}.AsSelector(),
|
expectedSelector: labels.Set{}.AsSelector(),
|
||||||
expectError: false,
|
expectError: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "wrong annotation filter",
|
||||||
|
annotationFilter: "=test",
|
||||||
|
expectedSelector: nil,
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
113
source/informers/indexers.go
Normal file
113
source/informers/indexers.go
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
/*
|
||||||
|
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 (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/labels"
|
||||||
|
"k8s.io/apimachinery/pkg/types"
|
||||||
|
"k8s.io/client-go/tools/cache"
|
||||||
|
|
||||||
|
"sigs.k8s.io/external-dns/source/annotations"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
IndexWithSelectors = "withSelectors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type IndexSelectorOptions struct {
|
||||||
|
annotationFilter labels.Selector
|
||||||
|
labelSelector labels.Selector
|
||||||
|
}
|
||||||
|
|
||||||
|
func IndexSelectorWithAnnotationFilter(input string) func(options *IndexSelectorOptions) {
|
||||||
|
return func(options *IndexSelectorOptions) {
|
||||||
|
if input == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
selector, err := annotations.ParseFilter(input)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
options.annotationFilter = selector
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func IndexSelectorWithLabelSelector(input labels.Selector) func(options *IndexSelectorOptions) {
|
||||||
|
return func(options *IndexSelectorOptions) {
|
||||||
|
options.labelSelector = input
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IndexerWithOptions is a generic function that allows adding multiple indexers
|
||||||
|
// to a SharedIndexInformer for a specific Kubernetes resource type T. It accepts
|
||||||
|
// a variadic list of indexer functions, which define custom indexing logic.
|
||||||
|
//
|
||||||
|
// Each indexer function is applied to objects of type T, enabling flexible and
|
||||||
|
// reusable indexing based on annotations, labels, or other criteria.
|
||||||
|
//
|
||||||
|
// Example usage:
|
||||||
|
// err := IndexerWithOptions[*v1.Pod](
|
||||||
|
//
|
||||||
|
// IndexSelectorWithAnnotationFilter("example-annotation"),
|
||||||
|
// IndexSelectorWithLabelSelector(labels.SelectorFromSet(labels.Set{"app": "my-app"})),
|
||||||
|
//
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// This function ensures type safety and simplifies the process of adding
|
||||||
|
// custom indexers to informers.
|
||||||
|
func IndexerWithOptions[T metav1.Object](optFns ...func(options *IndexSelectorOptions)) cache.Indexers {
|
||||||
|
options := IndexSelectorOptions{}
|
||||||
|
for _, fn := range optFns {
|
||||||
|
fn(&options)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cache.Indexers{
|
||||||
|
IndexWithSelectors: func(obj interface{}) ([]string, error) {
|
||||||
|
entity, ok := obj.(T)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("object is not of type %T", new(T))
|
||||||
|
}
|
||||||
|
|
||||||
|
if options.annotationFilter != nil && !options.annotationFilter.Matches(labels.Set(entity.GetAnnotations())) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if options.labelSelector != nil && !options.labelSelector.Matches(labels.Set(entity.GetLabels())) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
key := types.NamespacedName{Namespace: entity.GetNamespace(), Name: entity.GetName()}.String()
|
||||||
|
return []string{key}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByKey retrieves an object of type T (metav1.Object) from the given cache.Indexer by its key.
|
||||||
|
// It returns the object and an error if the retrieval or type assertion fails.
|
||||||
|
// If the object does not exist, it returns the zero value of T and nil.
|
||||||
|
func GetByKey[T metav1.Object](indexer cache.Indexer, key string) (T, error) {
|
||||||
|
var entity T
|
||||||
|
obj, exists, err := indexer.GetByKey(key)
|
||||||
|
if err != nil || !exists {
|
||||||
|
return entity, err
|
||||||
|
}
|
||||||
|
|
||||||
|
entity, ok := obj.(T)
|
||||||
|
if !ok {
|
||||||
|
return entity, fmt.Errorf("object is not of type %T", new(T))
|
||||||
|
}
|
||||||
|
return entity, nil
|
||||||
|
}
|
185
source/informers/indexers_test.go
Normal file
185
source/informers/indexers_test.go
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
/*
|
||||||
|
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"
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||||
|
"k8s.io/apimachinery/pkg/labels"
|
||||||
|
"k8s.io/client-go/tools/cache"
|
||||||
|
"sigs.k8s.io/external-dns/source/annotations"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIndexerWithOptions_FilterByAnnotation(t *testing.T) {
|
||||||
|
indexers := IndexerWithOptions[*unstructured.Unstructured](
|
||||||
|
IndexSelectorWithAnnotationFilter("example-annotation"),
|
||||||
|
)
|
||||||
|
|
||||||
|
obj := &unstructured.Unstructured{}
|
||||||
|
obj.SetAnnotations(map[string]string{"example-annotation": "value"})
|
||||||
|
obj.SetNamespace("default")
|
||||||
|
obj.SetName("test-object")
|
||||||
|
|
||||||
|
keys, err := indexers[IndexWithSelectors](obj)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"default/test-object"}, keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIndexerWithOptions_FilterByLabel(t *testing.T) {
|
||||||
|
labelSelector := labels.SelectorFromSet(labels.Set{"app": "nginx"})
|
||||||
|
indexers := IndexerWithOptions[*corev1.Pod](
|
||||||
|
IndexSelectorWithLabelSelector(labelSelector),
|
||||||
|
)
|
||||||
|
|
||||||
|
obj := &corev1.Pod{}
|
||||||
|
obj.SetLabels(map[string]string{"app": "nginx"})
|
||||||
|
obj.SetNamespace("default")
|
||||||
|
obj.SetName("test-object")
|
||||||
|
|
||||||
|
keys, err := indexers[IndexWithSelectors](obj)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"default/test-object"}, keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIndexerWithOptions_NoMatch(t *testing.T) {
|
||||||
|
labelSelector := labels.SelectorFromSet(labels.Set{"app": "nginx"})
|
||||||
|
indexers := IndexerWithOptions[*unstructured.Unstructured](
|
||||||
|
IndexSelectorWithLabelSelector(labelSelector),
|
||||||
|
)
|
||||||
|
|
||||||
|
obj := &unstructured.Unstructured{}
|
||||||
|
obj.SetLabels(map[string]string{"app": "apache"})
|
||||||
|
obj.SetNamespace("default")
|
||||||
|
obj.SetName("test-object")
|
||||||
|
|
||||||
|
keys, err := indexers[IndexWithSelectors](obj)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Nil(t, keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIndexerWithOptions_InvalidType(t *testing.T) {
|
||||||
|
indexers := IndexerWithOptions[*unstructured.Unstructured]()
|
||||||
|
|
||||||
|
obj := "invalid-object"
|
||||||
|
|
||||||
|
keys, err := indexers[IndexWithSelectors](obj)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Nil(t, keys)
|
||||||
|
assert.Contains(t, err.Error(), "object is not of type")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIndexerWithOptions_EmptyOptions(t *testing.T) {
|
||||||
|
indexers := IndexerWithOptions[*unstructured.Unstructured]()
|
||||||
|
|
||||||
|
obj := &unstructured.Unstructured{}
|
||||||
|
obj.SetNamespace("default")
|
||||||
|
obj.SetName("test-object")
|
||||||
|
|
||||||
|
keys, err := indexers["withSelectors"](obj)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"default/test-object"}, keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIndexerWithOptions_AnnotationFilterNoMatch(t *testing.T) {
|
||||||
|
indexers := IndexerWithOptions[*unstructured.Unstructured](
|
||||||
|
IndexSelectorWithAnnotationFilter("example-annotation=value"),
|
||||||
|
)
|
||||||
|
|
||||||
|
obj := &unstructured.Unstructured{}
|
||||||
|
obj.SetAnnotations(map[string]string{"other-annotation": "value"})
|
||||||
|
obj.SetNamespace("default")
|
||||||
|
obj.SetName("test-object")
|
||||||
|
|
||||||
|
keys, err := indexers[IndexWithSelectors](obj)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Nil(t, keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIndexSelectorWithAnnotationFilter(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expectedFilter labels.Selector
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid input",
|
||||||
|
input: "key=value",
|
||||||
|
expectedFilter: func() labels.Selector { s, _ := annotations.ParseFilter("key=value"); return s }(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty input",
|
||||||
|
input: "",
|
||||||
|
expectedFilter: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "key only filter",
|
||||||
|
input: "app",
|
||||||
|
expectedFilter: func() labels.Selector { s, _ := annotations.ParseFilter("app"); return s }(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "poisoned input",
|
||||||
|
input: "=app",
|
||||||
|
expectedFilter: nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
options := &IndexSelectorOptions{}
|
||||||
|
IndexSelectorWithAnnotationFilter(tt.input)(options)
|
||||||
|
assert.Equal(t, tt.expectedFilter, options.annotationFilter)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetByKey_ObjectExists(t *testing.T) {
|
||||||
|
indexer := cache.NewIndexer(cache.MetaNamespaceKeyFunc, cache.Indexers{})
|
||||||
|
pod := &corev1.Pod{}
|
||||||
|
pod.SetNamespace("default")
|
||||||
|
pod.SetName("test-pod")
|
||||||
|
|
||||||
|
err := indexer.Add(pod)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
result, err := GetByKey[*corev1.Pod](indexer, "default/test-pod")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
assert.Equal(t, "test-pod", result.GetName())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetByKey_ObjectDoesNotExist(t *testing.T) {
|
||||||
|
indexer := cache.NewIndexer(cache.MetaNamespaceKeyFunc, cache.Indexers{})
|
||||||
|
|
||||||
|
result, err := GetByKey[*corev1.Pod](indexer, "default/non-existent-pod")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetByKey_TypeAssertionFailure(t *testing.T) {
|
||||||
|
indexer := cache.NewIndexer(cache.MetaNamespaceKeyFunc, cache.Indexers{})
|
||||||
|
service := &corev1.Service{}
|
||||||
|
service.SetNamespace("default")
|
||||||
|
service.SetName("test-service")
|
||||||
|
|
||||||
|
err := indexer.Add(service)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
result, err := GetByKey[*corev1.Pod](indexer, "default/test-service")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "object is not of type")
|
||||||
|
assert.Nil(t, result)
|
||||||
|
}
|
@ -25,15 +25,15 @@ import (
|
|||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
corev1 "k8s.io/api/core/v1"
|
corev1 "k8s.io/api/core/v1"
|
||||||
"k8s.io/apimachinery/pkg/labels"
|
"k8s.io/apimachinery/pkg/labels"
|
||||||
|
|
||||||
kubeinformers "k8s.io/client-go/informers"
|
kubeinformers "k8s.io/client-go/informers"
|
||||||
coreinformers "k8s.io/client-go/informers/core/v1"
|
coreinformers "k8s.io/client-go/informers/core/v1"
|
||||||
"k8s.io/client-go/kubernetes"
|
"k8s.io/client-go/kubernetes"
|
||||||
"k8s.io/client-go/tools/cache"
|
"k8s.io/client-go/tools/cache"
|
||||||
|
|
||||||
"sigs.k8s.io/external-dns/source/fqdn"
|
|
||||||
|
|
||||||
"sigs.k8s.io/external-dns/endpoint"
|
"sigs.k8s.io/external-dns/endpoint"
|
||||||
"sigs.k8s.io/external-dns/source/annotations"
|
"sigs.k8s.io/external-dns/source/annotations"
|
||||||
|
"sigs.k8s.io/external-dns/source/fqdn"
|
||||||
"sigs.k8s.io/external-dns/source/informers"
|
"sigs.k8s.io/external-dns/source/informers"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -60,11 +60,22 @@ func NewPodSource(
|
|||||||
podSourceDomain string,
|
podSourceDomain string,
|
||||||
fqdnTemplate string,
|
fqdnTemplate string,
|
||||||
combineFqdnAnnotation bool,
|
combineFqdnAnnotation bool,
|
||||||
|
annotationFilter string,
|
||||||
|
labelSelector labels.Selector,
|
||||||
) (Source, error) {
|
) (Source, error) {
|
||||||
informerFactory := kubeinformers.NewSharedInformerFactoryWithOptions(kubeClient, 0, kubeinformers.WithNamespace(namespace))
|
informerFactory := kubeinformers.NewSharedInformerFactoryWithOptions(kubeClient, 0, kubeinformers.WithNamespace(namespace))
|
||||||
podInformer := informerFactory.Core().V1().Pods()
|
podInformer := informerFactory.Core().V1().Pods()
|
||||||
nodeInformer := informerFactory.Core().V1().Nodes()
|
nodeInformer := informerFactory.Core().V1().Nodes()
|
||||||
|
|
||||||
|
err := podInformer.Informer().AddIndexers(informers.IndexerWithOptions[*corev1.Pod](
|
||||||
|
informers.IndexSelectorWithAnnotationFilter(annotationFilter),
|
||||||
|
informers.IndexSelectorWithLabelSelector(labelSelector),
|
||||||
|
))
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to add indexers to pod informer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
_, _ = podInformer.Informer().AddEventHandler(
|
_, _ = podInformer.Informer().AddEventHandler(
|
||||||
cache.ResourceEventHandlerFuncs{
|
cache.ResourceEventHandlerFuncs{
|
||||||
AddFunc: func(obj interface{}) {
|
AddFunc: func(obj interface{}) {
|
||||||
@ -107,13 +118,15 @@ func (*podSource) AddEventHandler(_ context.Context, _ func()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (ps *podSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) {
|
func (ps *podSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) {
|
||||||
pods, err := ps.podInformer.Lister().Pods(ps.namespace).List(labels.Everything())
|
indexKeys := ps.podInformer.Informer().GetIndexer().ListIndexFuncValues(informers.IndexWithSelectors)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
endpointMap := make(map[endpoint.EndpointKey][]string)
|
endpointMap := make(map[endpoint.EndpointKey][]string)
|
||||||
for _, pod := range pods {
|
for _, key := range indexKeys {
|
||||||
|
pod, err := informers.GetByKey[*corev1.Pod](ps.podInformer.Informer().GetIndexer(), key)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if ps.fqdnTemplate == nil || ps.combineFQDNAnnotation {
|
if ps.fqdnTemplate == nil || ps.combineFQDNAnnotation {
|
||||||
ps.addPodEndpointsToEndpointMap(endpointMap, pod)
|
ps.addPodEndpointsToEndpointMap(endpointMap, pod)
|
||||||
}
|
}
|
||||||
|
@ -58,7 +58,9 @@ func TestNewPodSourceWithFqdn(t *testing.T) {
|
|||||||
false,
|
false,
|
||||||
"",
|
"",
|
||||||
tt.fqdnTemplate,
|
tt.fqdnTemplate,
|
||||||
false)
|
false,
|
||||||
|
"",
|
||||||
|
nil)
|
||||||
|
|
||||||
if tt.expectError {
|
if tt.expectError {
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
@ -405,7 +407,9 @@ func TestPodSourceFqdnTemplatingExamples(t *testing.T) {
|
|||||||
false,
|
false,
|
||||||
tt.sourceDomain,
|
tt.sourceDomain,
|
||||||
tt.fqdnTemplate,
|
tt.fqdnTemplate,
|
||||||
tt.combineFQDN)
|
tt.combineFQDN,
|
||||||
|
"",
|
||||||
|
nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
endpoints, err := src.Endpoints(t.Context())
|
endpoints, err := src.Endpoints(t.Context())
|
||||||
@ -467,7 +471,9 @@ func TestPodSourceFqdnTemplatingExamples_Failed(t *testing.T) {
|
|||||||
false,
|
false,
|
||||||
tt.sourceDomain,
|
tt.sourceDomain,
|
||||||
tt.fqdnTemplate,
|
tt.fqdnTemplate,
|
||||||
tt.combineFQDN)
|
tt.combineFQDN,
|
||||||
|
"",
|
||||||
|
nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
_, err = src.Endpoints(t.Context())
|
_, err = src.Endpoints(t.Context())
|
||||||
|
232
source/pod_indexer_test.go
Normal file
232
source/pod_indexer_test.go
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
/*
|
||||||
|
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 (
|
||||||
|
"fmt"
|
||||||
|
"math/rand/v2"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/client-go/kubernetes/fake"
|
||||||
|
"sigs.k8s.io/external-dns/source/annotations"
|
||||||
|
)
|
||||||
|
|
||||||
|
type podSpec struct {
|
||||||
|
namespace string
|
||||||
|
labels map[string]string
|
||||||
|
annotations map[string]string
|
||||||
|
// with labels and annotations
|
||||||
|
totalTarget int
|
||||||
|
// without provided labels and annotations
|
||||||
|
totalRandom int
|
||||||
|
}
|
||||||
|
|
||||||
|
func fixtureCreatePodsWithNodes(input []podSpec) []*corev1.Pod {
|
||||||
|
var pods []*corev1.Pod
|
||||||
|
|
||||||
|
var createPod = func(index int, spec podSpec) *corev1.Pod {
|
||||||
|
return &corev1.Pod{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: fmt.Sprintf("pod-%d-%s", index, uuid.NewString()),
|
||||||
|
Namespace: spec.namespace,
|
||||||
|
Labels: func() map[string]string {
|
||||||
|
if spec.totalTarget > index {
|
||||||
|
return spec.labels
|
||||||
|
}
|
||||||
|
return map[string]string{
|
||||||
|
"app": fmt.Sprintf("my-app-%d", rand.IntN(10)),
|
||||||
|
"index": strconv.Itoa(index),
|
||||||
|
}
|
||||||
|
}(),
|
||||||
|
Annotations: func() map[string]string {
|
||||||
|
if spec.totalTarget > index {
|
||||||
|
return spec.annotations
|
||||||
|
}
|
||||||
|
return map[string]string{
|
||||||
|
"key1": fmt.Sprintf("value-%d", rand.IntN(10)),
|
||||||
|
}
|
||||||
|
}(),
|
||||||
|
},
|
||||||
|
Spec: corev1.PodSpec{},
|
||||||
|
Status: corev1.PodStatus{
|
||||||
|
Phase: corev1.PodRunning,
|
||||||
|
PodIPs: []corev1.PodIP{
|
||||||
|
{IP: net.IPv4(192, byte(rand.IntN(250)), byte(rand.IntN(250)), byte(index)).String()},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, el := range input {
|
||||||
|
totalPods := el.totalTarget + el.totalRandom
|
||||||
|
for i := 0; i < totalPods; i++ {
|
||||||
|
pods = append(pods, createPod(i, el))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
rand.Shuffle(len(pods), func(i, j int) {
|
||||||
|
pods[i], pods[j] = pods[j], pods[i]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// assign nodes to pods
|
||||||
|
for i, pod := range pods {
|
||||||
|
pod.Spec.NodeName = fmt.Sprintf("node-%d", i/5) // Assign 5 pods per node
|
||||||
|
}
|
||||||
|
return pods
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPodsWithAnnotationsAndLabels(t *testing.T) {
|
||||||
|
// total target pods 700
|
||||||
|
// total random pods 3950
|
||||||
|
pods := fixtureCreatePodsWithNodes([]podSpec{
|
||||||
|
{
|
||||||
|
namespace: "dev",
|
||||||
|
labels: map[string]string{"app": "nginx", "env": "dev", "agent": "enabled"},
|
||||||
|
annotations: map[string]string{"arch": "amd64"},
|
||||||
|
totalTarget: 300,
|
||||||
|
totalRandom: 700,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
namespace: "prod",
|
||||||
|
labels: map[string]string{"app": "nginx", "env": "prod", "agent": "enabled"},
|
||||||
|
annotations: map[string]string{"arch": "amd64"},
|
||||||
|
totalTarget: 150,
|
||||||
|
totalRandom: 2700,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
namespace: "default",
|
||||||
|
labels: map[string]string{"app": "nginx", "agent": "disabled"},
|
||||||
|
annotations: map[string]string{"arch": "amd64"},
|
||||||
|
totalTarget: 250,
|
||||||
|
totalRandom: 450,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
namespace: "kube-system",
|
||||||
|
labels: map[string]string{},
|
||||||
|
annotations: map[string]string{},
|
||||||
|
totalTarget: 0,
|
||||||
|
totalRandom: 100,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
client := fake.NewClientset()
|
||||||
|
|
||||||
|
nodes := map[string]bool{}
|
||||||
|
|
||||||
|
for _, pod := range pods {
|
||||||
|
if _, exists := nodes[pod.Spec.NodeName]; !exists {
|
||||||
|
nodes[pod.Spec.NodeName] = true
|
||||||
|
node := &corev1.Node{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: pod.Spec.NodeName,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if _, err := client.CoreV1().Nodes().Create(t.Context(), node, metav1.CreateOptions{}); err != nil {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := client.CoreV1().Pods(pod.Namespace).Create(t.Context(), pod, metav1.CreateOptions{}); err != nil {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
namespace string
|
||||||
|
labelSelector string
|
||||||
|
annotationFilter string
|
||||||
|
expectedEndpointCount int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "prod namespace with labels",
|
||||||
|
namespace: "prod",
|
||||||
|
labelSelector: "app=nginx",
|
||||||
|
expectedEndpointCount: 150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "prod namespace with annotations",
|
||||||
|
namespace: "prod",
|
||||||
|
annotationFilter: "arch=amd64",
|
||||||
|
expectedEndpointCount: 150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "prod namespace with annotations and labels not exists",
|
||||||
|
namespace: "prod",
|
||||||
|
labelSelector: "app=not-exists",
|
||||||
|
annotationFilter: "arch=amd64",
|
||||||
|
expectedEndpointCount: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all namespaces with correct annotations and labels",
|
||||||
|
namespace: "",
|
||||||
|
labelSelector: "app=nginx,agent=enabled",
|
||||||
|
annotationFilter: "arch=amd64",
|
||||||
|
expectedEndpointCount: 450, // 300 from dev + 150 from prod
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all namespaces with loose annotations and labels",
|
||||||
|
namespace: "",
|
||||||
|
labelSelector: "app=nginx",
|
||||||
|
annotationFilter: "arch=amd64",
|
||||||
|
expectedEndpointCount: 700, // 300 from dev + 150 from prod + 250 from default
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all namespaces with loose annotations and labels",
|
||||||
|
namespace: "",
|
||||||
|
labelSelector: "agent",
|
||||||
|
annotationFilter: "arch",
|
||||||
|
expectedEndpointCount: 700,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all namespaces without filters",
|
||||||
|
namespace: "",
|
||||||
|
labelSelector: "",
|
||||||
|
annotationFilter: "",
|
||||||
|
expectedEndpointCount: 4650,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single namespace without filters",
|
||||||
|
namespace: "default",
|
||||||
|
labelSelector: "",
|
||||||
|
annotationFilter: "",
|
||||||
|
expectedEndpointCount: 700,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
selector, _ := annotations.ParseFilter(tt.labelSelector)
|
||||||
|
pSource, err := NewPodSource(
|
||||||
|
t.Context(), client,
|
||||||
|
tt.namespace, "",
|
||||||
|
false, "",
|
||||||
|
"{{ .Name }}.tld.org", false,
|
||||||
|
tt.annotationFilter, selector)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
endpoints, err := pSource.Endpoints(t.Context())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Len(t, endpoints, tt.expectedEndpointCount)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@ -657,7 +657,7 @@ func TestPodSource(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := NewPodSource(ctx, kubernetes, tc.targetNamespace, tc.compatibility, tc.ignoreNonHostNetworkPods, tc.PodSourceDomain, "", false)
|
client, err := NewPodSource(ctx, kubernetes, tc.targetNamespace, tc.compatibility, tc.ignoreNonHostNetworkPods, tc.PodSourceDomain, "", false, "", nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
endpoints, err := client.Endpoints(ctx)
|
endpoints, err := client.Endpoints(ctx)
|
||||||
@ -885,7 +885,7 @@ func TestPodSourceLogs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := NewPodSource(ctx, kubernetes, "", "", tc.ignoreNonHostNetworkPods, "", "", false)
|
client, err := NewPodSource(ctx, kubernetes, "", "", tc.ignoreNonHostNetworkPods, "", "", false, "", nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
hook := testutils.LogsUnderTestWithLogLevel(log.DebugLevel, t)
|
hook := testutils.LogsUnderTestWithLogLevel(log.DebugLevel, t)
|
||||||
|
@ -448,7 +448,7 @@ func buildPodSource(ctx context.Context, p ClientGenerator, cfg *Config) (Source
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return NewPodSource(ctx, client, cfg.Namespace, cfg.Compatibility, cfg.IgnoreNonHostNetworkPods, cfg.PodSourceDomain, cfg.FQDNTemplate, cfg.CombineFQDNAndAnnotation)
|
return NewPodSource(ctx, client, cfg.Namespace, cfg.Compatibility, cfg.IgnoreNonHostNetworkPods, cfg.PodSourceDomain, cfg.FQDNTemplate, cfg.CombineFQDNAndAnnotation, cfg.AnnotationFilter, cfg.LabelFilter)
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildIstioGatewaySource creates an Istio Gateway source for exposing Istio gateways as DNS records.
|
// buildIstioGatewaySource creates an Istio Gateway source for exposing Istio gateways as DNS records.
|
||||||
|
Loading…
Reference in New Issue
Block a user