From 7bf26bab88cc83112f9494ec4bb3e9ba49b12170 Mon Sep 17 00:00:00 2001 From: Atanas Chuchev Date: Mon, 26 Jan 2026 11:15:14 +0200 Subject: [PATCH 1/5] Discovery of ConfigMaps and Label Selectors --- .../disco-agent/templates/configmap.yaml | 8 + .../__snapshot__/configmap_test.yaml.snap | 32 ++ internal/cyberark/dataupload/dataupload.go | 2 + pkg/client/client_cyberark.go | 3 + ...lient_cyberark_convertdatareadings_test.go | 357 ++++++++++++++++++ pkg/client/client_cyberark_test.go | 1 + pkg/datagatherer/k8sdynamic/dynamic.go | 44 ++- pkg/datagatherer/k8sdynamic/dynamic_test.go | 212 +++++++++++ 8 files changed, 654 insertions(+), 5 deletions(-) diff --git a/deploy/charts/disco-agent/templates/configmap.yaml b/deploy/charts/disco-agent/templates/configmap.yaml index 231a26cd..3b974919 100644 --- a/deploy/charts/disco-agent/templates/configmap.yaml +++ b/deploy/charts/disco-agent/templates/configmap.yaml @@ -38,6 +38,14 @@ data: resource-type: resource: serviceaccounts version: v1 + - kind: k8s-dynamic + name: ark/configmaps + config: + resource-type: + resource: configmaps + version: v1 + label-selectors: + - conjur.org/name=conjur-connect-configmap - kind: k8s-dynamic name: ark/roles config: 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..9b2491b7 100644 --- a/deploy/charts/disco-agent/tests/__snapshot__/configmap_test.yaml.snap +++ b/deploy/charts/disco-agent/tests/__snapshot__/configmap_test.yaml.snap @@ -26,6 +26,14 @@ custom-cluster-description: resource-type: resource: serviceaccounts version: v1 + - kind: k8s-dynamic + name: ark/configmaps + config: + resource-type: + resource: configmaps + version: v1 + label-selectors: + - conjur.org/name=conjur-connect-configmap - kind: k8s-dynamic name: ark/roles config: @@ -133,6 +141,14 @@ custom-cluster-name: resource-type: resource: serviceaccounts version: v1 + - kind: k8s-dynamic + name: ark/configmaps + config: + resource-type: + resource: configmaps + version: v1 + label-selectors: + - conjur.org/name=conjur-connect-configmap - kind: k8s-dynamic name: ark/roles config: @@ -240,6 +256,14 @@ custom-period: resource-type: resource: serviceaccounts version: v1 + - kind: k8s-dynamic + name: ark/configmaps + config: + resource-type: + resource: configmaps + version: v1 + label-selectors: + - conjur.org/name=conjur-connect-configmap - kind: k8s-dynamic name: ark/roles config: @@ -347,6 +371,14 @@ defaults: resource-type: resource: serviceaccounts version: v1 + - kind: k8s-dynamic + name: ark/configmaps + config: + resource-type: + resource: configmaps + version: v1 + label-selectors: + - conjur.org/name=conjur-connect-configmap - kind: k8s-dynamic name: ark/roles config: diff --git a/internal/cyberark/dataupload/dataupload.go b/internal/cyberark/dataupload/dataupload.go index b9ccb5f5..7db43985 100644 --- a/internal/cyberark/dataupload/dataupload.go +++ b/internal/cyberark/dataupload/dataupload.go @@ -62,6 +62,8 @@ type Snapshot struct { Secrets []runtime.Object `json:"secrets"` // ServiceAccounts is a list of ServiceAccount resources in the cluster. ServiceAccounts []runtime.Object `json:"serviceaccounts"` + // ConfigMaps is a list of ConfigMap resources in the cluster. + ConfigMaps []runtime.Object `json:"configmaps"` // Roles is a list of Role resources in the cluster. Roles []runtime.Object `json:"roles"` // ClusterRoles is a list of ClusterRole resources in the cluster. 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_convertdatareadings_test.go b/pkg/client/client_cyberark_convertdatareadings_test.go index 4fc33198..26c2e931 100644 --- a/pkg/client/client_cyberark_convertdatareadings_test.go +++ b/pkg/client/client_cyberark_convertdatareadings_test.go @@ -253,6 +253,363 @@ func TestExtractResourceListFromReading(t *testing.T) { } } +// TestConvertDataReadings_ConfigMaps tests that configmaps are correctly converted. +func TestConvertDataReadings_ConfigMaps(t *testing.T) { + extractorFunctions := map[string]func(*api.DataReading, *dataupload.Snapshot) error{ + "ark/discovery": extractClusterIDAndServerVersionFromReading, + "ark/configmaps": func(reading *api.DataReading, snapshot *dataupload.Snapshot) error { + return extractResourceListFromReading(reading, &snapshot.ConfigMaps) + }, + } + + readings := []*api.DataReading{ + { + DataGatherer: "ark/discovery", + Data: &api.DiscoveryData{ + ClusterID: "test-cluster-id", + ServerVersion: &version.Info{ + GitVersion: "v1.21.0", + }, + }, + }, + { + DataGatherer: "ark/configmaps", + Data: &api.DynamicData{ + Items: []*api.GatheredResource{ + { + Resource: &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": "conjur-connect", + "namespace": "conjur", + "labels": map[string]any{ + "conjur.org/name": "conjur-connect-configmap", + }, + }, + "data": map[string]any{ + "config.yaml": "some-config-data", + }, + }, + }, + }, + { + Resource: &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": "another-configmap", + "namespace": "default", + "labels": map[string]any{ + "conjur.org/name": "conjur-connect-configmap", + }, + }, + "data": map[string]any{ + "setting": "value", + }, + }, + }, + }, + // Deleted configmap should be ignored + { + DeletedAt: api.Time{Time: time.Now()}, + Resource: &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": "deleted-configmap", + "namespace": "default", + }, + }, + }, + }, + }, + }, + }, + } + + var snapshot dataupload.Snapshot + err := convertDataReadings(extractorFunctions, readings, &snapshot) + require.NoError(t, err) + + // Verify the snapshot contains the expected data + assert.Equal(t, "test-cluster-id", snapshot.ClusterID) + assert.Equal(t, "v1.21.0", snapshot.K8SVersion) + require.Len(t, snapshot.ConfigMaps, 2, "should have 2 configmaps (deleted one should be excluded)") + + // Verify the first configmap + cm1, ok := snapshot.ConfigMaps[0].(*unstructured.Unstructured) + require.True(t, ok, "configmap should be unstructured") + assert.Equal(t, "ConfigMap", cm1.GetKind()) + assert.Equal(t, "conjur-connect", cm1.GetName()) + assert.Equal(t, "conjur", cm1.GetNamespace()) + + // Verify the second configmap + cm2, ok := snapshot.ConfigMaps[1].(*unstructured.Unstructured) + require.True(t, ok, "configmap should be unstructured") + assert.Equal(t, "ConfigMap", cm2.GetKind()) + assert.Equal(t, "another-configmap", cm2.GetName()) + assert.Equal(t, "default", cm2.GetNamespace()) +} + +// TestConvertDataReadings_ServiceAccounts tests that serviceaccounts are correctly converted. +func TestConvertDataReadings_ServiceAccounts(t *testing.T) { + extractorFunctions := map[string]func(*api.DataReading, *dataupload.Snapshot) error{ + "ark/discovery": extractClusterIDAndServerVersionFromReading, + "ark/serviceaccounts": func(reading *api.DataReading, snapshot *dataupload.Snapshot) error { + return extractResourceListFromReading(reading, &snapshot.ServiceAccounts) + }, + } + + readings := []*api.DataReading{ + { + DataGatherer: "ark/discovery", + Data: &api.DiscoveryData{ + ClusterID: "test-cluster-id", + ServerVersion: &version.Info{ + GitVersion: "v1.22.0", + }, + }, + }, + { + DataGatherer: "ark/serviceaccounts", + Data: &api.DynamicData{ + Items: []*api.GatheredResource{ + { + Resource: &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "v1", + "kind": "ServiceAccount", + "metadata": map[string]any{ + "name": "default", + "namespace": "default", + }, + }, + }, + }, + { + Resource: &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "v1", + "kind": "ServiceAccount", + "metadata": map[string]any{ + "name": "app-sa", + "namespace": "production", + "labels": map[string]any{ + "app": "myapp", + }, + }, + }, + }, + }, + }, + }, + }, + } + + var snapshot dataupload.Snapshot + err := convertDataReadings(extractorFunctions, readings, &snapshot) + require.NoError(t, err) + + assert.Equal(t, "test-cluster-id", snapshot.ClusterID) + assert.Equal(t, "v1.22.0", snapshot.K8SVersion) + require.Len(t, snapshot.ServiceAccounts, 2) + + sa1, ok := snapshot.ServiceAccounts[0].(*unstructured.Unstructured) + require.True(t, ok) + assert.Equal(t, "ServiceAccount", sa1.GetKind()) + assert.Equal(t, "default", sa1.GetName()) +} + +// TestConvertDataReadings_Roles tests that roles are correctly converted. +func TestConvertDataReadings_Roles(t *testing.T) { + extractorFunctions := map[string]func(*api.DataReading, *dataupload.Snapshot) error{ + "ark/discovery": extractClusterIDAndServerVersionFromReading, + "ark/roles": func(reading *api.DataReading, snapshot *dataupload.Snapshot) error { + return extractResourceListFromReading(reading, &snapshot.Roles) + }, + } + + readings := []*api.DataReading{ + { + DataGatherer: "ark/discovery", + Data: &api.DiscoveryData{ + ClusterID: "rbac-cluster", + ServerVersion: &version.Info{ + GitVersion: "v1.23.0", + }, + }, + }, + { + DataGatherer: "ark/roles", + Data: &api.DynamicData{ + Items: []*api.GatheredResource{ + { + Resource: &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "rbac.authorization.k8s.io/v1", + "kind": "Role", + "metadata": map[string]any{ + "name": "pod-reader", + "namespace": "default", + "labels": map[string]any{ + "rbac.authorization.k8s.io/aggregate-to-view": "true", + }, + }, + "rules": []any{ + map[string]any{ + "apiGroups": []any{""}, + "resources": []any{"pods"}, + "verbs": []any{"get", "list"}, + }, + }, + }, + }, + }, + // Deleted role should be excluded + { + DeletedAt: api.Time{Time: time.Now()}, + Resource: &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "rbac.authorization.k8s.io/v1", + "kind": "Role", + "metadata": map[string]any{ + "name": "deleted-role", + "namespace": "default", + }, + }, + }, + }, + }, + }, + }, + } + + var snapshot dataupload.Snapshot + err := convertDataReadings(extractorFunctions, readings, &snapshot) + require.NoError(t, err) + + assert.Equal(t, "rbac-cluster", snapshot.ClusterID) + require.Len(t, snapshot.Roles, 1, "deleted role should be excluded") + + role, ok := snapshot.Roles[0].(*unstructured.Unstructured) + require.True(t, ok) + assert.Equal(t, "Role", role.GetKind()) + assert.Equal(t, "pod-reader", role.GetName()) +} + +// TestConvertDataReadings_MultipleResources tests conversion with multiple resource types. +func TestConvertDataReadings_MultipleResources(t *testing.T) { + extractorFunctions := map[string]func(*api.DataReading, *dataupload.Snapshot) error{ + "ark/discovery": extractClusterIDAndServerVersionFromReading, + "ark/configmaps": func(reading *api.DataReading, snapshot *dataupload.Snapshot) error { + return extractResourceListFromReading(reading, &snapshot.ConfigMaps) + }, + "ark/serviceaccounts": func(reading *api.DataReading, snapshot *dataupload.Snapshot) error { + return extractResourceListFromReading(reading, &snapshot.ServiceAccounts) + }, + "ark/deployments": func(reading *api.DataReading, snapshot *dataupload.Snapshot) error { + return extractResourceListFromReading(reading, &snapshot.Deployments) + }, + } + + readings := []*api.DataReading{ + { + DataGatherer: "ark/discovery", + Data: &api.DiscoveryData{ + ClusterID: "multi-resource-cluster", + ServerVersion: &version.Info{ + GitVersion: "v1.24.0", + }, + }, + }, + { + DataGatherer: "ark/configmaps", + Data: &api.DynamicData{ + Items: []*api.GatheredResource{ + { + Resource: &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": "app-config", + "namespace": "default", + }, + }, + }, + }, + }, + }, + }, + { + DataGatherer: "ark/serviceaccounts", + Data: &api.DynamicData{ + Items: []*api.GatheredResource{ + { + Resource: &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "v1", + "kind": "ServiceAccount", + "metadata": map[string]any{ + "name": "app-sa", + "namespace": "default", + }, + }, + }, + }, + }, + }, + }, + { + DataGatherer: "ark/deployments", + Data: &api.DynamicData{ + Items: []*api.GatheredResource{ + { + Resource: &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]any{ + "name": "web-app", + "namespace": "default", + }, + }, + }, + }, + }, + }, + }, + } + + var snapshot dataupload.Snapshot + err := convertDataReadings(extractorFunctions, readings, &snapshot) + require.NoError(t, err) + + // Verify all resources are present + assert.Equal(t, "multi-resource-cluster", snapshot.ClusterID) + assert.Equal(t, "v1.24.0", snapshot.K8SVersion) + require.Len(t, snapshot.ConfigMaps, 1) + require.Len(t, snapshot.ServiceAccounts, 1) + require.Len(t, snapshot.Deployments, 1) + + // Verify each resource type + cm, ok := snapshot.ConfigMaps[0].(*unstructured.Unstructured) + require.True(t, ok) + assert.Equal(t, "app-config", cm.GetName()) + + sa, ok := snapshot.ServiceAccounts[0].(*unstructured.Unstructured) + require.True(t, ok) + assert.Equal(t, "app-sa", sa.GetName()) + + deploy, ok := snapshot.Deployments[0].(*unstructured.Unstructured) + require.True(t, ok) + assert.Equal(t, "web-app", deploy.GetName()) +} + // TestConvertDataReadings tests the convertDataReadings function. func TestConvertDataReadings(t *testing.T) { simpleExtractorFunctions := map[string]func(*api.DataReading, *dataupload.Snapshot) error{ diff --git a/pkg/client/client_cyberark_test.go b/pkg/client/client_cyberark_test.go index 61c33764..3fc7672a 100644 --- a/pkg/client/client_cyberark_test.go +++ b/pkg/client/client_cyberark_test.go @@ -79,6 +79,7 @@ func TestCyberArkClient_PostDataReadingsWithOptions_RealAPI(t *testing.T) { var defaultDynamicDatagathererNames = []string{ "ark/secrets", "ark/serviceaccounts", + "ark/configmaps", "ark/roles", "ark/clusterroles", "ark/rolebindings", diff --git a/pkg/datagatherer/k8sdynamic/dynamic.go b/pkg/datagatherer/k8sdynamic/dynamic.go index 7a6349be..68ce2701 100644 --- a/pkg/datagatherer/k8sdynamic/dynamic.go +++ b/pkg/datagatherer/k8sdynamic/dynamic.go @@ -49,6 +49,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/dynamic" @@ -77,6 +78,8 @@ 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"` + // LabelSelectors is a list of label selectors to use when listing this resource + LabelSelectors []string `yaml:"label-selectors"` } // UnmarshalYAML unmarshals the ConfigDynamic resolving GroupVersionResource. @@ -91,6 +94,7 @@ func (c *ConfigDynamic) UnmarshalYAML(unmarshal func(any) error) error { ExcludeNamespaces []string `yaml:"exclude-namespaces"` IncludeNamespaces []string `yaml:"include-namespaces"` FieldSelectors []string `yaml:"field-selectors"` + LabelSelectors []string `yaml:"label-selectors"` }{} err := unmarshal(&aux) if err != nil { @@ -104,6 +108,7 @@ func (c *ConfigDynamic) UnmarshalYAML(unmarshal func(any) error) error { c.ExcludeNamespaces = aux.ExcludeNamespaces c.IncludeNamespaces = aux.IncludeNamespaces c.FieldSelectors = aux.FieldSelectors + c.LabelSelectors = aux.LabelSelectors return nil } @@ -119,16 +124,26 @@ func (c *ConfigDynamic) validate() error { errs = append(errs, "invalid configuration: GroupVersionResource.Resource cannot be empty") } - for i, selectorString := range c.FieldSelectors { - if selectorString == "" { + for i, fieldSelectorString := range c.FieldSelectors { + if fieldSelectorString == "" { errs = append(errs, fmt.Sprintf("invalid field selector %d: must not be empty", i)) } - _, err := fields.ParseSelector(selectorString) + _, err := fields.ParseSelector(fieldSelectorString) if err != nil { errs = append(errs, fmt.Sprintf("invalid field selector %d: %s", i, err)) } } + for i, labelSelectorString := range c.LabelSelectors { + if labelSelectorString == "" { + errs = append(errs, fmt.Sprintf("invalid label selector %d: must not be empty", i)) + } + _, err := labels.Parse(labelSelectorString) + if err != nil { + errs = append(errs, fmt.Sprintf("invalid label selector %d: %s", i, err)) + } + } + if len(errs) > 0 { return errors.New(strings.Join(errs, ", ")) } @@ -207,8 +222,21 @@ func (c *ConfigDynamic) newDataGathererWithClient(ctx context.Context, cl dynami // Add any custom field selectors to the excluded namespaces selector // The selectors have already been validated, so it is safe to use // ParseSelectorOrDie here. - for _, selectorString := range c.FieldSelectors { - fieldSelector = fields.AndSelectors(fieldSelector, fields.ParseSelectorOrDie(selectorString)) + for _, fieldSelectorString := range c.FieldSelectors { + fieldSelector = fields.AndSelectors(fieldSelector, fields.ParseSelectorOrDie(fieldSelectorString)) + } + + labelSelector := labels.Everything() + for _, labelSelectorString := range c.LabelSelectors { + selector, err := labels.Parse(labelSelectorString) + if err != nil { + return nil, err + } + reqs, selectable := selector.Requirements() + if !selectable { + return nil, fmt.Errorf("invalid label selector %q: not selectable", labelSelectorString) + } + labelSelector = labelSelector.Add(reqs...) } // init cache to store gathered resources @@ -217,6 +245,7 @@ func (c *ConfigDynamic) newDataGathererWithClient(ctx context.Context, cl dynami newDataGatherer := &DataGathererDynamic{ groupVersionResource: c.GroupVersionResource, fieldSelector: fieldSelector.String(), + labelSelector: labelSelector.String(), namespaces: c.IncludeNamespaces, cache: dgCache, } @@ -237,6 +266,7 @@ func (c *ConfigDynamic) newDataGathererWithClient(ctx context.Context, cl dynami informers.WithNamespace(metav1.NamespaceAll), informers.WithTweakListOptions(func(options *metav1.ListOptions) { options.FieldSelector = fieldSelector.String() + options.LabelSelector = labelSelector.String() }), ) newDataGatherer.informer = informerFunc(factory) @@ -249,6 +279,7 @@ func (c *ConfigDynamic) newDataGathererWithClient(ctx context.Context, cl dynami metav1.NamespaceAll, func(options *metav1.ListOptions) { options.FieldSelector = fieldSelector.String() + options.LabelSelector = labelSelector.String() }, ) newDataGatherer.informer = factory.ForResource(c.GroupVersionResource).Informer() @@ -293,6 +324,9 @@ type DataGathererDynamic struct { // returned by the Kubernetes API. // https://kubernetes.io/docs/concepts/overview/working-with-objects/field-selectors/ fieldSelector string + // labelSelector is a label selector string used to filter resources + // returned by the Kubernetes API. + labelSelector string // cache holds all resources watched by the data gatherer, default object expiry time 5 minutes // 30 seconds purge time https://pkg.go.dev/github.com/patrickmn/go-cache cache *cache.Cache diff --git a/pkg/datagatherer/k8sdynamic/dynamic_test.go b/pkg/datagatherer/k8sdynamic/dynamic_test.go index 5745f254..542051eb 100644 --- a/pkg/datagatherer/k8sdynamic/dynamic_test.go +++ b/pkg/datagatherer/k8sdynamic/dynamic_test.go @@ -1264,3 +1264,215 @@ func toRegexps(keys []string) []*regexp.Regexp { } return regexps } + +// TestValidate_LabelSelectors tests validation of label selectors. +func TestValidate_LabelSelectors(t *testing.T) { + tests := []struct { + name string + labelSelectors []string + expectError bool + errorContains string + }{ + { + name: "valid simple label selector", + labelSelectors: []string{"app=myapp"}, + expectError: false, + }, + { + name: "valid label selector with dot notation", + labelSelectors: []string{"conjur.org/name=conjur-connect-configmap"}, + expectError: false, + }, + { + name: "valid negative label selector", + labelSelectors: []string{"app!=test"}, + expectError: false, + }, + { + name: "valid multiple label selectors", + labelSelectors: []string{"app=myapp", "environment=production"}, + expectError: false, + }, + { + name: "valid label existence check", + labelSelectors: []string{"app"}, + expectError: false, + }, + { + name: "valid label non-existence check", + labelSelectors: []string{"!app"}, + expectError: false, + }, + { + name: "valid set-based selector", + labelSelectors: []string{"environment in (production, staging)"}, + expectError: false, + }, + { + name: "valid negative set-based selector", + labelSelectors: []string{"environment notin (dev, test)"}, + expectError: false, + }, + { + name: "empty label selector", + labelSelectors: []string{""}, + expectError: true, + errorContains: "must not be empty", + }, + { + name: "invalid label selector syntax", + labelSelectors: []string{"invalid===syntax"}, + expectError: true, + errorContains: "invalid label selector", + }, + { + name: "multiple selectors with one invalid", + labelSelectors: []string{"app=valid", "invalid==="}, + expectError: true, + errorContains: "invalid label selector 1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := &ConfigDynamic{ + GroupVersionResource: schema.GroupVersionResource{ + Version: "v1", + Resource: "configmaps", + }, + LabelSelectors: tt.labelSelectors, + } + + err := config.validate() + if tt.expectError { + require.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + require.NoError(t, err) + } + }) + } +} + +// TestValidate_FieldSelectors tests validation of field selectors. +func TestValidate_FieldSelectors(t *testing.T) { + tests := []struct { + name string + fieldSelectors []string + expectError bool + errorContains string + }{ + { + name: "valid field selector", + fieldSelectors: []string{"metadata.name=test"}, + expectError: false, + }, + { + name: "valid negative field selector", + fieldSelectors: []string{"type!=kubernetes.io/dockercfg"}, + expectError: false, + }, + { + name: "multiple valid field selectors", + fieldSelectors: []string{"metadata.namespace=default", "type!=Opaque"}, + expectError: false, + }, + { + name: "empty field selector", + fieldSelectors: []string{""}, + expectError: true, + errorContains: "must not be empty", + }, + { + name: "invalid field selector syntax", + fieldSelectors: []string{"invalid===field"}, + expectError: true, + errorContains: "invalid field selector", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := &ConfigDynamic{ + GroupVersionResource: schema.GroupVersionResource{ + Version: "v1", + Resource: "secrets", + }, + FieldSelectors: tt.fieldSelectors, + } + + err := config.validate() + if tt.expectError { + require.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + require.NoError(t, err) + } + }) + } +} + +// TestValidate_CombinedSelectors tests validation with both field and label selectors. +func TestValidate_CombinedSelectors(t *testing.T) { + tests := []struct { + name string + fieldSelectors []string + labelSelectors []string + expectError bool + errorContains string + }{ + { + name: "valid field and label selectors", + fieldSelectors: []string{"type!=kubernetes.io/dockercfg"}, + labelSelectors: []string{"app=myapp"}, + expectError: false, + }, + { + name: "invalid field selector with valid label selector", + fieldSelectors: []string{"invalid==="}, + labelSelectors: []string{"app=myapp"}, + expectError: true, + errorContains: "invalid field selector", + }, + { + name: "valid field selector with invalid label selector", + fieldSelectors: []string{"type!=Opaque"}, + labelSelectors: []string{"invalid==="}, + expectError: true, + errorContains: "invalid label selector", + }, + { + name: "both selectors invalid", + fieldSelectors: []string{"bad===field"}, + labelSelectors: []string{"bad===label"}, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := &ConfigDynamic{ + GroupVersionResource: schema.GroupVersionResource{ + Version: "v1", + Resource: "configmaps", + }, + FieldSelectors: tt.fieldSelectors, + LabelSelectors: tt.labelSelectors, + } + + err := config.validate() + if tt.expectError { + require.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + require.NoError(t, err) + } + }) + } +} From 4ab9388ecb0f2e9bac46570a0683d0277e4ee81a Mon Sep 17 00:00:00 2001 From: Atanas Chuchev Date: Mon, 26 Jan 2026 11:24:29 +0200 Subject: [PATCH 2/5] Update the example --- examples/machinehub.yaml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/examples/machinehub.yaml b/examples/machinehub.yaml index ea0b28e5..2bf89b60 100644 --- a/examples/machinehub.yaml +++ b/examples/machinehub.yaml @@ -37,6 +37,16 @@ data-gatherers: resource: serviceaccounts version: v1 +# Gather Kubernetes config maps with specific label +- name: ark/configmaps + kind: k8s-dynamic + config: + resource-type: + resource: configmaps + version: v1 + label-selectors: + - conjur.org/name=conjur-connect-configmap + # Gather Kubernetes roles - name: ark/roles kind: k8s-dynamic From 0ec0508e40fd685da8513f5f50b32cce45e261c2 Mon Sep 17 00:00:00 2001 From: Atanas Chuchev Date: Mon, 26 Jan 2026 11:28:32 +0200 Subject: [PATCH 3/5] Add ConfigMaps data-gatherer to input.json --- examples/machinehub/input.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/examples/machinehub/input.json b/examples/machinehub/input.json index 2cdba65c..fecf800b 100644 --- a/examples/machinehub/input.json +++ b/examples/machinehub/input.json @@ -123,5 +123,11 @@ "data": { "items": [] } + }, + { + "data-gatherer": "ark/configmaps", + "data": { + "items": [] + } } ] From 57152cf8fdea4966caca2d6cb1711c4693b747ee Mon Sep 17 00:00:00 2001 From: Atanas Chuchev Date: Mon, 26 Jan 2026 11:37:59 +0200 Subject: [PATCH 4/5] Add ConfigMap support to dynamic data gatherer and tests --- pkg/datagatherer/k8sdynamic/dynamic.go | 3 +++ pkg/datagatherer/k8sdynamic/dynamic_test.go | 29 ++++++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/pkg/datagatherer/k8sdynamic/dynamic.go b/pkg/datagatherer/k8sdynamic/dynamic.go index 68ce2701..dbd2101f 100644 --- a/pkg/datagatherer/k8sdynamic/dynamic.go +++ b/pkg/datagatherer/k8sdynamic/dynamic.go @@ -166,6 +166,9 @@ var kubernetesNativeResources = map[schema.GroupVersionResource]sharedInformerFu corev1.SchemeGroupVersion.WithResource("services"): func(sharedFactory informers.SharedInformerFactory) k8scache.SharedIndexInformer { return sharedFactory.Core().V1().Services().Informer() }, + corev1.SchemeGroupVersion.WithResource("configmaps"): func(sharedFactory informers.SharedInformerFactory) k8scache.SharedIndexInformer { + return sharedFactory.Core().V1().ConfigMaps().Informer() + }, appsv1.SchemeGroupVersion.WithResource("deployments"): func(sharedFactory informers.SharedInformerFactory) k8scache.SharedIndexInformer { return sharedFactory.Apps().V1().Deployments().Informer() }, diff --git a/pkg/datagatherer/k8sdynamic/dynamic_test.go b/pkg/datagatherer/k8sdynamic/dynamic_test.go index 542051eb..6e88ef07 100644 --- a/pkg/datagatherer/k8sdynamic/dynamic_test.go +++ b/pkg/datagatherer/k8sdynamic/dynamic_test.go @@ -124,6 +124,10 @@ func TestNewDataGathererWithClientAndDynamicInformer(t *testing.T) { "type!=kubernetes.io/service-account-token", "type!=kubernetes.io/dockercfg", }, + LabelSelectors: []string{ + "conjur.org/name=conjur-connect-configmap", + "app=my-app", + }, } cl := fake.NewSimpleDynamicClient(runtime.NewScheme()) dg, err := config.newDataGathererWithClient(ctx, cl, nil) @@ -138,6 +142,7 @@ func TestNewDataGathererWithClientAndDynamicInformer(t *testing.T) { // during initialization namespaces: config.IncludeNamespaces, fieldSelector: "metadata.namespace!=kube-system,type!=kubernetes.io/service-account-token,type!=kubernetes.io/dockercfg", + labelSelector: "app=my-app,conjur.org/name=conjur-connect-configmap", } gatherer := dg.(*DataGathererDynamic) @@ -160,6 +165,9 @@ func TestNewDataGathererWithClientAndDynamicInformer(t *testing.T) { if !reflect.DeepEqual(gatherer.fieldSelector, expected.fieldSelector) { t.Errorf("expected %v, got %v", expected.fieldSelector, gatherer.fieldSelector) } + if !reflect.DeepEqual(gatherer.labelSelector, expected.labelSelector) { + t.Errorf("expected %v, got %v", expected.labelSelector, gatherer.labelSelector) + } } func TestNewDataGathererWithClientAndSharedIndexInformer(t *testing.T) { @@ -167,6 +175,10 @@ func TestNewDataGathererWithClientAndSharedIndexInformer(t *testing.T) { config := ConfigDynamic{ IncludeNamespaces: []string{"a"}, GroupVersionResource: schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}, + LabelSelectors: []string{ + "app=my-app", + "version=v1", + }, } clientset := fakeclientset.NewSimpleClientset() dg, err := config.newDataGathererWithClient(ctx, nil, clientset) @@ -178,7 +190,8 @@ func TestNewDataGathererWithClientAndSharedIndexInformer(t *testing.T) { groupVersionResource: config.GroupVersionResource, // it's important that the namespaces are set as the IncludeNamespaces // during initialization - namespaces: config.IncludeNamespaces, + namespaces: config.IncludeNamespaces, + labelSelector: "app=my-app,version=v1", } gatherer := dg.(*DataGathererDynamic) @@ -198,6 +211,9 @@ func TestNewDataGathererWithClientAndSharedIndexInformer(t *testing.T) { if gatherer.registration == nil { t.Errorf("unexpected event handler registration value: %v", nil) } + if !reflect.DeepEqual(gatherer.labelSelector, expected.labelSelector) { + t.Errorf("expected %v, got %v", expected.labelSelector, gatherer.labelSelector) + } } func TestUnmarshalDynamicConfig(t *testing.T) { @@ -217,6 +233,9 @@ include-namespaces: - default field-selectors: - type!=kubernetes.io/service-account-token +label-selectors: +- conjur.org/name=conjur-connect-configmap +- app=my-app ` expectedGVR := schema.GroupVersionResource{ @@ -236,6 +255,11 @@ field-selectors: "type!=kubernetes.io/service-account-token", } + expectedLabelSelectors := []string{ + "conjur.org/name=conjur-connect-configmap", + "app=my-app", + } + cfg := ConfigDynamic{} err := yaml.Unmarshal([]byte(textCfg), &cfg) if err != nil { @@ -259,6 +283,9 @@ field-selectors: if got, want := cfg.FieldSelectors, expectedFieldSelectors; !reflect.DeepEqual(got, want) { t.Errorf("FieldSelectors does not match: got=%+v want=%+v", got, want) } + if got, want := cfg.LabelSelectors, expectedLabelSelectors; !reflect.DeepEqual(got, want) { + t.Errorf("LabelSelectors does not match: got=%+v want=%+v", got, want) + } } func TestConfigDynamicValidate(t *testing.T) { From afa9ca6841809155ae2c18bd707a31031eb27314 Mon Sep 17 00:00:00 2001 From: Atanas Chuchev Date: Tue, 27 Jan 2026 11:25:18 +0200 Subject: [PATCH 5/5] Clarify comment for gathering Kubernetes config maps with specific conjur.org label --- examples/machinehub.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/machinehub.yaml b/examples/machinehub.yaml index 2bf89b60..2076ecfb 100644 --- a/examples/machinehub.yaml +++ b/examples/machinehub.yaml @@ -37,7 +37,7 @@ data-gatherers: resource: serviceaccounts version: v1 -# Gather Kubernetes config maps with specific label +# Gather Kubernetes config maps with specific conjur.org label - name: ark/configmaps kind: k8s-dynamic config: