Skip to content

Commit b3bf3ca

Browse files
feat(source): pods added support for annotation filter and label selectors
1 parent 2d898cd commit b3bf3ca

File tree

9 files changed

+588
-33
lines changed

9 files changed

+588
-33
lines changed

docs/sources/about.md

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,26 +5,26 @@ A source in ExternalDNS defines where DNS records are discovered from within you
55
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.
66

77
| Source | Resources | annotation-filter | label-filter |
8-
| --------------------------------------- | ----------------------------------------------------------------------------- | ----------------- | ------------ |
9-
| ambassador-host | Host.getambassador.io | Yes | Yes |
8+
|-----------------------------------------|-------------------------------------------------------------------------------|:-----------------:|:------------:|
9+
| ambassador-host | Host.getambassador.io | Yes | Yes |
1010
| connector | | | |
11-
| contour-httpproxy | HttpProxy.projectcontour.io | Yes | |
11+
| contour-httpproxy | HttpProxy.projectcontour.io | Yes | |
1212
| cloudfoundry | | | |
13-
| [crd](crd.md) | DNSEndpoint.externaldns.k8s.io | Yes | Yes |
14-
| [f5-virtualserver](f5-virtualserver.md) | VirtualServer.cis.f5.com | Yes | |
15-
| [gateway-grpcroute](gateway.md) | GRPCRoute.gateway.networking.k8s.io | Yes | Yes |
16-
| [gateway-httproute](gateway.md) | HTTPRoute.gateway.networking.k8s.io | Yes | Yes |
17-
| [gateway-tcproute](gateway.md) | TCPRoute.gateway.networking.k8s.io | Yes | Yes |
18-
| [gateway-tlsroute](gateway.md) | TLSRoute.gateway.networking.k8s.io | Yes | Yes |
19-
| [gateway-udproute](gateway.md) | UDPRoute.gateway.networking.k8s.io | Yes | Yes |
13+
| [crd](crd.md) | DNSEndpoint.externaldns.k8s.io | Yes | Yes |
14+
| [f5-virtualserver](f5-virtualserver.md) | VirtualServer.cis.f5.com | Yes | |
15+
| [gateway-grpcroute](gateway.md) | GRPCRoute.gateway.networking.k8s.io | Yes | Yes |
16+
| [gateway-httproute](gateway.md) | HTTPRoute.gateway.networking.k8s.io | Yes | Yes |
17+
| [gateway-tcproute](gateway.md) | TCPRoute.gateway.networking.k8s.io | Yes | Yes |
18+
| [gateway-tlsroute](gateway.md) | TLSRoute.gateway.networking.k8s.io | Yes | Yes |
19+
| [gateway-udproute](gateway.md) | UDPRoute.gateway.networking.k8s.io | Yes | Yes |
2020
| [gloo-proxy](gloo-proxy.md) | Proxy.gloo.solo.io | | |
21-
| [ingress](ingress.md) | Ingress.networking.k8s.io | Yes | Yes |
22-
| [istio-gateway](istio.md) | Gateway.networking.istio.io | Yes | |
23-
| [istio-virtualservice](istio.md) | VirtualService.networking.istio.io | Yes | |
24-
| [kong-tcpingress](kong.md) | TCPIngress.configuration.konghq.com | Yes | |
25-
| [node](nodes.md) | Node | Yes | Yes |
26-
| [openshift-route](openshift.md) | Route.route.openshift.io | Yes | Yes |
27-
| [pod](pod.md) | Pod | | |
28-
| [service](service.md) | Service | Yes | Yes |
29-
| skipper-routegroup | RouteGroup.zalando.org | Yes | |
30-
| [traefik-proxy](traefik-proxy.md) | IngressRoute.traefik.io IngressRouteTCP.traefik.io IngressRouteUDP.traefik.io | Yes | |
21+
| [ingress](ingress.md) | Ingress.networking.k8s.io | Yes | Yes |
22+
| [istio-gateway](istio.md) | Gateway.networking.istio.io | Yes | |
23+
| [istio-virtualservice](istio.md) | VirtualService.networking.istio.io | Yes | |
24+
| [kong-tcpingress](kong.md) | TCPIngress.configuration.konghq.com | Yes | |
25+
| [node](nodes.md) | Node | Yes | Yes |
26+
| [openshift-route](openshift.md) | Route.route.openshift.io | Yes | Yes |
27+
| [pod](pod.md) | Pod | Yes | Yes |
28+
| [service](service.md) | Service | Yes | Yes |
29+
| skipper-routegroup | RouteGroup.zalando.org | Yes | |
30+
| [traefik-proxy](traefik-proxy.md) | IngressRoute.traefik.io IngressRouteTCP.traefik.io IngressRouteUDP.traefik.io | Yes | |

source/annotations/processors_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@ func TestParseAnnotationFilter(t *testing.T) {
4747
expectedSelector: labels.Set{}.AsSelector(),
4848
expectError: false,
4949
},
50+
{
51+
name: "wrong annotation filter",
52+
annotationFilter: "=test",
53+
expectedSelector: nil,
54+
expectError: true,
55+
},
5056
}
5157

5258
for _, tt := range tests {

source/informers/indexers.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
Licensed under the Apache License, Version 2.0 (the "License");
4+
you may not use this file except in compliance with the License.
5+
You may obtain a copy of the License at
6+
http://www.apache.org/licenses/LICENSE-2.0
7+
Unless required by applicable law or agreed to in writing, software
8+
distributed under the License is distributed on an "AS IS" BASIS,
9+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
See the License for the specific language governing permissions and
11+
limitations under the License.
12+
*/
13+
14+
package informers
15+
16+
import (
17+
"fmt"
18+
19+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
20+
"k8s.io/apimachinery/pkg/labels"
21+
"k8s.io/apimachinery/pkg/types"
22+
"k8s.io/client-go/tools/cache"
23+
24+
"sigs.k8s.io/external-dns/source/annotations"
25+
)
26+
27+
const (
28+
IndexWithSelectors = "withSelectors"
29+
)
30+
31+
type IndexSelectorOptions struct {
32+
annotationFilter labels.Selector
33+
labelSelector labels.Selector
34+
}
35+
36+
func IndexSelectorWithAnnotationFilter(input string) func(options *IndexSelectorOptions) {
37+
return func(options *IndexSelectorOptions) {
38+
if input == "" {
39+
return
40+
}
41+
selector, err := annotations.ParseFilter(input)
42+
if err != nil {
43+
return
44+
}
45+
options.annotationFilter = selector
46+
}
47+
}
48+
49+
func IndexSelectorWithLabelSelector(input labels.Selector) func(options *IndexSelectorOptions) {
50+
return func(options *IndexSelectorOptions) {
51+
options.labelSelector = input
52+
}
53+
}
54+
55+
// IndexerWithOptions is a generic function that allows adding multiple indexers
56+
// to a SharedIndexInformer for a specific Kubernetes resource type T. It accepts
57+
// a variadic list of indexer functions, which define custom indexing logic.
58+
//
59+
// Each indexer function is applied to objects of type T, enabling flexible and
60+
// reusable indexing based on annotations, labels, or other criteria.
61+
//
62+
// Example usage:
63+
// err := IndexerWithOptions[*v1.Pod](
64+
//
65+
// IndexSelectorWithAnnotationFilter("example-annotation"),
66+
// IndexSelectorWithLabelSelector(labels.SelectorFromSet(labels.Set{"app": "my-app"})),
67+
//
68+
// )
69+
//
70+
// This function ensures type safety and simplifies the process of adding
71+
// custom indexers to informers.
72+
func IndexerWithOptions[T metav1.Object](optFns ...func(options *IndexSelectorOptions)) cache.Indexers {
73+
options := IndexSelectorOptions{}
74+
for _, fn := range optFns {
75+
fn(&options)
76+
}
77+
78+
return cache.Indexers{
79+
IndexWithSelectors: func(obj interface{}) ([]string, error) {
80+
entity, ok := obj.(T)
81+
if !ok {
82+
return nil, fmt.Errorf("object is not of type %T", new(T))
83+
}
84+
85+
if options.annotationFilter != nil && !options.annotationFilter.Matches(labels.Set(entity.GetAnnotations())) {
86+
return nil, nil
87+
}
88+
89+
if options.labelSelector != nil && !options.labelSelector.Matches(labels.Set(entity.GetLabels())) {
90+
return nil, nil
91+
}
92+
key := types.NamespacedName{Namespace: entity.GetNamespace(), Name: entity.GetName()}.String()
93+
return []string{key}, nil
94+
},
95+
}
96+
}
97+
98+
// GetByKey retrieves an object of type T (metav1.Object) from the given cache.Indexer by its key.
99+
// It returns the object and an error if the retrieval or type assertion fails.
100+
// If the object does not exist, it returns the zero value of T and nil.
101+
func GetByKey[T metav1.Object](indexer cache.Indexer, key string) (T, error) {
102+
var entity T
103+
obj, exists, err := indexer.GetByKey(key)
104+
if err != nil || !exists {
105+
return entity, err
106+
}
107+
108+
entity, ok := obj.(T)
109+
if !ok {
110+
return entity, fmt.Errorf("object is not of type %T", new(T))
111+
}
112+
return entity, nil
113+
}

source/informers/indexers_test.go

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
Licensed under the Apache License, Version 2.0 (the "License");
4+
you may not use this file except in compliance with the License.
5+
You may obtain a copy of the License at
6+
http://www.apache.org/licenses/LICENSE-2.0
7+
Unless required by applicable law or agreed to in writing, software
8+
distributed under the License is distributed on an "AS IS" BASIS,
9+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
See the License for the specific language governing permissions and
11+
limitations under the License.
12+
*/
13+
14+
package informers
15+
16+
import (
17+
"testing"
18+
19+
"github.com/stretchr/testify/assert"
20+
corev1 "k8s.io/api/core/v1"
21+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
22+
"k8s.io/apimachinery/pkg/labels"
23+
"k8s.io/client-go/tools/cache"
24+
"sigs.k8s.io/external-dns/source/annotations"
25+
)
26+
27+
func TestIndexerWithOptions_FilterByAnnotation(t *testing.T) {
28+
indexers := IndexerWithOptions[*unstructured.Unstructured](
29+
IndexSelectorWithAnnotationFilter("example-annotation"),
30+
)
31+
32+
obj := &unstructured.Unstructured{}
33+
obj.SetAnnotations(map[string]string{"example-annotation": "value"})
34+
obj.SetNamespace("default")
35+
obj.SetName("test-object")
36+
37+
keys, err := indexers[IndexWithSelectors](obj)
38+
assert.NoError(t, err)
39+
assert.Equal(t, []string{"default/test-object"}, keys)
40+
}
41+
42+
func TestIndexerWithOptions_FilterByLabel(t *testing.T) {
43+
labelSelector := labels.SelectorFromSet(labels.Set{"app": "nginx"})
44+
indexers := IndexerWithOptions[*corev1.Pod](
45+
IndexSelectorWithLabelSelector(labelSelector),
46+
)
47+
48+
obj := &corev1.Pod{}
49+
obj.SetLabels(map[string]string{"app": "nginx"})
50+
obj.SetNamespace("default")
51+
obj.SetName("test-object")
52+
53+
keys, err := indexers[IndexWithSelectors](obj)
54+
assert.NoError(t, err)
55+
assert.Equal(t, []string{"default/test-object"}, keys)
56+
}
57+
58+
func TestIndexerWithOptions_NoMatch(t *testing.T) {
59+
labelSelector := labels.SelectorFromSet(labels.Set{"app": "nginx"})
60+
indexers := IndexerWithOptions[*unstructured.Unstructured](
61+
IndexSelectorWithLabelSelector(labelSelector),
62+
)
63+
64+
obj := &unstructured.Unstructured{}
65+
obj.SetLabels(map[string]string{"app": "apache"})
66+
obj.SetNamespace("default")
67+
obj.SetName("test-object")
68+
69+
keys, err := indexers[IndexWithSelectors](obj)
70+
assert.NoError(t, err)
71+
assert.Nil(t, keys)
72+
}
73+
74+
func TestIndexerWithOptions_InvalidType(t *testing.T) {
75+
indexers := IndexerWithOptions[*unstructured.Unstructured]()
76+
77+
obj := "invalid-object"
78+
79+
keys, err := indexers[IndexWithSelectors](obj)
80+
assert.Error(t, err)
81+
assert.Nil(t, keys)
82+
assert.Contains(t, err.Error(), "object is not of type")
83+
}
84+
85+
func TestIndexerWithOptions_EmptyOptions(t *testing.T) {
86+
indexers := IndexerWithOptions[*unstructured.Unstructured]()
87+
88+
obj := &unstructured.Unstructured{}
89+
obj.SetNamespace("default")
90+
obj.SetName("test-object")
91+
92+
keys, err := indexers["withSelectors"](obj)
93+
assert.NoError(t, err)
94+
assert.Equal(t, []string{"default/test-object"}, keys)
95+
}
96+
97+
func TestIndexerWithOptions_AnnotationFilterNoMatch(t *testing.T) {
98+
indexers := IndexerWithOptions[*unstructured.Unstructured](
99+
IndexSelectorWithAnnotationFilter("example-annotation=value"),
100+
)
101+
102+
obj := &unstructured.Unstructured{}
103+
obj.SetAnnotations(map[string]string{"other-annotation": "value"})
104+
obj.SetNamespace("default")
105+
obj.SetName("test-object")
106+
107+
keys, err := indexers[IndexWithSelectors](obj)
108+
assert.NoError(t, err)
109+
assert.Nil(t, keys)
110+
}
111+
112+
func TestIndexSelectorWithAnnotationFilter(t *testing.T) {
113+
tests := []struct {
114+
name string
115+
input string
116+
expectedFilter labels.Selector
117+
}{
118+
{
119+
name: "valid input",
120+
input: "key=value",
121+
expectedFilter: func() labels.Selector { s, _ := annotations.ParseFilter("key=value"); return s }(),
122+
},
123+
{
124+
name: "empty input",
125+
input: "",
126+
expectedFilter: nil,
127+
},
128+
{
129+
name: "key only filter",
130+
input: "app",
131+
expectedFilter: func() labels.Selector { s, _ := annotations.ParseFilter("app"); return s }(),
132+
},
133+
{
134+
name: "poisoned intput",
135+
input: "=app",
136+
expectedFilter: nil,
137+
},
138+
}
139+
140+
for _, tt := range tests {
141+
t.Run(tt.name, func(t *testing.T) {
142+
options := &IndexSelectorOptions{}
143+
IndexSelectorWithAnnotationFilter(tt.input)(options)
144+
assert.Equal(t, tt.expectedFilter, options.annotationFilter)
145+
})
146+
}
147+
}
148+
149+
func TestGetByKey_ObjectExists(t *testing.T) {
150+
indexer := cache.NewIndexer(cache.MetaNamespaceKeyFunc, cache.Indexers{})
151+
pod := &corev1.Pod{}
152+
pod.SetNamespace("default")
153+
pod.SetName("test-pod")
154+
155+
err := indexer.Add(pod)
156+
assert.NoError(t, err)
157+
158+
result, err := GetByKey[*corev1.Pod](indexer, "default/test-pod")
159+
assert.NoError(t, err)
160+
assert.NotNil(t, result)
161+
assert.Equal(t, "test-pod", result.GetName())
162+
}
163+
164+
func TestGetByKey_ObjectDoesNotExist(t *testing.T) {
165+
indexer := cache.NewIndexer(cache.MetaNamespaceKeyFunc, cache.Indexers{})
166+
167+
result, err := GetByKey[*corev1.Pod](indexer, "default/non-existent-pod")
168+
assert.NoError(t, err)
169+
assert.Nil(t, result)
170+
}
171+
172+
func TestGetByKey_TypeAssertionFailure(t *testing.T) {
173+
indexer := cache.NewIndexer(cache.MetaNamespaceKeyFunc, cache.Indexers{})
174+
service := &corev1.Service{}
175+
service.SetNamespace("default")
176+
service.SetName("test-service")
177+
178+
err := indexer.Add(service)
179+
assert.NoError(t, err)
180+
181+
result, err := GetByKey[*corev1.Pod](indexer, "default/test-service")
182+
assert.Error(t, err)
183+
assert.Contains(t, err.Error(), "object is not of type")
184+
assert.Nil(t, result)
185+
}

0 commit comments

Comments
 (0)