Parcourir la source

feat: introduce state for generator and new grafana SA generator

Signed-off-by: Moritz Johner <beller.moritz@googlemail.com>
Moritz Johner il y a 1 an
Parent
commit
a372c4e7cb
58 fichiers modifiés avec 2318 ajouts et 205 suppressions
  1. 1 1
      .github/workflows/helm.yml
  2. 11 0
      apis/externalsecrets/v1alpha1/pushsecret_types.go
  3. 1 0
      apis/externalsecrets/v1alpha1/zz_generated.deepcopy.go
  4. 62 1
      apis/externalsecrets/v1beta1/externalsecret_types.go
  5. 2 2
      apis/externalsecrets/v1beta1/externalsecret_webhook.go
  6. 100 0
      apis/externalsecrets/v1beta1/zz_generated.deepcopy.go
  7. 18 1
      apis/generators/v1alpha1/generator_interfaces.go
  8. 9 0
      apis/generators/v1alpha1/register.go
  9. 3 1
      apis/generators/v1alpha1/types_cluster.go
  10. 77 0
      apis/generators/v1alpha1/types_grafana.go
  11. 157 0
      apis/generators/v1alpha1/zz_generated.deepcopy.go
  12. 3 3
      cmd/root.go
  13. 2 0
      config/crds/bases/external-secrets.io_clusterexternalsecrets.yaml
  14. 66 0
      config/crds/bases/external-secrets.io_externalsecrets.yaml
  15. 65 0
      config/crds/bases/external-secrets.io_pushsecrets.yaml
  16. 58 0
      config/crds/bases/generators.external-secrets.io_clustergenerators.yaml
  17. 102 0
      config/crds/bases/generators.external-secrets.io_grafanas.yaml
  18. 1 0
      config/crds/bases/kustomization.yaml
  19. 3 0
      deploy/charts/external-secrets/templates/rbac.yaml
  20. 293 0
      deploy/crds/bundle.yaml
  21. 171 0
      design/011-generator-state.md
  22. 163 0
      docs/api/spec.md
  23. 15 4
      e2e/framework/addon/eso.go
  24. 2 6
      e2e/framework/addon/eso_argocd_application.go
  25. 5 1
      e2e/framework/addon/eso_flux_helm.go
  26. 9 12
      e2e/framework/addon/uninstall_eso_crds.go
  27. 9 0
      go.mod
  28. 18 0
      go.sum
  29. 51 24
      pkg/controllers/externalsecret/externalsecret_controller.go
  30. 38 17
      pkg/controllers/externalsecret/externalsecret_controller_secret.go
  31. 15 14
      pkg/controllers/externalsecret/externalsecret_controller_test.go
  32. 84 29
      pkg/controllers/pushsecret/pushsecret_controller.go
  33. 37 0
      pkg/controllers/util/util.go
  34. 15 11
      pkg/generator/acr/acr.go
  35. 1 1
      pkg/generator/acr/acr_test.go
  36. 14 10
      pkg/generator/ecr/ecr.go
  37. 1 1
      pkg/generator/ecr/ecr_test.go
  38. 8 4
      pkg/generator/fake/fake.go
  39. 1 1
      pkg/generator/fake/fake_test.go
  40. 90 0
      pkg/generator/gc/gc.go
  41. 11 7
      pkg/generator/gcr/gcr.go
  42. 1 1
      pkg/generator/gcr/gcr_test.go
  43. 14 10
      pkg/generator/github/github.go
  44. 1 1
      pkg/generator/github/github_test.go
  45. 189 0
      pkg/generator/grafana/grafana.go
  46. 10 6
      pkg/generator/password/password.go
  47. 1 1
      pkg/generator/password/password_test.go
  48. 1 0
      pkg/generator/register/register.go
  49. 250 0
      pkg/generator/statemanager/statemanager.go
  50. 12 8
      pkg/generator/sts/sts.go
  51. 1 1
      pkg/generator/sts/sts_test.go
  52. 8 4
      pkg/generator/uuid/uuid.go
  53. 1 1
      pkg/generator/uuid/uuid_test.go
  54. 19 15
      pkg/generator/vault/vault.go
  55. 1 1
      pkg/generator/vault/vault_test.go
  56. 9 4
      pkg/generator/webhook/webhook.go
  57. 1 1
      pkg/generator/webhook/webhook_test.go
  58. 7 0
      pkg/utils/resolvers/generator.go

+ 1 - 1
.github/workflows/helm.yml

@@ -36,7 +36,7 @@ jobs:
 
       - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
         with:
-          python-version: 3.7
+          python-version: 3.12
 
       - name: Set up chart-testing
         uses: helm/chart-testing-action@e6669bcd63d7cb57cb4380c33043eebe5d111992 # v2.6.1

+ 11 - 0
apis/externalsecrets/v1alpha1/pushsecret_types.go

@@ -212,6 +212,9 @@ type PushSecretStatus struct {
 	SyncedPushSecrets SyncedPushSecretsMap `json:"syncedPushSecrets,omitempty"`
 	// +optional
 	Conditions []PushSecretStatusCondition `json:"conditions,omitempty"`
+
+	// +optional
+	GeneratorState esv1beta1.GeneratorState `json:"generatorState,omitempty"`
 }
 
 // +kubebuilder:object:root=true
@@ -231,6 +234,14 @@ type PushSecret struct {
 	Status PushSecretStatus `json:"status,omitempty"`
 }
 
+func (ps *PushSecret) GetGeneratorState() *esv1beta1.GeneratorState {
+	return &ps.Status.GeneratorState
+}
+
+func (ps *PushSecret) SetGeneratorState(state esv1beta1.GeneratorState) {
+	ps.Status.GeneratorState = state
+}
+
 // +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`

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

@@ -1299,6 +1299,7 @@ func (in *PushSecretStatus) DeepCopyInto(out *PushSecretStatus) {
 			(*in)[i].DeepCopyInto(&(*out)[i])
 		}
 	}
+	in.GeneratorState.DeepCopyInto(&out.GeneratorState)
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PushSecretStatus.

+ 62 - 1
apis/externalsecrets/v1beta1/externalsecret_types.go

@@ -16,6 +16,7 @@ package v1beta1
 
 import (
 	corev1 "k8s.io/api/core/v1"
+	apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 )
 
@@ -424,7 +425,7 @@ type GeneratorRef struct {
 	APIVersion string `json:"apiVersion,omitempty"`
 
 	// Specify the Kind of the generator resource
-	// +kubebuilder:validation:Enum=ACRAccessToken;ClusterGenerator;ECRAuthorizationToken;Fake;GCRAccessToken;GithubAccessToken;Password;STSSessionToken;UUID;VaultDynamicSecret;Webhook
+	// +kubebuilder:validation:Enum=ACRAccessToken;ClusterGenerator;ECRAuthorizationToken;Fake;GCRAccessToken;GithubAccessToken;Password;STSSessionToken;UUID;VaultDynamicSecret;Webhook;Grafana
 	Kind string `json:"kind"`
 
 	// Specify the name of the generator resource
@@ -486,6 +487,58 @@ type ExternalSecretStatus struct {
 
 	// Binding represents a servicebinding.io Provisioned Service reference to the secret
 	Binding corev1.LocalObjectReference `json:"binding,omitempty"`
+
+	// +optional
+	GeneratorState GeneratorState `json:"generatorState,omitempty"`
+}
+
+// GeneratorState stores the state of generated resources,
+// though not all generators produce state.
+// It is used by ExternalSecret and PushSecret controller to
+// eventually garbage collect resources that were produced by a generator.
+type GeneratorState struct {
+	// latest contains the state of the most recent resources generated.
+	Latest map[string]*GeneratorResourceState `json:"latest,omitempty"`
+	// GC contains the state of resources that have been flagged for garbage collection.
+	// The resources are flagged for garbage collection when they are no longer
+	// referenced by the ExternalSecret/PushSecret resource or have been rotated.
+	// GC items may pile up if the garbage collection process fails.
+	GC map[string]*GeneratorGCState `json:"gc,omitempty"`
+}
+
+type GeneratorResourceState struct {
+	// Resource is the generator manifest that produced the state.
+	// It is a snapshot of the generator manifest at the time the state was produced.
+	// This manifest will be used to delete the resource. Any configuration that is referenced
+	// in the manifest should be available at the time of garbage collection. If that is not the case deletion will
+	// be blocked by a finalizer.
+	Resource *apiextensions.JSON `json:"resource"`
+	// State is the state that was produced by the generator implementation.
+	State *apiextensions.JSON `json:"state"`
+}
+
+// GeneratorGCState stores both the resource (the generator manifest) as well as the state
+// that was produced by the generator implementation.
+type GeneratorGCState struct {
+	// Resource is the generator manifest that produced the state.
+	// It is a snapshot of the generator manifest at the time the state was produced.
+	// This manifest will be used to delete the resource. Any configuration that is referenced
+	// in the manifest should be available at the time of garbage collection. If that is not the case deletion will
+	// be blocked by a finalizer.
+	Resource *apiextensions.JSON `json:"resource"`
+	// State is the state that was produced by the generator implementation.
+	State *apiextensions.JSON `json:"state"`
+	// FlaggedForGCTime is the time the resource was flagged for garbage collection.
+	FlaggedForGCTime metav1.Time `json:"flaggedForGCTime"`
+}
+
+// +kubebuilder:object:root=false
+// +kubebuilder:object:generate:false
+// +k8s:deepcopy-gen:interfaces=nil
+// +k8s:deepcopy-gen=nil
+type GeneratorStateManagingResource interface {
+	GetGeneratorState() *GeneratorState
+	SetGeneratorState(GeneratorState)
 }
 
 // +kubebuilder:object:root=true
@@ -506,6 +559,14 @@ type ExternalSecret struct {
 	Status ExternalSecretStatus `json:"status,omitempty"`
 }
 
+func (es *ExternalSecret) GetGeneratorState() *GeneratorState {
+	return &es.Status.GeneratorState
+}
+
+func (es *ExternalSecret) SetGeneratorState(state GeneratorState) {
+	es.Status.GeneratorState = state
+}
+
 const (
 	// AnnotationDataHash all secrets managed by an ExternalSecret have this annotation with the hash of their data.
 	AnnotationDataHash = "reconcile.external-secrets.io/data-hash"

+ 2 - 2
apis/externalsecrets/v1beta1/externalsecret_webhook.go

@@ -18,9 +18,9 @@ import (
 	ctrl "sigs.k8s.io/controller-runtime"
 )
 
-func (r *ExternalSecret) SetupWebhookWithManager(mgr ctrl.Manager) error {
+func (es *ExternalSecret) SetupWebhookWithManager(mgr ctrl.Manager) error {
 	return ctrl.NewWebhookManagedBy(mgr).
-		For(r).
+		For(es).
 		WithValidator(&ExternalSecretValidator{}).
 		Complete()
 }

+ 100 - 0
apis/externalsecrets/v1beta1/zz_generated.deepcopy.go

@@ -20,6 +20,7 @@ package v1beta1
 
 import (
 	metav1 "github.com/external-secrets/external-secrets/apis/meta/v1"
+	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
 	"k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/runtime"
 )
@@ -1421,6 +1422,7 @@ func (in *ExternalSecretStatus) DeepCopyInto(out *ExternalSecretStatus) {
 		}
 	}
 	out.Binding = in.Binding
+	in.GeneratorState.DeepCopyInto(&out.GeneratorState)
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalSecretStatus.
@@ -1715,6 +1717,32 @@ func (in *GCPWorkloadIdentity) DeepCopy() *GCPWorkloadIdentity {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *GeneratorGCState) DeepCopyInto(out *GeneratorGCState) {
+	*out = *in
+	if in.Resource != nil {
+		in, out := &in.Resource, &out.Resource
+		*out = new(apiextensionsv1.JSON)
+		(*in).DeepCopyInto(*out)
+	}
+	if in.State != nil {
+		in, out := &in.State, &out.State
+		*out = new(apiextensionsv1.JSON)
+		(*in).DeepCopyInto(*out)
+	}
+	in.FlaggedForGCTime.DeepCopyInto(&out.FlaggedForGCTime)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GeneratorGCState.
+func (in *GeneratorGCState) DeepCopy() *GeneratorGCState {
+	if in == nil {
+		return nil
+	}
+	out := new(GeneratorGCState)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *GeneratorRef) DeepCopyInto(out *GeneratorRef) {
 	*out = *in
@@ -1730,6 +1758,78 @@ func (in *GeneratorRef) DeepCopy() *GeneratorRef {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *GeneratorResourceState) DeepCopyInto(out *GeneratorResourceState) {
+	*out = *in
+	if in.Resource != nil {
+		in, out := &in.Resource, &out.Resource
+		*out = new(apiextensionsv1.JSON)
+		(*in).DeepCopyInto(*out)
+	}
+	if in.State != nil {
+		in, out := &in.State, &out.State
+		*out = new(apiextensionsv1.JSON)
+		(*in).DeepCopyInto(*out)
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GeneratorResourceState.
+func (in *GeneratorResourceState) DeepCopy() *GeneratorResourceState {
+	if in == nil {
+		return nil
+	}
+	out := new(GeneratorResourceState)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *GeneratorState) DeepCopyInto(out *GeneratorState) {
+	*out = *in
+	if in.Latest != nil {
+		in, out := &in.Latest, &out.Latest
+		*out = make(map[string]*GeneratorResourceState, len(*in))
+		for key, val := range *in {
+			var outVal *GeneratorResourceState
+			if val == nil {
+				(*out)[key] = nil
+			} else {
+				inVal := (*in)[key]
+				in, out := &inVal, &outVal
+				*out = new(GeneratorResourceState)
+				(*in).DeepCopyInto(*out)
+			}
+			(*out)[key] = outVal
+		}
+	}
+	if in.GC != nil {
+		in, out := &in.GC, &out.GC
+		*out = make(map[string]*GeneratorGCState, len(*in))
+		for key, val := range *in {
+			var outVal *GeneratorGCState
+			if val == nil {
+				(*out)[key] = nil
+			} else {
+				inVal := (*in)[key]
+				in, out := &inVal, &outVal
+				*out = new(GeneratorGCState)
+				(*in).DeepCopyInto(*out)
+			}
+			(*out)[key] = outVal
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GeneratorState.
+func (in *GeneratorState) DeepCopy() *GeneratorState {
+	if in == nil {
+		return nil
+	}
+	out := new(GeneratorState)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *GenericStoreValidator) DeepCopyInto(out *GenericStoreValidator) {
 	*out = *in

+ 18 - 1
apis/generators/v1alpha1/generator_interfaces.go

@@ -28,10 +28,27 @@ import (
 
 // Generator is the common interface for all generators that is actually used to generate whatever is needed.
 type Generator interface {
+	// Generate creates a new secret or set of secrets.
+	// The returned map is a mapping of secret names to their respective values.
+	// The status is an optional field that can be used to store any generator-specific
+	// state which can be used during the Cleanup phase.
 	Generate(
 		ctx context.Context,
 		obj *apiextensions.JSON,
 		kube client.Client,
 		namespace string,
-	) (map[string][]byte, error)
+	) (map[string][]byte, GeneratorProviderState, error)
+
+	// Cleanup deletes any resources created during the Generate phase.
+	// Cleanup is idempotent and should not return an error if the resources
+	// have already been deleted.
+	Cleanup(
+		ctx context.Context,
+		obj *apiextensions.JSON,
+		status GeneratorProviderState,
+		kube client.Client,
+		namespace string,
+	) error
 }
+
+type GeneratorProviderState *apiextensions.JSON

+ 9 - 0
apis/generators/v1alpha1/register.go

@@ -116,6 +116,14 @@ var (
 	UUIDGroupVersionKind = SchemeGroupVersion.WithKind(UUIDKind)
 )
 
+// Grafana type metadata.
+var (
+	GrafanaKind             = reflect.TypeOf(Grafana{}).Name()
+	GrafanaGroupKind        = schema.GroupKind{Group: Group, Kind: GrafanaKind}.String()
+	GrafanaKindAPIVersion   = GrafanaKind + "." + SchemeGroupVersion.String()
+	GrafanaGroupVersionKind = SchemeGroupVersion.WithKind(GrafanaKind)
+)
+
 // ClusterGenerator type metadata.
 var (
 	ClusterGeneratorKind             = reflect.TypeOf(ClusterGenerator{}).Name()
@@ -151,4 +159,5 @@ func init() {
 	SchemeBuilder.Register(&UUID{}, &UUIDList{})
 	SchemeBuilder.Register(&VaultDynamicSecret{}, &VaultDynamicSecretList{})
 	SchemeBuilder.Register(&Webhook{}, &WebhookList{})
+	SchemeBuilder.Register(&Grafana{}, &GrafanaList{})
 }

+ 3 - 1
apis/generators/v1alpha1/types_cluster.go

@@ -27,7 +27,7 @@ type ClusterGeneratorSpec struct {
 }
 
 // GeneratorKind represents a kind of generator.
-// +kubebuilder:validation:Enum=ACRAccessToken;ECRAuthorizationToken;Fake;GCRAccessToken;GithubAccessToken;Password;STSSessionToken;UUID;VaultDynamicSecret;Webhook
+// +kubebuilder:validation:Enum=ACRAccessToken;ECRAuthorizationToken;Fake;GCRAccessToken;GithubAccessToken;Password;STSSessionToken;UUID;VaultDynamicSecret;Webhook;Grafana
 type GeneratorKind string
 
 const (
@@ -41,6 +41,7 @@ const (
 	GeneratorKindUUID                  GeneratorKind = "UUID"
 	GeneratorKindVaultDynamicSecret    GeneratorKind = "VaultDynamicSecret"
 	GeneratorKindWebhook               GeneratorKind = "Webhook"
+	GeneratorKindGrafana               GeneratorKind = "Grafana"
 )
 
 // +kubebuilder:validation:MaxProperties=1
@@ -56,6 +57,7 @@ type GeneratorSpec struct {
 	UUIDSpec                  *UUIDSpec                  `json:"uuidSpec,omitempty"`
 	VaultDynamicSecretSpec    *VaultDynamicSecretSpec    `json:"vaultDynamicSecretSpec,omitempty"`
 	WebhookSpec               *WebhookSpec               `json:"webhookSpec,omitempty"`
+	GrafanaSpec               *GrafanaSpec               `json:"grafanaSpec,omitempty"`
 }
 
 // ClusterGenerator represents a cluster-wide generator which can be referenced as part of `generatorRef` fields.

+ 77 - 0
apis/generators/v1alpha1/types_grafana.go

@@ -0,0 +1,77 @@
+/*
+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 v1alpha1
+
+import (
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// GrafanaSpec controls the behavior of the external generator.
+type GrafanaSpec struct {
+	// URL is the URL of the Grafana instance.
+	URL string `json:"url"`
+	// Auth is the authentication configuration to authenticate
+	// against the Grafana instance.
+	Auth GrafanaAuth `json:"auth"`
+	// ServiceAccount is the configuration for the service account that
+	// is supposed to be generated by the generator.
+	ServiceAccount GrafanaServiceAccount `json:"serviceAccount"`
+}
+
+type GrafanaServiceAccount struct {
+	// Name is the name of the service account.
+	Name string `json:"name"`
+	// Role is the role of the service account.
+	// See here for the documentation on basic roles offered by Grafana:
+	// https://grafana.com/docs/grafana/latest/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/
+	Role string `json:"role"`
+}
+
+type GrafanaAuth struct {
+	// A service account token used to authenticate against the Grafana instance.
+	// Note: you need a token which has elevated permissions to create service accounts.
+	Token SecretKeySelector `json:"token"`
+}
+
+type GrafanaServiceAccountTokenState struct {
+	ServiceAccount GrafanaStateServiceAccount `json:"serviceAccount"`
+}
+
+type GrafanaStateServiceAccount struct {
+	ServiceAccountID      *int64  `json:"id"`
+	ServiceAccountLogin   *string `json:"login"`
+	ServiceAccountTokenID *int64  `json:"tokenID"`
+}
+
+// +kubebuilder:object:root=true
+// +kubebuilder:storageversion
+// +kubebuilder:subresource:status
+// +kubebuilder:metadata:labels="external-secrets.io/component=controller"
+// +kubebuilder:resource:scope=Namespaced,categories={external-secrets, external-secrets-generators}
+type Grafana struct {
+	metav1.TypeMeta   `json:",inline"`
+	metav1.ObjectMeta `json:"metadata,omitempty"`
+
+	Spec GrafanaSpec `json:"spec,omitempty"`
+}
+
+// +kubebuilder:object:root=true
+
+// ExternalList contains a list of Grafana Generator resources.
+type GrafanaList struct {
+	metav1.TypeMeta `json:",inline"`
+	metav1.ListMeta `json:"metadata,omitempty"`
+	Items           []Grafana `json:"items"`
+}

+ 157 - 0
apis/generators/v1alpha1/zz_generated.deepcopy.go

@@ -693,6 +693,11 @@ func (in *GeneratorSpec) DeepCopyInto(out *GeneratorSpec) {
 		*out = new(WebhookSpec)
 		(*in).DeepCopyInto(*out)
 	}
+	if in.GrafanaSpec != nil {
+		in, out := &in.GrafanaSpec, &out.GrafanaSpec
+		*out = new(GrafanaSpec)
+		**out = **in
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GeneratorSpec.
@@ -823,6 +828,158 @@ func (in *GithubSecretRef) DeepCopy() *GithubSecretRef {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *Grafana) DeepCopyInto(out *Grafana) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+	out.Spec = in.Spec
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Grafana.
+func (in *Grafana) DeepCopy() *Grafana {
+	if in == nil {
+		return nil
+	}
+	out := new(Grafana)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *Grafana) 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 *GrafanaAuth) DeepCopyInto(out *GrafanaAuth) {
+	*out = *in
+	out.Token = in.Token
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaAuth.
+func (in *GrafanaAuth) DeepCopy() *GrafanaAuth {
+	if in == nil {
+		return nil
+	}
+	out := new(GrafanaAuth)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *GrafanaList) DeepCopyInto(out *GrafanaList) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ListMeta.DeepCopyInto(&out.ListMeta)
+	if in.Items != nil {
+		in, out := &in.Items, &out.Items
+		*out = make([]Grafana, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaList.
+func (in *GrafanaList) DeepCopy() *GrafanaList {
+	if in == nil {
+		return nil
+	}
+	out := new(GrafanaList)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *GrafanaList) 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 *GrafanaServiceAccount) DeepCopyInto(out *GrafanaServiceAccount) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaServiceAccount.
+func (in *GrafanaServiceAccount) DeepCopy() *GrafanaServiceAccount {
+	if in == nil {
+		return nil
+	}
+	out := new(GrafanaServiceAccount)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *GrafanaServiceAccountTokenState) DeepCopyInto(out *GrafanaServiceAccountTokenState) {
+	*out = *in
+	in.ServiceAccount.DeepCopyInto(&out.ServiceAccount)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaServiceAccountTokenState.
+func (in *GrafanaServiceAccountTokenState) DeepCopy() *GrafanaServiceAccountTokenState {
+	if in == nil {
+		return nil
+	}
+	out := new(GrafanaServiceAccountTokenState)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *GrafanaSpec) DeepCopyInto(out *GrafanaSpec) {
+	*out = *in
+	out.Auth = in.Auth
+	out.ServiceAccount = in.ServiceAccount
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaSpec.
+func (in *GrafanaSpec) DeepCopy() *GrafanaSpec {
+	if in == nil {
+		return nil
+	}
+	out := new(GrafanaSpec)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *GrafanaStateServiceAccount) DeepCopyInto(out *GrafanaStateServiceAccount) {
+	*out = *in
+	if in.ServiceAccountID != nil {
+		in, out := &in.ServiceAccountID, &out.ServiceAccountID
+		*out = new(int64)
+		**out = **in
+	}
+	if in.ServiceAccountLogin != nil {
+		in, out := &in.ServiceAccountLogin, &out.ServiceAccountLogin
+		*out = new(string)
+		**out = **in
+	}
+	if in.ServiceAccountTokenID != nil {
+		in, out := &in.ServiceAccountTokenID, &out.ServiceAccountTokenID
+		*out = new(int64)
+		**out = **in
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaStateServiceAccount.
+func (in *GrafanaStateServiceAccount) DeepCopy() *GrafanaStateServiceAccount {
+	if in == nil {
+		return nil
+	}
+	out := new(GrafanaStateServiceAccount)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *Password) DeepCopyInto(out *Password) {
 	*out = *in

+ 3 - 3
cmd/root.go

@@ -148,7 +148,7 @@ var rootCmd = &cobra.Command{
 			clientCacheDisableFor = append(clientCacheDisableFor, &v1.ConfigMap{})
 		}
 
-		ctrlOpts := ctrl.Options{
+		mgrOpts := ctrl.Options{
 			Scheme: scheme,
 			Metrics: server.Options{
 				BindAddress: metricsAddr,
@@ -165,11 +165,11 @@ var rootCmd = &cobra.Command{
 			LeaderElectionID: "external-secrets-controller",
 		}
 		if namespace != "" {
-			ctrlOpts.Cache.DefaultNamespaces = map[string]cache.Config{
+			mgrOpts.Cache.DefaultNamespaces = map[string]cache.Config{
 				namespace: {},
 			}
 		}
-		mgr, err := ctrl.NewManager(config, ctrlOpts)
+		mgr, err := ctrl.NewManager(config, mgrOpts)
 		if err != nil {
 			setupLog.Error(err, "unable to start manager")
 			os.Exit(1)

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

@@ -170,6 +170,7 @@ spec:
                                   - UUID
                                   - VaultDynamicSecret
                                   - Webhook
+                                  - Grafana
                                   type: string
                                 name:
                                   description: Specify the name of the generator resource
@@ -365,6 +366,7 @@ spec:
                                   - UUID
                                   - VaultDynamicSecret
                                   - Webhook
+                                  - Grafana
                                   type: string
                                 name:
                                   description: Specify the name of the generator resource

+ 66 - 0
config/crds/bases/external-secrets.io_externalsecrets.yaml

@@ -460,6 +460,7 @@ spec:
                               - UUID
                               - VaultDynamicSecret
                               - Webhook
+                              - Grafana
                               type: string
                             name:
                               description: Specify the name of the generator resource
@@ -654,6 +655,7 @@ spec:
                               - UUID
                               - VaultDynamicSecret
                               - Webhook
+                              - Grafana
                               type: string
                             name:
                               description: Specify the name of the generator resource
@@ -913,6 +915,70 @@ spec:
                   - type
                   type: object
                 type: array
+              generatorState:
+                description: |-
+                  GeneratorState stores the state of generated resources,
+                  though not all generators produce state.
+                  It is used by ExternalSecret and PushSecret controller to
+                  eventually garbage collect resources that were produced by a generator.
+                properties:
+                  gc:
+                    additionalProperties:
+                      description: |-
+                        GeneratorGCState stores both the resource (the generator manifest) as well as the state
+                        that was produced by the generator implementation.
+                      properties:
+                        flaggedForGCTime:
+                          description: FlaggedForGCTime is the time the resource was
+                            flagged for garbage collection.
+                          format: date-time
+                          type: string
+                        resource:
+                          description: |-
+                            Resource is the generator manifest that produced the state.
+                            It is a snapshot of the generator manifest at the time the state was produced.
+                            This manifest will be used to delete the resource. Any configuration that is referenced
+                            in the manifest should be available at the time of garbage collection. If that is not the case deletion will
+                            be blocked by a finalizer.
+                          x-kubernetes-preserve-unknown-fields: true
+                        state:
+                          description: State is the state that was produced by the
+                            generator implementation.
+                          x-kubernetes-preserve-unknown-fields: true
+                      required:
+                      - flaggedForGCTime
+                      - resource
+                      - state
+                      type: object
+                    description: |-
+                      GC contains the state of resources that have been flagged for garbage collection.
+                      The resources are flagged for garbage collection when they are no longer
+                      referenced by the ExternalSecret/PushSecret resource or have been rotated.
+                      GC items may pile up if the garbage collection process fails.
+                    type: object
+                  latest:
+                    additionalProperties:
+                      properties:
+                        resource:
+                          description: |-
+                            Resource is the generator manifest that produced the state.
+                            It is a snapshot of the generator manifest at the time the state was produced.
+                            This manifest will be used to delete the resource. Any configuration that is referenced
+                            in the manifest should be available at the time of garbage collection. If that is not the case deletion will
+                            be blocked by a finalizer.
+                          x-kubernetes-preserve-unknown-fields: true
+                        state:
+                          description: State is the state that was produced by the
+                            generator implementation.
+                          x-kubernetes-preserve-unknown-fields: true
+                      required:
+                      - resource
+                      - state
+                      type: object
+                    description: latest contains the state of the most recent resources
+                      generated.
+                    type: object
+                type: object
               refreshTime:
                 description: |-
                   refreshTime is the time and date the external secret was fetched and

+ 65 - 0
config/crds/bases/external-secrets.io_pushsecrets.yaml

@@ -193,6 +193,7 @@ spec:
                         - UUID
                         - VaultDynamicSecret
                         - Webhook
+                        - Grafana
                         type: string
                       name:
                         description: Specify the name of the generator resource
@@ -375,6 +376,70 @@ spec:
                   - type
                   type: object
                 type: array
+              generatorState:
+                description: |-
+                  GeneratorState stores the state of generated resources,
+                  though not all generators produce state.
+                  It is used by ExternalSecret and PushSecret controller to
+                  eventually garbage collect resources that were produced by a generator.
+                properties:
+                  gc:
+                    additionalProperties:
+                      description: |-
+                        GeneratorGCState stores both the resource (the generator manifest) as well as the state
+                        that was produced by the generator implementation.
+                      properties:
+                        flaggedForGCTime:
+                          description: FlaggedForGCTime is the time the resource was
+                            flagged for garbage collection.
+                          format: date-time
+                          type: string
+                        resource:
+                          description: |-
+                            Resource is the generator manifest that produced the state.
+                            It is a snapshot of the generator manifest at the time the state was produced.
+                            This manifest will be used to delete the resource. Any configuration that is referenced
+                            in the manifest should be available at the time of garbage collection. If that is not the case deletion will
+                            be blocked by a finalizer.
+                          x-kubernetes-preserve-unknown-fields: true
+                        state:
+                          description: State is the state that was produced by the
+                            generator implementation.
+                          x-kubernetes-preserve-unknown-fields: true
+                      required:
+                      - flaggedForGCTime
+                      - resource
+                      - state
+                      type: object
+                    description: |-
+                      GC contains the state of resources that have been flagged for garbage collection.
+                      The resources are flagged for garbage collection when they are no longer
+                      referenced by the ExternalSecret/PushSecret resource or have been rotated.
+                      GC items may pile up if the garbage collection process fails.
+                    type: object
+                  latest:
+                    additionalProperties:
+                      properties:
+                        resource:
+                          description: |-
+                            Resource is the generator manifest that produced the state.
+                            It is a snapshot of the generator manifest at the time the state was produced.
+                            This manifest will be used to delete the resource. Any configuration that is referenced
+                            in the manifest should be available at the time of garbage collection. If that is not the case deletion will
+                            be blocked by a finalizer.
+                          x-kubernetes-preserve-unknown-fields: true
+                        state:
+                          description: State is the state that was produced by the
+                            generator implementation.
+                          x-kubernetes-preserve-unknown-fields: true
+                      required:
+                      - resource
+                      - state
+                      type: object
+                    description: latest contains the state of the most recent resources
+                      generated.
+                    type: object
+                type: object
               refreshTime:
                 description: |-
                   refreshTime is the time and date the external secret was fetched and

+ 58 - 0
config/crds/bases/generators.external-secrets.io_clustergenerators.yaml

@@ -523,6 +523,63 @@ spec:
                     - auth
                     - installID
                     type: object
+                  grafanaSpec:
+                    description: GrafanaSpec controls the behavior of the external
+                      generator.
+                    properties:
+                      auth:
+                        description: |-
+                          Auth is the authentication configuration to authenticate
+                          against the Grafana instance.
+                        properties:
+                          token:
+                            description: |-
+                              A service account token used to authenticate against the Grafana instance.
+                              Note: you need a token which has elevated permissions to create service accounts.
+                            properties:
+                              key:
+                                description: The key where the token is found.
+                                maxLength: 253
+                                minLength: 1
+                                pattern: ^[-._a-zA-Z0-9]+$
+                                type: string
+                              name:
+                                description: The name of the Secret resource being
+                                  referred to.
+                                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
+                        required:
+                        - token
+                        type: object
+                      serviceAccount:
+                        description: |-
+                          ServiceAccount is the configuration for the service account that
+                          is supposed to be generated by the generator.
+                        properties:
+                          name:
+                            description: Name is the name of the service account.
+                            type: string
+                          role:
+                            description: |-
+                              Role is the role of the service account.
+                              See here for the documentation on basic roles offered by Grafana:
+                              https://grafana.com/docs/grafana/latest/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/
+                            type: string
+                        required:
+                        - name
+                        - role
+                        type: object
+                      url:
+                        description: URL is the URL of the Grafana instance.
+                        type: string
+                    required:
+                    - auth
+                    - serviceAccount
+                    - url
+                    type: object
                   passwordSpec:
                     description: PasswordSpec controls the behavior of the password
                       generator.
@@ -1690,6 +1747,7 @@ spec:
                 - UUID
                 - VaultDynamicSecret
                 - Webhook
+                - Grafana
                 type: string
             required:
             - generator

+ 102 - 0
config/crds/bases/generators.external-secrets.io_grafanas.yaml

@@ -0,0 +1,102 @@
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.16.5
+  labels:
+    external-secrets.io/component: controller
+  name: grafanas.generators.external-secrets.io
+spec:
+  group: generators.external-secrets.io
+  names:
+    categories:
+    - external-secrets
+    - external-secrets-generators
+    kind: Grafana
+    listKind: GrafanaList
+    plural: grafanas
+    singular: grafana
+  scope: Namespaced
+  versions:
+  - 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:
+            description: GrafanaSpec controls the behavior of the external generator.
+            properties:
+              auth:
+                description: |-
+                  Auth is the authentication configuration to authenticate
+                  against the Grafana instance.
+                properties:
+                  token:
+                    description: |-
+                      A service account token used to authenticate against the Grafana instance.
+                      Note: you need a token which has elevated permissions to create service accounts.
+                    properties:
+                      key:
+                        description: The key where the token is found.
+                        maxLength: 253
+                        minLength: 1
+                        pattern: ^[-._a-zA-Z0-9]+$
+                        type: string
+                      name:
+                        description: The name of the Secret resource being referred
+                          to.
+                        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
+                required:
+                - token
+                type: object
+              serviceAccount:
+                description: |-
+                  ServiceAccount is the configuration for the service account that
+                  is supposed to be generated by the generator.
+                properties:
+                  name:
+                    description: Name is the name of the service account.
+                    type: string
+                  role:
+                    description: |-
+                      Role is the role of the service account.
+                      See here for the documentation on basic roles offered by Grafana:
+                      https://grafana.com/docs/grafana/latest/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/
+                    type: string
+                required:
+                - name
+                - role
+                type: object
+              url:
+                description: URL is the URL of the Grafana instance.
+                type: string
+            required:
+            - auth
+            - serviceAccount
+            - url
+            type: object
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}

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

@@ -13,6 +13,7 @@ resources:
   - generators.external-secrets.io_fakes.yaml
   - generators.external-secrets.io_gcraccesstokens.yaml
   - generators.external-secrets.io_githubaccesstokens.yaml
+  - generators.external-secrets.io_grafanas.yaml
   - generators.external-secrets.io_passwords.yaml
   - generators.external-secrets.io_stssessiontokens.yaml
   - generators.external-secrets.io_uuids.yaml

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

@@ -61,6 +61,7 @@ rules:
     - "uuids"
     - "vaultdynamicsecrets"
     - "webhooks"
+    - "grafanas"
     verbs:
     - "get"
     - "list"
@@ -156,6 +157,7 @@ rules:
     - "passwords"
     - "vaultdynamicsecrets"
     - "webhooks"
+    - "grafanas"
     verbs:
       - "get"
       - "watch"
@@ -202,6 +204,7 @@ rules:
     - "passwords"
     - "vaultdynamicsecrets"
     - "webhooks"
+    - "grafanas"
     verbs:
       - "create"
       - "delete"

+ 293 - 0
deploy/crds/bundle.yaml

@@ -160,6 +160,7 @@ spec:
                                       - UUID
                                       - VaultDynamicSecret
                                       - Webhook
+                                      - Grafana
                                     type: string
                                   name:
                                     description: Specify the name of the generator resource
@@ -346,6 +347,7 @@ spec:
                                       - UUID
                                       - VaultDynamicSecret
                                       - Webhook
+                                      - Grafana
                                     type: string
                                   name:
                                     description: Specify the name of the generator resource
@@ -6944,6 +6946,7 @@ spec:
                                   - UUID
                                   - VaultDynamicSecret
                                   - Webhook
+                                  - Grafana
                                 type: string
                               name:
                                 description: Specify the name of the generator resource
@@ -7130,6 +7133,7 @@ spec:
                                   - UUID
                                   - VaultDynamicSecret
                                   - Webhook
+                                  - Grafana
                                 type: string
                               name:
                                 description: Specify the name of the generator resource
@@ -7382,6 +7386,66 @@ spec:
                       - type
                     type: object
                   type: array
+                generatorState:
+                  description: |-
+                    GeneratorState stores the state of generated resources,
+                    though not all generators produce state.
+                    It is used by ExternalSecret and PushSecret controller to
+                    eventually garbage collect resources that were produced by a generator.
+                  properties:
+                    gc:
+                      additionalProperties:
+                        description: |-
+                          GeneratorGCState stores both the resource (the generator manifest) as well as the state
+                          that was produced by the generator implementation.
+                        properties:
+                          flaggedForGCTime:
+                            description: FlaggedForGCTime is the time the resource was flagged for garbage collection.
+                            format: date-time
+                            type: string
+                          resource:
+                            description: |-
+                              Resource is the generator manifest that produced the state.
+                              It is a snapshot of the generator manifest at the time the state was produced.
+                              This manifest will be used to delete the resource. Any configuration that is referenced
+                              in the manifest should be available at the time of garbage collection. If that is not the case deletion will
+                              be blocked by a finalizer.
+                            x-kubernetes-preserve-unknown-fields: true
+                          state:
+                            description: State is the state that was produced by the generator implementation.
+                            x-kubernetes-preserve-unknown-fields: true
+                        required:
+                          - flaggedForGCTime
+                          - resource
+                          - state
+                        type: object
+                      description: |-
+                        GC contains the state of resources that have been flagged for garbage collection.
+                        The resources are flagged for garbage collection when they are no longer
+                        referenced by the ExternalSecret/PushSecret resource or have been rotated.
+                        GC items may pile up if the garbage collection process fails.
+                      type: object
+                    latest:
+                      additionalProperties:
+                        properties:
+                          resource:
+                            description: |-
+                              Resource is the generator manifest that produced the state.
+                              It is a snapshot of the generator manifest at the time the state was produced.
+                              This manifest will be used to delete the resource. Any configuration that is referenced
+                              in the manifest should be available at the time of garbage collection. If that is not the case deletion will
+                              be blocked by a finalizer.
+                            x-kubernetes-preserve-unknown-fields: true
+                          state:
+                            description: State is the state that was produced by the generator implementation.
+                            x-kubernetes-preserve-unknown-fields: true
+                        required:
+                          - resource
+                          - state
+                        type: object
+                      description: latest contains the state of the most recent resources generated.
+                      type: object
+                  type: object
                 refreshTime:
                   description: |-
                     refreshTime is the time and date the external secret was fetched and
@@ -7598,6 +7662,7 @@ spec:
                             - UUID
                             - VaultDynamicSecret
                             - Webhook
+                            - Grafana
                           type: string
                         name:
                           description: Specify the name of the generator resource
@@ -7775,6 +7840,66 @@ spec:
                       - type
                     type: object
                   type: array
+                generatorState:
+                  description: |-
+                    GeneratorState stores the state of generated resources,
+                    though not all generators produce state.
+                    It is used by ExternalSecret and PushSecret controller to
+                    eventually garbage collect resources that were produced by a generator.
+                  properties:
+                    gc:
+                      additionalProperties:
+                        description: |-
+                          GeneratorGCState stores both the resource (the generator manifest) as well as the state
+                          that was produced by the generator implementation.
+                        properties:
+                          flaggedForGCTime:
+                            description: FlaggedForGCTime is the time the resource was flagged for garbage collection.
+                            format: date-time
+                            type: string
+                          resource:
+                            description: |-
+                              Resource is the generator manifest that produced the state.
+                              It is a snapshot of the generator manifest at the time the state was produced.
+                              This manifest will be used to delete the resource. Any configuration that is referenced
+                              in the manifest should be available at the time of garbage collection. If that is not the case deletion will
+                              be blocked by a finalizer.
+                            x-kubernetes-preserve-unknown-fields: true
+                          state:
+                            description: State is the state that was produced by the generator implementation.
+                            x-kubernetes-preserve-unknown-fields: true
+                        required:
+                          - flaggedForGCTime
+                          - resource
+                          - state
+                        type: object
+                      description: |-
+                        GC contains the state of resources that have been flagged for garbage collection.
+                        The resources are flagged for garbage collection when they are no longer
+                        referenced by the ExternalSecret/PushSecret resource or have been rotated.
+                        GC items may pile up if the garbage collection process fails.
+                      type: object
+                    latest:
+                      additionalProperties:
+                        properties:
+                          resource:
+                            description: |-
+                              Resource is the generator manifest that produced the state.
+                              It is a snapshot of the generator manifest at the time the state was produced.
+                              This manifest will be used to delete the resource. Any configuration that is referenced
+                              in the manifest should be available at the time of garbage collection. If that is not the case deletion will
+                              be blocked by a finalizer.
+                            x-kubernetes-preserve-unknown-fields: true
+                          state:
+                            description: State is the state that was produced by the generator implementation.
+                            x-kubernetes-preserve-unknown-fields: true
+                        required:
+                          - resource
+                          - state
+                        type: object
+                      description: latest contains the state of the most recent resources generated.
+                      type: object
+                  type: object
                 refreshTime:
                   description: |-
                     refreshTime is the time and date the external secret was fetched and
@@ -14337,6 +14462,61 @@ spec:
                         - auth
                         - installID
                       type: object
+                    grafanaSpec:
+                      description: GrafanaSpec controls the behavior of the external generator.
+                      properties:
+                        auth:
+                          description: |-
+                            Auth is the authentication configuration to authenticate
+                            against the Grafana instance.
+                          properties:
+                            token:
+                              description: |-
+                                A service account token used to authenticate against the Grafana instance.
+                                Note: you need a token which has elevated permissions to create service accounts.
+                              properties:
+                                key:
+                                  description: The key where the token is found.
+                                  maxLength: 253
+                                  minLength: 1
+                                  pattern: ^[-._a-zA-Z0-9]+$
+                                  type: string
+                                name:
+                                  description: The name of the Secret resource being referred to.
+                                  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
+                          required:
+                            - token
+                          type: object
+                        serviceAccount:
+                          description: |-
+                            ServiceAccount is the configuration for the service account that
+                            is supposed to be generated by the generator.
+                          properties:
+                            name:
+                              description: Name is the name of the service account.
+                              type: string
+                            role:
+                              description: |-
+                                Role is the role of the service account.
+                                See here for the documentation on basic roles offered by Grafana:
+                                https://grafana.com/docs/grafana/latest/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/
+                              type: string
+                          required:
+                            - name
+                            - role
+                          type: object
+                        url:
+                          description: URL is the URL of the Grafana instance.
+                          type: string
+                      required:
+                        - auth
+                        - serviceAccount
+                        - url
+                      type: object
                     passwordSpec:
                       description: PasswordSpec controls the behavior of the password generator.
                       properties:
@@ -15450,6 +15630,7 @@ spec:
                     - UUID
                     - VaultDynamicSecret
                     - Webhook
+                    - Grafana
                   type: string
               required:
                 - generator
@@ -16015,6 +16196,118 @@ spec:
 ---
 apiVersion: apiextensions.k8s.io/v1
 kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.16.5
+  labels:
+    external-secrets.io/component: controller
+  name: grafanas.generators.external-secrets.io
+spec:
+  group: generators.external-secrets.io
+  names:
+    categories:
+      - external-secrets
+      - external-secrets-generators
+    kind: Grafana
+    listKind: GrafanaList
+    plural: grafanas
+    singular: grafana
+  scope: Namespaced
+  versions:
+    - 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:
+              description: GrafanaSpec controls the behavior of the external generator.
+              properties:
+                auth:
+                  description: |-
+                    Auth is the authentication configuration to authenticate
+                    against the Grafana instance.
+                  properties:
+                    token:
+                      description: |-
+                        A service account token used to authenticate against the Grafana instance.
+                        Note: you need a token which has elevated permissions to create service accounts.
+                      properties:
+                        key:
+                          description: The key where the token is found.
+                          maxLength: 253
+                          minLength: 1
+                          pattern: ^[-._a-zA-Z0-9]+$
+                          type: string
+                        name:
+                          description: The name of the Secret resource being referred to.
+                          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
+                  required:
+                    - token
+                  type: object
+                serviceAccount:
+                  description: |-
+                    ServiceAccount is the configuration for the service account that
+                    is supposed to be generated by the generator.
+                  properties:
+                    name:
+                      description: Name is the name of the service account.
+                      type: string
+                    role:
+                      description: |-
+                        Role is the role of the service account.
+                        See here for the documentation on basic roles offered by Grafana:
+                        https://grafana.com/docs/grafana/latest/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/
+                      type: string
+                  required:
+                    - name
+                    - role
+                  type: object
+                url:
+                  description: URL is the URL of the Grafana instance.
+                  type: string
+              required:
+                - auth
+                - serviceAccount
+                - url
+              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.16.5

+ 171 - 0
design/011-generator-state.md

@@ -0,0 +1,171 @@
+```yaml
+---
+title: Generator State
+version: v1alpha1
+authors: Moritz Johner
+creation-date: 2024-10-05
+status: draft
+---
+```
+
+# Generator State
+
+## Problem Description
+
+Generators always have been stateless to avoid complexity. It has brought us a lot of limitations, like lack of support for generating more complex secrets for e.g. GCP Service Accounts, AWS IAM users, Grafana Cloud Service Accounts or Azure Service Principals. 
+Having the ability to store state created by a generator within ESO/Kubernetes would allow us to manage user or system accounts for databases systems, message brokers or managed service providers.
+
+This will not only help us to clean up secrets previously created by a generator, 
+it will also significantly help with the use-case of rotating secrets.
+
+
+## Proposed Solution
+
+Let's assume we want to implement a generator for Grafana Service Accounts. The workflow is as follows ([see docs](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#create-service-account)):
+
+1. Create a Service Account with a name and role.
+```
+POST /api/serviceaccounts
+{
+  "name": "test",
+  "role": "Editor",
+}
+
+--- response
+{
+	"id": 42,
+	"name": "test",
+	"login": "sa-test",
+	"role": "Editor",
+	// .. omitted for previty
+}
+```
+
+2. Create Token with name
+```
+POST /api/serviceaccounts/42/tokens
+{
+	"name": "eso-gen" # token name
+}
+--- response
+{
+	"id": 7,
+	"name": "eso-gen",
+	"key": "eyJrIjoXXXXXXXXX=="
+}
+
+```
+
+We should not create hundreds of thousands of tokens every time we want to rotate a secret. 
+Instead, we want to be a good citizen and delete old service account tokens after a reasonable amount of time.
+
+For the sake of this example we have to store the following things:
+
+1. Service Account ID (`42` from above)
+2. Service Account Token ID (`7` from above)
+3. everything from the generator spec (grafana URL, organization ID, service account name + role)
+
+This state is stored on the custom resource status field.
+Depending on the custom resource type `Kind=ExternalSecret` or `Kind=PushSecret` we have different schemas, because generators can be referenced only once (`Kind=PushSecret`) or multiple times (`Kind=ExternalSecret`).
+
+I propose to simply let the `Generate()` function to return the state and let the CR controller decide on how to store and handle that.
+
+In addition, we need a `Cleanup()` function which simply takes the state and everything else that is needed to cleanup the generated secret.
+
+```go
+type GeneratorState *apiextensions.JSON
+
+type Generator interface {
+	// Generate creates a new secret or set of secrets.
+	// The returned map is a mapping of secret names to their respective values.
+	// The status is an optional field that can be used to store any generator-specific
+	// state which can be used during the Cleanup phase.
+	Generate(
+		ctx context.Context,
+		generatorResource *apiextensions.JSON,
+		k8sClient client.Client,
+		namespace string,
+	) (map[string][]byte, GeneratorState, error)
+
+	// Cleanup deletes any resources created during the Generate phase.
+	// Cleanup is idempotent and should not return an error if the resources
+	// have already been deleted.
+	Cleanup(
+		ctx context.Context,
+		generatorResource *apiextensions.JSON,
+		status GeneratorState,
+		k8sClient client.Client,
+		namespace string,
+	) error
+}
+```
+
+As for the `Cleanup()` we need to take a close look at the `Kind=PushSecret` implementation. 
+We can notice that we need to deal with the following cases to properly clean up the generated secret:
+
+###### 1. The `Kind=PushSecret` resource re-generates a secret due to `spec.refreshInterval` or manual reconciliation
+
+When the secret is being rotated, then we should not immediately call `Cleanup()`, because it will take some time until the new secret is available to the consumer. If we, e.g. use the `kubernetes` provider to push a secret into a different namespace or cluster, then [it will take 60-90 seconds until it is propagated](https://ahmet.im/blog/kubernetes-secret-volumes-delay/) when consumed as a volumeMount. When used as a environment variable, then it even needs a pod restart (e.g. using [stakater/Reloader](https://github.com/stakater/Reloader)).
+
+With that being said, we should not immediately cleanup the secret, but instead flag it as *to-be-deleted* and wait a `grace period` before we finally delete it.
+
+
+###### 2. A user deletes the `Kind=PushSecret` resource
+
+In contrast to 👆, we should follow `PushSecret.spec.deletionPolicy` to either orphan the generated secret (`deletionPolicy=None`) or immediately delete it when using `deletionPolicy=Delete`. A finalizer blocks the deletion until the secret has been removed from the target store **and** the generated secret (if any) has been cleaned up. Since we delete it in the target store we should be fine to immediately delete it.
+
+
+###### 3. A user changes `spec.selector.generatorRef` to `spec.selector.secret` or vice versa
+
+In this case we follow the same procedure as described in `1.`: wait for grace period and then delete the secret. 
+
+When jumping from `selector.generatorRef` to `selector.secret` back and forth multiple times, **the generator implementation must ensure that the generated state is unique for every invocation**, otherwise we may run into a race condition and accidentally delete a newly generated secret if the timings align.
+In our grafana example above, the Service Account Token ID (`7` from above) is monotonically incrementing with every invocation, hence this is not a problem here.
+Alternatively, we can consider to embed a `UUID` for every `.Generate()` invocation.
+
+### Storing Generator State
+
+We'll store the state in the CR `status.generatorState` field.
+We need to store the full generator CR, because it contains the all the configuration
+needed to create/cleanup the secret. Because the generator spec can change at any time, 
+we can not rely on it to be available later on in the process.
+
+If someone decides to, e.g. `kubectl delete -f ./all-the-things.yaml`, then this will likely cause orphaned data.
+This will result in support issues and maintainer fatigue.
+
+Overview over the state management process:
+
+- when a secret is **generated initially**, the generator resource and the returned state will be stored in `status.generatorState.latest`. 
+- when a secret is **rotated**, the previous `status.generatorState.latest` will be moved over to `status.generatorState.gc` which is a map. The map key is a hash of the generator resource and generator state. In addition to the resource and the state, a  `flaggedForGCTime` field is added which contains the timestamp when the resource has been moved over to the `gc` field.
+Furthermore, the resource/state data is enqueued into a internal garbage collection process which will call `.Cleanup()` after a pre-defined grace period.
+- If the controller restarts between the time secret rotation time and the call to `.Cleanup()`, then we may miss cleanups. To work around that we need to run a garbage collection pass in the PushSecret controller's `.Reconcile()` function which iterates over `status.generatorState.gc` and verifies if the entry is old enough to be cleaned up.
+
+In the first iteration we can go with a global grace period, which can be configured by the user with a flag `--generator-gc-grace-period`. In the future we can consider adding it to the generator spec, or embedding it in the state returned by the generator.
+
+```yaml
+Kind: PushSecret
+spec: 
+  selector:
+    generatorRef:
+      apiVersion: generators.external-secrets.io/v1alpha1
+      kind: GrafanaServiceAccount
+      name: development-stack
+  # [...] omitted for brevity
+status:
+  # the generator state contains all the necessary information 
+  # to clean up previously generated secrets.
+  generatorState:
+    # latest contains the generator resource (yes, the fully resource spec)
+    # as well as the state returned from generator.
+    latest: 
+      resource: {}
+      state: {}
+    # GC is a map which contains all the previous invocations of
+    # a generator. It contains the same data resource/state and in addition to that a 
+    # time when this was flagged for GC.
+    gc:
+      8b222365498123...:
+        flaggedForGCTime: 2024-10-04T20:11:56Z
+        resource: {}
+        state: {}
+```

+ 163 - 0
docs/api/spec.md

@@ -3853,6 +3853,19 @@ Kubernetes core/v1.LocalObjectReference
 <p>Binding represents a servicebinding.io Provisioned Service reference to the secret</p>
 </td>
 </tr>
+<tr>
+<td>
+<code>generatorState</code></br>
+<em>
+<a href="#external-secrets.io/v1beta1.GeneratorState">
+GeneratorState
+</a>
+</em>
+</td>
+<td>
+<em>(Optional)</em>
+</td>
+</tr>
 </tbody>
 </table>
 <h3 id="external-secrets.io/v1beta1.ExternalSecretStatusCondition">ExternalSecretStatusCondition
@@ -4547,6 +4560,61 @@ string
 </tr>
 </tbody>
 </table>
+<h3 id="external-secrets.io/v1beta1.GeneratorGCState">GeneratorGCState
+</h3>
+<p>
+<p>GeneratorGCState stores both the resource (the generator manifest) as well as the state
+that was produced by the generator implementation.</p>
+</p>
+<table>
+<thead>
+<tr>
+<th>Field</th>
+<th>Description</th>
+</tr>
+</thead>
+<tbody>
+<tr>
+<td>
+<code>resource</code></br>
+<em>
+k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1.JSON
+</em>
+</td>
+<td>
+<p>Resource is the generator manifest that produced the state.
+It is a snapshot of the generator manifest at the time the state was produced.
+This manifest will be used to delete the resource. Any configuration that is referenced
+in the manifest should be available at the time of garbage collection. If that is not the case deletion will
+be blocked by a finalizer.</p>
+</td>
+</tr>
+<tr>
+<td>
+<code>state</code></br>
+<em>
+k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1.JSON
+</em>
+</td>
+<td>
+<p>State is the state that was produced by the generator implementation.</p>
+</td>
+</tr>
+<tr>
+<td>
+<code>flaggedForGCTime</code></br>
+<em>
+<a href="https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.25/#time-v1-meta">
+Kubernetes meta/v1.Time
+</a>
+</em>
+</td>
+<td>
+<p>FlaggedForGCTime is the time the resource was flagged for garbage collection.</p>
+</td>
+</tr>
+</tbody>
+</table>
 <h3 id="external-secrets.io/v1beta1.GeneratorRef">GeneratorRef
 </h3>
 <p>
@@ -4600,6 +4668,101 @@ string
 </tr>
 </tbody>
 </table>
+<h3 id="external-secrets.io/v1beta1.GeneratorResourceState">GeneratorResourceState
+</h3>
+<p>
+</p>
+<table>
+<thead>
+<tr>
+<th>Field</th>
+<th>Description</th>
+</tr>
+</thead>
+<tbody>
+<tr>
+<td>
+<code>resource</code></br>
+<em>
+k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1.JSON
+</em>
+</td>
+<td>
+<p>Resource is the generator manifest that produced the state.
+It is a snapshot of the generator manifest at the time the state was produced.
+This manifest will be used to delete the resource. Any configuration that is referenced
+in the manifest should be available at the time of garbage collection. If that is not the case deletion will
+be blocked by a finalizer.</p>
+</td>
+</tr>
+<tr>
+<td>
+<code>state</code></br>
+<em>
+k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1.JSON
+</em>
+</td>
+<td>
+<p>State is the state that was produced by the generator implementation.</p>
+</td>
+</tr>
+</tbody>
+</table>
+<h3 id="external-secrets.io/v1beta1.GeneratorState">GeneratorState
+</h3>
+<p>
+(<em>Appears on:</em>
+<a href="#external-secrets.io/v1beta1.ExternalSecretStatus">ExternalSecretStatus</a>)
+</p>
+<p>
+<p>GeneratorState stores the state of generated resources,
+though not all generators produce state.
+It is used by ExternalSecret and PushSecret controller to
+eventually garbage collect resources that were produced by a generator.</p>
+</p>
+<table>
+<thead>
+<tr>
+<th>Field</th>
+<th>Description</th>
+</tr>
+</thead>
+<tbody>
+<tr>
+<td>
+<code>latest</code></br>
+<em>
+<a href="#external-secrets.io/v1beta1.*github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1.GeneratorResourceState">
+map[string]*github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1.GeneratorResourceState
+</a>
+</em>
+</td>
+<td>
+<p>latest contains the state of the most recent resources generated.</p>
+</td>
+</tr>
+<tr>
+<td>
+<code>gc</code></br>
+<em>
+<a href="#external-secrets.io/v1beta1.*github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1.GeneratorGCState">
+map[string]*github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1.GeneratorGCState
+</a>
+</em>
+</td>
+<td>
+<p>GC contains the state of resources that have been flagged for garbage collection.
+The resources are flagged for garbage collection when they are no longer
+referenced by the ExternalSecret/PushSecret resource or have been rotated.
+GC items may pile up if the garbage collection process fails.</p>
+</td>
+</tr>
+</tbody>
+</table>
+<h3 id="external-secrets.io/v1beta1.GeneratorStateManagingResource">GeneratorStateManagingResource
+</h3>
+<p>
+</p>
 <h3 id="external-secrets.io/v1beta1.GenericStore">GenericStore
 </h3>
 <p>

+ 15 - 4
e2e/framework/addon/eso.go

@@ -16,6 +16,7 @@ package addon
 
 import (
 	"os"
+	"time"
 
 	// nolint
 	. "github.com/onsi/ginkgo/v2"
@@ -172,7 +173,7 @@ func WithCRDs() MutationFunc {
 }
 
 func (l *ESO) Install() error {
-	By("Installing eso\n")
+	By("Installing eso")
 	err := l.HelmChart.Install()
 	if err != nil {
 		return err
@@ -182,13 +183,23 @@ func (l *ESO) Install() error {
 }
 
 func (l *ESO) Uninstall() error {
+	// uninstalling CRDs will trigger the deletion of all CRs. They block the deletion of the CRDs if
+	// a finalizer is present.
+	// We must uninstall the CRDs before the eso chart,
+	// otherwise ESO will not remove the finalizer.
+	if l.HelmChart.HasVar(installCRDsVar, "true") {
+		By("Uninstalling eso CRDs")
+		err := uninstallCRDs(l.config)
+		if err != nil {
+			return err
+		}
+		// Give ESO a grace period to clean up the CRs
+		<-time.After(time.Minute)
+	}
 	By("Uninstalling eso")
 	err := l.HelmChart.Uninstall()
 	if err != nil {
 		return err
 	}
-	if l.HelmChart.HasVar(installCRDsVar, "true") {
-		return uninstallCRDs(l.config)
-	}
 	return nil
 }

+ 2 - 6
e2e/framework/addon/eso_argocd_application.go

@@ -158,15 +158,11 @@ func (c *ArgoCDApplication) Install() error {
 
 // Uninstall removes the chart aswell as the repo.
 func (c *ArgoCDApplication) Uninstall() error {
-	err := c.dc.Resource(argoApp).Namespace(c.Namespace).Delete(context.Background(), c.Name, metav1.DeleteOptions{})
+	err := uninstallCRDs(c.config)
 	if err != nil {
 		return err
 	}
-	err = uninstallCRDs(c.config)
-	if err != nil {
-		return err
-	}
-	return nil
+	return c.dc.Resource(argoApp).Namespace(c.Namespace).Delete(context.Background(), c.Name, metav1.DeleteOptions{})
 }
 
 func (c *ArgoCDApplication) Logs() error {

+ 5 - 1
e2e/framework/addon/eso_flux_helm.go

@@ -145,7 +145,11 @@ func (c *FluxHelmRelease) Install() error {
 
 // Uninstall removes the chart aswell as the repo.
 func (c *FluxHelmRelease) Uninstall() error {
-	err := c.config.CRClient.Delete(context.Background(), &fluxhelm.HelmRelease{
+	err := uninstallCRDs(c.config)
+	if err != nil {
+		return err
+	}
+	err = c.config.CRClient.Delete(context.Background(), &fluxhelm.HelmRelease{
 		ObjectMeta: metav1.ObjectMeta{
 			Name:      c.Name,
 			Namespace: c.Namespace,

+ 9 - 12
e2e/framework/addon/uninstall_eso_crds.go

@@ -20,24 +20,21 @@ import (
 	"github.com/onsi/ginkgo/v2"
 	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
-	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 )
 
 func uninstallCRDs(cfg *Config) error {
 	ginkgo.By("Uninstalling eso CRDs")
-	for _, crdName := range []string{
-		"clusterexternalsecrets.external-secrets.io",
-		"clustersecretstores.external-secrets.io",
-		"externalsecrets.external-secrets.io",
-		"secretstores.external-secrets.io",
-	} {
-		crd := &apiextensionsv1.CustomResourceDefinition{
-			ObjectMeta: metav1.ObjectMeta{
-				Name: crdName,
-			},
+	var crdList apiextensionsv1.CustomResourceDefinitionList
+	if err := cfg.CRClient.List(context.Background(), &crdList); err != nil {
+		return err
+	}
+
+	for _, crd := range crdList.Items {
+		if crd.Spec.Group != "external-secrets.io" {
+			continue
 		}
-		err := cfg.CRClient.Delete(context.Background(), crd, &client.DeleteOptions{})
+		err := cfg.CRClient.Delete(context.Background(), &crd, &client.DeleteOptions{})
 		if err != nil && !apierrors.IsNotFound(err) {
 			return err
 		}

+ 9 - 0
go.mod

@@ -80,8 +80,10 @@ require (
 	github.com/cenkalti/backoff/v4 v4.3.0
 	github.com/cyberark/conjur-api-go v0.12.7
 	github.com/fortanix/sdkms-client-go v0.4.0
+	github.com/go-co-op/gocron/v2 v2.12.1
 	github.com/go-openapi/strfmt v0.23.0
 	github.com/golang-jwt/jwt/v5 v5.2.1
+	github.com/grafana/grafana-openapi-client-go v0.0.0-20240826142251-d1c93bae4198
 	github.com/hashicorp/golang-lru v1.0.2
 	github.com/hashicorp/vault/api/auth/aws v0.8.0
 	github.com/hashicorp/vault/api/auth/userpass v0.8.0
@@ -127,6 +129,11 @@ require (
 	github.com/gabriel-vasile/mimetype v1.4.7 // indirect
 	github.com/go-jose/go-jose/v4 v4.0.4 // indirect
 	github.com/go-logr/stdr v1.2.2 // indirect
+	github.com/go-openapi/analysis v0.23.0 // indirect
+	github.com/go-openapi/loads v0.22.0 // indirect
+	github.com/go-openapi/runtime v0.28.0 // indirect
+	github.com/go-openapi/spec v0.21.0 // indirect
+	github.com/go-openapi/validate v0.24.0 // indirect
 	github.com/go-playground/validator/v10 v10.23.0 // indirect
 	github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
 	github.com/godbus/dbus/v5 v5.1.0 // indirect
@@ -135,9 +142,11 @@ require (
 	github.com/google/s2a-go v0.1.8 // indirect
 	github.com/hashicorp/go-secure-stdlib/awsutil v0.3.0 // indirect
 	github.com/hashicorp/go-uuid v1.0.3 // indirect
+	github.com/jonboulle/clockwork v0.4.0 // indirect
 	github.com/klauspost/compress v1.17.11 // indirect
 	github.com/lestrrat-go/httprc v1.0.6 // indirect
 	github.com/nxadm/tail v1.4.11 // indirect
+	github.com/robfig/cron/v3 v3.0.1 // indirect
 	github.com/segmentio/asm v1.2.0 // indirect
 	github.com/sirupsen/logrus v1.9.3 // indirect
 	github.com/tjfoc/gmsm v1.4.1 // indirect

+ 18 - 0
go.sum

@@ -277,6 +277,8 @@ github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
 github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
 github.com/go-chef/chef v0.30.1 h1:yvOSijEBWAQtRbBPj9hz1atEJUU6HckPc7AaEyZXnLg=
 github.com/go-chef/chef v0.30.1/go.mod h1:7RU1oCrRErTrkmIszkhJ9vHw7Bv2hZ1Vv1C1qKj01fc=
+github.com/go-co-op/gocron/v2 v2.12.1 h1:dCIIBFbzhWKdgXeEifBjHPzgQ1hoWhjS4289Hjjy1uw=
+github.com/go-co-op/gocron/v2 v2.12.1/go.mod h1:xY7bJxGazKam1cz04EebrlP4S9q4iWdiAylMGP3jY9w=
 github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
@@ -291,16 +293,26 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
 github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ=
 github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg=
+github.com/go-openapi/analysis v0.23.0 h1:aGday7OWupfMs+LbmLZG4k0MYXIANxcuBTYUC03zFCU=
+github.com/go-openapi/analysis v0.23.0/go.mod h1:9mz9ZWaSlV8TvjQHLl2mUW2PbZtemkE8yA5v22ohupo=
 github.com/go-openapi/errors v0.22.0 h1:c4xY/OLxUBSTiepAg3j/MHuAv5mJhnf53LLMWFB+u/w=
 github.com/go-openapi/errors v0.22.0/go.mod h1:J3DmZScxCDufmIMsdOuDHxJbdOGC0xtUynjIx092vXE=
 github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
 github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
 github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
 github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
+github.com/go-openapi/loads v0.22.0 h1:ECPGd4jX1U6NApCGG1We+uEozOAvXvJSF4nnwHZ8Aco=
+github.com/go-openapi/loads v0.22.0/go.mod h1:yLsaTCS92mnSAZX5WWoxszLj0u+Ojl+Zs5Stn1oF+rs=
+github.com/go-openapi/runtime v0.28.0 h1:gpPPmWSNGo214l6n8hzdXYhPuJcGtziTOgUpvsFWGIQ=
+github.com/go-openapi/runtime v0.28.0/go.mod h1:QN7OzcS+XuYmkQLw05akXk0jRH/eZ3kb18+1KwW9gyc=
+github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY=
+github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
 github.com/go-openapi/strfmt v0.23.0 h1:nlUS6BCqcnAk0pyhi9Y+kdDVZdZMHfEKQiS4HaMgO/c=
 github.com/go-openapi/strfmt v0.23.0/go.mod h1:NrtIpfKtWIygRkKVsxh7XQMDQW5HKQl6S5ik2elW+K4=
 github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
 github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
+github.com/go-openapi/validate v0.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3BumrGD58=
+github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ=
 github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
 github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
 github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -426,6 +438,8 @@ github.com/googleapis/gax-go/v2 v2.14.0 h1:f+jMrjBPl+DL9nI4IQzLUxMq7XrAqFYB7hBPq
 github.com/googleapis/gax-go/v2 v2.14.0/go.mod h1:lhBCnjdLrWRaPvLWhmc8IS24m9mr07qSYnHncrgo+zk=
 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
 github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
+github.com/grafana/grafana-openapi-client-go v0.0.0-20240826142251-d1c93bae4198 h1:JEoUdaKnBdZ57YsWiDeAERYu52W4c7g7eAAxY2PpWl8=
+github.com/grafana/grafana-openapi-client-go v0.0.0-20240826142251-d1c93bae4198/go.mod h1:hiZnMmXc9KXNUlvkV2BKFsiWuIFF/fF4wGgYWEjBitI=
 github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
 github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
 github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -482,6 +496,8 @@ github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9Y
 github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
 github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
 github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
+github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4=
+github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc=
 github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
 github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
 github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
@@ -602,6 +618,8 @@ github.com/r3labs/diff v0.0.0-20191120142937-b4ed99a31f5a h1:2v4Ipjxa3sh+xn6Gvtg
 github.com/r3labs/diff v0.0.0-20191120142937-b4ed99a31f5a/go.mod h1:ozniNEFS3j1qCwHKdvraMn1WJOsUxHd7lYfukEIS4cs=
 github.com/redis/go-redis/v9 v9.6.1 h1:HHDteefn6ZkTtY5fGUE8tj8uy85AHk6zP7CpzIAM0y4=
 github.com/redis/go-redis/v9 v9.6.1/go.mod h1:0C0c6ycQsdpVNQpxb1njEQIqkx5UcsM8FJCQLgE9+RA=
+github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
+github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
 github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
 github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=

+ 51 - 24
pkg/controllers/externalsecret/externalsecret_controller.go

@@ -53,10 +53,11 @@ import (
 	// Metrics.
 	"github.com/external-secrets/external-secrets/pkg/controllers/externalsecret/esmetrics"
 	ctrlmetrics "github.com/external-secrets/external-secrets/pkg/controllers/metrics"
+	"github.com/external-secrets/external-secrets/pkg/controllers/util"
+	"github.com/external-secrets/external-secrets/pkg/generator/statemanager"
 	"github.com/external-secrets/external-secrets/pkg/utils"
 	"github.com/external-secrets/external-secrets/pkg/utils/resolvers"
 
-	// Loading registered generators.
 	_ "github.com/external-secrets/external-secrets/pkg/generator/register"
 	// Loading registered providers.
 	_ "github.com/external-secrets/external-secrets/pkg/provider/register"
@@ -83,6 +84,7 @@ const (
 	msgErrorUpdateImmutable = "could not update secret, target is immutable"
 	msgErrorBecomeOwner     = "failed to take ownership of target secret"
 	msgErrorIsOwned         = "target is owned by another ExternalSecret"
+	msgErrorGarbageCollect  = "could not garbage collect generator state"
 
 	// log messages.
 	logErrorGetES                = "unable to get ExternalSecret"
@@ -117,6 +119,7 @@ var (
 )
 
 const indexESTargetSecretNameField = ".metadata.targetSecretName"
+const externalSecretFinalizer = "externalsecret.externalsecrets.io/finalizer"
 
 // Reconciler reconciles a ExternalSecret object.
 type Reconciler struct {
@@ -172,6 +175,40 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ct
 		return ctrl.Result{}, err
 	}
 
+	// We fetch the ExternalSecret resource above, however the status subresource may be inconsistent.
+	// We have to explicitly fetch it, otherwise it may be missing and will cause
+	// unexpected side effects.
+	// When we update the status below and immediately requeue the request, the status
+	// will be updated, but the ExternalSecret cache won't be up to date yet.
+	err = r.SubResource("status").Get(ctx, externalSecret, externalSecret)
+	if err != nil {
+		log.Error(err, "failed to get status subresource")
+		return ctrl.Result{}, err
+	}
+
+	if externalSecret.ObjectMeta.DeletionTimestamp.IsZero() {
+		if added := controllerutil.AddFinalizer(externalSecret, externalSecretFinalizer); added {
+			if err := r.Client.Update(ctx, externalSecret, &client.UpdateOptions{}); err != nil {
+				return ctrl.Result{}, fmt.Errorf("could not update finalizers: %w", err)
+			}
+			return ctrl.Result{Requeue: true}, nil
+		}
+	} else if controllerutil.ContainsFinalizer(externalSecret, externalSecretFinalizer) {
+		sm := statemanager.New(r.Client, r.Scheme, externalSecret.Namespace, externalSecret)
+		if err := sm.CleanupImmediate(ctx, externalSecret, r.Client, externalSecret.Namespace); err != nil {
+			r.markAsFailed(msgErrorGarbageCollect, err, externalSecret, syncCallsError.With(resourceLabels))
+			return ctrl.Result{}, err
+		}
+
+		r.Log.Info("removing finalizer")
+		controllerutil.RemoveFinalizer(externalSecret, externalSecretFinalizer)
+		if err := r.Client.Update(ctx, externalSecret, &client.UpdateOptions{}); err != nil {
+			return ctrl.Result{}, fmt.Errorf("could not update finalizers: %w", err)
+		}
+
+		return ctrl.Result{}, nil
+	}
+
 	// skip reconciliation if deletion timestamp is set on external secret
 	if !externalSecret.GetDeletionTimestamp().IsZero() {
 		log.V(1).Info("skipping ExternalSecret, it is marked for deletion")
@@ -281,7 +318,8 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ct
 	// NOTE: we use the ability of deferred functions to update named return values `result` and `err`
 	// NOTE: we dereference the DeepCopy of the status field because status fields are NOT pointers,
 	//       so otherwise the `equality.Semantic.DeepEqual` will always return false.
-	currentStatus := *externalSecret.Status.DeepCopy()
+	currentStatus := externalSecret.Status.DeepCopy()
+
 	defer func() {
 		// if the status has not changed, we don't need to update it
 		if equality.Semantic.DeepEqual(currentStatus, externalSecret.Status) {
@@ -446,6 +484,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ct
 		return nil
 	}
 
+	//nolint:nolintlint
 	switch externalSecret.Spec.Target.CreationPolicy { //nolint:exhaustive
 	case esv1beta1.CreatePolicyMerge:
 		// update the secret, if it exists
@@ -511,6 +550,11 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ct
 		return ctrl.Result{}, err
 	}
 
+	sm := statemanager.New(r.Client, r.Scheme, externalSecret.Namespace, externalSecret)
+	if err := sm.GarbageCollect(ctx, r.Client, externalSecret.Namespace); err != nil {
+		r.markAsFailed(msgErrorGarbageCollect, err, externalSecret, syncCallsError.With(resourceLabels))
+		return ctrl.Result{}, err
+	}
 	r.markAsDone(externalSecret, start, log, esv1beta1.ConditionReasonSecretSynced, msgSynced)
 	return r.getRequeueResult(externalSecret), nil
 }
@@ -560,7 +604,7 @@ func (r *Reconciler) markAsDone(externalSecret *esv1beta1.ExternalSecret, start
 	SetExternalSecretCondition(externalSecret, *newReadyCondition)
 
 	externalSecret.Status.RefreshTime = metav1.NewTime(start)
-	externalSecret.Status.SyncedResourceVersion = getResourceVersion(externalSecret)
+	externalSecret.Status.SyncedResourceVersion = util.GetResourceVersion(externalSecret.ObjectMeta)
 
 	// if the status or reason has changed, log at the appropriate verbosity level
 	if oldReadyCondition == nil || oldReadyCondition.Status != newReadyCondition.Status || oldReadyCondition.Reason != newReadyCondition.Reason {
@@ -755,23 +799,6 @@ func getManagedFieldKeys(
 	return keys, nil
 }
 
-func getResourceVersion(es *esv1beta1.ExternalSecret) string {
-	return fmt.Sprintf("%d-%s", es.ObjectMeta.GetGeneration(), hashMeta(es.ObjectMeta))
-}
-
-// hashMeta returns a consistent hash of the `metadata.labels` and `metadata.annotations` fields of the given object.
-func hashMeta(m metav1.ObjectMeta) string {
-	type meta struct {
-		annotations map[string]string
-		labels      map[string]string
-	}
-	objectMeta := meta{
-		annotations: m.Annotations,
-		labels:      m.Labels,
-	}
-	return utils.ObjectHash(objectMeta)
-}
-
 func shouldSkipClusterSecretStore(r *Reconciler, es *esv1beta1.ExternalSecret) bool {
 	return !r.ClusterSecretStoreEnabled && es.Spec.SecretStoreRef.Kind == esv1beta1.ClusterSecretStoreKind
 }
@@ -858,7 +885,7 @@ func shouldRefresh(es *esv1beta1.ExternalSecret) bool {
 	}
 
 	// if the ExternalSecret has been updated, we should refresh
-	if es.Status.SyncedResourceVersion != getResourceVersion(es) {
+	if es.Status.SyncedResourceVersion != util.GetResourceVersion(es.ObjectMeta) {
 		return true
 	}
 
@@ -946,11 +973,11 @@ func (r *Reconciler) findObjectsForSecret(ctx context.Context, secret client.Obj
 	}
 
 	requests := make([]reconcile.Request, len(externalSecretsList.Items))
-	for i, item := range externalSecretsList.Items {
+	for i := range externalSecretsList.Items {
 		requests[i] = reconcile.Request{
 			NamespacedName: types.NamespacedName{
-				Name:      item.GetName(),
-				Namespace: item.GetNamespace(),
+				Name:      externalSecretsList.Items[i].GetName(),
+				Namespace: externalSecretsList.Items[i].GetNamespace(),
 			},
 		}
 	}

+ 38 - 17
pkg/controllers/externalsecret/externalsecret_controller_secret.go

@@ -19,6 +19,7 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"strconv"
 
 	v1 "k8s.io/api/core/v1"
 	apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
@@ -26,34 +27,45 @@ import (
 	esv1beta1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1"
 	genv1alpha1 "github.com/external-secrets/external-secrets/apis/generators/v1alpha1"
 	"github.com/external-secrets/external-secrets/pkg/controllers/secretstore"
+	"github.com/external-secrets/external-secrets/pkg/generator/statemanager"
 	"github.com/external-secrets/external-secrets/pkg/utils"
 	"github.com/external-secrets/external-secrets/pkg/utils/resolvers"
 
-	// Loading registered generators.
 	_ "github.com/external-secrets/external-secrets/pkg/generator/register"
 	_ "github.com/external-secrets/external-secrets/pkg/provider/register"
 )
 
 // getProviderSecretData returns the provider's secret data with the provided ExternalSecret.
 func (r *Reconciler) getProviderSecretData(ctx context.Context, externalSecret *esv1beta1.ExternalSecret) (map[string][]byte, error) {
+	var err error
 	// We MUST NOT create multiple instances of a provider client (mostly due to limitations with GCP)
 	// Clientmanager keeps track of the client instances
 	// that are created during the fetching process and closes clients
 	// if needed.
 	mgr := secretstore.NewManager(r.Client, r.ControllerClass, r.EnableFloodGate)
 	defer mgr.Close(ctx)
-
+	genState := statemanager.New(r.Client, r.Scheme, externalSecret.Namespace, externalSecret)
+	defer func() {
+		if err != nil {
+			if err := genState.Rollback(); err != nil {
+				r.Log.Error(err, "error rolling back generator state")
+			}
+			return
+		}
+		if err := genState.Commit(); err != nil {
+			r.Log.Error(err, "error committing generator state")
+		}
+	}()
 	providerData := make(map[string][]byte)
 	for i, remoteRef := range externalSecret.Spec.DataFrom {
 		var secretMap map[string][]byte
-		var err error
 
 		if remoteRef.Find != nil {
-			secretMap, err = r.handleFindAllSecrets(ctx, externalSecret, remoteRef, mgr, i)
+			secretMap, err = r.handleFindAllSecrets(ctx, externalSecret, remoteRef, mgr, genState, i)
 		} else if remoteRef.Extract != nil {
-			secretMap, err = r.handleExtractSecrets(ctx, externalSecret, remoteRef, mgr, i)
+			secretMap, err = r.handleExtractSecrets(ctx, externalSecret, remoteRef, mgr, genState, i)
 		} else if remoteRef.SourceRef != nil && remoteRef.SourceRef.GeneratorRef != nil {
-			secretMap, err = r.handleGenerateSecrets(ctx, externalSecret.Namespace, remoteRef, i)
+			secretMap, err = r.handleGenerateSecrets(ctx, externalSecret.Namespace, remoteRef, i, genState)
 		}
 		if errors.Is(err, esv1beta1.NoSecretErr) && externalSecret.Spec.Target.DeletionPolicy != esv1beta1.DeletionPolicyRetain {
 			r.recorder.Event(
@@ -71,7 +83,7 @@ func (r *Reconciler) getProviderSecretData(ctx context.Context, externalSecret *
 	}
 
 	for i, secretRef := range externalSecret.Spec.Data {
-		err := r.handleSecretData(ctx, i, *externalSecret, secretRef, providerData, mgr)
+		err = r.handleSecretData(ctx, i, externalSecret, secretRef, providerData, mgr)
 		if errors.Is(err, esv1beta1.NoSecretErr) && externalSecret.Spec.Target.DeletionPolicy != esv1beta1.DeletionPolicyRetain {
 			r.recorder.Event(externalSecret, v1.EventTypeNormal, esv1beta1.ReasonDeleted, fmt.Sprintf("secret does not exist at provider using .data[%d] key=%s", i, secretRef.RemoteRef.Key))
 			continue
@@ -84,7 +96,7 @@ func (r *Reconciler) getProviderSecretData(ctx context.Context, externalSecret *
 	return providerData, nil
 }
 
-func (r *Reconciler) handleSecretData(ctx context.Context, i int, externalSecret esv1beta1.ExternalSecret, secretRef esv1beta1.ExternalSecretData, providerData map[string][]byte, cmgr *secretstore.Manager) error {
+func (r *Reconciler) handleSecretData(ctx context.Context, i int, externalSecret *esv1beta1.ExternalSecret, secretRef esv1beta1.ExternalSecretData, providerData map[string][]byte, cmgr *secretstore.Manager) error {
 	client, err := cmgr.Get(ctx, externalSecret.Spec.SecretStoreRef, externalSecret.Namespace, toStoreGenSourceRef(secretRef.SourceRef))
 	if err != nil {
 		return err
@@ -110,17 +122,20 @@ func toStoreGenSourceRef(ref *esv1beta1.StoreSourceRef) *esv1beta1.StoreGenerato
 	}
 }
 
-func (r *Reconciler) handleGenerateSecrets(ctx context.Context, namespace string, remoteRef esv1beta1.ExternalSecretDataFromRemoteRef, i int) (map[string][]byte, error) {
-	gen, obj, err := resolvers.GeneratorRef(ctx, r.Client, r.Scheme, namespace, remoteRef.SourceRef.GeneratorRef)
+func (r *Reconciler) handleGenerateSecrets(ctx context.Context, namespace string, remoteRef esv1beta1.ExternalSecretDataFromRemoteRef, i int, generatorState *statemanager.Manager) (map[string][]byte, error) {
+	gen, genResource, err := resolvers.GeneratorRef(ctx, r.Client, r.Scheme, namespace, remoteRef.SourceRef.GeneratorRef)
 	if err != nil {
 		return nil, fmt.Errorf("unable to resolve generator: %w", err)
 	}
-	// We still pass the namespace to the generate function because it needs to create
-	// namespace based objects.
-	secretMap, err := gen.Generate(ctx, obj, r.Client, namespace)
+	latestState := generatorState.GetLatest(generatorStateKey(i))
+	secretMap, newState, err := gen.Generate(ctx, genResource, r.Client, namespace)
 	if err != nil {
 		return nil, fmt.Errorf(errGenerate, i, err)
 	}
+	if latestState != nil {
+		generatorState.EnqueueMoveStateToGC(genResource, generatorStateKey(i), gen, latestState)
+	}
+	generatorState.EnqueueSetLatest(ctx, r.Client, generatorStateKey(i), namespace, genResource, gen, newState)
 	secretMap, err = utils.RewriteMap(remoteRef.Rewrite, secretMap)
 	if err != nil {
 		return nil, fmt.Errorf(errRewrite, i, err)
@@ -131,7 +146,11 @@ func (r *Reconciler) handleGenerateSecrets(ctx context.Context, namespace string
 	return secretMap, err
 }
 
-func (r *Reconciler) handleExtractSecrets(ctx context.Context, externalSecret *esv1beta1.ExternalSecret, remoteRef esv1beta1.ExternalSecretDataFromRemoteRef, cmgr *secretstore.Manager, i int) (map[string][]byte, error) {
+func generatorStateKey(i int) string {
+	return strconv.Itoa(i)
+}
+
+func (r *Reconciler) handleExtractSecrets(ctx context.Context, externalSecret *esv1beta1.ExternalSecret, remoteRef esv1beta1.ExternalSecretDataFromRemoteRef, cmgr *secretstore.Manager, genState *statemanager.Manager, i int) (map[string][]byte, error) {
 	client, err := cmgr.Get(ctx, externalSecret.Spec.SecretStoreRef, externalSecret.Namespace, remoteRef.SourceRef)
 	if err != nil {
 		return nil, err
@@ -157,10 +176,11 @@ func (r *Reconciler) handleExtractSecrets(ctx context.Context, externalSecret *e
 	if err != nil {
 		return nil, fmt.Errorf(errDecode, "spec.dataFrom", i, err)
 	}
-	return secretMap, err
+	genState.EnqueueFlagLatestStateForGC(generatorStateKey(i))
+	return secretMap, nil
 }
 
-func (r *Reconciler) handleFindAllSecrets(ctx context.Context, externalSecret *esv1beta1.ExternalSecret, remoteRef esv1beta1.ExternalSecretDataFromRemoteRef, cmgr *secretstore.Manager, i int) (map[string][]byte, error) {
+func (r *Reconciler) handleFindAllSecrets(ctx context.Context, externalSecret *esv1beta1.ExternalSecret, remoteRef esv1beta1.ExternalSecretDataFromRemoteRef, cmgr *secretstore.Manager, genState *statemanager.Manager, i int) (map[string][]byte, error) {
 	client, err := cmgr.Get(ctx, externalSecret.Spec.SecretStoreRef, externalSecret.Namespace, remoteRef.SourceRef)
 	if err != nil {
 		return nil, err
@@ -188,7 +208,8 @@ func (r *Reconciler) handleFindAllSecrets(ctx context.Context, externalSecret *e
 	if err != nil {
 		return nil, fmt.Errorf(errDecode, "spec.dataFrom", i, err)
 	}
-	return secretMap, err
+	genState.EnqueueFlagLatestStateForGC(generatorStateKey(i))
+	return secretMap, nil
 }
 
 func shouldSkipGenerator(r *Reconciler, generatorDef *apiextensions.JSON) (bool, error) {

+ 15 - 14
pkg/controllers/externalsecret/externalsecret_controller_test.go

@@ -41,6 +41,7 @@ import (
 	ctest "github.com/external-secrets/external-secrets/pkg/controllers/commontest"
 	"github.com/external-secrets/external-secrets/pkg/controllers/externalsecret/esmetrics"
 	ctrlmetrics "github.com/external-secrets/external-secrets/pkg/controllers/metrics"
+	"github.com/external-secrets/external-secrets/pkg/controllers/util"
 	"github.com/external-secrets/external-secrets/pkg/provider/testing/fake"
 	"github.com/external-secrets/external-secrets/pkg/utils"
 
@@ -2394,7 +2395,7 @@ var _ = Describe("ExternalSecret refresh logic", func() {
 					RefreshTime: metav1.Now(),
 				},
 			}
-			es.Status.SyncedResourceVersion = getResourceVersion(es)
+			es.Status.SyncedResourceVersion = util.GetResourceVersion(es.ObjectMeta)
 			// this should not refresh, rv matches object
 			Expect(shouldRefresh(es)).To(BeFalse())
 
@@ -2418,7 +2419,7 @@ var _ = Describe("ExternalSecret refresh logic", func() {
 					RefreshTime: metav1.Now(),
 				},
 			}
-			es.Status.SyncedResourceVersion = getResourceVersion(es)
+			es.Status.SyncedResourceVersion = util.GetResourceVersion(es.ObjectMeta)
 			// this should not refresh, rv matches object
 			Expect(shouldRefresh(es)).To(BeFalse())
 
@@ -2439,7 +2440,7 @@ var _ = Describe("ExternalSecret refresh logic", func() {
 					RefreshTime: metav1.Now(),
 				},
 			}
-			es.Status.SyncedResourceVersion = getResourceVersion(es)
+			es.Status.SyncedResourceVersion = util.GetResourceVersion(es.ObjectMeta)
 			Expect(shouldRefresh(es)).To(BeFalse())
 
 			// update gen -> refresh
@@ -2458,7 +2459,7 @@ var _ = Describe("ExternalSecret refresh logic", func() {
 				Status: esv1beta1.ExternalSecretStatus{},
 			}
 			// resource version matches
-			es.Status.SyncedResourceVersion = getResourceVersion(es)
+			es.Status.SyncedResourceVersion = util.GetResourceVersion(es.ObjectMeta)
 			Expect(shouldRefresh(es)).To(BeFalse())
 		})
 
@@ -2475,7 +2476,7 @@ var _ = Describe("ExternalSecret refresh logic", func() {
 				},
 			}
 			// resource version matches
-			es.Status.SyncedResourceVersion = getResourceVersion(es)
+			es.Status.SyncedResourceVersion = util.GetResourceVersion(es.ObjectMeta)
 			Expect(shouldRefresh(es)).To(BeTrue())
 		})
 
@@ -2490,20 +2491,20 @@ var _ = Describe("ExternalSecret refresh logic", func() {
 				Status: esv1beta1.ExternalSecretStatus{},
 			}
 			// resource version matches
-			es.Status.SyncedResourceVersion = getResourceVersion(es)
+			es.Status.SyncedResourceVersion = util.GetResourceVersion(es.ObjectMeta)
 			Expect(shouldRefresh(es)).To(BeTrue())
 		})
 
 	})
 	Context("objectmeta hash", func() {
 		It("should produce different hashes for different k/v pairs", func() {
-			h1 := hashMeta(metav1.ObjectMeta{
+			h1 := util.HashMeta(metav1.ObjectMeta{
 				Generation: 1,
 				Annotations: map[string]string{
 					"foo": "bar",
 				},
 			})
-			h2 := hashMeta(metav1.ObjectMeta{
+			h2 := util.HashMeta(metav1.ObjectMeta{
 				Generation: 1,
 				Annotations: map[string]string{
 					"foo": "bing",
@@ -2513,7 +2514,7 @@ var _ = Describe("ExternalSecret refresh logic", func() {
 		})
 
 		It("should produce different hashes for different generations but same label/annotations", func() {
-			h1 := hashMeta(metav1.ObjectMeta{
+			h1 := util.HashMeta(metav1.ObjectMeta{
 				Generation: 1,
 				Annotations: map[string]string{
 					"foo": "bar",
@@ -2522,7 +2523,7 @@ var _ = Describe("ExternalSecret refresh logic", func() {
 					"foo": "bar",
 				},
 			})
-			h2 := hashMeta(metav1.ObjectMeta{
+			h2 := util.HashMeta(metav1.ObjectMeta{
 				Generation: 2,
 				Annotations: map[string]string{
 					"foo": "bar",
@@ -2535,21 +2536,21 @@ var _ = Describe("ExternalSecret refresh logic", func() {
 		})
 
 		It("should produce the same hash for the same k/v pairs", func() {
-			h1 := hashMeta(metav1.ObjectMeta{
+			h1 := util.HashMeta(metav1.ObjectMeta{
 				Generation: 1,
 			})
-			h2 := hashMeta(metav1.ObjectMeta{
+			h2 := util.HashMeta(metav1.ObjectMeta{
 				Generation: 1,
 			})
 			Expect(h1).To(Equal(h2))
 
-			h1 = hashMeta(metav1.ObjectMeta{
+			h1 = util.HashMeta(metav1.ObjectMeta{
 				Generation: 1,
 				Annotations: map[string]string{
 					"foo": "bar",
 				},
 			})
-			h2 = hashMeta(metav1.ObjectMeta{
+			h2 = util.HashMeta(metav1.ObjectMeta{
 				Generation: 1,
 				Annotations: map[string]string{
 					"foo": "bar",

+ 84 - 29
pkg/controllers/pushsecret/pushsecret_controller.go

@@ -40,11 +40,12 @@ import (
 	ctrlmetrics "github.com/external-secrets/external-secrets/pkg/controllers/metrics"
 	"github.com/external-secrets/external-secrets/pkg/controllers/pushsecret/psmetrics"
 	"github.com/external-secrets/external-secrets/pkg/controllers/secretstore"
+	"github.com/external-secrets/external-secrets/pkg/controllers/util"
+	"github.com/external-secrets/external-secrets/pkg/generator/statemanager"
 	"github.com/external-secrets/external-secrets/pkg/provider/util/locks"
 	"github.com/external-secrets/external-secrets/pkg/utils"
 	"github.com/external-secrets/external-secrets/pkg/utils/resolvers"
 
-	// load generators.
 	_ "github.com/external-secrets/external-secrets/pkg/generator/register"
 )
 
@@ -119,32 +120,33 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
 	case esapi.PushSecretDeletionPolicyDelete:
 		// finalizer logic. Only added if we should delete the secrets
 		if ps.ObjectMeta.DeletionTimestamp.IsZero() {
-			if !controllerutil.ContainsFinalizer(&ps, pushSecretFinalizer) {
-				controllerutil.AddFinalizer(&ps, pushSecretFinalizer)
+			if added := controllerutil.AddFinalizer(&ps, pushSecretFinalizer); added {
 				if err := r.Client.Update(ctx, &ps, &client.UpdateOptions{}); err != nil {
 					return ctrl.Result{}, fmt.Errorf("could not update finalizers: %w", err)
 				}
-
-				return ctrl.Result{}, nil
+				return ctrl.Result{Requeue: true}, nil
+			}
+		} else if controllerutil.ContainsFinalizer(&ps, pushSecretFinalizer) {
+			// trigger a cleanup with no Synced Map
+			badState, err := r.DeleteSecretFromProviders(ctx, &ps, esapi.SyncedPushSecretsMap{}, mgr)
+			if err != nil {
+				msg := fmt.Sprintf("Failed to Delete Secrets from Provider: %v", err)
+				r.markAsFailed(msg, &ps, badState)
+				return ctrl.Result{}, err
+			}
+			sm := statemanager.New(r.Client, r.Scheme, ps.Namespace, &ps)
+			if err := sm.CleanupImmediate(ctx, &ps, r.Client, ps.Namespace); err != nil {
+				msg := fmt.Sprintf("Failed to cleanup generator state: %v", err)
+				r.markAsFailed(msg, &ps, nil)
+				return ctrl.Result{}, err
 			}
-		} else {
-			if controllerutil.ContainsFinalizer(&ps, pushSecretFinalizer) {
-				// trigger a cleanup with no Synced Map
-				badState, err := r.DeleteSecretFromProviders(ctx, &ps, esapi.SyncedPushSecretsMap{}, mgr)
-				if err != nil {
-					msg := fmt.Sprintf("Failed to Delete Secrets from Provider: %v", err)
-					r.markAsFailed(msg, &ps, badState)
-
-					return ctrl.Result{}, err
-				}
-
-				controllerutil.RemoveFinalizer(&ps, pushSecretFinalizer)
-				if err := r.Client.Update(ctx, &ps, &client.UpdateOptions{}); err != nil {
-					return ctrl.Result{}, fmt.Errorf("could not update finalizers: %w", err)
-				}
 
-				return ctrl.Result{}, nil
+			controllerutil.RemoveFinalizer(&ps, pushSecretFinalizer)
+			if err := r.Client.Update(ctx, &ps, &client.UpdateOptions{}); err != nil {
+				return ctrl.Result{}, fmt.Errorf("could not update finalizers: %w", err)
 			}
+
+			return ctrl.Result{}, nil
 		}
 	case esapi.PushSecretDeletionPolicyNone:
 		if controllerutil.ContainsFinalizer(&ps, pushSecretFinalizer) {
@@ -156,7 +158,17 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
 	default:
 	}
 
-	secret, err := r.resolveSecret(ctx, ps)
+	timeSinceLastRefresh := 0 * time.Second
+	if !ps.Status.RefreshTime.IsZero() {
+		timeSinceLastRefresh = time.Since(ps.Status.RefreshTime.Time)
+	}
+	if !shouldRefresh(ps) {
+		refreshInt = (ps.Spec.RefreshInterval.Duration - timeSinceLastRefresh) + 5*time.Second
+		log.V(1).Info("skipping refresh", "rv", util.GetResourceVersion(ps.ObjectMeta), "nr", refreshInt.Seconds())
+		return ctrl.Result{RequeueAfter: refreshInt}, nil
+	}
+
+	secret, err := r.resolveSecret(ctx, &ps)
 	if err != nil {
 		r.markAsFailed(errFailedGetSecret, &ps, nil)
 
@@ -208,11 +220,31 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
 	default:
 	}
 
-	r.markAsDone(&ps, syncedSecrets)
+	sm := statemanager.New(r.Client, r.Scheme, ps.Namespace, &ps)
+	if err := sm.GarbageCollect(ctx, r.Client, ps.Namespace); err != nil {
+		msg := fmt.Sprintf("Failed to cleanup generator state: %v", err)
+		r.markAsFailed(msg, &ps, syncedSecrets)
+		return ctrl.Result{}, err
+	}
+
+	r.markAsDone(&ps, syncedSecrets, start)
 
 	return ctrl.Result{RequeueAfter: refreshInt}, nil
 }
 
+func shouldRefresh(ps esapi.PushSecret) bool {
+	if ps.Status.SyncedResourceVersion != util.GetResourceVersion(ps.ObjectMeta) {
+		return true
+	}
+	if ps.Spec.RefreshInterval.Duration == 0 && ps.Status.SyncedResourceVersion != "" {
+		return false
+	}
+	if ps.Status.RefreshTime.IsZero() {
+		return true
+	}
+	return ps.Status.RefreshTime.Add(ps.Spec.RefreshInterval.Duration).Before(time.Now())
+}
+
 func (r *Reconciler) markAsFailed(msg string, ps *esapi.PushSecret, syncState esapi.SyncedPushSecretsMap) {
 	cond := newPushSecretCondition(esapi.PushSecretReady, v1.ConditionFalse, esapi.ReasonErrored, msg)
 	setPushSecretCondition(ps, *cond)
@@ -222,7 +254,7 @@ func (r *Reconciler) markAsFailed(msg string, ps *esapi.PushSecret, syncState es
 	r.recorder.Event(ps, v1.EventTypeWarning, esapi.ReasonErrored, msg)
 }
 
-func (r *Reconciler) markAsDone(ps *esapi.PushSecret, secrets esapi.SyncedPushSecretsMap) {
+func (r *Reconciler) markAsDone(ps *esapi.PushSecret, secrets esapi.SyncedPushSecretsMap, start time.Time) {
 	msg := "PushSecret synced successfully"
 	if ps.Spec.UpdatePolicy == esapi.PushSecretUpdatePolicyIfNotExists {
 		msg += ". Existing secrets in providers unchanged."
@@ -230,6 +262,8 @@ func (r *Reconciler) markAsDone(ps *esapi.PushSecret, secrets esapi.SyncedPushSe
 	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)
 	r.recorder.Event(ps, v1.EventTypeNormal, esapi.ReasonSynced, msg)
 }
 
@@ -355,7 +389,23 @@ func secretKeyExists(key string, secret *v1.Secret) bool {
 	return key == "" || ok
 }
 
-func (r *Reconciler) resolveSecret(ctx context.Context, ps esapi.PushSecret) (*v1.Secret, error) {
+const defaultGeneratorStateKey = "__pushsecret"
+
+func (r *Reconciler) resolveSecret(ctx context.Context, ps *esapi.PushSecret) (*v1.Secret, error) {
+	var err error
+	generatorState := statemanager.New(r.Client, r.Scheme, ps.Namespace, ps)
+	defer func() {
+		if err != nil {
+			if err := generatorState.Rollback(); err != nil {
+				r.Log.Error(err, "error rolling back generator state")
+			}
+
+			return
+		}
+		if err := generatorState.Commit(); err != nil {
+			r.Log.Error(err, "error committing generator state")
+		}
+	}()
 	if ps.Spec.Selector.Secret != nil {
 		secretName := types.NamespacedName{Name: ps.Spec.Selector.Secret.Name, Namespace: ps.Namespace}
 		secret := &v1.Secret{}
@@ -363,23 +413,28 @@ func (r *Reconciler) resolveSecret(ctx context.Context, ps esapi.PushSecret) (*v
 		if 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)
+		return r.resolveSecretFromGenerator(ctx, ps.Namespace, ps.Spec.Selector.GeneratorRef, generatorState)
 	}
 	return nil, errors.New("no secret selector provided")
 }
 
-func (r *Reconciler) resolveSecretFromGenerator(ctx context.Context, namespace string, generatorRef *v1beta1.GeneratorRef) (*v1.Secret, error) {
-	gen, obj, err := resolvers.GeneratorRef(ctx, r.Client, r.Scheme, namespace, generatorRef)
+func (r *Reconciler) resolveSecretFromGenerator(ctx context.Context, namespace string, generatorRef *v1beta1.GeneratorRef, generatorState *statemanager.Manager) (*v1.Secret, error) {
+	gen, genResource, err := resolvers.GeneratorRef(ctx, r.Client, r.Scheme, namespace, generatorRef)
 	if err != nil {
 		return nil, fmt.Errorf("unable to resolve generator: %w", err)
 	}
-	secretMap, err := gen.Generate(ctx, obj, r.Client, namespace)
+	prevState := generatorState.GetLatest(defaultGeneratorStateKey)
+	secretMap, newState, err := gen.Generate(ctx, genResource, r.Client, namespace)
 	if err != nil {
 		return nil, fmt.Errorf("unable to generate: %w", err)
 	}
+	if prevState != nil {
+		generatorState.EnqueueSetLatest(ctx, r.Client, defaultGeneratorStateKey, namespace, genResource, gen, newState)
+	}
 	return &v1.Secret{
 		ObjectMeta: metav1.ObjectMeta{
 			Name:      "___generated-secret",

+ 37 - 0
pkg/controllers/util/util.go

@@ -0,0 +1,37 @@
+/*
+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 util
+
+import (
+	"fmt"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	"github.com/external-secrets/external-secrets/pkg/utils"
+)
+
+func GetResourceVersion(meta metav1.ObjectMeta) string {
+	return fmt.Sprintf("%d-%s", meta.GetGeneration(), HashMeta(meta))
+}
+
+func HashMeta(m metav1.ObjectMeta) string {
+	type meta struct {
+		annotations map[string]string
+		labels      map[string]string
+	}
+	return utils.ObjectHash(meta{
+		annotations: m.Annotations,
+		labels:      m.Labels,
+	})
+}

+ 15 - 11
pkg/generator/acr/acr.go

@@ -70,14 +70,14 @@ const (
 // * access tokens are scoped to a specific repository or action (pull,push)
 // * refresh tokens can are scoped to whatever policy is attached to the identity that creates the acr refresh token
 // details can be found here: https://github.com/Azure/acr/blob/main/docs/AAD-OAuth.md#overview
-func (g *Generator) Generate(ctx context.Context, jsonSpec *apiextensions.JSON, crClient client.Client, namespace string) (map[string][]byte, error) {
+func (g *Generator) Generate(ctx context.Context, jsonSpec *apiextensions.JSON, crClient client.Client, namespace string) (map[string][]byte, genv1alpha1.GeneratorProviderState, error) {
 	cfg, err := ctrlcfg.GetConfig()
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 	kubeClient, err := kubernetes.NewForConfig(cfg)
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 	g.clientSecretCreds = func(tenantID, clientID, clientSecret string, options *azidentity.ClientSecretCredentialOptions) (TokenGetter, error) {
 		return azidentity.NewClientSecretCredential(tenantID, clientID, clientSecret, options)
@@ -93,6 +93,10 @@ func (g *Generator) Generate(ctx context.Context, jsonSpec *apiextensions.JSON,
 		fetchACRRefreshToken)
 }
 
+func (g *Generator) Cleanup(ctx context.Context, jsonSpec *apiextensions.JSON, _ genv1alpha1.GeneratorProviderState, crClient client.Client, namespace string) error {
+	return nil
+}
+
 func (g *Generator) generate(
 	ctx context.Context,
 	jsonSpec *apiextensions.JSON,
@@ -100,13 +104,13 @@ func (g *Generator) generate(
 	namespace string,
 	kubeClient kubernetes.Interface,
 	fetchAccessToken accessTokenFetcher,
-	fetchRefreshToken refreshTokenFetcher) (map[string][]byte, error) {
+	fetchRefreshToken refreshTokenFetcher) (map[string][]byte, genv1alpha1.GeneratorProviderState, error) {
 	if jsonSpec == nil {
-		return nil, errors.New(errNoSpec)
+		return nil, nil, errors.New(errNoSpec)
 	}
 	res, err := parseSpec(jsonSpec.Raw)
 	if err != nil {
-		return nil, fmt.Errorf(errParseSpec, err)
+		return nil, nil, fmt.Errorf(errParseSpec, err)
 	}
 	var accessToken string
 	// pick authentication strategy to create an AAD access token
@@ -136,27 +140,27 @@ func (g *Generator) generate(
 			namespace,
 		)
 	} else {
-		return nil, errors.New("unexpeted configuration")
+		return nil, nil, errors.New("unexpeted configuration")
 	}
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 	var acrToken string
 	acrToken, err = fetchRefreshToken(accessToken, res.Spec.TenantID, res.Spec.ACRRegistry)
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 	if res.Spec.Scope != "" {
 		acrToken, err = fetchAccessToken(acrToken, res.Spec.TenantID, res.Spec.ACRRegistry, res.Spec.Scope)
 		if err != nil {
-			return nil, err
+			return nil, nil, err
 		}
 	}
 
 	return map[string][]byte{
 		"username": []byte(defaultLoginUsername),
 		"password": []byte(acrToken),
-	}, nil
+	}, nil, nil
 }
 
 type accessTokenFetcher func(acrRefreshToken, tenantID, registryURL, scope string) (string, error)

+ 1 - 1
pkg/generator/acr/acr_test.go

@@ -188,7 +188,7 @@ spec:
 			g := &Generator{
 				clientSecretCreds: tt.args.clientSecretCreds,
 			}
-			got, err := g.generate(
+			got, _, err := g.generate(
 				tt.args.ctx,
 				tt.args.jsonSpec,
 				tt.args.crClient,

+ 14 - 10
pkg/generator/ecr/ecr.go

@@ -43,23 +43,27 @@ const (
 	errGetToken   = "unable to get authorization token: %w"
 )
 
-func (g *Generator) Generate(ctx context.Context, jsonSpec *apiextensions.JSON, kube client.Client, namespace string) (map[string][]byte, error) {
+func (g *Generator) Generate(ctx context.Context, jsonSpec *apiextensions.JSON, kube client.Client, namespace string) (map[string][]byte, genv1alpha1.GeneratorProviderState, error) {
 	return g.generate(ctx, jsonSpec, kube, namespace, ecrFactory)
 }
 
+func (g *Generator) Cleanup(ctx context.Context, jsonSpec *apiextensions.JSON, _ genv1alpha1.GeneratorProviderState, crClient client.Client, namespace string) error {
+	return nil
+}
+
 func (g *Generator) generate(
 	ctx context.Context,
 	jsonSpec *apiextensions.JSON,
 	kube client.Client,
 	namespace string,
 	ecrFunc ecrFactoryFunc,
-) (map[string][]byte, error) {
+) (map[string][]byte, genv1alpha1.GeneratorProviderState, error) {
 	if jsonSpec == nil {
-		return nil, errors.New(errNoSpec)
+		return nil, nil, errors.New(errNoSpec)
 	}
 	res, err := parseSpec(jsonSpec.Raw)
 	if err != nil {
-		return nil, fmt.Errorf(errParseSpec, err)
+		return nil, nil, fmt.Errorf(errParseSpec, err)
 	}
 	sess, err := awsauth.NewGeneratorSession(
 		ctx,
@@ -74,25 +78,25 @@ func (g *Generator) generate(
 		awsauth.DefaultSTSProvider,
 		awsauth.DefaultJWTProvider)
 	if err != nil {
-		return nil, fmt.Errorf(errCreateSess, err)
+		return nil, nil, fmt.Errorf(errCreateSess, err)
 	}
 	client := ecrFunc(sess)
 	out, err := client.GetAuthorizationToken(&ecr.GetAuthorizationTokenInput{})
 	if err != nil {
-		return nil, fmt.Errorf(errGetToken, err)
+		return nil, nil, fmt.Errorf(errGetToken, err)
 	}
 	if len(out.AuthorizationData) != 1 {
-		return nil, fmt.Errorf("unexpected number of authorization tokens. expected 1, found %d", len(out.AuthorizationData))
+		return nil, nil, fmt.Errorf("unexpected number of authorization tokens. expected 1, found %d", len(out.AuthorizationData))
 	}
 
 	// AuthorizationToken is base64 encoded {username}:{password} string
 	decodedToken, err := base64.StdEncoding.DecodeString(*out.AuthorizationData[0].AuthorizationToken)
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 	parts := strings.Split(string(decodedToken), ":")
 	if len(parts) != 2 {
-		return nil, errors.New("unexpected token format")
+		return nil, nil, errors.New("unexpected token format")
 	}
 
 	exp := out.AuthorizationData[0].ExpiresAt.UTC().Unix()
@@ -101,7 +105,7 @@ func (g *Generator) generate(
 		"password":       []byte(parts[1]),
 		"proxy_endpoint": []byte(*out.AuthorizationData[0].ProxyEndpoint),
 		"expires_at":     []byte(strconv.FormatInt(exp, 10)),
-	}, nil
+	}, nil, nil
 }
 
 type ecrFactoryFunc func(aws *session.Session) ecriface.ECRAPI

+ 1 - 1
pkg/generator/ecr/ecr_test.go

@@ -120,7 +120,7 @@ spec:
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			g := &Generator{}
-			got, err := g.generate(
+			got, _, err := g.generate(
 				tt.args.ctx,
 				tt.args.jsonSpec,
 				tt.args.kube,

+ 8 - 4
pkg/generator/fake/fake.go

@@ -34,19 +34,23 @@ const (
 	errGetToken  = "unable to get authorization token: %w"
 )
 
-func (g *Generator) Generate(_ context.Context, jsonSpec *apiextensions.JSON, _ client.Client, _ string) (map[string][]byte, error) {
+func (g *Generator) Generate(_ context.Context, jsonSpec *apiextensions.JSON, _ client.Client, _ string) (map[string][]byte, genv1alpha1.GeneratorProviderState, error) {
 	if jsonSpec == nil {
-		return nil, errors.New(errNoSpec)
+		return nil, nil, errors.New(errNoSpec)
 	}
 	res, err := parseSpec(jsonSpec.Raw)
 	if err != nil {
-		return nil, fmt.Errorf(errParseSpec, err)
+		return nil, nil, fmt.Errorf(errParseSpec, err)
 	}
 	out := make(map[string][]byte)
 	for k, v := range res.Spec.Data {
 		out[k] = []byte(v)
 	}
-	return out, nil
+	return out, nil, nil
+}
+
+func (g *Generator) Cleanup(ctx context.Context, jsonSpec *apiextensions.JSON, _ genv1alpha1.GeneratorProviderState, crClient client.Client, namespace string) error {
+	return nil
 }
 
 func parseSpec(data []byte) (*genv1alpha1.Fake, error) {

+ 1 - 1
pkg/generator/fake/fake_test.go

@@ -79,7 +79,7 @@ func TestGenerate(t *testing.T) {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			g := &Generator{}
-			got, err := g.Generate(tt.args.ctx, tt.args.jsonSpec, tt.args.kube, tt.args.namespace)
+			got, _, err := g.Generate(tt.args.ctx, tt.args.jsonSpec, tt.args.kube, tt.args.namespace)
 			if (err != nil) != tt.wantErr {
 				t.Errorf("Generator.Generate() error = %v, wantErr %v", err, tt.wantErr)
 				return

+ 90 - 0
pkg/generator/gc/gc.go

@@ -0,0 +1,90 @@
+/*
+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 gc
+
+import (
+	"context"
+	"crypto/sha256"
+	"fmt"
+	"time"
+
+	"github.com/go-co-op/gocron/v2"
+	"github.com/spf13/pflag"
+	apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	genv1alpha1 "github.com/external-secrets/external-secrets/apis/generators/v1alpha1"
+	"github.com/external-secrets/external-secrets/pkg/feature"
+)
+
+var gcGracePeriod time.Duration
+var log = ctrl.Log.WithName("generator-gc")
+var scheduler gocron.Scheduler
+
+func init() {
+	fs := pflag.NewFlagSet("gc", pflag.ExitOnError)
+	fs.DurationVar(&gcGracePeriod, "generator-gc-grace-period", time.Minute*2, "Duration after which generated secrets are cleaned up after they have been flagged for gc.")
+	feature.Register(feature.Feature{
+		Flags: fs,
+	})
+	var err error
+	scheduler, err = gocron.NewScheduler()
+	if err != nil {
+		panic(err)
+	}
+	scheduler.Start()
+}
+
+type Entry struct {
+	Resource *apiextensions.JSON
+	Impl     genv1alpha1.Generator
+	State    genv1alpha1.GeneratorProviderState
+}
+
+func (e Entry) Key() string {
+	h := sha256.New()
+	h.Write(e.Resource.Raw)
+	hash := h.Sum(e.State.Raw)
+	return fmt.Sprintf("%x", hash)
+}
+
+func Enqueue(e Entry) error {
+	log.V(1).Info("putting state into GC", "entry", e)
+	_, err := scheduler.NewJob(
+		gocron.OneTimeJob(gocron.OneTimeJobStartDateTime(time.Now().Add(gcGracePeriod))),
+		gocron.NewTask(func(entry Entry) {
+			err := entry.Impl.Cleanup(context.Background(), entry.Resource, entry.State, nil, "")
+			if err != nil {
+				log.Error(err, "failed to cleanup generator secret", "generator", entry.Resource)
+			}
+		}, e))
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
+func Cleanup(ctx context.Context, flaggedForGC time.Time, entry Entry, kclient client.Client, ns string) (bool, error) {
+	log.V(1).Info("cleaning up generator", "entry", entry)
+	if flaggedForGC.Add(gcGracePeriod).After(time.Now()) {
+		log.V(1).Info("generator is not ready for cleanup", "entry", entry)
+		return false, nil
+	}
+	err := entry.Impl.Cleanup(context.Background(), entry.Resource, entry.State, kclient, ns)
+	if err != nil {
+		return false, err
+	}
+	return true, nil
+}

+ 11 - 7
pkg/generator/gcr/gcr.go

@@ -41,7 +41,7 @@ const (
 	errGetToken  = "unable to get authorization token: %w"
 )
 
-func (g *Generator) Generate(ctx context.Context, jsonSpec *apiextensions.JSON, kube client.Client, namespace string) (map[string][]byte, error) {
+func (g *Generator) Generate(ctx context.Context, jsonSpec *apiextensions.JSON, kube client.Client, namespace string) (map[string][]byte, genv1alpha1.GeneratorProviderState, error) {
 	return g.generate(
 		ctx,
 		jsonSpec,
@@ -51,36 +51,40 @@ func (g *Generator) Generate(ctx context.Context, jsonSpec *apiextensions.JSON,
 	)
 }
 
+func (g *Generator) Cleanup(ctx context.Context, jsonSpec *apiextensions.JSON, _ genv1alpha1.GeneratorProviderState, crClient client.Client, namespace string) error {
+	return nil
+}
+
 func (g *Generator) generate(
 	ctx context.Context,
 	jsonSpec *apiextensions.JSON,
 	kube client.Client,
 	namespace string,
-	tokenSource tokenSourceFunc) (map[string][]byte, error) {
+	tokenSource tokenSourceFunc) (map[string][]byte, genv1alpha1.GeneratorProviderState, error) {
 	if jsonSpec == nil {
-		return nil, errors.New(errNoSpec)
+		return nil, nil, errors.New(errNoSpec)
 	}
 	res, err := parseSpec(jsonSpec.Raw)
 	if err != nil {
-		return nil, fmt.Errorf(errParseSpec, err)
+		return nil, nil, fmt.Errorf(errParseSpec, err)
 	}
 	ts, err := tokenSource(ctx, esv1beta1.GCPSMAuth{
 		SecretRef:        (*esv1beta1.GCPSMAuthSecretRef)(res.Spec.Auth.SecretRef),
 		WorkloadIdentity: (*esv1beta1.GCPWorkloadIdentity)(res.Spec.Auth.WorkloadIdentity),
 	}, res.Spec.ProjectID, resolvers.EmptyStoreKind, kube, namespace)
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 	token, err := ts.Token()
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 	exp := strconv.FormatInt(token.Expiry.UTC().Unix(), 10)
 	return map[string][]byte{
 		"username": []byte(defaultLoginUsername),
 		"password": []byte(token.AccessToken),
 		"expiry":   []byte(exp),
-	}, nil
+	}, nil, nil
 }
 
 type tokenSourceFunc func(ctx context.Context, auth esv1beta1.GCPSMAuth, projectID string, storeKind string, kube client.Client, namespace string) (oauth2.TokenSource, error)

+ 1 - 1
pkg/generator/gcr/gcr_test.go

@@ -94,7 +94,7 @@ spec:
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			g := &Generator{}
-			got, err := g.generate(
+			got, _, err := g.generate(
 				tt.args.ctx,
 				tt.args.jsonSpec,
 				tt.args.kube,

+ 14 - 10
pkg/generator/github/github.go

@@ -60,7 +60,7 @@ const (
 	httpClientTimeout = 5 * time.Second
 )
 
-func (g *Generator) Generate(ctx context.Context, jsonSpec *apiextensions.JSON, kube client.Client, namespace string) (map[string][]byte, error) {
+func (g *Generator) Generate(ctx context.Context, jsonSpec *apiextensions.JSON, kube client.Client, namespace string) (map[string][]byte, genv1alpha1.GeneratorProviderState, error) {
 	return g.generate(
 		ctx,
 		jsonSpec,
@@ -69,20 +69,24 @@ func (g *Generator) Generate(ctx context.Context, jsonSpec *apiextensions.JSON,
 	)
 }
 
+func (g *Generator) Cleanup(ctx context.Context, jsonSpec *apiextensions.JSON, _ genv1alpha1.GeneratorProviderState, crClient client.Client, namespace string) error {
+	return nil
+}
+
 func (g *Generator) generate(
 	ctx context.Context,
 	jsonSpec *apiextensions.JSON,
 	kube client.Client,
-	namespace string) (map[string][]byte, error) {
+	namespace string) (map[string][]byte, genv1alpha1.GeneratorProviderState, error) {
 	if jsonSpec == nil {
-		return nil, errors.New(errNoSpec)
+		return nil, nil, errors.New(errNoSpec)
 	}
 	ctx, cancel := context.WithTimeout(ctx, contextTimeout)
 	defer cancel()
 
 	gh, err := newGHClient(ctx, kube, namespace, g.httpClient, jsonSpec)
 	if err != nil {
-		return nil, fmt.Errorf("error creating request: %w", err)
+		return nil, nil, fmt.Errorf("error creating request: %w", err)
 	}
 
 	payload := make(map[string]interface{})
@@ -97,7 +101,7 @@ func (g *Generator) generate(
 	if len(payload) > 0 {
 		bodyBytes, err := json.Marshal(payload)
 		if err != nil {
-			return nil, fmt.Errorf("error marshaling payload: %w", err)
+			return nil, nil, fmt.Errorf("error marshaling payload: %w", err)
 		}
 
 		body = bytes.NewReader(bodyBytes)
@@ -106,30 +110,30 @@ func (g *Generator) generate(
 	// Github api expects POST request
 	req, err := http.NewRequestWithContext(ctx, http.MethodPost, gh.URL, body)
 	if err != nil {
-		return nil, fmt.Errorf("error creating request: %w", err)
+		return nil, nil, fmt.Errorf("error creating request: %w", err)
 	}
 	req.Header.Add("Authorization", "Bearer "+gh.InstallTkn)
 	req.Header.Add("Accept", "application/vnd.github.v3+json")
 
 	resp, err := gh.HTTP.Do(req)
 	if err != nil {
-		return nil, fmt.Errorf("error performing request: %w", err)
+		return nil, nil, fmt.Errorf("error performing request: %w", err)
 	}
 	defer resp.Body.Close()
 
 	// git access token
 	var gat map[string]any
 	if err := json.NewDecoder(resp.Body).Decode(&gat); err != nil && resp.StatusCode >= 200 && resp.StatusCode < 300 {
-		return nil, fmt.Errorf("error decoding response: %w", err)
+		return nil, nil, fmt.Errorf("error decoding response: %w", err)
 	}
 
 	accessToken, ok := gat["token"].(string)
 	if !ok {
-		return nil, errors.New("token isn't a string or token key doesn't exist")
+		return nil, nil, errors.New("token isn't a string or token key doesn't exist")
 	}
 	return map[string][]byte{
 		defaultLoginUsername: []byte(accessToken),
-	}, nil
+	}, nil, nil
 }
 
 func newGHClient(ctx context.Context, k client.Client, n string, hc *http.Client,

+ 1 - 1
pkg/generator/github/github_test.go

@@ -128,7 +128,7 @@ spec:
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			g := &Generator{httpClient: server.Client()}
-			got, err := g.generate(
+			got, _, err := g.generate(
 				tt.args.ctx,
 				tt.args.jsonSpec,
 				tt.args.kube,

+ 189 - 0
pkg/generator/grafana/grafana.go

@@ -0,0 +1,189 @@
+/*
+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 grafana
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"strings"
+
+	"github.com/google/uuid"
+	grafanaclient "github.com/grafana/grafana-openapi-client-go/client"
+	grafanasa "github.com/grafana/grafana-openapi-client-go/client/service_accounts"
+	"github.com/grafana/grafana-openapi-client-go/models"
+	apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+	"k8s.io/utils/ptr"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	genv1alpha1 "github.com/external-secrets/external-secrets/apis/generators/v1alpha1"
+	esmeta "github.com/external-secrets/external-secrets/apis/meta/v1"
+	"github.com/external-secrets/external-secrets/pkg/utils/resolvers"
+)
+
+type Grafana struct{}
+
+func (w *Grafana) Generate(ctx context.Context, jsonSpec *apiextensions.JSON, kclient client.Client, ns string) (map[string][]byte, genv1alpha1.GeneratorProviderState, error) {
+	gen, err := parseSpec(jsonSpec.Raw)
+	if err != nil {
+		return nil, nil, err
+	}
+	secret, err := resolvers.SecretKeyRef(ctx, kclient, resolvers.EmptyStoreKind, ns, &esmeta.SecretKeySelector{
+		Namespace: &ns,
+		Name:      gen.Spec.Auth.Token.Name,
+		Key:       gen.Spec.Auth.Token.Key,
+	})
+	if err != nil {
+		return nil, nil, err
+	}
+	url := strings.TrimPrefix(gen.Spec.URL, "https://")
+	cfg := &grafanaclient.TransportConfig{
+		Host:     url,
+		BasePath: "/api",
+		Schemes:  []string{"https"},
+		APIKey:   secret,
+	}
+
+	cl := grafanaclient.NewHTTPClientWithConfig(nil, cfg)
+	state, err := createOrGetServiceAccount(cl, gen)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	// create new token
+	res, err := cl.ServiceAccounts.CreateToken(&grafanasa.CreateTokenParams{
+		ServiceAccountID: *state.ServiceAccount.ServiceAccountID,
+		Body: &models.AddServiceAccountTokenCommand{
+			Name: uuid.New().String(),
+		},
+	}, nil)
+	if err != nil {
+		return nil, nil, err
+	}
+	state.ServiceAccount.ServiceAccountTokenID = ptr.To(res.Payload.ID)
+	return tokenResponse(state, res.Payload.Key)
+}
+
+func (w *Grafana) Cleanup(ctx context.Context, jsonSpec *apiextensions.JSON, previousStatus genv1alpha1.GeneratorProviderState, kclient client.Client, ns string) error {
+	if previousStatus == nil {
+		return fmt.Errorf("missing previous status")
+	}
+	status, err := parseStatus(previousStatus.Raw)
+	if err != nil {
+		return err
+	}
+	gen, err := parseSpec(jsonSpec.Raw)
+	if err != nil {
+		return err
+	}
+	cl, err := newClient(ctx, gen, kclient, ns)
+	if err != nil {
+		return err
+	}
+	_, err = cl.ServiceAccounts.DeleteToken(*status.ServiceAccount.ServiceAccountTokenID, *status.ServiceAccount.ServiceAccountID)
+	if err != nil && !strings.Contains(err.Error(), "service account token not found") {
+		return err
+	}
+	return nil
+}
+
+func newClient(ctx context.Context, gen *genv1alpha1.Grafana, kclient client.Client, ns string) (*grafanaclient.GrafanaHTTPAPI, error) {
+	secret, err := resolvers.SecretKeyRef(ctx, kclient, resolvers.EmptyStoreKind, ns, &esmeta.SecretKeySelector{
+		Namespace: &ns,
+		Name:      gen.Spec.Auth.Token.Name,
+		Key:       gen.Spec.Auth.Token.Key,
+	})
+	if err != nil {
+		return nil, err
+	}
+	url := strings.TrimPrefix(gen.Spec.URL, "https://")
+	cfg := &grafanaclient.TransportConfig{
+		Host:     url,
+		BasePath: "/api",
+		Schemes:  []string{"https"},
+		APIKey:   secret,
+	}
+	return grafanaclient.NewHTTPClientWithConfig(nil, cfg), nil
+}
+
+func createOrGetServiceAccount(cl *grafanaclient.GrafanaHTTPAPI, gen *genv1alpha1.Grafana) (*genv1alpha1.GrafanaServiceAccountTokenState, error) {
+	res, err := cl.ServiceAccounts.CreateServiceAccount(&grafanasa.CreateServiceAccountParams{
+		Body: &models.CreateServiceAccountForm{
+			Name: gen.Spec.ServiceAccount.Name,
+		},
+	}, nil)
+	if err == nil {
+		return &genv1alpha1.GrafanaServiceAccountTokenState{
+			ServiceAccount: genv1alpha1.GrafanaStateServiceAccount{
+				ServiceAccountID:    ptr.To(res.Payload.ID),
+				ServiceAccountLogin: &res.Payload.Login,
+			},
+		}, nil
+	}
+
+	if strings.Contains(err.Error(), "service account already exists") {
+		// fetch id
+		saList, err := cl.ServiceAccounts.SearchOrgServiceAccountsWithPaging(&grafanasa.SearchOrgServiceAccountsWithPagingParams{
+			Perpage: ptr.To(int64(100)),
+			Page:    ptr.To(int64(1)),
+			Query:   ptr.To(gen.Spec.ServiceAccount.Name),
+		})
+		if err != nil {
+			return nil, err
+		}
+		for _, sa := range saList.Payload.ServiceAccounts {
+			if sa.Name == gen.Spec.ServiceAccount.Name {
+				return &genv1alpha1.GrafanaServiceAccountTokenState{
+					ServiceAccount: genv1alpha1.GrafanaStateServiceAccount{
+						ServiceAccountID:    &sa.ID,
+						ServiceAccountLogin: &sa.Login,
+					},
+				}, nil
+			}
+		}
+	}
+
+	return nil, err
+}
+
+func tokenResponse(state *genv1alpha1.GrafanaServiceAccountTokenState, token string) (map[string][]byte, genv1alpha1.GeneratorProviderState, error) {
+	newStateJSON, err := json.Marshal(state)
+	if err != nil {
+		return nil, nil, err
+	}
+	return map[string][]byte{
+		"login": []byte(*state.ServiceAccount.ServiceAccountLogin),
+		"token": []byte(token),
+	}, &apiextensions.JSON{Raw: newStateJSON}, nil
+}
+
+func parseSpec(data []byte) (*genv1alpha1.Grafana, error) {
+	var spec genv1alpha1.Grafana
+	err := json.Unmarshal(data, &spec)
+	return &spec, err
+}
+
+func parseStatus(data []byte) (*genv1alpha1.GrafanaServiceAccountTokenState, error) {
+	var state genv1alpha1.GrafanaServiceAccountTokenState
+	err := json.Unmarshal(data, &state)
+	if err != nil {
+		return nil, err
+	}
+	return &state, err
+}
+
+func init() {
+	genv1alpha1.Register(genv1alpha1.GrafanaKind, &Grafana{})
+}

+ 10 - 6
pkg/generator/password/password.go

@@ -49,20 +49,24 @@ type generateFunc func(
 	allowRepeat bool,
 ) (string, error)
 
-func (g *Generator) Generate(_ context.Context, jsonSpec *apiextensions.JSON, _ client.Client, _ string) (map[string][]byte, error) {
+func (g *Generator) Generate(_ context.Context, jsonSpec *apiextensions.JSON, _ client.Client, _ string) (map[string][]byte, genv1alpha1.GeneratorProviderState, error) {
 	return g.generate(
 		jsonSpec,
 		generateSafePassword,
 	)
 }
 
-func (g *Generator) generate(jsonSpec *apiextensions.JSON, passGen generateFunc) (map[string][]byte, error) {
+func (g *Generator) Cleanup(_ context.Context, jsonSpec *apiextensions.JSON, state genv1alpha1.GeneratorProviderState, _ client.Client, _ string) error {
+	return nil
+}
+
+func (g *Generator) generate(jsonSpec *apiextensions.JSON, passGen generateFunc) (map[string][]byte, genv1alpha1.GeneratorProviderState, error) {
 	if jsonSpec == nil {
-		return nil, errors.New(errNoSpec)
+		return nil, nil, errors.New(errNoSpec)
 	}
 	res, err := parseSpec(jsonSpec.Raw)
 	if err != nil {
-		return nil, fmt.Errorf(errParseSpec, err)
+		return nil, nil, fmt.Errorf(errParseSpec, err)
 	}
 	symbolCharacters := defaultSymbolChars
 	if res.Spec.SymbolCharacters != nil {
@@ -89,11 +93,11 @@ func (g *Generator) generate(jsonSpec *apiextensions.JSON, passGen generateFunc)
 		res.Spec.AllowRepeat,
 	)
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 	return map[string][]byte{
 		"password": []byte(pass),
-	}, nil
+	}, nil, nil
 }
 
 func generateSafePassword(

+ 1 - 1
pkg/generator/password/password_test.go

@@ -112,7 +112,7 @@ func TestGenerate(t *testing.T) {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			g := &Generator{}
-			got, err := g.generate(tt.args.jsonSpec, tt.args.passGen)
+			got, _, err := g.generate(tt.args.jsonSpec, tt.args.passGen)
 			if (err != nil) != tt.wantErr {
 				t.Errorf("Generator.Generate() error = %v, wantErr %v", err, tt.wantErr)
 				return

+ 1 - 0
pkg/generator/register/register.go

@@ -22,6 +22,7 @@ import (
 	_ "github.com/external-secrets/external-secrets/pkg/generator/fake"
 	_ "github.com/external-secrets/external-secrets/pkg/generator/gcr"
 	_ "github.com/external-secrets/external-secrets/pkg/generator/github"
+	_ "github.com/external-secrets/external-secrets/pkg/generator/grafana"
 	_ "github.com/external-secrets/external-secrets/pkg/generator/password"
 	_ "github.com/external-secrets/external-secrets/pkg/generator/sts"
 	_ "github.com/external-secrets/external-secrets/pkg/generator/uuid"

+ 250 - 0
pkg/generator/statemanager/statemanager.go

@@ -0,0 +1,250 @@
+/*
+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 statemanager
+
+import (
+	"context"
+	"errors"
+	"fmt"
+
+	"github.com/google/uuid"
+	apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	"github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1"
+	"github.com/external-secrets/external-secrets/apis/generators/v1alpha1"
+	"github.com/external-secrets/external-secrets/pkg/generator/gc"
+	"github.com/external-secrets/external-secrets/pkg/utils/resolvers"
+)
+
+// Manager takes care of maintaining the state of the generators.
+// It provides the ability to commit and rollback the state of the generators,
+// which is needed when we have multiple generators that need to be created or
+// other operations which can fail.
+// The manager shall be used to modify the state of the generators on a given resource.
+// The user can choose any key to store the state of the generator on the "latest" field.
+// When state is moved to GC, manager will create a hash of the key and the generator state
+// and store it in the "GC" field.
+type Manager struct {
+	scheme        *runtime.Scheme
+	client        client.Client
+	namespace     string
+	resource      v1beta1.GeneratorStateManagingResource
+	internalState []QueueItem
+}
+
+type QueueItem struct {
+	Rollback func() error
+	Commit   func() error
+}
+
+func New(client client.Client, scheme *runtime.Scheme, namespace string,
+	resource v1beta1.GeneratorStateManagingResource) *Manager {
+	return &Manager{
+		scheme:    scheme,
+		client:    client,
+		namespace: namespace,
+		resource:  resource,
+	}
+}
+
+// Rollback will rollback the enqueued operations.
+func (m *Manager) Rollback() error {
+	var errs []error
+	for _, item := range m.internalState {
+		if err := item.Rollback(); err != nil {
+			errs = append(errs, err)
+		}
+	}
+	return errors.Join(errs...)
+}
+
+// Commit will apply the enqueued changes to the state of the generators.
+func (m *Manager) Commit() error {
+	var errs []error
+	for _, item := range m.internalState {
+		if err := item.Commit(); err != nil {
+			errs = append(errs, err)
+		}
+	}
+	return errors.Join(errs...)
+}
+
+// EnqueueFlagLatestStateForGC will flag the latest state for garbage collection after Commit.
+// It will be cleaned up later by the garbage collector.
+func (m *Manager) EnqueueFlagLatestStateForGC(stateKey string) {
+	m.internalState = append(m.internalState, QueueItem{
+		Commit: func() error {
+			genState := m.resource.GetGeneratorState()
+			if genState.Latest == nil {
+				return nil
+			}
+			latest, ok := genState.Latest[stateKey]
+			if !ok {
+				return nil
+			}
+			gen, err := m.getGenerator(latest.Resource.Raw)
+			if err != nil {
+				return err
+			}
+			return m.moveStateToGC(latest.Resource, stateKey, gen, latest.State)
+		},
+	})
+}
+
+func (m *Manager) getGenerator(resource []byte) (v1alpha1.Generator, error) {
+	us := &unstructured.Unstructured{}
+	if err := us.UnmarshalJSON(resource); err != nil {
+		return nil, fmt.Errorf("unable to unmarshal resource: %w", err)
+	}
+	ref := v1beta1.GeneratorRef{
+		APIVersion: us.GetAPIVersion(),
+		Kind:       us.GetKind(),
+		Name:       us.GetName(),
+	}
+	gen, _, err := resolvers.GeneratorRef(context.TODO(), m.client, m.scheme, m.namespace, &ref)
+	return gen, err
+}
+
+// EnqueueMoveStateToGC will move the generator state to GC if Commit() is called.
+func (m *Manager) EnqueueMoveStateToGC(resource *apiextensions.JSON, stateKey string, gen v1alpha1.Generator, state v1alpha1.GeneratorProviderState) {
+	m.internalState = append(m.internalState, QueueItem{
+		Commit: func() error {
+			return m.moveStateToGC(resource, stateKey, gen, state)
+		},
+	})
+}
+
+// moveStateToGC moves the generator state to GC and enqueues it for cleanup.
+func (m *Manager) moveStateToGC(resource *apiextensions.JSON, stateKey string, gen v1alpha1.Generator, state v1alpha1.GeneratorProviderState) error {
+	genState := m.resource.GetGeneratorState()
+	entry := gc.Entry{
+		Resource: resource,
+		Impl:     gen,
+		State:    state,
+	}
+	if err := gc.Enqueue(entry); err != nil {
+		return fmt.Errorf("unable to enqueue generator state for GC: %w", err)
+	}
+	if genState.GC == nil {
+		genState.GC = make(map[string]*v1beta1.GeneratorGCState)
+	}
+	genState.GC[gcGeneratorStateKey(entry, stateKey)] = &v1beta1.GeneratorGCState{
+		Resource:         resource,
+		State:            state,
+		FlaggedForGCTime: metav1.Now(),
+	}
+	return nil
+}
+
+func gcGeneratorStateKey(entry gc.Entry, key string) string {
+	return fmt.Sprintf("[%s]-%s", key, entry.Key())
+}
+
+// EnqueueSetLatest sets the latest state for the given key.
+// It will commit the state on success or move the state to GC on failure.
+func (m *Manager) EnqueueSetLatest(ctx context.Context, kubeClient client.Client, stateKey, namespace string, resource *apiextensions.JSON, gen v1alpha1.Generator, state v1alpha1.GeneratorProviderState) {
+	m.internalState = append(m.internalState, QueueItem{
+		// Store state at .Latest[<key>] on success
+		// or attempt to immediately delete the state on failure
+		Commit: func() error {
+			genState := m.resource.GetGeneratorState()
+			if genState.Latest == nil {
+				genState.Latest = make(map[string]*v1beta1.GeneratorResourceState)
+			}
+			genState.Latest[stateKey] = &v1beta1.GeneratorResourceState{
+				Resource: resource,
+				State:    state,
+			}
+			return nil
+		},
+		// Rollback by cleaning up the state.
+		// In case of failure, move the state to GC so it will be cleaned up later.
+		Rollback: func() error {
+			err := gen.Cleanup(ctx, resource, state, kubeClient, namespace)
+			if err == nil {
+				return nil
+			}
+			return m.moveStateToGC(resource, fmt.Sprintf("rollback-%s", uuid.New().String()), gen, state)
+		},
+	})
+}
+
+// GetLatest returns the latest state for the given key.
+func (m *Manager) GetLatest(key string) *apiextensions.JSON {
+	state := m.resource.GetGeneratorState()
+	if state.Latest == nil {
+		return nil
+	}
+	latest := state.Latest[key]
+	if latest == nil {
+		return nil
+	}
+	return latest.State
+}
+
+// CleanupImmediate will cleanup the generator state immediately.
+// This is useful when we want to cleanup the state immediately after deletion of the resource.
+func (m *Manager) CleanupImmediate(ctx context.Context, resource v1beta1.GeneratorStateManagingResource, kubeClient client.Client, namespace string) error {
+	var errs []error
+	generatorState := resource.GetGeneratorState()
+	for _, gcState := range generatorState.GC {
+		gen, err := m.getGenerator(gcState.Resource.Raw)
+		if err != nil {
+			errs = append(errs, fmt.Errorf("unable to get generator: %w", err))
+			continue
+		}
+		err = gen.Cleanup(ctx, gcState.Resource, gcState.State, kubeClient, namespace)
+		if err != nil {
+			errs = append(errs, fmt.Errorf("failed to cleanup generator state: %w", err))
+		}
+	}
+	return errors.Join(errs...)
+}
+
+// GarbageCollect will cleanup the generator state that is flagged for GC.
+// It updates the generator state with the new GC state.
+// If an error occurs during cleanup of a generator state,
+// it will be aggregated and returned at the end but the cleanup will continue for the remaining generator states.
+func (m *Manager) GarbageCollect(ctx context.Context, kubeClient client.Client, namespace string) error {
+	var errs []error
+	generatorState := m.resource.GetGeneratorState()
+	newGCState := make(map[string]*v1beta1.GeneratorGCState)
+	for idx, gcState := range generatorState.GC {
+		gen, err := m.getGenerator(gcState.Resource.Raw)
+		if err != nil {
+			errs = append(errs, fmt.Errorf("unable to get generator: %w", err))
+			continue
+		}
+		deleted, err := gc.Cleanup(ctx, gcState.FlaggedForGCTime.Time, gc.Entry{
+			Resource: gcState.Resource,
+			Impl:     gen,
+			State:    gcState.State,
+		}, kubeClient, namespace)
+		if err != nil {
+			errs = append(errs, fmt.Errorf("failed to cleanup generator state: %w", err))
+			continue
+		}
+		if !deleted {
+			newGCState[idx] = gcState
+		}
+	}
+	generatorState.GC = newGCState
+	m.resource.SetGeneratorState(*generatorState)
+	return errors.Join(errs...)
+}

+ 12 - 8
pkg/generator/sts/sts.go

@@ -41,7 +41,7 @@ const (
 	errGetToken   = "unable to get authorization token: %w"
 )
 
-func (g *Generator) Generate(ctx context.Context, jsonSpec *apiextensions.JSON, kube client.Client, namespace string) (map[string][]byte, error) {
+func (g *Generator) Generate(ctx context.Context, jsonSpec *apiextensions.JSON, kube client.Client, namespace string) (map[string][]byte, genv1alpha1.GeneratorProviderState, error) {
 	return g.generate(ctx, jsonSpec, kube, namespace, stsFactory)
 }
 
@@ -51,13 +51,13 @@ func (g *Generator) generate(
 	kube client.Client,
 	namespace string,
 	stsFunc stsFactoryFunc,
-) (map[string][]byte, error) {
+) (map[string][]byte, genv1alpha1.GeneratorProviderState, error) {
 	if jsonSpec == nil {
-		return nil, errors.New(errNoSpec)
+		return nil, nil, errors.New(errNoSpec)
 	}
 	res, err := parseSpec(jsonSpec.Raw)
 	if err != nil {
-		return nil, fmt.Errorf(errParseSpec, err)
+		return nil, nil, fmt.Errorf(errParseSpec, err)
 	}
 	sess, err := awsauth.NewGeneratorSession(
 		ctx,
@@ -72,7 +72,7 @@ func (g *Generator) generate(
 		awsauth.DefaultSTSProvider,
 		awsauth.DefaultJWTProvider)
 	if err != nil {
-		return nil, fmt.Errorf(errCreateSess, err)
+		return nil, nil, fmt.Errorf(errCreateSess, err)
 	}
 	client := stsFunc(sess)
 	input := &sts.GetSessionTokenInput{}
@@ -83,10 +83,10 @@ func (g *Generator) generate(
 	}
 	out, err := client.GetSessionToken(input)
 	if err != nil {
-		return nil, fmt.Errorf(errGetToken, err)
+		return nil, nil, fmt.Errorf(errGetToken, err)
 	}
 	if out.Credentials == nil {
-		return nil, errors.New("no credentials found")
+		return nil, nil, errors.New("no credentials found")
 	}
 
 	return map[string][]byte{
@@ -94,7 +94,11 @@ func (g *Generator) generate(
 		"expiration":        []byte(strconv.FormatInt(out.Credentials.Expiration.Unix(), 10)),
 		"secret_access_key": []byte(*out.Credentials.SecretAccessKey),
 		"session_token":     []byte(*out.Credentials.SessionToken),
-	}, nil
+	}, nil, nil
+}
+
+func (g *Generator) Cleanup(_ context.Context, jsonSpec *apiextensions.JSON, state genv1alpha1.GeneratorProviderState, _ client.Client, _ string) error {
+	return nil
 }
 
 type stsFactoryFunc func(aws *session.Session) stsiface.STSAPI

+ 1 - 1
pkg/generator/sts/sts_test.go

@@ -119,7 +119,7 @@ spec:
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			g := &Generator{}
-			got, err := g.generate(
+			got, _, err := g.generate(
 				tt.args.ctx,
 				tt.args.jsonSpec,
 				tt.args.kube,

+ 8 - 4
pkg/generator/uuid/uuid.go

@@ -29,21 +29,25 @@ type Generator struct{}
 
 type generateFunc func() (string, error)
 
-func (g *Generator) Generate(_ context.Context, jsonSpec *apiextensions.JSON, _ client.Client, _ string) (map[string][]byte, error) {
+func (g *Generator) Generate(_ context.Context, jsonSpec *apiextensions.JSON, _ client.Client, _ string) (map[string][]byte, genv1alpha1.GeneratorProviderState, error) {
 	return g.generate(
 		jsonSpec,
 		generateUUID,
 	)
 }
 
-func (g *Generator) generate(_ *apiextensions.JSON, uuidGen generateFunc) (map[string][]byte, error) {
+func (g *Generator) Cleanup(_ context.Context, jsonSpec *apiextensions.JSON, state genv1alpha1.GeneratorProviderState, _ client.Client, _ string) error {
+	return nil
+}
+
+func (g *Generator) generate(_ *apiextensions.JSON, uuidGen generateFunc) (map[string][]byte, genv1alpha1.GeneratorProviderState, error) {
 	uuid, err := uuidGen()
 	if err != nil {
-		return nil, fmt.Errorf("unable to generate UUID: %w", err)
+		return nil, nil, fmt.Errorf("unable to generate UUID: %w", err)
 	}
 	return map[string][]byte{
 		"uuid": []byte(uuid),
-	}, nil
+	}, nil, nil
 }
 
 func generateUUID() (string, error) {

+ 1 - 1
pkg/generator/uuid/uuid_test.go

@@ -49,7 +49,7 @@ func TestGenerate(t *testing.T) {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			g := &Generator{}
-			got, err := g.generate(tt.args.jsonSpec, generateUUID)
+			got, _, err := g.generate(tt.args.jsonSpec, generateUUID)
 			if (err != nil) != tt.wantErr {
 				t.Errorf("Generator.Generate() error = %v, wantErr %v", err, tt.wantErr)
 				return

+ 19 - 15
pkg/generator/vault/vault.go

@@ -42,7 +42,7 @@ const (
 	errGetSecret   = "unable to get dynamic secret: %w"
 )
 
-func (g *Generator) Generate(ctx context.Context, jsonSpec *apiextensions.JSON, kube client.Client, namespace string) (map[string][]byte, error) {
+func (g *Generator) Generate(ctx context.Context, jsonSpec *apiextensions.JSON, kube client.Client, namespace string) (map[string][]byte, genv1alpha1.GeneratorProviderState, error) {
 	c := &provider.Provider{NewVaultClient: provider.NewVaultClient}
 
 	// controller-runtime/client does not support TokenRequest or other subresource APIs
@@ -50,30 +50,34 @@ func (g *Generator) Generate(ctx context.Context, jsonSpec *apiextensions.JSON,
 	// (for Kubernetes service account token auth)
 	restCfg, err := ctrlcfg.GetConfig()
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 	clientset, err := kubernetes.NewForConfig(restCfg)
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 
 	return g.generate(ctx, c, jsonSpec, kube, clientset.CoreV1(), namespace)
 }
 
-func (g *Generator) generate(ctx context.Context, c *provider.Provider, jsonSpec *apiextensions.JSON, kube client.Client, corev1 typedcorev1.CoreV1Interface, namespace string) (map[string][]byte, error) {
+func (g *Generator) Cleanup(_ context.Context, jsonSpec *apiextensions.JSON, state genv1alpha1.GeneratorProviderState, _ client.Client, _ string) error {
+	return nil
+}
+
+func (g *Generator) generate(ctx context.Context, c *provider.Provider, jsonSpec *apiextensions.JSON, kube client.Client, corev1 typedcorev1.CoreV1Interface, namespace string) (map[string][]byte, genv1alpha1.GeneratorProviderState, error) {
 	if jsonSpec == nil {
-		return nil, errors.New(errNoSpec)
+		return nil, nil, errors.New(errNoSpec)
 	}
 	res, err := parseSpec(jsonSpec.Raw)
 	if err != nil {
-		return nil, fmt.Errorf(errParseSpec, err)
+		return nil, nil, fmt.Errorf(errParseSpec, err)
 	}
 	if res == nil || res.Spec.Provider == nil {
-		return nil, errors.New("no Vault provider config in spec")
+		return nil, nil, errors.New("no Vault provider config in spec")
 	}
 	cl, err := c.NewGeneratorClient(ctx, kube, corev1, res.Spec.Provider, namespace, res.Spec.RetrySettings)
 	if err != nil {
-		return nil, fmt.Errorf(errVaultClient, err)
+		return nil, nil, fmt.Errorf(errVaultClient, err)
 	}
 
 	var result *vault.Secret
@@ -88,16 +92,16 @@ func (g *Generator) generate(ctx context.Context, c *provider.Provider, jsonSpec
 		if res.Spec.Parameters != nil {
 			err = json.Unmarshal(res.Spec.Parameters.Raw, &params)
 			if err != nil {
-				return nil, err
+				return nil, nil, err
 			}
 		}
 		result, err = cl.Logical().WriteWithContext(ctx, res.Spec.Path, params)
 	}
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 	if result == nil {
-		return nil, fmt.Errorf(errGetSecret, errors.New("empty response from Vault"))
+		return nil, nil, fmt.Errorf(errGetSecret, errors.New("empty response from Vault"))
 	}
 
 	data := make(map[string]any)
@@ -105,11 +109,11 @@ func (g *Generator) generate(ctx context.Context, c *provider.Provider, jsonSpec
 	if res.Spec.ResultType == genv1alpha1.VaultDynamicSecretResultTypeAuth {
 		authJSON, err := json.Marshal(result.Auth)
 		if err != nil {
-			return nil, err
+			return nil, nil, err
 		}
 		err = json.Unmarshal(authJSON, &data)
 		if err != nil {
-			return nil, err
+			return nil, nil, err
 		}
 	} else {
 		data = result.Data
@@ -118,10 +122,10 @@ func (g *Generator) generate(ctx context.Context, c *provider.Provider, jsonSpec
 	for k := range data {
 		response[k], err = utils.GetByteValueFromMap(data, k)
 		if err != nil {
-			return nil, err
+			return nil, nil, err
 		}
 	}
-	return response, nil
+	return response, nil, nil
 }
 
 func parseSpec(data []byte) (*genv1alpha1.VaultDynamicSecret, error) {

+ 1 - 1
pkg/generator/vault/vault_test.go

@@ -167,7 +167,7 @@ spec:
 		t.Run(name, func(t *testing.T) {
 			c := &provider.Provider{NewVaultClient: fake.ClientWithLoginMock}
 			gen := &Generator{}
-			val, err := gen.generate(context.Background(), c, tc.args.jsonSpec, tc.args.kube, tc.args.corev1, "testing")
+			val, _, err := gen.generate(context.Background(), c, tc.args.jsonSpec, tc.args.kube, tc.args.corev1, "testing")
 			if diff := cmp.Diff(tc.want.err.Error(), err.Error()); diff != "" {
 				t.Errorf("\n%s\nvault.GetSecret(...): -want error, +got error:\n%s", tc.reason, diff)
 			}

+ 9 - 4
pkg/generator/webhook/webhook.go

@@ -31,22 +31,27 @@ type Webhook struct {
 	url string
 }
 
-func (w *Webhook) Generate(ctx context.Context, jsonSpec *apiextensions.JSON, kclient client.Client, ns string) (map[string][]byte, error) {
+func (w *Webhook) Generate(ctx context.Context, jsonSpec *apiextensions.JSON, kclient client.Client, ns string) (map[string][]byte, genv1alpha1.GeneratorProviderState, error) {
 	w.wh.EnforceLabels = true
 	w.wh.ClusterScoped = false
 	provider, err := parseSpec(jsonSpec.Raw)
 	w.wh = webhook.Webhook{}
 	if err != nil {
-		return nil, fmt.Errorf("failed to parse provider spec: %w", err)
+		return nil, nil, fmt.Errorf("failed to parse provider spec: %w", err)
 	}
 	w.wh.Namespace = ns
 	w.url = provider.URL
 	w.wh.Kube = kclient
 	w.wh.HTTP, err = w.wh.GetHTTPClient(ctx, provider)
 	if err != nil {
-		return nil, fmt.Errorf("failed to prepare provider http client: %w", err)
+		return nil, nil, fmt.Errorf("failed to prepare provider http client: %w", err)
 	}
-	return w.wh.GetSecretMap(ctx, provider, nil)
+	data, err := w.wh.GetSecretMap(ctx, provider, nil)
+	return data, nil, err
+}
+
+func (w *Webhook) Cleanup(_ context.Context, jsonSpec *apiextensions.JSON, state genv1alpha1.GeneratorProviderState, _ client.Client, _ string) error {
+	return nil
 }
 
 func parseSpec(data []byte) (*webhook.Spec, error) {

+ 1 - 1
pkg/generator/webhook/webhook_test.go

@@ -221,7 +221,7 @@ func runTestCase(tc testCase, t *testing.T) {
 }
 
 func testGenerate(tc testCase, t *testing.T, client genv1alpha1.Generator, testStore *apiextensions.JSON) {
-	secretmap, err := client.Generate(context.Background(), testStore, nil, "testnamespace")
+	secretmap, _, err := client.Generate(context.Background(), testStore, nil, "testnamespace")
 	errStr := ""
 	if err != nil {
 		errStr = err.Error()

+ 7 - 0
pkg/utils/resolvers/generator.go

@@ -208,6 +208,13 @@ func clusterGeneratorToVirtual(gen *genv1alpha1.ClusterGenerator) (client.Object
 		return &genv1alpha1.Webhook{
 			Spec: *gen.Spec.Generator.WebhookSpec,
 		}, nil
+	case genv1alpha1.GeneratorKindGrafana:
+		if gen.Spec.Generator.GrafanaSpec == nil {
+			return nil, fmt.Errorf("when kind is %s, GrafanaSpec must be set", gen.Spec.Kind)
+		}
+		return &genv1alpha1.Grafana{
+			Spec: *gen.Spec.Generator.GrafanaSpec,
+		}, nil
 	default:
 		return nil, fmt.Errorf("unknown kind %s", gen.Spec.Kind)
 	}