diff --git a/apis/externalsecrets/v1beta1/provider_schema.go b/apis/externalsecrets/v1beta1/provider_schema.go index 8c7f37ad6..acbe069af 100644 --- a/apis/externalsecrets/v1beta1/provider_schema.go +++ b/apis/externalsecrets/v1beta1/provider_schema.go @@ -73,6 +73,9 @@ func GetProvider(s GenericStore) (Provider, error) { } spec := s.GetSpec() if spec == nil { + // Note, this condition can never be reached, because + // the Spec is not a pointer in Kubernetes. It will + // always exist. return nil, fmt.Errorf("no spec found in %#v", s) } storeName, err := getProviderName(spec.Provider) diff --git a/apis/externalsecrets/v1beta1/secretstore_types.go b/apis/externalsecrets/v1beta1/secretstore_types.go index 0ac7458b7..31346b430 100644 --- a/apis/externalsecrets/v1beta1/secretstore_types.go +++ b/apis/externalsecrets/v1beta1/secretstore_types.go @@ -50,7 +50,12 @@ type ClusterSecretStoreCondition struct { NamespaceSelector *metav1.LabelSelector `json:"namespaceSelector,omitempty"` // Choose namespaces by name + // +optional Namespaces []string `json:"namespaces,omitempty"` + + // Choose namespaces by using regex matching + // +optional + NamespaceRegexes []string `json:"namespaceRegexes,omitempty"` } // SecretStoreProvider contains the provider-specific configuration. diff --git a/apis/externalsecrets/v1beta1/secretstore_validator.go b/apis/externalsecrets/v1beta1/secretstore_validator.go index aa48978a5..20da62a0a 100644 --- a/apis/externalsecrets/v1beta1/secretstore_validator.go +++ b/apis/externalsecrets/v1beta1/secretstore_validator.go @@ -16,7 +16,9 @@ package v1beta1 import ( "context" + "errors" "fmt" + "regexp" "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" @@ -54,9 +56,27 @@ func (r *GenericStoreValidator) ValidateDelete(_ context.Context, _ runtime.Obje } func validateStore(store GenericStore) (admission.Warnings, error) { + if err := validateConditions(store); err != nil { + return nil, err + } + provider, err := GetProvider(store) if err != nil { return nil, err } + return provider.ValidateStore(store) } + +func validateConditions(store GenericStore) error { + var errs error + for ci, condition := range store.GetSpec().Conditions { + for ri, r := range condition.NamespaceRegexes { + if _, err := regexp.Compile(r); err != nil { + errs = errors.Join(errs, fmt.Errorf("failed to compile %dth namespace regex in %dth condition: %w", ri, ci, err)) + } + } + } + + return errs +} diff --git a/apis/externalsecrets/v1beta1/secretstore_validator_test.go b/apis/externalsecrets/v1beta1/secretstore_validator_test.go new file mode 100644 index 000000000..f146d5aa9 --- /dev/null +++ b/apis/externalsecrets/v1beta1/secretstore_validator_test.go @@ -0,0 +1,150 @@ +/* +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1beta1 + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +// ValidationProvider is a simple provider that we can use without cyclic import. +type ValidationProvider struct { + Provider +} + +func (v *ValidationProvider) ValidateStore(_ GenericStore) (admission.Warnings, error) { + return nil, nil +} + +func TestValidateSecretStore(t *testing.T) { + tests := []struct { + name string + obj *SecretStore + mock func() + assertErr func(t *testing.T, err error) + }{ + { + name: "valid regex", + obj: &SecretStore{ + Spec: SecretStoreSpec{ + Conditions: []ClusterSecretStoreCondition{ + { + NamespaceRegexes: []string{`.*`}, + }, + }, + Provider: &SecretStoreProvider{ + AWS: &AWSProvider{}, + }, + }, + }, + mock: func() { + ForceRegister(&ValidationProvider{}, &SecretStoreProvider{ + AWS: &AWSProvider{}, + }) + }, + assertErr: func(t *testing.T, err error) { + require.NoError(t, err) + }, + }, + { + name: "invalid regex", + obj: &SecretStore{ + Spec: SecretStoreSpec{ + Conditions: []ClusterSecretStoreCondition{ + { + NamespaceRegexes: []string{`\1`}, + }, + }, + Provider: &SecretStoreProvider{ + AWS: &AWSProvider{}, + }, + }, + }, + mock: func() { + ForceRegister(&ValidationProvider{}, &SecretStoreProvider{ + AWS: &AWSProvider{}, + }) + }, + assertErr: func(t *testing.T, err error) { + assert.EqualError(t, err, "failed to compile 0th namespace regex in 0th condition: error parsing regexp: invalid escape sequence: `\\1`") + }, + }, + { + name: "multiple errors", + obj: &SecretStore{ + Spec: SecretStoreSpec{ + Conditions: []ClusterSecretStoreCondition{ + { + NamespaceRegexes: []string{`\1`, `\2`}, + }, + }, + Provider: &SecretStoreProvider{ + AWS: &AWSProvider{}, + }, + }, + }, + mock: func() { + ForceRegister(&ValidationProvider{}, &SecretStoreProvider{ + AWS: &AWSProvider{}, + }) + }, + assertErr: func(t *testing.T, err error) { + assert.EqualError(t, err, "failed to compile 0th namespace regex in 0th condition: error parsing regexp: invalid escape sequence: `\\1`\nfailed to compile 1th namespace regex in 0th condition: error parsing regexp: invalid escape sequence: `\\2`") + }, + }, + { + name: "secret store must have only a single backend", + obj: &SecretStore{ + Spec: SecretStoreSpec{ + Provider: &SecretStoreProvider{ + AWS: &AWSProvider{}, + GCPSM: &GCPSMProvider{}, + }, + }, + }, + assertErr: func(t *testing.T, err error) { + assert.EqualError(t, err, "store error for : secret stores must only have exactly one backend specified, found 2") + }, + }, + { + name: "no registered store backend", + obj: &SecretStore{ + Spec: SecretStoreSpec{ + Conditions: []ClusterSecretStoreCondition{ + { + Namespaces: []string{"default"}, + }, + }, + }, + }, + assertErr: func(t *testing.T, err error) { + assert.EqualError(t, err, "store error for : secret stores must only have exactly one backend specified, found 0") + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.mock != nil { + tt.mock() + } + + _, err := validateStore(tt.obj) + tt.assertErr(t, err) + }) + } +} diff --git a/apis/externalsecrets/v1beta1/zz_generated.deepcopy.go b/apis/externalsecrets/v1beta1/zz_generated.deepcopy.go index 2bb5ddd6b..fae7037ea 100644 --- a/apis/externalsecrets/v1beta1/zz_generated.deepcopy.go +++ b/apis/externalsecrets/v1beta1/zz_generated.deepcopy.go @@ -682,6 +682,11 @@ func (in *ClusterSecretStoreCondition) DeepCopyInto(out *ClusterSecretStoreCondi *out = make([]string, len(*in)) copy(*out, *in) } + if in.NamespaceRegexes != nil { + in, out := &in.NamespaceRegexes, &out.NamespaceRegexes + *out = make([]string, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterSecretStoreCondition. diff --git a/config/crds/bases/external-secrets.io_clustersecretstores.yaml b/config/crds/bases/external-secrets.io_clustersecretstores.yaml index b07fcfec2..58184ddae 100644 --- a/config/crds/bases/external-secrets.io_clustersecretstores.yaml +++ b/config/crds/bases/external-secrets.io_clustersecretstores.yaml @@ -1646,6 +1646,11 @@ spec: ClusterSecretStoreCondition describes a condition by which to choose namespaces to process ExternalSecrets in for a ClusterSecretStore instance. properties: + namespaceRegexes: + description: Choose namespaces by using regex matching + items: + type: string + type: array namespaceSelector: description: Choose namespace using a labelSelector properties: diff --git a/config/crds/bases/external-secrets.io_secretstores.yaml b/config/crds/bases/external-secrets.io_secretstores.yaml index 68d47d4c9..74fb9bad0 100644 --- a/config/crds/bases/external-secrets.io_secretstores.yaml +++ b/config/crds/bases/external-secrets.io_secretstores.yaml @@ -1646,6 +1646,11 @@ spec: ClusterSecretStoreCondition describes a condition by which to choose namespaces to process ExternalSecrets in for a ClusterSecretStore instance. properties: + namespaceRegexes: + description: Choose namespaces by using regex matching + items: + type: string + type: array namespaceSelector: description: Choose namespace using a labelSelector properties: diff --git a/deploy/crds/bundle.yaml b/deploy/crds/bundle.yaml index fb420cfb8..5cf1dd7e9 100644 --- a/deploy/crds/bundle.yaml +++ b/deploy/crds/bundle.yaml @@ -2203,6 +2203,11 @@ spec: ClusterSecretStoreCondition describes a condition by which to choose namespaces to process ExternalSecrets in for a ClusterSecretStore instance. properties: + namespaceRegexes: + description: Choose namespaces by using regex matching + items: + type: string + type: array namespaceSelector: description: Choose namespace using a labelSelector properties: @@ -7689,6 +7694,11 @@ spec: ClusterSecretStoreCondition describes a condition by which to choose namespaces to process ExternalSecrets in for a ClusterSecretStore instance. properties: + namespaceRegexes: + description: Choose namespaces by using regex matching + items: + type: string + type: array namespaceSelector: description: Choose namespace using a labelSelector properties: diff --git a/docs/api/spec.md b/docs/api/spec.md index 69fa80ff7..b4bf821a6 100644 --- a/docs/api/spec.md +++ b/docs/api/spec.md @@ -1861,9 +1861,22 @@ Kubernetes meta/v1.LabelSelector +(Optional)

Choose namespaces by name

+ + +namespaceRegexes
+ +[]string + + + +(Optional) +

Choose namespaces by using regex matching

+ +

ConjurAPIKey diff --git a/docs/snippets/full-cluster-secret-store.yaml b/docs/snippets/full-cluster-secret-store.yaml index 9743212bb..bcf82ae4f 100644 --- a/docs/snippets/full-cluster-secret-store.yaml +++ b/docs/snippets/full-cluster-secret-store.yaml @@ -142,7 +142,7 @@ spec: # Conditions about namespaces in which the ClusterSecretStore is usable for ExternalSecrets conditions: - # Options are namespaceSelector, or namespaces + # Options are namespaceSelector, namespaces or namespacesRegex - namespaceSelector: matchLabels: my.namespace.io/some-label: "value" # Only namespaces with that label will work @@ -151,6 +151,11 @@ spec: - "namespace-a" - "namespace-b" + # Namespace regex is helpful for namespace naming convention or when an external tool auto generate namespaces with prefix + - namespacesRegex: + - "namespace-a-.*" # All namespaces prefixed by namespace-a- will work + - "namespace-b-.*" # All namespaces prefixed by namespace-b- will work + # conditions needs only one of the conditions to meet for the CSS to be usable in the namespace. status: diff --git a/pkg/controllers/secretstore/client_manager.go b/pkg/controllers/secretstore/client_manager.go index a87cfa7e2..8920586bc 100644 --- a/pkg/controllers/secretstore/client_manager.go +++ b/pkg/controllers/secretstore/client_manager.go @@ -17,6 +17,7 @@ package secretstore import ( "context" "fmt" + "regexp" "strings" "github.com/go-logr/logr" @@ -245,6 +246,18 @@ func (m *Manager) shouldProcessSecret(store esv1beta1.GenericStore, ns string) ( return true, nil } } + + for _, reg := range condition.NamespaceRegexes { + match, err := regexp.MatchString(reg, ns) + if err != nil { + // Should not happen since store validation already verified the regexes. + return false, fmt.Errorf("failed to compile regex %v: %w", reg, err) + } + + if match { + return true, nil + } + } } return false, nil diff --git a/pkg/controllers/secretstore/client_manager_test.go b/pkg/controllers/secretstore/client_manager_test.go index d5a10f3ad..41b5c0021 100644 --- a/pkg/controllers/secretstore/client_manager_test.go +++ b/pkg/controllers/secretstore/client_manager_test.go @@ -310,6 +310,96 @@ func TestManagerGet(t *testing.T) { } } +func TestShouldProcessSecret(t *testing.T) { + scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) + _ = esv1beta1.AddToScheme(scheme) + _ = apiextensionsv1.AddToScheme(scheme) + + testNamespace := "test-a" + testCases := []struct { + name string + conditions []esv1beta1.ClusterSecretStoreCondition + namespace *corev1.Namespace + wantErr string + want bool + }{ + { + name: "processes a regex condition", + conditions: []esv1beta1.ClusterSecretStoreCondition{ + { + NamespaceRegexes: []string{`test-*`}, + }, + }, + namespace: &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: testNamespace, + }, + }, + want: true, + }, + { + name: "process multiple regexes", + conditions: []esv1beta1.ClusterSecretStoreCondition{ + { + NamespaceRegexes: []string{`nope`, `test-*`}, + }, + }, + namespace: &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: testNamespace, + }, + }, + want: true, + }, + { + name: "shouldn't process if nothing matches", + conditions: []esv1beta1.ClusterSecretStoreCondition{ + { + NamespaceRegexes: []string{`nope`}, + }, + }, + namespace: &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: testNamespace, + }, + }, + want: false, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + fakeSpec := esv1beta1.SecretStoreSpec{ + Conditions: tt.conditions, + } + + defaultStore := &esv1beta1.ClusterSecretStore{ + TypeMeta: metav1.TypeMeta{Kind: esv1beta1.ClusterSecretStoreKind}, + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: tt.namespace.Name, + }, + Spec: fakeSpec, + } + + client := fakeclient.NewClientBuilder().WithScheme(scheme).WithObjects(defaultStore, tt.namespace).Build() + clientMap := make(map[clientKey]*clientVal) + mgr := &Manager{ + log: logr.Discard(), + client: client, + enableFloodgate: true, + clientMap: clientMap, + } + + got, err := mgr.shouldProcessSecret(defaultStore, tt.namespace.Name) + require.NoError(t, err) + + assert.Equal(t, tt.want, got) + }) + } +} + type WrapProvider struct { newClientFunc func( context.Context, diff --git a/pkg/controllers/secretstore/common_test.go b/pkg/controllers/secretstore/common_test.go index fb2a17188..28bc79909 100644 --- a/pkg/controllers/secretstore/common_test.go +++ b/pkg/controllers/secretstore/common_test.go @@ -44,7 +44,7 @@ var _ = Describe("SecretStore reconcile", func() { Expect(k8sClient.Delete(context.Background(), test.store)).ToNot(HaveOccurred()) }) - // a invalid provider config should be reflected + // an invalid provider config should be reflected // in the store status condition invalidProvider := func(tc *testCase) { tc.assert = func() {