Browse Source

feat: cluster push secret with pushing all secrets from a namespace (#4162)

* feat: cluster push secret with pushing all secrets from a namespace

Signed-off-by: Gergely Brautigam <182850+Skarlso@users.noreply.github.com>

* update the unit test and add RBAC for the new type and registration

Signed-off-by: Gergely Brautigam <182850+Skarlso@users.noreply.github.com>

* updating with check-diff

Signed-off-by: Gergely Brautigam <182850+Skarlso@users.noreply.github.com>

* adding metrics and crd values and the reconciler

Signed-off-by: Gergely Brautigam <182850+Skarlso@users.noreply.github.com>

* remove namespace list selector and deprecate it in clusterexternalsecret

Signed-off-by: Gergely Brautigam <182850+Skarlso@users.noreply.github.com>

* refactor for sonar

Signed-off-by: Gergely Brautigam <182850+Skarlso@users.noreply.github.com>

* added unit tests, updated rbac and configured metrics

Signed-off-by: Gergely Brautigam <182850+Skarlso@users.noreply.github.com>

* made the test work and refactored a bit

Signed-off-by: Gergely Brautigam <182850+Skarlso@users.noreply.github.com>

* run helm.test.update

Signed-off-by: Gergely Brautigam <182850+Skarlso@users.noreply.github.com>

* rename the secretSelector to selector since the name is implied already by the context

Signed-off-by: Gergely Brautigam <182850+Skarlso@users.noreply.github.com>

* update the documentation to add cluster push secret to the apis

Signed-off-by: Gergely Brautigam <182850+Skarlso@users.noreply.github.com>

* added helm values for disabling cluster push secret

Signed-off-by: Gergely Brautigam <182850+Skarlso@users.noreply.github.com>

---------

Signed-off-by: Gergely Brautigam <182850+Skarlso@users.noreply.github.com>
Gergely Brautigam 1 year ago
parent
commit
e07ba8088e
33 changed files with 3441 additions and 125 deletions
  1. 3 0
      PROJECT
  2. 104 2
      apis/externalsecrets/v1alpha1/pushsecret_types.go
  3. 8 0
      apis/externalsecrets/v1alpha1/register.go
  4. 199 1
      apis/externalsecrets/v1alpha1/zz_generated.deepcopy.go
  5. 1 0
      apis/externalsecrets/v1beta1/clusterexternalsecret_types.go
  6. 21 0
      cmd/controller/root.go
  7. 3 2
      config/crds/bases/external-secrets.io_clusterexternalsecrets.yaml
  8. 542 0
      config/crds/bases/external-secrets.io_clusterpushsecrets.yaml
  9. 46 2
      config/crds/bases/external-secrets.io_pushsecrets.yaml
  10. 1 0
      config/crds/bases/kustomization.yaml
  11. 2 0
      deploy/charts/external-secrets/README.md
  12. 4 1
      deploy/charts/external-secrets/templates/deployment.yaml
  13. 15 0
      deploy/charts/external-secrets/templates/rbac.yaml
  14. 3 0
      deploy/charts/external-secrets/values.schema.json
  15. 5 0
      deploy/charts/external-secrets/values.yaml
  16. 574 3
      deploy/crds/bundle.yaml
  17. 12 0
      docs/api/clusterpushsecret.md
  18. 4 2
      docs/api/spec.md
  19. 8 4
      docs/guides/disable-cluster-features.md
  20. 81 0
      docs/snippets/full-cluster-push-secret.yaml
  21. 1 0
      hack/api-docs/mkdocs.yml
  22. 9 62
      pkg/controllers/clusterexternalsecret/clusterexternalsecret_controller.go
  23. 358 0
      pkg/controllers/clusterpushsecret/clusterpushsecret_controller.go
  24. 893 0
      pkg/controllers/clusterpushsecret/clusterpushsecret_controller_test.go
  25. 102 0
      pkg/controllers/clusterpushsecret/cpsmetrics/cpsmetrics.go
  26. 120 0
      pkg/controllers/clusterpushsecret/cpsmetrics/cpsmetrics_test.go
  27. 115 0
      pkg/controllers/clusterpushsecret/suite_test.go
  28. 56 0
      pkg/controllers/clusterpushsecret/util.go
  29. 75 46
      pkg/controllers/pushsecret/pushsecret_controller.go
  30. 60 0
      pkg/utils/utils.go
  31. 5 0
      tests/__snapshot__/clustergenerator-v1alpha1.yaml
  32. 5 0
      tests/__snapshot__/grafana-v1alpha1.yaml
  33. 6 0
      tests/__snapshot__/pushsecret-v1alpha1.yaml

+ 3 - 0
PROJECT

@@ -20,4 +20,7 @@ version: "2"
 - group: external-secrets
   kind: ExternalSecret
   version: v1beta1
+- group: external-secrets
+  kind: ClusterPushSecret
+  version: v1alpha1
 version: "3"

+ 104 - 2
apis/externalsecrets/v1alpha1/pushsecret_types.go

@@ -105,7 +105,12 @@ type PushSecretSecret struct {
 	// +kubebuilder:validation:MinLength:=1
 	// +kubebuilder:validation:MaxLength:=253
 	// +kubebuilder:validation:Pattern:=^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
-	Name string `json:"name"`
+	// +optional
+	Name string `json:"name,omitempty"`
+
+	// Selector chooses secrets using a labelSelector.
+	// +optional
+	Selector *metav1.LabelSelector `json:"selector,omitempty"`
 }
 
 // +kubebuilder:validation:MinProperties=1
@@ -232,12 +237,109 @@ type PushSecret struct {
 	Status PushSecretStatus `json:"status,omitempty"`
 }
 
+// PushSecretList contains a list of PushSecret resources.
 // +kubebuilder:object:root=true
 // +kubebuilder:printcolumn:name="AGE",type="date",JSONPath=".metadata.creationTimestamp"
 // +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].reason`
-// PushSecretList contains a list of PushSecret resources.
 type PushSecretList struct {
 	metav1.TypeMeta `json:",inline"`
 	metav1.ListMeta `json:"metadata,omitempty"`
 	Items           []PushSecret `json:"items"`
 }
+
+// ClusterPushSecretCondition used to refine PushSecrets to specific namespaces and names.
+type ClusterPushSecretCondition struct {
+	// Choose namespace using a labelSelector
+	// +optional
+	NamespaceSelector *metav1.LabelSelector `json:"namespaceSelector,omitempty"`
+
+	// Choose namespaces by name
+	// +optional
+	// +kubebuilder:validation:items:MinLength:=1
+	// +kubebuilder:validation:items:MaxLength:=63
+	// +kubebuilder:validation:items:Pattern:=^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
+	Namespaces []string `json:"namespaces,omitempty"`
+}
+
+// PushSecretMetadata defines metadata fields for the PushSecret generated by the ClusterPushSecret.
+type PushSecretMetadata struct {
+	// +optional
+	Annotations map[string]string `json:"annotations,omitempty"`
+
+	// +optional
+	Labels map[string]string `json:"labels,omitempty"`
+}
+
+type ClusterPushSecretSpec struct {
+	// PushSecretSpec defines what to do with the secrets.
+	PushSecretSpec PushSecretSpec `json:"pushSecretSpec"`
+	// The time in which the controller should reconcile its objects and recheck namespaces for labels.
+	RefreshInterval *metav1.Duration `json:"refreshTime,omitempty"`
+	// The name of the push secrets to be created.
+	// Defaults to the name of the ClusterPushSecret
+	// +optional
+	// +kubebuilder:validation:MinLength:=1
+	// +kubebuilder:validation:MaxLength:=253
+	// +kubebuilder:validation:Pattern:=^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+	PushSecretName string `json:"pushSecretName,omitempty"`
+
+	// The metadata of the external secrets to be created
+	// +optional
+	PushSecretMetadata PushSecretMetadata `json:"pushSecretMetadata,omitempty"`
+
+	// A list of labels to select by to find the Namespaces to create the ExternalSecrets in. The selectors are ORed.
+	// +optional
+	NamespaceSelectors []*metav1.LabelSelector `json:"namespaceSelectors,omitempty"`
+}
+
+// ClusterPushSecretNamespaceFailure represents a failed namespace deployment and it's reason.
+type ClusterPushSecretNamespaceFailure struct {
+
+	// Namespace is the namespace that failed when trying to apply an PushSecret
+	Namespace string `json:"namespace"`
+
+	// Reason is why the PushSecret failed to apply to the namespace
+	// +optional
+	Reason string `json:"reason,omitempty"`
+}
+
+type ClusterPushSecretStatus struct {
+	// Failed namespaces are the namespaces that failed to apply an PushSecret
+	// +optional
+	FailedNamespaces []ClusterPushSecretNamespaceFailure `json:"failedNamespaces,omitempty"`
+
+	// ProvisionedNamespaces are the namespaces where the ClusterPushSecret has secrets
+	// +optional
+	ProvisionedNamespaces []string `json:"provisionedNamespaces,omitempty"`
+	PushSecretName        string   `json:"pushSecretName,omitempty"`
+
+	// +optional
+	Conditions []PushSecretStatusCondition `json:"conditions,omitempty"`
+}
+
+// +kubebuilder:object:root=true
+// +kubebuilder:storageversion
+// ClusterPushSecretCondition is the Schema for the PushSecrets API.
+// +kubebuilder:printcolumn:name="AGE",type="date",JSONPath=".metadata.creationTimestamp"
+// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].reason`
+// +kubebuilder:subresource:status
+// +kubebuilder:metadata:labels="external-secrets.io/component=controller"
+// +kubebuilder:resource:scope=Cluster,categories={external-secrets}
+
+type ClusterPushSecret struct {
+	metav1.TypeMeta   `json:",inline"`
+	metav1.ObjectMeta `json:"metadata,omitempty"`
+
+	Spec   ClusterPushSecretSpec   `json:"spec,omitempty"`
+	Status ClusterPushSecretStatus `json:"status,omitempty"`
+}
+
+// ClusterPushSecretList contains a list of ClusterPushSecret resources.
+// +kubebuilder:object:root=true
+// +kubebuilder:printcolumn:name="AGE",type="date",JSONPath=".metadata.creationTimestamp"
+// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].reason`
+type ClusterPushSecretList struct {
+	metav1.TypeMeta `json:",inline"`
+	metav1.ListMeta `json:"metadata,omitempty"`
+	Items           []ClusterPushSecret `json:"items"`
+}

+ 8 - 0
apis/externalsecrets/v1alpha1/register.go

@@ -67,9 +67,17 @@ var (
 	PushSecretGroupVersionKind = SchemeGroupVersion.WithKind(PushSecretKind)
 )
 
+var (
+	ClusterPushSecretKind             = reflect.TypeOf(ClusterPushSecret{}).Name()
+	ClusterPushSecretGroupKind        = schema.GroupKind{Group: Group, Kind: ClusterPushSecretKind}.String()
+	ClusterPushSecretKindAPIVersion   = ClusterPushSecretKind + "." + SchemeGroupVersion.String()
+	ClusterPushSecretGroupVersionKind = SchemeGroupVersion.WithKind(ClusterPushSecretKind)
+)
+
 func init() {
 	SchemeBuilder.Register(&ExternalSecret{}, &ExternalSecretList{})
 	SchemeBuilder.Register(&SecretStore{}, &SecretStoreList{})
 	SchemeBuilder.Register(&ClusterSecretStore{}, &ClusterSecretStoreList{})
 	SchemeBuilder.Register(&PushSecret{}, &PushSecretList{})
+	SchemeBuilder.Register(&ClusterPushSecret{}, &ClusterPushSecretList{})
 }

+ 199 - 1
apis/externalsecrets/v1alpha1/zz_generated.deepcopy.go

@@ -384,6 +384,170 @@ func (in *CertAuth) DeepCopy() *CertAuth {
 }
 
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ClusterPushSecret) DeepCopyInto(out *ClusterPushSecret) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+	in.Spec.DeepCopyInto(&out.Spec)
+	in.Status.DeepCopyInto(&out.Status)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterPushSecret.
+func (in *ClusterPushSecret) DeepCopy() *ClusterPushSecret {
+	if in == nil {
+		return nil
+	}
+	out := new(ClusterPushSecret)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *ClusterPushSecret) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ClusterPushSecretCondition) DeepCopyInto(out *ClusterPushSecretCondition) {
+	*out = *in
+	if in.NamespaceSelector != nil {
+		in, out := &in.NamespaceSelector, &out.NamespaceSelector
+		*out = new(v1.LabelSelector)
+		(*in).DeepCopyInto(*out)
+	}
+	if in.Namespaces != nil {
+		in, out := &in.Namespaces, &out.Namespaces
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterPushSecretCondition.
+func (in *ClusterPushSecretCondition) DeepCopy() *ClusterPushSecretCondition {
+	if in == nil {
+		return nil
+	}
+	out := new(ClusterPushSecretCondition)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ClusterPushSecretList) DeepCopyInto(out *ClusterPushSecretList) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ListMeta.DeepCopyInto(&out.ListMeta)
+	if in.Items != nil {
+		in, out := &in.Items, &out.Items
+		*out = make([]ClusterPushSecret, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterPushSecretList.
+func (in *ClusterPushSecretList) DeepCopy() *ClusterPushSecretList {
+	if in == nil {
+		return nil
+	}
+	out := new(ClusterPushSecretList)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *ClusterPushSecretList) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ClusterPushSecretNamespaceFailure) DeepCopyInto(out *ClusterPushSecretNamespaceFailure) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterPushSecretNamespaceFailure.
+func (in *ClusterPushSecretNamespaceFailure) DeepCopy() *ClusterPushSecretNamespaceFailure {
+	if in == nil {
+		return nil
+	}
+	out := new(ClusterPushSecretNamespaceFailure)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ClusterPushSecretSpec) DeepCopyInto(out *ClusterPushSecretSpec) {
+	*out = *in
+	in.PushSecretSpec.DeepCopyInto(&out.PushSecretSpec)
+	if in.RefreshInterval != nil {
+		in, out := &in.RefreshInterval, &out.RefreshInterval
+		*out = new(v1.Duration)
+		**out = **in
+	}
+	in.PushSecretMetadata.DeepCopyInto(&out.PushSecretMetadata)
+	if in.NamespaceSelectors != nil {
+		in, out := &in.NamespaceSelectors, &out.NamespaceSelectors
+		*out = make([]*v1.LabelSelector, len(*in))
+		for i := range *in {
+			if (*in)[i] != nil {
+				in, out := &(*in)[i], &(*out)[i]
+				*out = new(v1.LabelSelector)
+				(*in).DeepCopyInto(*out)
+			}
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterPushSecretSpec.
+func (in *ClusterPushSecretSpec) DeepCopy() *ClusterPushSecretSpec {
+	if in == nil {
+		return nil
+	}
+	out := new(ClusterPushSecretSpec)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ClusterPushSecretStatus) DeepCopyInto(out *ClusterPushSecretStatus) {
+	*out = *in
+	if in.FailedNamespaces != nil {
+		in, out := &in.FailedNamespaces, &out.FailedNamespaces
+		*out = make([]ClusterPushSecretNamespaceFailure, len(*in))
+		copy(*out, *in)
+	}
+	if in.ProvisionedNamespaces != nil {
+		in, out := &in.ProvisionedNamespaces, &out.ProvisionedNamespaces
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
+	if in.Conditions != nil {
+		in, out := &in.Conditions, &out.Conditions
+		*out = make([]PushSecretStatusCondition, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterPushSecretStatus.
+func (in *ClusterPushSecretStatus) DeepCopy() *ClusterPushSecretStatus {
+	if in == nil {
+		return nil
+	}
+	out := new(ClusterPushSecretStatus)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *ClusterSecretStore) DeepCopyInto(out *ClusterSecretStore) {
 	*out = *in
 	out.TypeMeta = in.TypeMeta
@@ -1176,6 +1340,35 @@ func (in *PushSecretMatch) DeepCopy() *PushSecretMatch {
 }
 
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *PushSecretMetadata) DeepCopyInto(out *PushSecretMetadata) {
+	*out = *in
+	if in.Annotations != nil {
+		in, out := &in.Annotations, &out.Annotations
+		*out = make(map[string]string, len(*in))
+		for key, val := range *in {
+			(*out)[key] = val
+		}
+	}
+	if in.Labels != nil {
+		in, out := &in.Labels, &out.Labels
+		*out = make(map[string]string, len(*in))
+		for key, val := range *in {
+			(*out)[key] = val
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PushSecretMetadata.
+func (in *PushSecretMetadata) DeepCopy() *PushSecretMetadata {
+	if in == nil {
+		return nil
+	}
+	out := new(PushSecretMetadata)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *PushSecretRemoteRef) DeepCopyInto(out *PushSecretRemoteRef) {
 	*out = *in
 }
@@ -1193,6 +1386,11 @@ func (in *PushSecretRemoteRef) DeepCopy() *PushSecretRemoteRef {
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *PushSecretSecret) DeepCopyInto(out *PushSecretSecret) {
 	*out = *in
+	if in.Selector != nil {
+		in, out := &in.Selector, &out.Selector
+		*out = new(v1.LabelSelector)
+		(*in).DeepCopyInto(*out)
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PushSecretSecret.
@@ -1211,7 +1409,7 @@ func (in *PushSecretSelector) DeepCopyInto(out *PushSecretSelector) {
 	if in.Secret != nil {
 		in, out := &in.Secret, &out.Secret
 		*out = new(PushSecretSecret)
-		**out = **in
+		(*in).DeepCopyInto(*out)
 	}
 	if in.GeneratorRef != nil {
 		in, out := &in.GeneratorRef, &out.GeneratorRef

+ 1 - 0
apis/externalsecrets/v1beta1/clusterexternalsecret_types.go

@@ -46,6 +46,7 @@ type ClusterExternalSecretSpec struct {
 	NamespaceSelectors []*metav1.LabelSelector `json:"namespaceSelectors,omitempty"`
 
 	// Choose namespaces by name. This field is ORed with anything that NamespaceSelectors ends up choosing.
+	// Deprecated: Use NamespaceSelectors instead.
 	// +optional
 	// +kubebuilder:validation:items:MinLength:=1
 	// +kubebuilder:validation:items:MaxLength:=63

+ 21 - 0
cmd/controller/root.go

@@ -38,6 +38,8 @@ import (
 	genv1alpha1 "github.com/external-secrets/external-secrets/apis/generators/v1alpha1"
 	"github.com/external-secrets/external-secrets/pkg/controllers/clusterexternalsecret"
 	"github.com/external-secrets/external-secrets/pkg/controllers/clusterexternalsecret/cesmetrics"
+	"github.com/external-secrets/external-secrets/pkg/controllers/clusterpushsecret"
+	"github.com/external-secrets/external-secrets/pkg/controllers/clusterpushsecret/cpsmetrics"
 	ctrlcommon "github.com/external-secrets/external-secrets/pkg/controllers/common"
 	"github.com/external-secrets/external-secrets/pkg/controllers/externalsecret"
 	"github.com/external-secrets/external-secrets/pkg/controllers/externalsecret/esmetrics"
@@ -76,6 +78,7 @@ var (
 	namespace                             string
 	enableClusterStoreReconciler          bool
 	enableClusterExternalSecretReconciler bool
+	enableClusterPushSecretReconciler     bool
 	enablePushSecretReconciler            bool
 	enableFloodGate                       bool
 	enableExtendedMetricLabels            bool
@@ -265,6 +268,23 @@ var rootCmd = &cobra.Command{
 			}
 		}
 
+		if enableClusterPushSecretReconciler {
+			cpsmetrics.SetUpMetrics()
+
+			if err = (&clusterpushsecret.Reconciler{
+				Client:          mgr.GetClient(),
+				Log:             ctrl.Log.WithName("controllers").WithName("ClusterPushSecret"),
+				Scheme:          mgr.GetScheme(),
+				RequeueInterval: time.Hour,
+				Recorder:        mgr.GetEventRecorderFor("external-secrets-controller"),
+			}).SetupWithManager(mgr, controller.Options{
+				MaxConcurrentReconciles: concurrent,
+			}); err != nil {
+				setupLog.Error(err, errCreateController, "controller", "ClusterPushSecret")
+				os.Exit(1)
+			}
+		}
+
 		fs := feature.Features()
 		for _, f := range fs {
 			if f.Initialize == nil {
@@ -298,6 +318,7 @@ func init() {
 	rootCmd.Flags().StringVar(&namespace, "namespace", "", "watch external secrets scoped in the provided namespace only. ClusterSecretStore can be used but only work if it doesn't reference resources from other namespaces")
 	rootCmd.Flags().BoolVar(&enableClusterStoreReconciler, "enable-cluster-store-reconciler", true, "Enable cluster store reconciler.")
 	rootCmd.Flags().BoolVar(&enableClusterExternalSecretReconciler, "enable-cluster-external-secret-reconciler", true, "Enable cluster external secret reconciler.")
+	rootCmd.Flags().BoolVar(&enableClusterPushSecretReconciler, "enable-cluster-push-secret-reconciler", true, "Enable cluster push secret reconciler.")
 	rootCmd.Flags().BoolVar(&enablePushSecretReconciler, "enable-push-secret-reconciler", true, "Enable push secret reconciler.")
 	rootCmd.Flags().BoolVar(&enableSecretsCache, "enable-secrets-caching", false, "Enable secrets caching for ALL secrets in the cluster (WARNING: can increase memory usage).")
 	rootCmd.Flags().BoolVar(&enableConfigMapsCache, "enable-configmaps-caching", false, "Enable configmaps caching for ALL configmaps in the cluster (WARNING: can increase memory usage).")

+ 3 - 2
config/crds/bases/external-secrets.io_clusterexternalsecrets.yaml

@@ -697,8 +697,9 @@ spec:
                   x-kubernetes-map-type: atomic
                 type: array
               namespaces:
-                description: Choose namespaces by name. This field is ORed with anything
-                  that NamespaceSelectors ends up choosing.
+                description: |-
+                  Choose namespaces by name. This field is ORed with anything that NamespaceSelectors ends up choosing.
+                  Deprecated: Use NamespaceSelectors instead.
                 items:
                   maxLength: 63
                   minLength: 1

+ 542 - 0
config/crds/bases/external-secrets.io_clusterpushsecrets.yaml

@@ -0,0 +1,542 @@
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.17.2
+  labels:
+    external-secrets.io/component: controller
+  name: clusterpushsecrets.external-secrets.io
+spec:
+  group: external-secrets.io
+  names:
+    categories:
+    - external-secrets
+    kind: ClusterPushSecret
+    listKind: ClusterPushSecretList
+    plural: clusterpushsecrets
+    singular: clusterpushsecret
+  scope: Cluster
+  versions:
+  - additionalPrinterColumns:
+    - jsonPath: .metadata.creationTimestamp
+      name: AGE
+      type: date
+    - jsonPath: .status.conditions[?(@.type=="Ready")].reason
+      name: Status
+      type: string
+    name: v1alpha1
+    schema:
+      openAPIV3Schema:
+        properties:
+          apiVersion:
+            description: |-
+              APIVersion defines the versioned schema of this representation of an object.
+              Servers should convert recognized schemas to the latest internal value, and
+              may reject unrecognized values.
+              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+            type: string
+          kind:
+            description: |-
+              Kind is a string value representing the REST resource this object represents.
+              Servers may infer this from the endpoint the client submits requests to.
+              Cannot be updated.
+              In CamelCase.
+              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+            type: string
+          metadata:
+            type: object
+          spec:
+            properties:
+              namespaceSelectors:
+                description: A list of labels to select by to find the Namespaces
+                  to create the ExternalSecrets in. The selectors are ORed.
+                items:
+                  description: |-
+                    A label selector is a label query over a set of resources. The result of matchLabels and
+                    matchExpressions are ANDed. An empty label selector matches all objects. A null
+                    label selector matches no objects.
+                  properties:
+                    matchExpressions:
+                      description: matchExpressions is a list of label selector requirements.
+                        The requirements are ANDed.
+                      items:
+                        description: |-
+                          A label selector requirement is a selector that contains values, a key, and an operator that
+                          relates the key and values.
+                        properties:
+                          key:
+                            description: key is the label key that the selector applies
+                              to.
+                            type: string
+                          operator:
+                            description: |-
+                              operator represents a key's relationship to a set of values.
+                              Valid operators are In, NotIn, Exists and DoesNotExist.
+                            type: string
+                          values:
+                            description: |-
+                              values is an array of string values. If the operator is In or NotIn,
+                              the values array must be non-empty. If the operator is Exists or DoesNotExist,
+                              the values array must be empty. This array is replaced during a strategic
+                              merge patch.
+                            items:
+                              type: string
+                            type: array
+                            x-kubernetes-list-type: atomic
+                        required:
+                        - key
+                        - operator
+                        type: object
+                      type: array
+                      x-kubernetes-list-type: atomic
+                    matchLabels:
+                      additionalProperties:
+                        type: string
+                      description: |-
+                        matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
+                        map is equivalent to an element of matchExpressions, whose key field is "key", the
+                        operator is "In", and the values array contains only "value". The requirements are ANDed.
+                      type: object
+                  type: object
+                  x-kubernetes-map-type: atomic
+                type: array
+              pushSecretMetadata:
+                description: The metadata of the external secrets to be created
+                properties:
+                  annotations:
+                    additionalProperties:
+                      type: string
+                    type: object
+                  labels:
+                    additionalProperties:
+                      type: string
+                    type: object
+                type: object
+              pushSecretName:
+                description: |-
+                  The name of the push secrets to be created.
+                  Defaults to the name of the ClusterPushSecret
+                maxLength: 253
+                minLength: 1
+                pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+                type: string
+              pushSecretSpec:
+                description: PushSecretSpec defines what to do with the secrets.
+                properties:
+                  data:
+                    description: Secret Data that should be pushed to providers
+                    items:
+                      properties:
+                        conversionStrategy:
+                          default: None
+                          description: Used to define a conversion Strategy for the
+                            secret keys
+                          enum:
+                          - None
+                          - ReverseUnicode
+                          type: string
+                        match:
+                          description: Match a given Secret Key to be pushed to the
+                            provider.
+                          properties:
+                            remoteRef:
+                              description: Remote Refs to push to providers.
+                              properties:
+                                property:
+                                  description: Name of the property in the resulting
+                                    secret
+                                  type: string
+                                remoteKey:
+                                  description: Name of the resulting provider secret.
+                                  type: string
+                              required:
+                              - remoteKey
+                              type: object
+                            secretKey:
+                              description: Secret Key to be pushed
+                              type: string
+                          required:
+                          - remoteRef
+                          type: object
+                        metadata:
+                          description: |-
+                            Metadata is metadata attached to the secret.
+                            The structure of metadata is provider specific, please look it up in the provider documentation.
+                          x-kubernetes-preserve-unknown-fields: true
+                      required:
+                      - match
+                      type: object
+                    type: array
+                  deletionPolicy:
+                    default: None
+                    description: Deletion Policy to handle Secrets in the provider.
+                    enum:
+                    - Delete
+                    - None
+                    type: string
+                  refreshInterval:
+                    default: 1h
+                    description: The Interval to which External Secrets will try to
+                      push a secret definition
+                    type: string
+                  secretStoreRefs:
+                    items:
+                      properties:
+                        kind:
+                          default: SecretStore
+                          description: Kind of the SecretStore resource (SecretStore
+                            or ClusterSecretStore)
+                          enum:
+                          - SecretStore
+                          - ClusterSecretStore
+                          type: string
+                        labelSelector:
+                          description: Optionally, sync to secret stores with label
+                            selector
+                          properties:
+                            matchExpressions:
+                              description: matchExpressions is a list of label selector
+                                requirements. The requirements are ANDed.
+                              items:
+                                description: |-
+                                  A label selector requirement is a selector that contains values, a key, and an operator that
+                                  relates the key and values.
+                                properties:
+                                  key:
+                                    description: key is the label key that the selector
+                                      applies to.
+                                    type: string
+                                  operator:
+                                    description: |-
+                                      operator represents a key's relationship to a set of values.
+                                      Valid operators are In, NotIn, Exists and DoesNotExist.
+                                    type: string
+                                  values:
+                                    description: |-
+                                      values is an array of string values. If the operator is In or NotIn,
+                                      the values array must be non-empty. If the operator is Exists or DoesNotExist,
+                                      the values array must be empty. This array is replaced during a strategic
+                                      merge patch.
+                                    items:
+                                      type: string
+                                    type: array
+                                    x-kubernetes-list-type: atomic
+                                required:
+                                - key
+                                - operator
+                                type: object
+                              type: array
+                              x-kubernetes-list-type: atomic
+                            matchLabels:
+                              additionalProperties:
+                                type: string
+                              description: |-
+                                matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
+                                map is equivalent to an element of matchExpressions, whose key field is "key", the
+                                operator is "In", and the values array contains only "value". The requirements are ANDed.
+                              type: object
+                          type: object
+                          x-kubernetes-map-type: atomic
+                        name:
+                          description: Optionally, sync to the SecretStore of the
+                            given name
+                          maxLength: 253
+                          minLength: 1
+                          pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+                          type: string
+                      type: object
+                    type: array
+                  selector:
+                    description: The Secret Selector (k8s source) for the Push Secret
+                    maxProperties: 1
+                    minProperties: 1
+                    properties:
+                      generatorRef:
+                        description: Point to a generator to create a Secret.
+                        properties:
+                          apiVersion:
+                            default: generators.external-secrets.io/v1alpha1
+                            description: Specify the apiVersion of the generator resource
+                            type: string
+                          kind:
+                            description: Specify the Kind of the generator resource
+                            enum:
+                            - ACRAccessToken
+                            - ClusterGenerator
+                            - ECRAuthorizationToken
+                            - Fake
+                            - GCRAccessToken
+                            - GithubAccessToken
+                            - QuayAccessToken
+                            - Password
+                            - STSSessionToken
+                            - UUID
+                            - VaultDynamicSecret
+                            - Webhook
+                            - Grafana
+                            type: string
+                          name:
+                            description: Specify the name of the generator resource
+                            maxLength: 253
+                            minLength: 1
+                            pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+                            type: string
+                        required:
+                        - kind
+                        - name
+                        type: object
+                      secret:
+                        description: Select a Secret to Push.
+                        properties:
+                          name:
+                            description: |-
+                              Name of the Secret.
+                              The Secret must exist in the same namespace as the PushSecret manifest.
+                            maxLength: 253
+                            minLength: 1
+                            pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+                            type: string
+                          selector:
+                            description: Selector chooses secrets using a labelSelector.
+                            properties:
+                              matchExpressions:
+                                description: matchExpressions is a list of label selector
+                                  requirements. The requirements are ANDed.
+                                items:
+                                  description: |-
+                                    A label selector requirement is a selector that contains values, a key, and an operator that
+                                    relates the key and values.
+                                  properties:
+                                    key:
+                                      description: key is the label key that the selector
+                                        applies to.
+                                      type: string
+                                    operator:
+                                      description: |-
+                                        operator represents a key's relationship to a set of values.
+                                        Valid operators are In, NotIn, Exists and DoesNotExist.
+                                      type: string
+                                    values:
+                                      description: |-
+                                        values is an array of string values. If the operator is In or NotIn,
+                                        the values array must be non-empty. If the operator is Exists or DoesNotExist,
+                                        the values array must be empty. This array is replaced during a strategic
+                                        merge patch.
+                                      items:
+                                        type: string
+                                      type: array
+                                      x-kubernetes-list-type: atomic
+                                  required:
+                                  - key
+                                  - operator
+                                  type: object
+                                type: array
+                                x-kubernetes-list-type: atomic
+                              matchLabels:
+                                additionalProperties:
+                                  type: string
+                                description: |-
+                                  matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
+                                  map is equivalent to an element of matchExpressions, whose key field is "key", the
+                                  operator is "In", and the values array contains only "value". The requirements are ANDed.
+                                type: object
+                            type: object
+                            x-kubernetes-map-type: atomic
+                        type: object
+                    type: object
+                  template:
+                    description: Template defines a blueprint for the created Secret
+                      resource.
+                    properties:
+                      data:
+                        additionalProperties:
+                          type: string
+                        type: object
+                      engineVersion:
+                        default: v2
+                        description: |-
+                          EngineVersion specifies the template engine version
+                          that should be used to compile/execute the
+                          template specified in .data and .templateFrom[].
+                        enum:
+                        - v1
+                        - v2
+                        type: string
+                      mergePolicy:
+                        default: Replace
+                        enum:
+                        - Replace
+                        - Merge
+                        type: string
+                      metadata:
+                        description: ExternalSecretTemplateMetadata defines metadata
+                          fields for the Secret blueprint.
+                        properties:
+                          annotations:
+                            additionalProperties:
+                              type: string
+                            type: object
+                          labels:
+                            additionalProperties:
+                              type: string
+                            type: object
+                        type: object
+                      templateFrom:
+                        items:
+                          properties:
+                            configMap:
+                              properties:
+                                items:
+                                  description: A list of keys in the ConfigMap/Secret
+                                    to use as templates for Secret data
+                                  items:
+                                    properties:
+                                      key:
+                                        description: A key in the ConfigMap/Secret
+                                        maxLength: 253
+                                        minLength: 1
+                                        pattern: ^[-._a-zA-Z0-9]+$
+                                        type: string
+                                      templateAs:
+                                        default: Values
+                                        enum:
+                                        - Values
+                                        - KeysAndValues
+                                        type: string
+                                    required:
+                                    - key
+                                    type: object
+                                  type: array
+                                name:
+                                  description: The name of the ConfigMap/Secret resource
+                                  maxLength: 253
+                                  minLength: 1
+                                  pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+                                  type: string
+                              required:
+                              - items
+                              - name
+                              type: object
+                            literal:
+                              type: string
+                            secret:
+                              properties:
+                                items:
+                                  description: A list of keys in the ConfigMap/Secret
+                                    to use as templates for Secret data
+                                  items:
+                                    properties:
+                                      key:
+                                        description: A key in the ConfigMap/Secret
+                                        maxLength: 253
+                                        minLength: 1
+                                        pattern: ^[-._a-zA-Z0-9]+$
+                                        type: string
+                                      templateAs:
+                                        default: Values
+                                        enum:
+                                        - Values
+                                        - KeysAndValues
+                                        type: string
+                                    required:
+                                    - key
+                                    type: object
+                                  type: array
+                                name:
+                                  description: The name of the ConfigMap/Secret resource
+                                  maxLength: 253
+                                  minLength: 1
+                                  pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+                                  type: string
+                              required:
+                              - items
+                              - name
+                              type: object
+                            target:
+                              default: Data
+                              enum:
+                              - Data
+                              - Annotations
+                              - Labels
+                              type: string
+                          type: object
+                        type: array
+                      type:
+                        type: string
+                    type: object
+                  updatePolicy:
+                    default: Replace
+                    description: UpdatePolicy to handle Secrets in the provider.
+                    enum:
+                    - Replace
+                    - IfNotExists
+                    type: string
+                required:
+                - secretStoreRefs
+                - selector
+                type: object
+              refreshTime:
+                description: The time in which the controller should reconcile its
+                  objects and recheck namespaces for labels.
+                type: string
+            required:
+            - pushSecretSpec
+            type: object
+          status:
+            properties:
+              conditions:
+                items:
+                  description: PushSecretStatusCondition indicates the status of the
+                    PushSecret.
+                  properties:
+                    lastTransitionTime:
+                      format: date-time
+                      type: string
+                    message:
+                      type: string
+                    reason:
+                      type: string
+                    status:
+                      type: string
+                    type:
+                      description: PushSecretConditionType indicates the condition
+                        of the PushSecret.
+                      type: string
+                  required:
+                  - status
+                  - type
+                  type: object
+                type: array
+              failedNamespaces:
+                description: Failed namespaces are the namespaces that failed to apply
+                  an PushSecret
+                items:
+                  description: ClusterPushSecretNamespaceFailure represents a failed
+                    namespace deployment and it's reason.
+                  properties:
+                    namespace:
+                      description: Namespace is the namespace that failed when trying
+                        to apply an PushSecret
+                      type: string
+                    reason:
+                      description: Reason is why the PushSecret failed to apply to
+                        the namespace
+                      type: string
+                  required:
+                  - namespace
+                  type: object
+                type: array
+              provisionedNamespaces:
+                description: ProvisionedNamespaces are the namespaces where the ClusterPushSecret
+                  has secrets
+                items:
+                  type: string
+                type: array
+              pushSecretName:
+                type: string
+            type: object
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}

+ 46 - 2
config/crds/bases/external-secrets.io_pushsecrets.yaml

@@ -220,8 +220,52 @@ spec:
                         minLength: 1
                         pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
                         type: string
-                    required:
-                    - name
+                      selector:
+                        description: Selector chooses secrets using a labelSelector.
+                        properties:
+                          matchExpressions:
+                            description: matchExpressions is a list of label selector
+                              requirements. The requirements are ANDed.
+                            items:
+                              description: |-
+                                A label selector requirement is a selector that contains values, a key, and an operator that
+                                relates the key and values.
+                              properties:
+                                key:
+                                  description: key is the label key that the selector
+                                    applies to.
+                                  type: string
+                                operator:
+                                  description: |-
+                                    operator represents a key's relationship to a set of values.
+                                    Valid operators are In, NotIn, Exists and DoesNotExist.
+                                  type: string
+                                values:
+                                  description: |-
+                                    values is an array of string values. If the operator is In or NotIn,
+                                    the values array must be non-empty. If the operator is Exists or DoesNotExist,
+                                    the values array must be empty. This array is replaced during a strategic
+                                    merge patch.
+                                  items:
+                                    type: string
+                                  type: array
+                                  x-kubernetes-list-type: atomic
+                              required:
+                              - key
+                              - operator
+                              type: object
+                            type: array
+                            x-kubernetes-list-type: atomic
+                          matchLabels:
+                            additionalProperties:
+                              type: string
+                            description: |-
+                              matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
+                              map is equivalent to an element of matchExpressions, whose key field is "key", the
+                              operator is "In", and the values array contains only "value". The requirements are ANDed.
+                            type: object
+                        type: object
+                        x-kubernetes-map-type: atomic
                     type: object
                 type: object
               template:

+ 1 - 0
config/crds/bases/kustomization.yaml

@@ -3,6 +3,7 @@ apiVersion: kustomize.config.k8s.io/v1beta1
 kind: Kustomization
 resources:
   - external-secrets.io_clusterexternalsecrets.yaml
+  - external-secrets.io_clusterpushsecrets.yaml
   - external-secrets.io_clustersecretstores.yaml
   - external-secrets.io_externalsecrets.yaml
   - external-secrets.io_pushsecrets.yaml

+ 2 - 0
deploy/charts/external-secrets/README.md

@@ -90,6 +90,7 @@ The command removes all the Kubernetes components associated with the chart and
 | crds.conversion.enabled | bool | `true` | If webhook is set to false this also needs to be set to false otherwise the kubeapi will be hammered because the conversion is looking for a webhook endpoint. |
 | crds.createClusterExternalSecret | bool | `true` | If true, create CRDs for Cluster External Secret. |
 | crds.createClusterGenerator | bool | `true` | If true, create CRDs for Cluster Generator. |
+| crds.createClusterPushSecret | bool | `true` | If true, create CRDs for Cluster Push Secret. |
 | crds.createClusterSecretStore | bool | `true` | If true, create CRDs for Cluster Secret Store. |
 | crds.createPushSecret | bool | `true` | If true, create CRDs for Push Secret. |
 | createOperator | bool | `true` | Specifies whether an external secret operator deployment be created. |
@@ -132,6 +133,7 @@ The command removes all the Kubernetes components associated with the chart and
 | podSpecExtra | object | `{}` | Any extra pod spec on the deployment |
 | priorityClassName | string | `""` | Pod priority class name. |
 | processClusterExternalSecret | bool | `true` | if true, the operator will process cluster external secret. Else, it will ignore them. |
+| processClusterPushSecret | bool | `true` | if true, the operator will process cluster push secret. Else, it will ignore them. |
 | processClusterStore | bool | `true` | if true, the operator will process cluster store. Else, it will ignore them. |
 | processPushSecret | bool | `true` | if true, the operator will process push secret. Else, it will ignore them. |
 | rbac.create | bool | `true` | Specifies whether role and rolebinding resources should be created. |

+ 4 - 1
deploy/charts/external-secrets/templates/deployment.yaml

@@ -51,7 +51,7 @@ spec:
           {{- end }}
           image: {{ include "external-secrets.image" (dict "chartAppVersion" .Chart.AppVersion "image" .Values.image) | trim }}
           imagePullPolicy: {{ .Values.image.pullPolicy }}
-          {{- if or (.Values.leaderElect) (.Values.scopedNamespace) (.Values.processClusterStore) (.Values.processClusterExternalSecret) (.Values.concurrent) (.Values.extraArgs) }}
+          {{- if or (.Values.leaderElect) (.Values.scopedNamespace) (.Values.processClusterStore) (.Values.processClusterExternalSecret) (.Values.processClusterPushSecret) (.Values.concurrent) (.Values.extraArgs) }}
           args:
           {{- if .Values.leaderElect }}
           - --enable-leader-election=true
@@ -69,6 +69,9 @@ spec:
             {{- if not .Values.processClusterExternalSecret }}
           - --enable-cluster-external-secret-reconciler=false
             {{- end }}
+            {{- if not .Values.processClusterPushSecret }}
+          - --enable-cluster-push-secret-reconciler=false
+            {{- end }}
           {{- end }}
           {{- if not .Values.processPushSecret }}
           - --enable-push-secret-reconciler=false

+ 15 - 0
deploy/charts/external-secrets/templates/rbac.yaml

@@ -21,6 +21,7 @@ rules:
     - "externalsecrets"
     - "clusterexternalsecrets"
     - "pushsecrets"
+    - "clusterpushsecrets"
     verbs:
     - "get"
     - "list"
@@ -43,6 +44,9 @@ rules:
     - "pushsecrets"
     - "pushsecrets/status"
     - "pushsecrets/finalizers"
+    - "clusterpushsecrets"
+    - "clusterpushsecrets/status"
+    - "clusterpushsecrets/finalizers"
     verbs:
     - "get"
     - "update"
@@ -130,6 +134,14 @@ rules:
     - "create"
     - "update"
     - "delete"
+  - apiGroups:
+    - "external-secrets.io"
+    resources:
+    - "pushsecrets"
+    verbs:
+    - "create"
+    - "update"
+    - "delete"
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 {{- if and .Values.scopedNamespace .Values.scopedRBAC }}
@@ -155,6 +167,7 @@ rules:
       - "secretstores"
       - "clustersecretstores"
       - "pushsecrets"
+      - "clusterpushsecrets"
     verbs:
       - "get"
       - "watch"
@@ -202,6 +215,7 @@ rules:
       - "secretstores"
       - "clustersecretstores"
       - "pushsecrets"
+      - "clusterpushsecrets"
     verbs:
       - "create"
       - "delete"
@@ -319,6 +333,7 @@ rules:
     - "external-secrets.io"
     resources:
     - "externalsecrets"
+    - "pushsecrets"
     verbs:
     - "get"
     - "list"

+ 3 - 0
deploy/charts/external-secrets/values.schema.json

@@ -273,6 +273,9 @@
                 "createClusterGenerator": {
                     "type": "boolean"
                 },
+                "createClusterPushSecret": {
+                    "type": "boolean"
+                },
                 "createClusterSecretStore": {
                     "type": "boolean"
                 },

+ 5 - 0
deploy/charts/external-secrets/values.yaml

@@ -41,6 +41,8 @@ crds:
   createClusterSecretStore: true
   # -- If true, create CRDs for Cluster Generator.
   createClusterGenerator: true
+  # -- If true, create CRDs for Cluster Push Secret.
+  createClusterPushSecret: true
   # -- If true, create CRDs for Push Secret.
   createPushSecret: true
   annotations: {}
@@ -79,6 +81,9 @@ scopedRBAC: false
 # -- if true, the operator will process cluster external secret. Else, it will ignore them.
 processClusterExternalSecret: true
 
+# -- if true, the operator will process cluster push secret. Else, it will ignore them.
+processClusterPushSecret: true
+
 # -- if true, the operator will process cluster store. Else, it will ignore them.
 processClusterStore: true
 

+ 574 - 3
deploy/crds/bundle.yaml

@@ -664,7 +664,9 @@ spec:
                     x-kubernetes-map-type: atomic
                   type: array
                 namespaces:
-                  description: Choose namespaces by name. This field is ORed with anything that NamespaceSelectors ends up choosing.
+                  description: |-
+                    Choose namespaces by name. This field is ORed with anything that NamespaceSelectors ends up choosing.
+                    Deprecated: Use NamespaceSelectors instead.
                   items:
                     maxLength: 63
                     minLength: 1
@@ -741,6 +743,533 @@ metadata:
     controller-gen.kubebuilder.io/version: v0.17.2
   labels:
     external-secrets.io/component: controller
+  name: clusterpushsecrets.external-secrets.io
+spec:
+  group: external-secrets.io
+  names:
+    categories:
+      - external-secrets
+    kind: ClusterPushSecret
+    listKind: ClusterPushSecretList
+    plural: clusterpushsecrets
+    singular: clusterpushsecret
+  scope: Cluster
+  versions:
+    - additionalPrinterColumns:
+        - jsonPath: .metadata.creationTimestamp
+          name: AGE
+          type: date
+        - jsonPath: .status.conditions[?(@.type=="Ready")].reason
+          name: Status
+          type: string
+      name: v1alpha1
+      schema:
+        openAPIV3Schema:
+          properties:
+            apiVersion:
+              description: |-
+                APIVersion defines the versioned schema of this representation of an object.
+                Servers should convert recognized schemas to the latest internal value, and
+                may reject unrecognized values.
+                More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+              type: string
+            kind:
+              description: |-
+                Kind is a string value representing the REST resource this object represents.
+                Servers may infer this from the endpoint the client submits requests to.
+                Cannot be updated.
+                In CamelCase.
+                More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+              type: string
+            metadata:
+              type: object
+            spec:
+              properties:
+                namespaceSelectors:
+                  description: A list of labels to select by to find the Namespaces to create the ExternalSecrets in. The selectors are ORed.
+                  items:
+                    description: |-
+                      A label selector is a label query over a set of resources. The result of matchLabels and
+                      matchExpressions are ANDed. An empty label selector matches all objects. A null
+                      label selector matches no objects.
+                    properties:
+                      matchExpressions:
+                        description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
+                        items:
+                          description: |-
+                            A label selector requirement is a selector that contains values, a key, and an operator that
+                            relates the key and values.
+                          properties:
+                            key:
+                              description: key is the label key that the selector applies to.
+                              type: string
+                            operator:
+                              description: |-
+                                operator represents a key's relationship to a set of values.
+                                Valid operators are In, NotIn, Exists and DoesNotExist.
+                              type: string
+                            values:
+                              description: |-
+                                values is an array of string values. If the operator is In or NotIn,
+                                the values array must be non-empty. If the operator is Exists or DoesNotExist,
+                                the values array must be empty. This array is replaced during a strategic
+                                merge patch.
+                              items:
+                                type: string
+                              type: array
+                              x-kubernetes-list-type: atomic
+                          required:
+                            - key
+                            - operator
+                          type: object
+                        type: array
+                        x-kubernetes-list-type: atomic
+                      matchLabels:
+                        additionalProperties:
+                          type: string
+                        description: |-
+                          matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
+                          map is equivalent to an element of matchExpressions, whose key field is "key", the
+                          operator is "In", and the values array contains only "value". The requirements are ANDed.
+                        type: object
+                    type: object
+                    x-kubernetes-map-type: atomic
+                  type: array
+                pushSecretMetadata:
+                  description: The metadata of the external secrets to be created
+                  properties:
+                    annotations:
+                      additionalProperties:
+                        type: string
+                      type: object
+                    labels:
+                      additionalProperties:
+                        type: string
+                      type: object
+                  type: object
+                pushSecretName:
+                  description: |-
+                    The name of the push secrets to be created.
+                    Defaults to the name of the ClusterPushSecret
+                  maxLength: 253
+                  minLength: 1
+                  pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+                  type: string
+                pushSecretSpec:
+                  description: PushSecretSpec defines what to do with the secrets.
+                  properties:
+                    data:
+                      description: Secret Data that should be pushed to providers
+                      items:
+                        properties:
+                          conversionStrategy:
+                            default: None
+                            description: Used to define a conversion Strategy for the secret keys
+                            enum:
+                              - None
+                              - ReverseUnicode
+                            type: string
+                          match:
+                            description: Match a given Secret Key to be pushed to the provider.
+                            properties:
+                              remoteRef:
+                                description: Remote Refs to push to providers.
+                                properties:
+                                  property:
+                                    description: Name of the property in the resulting secret
+                                    type: string
+                                  remoteKey:
+                                    description: Name of the resulting provider secret.
+                                    type: string
+                                required:
+                                  - remoteKey
+                                type: object
+                              secretKey:
+                                description: Secret Key to be pushed
+                                type: string
+                            required:
+                              - remoteRef
+                            type: object
+                          metadata:
+                            description: |-
+                              Metadata is metadata attached to the secret.
+                              The structure of metadata is provider specific, please look it up in the provider documentation.
+                            x-kubernetes-preserve-unknown-fields: true
+                        required:
+                          - match
+                        type: object
+                      type: array
+                    deletionPolicy:
+                      default: None
+                      description: Deletion Policy to handle Secrets in the provider.
+                      enum:
+                        - Delete
+                        - None
+                      type: string
+                    refreshInterval:
+                      default: 1h
+                      description: The Interval to which External Secrets will try to push a secret definition
+                      type: string
+                    secretStoreRefs:
+                      items:
+                        properties:
+                          kind:
+                            default: SecretStore
+                            description: Kind of the SecretStore resource (SecretStore or ClusterSecretStore)
+                            enum:
+                              - SecretStore
+                              - ClusterSecretStore
+                            type: string
+                          labelSelector:
+                            description: Optionally, sync to secret stores with label selector
+                            properties:
+                              matchExpressions:
+                                description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
+                                items:
+                                  description: |-
+                                    A label selector requirement is a selector that contains values, a key, and an operator that
+                                    relates the key and values.
+                                  properties:
+                                    key:
+                                      description: key is the label key that the selector applies to.
+                                      type: string
+                                    operator:
+                                      description: |-
+                                        operator represents a key's relationship to a set of values.
+                                        Valid operators are In, NotIn, Exists and DoesNotExist.
+                                      type: string
+                                    values:
+                                      description: |-
+                                        values is an array of string values. If the operator is In or NotIn,
+                                        the values array must be non-empty. If the operator is Exists or DoesNotExist,
+                                        the values array must be empty. This array is replaced during a strategic
+                                        merge patch.
+                                      items:
+                                        type: string
+                                      type: array
+                                      x-kubernetes-list-type: atomic
+                                  required:
+                                    - key
+                                    - operator
+                                  type: object
+                                type: array
+                                x-kubernetes-list-type: atomic
+                              matchLabels:
+                                additionalProperties:
+                                  type: string
+                                description: |-
+                                  matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
+                                  map is equivalent to an element of matchExpressions, whose key field is "key", the
+                                  operator is "In", and the values array contains only "value". The requirements are ANDed.
+                                type: object
+                            type: object
+                            x-kubernetes-map-type: atomic
+                          name:
+                            description: Optionally, sync to the SecretStore of the given name
+                            maxLength: 253
+                            minLength: 1
+                            pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+                            type: string
+                        type: object
+                      type: array
+                    selector:
+                      description: The Secret Selector (k8s source) for the Push Secret
+                      maxProperties: 1
+                      minProperties: 1
+                      properties:
+                        generatorRef:
+                          description: Point to a generator to create a Secret.
+                          properties:
+                            apiVersion:
+                              default: generators.external-secrets.io/v1alpha1
+                              description: Specify the apiVersion of the generator resource
+                              type: string
+                            kind:
+                              description: Specify the Kind of the generator resource
+                              enum:
+                                - ACRAccessToken
+                                - ClusterGenerator
+                                - ECRAuthorizationToken
+                                - Fake
+                                - GCRAccessToken
+                                - GithubAccessToken
+                                - QuayAccessToken
+                                - Password
+                                - STSSessionToken
+                                - UUID
+                                - VaultDynamicSecret
+                                - Webhook
+                                - Grafana
+                              type: string
+                            name:
+                              description: Specify the name of the generator resource
+                              maxLength: 253
+                              minLength: 1
+                              pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+                              type: string
+                          required:
+                            - kind
+                            - name
+                          type: object
+                        secret:
+                          description: Select a Secret to Push.
+                          properties:
+                            name:
+                              description: |-
+                                Name of the Secret.
+                                The Secret must exist in the same namespace as the PushSecret manifest.
+                              maxLength: 253
+                              minLength: 1
+                              pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+                              type: string
+                            selector:
+                              description: Selector chooses secrets using a labelSelector.
+                              properties:
+                                matchExpressions:
+                                  description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
+                                  items:
+                                    description: |-
+                                      A label selector requirement is a selector that contains values, a key, and an operator that
+                                      relates the key and values.
+                                    properties:
+                                      key:
+                                        description: key is the label key that the selector applies to.
+                                        type: string
+                                      operator:
+                                        description: |-
+                                          operator represents a key's relationship to a set of values.
+                                          Valid operators are In, NotIn, Exists and DoesNotExist.
+                                        type: string
+                                      values:
+                                        description: |-
+                                          values is an array of string values. If the operator is In or NotIn,
+                                          the values array must be non-empty. If the operator is Exists or DoesNotExist,
+                                          the values array must be empty. This array is replaced during a strategic
+                                          merge patch.
+                                        items:
+                                          type: string
+                                        type: array
+                                        x-kubernetes-list-type: atomic
+                                    required:
+                                      - key
+                                      - operator
+                                    type: object
+                                  type: array
+                                  x-kubernetes-list-type: atomic
+                                matchLabels:
+                                  additionalProperties:
+                                    type: string
+                                  description: |-
+                                    matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
+                                    map is equivalent to an element of matchExpressions, whose key field is "key", the
+                                    operator is "In", and the values array contains only "value". The requirements are ANDed.
+                                  type: object
+                              type: object
+                              x-kubernetes-map-type: atomic
+                          type: object
+                      type: object
+                    template:
+                      description: Template defines a blueprint for the created Secret resource.
+                      properties:
+                        data:
+                          additionalProperties:
+                            type: string
+                          type: object
+                        engineVersion:
+                          default: v2
+                          description: |-
+                            EngineVersion specifies the template engine version
+                            that should be used to compile/execute the
+                            template specified in .data and .templateFrom[].
+                          enum:
+                            - v1
+                            - v2
+                          type: string
+                        mergePolicy:
+                          default: Replace
+                          enum:
+                            - Replace
+                            - Merge
+                          type: string
+                        metadata:
+                          description: ExternalSecretTemplateMetadata defines metadata fields for the Secret blueprint.
+                          properties:
+                            annotations:
+                              additionalProperties:
+                                type: string
+                              type: object
+                            labels:
+                              additionalProperties:
+                                type: string
+                              type: object
+                          type: object
+                        templateFrom:
+                          items:
+                            properties:
+                              configMap:
+                                properties:
+                                  items:
+                                    description: A list of keys in the ConfigMap/Secret to use as templates for Secret data
+                                    items:
+                                      properties:
+                                        key:
+                                          description: A key in the ConfigMap/Secret
+                                          maxLength: 253
+                                          minLength: 1
+                                          pattern: ^[-._a-zA-Z0-9]+$
+                                          type: string
+                                        templateAs:
+                                          default: Values
+                                          enum:
+                                            - Values
+                                            - KeysAndValues
+                                          type: string
+                                      required:
+                                        - key
+                                      type: object
+                                    type: array
+                                  name:
+                                    description: The name of the ConfigMap/Secret resource
+                                    maxLength: 253
+                                    minLength: 1
+                                    pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+                                    type: string
+                                required:
+                                  - items
+                                  - name
+                                type: object
+                              literal:
+                                type: string
+                              secret:
+                                properties:
+                                  items:
+                                    description: A list of keys in the ConfigMap/Secret to use as templates for Secret data
+                                    items:
+                                      properties:
+                                        key:
+                                          description: A key in the ConfigMap/Secret
+                                          maxLength: 253
+                                          minLength: 1
+                                          pattern: ^[-._a-zA-Z0-9]+$
+                                          type: string
+                                        templateAs:
+                                          default: Values
+                                          enum:
+                                            - Values
+                                            - KeysAndValues
+                                          type: string
+                                      required:
+                                        - key
+                                      type: object
+                                    type: array
+                                  name:
+                                    description: The name of the ConfigMap/Secret resource
+                                    maxLength: 253
+                                    minLength: 1
+                                    pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+                                    type: string
+                                required:
+                                  - items
+                                  - name
+                                type: object
+                              target:
+                                default: Data
+                                enum:
+                                  - Data
+                                  - Annotations
+                                  - Labels
+                                type: string
+                            type: object
+                          type: array
+                        type:
+                          type: string
+                      type: object
+                    updatePolicy:
+                      default: Replace
+                      description: UpdatePolicy to handle Secrets in the provider.
+                      enum:
+                        - Replace
+                        - IfNotExists
+                      type: string
+                  required:
+                    - secretStoreRefs
+                    - selector
+                  type: object
+                refreshTime:
+                  description: The time in which the controller should reconcile its objects and recheck namespaces for labels.
+                  type: string
+              required:
+                - pushSecretSpec
+              type: object
+            status:
+              properties:
+                conditions:
+                  items:
+                    description: PushSecretStatusCondition indicates the status of the PushSecret.
+                    properties:
+                      lastTransitionTime:
+                        format: date-time
+                        type: string
+                      message:
+                        type: string
+                      reason:
+                        type: string
+                      status:
+                        type: string
+                      type:
+                        description: PushSecretConditionType indicates the condition of the PushSecret.
+                        type: string
+                    required:
+                      - status
+                      - type
+                    type: object
+                  type: array
+                failedNamespaces:
+                  description: Failed namespaces are the namespaces that failed to apply an PushSecret
+                  items:
+                    description: ClusterPushSecretNamespaceFailure represents a failed namespace deployment and it's reason.
+                    properties:
+                      namespace:
+                        description: Namespace is the namespace that failed when trying to apply an PushSecret
+                        type: string
+                      reason:
+                        description: Reason is why the PushSecret failed to apply to the namespace
+                        type: string
+                    required:
+                      - namespace
+                    type: object
+                  type: array
+                provisionedNamespaces:
+                  description: ProvisionedNamespaces are the namespaces where the ClusterPushSecret has secrets
+                  items:
+                    type: string
+                  type: array
+                pushSecretName:
+                  type: string
+              type: object
+          type: object
+      served: true
+      storage: true
+      subresources:
+        status: {}
+  conversion:
+    strategy: Webhook
+    webhook:
+      conversionReviewVersions:
+        - v1
+      clientConfig:
+        service:
+          name: kubernetes
+          namespace: default
+          path: /convert
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.17.2
+  labels:
+    external-secrets.io/component: controller
   name: clustersecretstores.external-secrets.io
 spec:
   group: external-secrets.io
@@ -7789,8 +8318,50 @@ spec:
                           minLength: 1
                           pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
                           type: string
-                      required:
-                        - name
+                        selector:
+                          description: Selector chooses secrets using a labelSelector.
+                          properties:
+                            matchExpressions:
+                              description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
+                              items:
+                                description: |-
+                                  A label selector requirement is a selector that contains values, a key, and an operator that
+                                  relates the key and values.
+                                properties:
+                                  key:
+                                    description: key is the label key that the selector applies to.
+                                    type: string
+                                  operator:
+                                    description: |-
+                                      operator represents a key's relationship to a set of values.
+                                      Valid operators are In, NotIn, Exists and DoesNotExist.
+                                    type: string
+                                  values:
+                                    description: |-
+                                      values is an array of string values. If the operator is In or NotIn,
+                                      the values array must be non-empty. If the operator is Exists or DoesNotExist,
+                                      the values array must be empty. This array is replaced during a strategic
+                                      merge patch.
+                                    items:
+                                      type: string
+                                    type: array
+                                    x-kubernetes-list-type: atomic
+                                required:
+                                  - key
+                                  - operator
+                                type: object
+                              type: array
+                              x-kubernetes-list-type: atomic
+                            matchLabels:
+                              additionalProperties:
+                                type: string
+                              description: |-
+                                matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
+                                map is equivalent to an element of matchExpressions, whose key field is "key", the
+                                operator is "In", and the values array contains only "value". The requirements are ANDed.
+                              type: object
+                          type: object
+                          x-kubernetes-map-type: atomic
                       type: object
                   type: object
                 template:

+ 12 - 0
docs/api/clusterpushsecret.md

@@ -0,0 +1,12 @@
+The `ClusterPushSecret` is a cluster scoped resource that can be used to manage `PushSecret` resources in specific namespaces.
+
+With `namespaceSelectors` you can select namespaces in which the PushSecret should be created.
+If there is a conflict with an existing resource the controller will error out.
+
+## Example
+
+Below is an example of the `ClusterPushSecret` in use.
+
+```yaml
+{% include 'full-cluster-push-secret.yaml' %}
+```

+ 4 - 2
docs/api/spec.md

@@ -1932,7 +1932,8 @@ Deprecated: Use NamespaceSelectors instead.</p>
 </td>
 <td>
 <em>(Optional)</em>
-<p>Choose namespaces by name. This field is ORed with anything that NamespaceSelectors ends up choosing.</p>
+<p>Choose namespaces by name. This field is ORed with anything that NamespaceSelectors ends up choosing.
+Deprecated: Use NamespaceSelectors instead.</p>
 </td>
 </tr>
 <tr>
@@ -2121,7 +2122,8 @@ Deprecated: Use NamespaceSelectors instead.</p>
 </td>
 <td>
 <em>(Optional)</em>
-<p>Choose namespaces by name. This field is ORed with anything that NamespaceSelectors ends up choosing.</p>
+<p>Choose namespaces by name. This field is ORed with anything that NamespaceSelectors ends up choosing.
+Deprecated: Use NamespaceSelectors instead.</p>
 </td>
 </tr>
 <tr>

+ 8 - 4
docs/guides/disable-cluster-features.md

@@ -1,21 +1,25 @@
-# Deploying without ClusterSecretStore and ClusterExternalSecret
+# Deploying without ClusterSecretStore and ClusterExternalSecret and ClusterPushSecret
 
-When deploying External Secrets Operator via Helm chart, the default configuration will install `ClusterSecretStore` and `ClusterExternalSecret` CRDs and these objects will be processed by the operator.
+When deploying External Secrets Operator via Helm chart, the default configuration will install `ClusterSecretStore` and `ClusterExternalSecret` and other CRDs and these objects will be processed by the operator.
 
 In order to disable both or one of these features, it is necessary to configure the `crds.*` Helm value, as well as the `process*` Helm value, as these 2 values are connected.
 
-If you would like to install the operator without `ClusterSecretStore` and `ClusterExternalSecret` management, you will have to :
+If you would like to install the operator without `ClusterSecretStore` and `ClusterExternalSecret` and `ClusterPushSecret` management, you will have to :
 
 * set `crds.createClusterExternalSecret` to false
 * set `crds.createClusterSecretStore` to false
+* set `crds.createClusterPushSecret` to false
 * set `processClusterExternalSecret` to false
 * set `processClusterStore` to false
+* set `processClusterPushSecret` to false
 
 Example:
 
 ```bash
 helm install external-secrets external-secrets/external-secrets --set crds.createClusterExternalSecret=false \
 --set crds.createClusterSecretStore=false \
+--set crds.createClusterPushSecret=false \
 --set processClusterExternalSecret=false \
---set processClusterStore=false
+--set processClusterStore=false \
+--set processClusterPushSecret=false
 ```

+ 81 - 0
docs/snippets/full-cluster-push-secret.yaml

@@ -0,0 +1,81 @@
+{% raw %}
+apiVersion: external-secrets.io/v1alpha1
+kind: ClusterPushSecret
+metadata:
+  name: "hello-world"
+spec:
+  # The name to be used on the PushSecrets
+  pushSecretName: "hello-world-ps"
+
+  # This is a list of basic label selector to select the namespaces to deploy PushSecrets to.
+  # you can read more about them here https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#resources-that-support-set-based-requirements
+  # The list is OR'd together, so if any of the namespaceSelectors match the namespace,
+  # the ExternalSecret will be deployed to that namespace.
+  namespaceSelectors:
+  - matchLabels:
+      cool: label
+
+  # How often the ClusterPushSecret should reconcile itself
+  # This will decide how often to check and make sure that the PushSecrets exist in the matching namespaces
+  refreshTime: "1m"
+
+  # This is the spec of the PushSecrets to be created
+  # The content of this was taken from our PushSecret example
+  pushSecretSpec:
+    updatePolicy: Replace # Policy to overwrite existing secrets in the provider on sync
+    deletionPolicy: Delete # the provider' secret will be deleted if the PushSecret is deleted
+    refreshInterval: 1h # Refresh interval for which push secret will reconcile
+    secretStoreRefs: # A list of secret stores to push secrets to
+      - name: aws-parameterstore
+        kind: SecretStore
+    selector:
+      secret:
+        name: pokedex-credentials # Source Kubernetes secret to be pushed
+      # Alternatively, you can point to a generator that produces values to be pushed
+      generatorRef:
+        apiVersion: external-secrets.io/v1alpha1
+        kind: ECRAuthorizationToken
+        name: prod-registry-credentials
+    template:
+      metadata:
+        annotations: { }
+        labels: { }
+      data:
+        best-pokemon: "{{ .best-pokemon | toString | upper }} is the really best!"
+      # Uses an existing template from configmap
+      # Secret is fetched, merged and templated within the referenced configMap data
+      # It does not update the configmap, it creates a secret with: data["alertmanager.yml"] = ...result...
+      templateFrom:
+        - configMap:
+            name: application-config-tmpl
+            items:
+              - key: config.yml
+    data:
+      - conversionStrategy: None # Also supports the ReverseUnicode strategy
+        match:
+          secretKey: best-pokemon # Source Kubernetes secret key to be pushed
+          remoteRef:
+            remoteKey: my-first-parameter # Remote reference (where the secret is going to be pushed)
+
+status:
+  # This will list any namespaces where the creation of the ExternalSecret failed
+  # This will not list any issues with the ExternalSecrets, you will have to check the
+  # ExternalSecrets to see any issues with them.
+  failedNamespaces:
+    - namespace: "matching-ns-1"
+      # This is one of the possible messages, and likely the most common
+      reason: "external secret already exists in namespace"
+
+  # You can find all matching and successfully deployed namespaces here
+  provisionedNamespaces:
+    - "matching-ns-3"
+    - "matching-ns-2"
+
+  # The condition can be Ready, PartiallyReady, or NotReady
+  # PartiallyReady would indicate an error in 1 or more namespaces
+  # NotReady would indicate errors in all namespaces meaning all ExternalSecrets resulted in errors
+  conditions:
+  - type: PartiallyReady
+    status: "True"
+    lastTransitionTime: "2022-01-12T12:33:02Z"
+{% endraw %}

+ 1 - 0
hack/api-docs/mkdocs.yml

@@ -65,6 +65,7 @@ nav:
       - SecretStore: api/secretstore.md
       - ClusterSecretStore: api/clustersecretstore.md
       - ClusterExternalSecret: api/clusterexternalsecret.md
+      - ClusterPushSecret: api/clusterpushsecret.md
       - PushSecret: api/pushsecret.md
     - Generators:
       - "api/generator/index.md"

+ 9 - 62
pkg/controllers/clusterexternalsecret/clusterexternalsecret_controller.go

@@ -18,7 +18,6 @@ import (
 	"context"
 	"errors"
 	"fmt"
-	"reflect"
 	"slices"
 	"sort"
 	"time"
@@ -35,14 +34,13 @@ import (
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/controller-runtime/pkg/controller"
 	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
-	"sigs.k8s.io/controller-runtime/pkg/event"
 	"sigs.k8s.io/controller-runtime/pkg/handler"
-	"sigs.k8s.io/controller-runtime/pkg/predicate"
 	"sigs.k8s.io/controller-runtime/pkg/reconcile"
 
 	esv1beta1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1"
 	"github.com/external-secrets/external-secrets/pkg/controllers/clusterexternalsecret/cesmetrics"
 	ctrlmetrics "github.com/external-secrets/external-secrets/pkg/controllers/metrics"
+	"github.com/external-secrets/external-secrets/pkg/utils"
 )
 
 // Reconciler reconciles a ClusterExternalSecret object.
@@ -112,7 +110,13 @@ func (r *Reconciler) reconcile(ctx context.Context, log logr.Logger, clusterExte
 	}
 	clusterExternalSecret.Status.ExternalSecretName = esName
 
-	namespaces, err := r.getTargetNamespaces(ctx, clusterExternalSecret)
+	selectors := []*metav1.LabelSelector{}
+	if s := clusterExternalSecret.Spec.NamespaceSelector; s != nil {
+		selectors = append(selectors, s)
+	}
+	selectors = append(selectors, clusterExternalSecret.Spec.NamespaceSelectors...)
+
+	namespaces, err := utils.GetTargetNamespaces(ctx, r.Client, clusterExternalSecret.Spec.Namespaces, selectors)
 	if err != nil {
 		log.Error(err, "failed to get target Namespaces")
 		failedNamespaces := map[string]error{
@@ -199,46 +203,6 @@ func (r *Reconciler) removeOldSecrets(ctx context.Context, log logr.Logger, clus
 	return nil
 }
 
-func (r *Reconciler) getTargetNamespaces(ctx context.Context, ces *esv1beta1.ClusterExternalSecret) ([]v1.Namespace, error) {
-	var selectors []*metav1.LabelSelector //nolint:prealloc // ces.Spec.NamespaceSelector might be empty.
-	if s := ces.Spec.NamespaceSelector; s != nil {
-		selectors = append(selectors, s)
-	}
-	for _, ns := range ces.Spec.Namespaces {
-		selectors = append(selectors, &metav1.LabelSelector{
-			MatchLabels: map[string]string{
-				"kubernetes.io/metadata.name": ns,
-			},
-		})
-	}
-	selectors = append(selectors, ces.Spec.NamespaceSelectors...)
-
-	var namespaces []v1.Namespace
-	namespaceSet := make(map[string]struct{})
-	for _, selector := range selectors {
-		labelSelector, err := metav1.LabelSelectorAsSelector(selector)
-		if err != nil {
-			return nil, fmt.Errorf("failed to convert label selector %s: %w", selector, err)
-		}
-
-		var nl v1.NamespaceList
-		err = r.List(ctx, &nl, &client.ListOptions{LabelSelector: labelSelector})
-		if err != nil {
-			return nil, fmt.Errorf("failed to list namespaces by label selector %s: %w", selector, err)
-		}
-
-		for _, n := range nl.Items {
-			if _, exist := namespaceSet[n.Name]; exist {
-				continue
-			}
-			namespaceSet[n.Name] = struct{}{}
-			namespaces = append(namespaces, n)
-		}
-	}
-
-	return namespaces, nil
-}
-
 func (r *Reconciler) createOrUpdateExternalSecret(ctx context.Context, clusterExternalSecret *esv1beta1.ClusterExternalSecret, namespace v1.Namespace, esName string, esMetadata esv1beta1.ExternalSecretMetadata) error {
 	externalSecret := &esv1beta1.ExternalSecret{
 		ObjectMeta: metav1.ObjectMeta{
@@ -357,7 +321,7 @@ func (r *Reconciler) SetupWithManager(mgr ctrl.Manager, opts controller.Options)
 		Watches(
 			&v1.Namespace{},
 			handler.EnqueueRequestsFromMapFunc(r.findObjectsForNamespace),
-			builder.WithPredicates(namespacePredicate()),
+			builder.WithPredicates(utils.NamespacePredicate()),
 		).
 		Complete(r)
 }
@@ -420,20 +384,3 @@ func (r *Reconciler) queueRequestsForItem(clusterExternalSecrets *esv1beta1.Clus
 
 	return requests
 }
-
-func namespacePredicate() predicate.Predicate {
-	return predicate.Funcs{
-		CreateFunc: func(e event.CreateEvent) bool {
-			return true
-		},
-		UpdateFunc: func(e event.UpdateEvent) bool {
-			if e.ObjectOld == nil || e.ObjectNew == nil {
-				return false
-			}
-			return !reflect.DeepEqual(e.ObjectOld.GetLabels(), e.ObjectNew.GetLabels())
-		},
-		DeleteFunc: func(deleteEvent event.DeleteEvent) bool {
-			return true
-		},
-	}
-}

+ 358 - 0
pkg/controllers/clusterpushsecret/clusterpushsecret_controller.go

@@ -0,0 +1,358 @@
+/*
+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 clusterpushsecret
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"sort"
+	"time"
+
+	"github.com/go-logr/logr"
+	v1 "k8s.io/api/core/v1"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/labels"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/client-go/tools/record"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/builder"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/controller"
+	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+	"sigs.k8s.io/controller-runtime/pkg/handler"
+	"sigs.k8s.io/controller-runtime/pkg/reconcile"
+
+	"github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
+	"github.com/external-secrets/external-secrets/pkg/controllers/clusterpushsecret/cpsmetrics"
+	"github.com/external-secrets/external-secrets/pkg/controllers/pushsecret"
+	"github.com/external-secrets/external-secrets/pkg/utils"
+)
+
+// Reconciler reconciles a ClusterPushSecret object.
+type Reconciler struct {
+	client.Client
+	Log             logr.Logger
+	Scheme          *runtime.Scheme
+	RequeueInterval time.Duration
+	Recorder        record.EventRecorder
+}
+
+const (
+	errPatchStatus          = "error merging"
+	errGetCES               = "could not get ClusterPushSecret"
+	errConvertLabelSelector = "unable to convert label selector"
+	errGetExistingPS        = "could not get existing PushSecret"
+	errNamespacesFailed     = "one or more namespaces failed"
+)
+
+func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
+	log := r.Log.WithValues("ClusterPushSecret", req.NamespacedName)
+
+	var cps v1alpha1.ClusterPushSecret
+	err := r.Get(ctx, req.NamespacedName, &cps)
+	if err != nil {
+		if apierrors.IsNotFound(err) {
+			cpsmetrics.RemoveMetrics(req.Namespace, req.Name)
+			return ctrl.Result{}, nil
+		}
+
+		log.Error(err, errGetCES)
+		return ctrl.Result{}, err
+	}
+
+	// skip reconciliation if deletion timestamp is set on cluster external secret
+	if cps.DeletionTimestamp != nil {
+		log.Info("skipping as it is in deletion")
+		return ctrl.Result{}, nil
+	}
+
+	p := client.MergeFrom(cps.DeepCopy())
+	defer r.deferPatch(ctx, log, &cps, p)
+
+	refreshInt := r.RequeueInterval
+	if cps.Spec.RefreshInterval != nil {
+		refreshInt = cps.Spec.RefreshInterval.Duration
+	}
+
+	esName := cps.Spec.PushSecretName
+	if esName == "" {
+		esName = cps.ObjectMeta.Name
+	}
+
+	if err := r.deleteOldPushSecrets(ctx, &cps, esName, log); err != nil {
+		return ctrl.Result{}, err
+	}
+
+	cps.Status.PushSecretName = esName
+
+	namespaces, err := utils.GetTargetNamespaces(ctx, r.Client, nil, cps.Spec.NamespaceSelectors)
+	if err != nil {
+		log.Error(err, "failed to get target Namespaces")
+		r.markAsFailed("failed to get target Namespaces", &cps)
+		return ctrl.Result{}, err
+	}
+
+	failedNamespaces := r.deleteOutdatedPushSecrets(ctx, namespaces, esName, cps.Name, cps.Status.ProvisionedNamespaces)
+	provisionedNamespaces := r.updateProvisionedNamespaces(ctx, namespaces, esName, log, failedNamespaces, &cps)
+
+	condition := NewClusterPushSecretCondition(failedNamespaces)
+	SetClusterPushSecretCondition(&cps, *condition)
+
+	cps.Status.FailedNamespaces = toNamespaceFailures(failedNamespaces)
+	sort.Strings(provisionedNamespaces)
+	cps.Status.ProvisionedNamespaces = provisionedNamespaces
+
+	return ctrl.Result{RequeueAfter: refreshInt}, nil
+}
+
+func (r *Reconciler) updateProvisionedNamespaces(
+	ctx context.Context,
+	namespaces []v1.Namespace,
+	esName string,
+	log logr.Logger,
+	failedNamespaces map[string]error,
+	cps *v1alpha1.ClusterPushSecret,
+) []string {
+	var provisionedNamespaces []string //nolint:prealloc // I have no idea what the size will be.
+	for _, namespace := range namespaces {
+		var pushSecret v1alpha1.PushSecret
+		err := r.Get(ctx, types.NamespacedName{
+			Name:      esName,
+			Namespace: namespace.Name,
+		}, &pushSecret)
+		if err != nil && !apierrors.IsNotFound(err) {
+			log.Error(err, errGetExistingPS)
+			failedNamespaces[namespace.Name] = err
+			continue
+		}
+
+		if err == nil && !isPushSecretOwnedBy(&pushSecret, cps.Name) {
+			failedNamespaces[namespace.Name] = errors.New("push secret already exists in namespace")
+			continue
+		}
+
+		if err := r.createOrUpdatePushSecret(ctx, cps, namespace, esName, cps.Spec.PushSecretMetadata); err != nil {
+			log.Error(err, "failed to create or update push secret")
+			failedNamespaces[namespace.Name] = err
+			continue
+		}
+
+		provisionedNamespaces = append(provisionedNamespaces, namespace.Name)
+	}
+
+	return provisionedNamespaces
+}
+
+func (r *Reconciler) deleteOldPushSecrets(ctx context.Context, cps *v1alpha1.ClusterPushSecret, esName string, log logr.Logger) error {
+	var lastErr error
+	if prevName := cps.Status.PushSecretName; prevName != esName {
+		// PushSecretName has changed, so remove the old ones
+		failedNamespaces := map[string]error{}
+		for _, ns := range cps.Status.ProvisionedNamespaces {
+			if err := r.deletePushSecret(ctx, prevName, cps.Name, ns); err != nil {
+				log.Error(err, "could not delete PushSecret")
+				failedNamespaces[ns] = err
+				lastErr = err
+			}
+		}
+
+		if len(failedNamespaces) > 0 {
+			r.markAsFailed("failed to delete push secret", cps)
+			cps.Status.FailedNamespaces = toNamespaceFailures(failedNamespaces)
+			return lastErr
+		}
+	}
+
+	return nil
+}
+
+func (r *Reconciler) markAsFailed(msg string, ps *v1alpha1.ClusterPushSecret) {
+	cond := pushsecret.NewPushSecretCondition(v1alpha1.PushSecretReady, v1.ConditionFalse, v1alpha1.ReasonErrored, msg)
+	setClusterPushSecretCondition(ps, *cond)
+	r.Recorder.Event(ps, v1.EventTypeWarning, v1alpha1.ReasonErrored, msg)
+}
+
+func setClusterPushSecretCondition(ps *v1alpha1.ClusterPushSecret, condition v1alpha1.PushSecretStatusCondition) {
+	currentCond := pushsecret.GetPushSecretCondition(ps.Status.Conditions, condition.Type)
+	if currentCond != nil && currentCond.Status == condition.Status &&
+		currentCond.Reason == condition.Reason && currentCond.Message == condition.Message {
+		return
+	}
+
+	// Do not update lastTransitionTime if the status of the condition doesn't change.
+	if currentCond != nil && currentCond.Status == condition.Status {
+		condition.LastTransitionTime = currentCond.LastTransitionTime
+	}
+
+	ps.Status.Conditions = append(pushsecret.FilterOutCondition(ps.Status.Conditions, condition.Type), condition)
+}
+
+func (r *Reconciler) createOrUpdatePushSecret(ctx context.Context, csp *v1alpha1.ClusterPushSecret, namespace v1.Namespace, esName string, esMetadata v1alpha1.PushSecretMetadata) error {
+	pushSecret := &v1alpha1.PushSecret{
+		ObjectMeta: metav1.ObjectMeta{
+			Namespace: namespace.Name,
+			Name:      esName,
+		},
+	}
+
+	mutateFunc := func() error {
+		pushSecret.Labels = esMetadata.Labels
+		pushSecret.Annotations = esMetadata.Annotations
+		pushSecret.Spec = csp.Spec.PushSecretSpec
+
+		if err := controllerutil.SetControllerReference(csp, pushSecret, r.Scheme); err != nil {
+			return fmt.Errorf("could not set the controller owner reference %w", err)
+		}
+
+		return nil
+	}
+
+	if _, err := ctrl.CreateOrUpdate(ctx, r.Client, pushSecret, mutateFunc); err != nil {
+		return fmt.Errorf("could not create or update push secret: %w", err)
+	}
+
+	return nil
+}
+
+func (r *Reconciler) deletePushSecret(ctx context.Context, esName, cesName, namespace string) error {
+	var existingPs v1alpha1.PushSecret
+	err := r.Get(ctx, types.NamespacedName{
+		Name:      esName,
+		Namespace: namespace,
+	}, &existingPs)
+	if err != nil {
+		// If we can't find it then just leave
+		if apierrors.IsNotFound(err) {
+			return nil
+		}
+		return err
+	}
+
+	if !isPushSecretOwnedBy(&existingPs, cesName) {
+		return nil
+	}
+
+	err = r.Delete(ctx, &existingPs, &client.DeleteOptions{})
+	if err != nil {
+		return fmt.Errorf("external secret in non matching namespace could not be deleted: %w", err)
+	}
+
+	return nil
+}
+
+func (r *Reconciler) deferPatch(ctx context.Context, log logr.Logger, cps *v1alpha1.ClusterPushSecret, p client.Patch) {
+	if err := r.Status().Patch(ctx, cps, p); err != nil {
+		log.Error(err, errPatchStatus)
+	}
+}
+
+func (r *Reconciler) deleteOutdatedPushSecrets(ctx context.Context, namespaces []v1.Namespace, esName, cesName string, provisionedNamespaces []string) map[string]error {
+	failedNamespaces := map[string]error{}
+	// Loop through existing namespaces first to make sure they still have our labels
+	for _, namespace := range getRemovedNamespaces(namespaces, provisionedNamespaces) {
+		err := r.deletePushSecret(ctx, esName, cesName, namespace)
+		if err != nil {
+			r.Log.Error(err, "unable to delete external secret")
+			failedNamespaces[namespace] = err
+		}
+	}
+
+	return failedNamespaces
+}
+
+func isPushSecretOwnedBy(ps *v1alpha1.PushSecret, cesName string) bool {
+	owner := metav1.GetControllerOf(ps)
+	return owner != nil && owner.APIVersion == v1alpha1.SchemeGroupVersion.String() && owner.Kind == "ClusterPushSecret" && owner.Name == cesName
+}
+
+func getRemovedNamespaces(currentNSs []v1.Namespace, provisionedNSs []string) []string {
+	currentNSSet := map[string]struct{}{}
+	for _, currentNs := range currentNSs {
+		currentNSSet[currentNs.Name] = struct{}{}
+	}
+
+	var removedNSs []string
+	for _, ns := range provisionedNSs {
+		if _, ok := currentNSSet[ns]; !ok {
+			removedNSs = append(removedNSs, ns)
+		}
+	}
+
+	return removedNSs
+}
+
+func toNamespaceFailures(failedNamespaces map[string]error) []v1alpha1.ClusterPushSecretNamespaceFailure {
+	namespaceFailures := make([]v1alpha1.ClusterPushSecretNamespaceFailure, len(failedNamespaces))
+
+	i := 0
+	for namespace, err := range failedNamespaces {
+		namespaceFailures[i] = v1alpha1.ClusterPushSecretNamespaceFailure{
+			Namespace: namespace,
+			Reason:    err.Error(),
+		}
+		i++
+	}
+	sort.Slice(namespaceFailures, func(i, j int) bool { return namespaceFailures[i].Namespace < namespaceFailures[j].Namespace })
+	return namespaceFailures
+}
+
+// SetupWithManager sets up the controller with the Manager.
+func (r *Reconciler) SetupWithManager(mgr ctrl.Manager, opts controller.Options) error {
+	return ctrl.NewControllerManagedBy(mgr).
+		WithOptions(opts).
+		For(&v1alpha1.ClusterPushSecret{}).
+		Owns(&v1alpha1.PushSecret{}).
+		Watches(
+			&v1.Namespace{},
+			handler.EnqueueRequestsFromMapFunc(r.findObjectsForNamespace),
+			builder.WithPredicates(utils.NamespacePredicate()),
+		).
+		Complete(r)
+}
+
+func (r *Reconciler) findObjectsForNamespace(ctx context.Context, namespace client.Object) []reconcile.Request {
+	var cpsl v1alpha1.ClusterPushSecretList
+	if err := r.List(ctx, &cpsl); err != nil {
+		r.Log.Error(err, errGetCES)
+		return []reconcile.Request{}
+	}
+
+	var requests []reconcile.Request
+	for i := range cpsl.Items {
+		cps := &cpsl.Items[i]
+		for _, selector := range cps.Spec.NamespaceSelectors {
+			labelSelector, err := metav1.LabelSelectorAsSelector(selector)
+			if err != nil {
+				r.Log.Error(err, errConvertLabelSelector)
+				continue
+			}
+
+			if labelSelector.Matches(labels.Set(namespace.GetLabels())) {
+				requests = append(requests, reconcile.Request{
+					NamespacedName: types.NamespacedName{
+						Name:      cps.GetName(),
+						Namespace: cps.GetNamespace(),
+					},
+				})
+				break
+			}
+		}
+	}
+
+	return requests
+}

+ 893 - 0
pkg/controllers/clusterpushsecret/clusterpushsecret_controller_test.go

@@ -0,0 +1,893 @@
+/*
+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 clusterpushsecret
+
+import (
+	"context"
+	"fmt"
+	"math/rand"
+	"sort"
+	"time"
+
+	v1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/types"
+	crclient "sigs.k8s.io/controller-runtime/pkg/client"
+
+	"github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
+	"github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1"
+	"github.com/external-secrets/external-secrets/pkg/controllers/clusterpushsecret/cpsmetrics"
+	ctrlmetrics "github.com/external-secrets/external-secrets/pkg/controllers/metrics"
+	"github.com/external-secrets/external-secrets/pkg/provider/testing/fake"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+)
+
+func init() {
+	ctrlmetrics.SetUpLabelNames(false)
+	cpsmetrics.SetUpMetrics()
+	fakeProvider = fake.New()
+	v1beta1.ForceRegister(fakeProvider, &v1beta1.SecretStoreProvider{
+		Fake: &v1beta1.FakeProvider{},
+	})
+}
+
+var (
+	secretName                = "test-secret"
+	testPushSecret            = "test-ps"
+	newPushSecret             = "new-ps-name"
+	defaultKey                = "default-key"
+	defaultVal                = "default-value"
+	testLabelKey              = "test-label-key"
+	testLabelValue            = "test-label-value"
+	testAnnotationKey         = "test-annotation-key"
+	testAnnotationValue       = "test-annotation-value"
+	updateStoreName           = "updated-test-store"
+	kubernetesMetadataLabel   = "kubernetes.io/metadata.name"
+	noneMatchingAnnotationKey = "no-longer-match-label-key"
+	noneMatchingAnnotationVal = "no-longer-match-annotation-value"
+	fakeProvider              *fake.Client
+	timeout                   = time.Second * 10
+	interval                  = time.Millisecond * 250
+)
+
+type clusterPushSecretTestCase struct {
+	namespaces                []v1.Namespace
+	clusterPushSecret         func(namespaces []v1.Namespace) v1alpha1.ClusterPushSecret
+	sourceSecret              func(namespaces []v1.Namespace) []v1.Secret
+	beforeCheck               func(ctx context.Context, namespaces []v1.Namespace, created v1alpha1.ClusterPushSecret)
+	expectedClusterPushSecret func(namespaces []v1.Namespace, created v1alpha1.ClusterPushSecret) v1alpha1.ClusterPushSecret
+	expectedPushSecrets       func(namespaces []v1.Namespace, created v1alpha1.ClusterPushSecret) []v1alpha1.PushSecret
+}
+
+var _ = Describe("ClusterPushSecret controller", func() {
+	defaultClusterPushSecret := func() *v1alpha1.ClusterPushSecret {
+		return &v1alpha1.ClusterPushSecret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name: fmt.Sprintf("test-pes-%s", randString(10)),
+			},
+			Spec: v1alpha1.ClusterPushSecretSpec{
+				PushSecretSpec: v1alpha1.PushSecretSpec{
+					RefreshInterval: &metav1.Duration{Duration: time.Hour},
+					SecretStoreRefs: []v1alpha1.PushSecretStoreRef{
+						{
+							Name: "test-store",
+							Kind: "SecretStore",
+						},
+					},
+					Selector: v1alpha1.PushSecretSelector{
+						Secret: &v1alpha1.PushSecretSecret{
+							Name: secretName,
+						},
+					},
+					Data: []v1alpha1.PushSecretData{
+						{
+							Match:    v1alpha1.PushSecretMatch{},
+							Metadata: nil,
+						},
+					},
+				},
+			},
+		}
+	}
+
+	defaultSourceSecret := func(namespaces []v1.Namespace) []v1.Secret {
+		var result []v1.Secret
+		for _, s := range namespaces {
+			result = append(result, v1.Secret{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      secretName,
+					Namespace: s.Name,
+				},
+				Data: map[string][]byte{
+					defaultKey: []byte(defaultVal),
+				},
+			})
+		}
+
+		return result
+	}
+
+	DescribeTable("When reconciling a ClusterPush Secret",
+		func(tc clusterPushSecretTestCase) {
+			ctx := context.Background()
+			By("creating namespaces")
+			var namespaces []v1.Namespace
+			for _, ns := range tc.namespaces {
+				err := k8sClient.Create(ctx, &ns)
+				Expect(err).ShouldNot(HaveOccurred())
+				namespaces = append(namespaces, ns)
+			}
+
+			for _, s := range tc.sourceSecret(namespaces) {
+				By("creating a source secret")
+				err := k8sClient.Create(ctx, &s)
+				Expect(err).ShouldNot(HaveOccurred())
+			}
+
+			By("creating a cluster push secret")
+			pes := tc.clusterPushSecret(tc.namespaces)
+			err := k8sClient.Create(ctx, &pes)
+			Expect(err).ShouldNot(HaveOccurred())
+
+			By("running before check")
+			if tc.beforeCheck != nil {
+				tc.beforeCheck(ctx, namespaces, pes)
+			}
+
+			// the before check above may have updated the namespaces, so refresh them
+			for i, ns := range namespaces {
+				err := k8sClient.Get(ctx, types.NamespacedName{Name: ns.Name}, &ns)
+				Expect(err).ShouldNot(HaveOccurred())
+				namespaces[i] = ns
+			}
+
+			By("checking the cluster push secret")
+			expectedCPS := tc.expectedClusterPushSecret(namespaces, pes)
+
+			Eventually(func(g Gomega) {
+				key := types.NamespacedName{Name: expectedCPS.Name}
+				var gotCes v1alpha1.ClusterPushSecret
+				err = k8sClient.Get(ctx, key, &gotCes)
+				g.Expect(err).ShouldNot(HaveOccurred())
+
+				g.Expect(gotCes.Labels).To(Equal(expectedCPS.Labels))
+				g.Expect(gotCes.Annotations).To(Equal(expectedCPS.Annotations))
+				g.Expect(gotCes.Spec).To(Equal(expectedCPS.Spec))
+				g.Expect(gotCes.Status).To(Equal(expectedCPS.Status))
+			}).WithTimeout(timeout).WithPolling(interval).Should(Succeed())
+
+			By("checking the push secrets")
+			expectedPSs := tc.expectedPushSecrets(namespaces, pes)
+
+			Eventually(func(g Gomega) {
+				var gotESs []v1alpha1.PushSecret
+				for _, ns := range namespaces {
+					var pushSecrets v1alpha1.PushSecretList
+					err := k8sClient.List(ctx, &pushSecrets, crclient.InNamespace(ns.Name))
+					g.Expect(err).ShouldNot(HaveOccurred())
+
+					gotESs = append(gotESs, pushSecrets.Items...)
+				}
+
+				g.Expect(len(gotESs)).Should(Equal(len(expectedPSs)))
+				for _, gotES := range gotESs {
+					found := false
+					for _, expectedPS := range expectedPSs {
+						if gotES.Namespace == expectedPS.Namespace && gotES.Name == expectedPS.Name {
+							found = true
+							g.Expect(gotES.Labels).To(Equal(expectedPS.Labels))
+							g.Expect(gotES.Annotations).To(Equal(expectedPS.Annotations))
+							g.Expect(gotES.Spec).To(Equal(expectedPS.Spec))
+						}
+					}
+					g.Expect(found).To(Equal(true))
+				}
+			}).WithTimeout(timeout).WithPolling(interval).Should(Succeed())
+		},
+
+		Entry("Should use cluster push secret name if push secret name isn't defined", clusterPushSecretTestCase{
+			namespaces: []v1.Namespace{
+				{ObjectMeta: metav1.ObjectMeta{Name: randomNamespaceName()}},
+			},
+			clusterPushSecret: func(namespaces []v1.Namespace) v1alpha1.ClusterPushSecret {
+				pes := defaultClusterPushSecret()
+				pes.Spec.NamespaceSelectors = []*metav1.LabelSelector{
+					{
+						MatchLabels: map[string]string{kubernetesMetadataLabel: namespaces[0].Name},
+					},
+				}
+				return *pes
+			},
+			sourceSecret: defaultSourceSecret,
+			expectedClusterPushSecret: func(namespaces []v1.Namespace, created v1alpha1.ClusterPushSecret) v1alpha1.ClusterPushSecret {
+				return v1alpha1.ClusterPushSecret{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: created.Name,
+					},
+					Spec: created.Spec,
+					Status: v1alpha1.ClusterPushSecretStatus{
+						PushSecretName:        created.Name,
+						ProvisionedNamespaces: []string{namespaces[0].Name},
+						Conditions: []v1alpha1.PushSecretStatusCondition{
+							{
+								Type:   v1alpha1.PushSecretReady,
+								Status: v1.ConditionTrue,
+							},
+						},
+					},
+				}
+			},
+			expectedPushSecrets: func(namespaces []v1.Namespace, created v1alpha1.ClusterPushSecret) []v1alpha1.PushSecret {
+				return []v1alpha1.PushSecret{
+					{
+						ObjectMeta: metav1.ObjectMeta{
+							Namespace: namespaces[0].Name,
+							Name:      created.Name,
+						},
+						Spec: created.Spec.PushSecretSpec,
+					},
+				}
+			},
+		}),
+		Entry("Should set push secret name and metadata if the fields are set", clusterPushSecretTestCase{
+			namespaces: []v1.Namespace{
+				{ObjectMeta: metav1.ObjectMeta{Name: randomNamespaceName()}},
+			},
+			clusterPushSecret: func(namespaces []v1.Namespace) v1alpha1.ClusterPushSecret {
+				pes := defaultClusterPushSecret()
+				pes.Spec.NamespaceSelectors = []*metav1.LabelSelector{
+					{
+						MatchLabels: map[string]string{kubernetesMetadataLabel: namespaces[0].Name},
+					},
+				}
+				pes.Spec.PushSecretName = testPushSecret
+				pes.Spec.PushSecretMetadata = v1alpha1.PushSecretMetadata{
+					Labels:      map[string]string{"test-label-key1": "test-label-value1", "test-label-key2": "test-label-value2"},
+					Annotations: map[string]string{"test-annotation-key1": "test-annotation-value1", "test-annotation-key2": "test-annotation-value2"},
+				}
+				return *pes
+			},
+			sourceSecret: defaultSourceSecret,
+			expectedClusterPushSecret: func(namespaces []v1.Namespace, created v1alpha1.ClusterPushSecret) v1alpha1.ClusterPushSecret {
+				return v1alpha1.ClusterPushSecret{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: created.Name,
+					},
+					Spec: created.Spec,
+					Status: v1alpha1.ClusterPushSecretStatus{
+						PushSecretName:        testPushSecret,
+						ProvisionedNamespaces: []string{namespaces[0].Name},
+						Conditions: []v1alpha1.PushSecretStatusCondition{
+							{
+								Type:   v1alpha1.PushSecretReady,
+								Status: v1.ConditionTrue,
+							},
+						},
+					},
+				}
+			},
+			expectedPushSecrets: func(namespaces []v1.Namespace, created v1alpha1.ClusterPushSecret) []v1alpha1.PushSecret {
+				return []v1alpha1.PushSecret{
+					{
+						ObjectMeta: metav1.ObjectMeta{
+							Namespace:   namespaces[0].Name,
+							Name:        testPushSecret,
+							Labels:      map[string]string{"test-label-key1": "test-label-value1", "test-label-key2": "test-label-value2"},
+							Annotations: map[string]string{"test-annotation-key1": "test-annotation-value1", "test-annotation-key2": "test-annotation-value2"},
+						},
+						Spec: created.Spec.PushSecretSpec,
+					},
+				}
+			},
+		}),
+		Entry("Should delete old push secrets if name has changed", clusterPushSecretTestCase{
+			namespaces: []v1.Namespace{
+				{ObjectMeta: metav1.ObjectMeta{Name: randomNamespaceName()}},
+			},
+			clusterPushSecret: func(namespaces []v1.Namespace) v1alpha1.ClusterPushSecret {
+				pes := defaultClusterPushSecret()
+				pes.Spec.NamespaceSelectors = []*metav1.LabelSelector{
+					{
+						MatchLabels: map[string]string{kubernetesMetadataLabel: namespaces[0].Name},
+					},
+				}
+				pes.Spec.PushSecretName = "old-es-name"
+				return *pes
+			},
+			sourceSecret: defaultSourceSecret,
+			beforeCheck: func(ctx context.Context, namespaces []v1.Namespace, created v1alpha1.ClusterPushSecret) {
+				// Wait until the push secret is provisioned
+				var es v1alpha1.PushSecret
+				Eventually(func(g Gomega) {
+					key := types.NamespacedName{Namespace: namespaces[0].Name, Name: "old-es-name"}
+					g.Expect(k8sClient.Get(ctx, key, &es)).ShouldNot(HaveOccurred())
+				}).WithTimeout(timeout).WithPolling(interval).Should(Succeed())
+
+				copied := created.DeepCopy()
+				copied.Spec.PushSecretName = newPushSecret
+				Expect(k8sClient.Patch(ctx, copied, crclient.MergeFrom(created.DeepCopy()))).ShouldNot(HaveOccurred())
+			},
+			expectedClusterPushSecret: func(namespaces []v1.Namespace, created v1alpha1.ClusterPushSecret) v1alpha1.ClusterPushSecret {
+				updatedSpec := created.Spec.DeepCopy()
+				updatedSpec.PushSecretName = newPushSecret
+
+				return v1alpha1.ClusterPushSecret{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: created.Name,
+					},
+					Spec: *updatedSpec,
+					Status: v1alpha1.ClusterPushSecretStatus{
+						PushSecretName:        newPushSecret,
+						ProvisionedNamespaces: []string{namespaces[0].Name},
+						Conditions: []v1alpha1.PushSecretStatusCondition{
+							{
+								Type:   v1alpha1.PushSecretReady,
+								Status: v1.ConditionTrue,
+							},
+						},
+					},
+				}
+			},
+			expectedPushSecrets: func(namespaces []v1.Namespace, created v1alpha1.ClusterPushSecret) []v1alpha1.PushSecret {
+				return []v1alpha1.PushSecret{
+					{
+						ObjectMeta: metav1.ObjectMeta{
+							Namespace: namespaces[0].Name,
+							Name:      newPushSecret,
+						},
+						Spec: created.Spec.PushSecretSpec,
+					},
+				}
+			},
+		}),
+		Entry("Should update push secret if the fields change", clusterPushSecretTestCase{
+			namespaces: []v1.Namespace{
+				{ObjectMeta: metav1.ObjectMeta{Name: randomNamespaceName()}},
+			},
+			clusterPushSecret: func(namespaces []v1.Namespace) v1alpha1.ClusterPushSecret {
+				pes := defaultClusterPushSecret()
+				pes.Spec.NamespaceSelectors = []*metav1.LabelSelector{
+					{
+						MatchLabels: map[string]string{kubernetesMetadataLabel: namespaces[0].Name},
+					},
+				}
+				return *pes
+			},
+			sourceSecret: defaultSourceSecret,
+			beforeCheck: func(ctx context.Context, namespaces []v1.Namespace, created v1alpha1.ClusterPushSecret) {
+				// Wait until the push secret is provisioned
+				var es v1alpha1.PushSecret
+				Eventually(func(g Gomega) {
+					key := types.NamespacedName{Namespace: namespaces[0].Name, Name: created.Name}
+					g.Expect(k8sClient.Get(ctx, key, &es)).ShouldNot(HaveOccurred())
+					g.Expect(len(es.Labels)).Should(Equal(0))
+					g.Expect(len(es.Annotations)).Should(Equal(0))
+					g.Expect(es.Spec).Should(Equal(created.Spec.PushSecretSpec))
+				}).WithTimeout(timeout).WithPolling(interval).Should(Succeed())
+
+				copied := created.DeepCopy()
+				copied.Spec.PushSecretMetadata = v1alpha1.PushSecretMetadata{
+					Labels:      map[string]string{testLabelKey: testLabelValue},
+					Annotations: map[string]string{testAnnotationKey: testAnnotationValue},
+				}
+				copied.Spec.PushSecretSpec.SecretStoreRefs = []v1alpha1.PushSecretStoreRef{
+					{
+						Name: updateStoreName,
+						Kind: "SecretStore",
+					},
+				}
+				Expect(k8sClient.Patch(ctx, copied, crclient.MergeFrom(created.DeepCopy()))).ShouldNot(HaveOccurred())
+			},
+			expectedClusterPushSecret: func(namespaces []v1.Namespace, created v1alpha1.ClusterPushSecret) v1alpha1.ClusterPushSecret {
+				updatedSpec := created.Spec.DeepCopy()
+				updatedSpec.PushSecretMetadata = v1alpha1.PushSecretMetadata{
+					Labels:      map[string]string{testLabelKey: testLabelValue},
+					Annotations: map[string]string{testAnnotationKey: testAnnotationValue},
+				}
+				updatedSpec.PushSecretSpec.SecretStoreRefs = []v1alpha1.PushSecretStoreRef{
+					{
+						Name: updateStoreName,
+						Kind: "SecretStore",
+					},
+				}
+
+				return v1alpha1.ClusterPushSecret{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: created.Name,
+					},
+					Spec: *updatedSpec,
+					Status: v1alpha1.ClusterPushSecretStatus{
+						PushSecretName:        created.Name,
+						ProvisionedNamespaces: []string{namespaces[0].Name},
+						Conditions: []v1alpha1.PushSecretStatusCondition{
+							{
+								Type:   v1alpha1.PushSecretReady,
+								Status: v1.ConditionTrue,
+							},
+						},
+					},
+				}
+			},
+			expectedPushSecrets: func(namespaces []v1.Namespace, created v1alpha1.ClusterPushSecret) []v1alpha1.PushSecret {
+				updatedSpec := created.Spec.PushSecretSpec.DeepCopy()
+				updatedSpec.SecretStoreRefs = []v1alpha1.PushSecretStoreRef{
+					{
+						Name: updateStoreName,
+						Kind: "SecretStore",
+					},
+				}
+
+				return []v1alpha1.PushSecret{
+					{
+						ObjectMeta: metav1.ObjectMeta{
+							Namespace:   namespaces[0].Name,
+							Name:        created.Name,
+							Labels:      map[string]string{testLabelKey: testLabelValue},
+							Annotations: map[string]string{testAnnotationKey: testAnnotationValue},
+						},
+						Spec: *updatedSpec,
+					},
+				}
+			},
+		}),
+		Entry("Should not overwrite existing push secrets and error out if one is present", clusterPushSecretTestCase{
+			namespaces: []v1.Namespace{
+				{ObjectMeta: metav1.ObjectMeta{Name: randomNamespaceName()}},
+			},
+			clusterPushSecret: func(namespaces []v1.Namespace) v1alpha1.ClusterPushSecret {
+				pes := defaultClusterPushSecret()
+				pes.Spec.NamespaceSelectors = []*metav1.LabelSelector{
+					{
+						MatchLabels: map[string]string{kubernetesMetadataLabel: namespaces[0].Name},
+					},
+				}
+
+				es := &v1alpha1.PushSecret{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      pes.Name,
+						Namespace: namespaces[0].Name,
+					},
+					Spec: v1alpha1.PushSecretSpec{
+						RefreshInterval: &metav1.Duration{Duration: time.Hour},
+						SecretStoreRefs: []v1alpha1.PushSecretStoreRef{
+							{
+								Name: updateStoreName,
+							},
+						},
+						Selector: v1alpha1.PushSecretSelector{
+							Secret: &v1alpha1.PushSecretSecret{
+								Name: secretName,
+							},
+						},
+					},
+				}
+				Expect(k8sClient.Create(context.Background(), es)).ShouldNot(HaveOccurred())
+
+				return *pes
+			},
+			sourceSecret: defaultSourceSecret,
+			expectedClusterPushSecret: func(namespaces []v1.Namespace, created v1alpha1.ClusterPushSecret) v1alpha1.ClusterPushSecret {
+				return v1alpha1.ClusterPushSecret{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: created.Name,
+					},
+					Spec: created.Spec,
+					Status: v1alpha1.ClusterPushSecretStatus{
+						PushSecretName: created.Name,
+						FailedNamespaces: []v1alpha1.ClusterPushSecretNamespaceFailure{
+							{
+								Namespace: namespaces[0].Name,
+								Reason:    "push secret already exists in namespace",
+							},
+						},
+						Conditions: []v1alpha1.PushSecretStatusCondition{
+							{
+								Type:    v1alpha1.PushSecretReady,
+								Status:  v1.ConditionFalse,
+								Message: "one or more namespaces failed",
+							},
+						},
+					},
+				}
+			},
+			expectedPushSecrets: func(namespaces []v1.Namespace, created v1alpha1.ClusterPushSecret) []v1alpha1.PushSecret {
+				return []v1alpha1.PushSecret{
+					{
+						ObjectMeta: metav1.ObjectMeta{
+							Namespace: namespaces[0].Name,
+							Name:      created.Name,
+						},
+						Spec: v1alpha1.PushSecretSpec{
+							RefreshInterval: &metav1.Duration{Duration: time.Hour},
+							SecretStoreRefs: []v1alpha1.PushSecretStoreRef{
+								{
+									Name: updateStoreName,
+									Kind: "SecretStore",
+								},
+							},
+							UpdatePolicy: "Replace",
+							Selector: v1alpha1.PushSecretSelector{
+								Secret: &v1alpha1.PushSecretSecret{
+									Name: secretName,
+								},
+							},
+							DeletionPolicy: "None",
+						},
+					},
+				}
+			},
+		}),
+		Entry("Should crate an push secret if one with the same name has been deleted", clusterPushSecretTestCase{
+			namespaces: []v1.Namespace{
+				{ObjectMeta: metav1.ObjectMeta{Name: randomNamespaceName()}},
+			},
+			clusterPushSecret: func(namespaces []v1.Namespace) v1alpha1.ClusterPushSecret {
+				pes := defaultClusterPushSecret()
+				pes.Spec.NamespaceSelectors = []*metav1.LabelSelector{
+					{
+						MatchLabels: map[string]string{kubernetesMetadataLabel: namespaces[0].Name},
+					},
+				}
+
+				es := &v1alpha1.PushSecret{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      pes.Name,
+						Namespace: namespaces[0].Name,
+					},
+					Spec: v1alpha1.PushSecretSpec{
+						RefreshInterval: &metav1.Duration{Duration: time.Hour},
+						SecretStoreRefs: []v1alpha1.PushSecretStoreRef{
+							{
+								Name: updateStoreName,
+							},
+						},
+						Selector: v1alpha1.PushSecretSelector{
+							Secret: &v1alpha1.PushSecretSecret{
+								Name: secretName,
+							},
+						},
+					},
+				}
+				Expect(k8sClient.Create(context.Background(), es)).ShouldNot(HaveOccurred())
+				return *pes
+			},
+			sourceSecret: defaultSourceSecret,
+			beforeCheck: func(ctx context.Context, namespaces []v1.Namespace, created v1alpha1.ClusterPushSecret) {
+				pes := v1alpha1.ClusterPushSecret{}
+				Eventually(func(g Gomega) {
+					key := types.NamespacedName{Namespace: created.Namespace, Name: created.Name}
+					g.Expect(k8sClient.Get(ctx, key, &pes)).ShouldNot(HaveOccurred())
+					g.Expect(len(pes.Status.FailedNamespaces)).Should(Equal(1))
+				}).WithTimeout(timeout).WithPolling(interval).Should(Succeed())
+
+				es := &v1alpha1.PushSecret{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      pes.Name,
+						Namespace: namespaces[0].Name,
+					},
+				}
+				Expect(k8sClient.Delete(ctx, es)).ShouldNot(HaveOccurred())
+			},
+			expectedClusterPushSecret: func(namespaces []v1.Namespace, created v1alpha1.ClusterPushSecret) v1alpha1.ClusterPushSecret {
+				return v1alpha1.ClusterPushSecret{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: created.Name,
+					},
+					Spec: created.Spec,
+					Status: v1alpha1.ClusterPushSecretStatus{
+						PushSecretName:        created.Name,
+						ProvisionedNamespaces: []string{namespaces[0].Name},
+						Conditions: []v1alpha1.PushSecretStatusCondition{
+							{
+								Type:   v1alpha1.PushSecretReady,
+								Status: v1.ConditionTrue,
+							},
+						},
+					},
+				}
+			},
+			expectedPushSecrets: func(namespaces []v1.Namespace, created v1alpha1.ClusterPushSecret) []v1alpha1.PushSecret {
+				return []v1alpha1.PushSecret{
+					{
+						ObjectMeta: metav1.ObjectMeta{
+							Namespace: namespaces[0].Name,
+							Name:      created.Name,
+						},
+						Spec: created.Spec.PushSecretSpec,
+					},
+				}
+			},
+		}),
+		Entry("Should delete push secrets when namespaces no longer match", clusterPushSecretTestCase{
+			namespaces: []v1.Namespace{
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:   randomNamespaceName(),
+						Labels: map[string]string{noneMatchingAnnotationKey: noneMatchingAnnotationVal},
+					},
+				},
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:   randomNamespaceName(),
+						Labels: map[string]string{noneMatchingAnnotationKey: noneMatchingAnnotationVal},
+					},
+				},
+			},
+			sourceSecret: defaultSourceSecret,
+			clusterPushSecret: func(namespaces []v1.Namespace) v1alpha1.ClusterPushSecret {
+				pes := defaultClusterPushSecret()
+				pes.Spec.RefreshInterval = &metav1.Duration{Duration: 100 * time.Millisecond}
+				pes.Spec.NamespaceSelectors = []*metav1.LabelSelector{
+					{
+						MatchLabels: map[string]string{noneMatchingAnnotationKey: noneMatchingAnnotationVal},
+					},
+				}
+				return *pes
+			},
+			beforeCheck: func(ctx context.Context, namespaces []v1.Namespace, created v1alpha1.ClusterPushSecret) {
+				// Wait until the target ESs have been created
+				Eventually(func(g Gomega) {
+					for _, ns := range namespaces {
+						key := types.NamespacedName{Namespace: ns.Name, Name: created.Name}
+						g.Expect(k8sClient.Get(ctx, key, &v1alpha1.PushSecret{})).ShouldNot(HaveOccurred())
+					}
+				}).WithTimeout(timeout).WithPolling(interval).Should(Succeed())
+
+				namespaces[0].Labels = map[string]string{}
+				Expect(k8sClient.Update(ctx, &namespaces[0])).ShouldNot(HaveOccurred())
+			},
+			expectedClusterPushSecret: func(namespaces []v1.Namespace, created v1alpha1.ClusterPushSecret) v1alpha1.ClusterPushSecret {
+				return v1alpha1.ClusterPushSecret{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: created.Name,
+					},
+					Spec: created.Spec,
+					Status: v1alpha1.ClusterPushSecretStatus{
+						PushSecretName:        created.Name,
+						ProvisionedNamespaces: []string{namespaces[1].Name},
+						Conditions: []v1alpha1.PushSecretStatusCondition{
+							{
+								Type:   v1alpha1.PushSecretReady,
+								Status: v1.ConditionTrue,
+							},
+						},
+					},
+				}
+			},
+			expectedPushSecrets: func(namespaces []v1.Namespace, created v1alpha1.ClusterPushSecret) []v1alpha1.PushSecret {
+				return []v1alpha1.PushSecret{
+					{
+						ObjectMeta: metav1.ObjectMeta{
+							Namespace: namespaces[1].Name,
+							Name:      created.Name,
+						},
+						Spec: created.Spec.PushSecretSpec,
+					},
+				}
+			},
+		}),
+		Entry("Should sync with match expression", clusterPushSecretTestCase{
+			namespaces: []v1.Namespace{
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:   randomNamespaceName(),
+						Labels: map[string]string{"prefix": "foo"},
+					},
+				},
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:   randomNamespaceName(),
+						Labels: map[string]string{"prefix": "bar"},
+					},
+				},
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:   randomNamespaceName(),
+						Labels: map[string]string{"prefix": "baz"},
+					},
+				},
+			},
+			sourceSecret: defaultSourceSecret,
+			clusterPushSecret: func(namespaces []v1.Namespace) v1alpha1.ClusterPushSecret {
+				pes := defaultClusterPushSecret()
+				pes.Spec.RefreshInterval = &metav1.Duration{Duration: 100 * time.Millisecond}
+				pes.Spec.NamespaceSelectors = []*metav1.LabelSelector{
+					{
+						MatchExpressions: []metav1.LabelSelectorRequirement{
+							{
+								Key:      "prefix",
+								Operator: metav1.LabelSelectorOpIn,
+								Values:   []string{"foo", "bar"}, // "baz" is excluded
+							},
+						},
+					},
+				}
+				return *pes
+			},
+			expectedClusterPushSecret: func(namespaces []v1.Namespace, created v1alpha1.ClusterPushSecret) v1alpha1.ClusterPushSecret {
+				provisionedNamespaces := []string{namespaces[0].Name, namespaces[1].Name}
+				sort.Strings(provisionedNamespaces)
+				return v1alpha1.ClusterPushSecret{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: created.Name,
+					},
+					Spec: created.Spec,
+					Status: v1alpha1.ClusterPushSecretStatus{
+						PushSecretName:        created.Name,
+						ProvisionedNamespaces: provisionedNamespaces,
+						Conditions: []v1alpha1.PushSecretStatusCondition{
+							{
+								Type:   v1alpha1.PushSecretReady,
+								Status: v1.ConditionTrue,
+							},
+						},
+					},
+				}
+			},
+			expectedPushSecrets: func(namespaces []v1.Namespace, created v1alpha1.ClusterPushSecret) []v1alpha1.PushSecret {
+				return []v1alpha1.PushSecret{
+					{
+						ObjectMeta: metav1.ObjectMeta{
+							Namespace: namespaces[0].Name,
+							Name:      created.Name,
+						},
+						Spec: created.Spec.PushSecretSpec,
+					},
+					{
+						ObjectMeta: metav1.ObjectMeta{
+							Namespace: namespaces[1].Name,
+							Name:      created.Name,
+						},
+						Spec: created.Spec.PushSecretSpec,
+					},
+				}
+			},
+		}),
+		Entry("Should be ready if no namespace matches", clusterPushSecretTestCase{
+			namespaces: []v1.Namespace{
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: randomNamespaceName(),
+					},
+				},
+			},
+			clusterPushSecret: func(namespaces []v1.Namespace) v1alpha1.ClusterPushSecret {
+				pes := defaultClusterPushSecret()
+				pes.Spec.NamespaceSelectors = []*metav1.LabelSelector{
+					{
+						MatchLabels: map[string]string{kubernetesMetadataLabel: "no-namespace-matches"},
+					},
+				}
+				return *pes
+			},
+			sourceSecret: defaultSourceSecret,
+			expectedClusterPushSecret: func(namespaces []v1.Namespace, created v1alpha1.ClusterPushSecret) v1alpha1.ClusterPushSecret {
+				return v1alpha1.ClusterPushSecret{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: created.Name,
+					},
+					Spec: created.Spec,
+					Status: v1alpha1.ClusterPushSecretStatus{
+						PushSecretName: created.Name,
+						Conditions: []v1alpha1.PushSecretStatusCondition{
+							{
+								Type:   v1alpha1.PushSecretReady,
+								Status: v1.ConditionTrue,
+							},
+						},
+					},
+				}
+			},
+			expectedPushSecrets: func(namespaces []v1.Namespace, created v1alpha1.ClusterPushSecret) []v1alpha1.PushSecret {
+				return []v1alpha1.PushSecret{}
+			},
+		}),
+		Entry("Should be ready if namespace is selected via the namespace selectors", clusterPushSecretTestCase{
+			namespaces: []v1.Namespace{
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "namespace1",
+						Labels: map[string]string{
+							"key": "value1",
+						},
+					},
+				},
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "namespace2",
+						Labels: map[string]string{
+							"key": "value2",
+						},
+					},
+				},
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "namespace3",
+						Labels: map[string]string{
+							"key": "value3",
+						},
+					},
+				},
+			},
+			clusterPushSecret: func(namespaces []v1.Namespace) v1alpha1.ClusterPushSecret {
+				pes := defaultClusterPushSecret()
+				pes.Spec.NamespaceSelectors = []*metav1.LabelSelector{
+					{
+						MatchLabels: map[string]string{"key": "value1"},
+					},
+					{
+						MatchLabels: map[string]string{"key": "value2"},
+					},
+				}
+				return *pes
+			},
+			sourceSecret: defaultSourceSecret,
+			expectedClusterPushSecret: func(namespaces []v1.Namespace, created v1alpha1.ClusterPushSecret) v1alpha1.ClusterPushSecret {
+				return v1alpha1.ClusterPushSecret{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: created.Name,
+					},
+					Spec: created.Spec,
+					Status: v1alpha1.ClusterPushSecretStatus{
+						PushSecretName: created.Name,
+						ProvisionedNamespaces: []string{
+							"namespace1",
+							"namespace2",
+						},
+						Conditions: []v1alpha1.PushSecretStatusCondition{
+							{
+								Type:   v1alpha1.PushSecretReady,
+								Status: v1.ConditionTrue,
+							},
+						},
+					},
+				}
+			},
+			expectedPushSecrets: func(namespaces []v1.Namespace, created v1alpha1.ClusterPushSecret) []v1alpha1.PushSecret {
+				return []v1alpha1.PushSecret{
+					{
+						ObjectMeta: metav1.ObjectMeta{
+							Namespace: "namespace1",
+							Name:      created.Name,
+						},
+						Spec: created.Spec.PushSecretSpec,
+					},
+					{
+						ObjectMeta: metav1.ObjectMeta{
+							Namespace: "namespace2",
+							Name:      created.Name,
+						},
+						Spec: created.Spec.PushSecretSpec,
+					},
+				}
+			},
+		}))
+})
+
+var letterRunes = []rune("abcdefghijklmnopqrstuvwxyz")
+
+func randString(n int) string {
+	b := make([]rune, n)
+	for i := range b {
+		b[i] = letterRunes[rand.Intn(len(letterRunes))]
+	}
+	return string(b)
+}
+
+func randomNamespaceName() string {
+	return fmt.Sprintf("testns-%s", randString(10))
+}

+ 102 - 0
pkg/controllers/clusterpushsecret/cpsmetrics/cpsmetrics.go

@@ -0,0 +1,102 @@
+/*
+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 cpsmetrics
+
+import (
+	"github.com/prometheus/client_golang/prometheus"
+	v1 "k8s.io/api/core/v1"
+	"sigs.k8s.io/controller-runtime/pkg/metrics"
+
+	"github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
+	ctrlmetrics "github.com/external-secrets/external-secrets/pkg/controllers/metrics"
+)
+
+const (
+	ClusterPushSecretSubsystem            = "clusterpushsecret"
+	ClusterPushSecretReconcileDurationKey = "reconcile_duration"
+	ClusterPushSecretStatusConditionKey   = "status_condition"
+)
+
+var gaugeVecMetrics = map[string]*prometheus.GaugeVec{}
+
+// SetUpMetrics is called at the root to set-up the metric logic using the
+// config flags provided.
+func SetUpMetrics() {
+	ClusterPushSecretReconcileDuration := prometheus.NewGaugeVec(prometheus.GaugeOpts{
+		Subsystem: ClusterPushSecretSubsystem,
+		Name:      ClusterPushSecretReconcileDurationKey,
+		Help:      "The duration time to reconcile the Cluster Push Secret",
+	}, ctrlmetrics.NonConditionMetricLabelNames)
+
+	ClusterPushSecretCondition := prometheus.NewGaugeVec(prometheus.GaugeOpts{
+		Subsystem: ClusterPushSecretSubsystem,
+		Name:      ClusterPushSecretStatusConditionKey,
+		Help:      "The status condition of a specific Cluster Push Secret",
+	}, ctrlmetrics.ConditionMetricLabelNames)
+
+	metrics.Registry.MustRegister(ClusterPushSecretReconcileDuration, ClusterPushSecretCondition)
+
+	gaugeVecMetrics = map[string]*prometheus.GaugeVec{
+		ClusterPushSecretStatusConditionKey:   ClusterPushSecretCondition,
+		ClusterPushSecretReconcileDurationKey: ClusterPushSecretReconcileDuration,
+	}
+}
+
+func GetGaugeVec(key string) *prometheus.GaugeVec {
+	return gaugeVecMetrics[key]
+}
+
+func UpdateClusterPushSecretCondition(ces *v1alpha1.ClusterPushSecret, condition *v1alpha1.PushSecretStatusCondition) {
+	if condition.Status != v1.ConditionTrue {
+		// This should not happen
+		return
+	}
+
+	cesInfo := make(map[string]string)
+	cesInfo["name"] = ces.Name
+	for k, v := range ces.Labels {
+		cesInfo[k] = v
+	}
+	conditionLabels := ctrlmetrics.RefineConditionMetricLabels(cesInfo)
+	ClusterPushSecretCondition := GetGaugeVec(ClusterPushSecretStatusConditionKey)
+
+	theOtherStatus := v1.ConditionFalse
+	if condition.Status == v1.ConditionFalse {
+		theOtherStatus = v1.ConditionTrue
+	}
+
+	ClusterPushSecretCondition.With(ctrlmetrics.RefineLabels(conditionLabels,
+		map[string]string{
+			"condition": string(condition.Type),
+			"status":    string(condition.Status),
+		})).Set(1)
+	ClusterPushSecretCondition.With(ctrlmetrics.RefineLabels(conditionLabels,
+		map[string]string{
+			"condition": string(condition.Type),
+			"status":    string(theOtherStatus),
+		})).Set(0)
+}
+
+// RemoveMetrics deletes all metrics published by the resource.
+func RemoveMetrics(namespace, name string) {
+	for _, gaugeVecMetric := range gaugeVecMetrics {
+		gaugeVecMetric.DeletePartialMatch(
+			map[string]string{
+				"namespace": namespace,
+				"name":      name,
+			},
+		)
+	}
+}

+ 120 - 0
pkg/controllers/clusterpushsecret/cpsmetrics/cpsmetrics_test.go

@@ -0,0 +1,120 @@
+/*
+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 cpsmetrics
+
+import (
+	"testing"
+
+	"github.com/prometheus/client_golang/prometheus"
+	"github.com/prometheus/client_golang/prometheus/testutil"
+	v1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	"github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
+	"github.com/external-secrets/external-secrets/pkg/controllers/metrics"
+)
+
+func TestUpdateClusterPushSecretCondition(t *testing.T) {
+	// Evacuate the original condition metric labels
+	tmpConditionMetricLabels := metrics.ConditionMetricLabels
+	defer func() {
+		metrics.ConditionMetricLabels = tmpConditionMetricLabels
+	}()
+	metrics.ConditionMetricLabels = map[string]string{"name": "", "namespace": "", "condition": "", "status": ""}
+
+	name := "test"
+
+	tests := []struct {
+		desc           string
+		condition      *v1alpha1.PushSecretStatusCondition
+		expectedCount  int
+		expectedValues []struct {
+			labels        prometheus.Labels
+			expectedValue float64
+		}
+	}{
+		{
+			desc: "ConditionTrue",
+			condition: &v1alpha1.PushSecretStatusCondition{
+				Type:   v1alpha1.PushSecretReady,
+				Status: v1.ConditionTrue,
+			},
+			expectedValues: []struct {
+				labels        prometheus.Labels
+				expectedValue float64
+			}{
+				{
+					labels: prometheus.Labels{
+						"namespace": "",
+						"name":      name,
+						"condition": "Ready",
+						"status":    "True",
+					},
+					expectedValue: 1.0,
+				},
+				{
+					labels: prometheus.Labels{
+						"namespace": "",
+						"name":      name,
+						"condition": "Ready",
+						"status":    "False",
+					},
+					expectedValue: 0.0,
+				},
+			},
+		},
+		{
+			desc: "ConditionFalse",
+			condition: &v1alpha1.PushSecretStatusCondition{
+				Type:   v1alpha1.PushSecretReady,
+				Status: v1.ConditionFalse,
+			},
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.desc, func(t *testing.T) {
+			ces := &v1alpha1.ClusterPushSecret{
+				ObjectMeta: metav1.ObjectMeta{
+					Name: name,
+				},
+			}
+
+			// Evacuate the original gauge vec
+			tmpGaugeVec := GetGaugeVec(ClusterPushSecretStatusConditionKey)
+			defer func() {
+				gaugeVecMetrics[ClusterPushSecretStatusConditionKey] = tmpGaugeVec
+			}()
+
+			gaugeVec := prometheus.NewGaugeVec(prometheus.GaugeOpts{
+				Subsystem: "psmetrics",
+				Name:      "TestUpdateClusterPushSecretCondition",
+			}, []string{"name", "namespace", "condition", "status"})
+
+			gaugeVecMetrics[ClusterPushSecretStatusConditionKey] = gaugeVec
+			UpdateClusterPushSecretCondition(ces, test.condition)
+
+			if got := testutil.CollectAndCount(gaugeVec); got != len(test.expectedValues) {
+				t.Fatalf("unexpected number of calls: got: %d, expected: %d", got, len(test.expectedValues))
+			}
+
+			for i, expected := range test.expectedValues {
+				if got := testutil.ToFloat64(gaugeVec.With(expected.labels)); got != expected.expectedValue {
+					t.Fatalf("#%d received unexpected gauge value: got: %v, expected: %v", i, got, expected.expectedValue)
+				}
+			}
+		})
+	}
+}

+ 115 - 0
pkg/controllers/clusterpushsecret/suite_test.go

@@ -0,0 +1,115 @@
+/*
+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 clusterpushsecret
+
+import (
+	"context"
+	"path/filepath"
+	"testing"
+	"time"
+
+	"go.uber.org/zap/zapcore"
+	"k8s.io/client-go/kubernetes/scheme"
+	"k8s.io/client-go/rest"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/controller"
+	"sigs.k8s.io/controller-runtime/pkg/envtest"
+	logf "sigs.k8s.io/controller-runtime/pkg/log"
+	"sigs.k8s.io/controller-runtime/pkg/log/zap"
+	"sigs.k8s.io/controller-runtime/pkg/metrics/server"
+
+	esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
+	esv1beta1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1"
+	genv1alpha1 "github.com/external-secrets/external-secrets/apis/generators/v1alpha1"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+)
+
+// These tests use Ginkgo (BDD-style Go testing framework). Refer to
+// http://onsi.github.io/ginkgo/ to learn more about Ginkgo.
+
+var cfg *rest.Config
+var k8sClient client.Client
+var testEnv *envtest.Environment
+var cancel context.CancelFunc
+
+func TestAPIs(t *testing.T) {
+	RegisterFailHandler(Fail)
+	RunSpecs(t, "Controller Suite")
+}
+
+var _ = BeforeSuite(func() {
+	log := zap.New(zap.WriteTo(GinkgoWriter), zap.Level(zapcore.DebugLevel))
+
+	logf.SetLogger(log)
+
+	By("bootstrapping test environment")
+	testEnv = &envtest.Environment{
+		CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "deploy", "crds")},
+	}
+
+	var ctx context.Context
+	ctx, cancel = context.WithCancel(context.Background())
+
+	var err error
+	cfg, err = testEnv.Start()
+	Expect(err).ToNot(HaveOccurred())
+	Expect(cfg).ToNot(BeNil())
+
+	err = esv1beta1.AddToScheme(scheme.Scheme)
+	Expect(err).NotTo(HaveOccurred())
+	err = esv1alpha1.AddToScheme(scheme.Scheme)
+	Expect(err).NotTo(HaveOccurred())
+	err = genv1alpha1.AddToScheme(scheme.Scheme)
+	Expect(err).NotTo(HaveOccurred())
+
+	k8sManager, err := ctrl.NewManager(cfg, ctrl.Options{
+		Scheme: scheme.Scheme,
+		Metrics: server.Options{
+			BindAddress: "0", // avoid port collision when testing
+		},
+	})
+	Expect(err).ToNot(HaveOccurred())
+
+	// do not use k8sManager.GetClient()
+	// see https://github.com/kubernetes-sigs/controller-runtime/issues/343#issuecomment-469435686
+	k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme})
+	Expect(k8sClient).ToNot(BeNil())
+	Expect(err).ToNot(HaveOccurred())
+
+	err = (&Reconciler{
+		Client:          k8sClient,
+		Scheme:          k8sManager.GetScheme(),
+		Log:             ctrl.Log.WithName("controllers").WithName("ClusterPushSecret"),
+		RequeueInterval: time.Second,
+	}).SetupWithManager(k8sManager, controller.Options{
+		MaxConcurrentReconciles: 1,
+	})
+	Expect(err).ToNot(HaveOccurred())
+
+	go func() {
+		defer GinkgoRecover()
+		Expect(k8sManager.Start(ctx)).ToNot(HaveOccurred())
+	}()
+})
+
+var _ = AfterSuite(func() {
+	By("tearing down the test environment")
+	cancel() // stop manager
+	err := testEnv.Stop()
+	Expect(err).ToNot(HaveOccurred())
+})

+ 56 - 0
pkg/controllers/clusterpushsecret/util.go

@@ -0,0 +1,56 @@
+/*
+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 clusterpushsecret
+
+import (
+	v1 "k8s.io/api/core/v1"
+
+	"github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
+	"github.com/external-secrets/external-secrets/pkg/controllers/clusterpushsecret/cpsmetrics"
+)
+
+func NewClusterPushSecretCondition(failedNamespaces map[string]error) *v1alpha1.PushSecretStatusCondition {
+	if len(failedNamespaces) == 0 {
+		return &v1alpha1.PushSecretStatusCondition{
+			Type:   v1alpha1.PushSecretReady,
+			Status: v1.ConditionTrue,
+		}
+	}
+
+	condition := &v1alpha1.PushSecretStatusCondition{
+		Type:    v1alpha1.PushSecretReady,
+		Status:  v1.ConditionFalse,
+		Message: errNamespacesFailed,
+	}
+
+	return condition
+}
+
+func SetClusterPushSecretCondition(ces *v1alpha1.ClusterPushSecret, condition v1alpha1.PushSecretStatusCondition) {
+	ces.Status.Conditions = append(filterOutCondition(ces.Status.Conditions, condition.Type), condition)
+	cpsmetrics.UpdateClusterPushSecretCondition(ces, &condition)
+}
+
+// filterOutCondition returns an empty set of conditions with the provided type.
+func filterOutCondition(conditions []v1alpha1.PushSecretStatusCondition, condType v1alpha1.PushSecretConditionType) []v1alpha1.PushSecretStatusCondition {
+	newConditions := make([]v1alpha1.PushSecretStatusCondition, 0, len(conditions))
+	for _, c := range conditions {
+		if c.Type == condType {
+			continue
+		}
+		newConditions = append(newConditions, c)
+	}
+	return newConditions
+}

+ 75 - 46
pkg/controllers/pushsecret/pushsecret_controller.go

@@ -161,7 +161,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
 		return ctrl.Result{RequeueAfter: refreshInt}, nil
 	}
 
-	secret, err := r.resolveSecret(ctx, &ps)
+	secrets, err := r.resolveSecrets(ctx, &ps)
 	if err != nil {
 		r.markAsFailed(errFailedGetSecret, &ps, nil)
 
@@ -174,10 +174,6 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
 		return ctrl.Result{}, err
 	}
 
-	if err := r.applyTemplate(ctx, &ps, secret); err != nil {
-		return ctrl.Result{}, err
-	}
-
 	secretStores, err = removeUnmanagedStores(ctx, req.Namespace, r, secretStores)
 	if err != nil {
 		r.markAsFailed(err.Error(), &ps, nil)
@@ -188,32 +184,41 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
 		return ctrl.Result{}, nil
 	}
 
-	syncedSecrets, err := r.PushSecretToProviders(ctx, secretStores, ps, secret, mgr)
-	if err != nil {
-		if errors.Is(err, locks.ErrConflict) {
-			log.Info("retry to acquire lock to update the secret later", "error", err)
-			return ctrl.Result{Requeue: true}, nil
+	allSyncedSecrets := make(esapi.SyncedPushSecretsMap)
+	for _, secret := range secrets {
+		if err := r.applyTemplate(ctx, &ps, &secret); err != nil {
+			return ctrl.Result{}, err
 		}
 
-		totalSecrets := mergeSecretState(syncedSecrets, ps.Status.SyncedPushSecrets)
-		msg := fmt.Sprintf(errFailedSetSecret, err)
-		r.markAsFailed(msg, &ps, totalSecrets)
-
-		return ctrl.Result{}, err
-	}
-	switch ps.Spec.DeletionPolicy {
-	case esapi.PushSecretDeletionPolicyDelete:
-		badSyncState, err := r.DeleteSecretFromProviders(ctx, &ps, syncedSecrets, mgr)
+		syncedSecrets, err := r.PushSecretToProviders(ctx, secretStores, ps, &secret, mgr)
 		if err != nil {
-			msg := fmt.Sprintf("Failed to Delete Secrets from Provider: %v", err)
-			r.markAsFailed(msg, &ps, badSyncState)
+			if errors.Is(err, locks.ErrConflict) {
+				log.Info("retry to acquire lock to update the secret later", "error", err)
+				return ctrl.Result{Requeue: true}, nil
+			}
+
+			totalSecrets := mergeSecretState(syncedSecrets, ps.Status.SyncedPushSecrets)
+			msg := fmt.Sprintf(errFailedSetSecret, err)
+			r.markAsFailed(msg, &ps, totalSecrets)
+
 			return ctrl.Result{}, err
 		}
-	case esapi.PushSecretDeletionPolicyNone:
-	default:
+		switch ps.Spec.DeletionPolicy {
+		case esapi.PushSecretDeletionPolicyDelete:
+			badSyncState, err := r.DeleteSecretFromProviders(ctx, &ps, syncedSecrets, mgr)
+			if err != nil {
+				msg := fmt.Sprintf("Failed to Delete Secrets from Provider: %v", err)
+				r.markAsFailed(msg, &ps, badSyncState)
+				return ctrl.Result{}, err
+			}
+		case esapi.PushSecretDeletionPolicyNone:
+		default:
+		}
+
+		allSyncedSecrets = mergeSecretState(allSyncedSecrets, syncedSecrets)
 	}
 
-	r.markAsDone(&ps, syncedSecrets, start)
+	r.markAsDone(&ps, allSyncedSecrets, start)
 
 	return ctrl.Result{RequeueAfter: refreshInt}, nil
 }
@@ -232,8 +237,8 @@ func shouldRefresh(ps esapi.PushSecret) bool {
 }
 
 func (r *Reconciler) markAsFailed(msg string, ps *esapi.PushSecret, syncState esapi.SyncedPushSecretsMap) {
-	cond := newPushSecretCondition(esapi.PushSecretReady, v1.ConditionFalse, esapi.ReasonErrored, msg)
-	setPushSecretCondition(ps, *cond)
+	cond := NewPushSecretCondition(esapi.PushSecretReady, v1.ConditionFalse, esapi.ReasonErrored, msg)
+	SetPushSecretCondition(ps, *cond)
 	if syncState != nil {
 		r.setSecrets(ps, syncState)
 	}
@@ -245,8 +250,8 @@ func (r *Reconciler) markAsDone(ps *esapi.PushSecret, secrets esapi.SyncedPushSe
 	if ps.Spec.UpdatePolicy == esapi.PushSecretUpdatePolicyIfNotExists {
 		msg += ". Existing secrets in providers unchanged."
 	}
-	cond := newPushSecretCondition(esapi.PushSecretReady, v1.ConditionTrue, esapi.ReasonSynced, msg)
-	setPushSecretCondition(ps, *cond)
+	cond := NewPushSecretCondition(esapi.PushSecretReady, v1.ConditionTrue, esapi.ReasonSynced, msg)
+	SetPushSecretCondition(ps, *cond)
 	r.setSecrets(ps, secrets)
 	ps.Status.RefreshTime = metav1.NewTime(start)
 	ps.Status.SyncedResourceVersion = util.GetResourceVersion(ps.ObjectMeta)
@@ -258,6 +263,10 @@ func (r *Reconciler) setSecrets(ps *esapi.PushSecret, status esapi.SyncedPushSec
 }
 
 func mergeSecretState(newMap, old esapi.SyncedPushSecretsMap) esapi.SyncedPushSecretsMap {
+	if newMap == nil {
+		return old
+	}
+
 	out := newMap.DeepCopy()
 	for k, v := range old {
 		_, ok := out[k]
@@ -377,7 +386,7 @@ func secretKeyExists(key string, secret *v1.Secret) bool {
 
 const defaultGeneratorStateKey = "__pushsecret"
 
-func (r *Reconciler) resolveSecret(ctx context.Context, ps *esapi.PushSecret) (*v1.Secret, error) {
+func (r *Reconciler) resolveSecrets(ctx context.Context, ps *esapi.PushSecret) ([]v1.Secret, error) {
 	var err error
 	generatorState := statemanager.New(ctx, r.Client, r.Scheme, ps.Namespace, ps)
 	defer func() {
@@ -392,19 +401,39 @@ func (r *Reconciler) resolveSecret(ctx context.Context, ps *esapi.PushSecret) (*
 			r.Log.Error(err, "error committing generator state")
 		}
 	}()
-	if ps.Spec.Selector.Secret != nil {
+
+	switch {
+	case ps.Spec.Selector.Secret != nil && ps.Spec.Selector.Secret.Name != "":
 		secretName := types.NamespacedName{Name: ps.Spec.Selector.Secret.Name, Namespace: ps.Namespace}
 		secret := &v1.Secret{}
-		err := r.Client.Get(ctx, secretName, secret)
-		if err != nil {
+		if err := r.Client.Get(ctx, secretName, secret); err != nil {
 			return nil, err
 		}
 		generatorState.EnqueueFlagLatestStateForGC(defaultGeneratorStateKey)
-		return secret, nil
-	}
-	if ps.Spec.Selector.GeneratorRef != nil {
-		return r.resolveSecretFromGenerator(ctx, ps.Namespace, ps.Spec.Selector.GeneratorRef, generatorState)
+
+		return []v1.Secret{*secret}, nil
+	case ps.Spec.Selector.GeneratorRef != nil:
+		secret, err := r.resolveSecretFromGenerator(ctx, ps.Namespace, ps.Spec.Selector.GeneratorRef, generatorState)
+		if err != nil {
+			return nil, fmt.Errorf("could not resolve secret from generator ref %v: %w", ps.Spec.Selector.GeneratorRef, err)
+		}
+
+		return []v1.Secret{*secret}, nil
+	case ps.Spec.Selector.Secret != nil && ps.Spec.Selector.Secret.Selector != nil:
+		labelSelector, err := metav1.LabelSelectorAsSelector(ps.Spec.Selector.Secret.Selector)
+		if err != nil {
+			return nil, err
+		}
+
+		var secretList v1.SecretList
+		err = r.List(ctx, &secretList, &client.ListOptions{LabelSelector: labelSelector})
+		if err != nil {
+			return nil, err
+		}
+
+		return secretList.Items, err
 	}
+
 	return nil, errors.New("no secret selector provided")
 }
 
@@ -503,7 +532,7 @@ func (r *Reconciler) getSecretStoreFromName(ctx context.Context, refStore esapi.
 	return &store, nil
 }
 
-func newPushSecretCondition(condType esapi.PushSecretConditionType, status v1.ConditionStatus, reason, message string) *esapi.PushSecretStatusCondition {
+func NewPushSecretCondition(condType esapi.PushSecretConditionType, status v1.ConditionStatus, reason, message string) *esapi.PushSecretStatusCondition {
 	return &esapi.PushSecretStatusCondition{
 		Type:               condType,
 		Status:             status,
@@ -513,8 +542,8 @@ func newPushSecretCondition(condType esapi.PushSecretConditionType, status v1.Co
 	}
 }
 
-func setPushSecretCondition(ps *esapi.PushSecret, condition esapi.PushSecretStatusCondition) {
-	currentCond := getPushSecretCondition(ps.Status, condition.Type)
+func SetPushSecretCondition(ps *esapi.PushSecret, condition esapi.PushSecretStatusCondition) {
+	currentCond := GetPushSecretCondition(ps.Status.Conditions, condition.Type)
 	if currentCond != nil && currentCond.Status == condition.Status &&
 		currentCond.Reason == condition.Reason && currentCond.Message == condition.Message {
 		psmetrics.UpdatePushSecretCondition(ps, &condition, 1.0)
@@ -526,7 +555,7 @@ func setPushSecretCondition(ps *esapi.PushSecret, condition esapi.PushSecretStat
 		condition.LastTransitionTime = currentCond.LastTransitionTime
 	}
 
-	ps.Status.Conditions = append(filterOutCondition(ps.Status.Conditions, condition.Type), condition)
+	ps.Status.Conditions = append(FilterOutCondition(ps.Status.Conditions, condition.Type), condition)
 
 	if currentCond != nil {
 		psmetrics.UpdatePushSecretCondition(ps, currentCond, 0.0)
@@ -535,8 +564,8 @@ func setPushSecretCondition(ps *esapi.PushSecret, condition esapi.PushSecretStat
 	psmetrics.UpdatePushSecretCondition(ps, &condition, 1.0)
 }
 
-// filterOutCondition returns an empty set of conditions with the provided type.
-func filterOutCondition(conditions []esapi.PushSecretStatusCondition, condType esapi.PushSecretConditionType) []esapi.PushSecretStatusCondition {
+// FilterOutCondition returns an empty set of conditions with the provided type.
+func FilterOutCondition(conditions []esapi.PushSecretStatusCondition, condType esapi.PushSecretConditionType) []esapi.PushSecretStatusCondition {
 	newConditions := make([]esapi.PushSecretStatusCondition, 0, len(conditions))
 	for _, c := range conditions {
 		if c.Type == condType {
@@ -547,10 +576,10 @@ func filterOutCondition(conditions []esapi.PushSecretStatusCondition, condType e
 	return newConditions
 }
 
-// getPushSecretCondition returns the condition with the provided type.
-func getPushSecretCondition(status esapi.PushSecretStatus, condType esapi.PushSecretConditionType) *esapi.PushSecretStatusCondition {
-	for i := range status.Conditions {
-		c := status.Conditions[i]
+// GetPushSecretCondition returns the condition with the provided type.
+func GetPushSecretCondition(conditions []esapi.PushSecretStatusCondition, condType esapi.PushSecretConditionType) *esapi.PushSecretStatusCondition {
+	for i := range conditions {
+		c := conditions[i]
 		if c.Type == condType {
 			return &c
 		}

+ 60 - 0
pkg/utils/utils.go

@@ -37,7 +37,10 @@ import (
 	"github.com/go-logr/logr"
 	corev1 "k8s.io/api/core/v1"
 	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/event"
+	"sigs.k8s.io/controller-runtime/pkg/predicate"
 
 	esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
 	esv1beta1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1"
@@ -608,6 +611,63 @@ func FetchCACertFromSource(ctx context.Context, opts CreateCertOpts) ([]byte, er
 	return nil, fmt.Errorf("unsupported CA provider type: %s", opts.CAProvider.Type)
 }
 
+// GetTargetNamespaces extracts namespaces based on selectors.
+func GetTargetNamespaces(ctx context.Context, cl client.Client, namespaceList []string, lbs []*metav1.LabelSelector) ([]corev1.Namespace, error) {
+	// make sure we don't alter the passed in slice.
+	selectors := make([]*metav1.LabelSelector, 0, len(namespaceList)+len(lbs))
+	for _, ns := range namespaceList {
+		selectors = append(selectors, &metav1.LabelSelector{
+			MatchLabels: map[string]string{
+				"kubernetes.io/metadata.name": ns,
+			},
+		})
+	}
+	selectors = append(selectors, lbs...)
+
+	var namespaces []corev1.Namespace
+	namespaceSet := make(map[string]struct{})
+	for _, selector := range selectors {
+		labelSelector, err := metav1.LabelSelectorAsSelector(selector)
+		if err != nil {
+			return nil, fmt.Errorf("failed to convert label selector %s: %w", selector, err)
+		}
+
+		var nl corev1.NamespaceList
+		err = cl.List(ctx, &nl, &client.ListOptions{LabelSelector: labelSelector})
+		if err != nil {
+			return nil, fmt.Errorf("failed to list namespaces by label selector %s: %w", selector, err)
+		}
+
+		for _, n := range nl.Items {
+			if _, exist := namespaceSet[n.Name]; exist {
+				continue
+			}
+			namespaceSet[n.Name] = struct{}{}
+			namespaces = append(namespaces, n)
+		}
+	}
+
+	return namespaces, nil
+}
+
+// NamespacePredicate can be used to watch for new or updated or deleted namespaces.
+func NamespacePredicate() predicate.Predicate {
+	return predicate.Funcs{
+		CreateFunc: func(e event.CreateEvent) bool {
+			return true
+		},
+		UpdateFunc: func(e event.UpdateEvent) bool {
+			if e.ObjectOld == nil || e.ObjectNew == nil {
+				return false
+			}
+			return !reflect.DeepEqual(e.ObjectOld.GetLabels(), e.ObjectNew.GetLabels())
+		},
+		DeleteFunc: func(deleteEvent event.DeleteEvent) bool {
+			return true
+		},
+	}
+}
+
 func base64decode(cert []byte) ([]byte, error) {
 	if c, err := parseCertificateBytes(cert); err == nil {
 		return c, nil

+ 5 - 0
tests/__snapshot__/clustergenerator-v1alpha1.yaml

@@ -82,6 +82,11 @@ spec:
       url: string
     grafanaSpec:
       auth:
+        basic:
+          password:
+            key: string
+            name: string
+          username: string
         token:
           key: string
           name: string

+ 5 - 0
tests/__snapshot__/grafana-v1alpha1.yaml

@@ -3,6 +3,11 @@ kind: Grafana
 metadata: {}
 spec:
   auth:
+    basic:
+      password:
+        key: string
+        name: string
+      username: string
     token:
       key: string
       name: string

+ 6 - 0
tests/__snapshot__/pushsecret-v1alpha1.yaml

@@ -28,6 +28,12 @@ spec:
       name: string
     secret:
       name: string
+      selector:
+        matchExpressions:
+        - key: string
+          operator: string
+          values: [] # minItems 0 of type string
+        matchLabels: {}
   template:
     data: {}
     engineVersion: "v2"