diff --git a/pkg/datagatherer/k8sdynamic/dynamic.go b/pkg/datagatherer/k8sdynamic/dynamic.go index 7a6349be..a02a6733 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,22 @@ 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)) + } + + // Add any custom label selectors + // The selectors have already been validated, so Parse is expected to + // succeed; any parse error is treated as a programming error. + labelSelector := labels.Everything() + for _, labelSelectorString := range c.LabelSelectors { + selector, err := labels.Parse(labelSelectorString) + if err != nil { + panic(fmt.Sprintf("PROGRAMMING ERROR: should have been caught in validation: "+ + "failed to parse validated label selector %q: %v", labelSelectorString, err)) + } + reqs, _ := selector.Requirements() + labelSelector = labelSelector.Add(reqs...) } // init cache to store gathered resources @@ -217,6 +246,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 +267,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 +280,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 +325,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..1adae119 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) { @@ -1264,3 +1291,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) + } + }) + } +}