diff --git a/deploy/charts/disco-agent/templates/configmap.yaml b/deploy/charts/disco-agent/templates/configmap.yaml index 231a26cd..ed126bd3 100644 --- a/deploy/charts/disco-agent/templates/configmap.yaml +++ b/deploy/charts/disco-agent/templates/configmap.yaml @@ -107,3 +107,11 @@ data: resource-type: version: v1 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 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..d413c197 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,14 @@ custom-cluster-description: resource-type: version: v1 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 kind: ConfigMap metadata: labels: @@ -202,6 +210,14 @@ custom-cluster-name: resource-type: version: v1 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 kind: ConfigMap metadata: labels: @@ -309,6 +325,14 @@ custom-period: resource-type: version: v1 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 kind: ConfigMap metadata: labels: @@ -416,6 +440,14 @@ defaults: resource-type: version: v1 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 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/k8sdynamic/cache.go b/pkg/datagatherer/k8sdynamic/cache.go index 99f33677..0cd52c2d 100644 --- a/pkg/datagatherer/k8sdynamic/cache.go +++ b/pkg/datagatherer/k8sdynamic/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/k8sdynamic/dynamic.go b/pkg/datagatherer/k8sdynamic/dynamic.go index 7a6349be..d89ff7a2 100644 --- a/pkg/datagatherer/k8sdynamic/dynamic.go +++ b/pkg/datagatherer/k8sdynamic/dynamic.go @@ -77,6 +77,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. @@ -88,9 +100,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 { @@ -104,6 +120,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 } @@ -115,6 +135,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") } @@ -145,6 +173,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() }, @@ -219,6 +250,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 @@ -302,6 +337,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. @@ -367,9 +409,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") @@ -563,6 +619,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/k8sdynamic/dynamic_test.go b/pkg/datagatherer/k8sdynamic/dynamic_test.go index 5745f254..0013fbf9 100644 --- a/pkg/datagatherer/k8sdynamic/dynamic_test.go +++ b/pkg/datagatherer/k8sdynamic/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) + }) + } +}