Skip to content

Commit f3486f1

Browse files
authored
feat: add serviceLabelSelector for label-based Service filtering (#307)
* feat: add serviceLabelSelector for label-based Service filtering Add a new `serviceLabelSelector` configuration directive that filters which Service resources are watched by a k8s_gateway instance, using standard Kubernetes label selector syntax. Filtering is applied at the API level via ListOptions.LabelSelector so non-matching Services are never fetched or cached. This enables split-horizon DNS setups where multiple k8s_gateway instances serve different network views without Services leaking across DNS views. Signed-off-by: Zadkiel AHARONIAN <hello@zadkiel.fr> * feat: support multiple serviceLabelSelectors for OR-of-ANDs filtering Each selector creates a separate Service informer; lookupServiceIndex queries all informers and deduplicates results by namespace/name. Selectors are normalized at parse time via labels.Parse().String(). Rename directive and Helm value from serviceLabelSelector (singular) to serviceLabelSelectors (plural) to reflect the new list semantics. Signed-off-by: Zadkiel AHARONIAN <hello@zadkiel.fr> * feat(helm): add serviceLabelSelectors docs and configmap tests Add helm-unittest cases for single selector, multiple selectors, and empty (omitted) rendering. Update chart README parameter table. Signed-off-by: Zadkiel AHARONIAN <hello@zadkiel.fr> * refactor(tests): use service-oriented naming in test fixtures Replace kitchen/bedroom/garage test names with service1/service2/service3 to better reflect the domain being tested. * refactor(tests): simplify label selectors to use app= pattern Use consistent app=serviceN labels across both TestServiceLabelSelector and TestMultiSelectorServiceLookup for clarity. * refactor(tests): normalize remaining label selectors to app= pattern * fix: reject empty strings in serviceLabelSelectors parsing * test: verify error messages in TestServiceLabelSelectorParsing Add expectedErr substring assertions for all error test cases. --------- Signed-off-by: Zadkiel AHARONIAN <hello@zadkiel.fr>
1 parent c39ee4b commit f3486f1

11 files changed

Lines changed: 515 additions & 22 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ k8s_gateway [ZONES...]
5858
resources [RESOURCES...]
5959
ingressClasses [CLASSES...]
6060
gatewayClasses [CLASSES...]
61+
serviceLabelSelectors SELECTOR [SELECTOR...]
6162
ttl TTL
6263
apex APEX
6364
secondary SECONDARY
@@ -69,6 +70,7 @@ k8s_gateway [ZONES...]
6970
* `resources` a subset of supported Kubernetes resources to watch. Available options are `[ Ingress | Service | HTTPRoute | TLSRoute | GRPCRoute | DNSEndpoint ]`. If no resources are specified only `Ingress` and `Service` will be monitored
7071
* `ingressClasses` to filter `Ingress` resources by `ingressClassName` values. Watches all by default.
7172
* `gatewayClasses` to filter `Gateway` resources by `gatewayClassName` values. Watches all by default.
73+
* `serviceLabelSelectors` to filter `Service` resources by labels using one or more [Kubernetes label selector](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#label-selectors) strings. Each selector creates a separate watch; results are merged. Watches all by default.
7274
* `ttl` can be used to override the default TTL value of 60 seconds.
7375
* `apex` can be used to override the default apex record value of `{ReleaseName}-k8s-gateway.{Namespace}`
7476
* `secondary` can be used to specify the optional apex record value of a peer nameserver running in the cluster (see `Dual Nameserver Deployment` section below).

charts/k8s-gateway/Chart.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ apiVersion: v2
22
name: k8s-gateway
33
description: A Helm chart for the k8s_gateway CoreDNS plugin
44
type: application
5-
version: 3.6.1
5+
version: 3.7.0
66
appVersion: 1.7.0
77
home: https://github.com/k8s-gateway/k8s_gateway
88
sources:
@@ -13,4 +13,4 @@ maintainers:
1313
annotations:
1414
artifacthub.io/changes: |
1515
- kind: added
16-
description: add project URL to chart metadata
16+
description: support multiple serviceLabelSelectors for OR-of-ANDs filtering

charts/k8s-gateway/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ The following table lists the configurable parameters of the k8s_gateway chart a
1515
| `watchedResources` | Resources to watch, e.g. `watchedResources: ["Ingress"]` | `["Ingress", "Service"]`|
1616
| `filters.ingressClasses` | Filter Ingress resources by their IngressClassName property | `[]` |
1717
| `filters.gatewayClasses` | Filter Gateway resources by their GatewayClassName property | `[]` |
18+
| `filters.serviceLabelSelectors` | Filter Service resources by label selectors. Each selector creates a separate watch; results are merged | `[]` |
1819
| `fallthrough.enabled` | Enable fallthrough support | `false` |
1920
| `fallthrough.zones` | List of zones to enable fallthrough on | `[]` |
2021
| `ttl` | TTL for non-apex responses (in seconds) | `300` |

charts/k8s-gateway/templates/configmap.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ data:
3636
{{- if .Values.filters.gatewayClasses }}
3737
gatewayClasses {{ join " " .Values.filters.gatewayClasses }}
3838
{{- end }}
39+
{{- if .Values.filters.serviceLabelSelectors }}
40+
serviceLabelSelectors{{ range .Values.filters.serviceLabelSelectors }} {{ . | quote }}{{ end }}
41+
{{- end }}
3942
{{- if .Values.fallthrough.enabled }}
4043
fallthrough {{- range .Values.fallthrough.zones }} {{ . }} {{- end }}
4144
{{- end }}

charts/k8s-gateway/tests/configmap_test.yaml

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,98 @@ tests:
206206
reload
207207
loadbalance
208208
}
209+
- it: Should render ConfigMap with single serviceLabelSelector
210+
set:
211+
domain: "example.com"
212+
watchedResources:
213+
- Service
214+
ttl: 300
215+
filters:
216+
serviceLabelSelectors:
217+
- "env=prod"
218+
template: templates/configmap.yaml
219+
asserts:
220+
- hasDocuments:
221+
count: 1
222+
- matchRegex:
223+
path: data.Corefile
224+
pattern: 'serviceLabelSelectors "env=prod"'
225+
- equal:
226+
path: data.Corefile
227+
value: |-
228+
.:1053 {
229+
k8s_gateway example.com {
230+
apex RELEASE-NAME-k8s-gateway.NAMESPACE
231+
ttl 300
232+
resources Service
233+
serviceLabelSelectors "env=prod"
234+
}
235+
log
236+
errors
237+
health {
238+
lameduck 5s
239+
}
240+
ready
241+
prometheus 0.0.0.0:9153
242+
forward . /etc/resolv.conf
243+
loop
244+
reload
245+
loadbalance
246+
}
247+
- it: Should render ConfigMap with multiple serviceLabelSelectors
248+
set:
249+
domain: "example.com"
250+
watchedResources:
251+
- Service
252+
ttl: 300
253+
filters:
254+
serviceLabelSelectors:
255+
- "app=service1,tier=frontend"
256+
- "app=service2,tier=backend"
257+
template: templates/configmap.yaml
258+
asserts:
259+
- hasDocuments:
260+
count: 1
261+
- matchRegex:
262+
path: data.Corefile
263+
pattern: 'serviceLabelSelectors "app=service1,tier=frontend" "app=service2,tier=backend"'
264+
- equal:
265+
path: data.Corefile
266+
value: |-
267+
.:1053 {
268+
k8s_gateway example.com {
269+
apex RELEASE-NAME-k8s-gateway.NAMESPACE
270+
ttl 300
271+
resources Service
272+
serviceLabelSelectors "app=service1,tier=frontend" "app=service2,tier=backend"
273+
}
274+
log
275+
errors
276+
health {
277+
lameduck 5s
278+
}
279+
ready
280+
prometheus 0.0.0.0:9153
281+
forward . /etc/resolv.conf
282+
loop
283+
reload
284+
loadbalance
285+
}
286+
- it: Should not render serviceLabelSelectors when empty
287+
set:
288+
domain: "example.com"
289+
watchedResources:
290+
- Service
291+
ttl: 300
292+
filters:
293+
serviceLabelSelectors: []
294+
template: templates/configmap.yaml
295+
asserts:
296+
- hasDocuments:
297+
count: 1
298+
- notMatchRegex:
299+
path: data.Corefile
300+
pattern: "serviceLabelSelectors"
209301
- it: Should render ConfigMap without scheme prefix when empty
210302
set:
211303
domain: "example.com"

charts/k8s-gateway/values.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ watchedResources: ["Ingress", "Service"]
3939
filters:
4040
ingressClasses: []
4141
gatewayClasses: []
42+
serviceLabelSelectors: []
4243

4344
# Service name of a secondary DNS server (should be `serviceName.namespace`)
4445
secondary: ""

gateway.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,9 @@ type Gateway struct {
6363
}
6464

6565
type ResourceFilters struct {
66-
ingressClasses []string
67-
gatewayClasses []string
66+
ingressClasses []string
67+
gatewayClasses []string
68+
serviceLabelSelectors []string
6869
}
6970

7071
// Create a new Gateway instance
@@ -391,7 +392,7 @@ func (gw *Gateway) SelfAddress(state request.Request) (records []dns.RR) {
391392
addrs1 = append(addrs1, results...)
392393
}
393394
results, raws = resource.lookup([]string{gw.secondNS})
394-
_ = raws
395+
_ = raws
395396
if len(results) > 0 {
396397
addrs2 = append(addrs2, results...)
397398
}

kubernetes.go

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -126,17 +126,25 @@ func newKubeController(ctx context.Context, c *kubernetes.Clientset, gw *gateway
126126
log.Infof("Ingress controller initialized")
127127

128128
case "Service":
129-
serviceController := cache.NewSharedIndexInformer(
130-
&cache.ListWatch{
131-
ListFunc: serviceLister(ctx, ctrl.client, core.NamespaceAll),
132-
WatchFunc: serviceWatcher(ctx, ctrl.client, core.NamespaceAll),
133-
},
134-
&core.Service{},
135-
defaultResyncPeriod,
136-
cache.Indexers{serviceHostnameIndex: serviceHostnameIndexFunc},
137-
)
138-
resource.lookup = lookupServiceIndex(serviceController)
139-
ctrl.controllers = append(ctrl.controllers, serviceController)
129+
selectors := originalGateway.resourceFilters.serviceLabelSelectors
130+
if len(selectors) == 0 {
131+
selectors = []string{""}
132+
}
133+
var serviceControllers []cache.SharedIndexInformer
134+
for _, sel := range selectors {
135+
sc := cache.NewSharedIndexInformer(
136+
&cache.ListWatch{
137+
ListFunc: serviceLister(ctx, ctrl.client, core.NamespaceAll, sel),
138+
WatchFunc: serviceWatcher(ctx, ctrl.client, core.NamespaceAll, sel),
139+
},
140+
&core.Service{},
141+
defaultResyncPeriod,
142+
cache.Indexers{serviceHostnameIndex: serviceHostnameIndexFunc},
143+
)
144+
serviceControllers = append(serviceControllers, sc)
145+
ctrl.controllers = append(ctrl.controllers, sc)
146+
}
147+
resource.lookup = lookupServiceIndex(serviceControllers)
140148
log.Infof("Service controller initialized")
141149
}
142150
}
@@ -380,8 +388,9 @@ func ingressLister(ctx context.Context, c kubernetes.Interface, ns string) func(
380388
}
381389
}
382390

383-
func serviceLister(ctx context.Context, c kubernetes.Interface, ns string) func(metav1.ListOptions) (runtime.Object, error) {
391+
func serviceLister(ctx context.Context, c kubernetes.Interface, ns string, labelSelector string) func(metav1.ListOptions) (runtime.Object, error) {
384392
return func(opts metav1.ListOptions) (runtime.Object, error) {
393+
opts.LabelSelector = labelSelector
385394
return c.CoreV1().Services(ns).List(ctx, opts)
386395
}
387396
}
@@ -416,8 +425,9 @@ func ingressWatcher(ctx context.Context, c kubernetes.Interface, ns string) func
416425
}
417426
}
418427

419-
func serviceWatcher(ctx context.Context, c kubernetes.Interface, ns string) func(metav1.ListOptions) (watch.Interface, error) {
428+
func serviceWatcher(ctx context.Context, c kubernetes.Interface, ns string, labelSelector string) func(metav1.ListOptions) (watch.Interface, error) {
420429
return func(opts metav1.ListOptions) (watch.Interface, error) {
430+
opts.LabelSelector = labelSelector
421431
return c.CoreV1().Services(ns).Watch(ctx, opts)
422432
}
423433
}
@@ -568,12 +578,26 @@ func checkDomainValid(domain string) bool {
568578
return false
569579
}
570580

571-
func lookupServiceIndex(ctrl cache.SharedIndexInformer) func([]string) (results []netip.Addr, raws []string) {
581+
func lookupServiceIndex(controllers []cache.SharedIndexInformer) func([]string) (results []netip.Addr, raws []string) {
572582
return func(indexKeys []string) (result []netip.Addr, raw []string) {
583+
seen := make(map[string]struct{})
573584
var objs []interface{}
574-
for _, key := range indexKeys {
575-
obj, _ := ctrl.GetIndexer().ByIndex(serviceHostnameIndex, strings.ToLower(key))
576-
objs = append(objs, obj...)
585+
for _, ctrl := range controllers {
586+
for _, key := range indexKeys {
587+
obj, _ := ctrl.GetIndexer().ByIndex(serviceHostnameIndex, strings.ToLower(key))
588+
for _, o := range obj {
589+
svc, ok := o.(*core.Service)
590+
if !ok {
591+
continue
592+
}
593+
nsName := svc.Namespace + "/" + svc.Name
594+
if _, dup := seen[nsName]; dup {
595+
continue
596+
}
597+
seen[nsName] = struct{}{}
598+
objs = append(objs, o)
599+
}
600+
}
577601
}
578602
log.Debugf("Found %d matching Service objects", len(objs))
579603
for _, obj := range objs {

0 commit comments

Comments
 (0)