From 8fc4343fb4248742176281083d1153e3d61e8c2d Mon Sep 17 00:00:00 2001 From: Atanas Chuchev Date: Mon, 12 Jan 2026 14:58:47 +0200 Subject: [PATCH 1/6] Add ConfigMaps discovery and reporting support - Add ConfigMaps field to Snapshot struct for data upload - Register ConfigMaps informer in kubernetesNativeResources - Add ark/configmaps extractor function for data processing - Update test coverage to include ConfigMaps - Add ConfigMaps configuration to disco-agent templates - Update example configurations and test snapshots This enhancement allows the agent to discover and report ConfigMap resources alongside existing resources like Pods and Daemonsets. --- .../disco-agent/templates/configmap.yaml | 6 +++++ .../__snapshot__/configmap_test.yaml.snap | 24 +++++++++++++++++++ examples/machinehub.yaml | 8 +++++++ examples/machinehub/input.json | 6 +++++ internal/cyberark/dataupload/dataupload.go | 2 ++ pkg/client/client_cyberark.go | 3 +++ pkg/client/client_cyberark_test.go | 1 + pkg/datagatherer/k8s/dynamic.go | 3 +++ 8 files changed, 53 insertions(+) diff --git a/deploy/charts/disco-agent/templates/configmap.yaml b/deploy/charts/disco-agent/templates/configmap.yaml index 231a26cd..85d7d063 100644 --- a/deploy/charts/disco-agent/templates/configmap.yaml +++ b/deploy/charts/disco-agent/templates/configmap.yaml @@ -107,3 +107,9 @@ data: resource-type: version: v1 resource: pods + - kind: k8s-dynamic + name: ark/configmaps + config: + resource-type: + version: v1 + resource: configmaps diff --git a/deploy/charts/disco-agent/tests/__snapshot__/configmap_test.yaml.snap b/deploy/charts/disco-agent/tests/__snapshot__/configmap_test.yaml.snap index 2c70df00..88a0c103 100644 --- a/deploy/charts/disco-agent/tests/__snapshot__/configmap_test.yaml.snap +++ b/deploy/charts/disco-agent/tests/__snapshot__/configmap_test.yaml.snap @@ -95,6 +95,12 @@ custom-cluster-description: resource-type: version: v1 resource: pods + - kind: k8s-dynamic + name: ark/configmaps + config: + resource-type: + version: v1 + resource: configmaps kind: ConfigMap metadata: labels: @@ -202,6 +208,12 @@ custom-cluster-name: resource-type: version: v1 resource: pods + - kind: k8s-dynamic + name: ark/configmaps + config: + resource-type: + version: v1 + resource: configmaps kind: ConfigMap metadata: labels: @@ -309,6 +321,12 @@ custom-period: resource-type: version: v1 resource: pods + - kind: k8s-dynamic + name: ark/configmaps + config: + resource-type: + version: v1 + resource: configmaps kind: ConfigMap metadata: labels: @@ -416,6 +434,12 @@ defaults: resource-type: version: v1 resource: pods + - kind: k8s-dynamic + name: ark/configmaps + config: + resource-type: + version: v1 + resource: configmaps kind: ConfigMap metadata: labels: diff --git a/examples/machinehub.yaml b/examples/machinehub.yaml index ea0b28e5..4b5f0282 100644 --- a/examples/machinehub.yaml +++ b/examples/machinehub.yaml @@ -125,3 +125,11 @@ data-gatherers: resource-type: version: v1 resource: pods + +# Gather Kubernetes configmaps +- name: ark/configmaps + kind: "k8s-dynamic" + config: + resource-type: + version: v1 + resource: configmaps \ No newline at end of file diff --git a/examples/machinehub/input.json b/examples/machinehub/input.json index 2cdba65c..244f39a9 100644 --- a/examples/machinehub/input.json +++ b/examples/machinehub/input.json @@ -118,6 +118,12 @@ "items": [] } }, + { + "data-gatherer": "ark/configmaps", + "data": { + "items": [] + } + }, { "data-gatherer": "ark/serviceaccounts", "data": { diff --git a/internal/cyberark/dataupload/dataupload.go b/internal/cyberark/dataupload/dataupload.go index b9ccb5f5..63502660 100644 --- a/internal/cyberark/dataupload/dataupload.go +++ b/internal/cyberark/dataupload/dataupload.go @@ -82,6 +82,8 @@ type Snapshot struct { Daemonsets []runtime.Object `json:"daemonsets"` // Pods is a list of Pod resources in the cluster. Pods []runtime.Object `json:"pods"` + // ConfigMaps is a list of ConfigMap resources in the cluster. + ConfigMaps []runtime.Object `json:"configmaps"` } // PutSnapshot PUTs the supplied snapshot to an [AWS presigned URL] which it obtains via the CyberArk inventory API. diff --git a/pkg/client/client_cyberark.go b/pkg/client/client_cyberark.go index c9310265..5c0e7578 100644 --- a/pkg/client/client_cyberark.go +++ b/pkg/client/client_cyberark.go @@ -186,6 +186,9 @@ var defaultExtractorFunctions = map[string]func(*api.DataReading, *dataupload.Sn "ark/pods": func(r *api.DataReading, s *dataupload.Snapshot) error { return extractResourceListFromReading(r, &s.Pods) }, + "ark/configmaps": func(r *api.DataReading, s *dataupload.Snapshot) error { + return extractResourceListFromReading(r, &s.ConfigMaps) + }, } // convertDataReadings processes a list of DataReadings using the provided diff --git a/pkg/client/client_cyberark_test.go b/pkg/client/client_cyberark_test.go index f0df5c64..75a95505 100644 --- a/pkg/client/client_cyberark_test.go +++ b/pkg/client/client_cyberark_test.go @@ -89,6 +89,7 @@ var defaultDynamicDatagathererNames = []string{ "ark/statefulsets", "ark/daemonsets", "ark/pods", + "ark/configmaps", } // fakeReadings returns a set of fake readings that includes a discovery reading diff --git a/pkg/datagatherer/k8s/dynamic.go b/pkg/datagatherer/k8s/dynamic.go index 92f10c33..244c76fc 100644 --- a/pkg/datagatherer/k8s/dynamic.go +++ b/pkg/datagatherer/k8s/dynamic.go @@ -144,6 +144,9 @@ var kubernetesNativeResources = map[schema.GroupVersionResource]sharedInformerFu corev1.SchemeGroupVersion.WithResource("pods"): func(sharedFactory informers.SharedInformerFactory) k8scache.SharedIndexInformer { return sharedFactory.Core().V1().Pods().Informer() }, + corev1.SchemeGroupVersion.WithResource("configmaps"): func(sharedFactory informers.SharedInformerFactory) k8scache.SharedIndexInformer { + return sharedFactory.Core().V1().ConfigMaps().Informer() + }, corev1.SchemeGroupVersion.WithResource("nodes"): func(sharedFactory informers.SharedInformerFactory) k8scache.SharedIndexInformer { return sharedFactory.Core().V1().Nodes().Informer() }, From 4698ff0c6bec614ca41ffc1724fbd78f16997985 Mon Sep 17 00:00:00 2001 From: Atanas Chuchev Date: Wed, 21 Jan 2026 17:04:58 +0200 Subject: [PATCH 2/6] Add label and annotation filtering support to ConfigDynamic --- .../disco-agent/templates/configmap.yaml | 2 + pkg/datagatherer/k8s/cache.go | 2 + pkg/datagatherer/k8s/dynamic.go | 137 ++++++- pkg/datagatherer/k8s/dynamic_test.go | 384 ++++++++++++++++++ 4 files changed, 520 insertions(+), 5 deletions(-) diff --git a/deploy/charts/disco-agent/templates/configmap.yaml b/deploy/charts/disco-agent/templates/configmap.yaml index 85d7d063..ed126bd3 100644 --- a/deploy/charts/disco-agent/templates/configmap.yaml +++ b/deploy/charts/disco-agent/templates/configmap.yaml @@ -109,6 +109,8 @@ data: resource: pods - kind: k8s-dynamic name: ark/configmaps + include-resources-by-labels: + conjur.org/name: "conjur-connect-configmap" config: resource-type: version: v1 diff --git a/pkg/datagatherer/k8s/cache.go b/pkg/datagatherer/k8s/cache.go index 4482f512..58f70da7 100644 --- a/pkg/datagatherer/k8s/cache.go +++ b/pkg/datagatherer/k8s/cache.go @@ -29,6 +29,8 @@ func (*realTime) now() time.Time { type cacheResource interface { GetUID() types.UID GetNamespace() string + GetLabels() map[string]string + GetAnnotations() map[string]string } func logCacheUpdateFailure(log logr.Logger, obj any, operation string) { diff --git a/pkg/datagatherer/k8s/dynamic.go b/pkg/datagatherer/k8s/dynamic.go index 244c76fc..8fc49dee 100644 --- a/pkg/datagatherer/k8s/dynamic.go +++ b/pkg/datagatherer/k8s/dynamic.go @@ -76,6 +76,18 @@ type ConfigDynamic struct { IncludeNamespaces []string `yaml:"include-namespaces"` // FieldSelectors is a list of field selectors to use when listing this resource FieldSelectors []string `yaml:"field-selectors"` + // IncludeResourcesByLabels filters to include only resources that have all of the specified labels. + // This controls which resources are collected, not which labels are included. + IncludeResourcesByLabels map[string]string `yaml:"include-resources-by-labels"` + // ExcludeResourcesByLabels filters to exclude resources that have any of the specified labels. + // This controls which resources are collected, not which labels are excluded. + ExcludeResourcesByLabels map[string]string `yaml:"exclude-resources-by-labels"` + // IncludeResourcesByAnnotations filters to include only resources that have all of the specified annotations. + // This controls which resources are collected, not which annotations are included. + IncludeResourcesByAnnotations map[string]string `yaml:"include-resources-by-annotations"` + // ExcludeResourcesByAnnotations filters to exclude resources that have any of the specified annotations. + // This controls which resources are collected, not which annotations are excluded. + ExcludeResourcesByAnnotations map[string]string `yaml:"exclude-resources-by-annotations"` } // UnmarshalYAML unmarshals the ConfigDynamic resolving GroupVersionResource. @@ -87,9 +99,13 @@ func (c *ConfigDynamic) UnmarshalYAML(unmarshal func(any) error) error { Version string `yaml:"version"` Resource string `yaml:"resource"` } `yaml:"resource-type"` - ExcludeNamespaces []string `yaml:"exclude-namespaces"` - IncludeNamespaces []string `yaml:"include-namespaces"` - FieldSelectors []string `yaml:"field-selectors"` + ExcludeNamespaces []string `yaml:"exclude-namespaces"` + IncludeNamespaces []string `yaml:"include-namespaces"` + FieldSelectors []string `yaml:"field-selectors"` + IncludeResourcesByLabels map[string]string `yaml:"include-resources-by-labels"` + ExcludeResourcesByLabels map[string]string `yaml:"exclude-resources-by-labels"` + IncludeResourcesByAnnotations map[string]string `yaml:"include-resources-by-annotations"` + ExcludeResourcesByAnnotations map[string]string `yaml:"exclude-resources-by-annotations"` }{} err := unmarshal(&aux) if err != nil { @@ -103,6 +119,10 @@ func (c *ConfigDynamic) UnmarshalYAML(unmarshal func(any) error) error { c.ExcludeNamespaces = aux.ExcludeNamespaces c.IncludeNamespaces = aux.IncludeNamespaces c.FieldSelectors = aux.FieldSelectors + c.IncludeResourcesByLabels = aux.IncludeResourcesByLabels + c.ExcludeResourcesByLabels = aux.ExcludeResourcesByLabels + c.IncludeResourcesByAnnotations = aux.IncludeResourcesByAnnotations + c.ExcludeResourcesByAnnotations = aux.ExcludeResourcesByAnnotations return nil } @@ -114,6 +134,14 @@ func (c *ConfigDynamic) validate() error { errs = append(errs, "cannot set excluded and included namespaces") } + if len(c.ExcludeResourcesByLabels) > 0 && len(c.IncludeResourcesByLabels) > 0 { + errs = append(errs, "cannot use both include-resources-by-labels and exclude-resources-by-labels") + } + + if len(c.ExcludeResourcesByAnnotations) > 0 && len(c.IncludeResourcesByAnnotations) > 0 { + errs = append(errs, "cannot use both include-resources-by-annotations and exclude-resources-by-annotations") + } + if c.GroupVersionResource.Resource == "" { errs = append(errs, "invalid configuration: GroupVersionResource.Resource cannot be empty") } @@ -221,6 +249,10 @@ func (c *ConfigDynamic) newDataGathererWithClient(ctx context.Context, cl dynami fieldSelector: fieldSelector.String(), namespaces: c.IncludeNamespaces, cache: dgCache, + includeLabels: c.IncludeResourcesByLabels, + excludeLabels: c.ExcludeResourcesByLabels, + includeAnnotations: c.IncludeResourcesByAnnotations, + excludeAnnotations: c.ExcludeResourcesByAnnotations, } // In order to reduce memory usage that might come from using Dynamic Informers @@ -304,6 +336,13 @@ type DataGathererDynamic struct { ExcludeAnnotKeys []*regexp.Regexp ExcludeLabelKeys []*regexp.Regexp + + // includeLabels and excludeLabels filter resources based on their labels + includeLabels map[string]string + excludeLabels map[string]string + // includeAnnotations and excludeAnnotations filter resources based on their annotations + includeAnnotations map[string]string + excludeAnnotations map[string]string } // Run starts the dynamic data gatherer's informers for resource collection. @@ -369,9 +408,23 @@ func (g *DataGathererDynamic) Fetch() (any, int, error) { cacheObject := item.Object.(*api.GatheredResource) if resource, ok := cacheObject.Resource.(cacheResource); ok { namespace := resource.GetNamespace() - if isIncludedNamespace(namespace, fetchNamespaces) { - items = append(items, cacheObject) + if !isIncludedNamespace(namespace, fetchNamespaces) { + continue + } + + // filter by labels + labels := resource.GetLabels() + if !matchesLabelFilter(labels, g.includeLabels, g.excludeLabels) { + continue + } + + // filter by annotations + annotations := resource.GetAnnotations() + if !matchesAnnotationFilter(annotations, g.includeAnnotations, g.excludeAnnotations) { + continue } + + items = append(items, cacheObject) continue } return nil, -1, fmt.Errorf("failed to parse cached resource") @@ -565,6 +618,80 @@ func isIncludedNamespace(namespace string, namespaces []string) bool { return slices.Contains(namespaces, namespace) } +// matchesLabelFilter checks if the resource labels match the include/exclude filters. +// If includeLabels is set, all key-value pairs must match for the resource to be included. +// An empty string value means "match any value for this key" (key-only matching). +// If excludeLabels is set, any matching key-value pair will exclude the resource. +func matchesLabelFilter(resourceLabels, includeLabels, excludeLabels map[string]string) bool { + // Check exclude labels first + if len(excludeLabels) > 0 { + for key, value := range excludeLabels { + if resourceValue, exists := resourceLabels[key]; exists { + // If exclude value is empty, exclude any resource with this key + // Otherwise, only exclude if the value also matches + if value == "" || resourceValue == value { + return false + } + } + } + } + + // Check include labels + if len(includeLabels) > 0 { + for key, value := range includeLabels { + resourceValue, exists := resourceLabels[key] + if !exists { + // Required label key is missing, filter it out + return false + } + // If include value is empty, we only care that the key exists + // Otherwise, the value must also match + if value != "" && resourceValue != value { + return false + } + } + } + + return true +} + +// matchesAnnotationFilter checks if the resource annotations match the include/exclude filters. +// If includeAnnotations is set, all key-value pairs must match for the resource to be included. +// An empty string value means "match any value for this key" (key-only matching). +// If excludeAnnotations is set, any matching key-value pair will exclude the resource. +func matchesAnnotationFilter(resourceAnnotations, includeAnnotations, excludeAnnotations map[string]string) bool { + // Check exclude annotations first + if len(excludeAnnotations) > 0 { + for key, value := range excludeAnnotations { + if resourceValue, exists := resourceAnnotations[key]; exists { + // If exclude value is empty, exclude any resource with this key + // Otherwise, only exclude if the value also matches + if value == "" || resourceValue == value { + return false + } + } + } + } + + // Check include annotations + if len(includeAnnotations) > 0 { + for key, value := range includeAnnotations { + resourceValue, exists := resourceAnnotations[key] + if !exists { + // Required annotation key is missing, filter it out + return false + } + // If include value is empty, we only care that the key exists + // Otherwise, the value must also match + if value != "" && resourceValue != value { + return false + } + } + } + + return true +} + func isNativeResource(gvr schema.GroupVersionResource) bool { _, ok := kubernetesNativeResources[gvr] return ok diff --git a/pkg/datagatherer/k8s/dynamic_test.go b/pkg/datagatherer/k8s/dynamic_test.go index 26b6ae90..03796fae 100644 --- a/pkg/datagatherer/k8s/dynamic_test.go +++ b/pkg/datagatherer/k8s/dynamic_test.go @@ -1264,3 +1264,387 @@ func toRegexps(keys []string) []*regexp.Regexp { } return regexps } + +func TestConfigDynamicValidate_LabelAndAnnotationFilters(t *testing.T) { + tests := []struct { + Config ConfigDynamic + ExpectedError string + }{ + { + Config: ConfigDynamic{ + GroupVersionResource: schema.GroupVersionResource{ + Group: "", + Version: "v1", + Resource: "configmaps", + }, + IncludeResourcesByLabels: map[string]string{"app": "test"}, + ExcludeResourcesByLabels: map[string]string{"env": "prod"}, + }, + ExpectedError: "cannot use both include-resources-by-labels and exclude-resources-by-labels", + }, + { + Config: ConfigDynamic{ + GroupVersionResource: schema.GroupVersionResource{ + Group: "", + Version: "v1", + Resource: "configmaps", + }, + IncludeResourcesByAnnotations: map[string]string{"app": "test"}, + ExcludeResourcesByAnnotations: map[string]string{"env": "prod"}, + }, + ExpectedError: "cannot use both include-resources-by-annotations and exclude-resources-by-annotations", + }, + { + Config: ConfigDynamic{ + GroupVersionResource: schema.GroupVersionResource{ + Group: "", + Version: "v1", + Resource: "configmaps", + }, + IncludeResourcesByLabels: map[string]string{"app": "test"}, + }, + ExpectedError: "", + }, + { + Config: ConfigDynamic{ + GroupVersionResource: schema.GroupVersionResource{ + Group: "", + Version: "v1", + Resource: "configmaps", + }, + ExcludeResourcesByLabels: map[string]string{"app": "test"}, + }, + ExpectedError: "", + }, + } + + for _, test := range tests { + err := test.Config.validate() + if err == nil && test.ExpectedError != "" { + t.Errorf("expected error: %q, got: nil", test.ExpectedError) + } + if err != nil && test.ExpectedError == "" { + t.Errorf("expected no error, got: %s", err.Error()) + } + if err != nil && test.ExpectedError != "" && !strings.Contains(err.Error(), test.ExpectedError) { + t.Errorf("expected %s, got %s", test.ExpectedError, err.Error()) + } + } +} + +func TestMatchesLabelFilter(t *testing.T) { + tests := map[string]struct { + resourceLabels map[string]string + includeLabels map[string]string + excludeLabels map[string]string + expected bool + }{ + "no filters - should match": { + resourceLabels: map[string]string{"app": "test"}, + includeLabels: nil, + excludeLabels: nil, + expected: true, + }, + "include label with exact match": { + resourceLabels: map[string]string{"app": "test", "version": "1.0"}, + includeLabels: map[string]string{"app": "test"}, + excludeLabels: nil, + expected: true, + }, + "include label key exists with empty value (key-only match)": { + resourceLabels: map[string]string{"conjur.org/name": "my-secret", "app": "test"}, + includeLabels: map[string]string{"conjur.org/name": ""}, + excludeLabels: nil, + expected: true, + }, + "include label key missing": { + resourceLabels: map[string]string{"app": "test"}, + includeLabels: map[string]string{"env": "prod"}, + excludeLabels: nil, + expected: false, + }, + "include label value mismatch": { + resourceLabels: map[string]string{"app": "test"}, + includeLabels: map[string]string{"app": "prod"}, + excludeLabels: nil, + expected: false, + }, + "exclude label with exact match": { + resourceLabels: map[string]string{"app": "test", "env": "prod"}, + includeLabels: nil, + excludeLabels: map[string]string{"env": "prod"}, + expected: false, + }, + "exclude label key exists with empty value (key-only match)": { + resourceLabels: map[string]string{"internal": "true"}, + includeLabels: nil, + excludeLabels: map[string]string{"internal": ""}, + expected: false, + }, + "exclude label key missing": { + resourceLabels: map[string]string{"app": "test"}, + includeLabels: nil, + excludeLabels: map[string]string{"env": "prod"}, + expected: true, + }, + "exclude label value mismatch": { + resourceLabels: map[string]string{"app": "test"}, + includeLabels: nil, + excludeLabels: map[string]string{"app": "prod"}, + expected: true, + }, + "multiple include labels all match": { + resourceLabels: map[string]string{"app": "test", "env": "prod", "version": "1.0"}, + includeLabels: map[string]string{"app": "test", "env": "prod"}, + excludeLabels: nil, + expected: true, + }, + "multiple include labels one missing": { + resourceLabels: map[string]string{"app": "test"}, + includeLabels: map[string]string{"app": "test", "env": "prod"}, + excludeLabels: nil, + expected: false, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + result := matchesLabelFilter(tc.resourceLabels, tc.includeLabels, tc.excludeLabels) + if result != tc.expected { + t.Errorf("expected %v, got %v", tc.expected, result) + } + }) + } +} + +func TestMatchesAnnotationFilter(t *testing.T) { + tests := map[string]struct { + resourceAnnotations map[string]string + includeAnnotations map[string]string + excludeAnnotations map[string]string + expected bool + }{ + "no filters - should match": { + resourceAnnotations: map[string]string{"description": "test"}, + includeAnnotations: nil, + excludeAnnotations: nil, + expected: true, + }, + "include annotation with exact match": { + resourceAnnotations: map[string]string{"description": "test", "owner": "team"}, + includeAnnotations: map[string]string{"description": "test"}, + excludeAnnotations: nil, + expected: true, + }, + "include annotation key exists with empty value (key-only match)": { + resourceAnnotations: map[string]string{"prometheus.io/scrape": "true"}, + includeAnnotations: map[string]string{"prometheus.io/scrape": ""}, + excludeAnnotations: nil, + expected: true, + }, + "include annotation key missing": { + resourceAnnotations: map[string]string{"description": "test"}, + includeAnnotations: map[string]string{"owner": "team"}, + excludeAnnotations: nil, + expected: false, + }, + "exclude annotation with exact match": { + resourceAnnotations: map[string]string{"description": "test", "internal": "true"}, + includeAnnotations: nil, + excludeAnnotations: map[string]string{"internal": "true"}, + expected: false, + }, + "exclude annotation key exists with empty value (key-only match)": { + resourceAnnotations: map[string]string{"deprecated": "yes"}, + includeAnnotations: nil, + excludeAnnotations: map[string]string{"deprecated": ""}, + expected: false, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + result := matchesAnnotationFilter(tc.resourceAnnotations, tc.includeAnnotations, tc.excludeAnnotations) + if result != tc.expected { + t.Errorf("expected %v, got %v", tc.expected, result) + } + }) + } +} + +func TestDynamicGatherer_Fetch_WithLabelFilters(t *testing.T) { + ctx := t.Context() + + tests := map[string]struct { + config ConfigDynamic + addObjects []runtime.Object + expected []*api.GatheredResource + expectedCount int + }{ + "include labels - key and value match for conjur.org/name": { + config: ConfigDynamic{ + GroupVersionResource: schema.GroupVersionResource{Group: "test.io", Version: "v1", Resource: "testresources"}, + IncludeResourcesByLabels: map[string]string{"conjur.org/name": "conjur-connect-configmap"}, + }, + addObjects: []runtime.Object{ + getObjectAnnot("test.io/v1", "TestResource", "res-with-matching-label", "default", nil, map[string]any{"conjur.org/name": "conjur-connect-configmap"}), + getObjectAnnot("test.io/v1", "TestResource", "res-with-different-value", "default", nil, map[string]any{"conjur.org/name": "other-value"}), + getObjectAnnot("test.io/v1", "TestResource", "res-without-label", "default", nil, map[string]any{"app": "test"}), + }, + expectedCount: 1, + expected: []*api.GatheredResource{ + { + Resource: getObjectAnnot("test.io/v1", "TestResource", "res-with-matching-label", "default", nil, map[string]any{"conjur.org/name": "conjur-connect-configmap"}), + }, + }, + }, + "include labels - key and value match": { + config: ConfigDynamic{ + GroupVersionResource: schema.GroupVersionResource{Group: "test.io", Version: "v1", Resource: "testresources"}, + IncludeResourcesByLabels: map[string]string{"app": "myapp"}, + }, + addObjects: []runtime.Object{ + getObjectAnnot("test.io/v1", "TestResource", "res-app-myapp", "default", nil, map[string]any{"app": "myapp"}), + getObjectAnnot("test.io/v1", "TestResource", "res-app-other", "default", nil, map[string]any{"app": "other"}), + }, + expectedCount: 1, + expected: []*api.GatheredResource{ + { + Resource: getObjectAnnot("test.io/v1", "TestResource", "res-app-myapp", "default", nil, map[string]any{"app": "myapp"}), + }, + }, + }, + "exclude labels - key only match": { + config: ConfigDynamic{ + GroupVersionResource: schema.GroupVersionResource{Group: "test.io", Version: "v1", Resource: "testresources"}, + ExcludeResourcesByLabels: map[string]string{"internal": ""}, + }, + addObjects: []runtime.Object{ + getObjectAnnot("test.io/v1", "TestResource", "res-internal", "default", nil, map[string]any{"internal": "true"}), + getObjectAnnot("test.io/v1", "TestResource", "res-public", "default", nil, map[string]any{"public": "true"}), + }, + expectedCount: 1, + expected: []*api.GatheredResource{ + { + Resource: getObjectAnnot("test.io/v1", "TestResource", "res-public", "default", nil, map[string]any{"public": "true"}), + }, + }, + }, + "exclude labels - key and value match": { + config: ConfigDynamic{ + GroupVersionResource: schema.GroupVersionResource{Group: "test.io", Version: "v1", Resource: "testresources"}, + ExcludeResourcesByLabels: map[string]string{"env": "test"}, + }, + addObjects: []runtime.Object{ + getObjectAnnot("test.io/v1", "TestResource", "res-env-test", "default", nil, map[string]any{"env": "test"}), + getObjectAnnot("test.io/v1", "TestResource", "res-env-prod", "default", nil, map[string]any{"env": "prod"}), + }, + expectedCount: 1, + expected: []*api.GatheredResource{ + { + Resource: getObjectAnnot("test.io/v1", "TestResource", "res-env-prod", "default", nil, map[string]any{"env": "prod"}), + }, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + cl := fake.NewSimpleDynamicClient(runtime.NewScheme(), tc.addObjects...) + dg, err := tc.config.newDataGathererWithClient(ctx, cl, nil) + require.NoError(t, err) + + dgd := dg.(*DataGathererDynamic) + + // Start the data gatherer + go func() { + if err = dgd.Run(ctx); err != nil { + t.Errorf("unexpected client error: %+v", err) + } + }() + + err = dgd.WaitForCacheSync(ctx) + require.NoError(t, err) + + // Give some time for the cache to populate + time.Sleep(200 * time.Millisecond) + + res, count, err := dgd.Fetch() + require.NoError(t, err) + + dynamicData := res.(*api.DynamicData) + assert.Equal(t, tc.expectedCount, count) + assert.Len(t, dynamicData.Items, tc.expectedCount) + + sortGatheredResources(dynamicData.Items) + sortGatheredResources(tc.expected) + + for i, item := range dynamicData.Items { + expectedItem := tc.expected[i] + assert.Equal(t, expectedItem.Resource.(*unstructured.Unstructured).GetName(), + item.Resource.(*unstructured.Unstructured).GetName()) + } + }) + } +} + +func TestDynamicGatherer_Fetch_WithAnnotationFilters(t *testing.T) { + ctx := t.Context() + + tests := map[string]struct { + config ConfigDynamic + addObjects []runtime.Object + expectedCount int + }{ + "include annotations - key only match": { + config: ConfigDynamic{ + GroupVersionResource: schema.GroupVersionResource{Group: "test.io", Version: "v1", Resource: "testresources"}, + IncludeResourcesByAnnotations: map[string]string{"prometheus.io/scrape": ""}, + }, + addObjects: []runtime.Object{ + getObjectAnnot("test.io/v1", "TestResource", "res-with-annot", "default", map[string]any{"prometheus.io/scrape": "true"}, nil), + getObjectAnnot("test.io/v1", "TestResource", "res-without-annot", "default", map[string]any{"description": "test"}, nil), + }, + expectedCount: 1, + }, + "exclude annotations - key and value match": { + config: ConfigDynamic{ + GroupVersionResource: schema.GroupVersionResource{Group: "test.io", Version: "v1", Resource: "testresources"}, + ExcludeResourcesByAnnotations: map[string]string{"deprecated": "true"}, + }, + addObjects: []runtime.Object{ + getObjectAnnot("test.io/v1", "TestResource", "res-deprecated", "default", map[string]any{"deprecated": "true"}, nil), + getObjectAnnot("test.io/v1", "TestResource", "res-active", "default", map[string]any{"active": "true"}, nil), + }, + expectedCount: 1, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + cl := fake.NewSimpleDynamicClient(runtime.NewScheme(), tc.addObjects...) + dg, err := tc.config.newDataGathererWithClient(ctx, cl, nil) + require.NoError(t, err) + + dgd := dg.(*DataGathererDynamic) + + // Start the data gatherer + go func() { + if err = dgd.Run(ctx); err != nil { + t.Errorf("unexpected client error: %+v", err) + } + }() + + err = dgd.WaitForCacheSync(ctx) + require.NoError(t, err) + + // Give some time for the cache to populate + time.Sleep(200 * time.Millisecond) + + _, count, err := dgd.Fetch() + require.NoError(t, err) + + assert.Equal(t, tc.expectedCount, count) + }) + } +} From a052007478ba1345484cb1d60e0300637ce4d8a3 Mon Sep 17 00:00:00 2001 From: Atanas Chuchev Date: Wed, 21 Jan 2026 17:27:51 +0200 Subject: [PATCH 3/6] Refactor label and annotation filter tests for consistency in ConfigDynamic --- pkg/datagatherer/k8sdynamic/dynamic_test.go | 24 ++++++++++----------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/pkg/datagatherer/k8sdynamic/dynamic_test.go b/pkg/datagatherer/k8sdynamic/dynamic_test.go index 335ff431..0013fbf9 100644 --- a/pkg/datagatherer/k8sdynamic/dynamic_test.go +++ b/pkg/datagatherer/k8sdynamic/dynamic_test.go @@ -1483,8 +1483,8 @@ func TestDynamicGatherer_Fetch_WithLabelFilters(t *testing.T) { }{ "include labels - key and value match for conjur.org/name": { config: ConfigDynamic{ - GroupVersionResource: schema.GroupVersionResource{Group: "test.io", Version: "v1", Resource: "testresources"}, - IncludeResourcesByLabels: map[string]string{"conjur.org/name": "conjur-connect-configmap"}, + GroupVersionResource: schema.GroupVersionResource{Group: "test.io", Version: "v1", Resource: "testresources"}, + IncludeResourcesByLabels: map[string]string{"conjur.org/name": "conjur-connect-configmap"}, }, addObjects: []runtime.Object{ getObjectAnnot("test.io/v1", "TestResource", "res-with-matching-label", "default", nil, map[string]any{"conjur.org/name": "conjur-connect-configmap"}), @@ -1500,8 +1500,8 @@ func TestDynamicGatherer_Fetch_WithLabelFilters(t *testing.T) { }, "include labels - key and value match": { config: ConfigDynamic{ - GroupVersionResource: schema.GroupVersionResource{Group: "test.io", Version: "v1", Resource: "testresources"}, - IncludeResourcesByLabels: map[string]string{"app": "myapp"}, + GroupVersionResource: schema.GroupVersionResource{Group: "test.io", Version: "v1", Resource: "testresources"}, + IncludeResourcesByLabels: map[string]string{"app": "myapp"}, }, addObjects: []runtime.Object{ getObjectAnnot("test.io/v1", "TestResource", "res-app-myapp", "default", nil, map[string]any{"app": "myapp"}), @@ -1516,8 +1516,8 @@ func TestDynamicGatherer_Fetch_WithLabelFilters(t *testing.T) { }, "exclude labels - key only match": { config: ConfigDynamic{ - GroupVersionResource: schema.GroupVersionResource{Group: "test.io", Version: "v1", Resource: "testresources"}, - ExcludeResourcesByLabels: map[string]string{"internal": ""}, + GroupVersionResource: schema.GroupVersionResource{Group: "test.io", Version: "v1", Resource: "testresources"}, + ExcludeResourcesByLabels: map[string]string{"internal": ""}, }, addObjects: []runtime.Object{ getObjectAnnot("test.io/v1", "TestResource", "res-internal", "default", nil, map[string]any{"internal": "true"}), @@ -1532,8 +1532,8 @@ func TestDynamicGatherer_Fetch_WithLabelFilters(t *testing.T) { }, "exclude labels - key and value match": { config: ConfigDynamic{ - GroupVersionResource: schema.GroupVersionResource{Group: "test.io", Version: "v1", Resource: "testresources"}, - ExcludeResourcesByLabels: map[string]string{"env": "test"}, + GroupVersionResource: schema.GroupVersionResource{Group: "test.io", Version: "v1", Resource: "testresources"}, + ExcludeResourcesByLabels: map[string]string{"env": "test"}, }, addObjects: []runtime.Object{ getObjectAnnot("test.io/v1", "TestResource", "res-env-test", "default", nil, map[string]any{"env": "test"}), @@ -1598,8 +1598,8 @@ func TestDynamicGatherer_Fetch_WithAnnotationFilters(t *testing.T) { }{ "include annotations - key only match": { config: ConfigDynamic{ - GroupVersionResource: schema.GroupVersionResource{Group: "test.io", Version: "v1", Resource: "testresources"}, - IncludeResourcesByAnnotations: map[string]string{"prometheus.io/scrape": ""}, + GroupVersionResource: schema.GroupVersionResource{Group: "test.io", Version: "v1", Resource: "testresources"}, + IncludeResourcesByAnnotations: map[string]string{"prometheus.io/scrape": ""}, }, addObjects: []runtime.Object{ getObjectAnnot("test.io/v1", "TestResource", "res-with-annot", "default", map[string]any{"prometheus.io/scrape": "true"}, nil), @@ -1609,8 +1609,8 @@ func TestDynamicGatherer_Fetch_WithAnnotationFilters(t *testing.T) { }, "exclude annotations - key and value match": { config: ConfigDynamic{ - GroupVersionResource: schema.GroupVersionResource{Group: "test.io", Version: "v1", Resource: "testresources"}, - ExcludeResourcesByAnnotations: map[string]string{"deprecated": "true"}, + GroupVersionResource: schema.GroupVersionResource{Group: "test.io", Version: "v1", Resource: "testresources"}, + ExcludeResourcesByAnnotations: map[string]string{"deprecated": "true"}, }, addObjects: []runtime.Object{ getObjectAnnot("test.io/v1", "TestResource", "res-deprecated", "default", map[string]any{"deprecated": "true"}, nil), From ca6480fe7a4b495dd6c6d533bed14fdfb63a5c2b Mon Sep 17 00:00:00 2001 From: Atanas Chuchev Date: Wed, 21 Jan 2026 17:57:51 +0200 Subject: [PATCH 4/6] Add label filtering for ConfigMap resources in dynamic data gatherers --- .../tests/__snapshot__/configmap_test.yaml.snap | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/deploy/charts/disco-agent/tests/__snapshot__/configmap_test.yaml.snap b/deploy/charts/disco-agent/tests/__snapshot__/configmap_test.yaml.snap index 88a0c103..d413c197 100644 --- a/deploy/charts/disco-agent/tests/__snapshot__/configmap_test.yaml.snap +++ b/deploy/charts/disco-agent/tests/__snapshot__/configmap_test.yaml.snap @@ -97,6 +97,8 @@ custom-cluster-description: resource: pods - kind: k8s-dynamic name: ark/configmaps + include-resources-by-labels: + conjur.org/name: "conjur-connect-configmap" config: resource-type: version: v1 @@ -210,6 +212,8 @@ custom-cluster-name: resource: pods - kind: k8s-dynamic name: ark/configmaps + include-resources-by-labels: + conjur.org/name: "conjur-connect-configmap" config: resource-type: version: v1 @@ -323,6 +327,8 @@ custom-period: resource: pods - kind: k8s-dynamic name: ark/configmaps + include-resources-by-labels: + conjur.org/name: "conjur-connect-configmap" config: resource-type: version: v1 @@ -436,6 +442,8 @@ defaults: resource: pods - kind: k8s-dynamic name: ark/configmaps + include-resources-by-labels: + conjur.org/name: "conjur-connect-configmap" config: resource-type: version: v1 From 0131500ad6ceee47b1fa97829d04eb63117672ba Mon Sep 17 00:00:00 2001 From: Atanas Chuchev Date: Fri, 23 Jan 2026 15:32:40 +0200 Subject: [PATCH 5/6] Refactor the filtering ConfigMaps by labels/annotations --- .../disco-agent/templates/configmap.yaml | 4 +- .../__snapshot__/configmap_test.yaml.snap | 16 +- examples/machinehub.yaml | 4 +- pkg/datagatherer/k8sdynamic/dynamic.go | 67 ++--- pkg/datagatherer/k8sdynamic/dynamic_test.go | 234 +++++++++++++++++- 5 files changed, 260 insertions(+), 65 deletions(-) diff --git a/deploy/charts/disco-agent/templates/configmap.yaml b/deploy/charts/disco-agent/templates/configmap.yaml index ed126bd3..a708fd25 100644 --- a/deploy/charts/disco-agent/templates/configmap.yaml +++ b/deploy/charts/disco-agent/templates/configmap.yaml @@ -109,9 +109,9 @@ data: resource: pods - kind: k8s-dynamic name: ark/configmaps - include-resources-by-labels: - conjur.org/name: "conjur-connect-configmap" config: resource-type: version: v1 resource: configmaps + include-resources-by-labels: + conjur.org/name: "conjur-connect-configmap" diff --git a/deploy/charts/disco-agent/tests/__snapshot__/configmap_test.yaml.snap b/deploy/charts/disco-agent/tests/__snapshot__/configmap_test.yaml.snap index d413c197..f0690aed 100644 --- a/deploy/charts/disco-agent/tests/__snapshot__/configmap_test.yaml.snap +++ b/deploy/charts/disco-agent/tests/__snapshot__/configmap_test.yaml.snap @@ -97,12 +97,12 @@ custom-cluster-description: resource: pods - kind: k8s-dynamic name: ark/configmaps - include-resources-by-labels: - conjur.org/name: "conjur-connect-configmap" config: resource-type: version: v1 resource: configmaps + include-resources-by-labels: + conjur.org/name: "conjur-connect-configmap" kind: ConfigMap metadata: labels: @@ -212,12 +212,12 @@ custom-cluster-name: resource: pods - kind: k8s-dynamic name: ark/configmaps - include-resources-by-labels: - conjur.org/name: "conjur-connect-configmap" config: resource-type: version: v1 resource: configmaps + include-resources-by-labels: + conjur.org/name: "conjur-connect-configmap" kind: ConfigMap metadata: labels: @@ -327,12 +327,12 @@ custom-period: resource: pods - kind: k8s-dynamic name: ark/configmaps - include-resources-by-labels: - conjur.org/name: "conjur-connect-configmap" config: resource-type: version: v1 resource: configmaps + include-resources-by-labels: + conjur.org/name: "conjur-connect-configmap" kind: ConfigMap metadata: labels: @@ -442,12 +442,12 @@ defaults: resource: pods - kind: k8s-dynamic name: ark/configmaps - include-resources-by-labels: - conjur.org/name: "conjur-connect-configmap" config: resource-type: version: v1 resource: configmaps + include-resources-by-labels: + conjur.org/name: "conjur-connect-configmap" kind: ConfigMap metadata: labels: diff --git a/examples/machinehub.yaml b/examples/machinehub.yaml index 4b5f0282..f3d5a3ad 100644 --- a/examples/machinehub.yaml +++ b/examples/machinehub.yaml @@ -132,4 +132,6 @@ data-gatherers: config: resource-type: version: v1 - resource: configmaps \ No newline at end of file + resource: configmaps + include-resources-by-labels: + conjur.org/name: "conjur-connect-configmap" \ No newline at end of file diff --git a/pkg/datagatherer/k8sdynamic/dynamic.go b/pkg/datagatherer/k8sdynamic/dynamic.go index d89ff7a2..e453e16e 100644 --- a/pkg/datagatherer/k8sdynamic/dynamic.go +++ b/pkg/datagatherer/k8sdynamic/dynamic.go @@ -415,13 +415,13 @@ func (g *DataGathererDynamic) Fetch() (any, int, error) { // filter by labels labels := resource.GetLabels() - if !matchesLabelFilter(labels, g.includeLabels, g.excludeLabels) { + if !matchesFilter(labels, g.includeLabels, g.excludeLabels) { continue } // filter by annotations annotations := resource.GetAnnotations() - if !matchesAnnotationFilter(annotations, g.includeAnnotations, g.excludeAnnotations) { + if !matchesFilter(annotations, g.includeAnnotations, g.excludeAnnotations) { continue } @@ -619,15 +619,15 @@ func isIncludedNamespace(namespace string, namespaces []string) bool { return slices.Contains(namespaces, namespace) } -// matchesLabelFilter checks if the resource labels match the include/exclude filters. -// If includeLabels is set, all key-value pairs must match for the resource to be included. +// matchesFilter checks if the resource metadata (labels or annotations) match the include/exclude filters. +// If includeFilters is set, all key-value pairs must match for the resource to be included. // An empty string value means "match any value for this key" (key-only matching). -// If excludeLabels is set, any matching key-value pair will exclude the resource. -func matchesLabelFilter(resourceLabels, includeLabels, excludeLabels map[string]string) bool { - // Check exclude labels first - if len(excludeLabels) > 0 { - for key, value := range excludeLabels { - if resourceValue, exists := resourceLabels[key]; exists { +// If excludeFilters is set, any matching key-value pair will exclude the resource. +func matchesFilter(resourceMetadata, includeFilters, excludeFilters map[string]string) bool { + // Check exclude filters first + if len(excludeFilters) > 0 { + for key, value := range excludeFilters { + if resourceValue, exists := resourceMetadata[key]; exists { // If exclude value is empty, exclude any resource with this key // Otherwise, only exclude if the value also matches if value == "" || resourceValue == value { @@ -637,49 +637,12 @@ func matchesLabelFilter(resourceLabels, includeLabels, excludeLabels map[string] } } - // Check include labels - if len(includeLabels) > 0 { - for key, value := range includeLabels { - resourceValue, exists := resourceLabels[key] + // Check include filters + if len(includeFilters) > 0 { + for key, value := range includeFilters { + resourceValue, exists := resourceMetadata[key] if !exists { - // Required label key is missing, filter it out - return false - } - // If include value is empty, we only care that the key exists - // Otherwise, the value must also match - if value != "" && resourceValue != value { - return false - } - } - } - - return true -} - -// matchesAnnotationFilter checks if the resource annotations match the include/exclude filters. -// If includeAnnotations is set, all key-value pairs must match for the resource to be included. -// An empty string value means "match any value for this key" (key-only matching). -// If excludeAnnotations is set, any matching key-value pair will exclude the resource. -func matchesAnnotationFilter(resourceAnnotations, includeAnnotations, excludeAnnotations map[string]string) bool { - // Check exclude annotations first - if len(excludeAnnotations) > 0 { - for key, value := range excludeAnnotations { - if resourceValue, exists := resourceAnnotations[key]; exists { - // If exclude value is empty, exclude any resource with this key - // Otherwise, only exclude if the value also matches - if value == "" || resourceValue == value { - return false - } - } - } - } - - // Check include annotations - if len(includeAnnotations) > 0 { - for key, value := range includeAnnotations { - resourceValue, exists := resourceAnnotations[key] - if !exists { - // Required annotation key is missing, filter it out + // Required key is missing, filter it out return false } // If include value is empty, we only care that the key exists diff --git a/pkg/datagatherer/k8sdynamic/dynamic_test.go b/pkg/datagatherer/k8sdynamic/dynamic_test.go index 0013fbf9..0df2b6f6 100644 --- a/pkg/datagatherer/k8sdynamic/dynamic_test.go +++ b/pkg/datagatherer/k8sdynamic/dynamic_test.go @@ -1409,7 +1409,7 @@ func TestMatchesLabelFilter(t *testing.T) { for name, tc := range tests { t.Run(name, func(t *testing.T) { - result := matchesLabelFilter(tc.resourceLabels, tc.includeLabels, tc.excludeLabels) + result := matchesFilter(tc.resourceLabels, tc.includeLabels, tc.excludeLabels) if result != tc.expected { t.Errorf("expected %v, got %v", tc.expected, result) } @@ -1464,7 +1464,7 @@ func TestMatchesAnnotationFilter(t *testing.T) { for name, tc := range tests { t.Run(name, func(t *testing.T) { - result := matchesAnnotationFilter(tc.resourceAnnotations, tc.includeAnnotations, tc.excludeAnnotations) + result := matchesFilter(tc.resourceAnnotations, tc.includeAnnotations, tc.excludeAnnotations) if result != tc.expected { t.Errorf("expected %v, got %v", tc.expected, result) } @@ -1648,3 +1648,233 @@ func TestDynamicGatherer_Fetch_WithAnnotationFilters(t *testing.T) { }) } } + +func TestDynamicGatherer_Fetch_WithCombinedFilters(t *testing.T) { + ctx := t.Context() + + tests := map[string]struct { + config ConfigDynamic + addObjects []runtime.Object + expectedCount int + expectedNames []string + }{ + "include labels AND include annotations - both must match": { + config: ConfigDynamic{ + GroupVersionResource: schema.GroupVersionResource{Group: "test.io", Version: "v1", Resource: "testresources"}, + IncludeResourcesByLabels: map[string]string{"app": "myapp"}, + IncludeResourcesByAnnotations: map[string]string{"prometheus.io/scrape": "true"}, + }, + addObjects: []runtime.Object{ + // Has both label and annotation - should be included + getObjectAnnot("test.io/v1", "TestResource", "res-both", "default", + map[string]any{"prometheus.io/scrape": "true"}, + map[string]any{"app": "myapp"}), + // Has label but not annotation - should be excluded + getObjectAnnot("test.io/v1", "TestResource", "res-label-only", "default", + map[string]any{"description": "test"}, + map[string]any{"app": "myapp"}), + // Has annotation but not label - should be excluded + getObjectAnnot("test.io/v1", "TestResource", "res-annot-only", "default", + map[string]any{"prometheus.io/scrape": "true"}, + map[string]any{"app": "other"}), + // Has neither - should be excluded + getObjectAnnot("test.io/v1", "TestResource", "res-none", "default", + map[string]any{"description": "test"}, + map[string]any{"env": "prod"}), + }, + expectedCount: 1, + expectedNames: []string{"res-both"}, + }, + "include labels AND exclude annotations - label must match, annotation must not": { + config: ConfigDynamic{ + GroupVersionResource: schema.GroupVersionResource{Group: "test.io", Version: "v1", Resource: "testresources"}, + IncludeResourcesByLabels: map[string]string{"app": "myapp"}, + ExcludeResourcesByAnnotations: map[string]string{"deprecated": "true"}, + }, + addObjects: []runtime.Object{ + // Has label and no deprecated annotation - should be included + getObjectAnnot("test.io/v1", "TestResource", "res-good", "default", + map[string]any{"description": "test"}, + map[string]any{"app": "myapp"}), + // Has label but also deprecated - should be excluded + getObjectAnnot("test.io/v1", "TestResource", "res-deprecated", "default", + map[string]any{"deprecated": "true"}, + map[string]any{"app": "myapp"}), + // No label - should be excluded + getObjectAnnot("test.io/v1", "TestResource", "res-no-label", "default", + map[string]any{"description": "test"}, + map[string]any{"app": "other"}), + }, + expectedCount: 1, + expectedNames: []string{"res-good"}, + }, + "exclude labels AND include annotations - annotation must match, label must not": { + config: ConfigDynamic{ + GroupVersionResource: schema.GroupVersionResource{Group: "test.io", Version: "v1", Resource: "testresources"}, + ExcludeResourcesByLabels: map[string]string{"env": "test"}, + IncludeResourcesByAnnotations: map[string]string{"monitor": "true"}, + }, + addObjects: []runtime.Object{ + // Has annotation and no excluded label - should be included + getObjectAnnot("test.io/v1", "TestResource", "res-good", "default", + map[string]any{"monitor": "true"}, + map[string]any{"env": "prod"}), + // Has annotation but also excluded label - should be excluded + getObjectAnnot("test.io/v1", "TestResource", "res-excluded", "default", + map[string]any{"monitor": "true"}, + map[string]any{"env": "test"}), + // No annotation - should be excluded + getObjectAnnot("test.io/v1", "TestResource", "res-no-annot", "default", + map[string]any{"description": "test"}, + map[string]any{"env": "prod"}), + }, + expectedCount: 1, + expectedNames: []string{"res-good"}, + }, + "exclude labels AND exclude annotations - either exclusion applies": { + config: ConfigDynamic{ + GroupVersionResource: schema.GroupVersionResource{Group: "test.io", Version: "v1", Resource: "testresources"}, + ExcludeResourcesByLabels: map[string]string{"env": "test"}, + ExcludeResourcesByAnnotations: map[string]string{"deprecated": "true"}, + }, + addObjects: []runtime.Object{ + // Has neither exclusion - should be included + getObjectAnnot("test.io/v1", "TestResource", "res-good", "default", + map[string]any{"description": "test"}, + map[string]any{"env": "prod"}), + // Has excluded label - should be excluded + getObjectAnnot("test.io/v1", "TestResource", "res-label-excluded", "default", + map[string]any{"description": "test"}, + map[string]any{"env": "test"}), + // Has excluded annotation - should be excluded + getObjectAnnot("test.io/v1", "TestResource", "res-annot-excluded", "default", + map[string]any{"deprecated": "true"}, + map[string]any{"env": "prod"}), + // Has both exclusions - should be excluded + getObjectAnnot("test.io/v1", "TestResource", "res-both-excluded", "default", + map[string]any{"deprecated": "true"}, + map[string]any{"env": "test"}), + }, + expectedCount: 1, + expectedNames: []string{"res-good"}, + }, + "include labels with key-only match AND include annotations with key-only match": { + config: ConfigDynamic{ + GroupVersionResource: schema.GroupVersionResource{Group: "test.io", Version: "v1", Resource: "testresources"}, + IncludeResourcesByLabels: map[string]string{"conjur.org/name": ""}, + IncludeResourcesByAnnotations: map[string]string{"managed-by": ""}, + }, + addObjects: []runtime.Object{ + // Has both keys with any value - should be included + getObjectAnnot("test.io/v1", "TestResource", "res-both", "default", + map[string]any{"managed-by": "operator"}, + map[string]any{"conjur.org/name": "my-config"}), + // Has label key but not annotation key - should be excluded + getObjectAnnot("test.io/v1", "TestResource", "res-label-only", "default", + map[string]any{"description": "test"}, + map[string]any{"conjur.org/name": "my-config"}), + // Has annotation key but not label key - should be excluded + getObjectAnnot("test.io/v1", "TestResource", "res-annot-only", "default", + map[string]any{"managed-by": "operator"}, + map[string]any{"app": "other"}), + }, + expectedCount: 1, + expectedNames: []string{"res-both"}, + }, + "multiple include labels AND multiple include annotations": { + config: ConfigDynamic{ + GroupVersionResource: schema.GroupVersionResource{Group: "test.io", Version: "v1", Resource: "testresources"}, + IncludeResourcesByLabels: map[string]string{ + "app": "myapp", + "version": "v1", + }, + IncludeResourcesByAnnotations: map[string]string{ + "prometheus.io/scrape": "true", + "prometheus.io/port": "8080", + }, + }, + addObjects: []runtime.Object{ + // Has all required labels and annotations - should be included + getObjectAnnot("test.io/v1", "TestResource", "res-all", "default", + map[string]any{"prometheus.io/scrape": "true", "prometheus.io/port": "8080"}, + map[string]any{"app": "myapp", "version": "v1"}), + // Missing one label - should be excluded + getObjectAnnot("test.io/v1", "TestResource", "res-missing-label", "default", + map[string]any{"prometheus.io/scrape": "true", "prometheus.io/port": "8080"}, + map[string]any{"app": "myapp"}), + // Missing one annotation - should be excluded + getObjectAnnot("test.io/v1", "TestResource", "res-missing-annot", "default", + map[string]any{"prometheus.io/scrape": "true"}, + map[string]any{"app": "myapp", "version": "v1"}), + }, + expectedCount: 1, + expectedNames: []string{"res-all"}, + }, + "exclude labels with key-only AND exclude annotations with key-only": { + config: ConfigDynamic{ + GroupVersionResource: schema.GroupVersionResource{Group: "test.io", Version: "v1", Resource: "testresources"}, + ExcludeResourcesByLabels: map[string]string{"internal": ""}, + ExcludeResourcesByAnnotations: map[string]string{"deprecated": ""}, + }, + addObjects: []runtime.Object{ + // Has neither exclusion key - should be included + getObjectAnnot("test.io/v1", "TestResource", "res-good", "default", + map[string]any{"description": "test"}, + map[string]any{"app": "myapp"}), + // Has excluded label key (any value) - should be excluded + getObjectAnnot("test.io/v1", "TestResource", "res-internal", "default", + map[string]any{"description": "test"}, + map[string]any{"internal": "yes"}), + // Has excluded annotation key (any value) - should be excluded + getObjectAnnot("test.io/v1", "TestResource", "res-deprecated", "default", + map[string]any{"deprecated": "yes"}, + map[string]any{"app": "myapp"}), + }, + expectedCount: 1, + expectedNames: []string{"res-good"}, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + cl := fake.NewSimpleDynamicClient(runtime.NewScheme(), tc.addObjects...) + dg, err := tc.config.newDataGathererWithClient(ctx, cl, nil) + require.NoError(t, err) + + dgd := dg.(*DataGathererDynamic) + + // Start the data gatherer + go func() { + if err = dgd.Run(ctx); err != nil { + t.Errorf("unexpected client error: %+v", err) + } + }() + + err = dgd.WaitForCacheSync(ctx) + require.NoError(t, err) + + // Give some time for the cache to populate + time.Sleep(200 * time.Millisecond) + + data, count, err := dgd.Fetch() + require.NoError(t, err) + + assert.Equal(t, tc.expectedCount, count) + + if len(tc.expectedNames) > 0 { + dynamicData, ok := data.(*api.DynamicData) + require.True(t, ok, "data should be *api.DynamicData") + + actualNames := make([]string, 0, len(dynamicData.Items)) + for _, item := range dynamicData.Items { + if !item.DeletedAt.IsZero() { + continue + } + actualNames = append(actualNames, item.Resource.(*unstructured.Unstructured).GetName()) + } + + assert.ElementsMatch(t, tc.expectedNames, actualNames) + } + }) + } +} From aa23448690d560e259ee2e7cf78a9ba0387f15d3 Mon Sep 17 00:00:00 2001 From: Atanas Chuchev Date: Fri, 23 Jan 2026 16:11:51 +0200 Subject: [PATCH 6/6] Add case sensitivity tests for label and annotation filters in dynamic tests --- pkg/datagatherer/k8sdynamic/dynamic_test.go | 186 ++++++++++++++++++++ 1 file changed, 186 insertions(+) diff --git a/pkg/datagatherer/k8sdynamic/dynamic_test.go b/pkg/datagatherer/k8sdynamic/dynamic_test.go index 0df2b6f6..090d8bc1 100644 --- a/pkg/datagatherer/k8sdynamic/dynamic_test.go +++ b/pkg/datagatherer/k8sdynamic/dynamic_test.go @@ -1405,6 +1405,30 @@ func TestMatchesLabelFilter(t *testing.T) { excludeLabels: nil, expected: false, }, + "case sensitive - include label key case mismatch": { + resourceLabels: map[string]string{"App": "test"}, + includeLabels: map[string]string{"app": "test"}, + excludeLabels: nil, + expected: false, + }, + "case sensitive - include label value case mismatch": { + resourceLabels: map[string]string{"app": "Test"}, + includeLabels: map[string]string{"app": "test"}, + excludeLabels: nil, + expected: false, + }, + "case sensitive - exclude label key case mismatch": { + resourceLabels: map[string]string{"App": "prod"}, + includeLabels: nil, + excludeLabels: map[string]string{"app": "prod"}, + expected: true, + }, + "case sensitive - exclude label value case mismatch": { + resourceLabels: map[string]string{"app": "Prod"}, + includeLabels: nil, + excludeLabels: map[string]string{"app": "prod"}, + expected: true, + }, } for name, tc := range tests { @@ -1460,6 +1484,30 @@ func TestMatchesAnnotationFilter(t *testing.T) { excludeAnnotations: map[string]string{"deprecated": ""}, expected: false, }, + "case sensitive - include annotation key case mismatch": { + resourceAnnotations: map[string]string{"Description": "test"}, + includeAnnotations: map[string]string{"description": "test"}, + excludeAnnotations: nil, + expected: false, + }, + "case sensitive - include annotation value case mismatch": { + resourceAnnotations: map[string]string{"description": "Test"}, + includeAnnotations: map[string]string{"description": "test"}, + excludeAnnotations: nil, + expected: false, + }, + "case sensitive - exclude annotation key case mismatch": { + resourceAnnotations: map[string]string{"Internal": "true"}, + includeAnnotations: nil, + excludeAnnotations: map[string]string{"internal": "true"}, + expected: true, + }, + "case sensitive - exclude annotation value case mismatch": { + resourceAnnotations: map[string]string{"internal": "True"}, + includeAnnotations: nil, + excludeAnnotations: map[string]string{"internal": "true"}, + expected: true, + }, } for name, tc := range tests { @@ -1878,3 +1926,141 @@ func TestDynamicGatherer_Fetch_WithCombinedFilters(t *testing.T) { }) } } + +func TestDynamicGatherer_Fetch_WithLabelFilters_NativeResources(t *testing.T) { + ctx := t.Context() + + tests := map[string]struct { + config ConfigDynamic + addObjects []runtime.Object + expectedCount int + expectedNames []string + }{ + "include labels on Pods - match by app label": { + config: ConfigDynamic{ + GroupVersionResource: schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}, + IncludeResourcesByLabels: map[string]string{"app": "nginx"}, + }, + addObjects: []runtime.Object{ + &corev1.Pod{ + TypeMeta: metav1.TypeMeta{Kind: "Pod", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{Name: "nginx-pod", Namespace: "default", UID: "uid-nginx", Labels: map[string]string{"app": "nginx"}}, + }, + &corev1.Pod{ + TypeMeta: metav1.TypeMeta{Kind: "Pod", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{Name: "apache-pod", Namespace: "default", UID: "uid-apache", Labels: map[string]string{"app": "apache"}}, + }, + &corev1.Pod{ + TypeMeta: metav1.TypeMeta{Kind: "Pod", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{Name: "no-label-pod", Namespace: "default", UID: "uid-no-label"}, + }, + }, + expectedCount: 1, + expectedNames: []string{"nginx-pod"}, + }, + "exclude labels on Pods - exclude test environment": { + config: ConfigDynamic{ + GroupVersionResource: schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}, + ExcludeResourcesByLabels: map[string]string{"env": "test"}, + }, + addObjects: []runtime.Object{ + &corev1.Pod{ + TypeMeta: metav1.TypeMeta{Kind: "Pod", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{Name: "prod-pod", Namespace: "default", UID: "uid-prod", Labels: map[string]string{"env": "prod"}}, + }, + &corev1.Pod{ + TypeMeta: metav1.TypeMeta{Kind: "Pod", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{Name: "test-pod", Namespace: "default", UID: "uid-test", Labels: map[string]string{"env": "test"}}, + }, + }, + expectedCount: 1, + expectedNames: []string{"prod-pod"}, + }, + "include labels on ConfigMaps - multiple label match": { + config: ConfigDynamic{ + GroupVersionResource: schema.GroupVersionResource{Group: "", Version: "v1", Resource: "configmaps"}, + IncludeResourcesByLabels: map[string]string{"app": "web", "tier": "frontend"}, + }, + addObjects: []runtime.Object{ + &corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{Kind: "ConfigMap", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{Name: "frontend-cm", Namespace: "default", UID: "uid-frontend", Labels: map[string]string{"app": "web", "tier": "frontend"}}, + }, + &corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{Kind: "ConfigMap", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{Name: "backend-cm", Namespace: "default", UID: "uid-backend", Labels: map[string]string{"app": "api", "tier": "backend"}}, + }, + &corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{Kind: "ConfigMap", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{Name: "partial-cm", Namespace: "default", UID: "uid-partial", Labels: map[string]string{"app": "web"}}, + }, + }, + expectedCount: 1, + expectedNames: []string{"frontend-cm"}, + }, + "exclude labels on ConfigMaps - exclude by key only": { + config: ConfigDynamic{ + GroupVersionResource: schema.GroupVersionResource{Group: "", Version: "v1", Resource: "configmaps"}, + ExcludeResourcesByLabels: map[string]string{"temporary": ""}, + }, + addObjects: []runtime.Object{ + &corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{Kind: "ConfigMap", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{Name: "app-config", Namespace: "default", UID: "uid-app-config", Labels: map[string]string{"app": "myapp"}}, + }, + &corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{Kind: "ConfigMap", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{Name: "temp-config", Namespace: "default", UID: "uid-temp", Labels: map[string]string{"temporary": "true"}}, + }, + }, + expectedCount: 1, + expectedNames: []string{"app-config"}, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + clientset := fakeclientset.NewSimpleClientset(tc.addObjects...) + dg, err := tc.config.newDataGathererWithClient(ctx, nil, clientset) + require.NoError(t, err) + + dgd := dg.(*DataGathererDynamic) + + // Start the data gatherer + go func() { + if err = dgd.Run(ctx); err != nil { + t.Errorf("unexpected client error: %+v", err) + } + }() + + err = dgd.WaitForCacheSync(ctx) + require.NoError(t, err) + + // Give some time for the cache to populate + time.Sleep(200 * time.Millisecond) + + data, count, err := dgd.Fetch() + require.NoError(t, err) + + assert.Equal(t, tc.expectedCount, count) + + if len(tc.expectedNames) > 0 { + dynamicData, ok := data.(*api.DynamicData) + require.True(t, ok, "data should be *api.DynamicData") + + actualNames := make([]string, 0, len(dynamicData.Items)) + for _, item := range dynamicData.Items { + if !item.DeletedAt.IsZero() { + continue + } + // For native resources, extract name from typed objects + if typedObj, ok := item.Resource.(metav1.Object); ok { + actualNames = append(actualNames, typedObj.GetName()) + } + } + + assert.ElementsMatch(t, tc.expectedNames, actualNames) + } + }) + } +}