Просмотр исходного кода

feat: introduce state for generator and new grafana SA generator

Signed-off-by: Moritz Johner <beller.moritz@googlemail.com>
Moritz Johner 1 год назад
Родитель
Сommit
a372c4e7cb
58 измененных файлов с 2318 добавлено и 205 удалено
  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
       - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
         with:
         with:
-          python-version: 3.7
+          python-version: 3.12
 
 
       - name: Set up chart-testing
       - name: Set up chart-testing
         uses: helm/chart-testing-action@e6669bcd63d7cb57cb4380c33043eebe5d111992 # v2.6.1
         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"`
 	SyncedPushSecrets SyncedPushSecretsMap `json:"syncedPushSecrets,omitempty"`
 	// +optional
 	// +optional
 	Conditions []PushSecretStatusCondition `json:"conditions,omitempty"`
 	Conditions []PushSecretStatusCondition `json:"conditions,omitempty"`
+
+	// +optional
+	GeneratorState esv1beta1.GeneratorState `json:"generatorState,omitempty"`
 }
 }
 
 
 // +kubebuilder:object:root=true
 // +kubebuilder:object:root=true
@@ -231,6 +234,14 @@ type PushSecret struct {
 	Status PushSecretStatus `json:"status,omitempty"`
 	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:object:root=true
 // +kubebuilder:printcolumn:name="AGE",type="date",JSONPath=".metadata.creationTimestamp"
 // +kubebuilder:printcolumn:name="AGE",type="date",JSONPath=".metadata.creationTimestamp"
 // +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].reason`
 // +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)[i].DeepCopyInto(&(*out)[i])
 		}
 		}
 	}
 	}
+	in.GeneratorState.DeepCopyInto(&out.GeneratorState)
 }
 }
 
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PushSecretStatus.
 // 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 (
 import (
 	corev1 "k8s.io/api/core/v1"
 	corev1 "k8s.io/api/core/v1"
+	apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 )
 )
 
 
@@ -424,7 +425,7 @@ type GeneratorRef struct {
 	APIVersion string `json:"apiVersion,omitempty"`
 	APIVersion string `json:"apiVersion,omitempty"`
 
 
 	// Specify the Kind of the generator resource
 	// 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"`
 	Kind string `json:"kind"`
 
 
 	// Specify the name of the generator resource
 	// 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 represents a servicebinding.io Provisioned Service reference to the secret
 	Binding corev1.LocalObjectReference `json:"binding,omitempty"`
 	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
 // +kubebuilder:object:root=true
@@ -506,6 +559,14 @@ type ExternalSecret struct {
 	Status ExternalSecretStatus `json:"status,omitempty"`
 	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 (
 const (
 	// AnnotationDataHash all secrets managed by an ExternalSecret have this annotation with the hash of their data.
 	// AnnotationDataHash all secrets managed by an ExternalSecret have this annotation with the hash of their data.
 	AnnotationDataHash = "reconcile.external-secrets.io/data-hash"
 	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"
 	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).
 	return ctrl.NewWebhookManagedBy(mgr).
-		For(r).
+		For(es).
 		WithValidator(&ExternalSecretValidator{}).
 		WithValidator(&ExternalSecretValidator{}).
 		Complete()
 		Complete()
 }
 }

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

@@ -20,6 +20,7 @@ package v1beta1
 
 
 import (
 import (
 	metav1 "github.com/external-secrets/external-secrets/apis/meta/v1"
 	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/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/runtime"
 )
 )
@@ -1421,6 +1422,7 @@ func (in *ExternalSecretStatus) DeepCopyInto(out *ExternalSecretStatus) {
 		}
 		}
 	}
 	}
 	out.Binding = in.Binding
 	out.Binding = in.Binding
+	in.GeneratorState.DeepCopyInto(&out.GeneratorState)
 }
 }
 
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalSecretStatus.
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalSecretStatus.
@@ -1715,6 +1717,32 @@ func (in *GCPWorkloadIdentity) DeepCopy() *GCPWorkloadIdentity {
 	return out
 	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.
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *GeneratorRef) DeepCopyInto(out *GeneratorRef) {
 func (in *GeneratorRef) DeepCopyInto(out *GeneratorRef) {
 	*out = *in
 	*out = *in
@@ -1730,6 +1758,78 @@ func (in *GeneratorRef) DeepCopy() *GeneratorRef {
 	return out
 	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.
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *GenericStoreValidator) DeepCopyInto(out *GenericStoreValidator) {
 func (in *GenericStoreValidator) DeepCopyInto(out *GenericStoreValidator) {
 	*out = *in
 	*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.
 // Generator is the common interface for all generators that is actually used to generate whatever is needed.
 type Generator interface {
 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(
 	Generate(
 		ctx context.Context,
 		ctx context.Context,
 		obj *apiextensions.JSON,
 		obj *apiextensions.JSON,
 		kube client.Client,
 		kube client.Client,
 		namespace string,
 		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)
 	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.
 // ClusterGenerator type metadata.
 var (
 var (
 	ClusterGeneratorKind             = reflect.TypeOf(ClusterGenerator{}).Name()
 	ClusterGeneratorKind             = reflect.TypeOf(ClusterGenerator{}).Name()
@@ -151,4 +159,5 @@ func init() {
 	SchemeBuilder.Register(&UUID{}, &UUIDList{})
 	SchemeBuilder.Register(&UUID{}, &UUIDList{})
 	SchemeBuilder.Register(&VaultDynamicSecret{}, &VaultDynamicSecretList{})
 	SchemeBuilder.Register(&VaultDynamicSecret{}, &VaultDynamicSecretList{})
 	SchemeBuilder.Register(&Webhook{}, &WebhookList{})
 	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.
 // 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
 type GeneratorKind string
 
 
 const (
 const (
@@ -41,6 +41,7 @@ const (
 	GeneratorKindUUID                  GeneratorKind = "UUID"
 	GeneratorKindUUID                  GeneratorKind = "UUID"
 	GeneratorKindVaultDynamicSecret    GeneratorKind = "VaultDynamicSecret"
 	GeneratorKindVaultDynamicSecret    GeneratorKind = "VaultDynamicSecret"
 	GeneratorKindWebhook               GeneratorKind = "Webhook"
 	GeneratorKindWebhook               GeneratorKind = "Webhook"
+	GeneratorKindGrafana               GeneratorKind = "Grafana"
 )
 )
 
 
 // +kubebuilder:validation:MaxProperties=1
 // +kubebuilder:validation:MaxProperties=1
@@ -56,6 +57,7 @@ type GeneratorSpec struct {
 	UUIDSpec                  *UUIDSpec                  `json:"uuidSpec,omitempty"`
 	UUIDSpec                  *UUIDSpec                  `json:"uuidSpec,omitempty"`
 	VaultDynamicSecretSpec    *VaultDynamicSecretSpec    `json:"vaultDynamicSecretSpec,omitempty"`
 	VaultDynamicSecretSpec    *VaultDynamicSecretSpec    `json:"vaultDynamicSecretSpec,omitempty"`
 	WebhookSpec               *WebhookSpec               `json:"webhookSpec,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.
 // 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)
 		*out = new(WebhookSpec)
 		(*in).DeepCopyInto(*out)
 		(*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.
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GeneratorSpec.
@@ -823,6 +828,158 @@ func (in *GithubSecretRef) DeepCopy() *GithubSecretRef {
 	return out
 	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.
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *Password) DeepCopyInto(out *Password) {
 func (in *Password) DeepCopyInto(out *Password) {
 	*out = *in
 	*out = *in

+ 3 - 3
cmd/root.go

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

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

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

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

@@ -460,6 +460,7 @@ spec:
                               - UUID
                               - UUID
                               - VaultDynamicSecret
                               - VaultDynamicSecret
                               - Webhook
                               - Webhook
+                              - Grafana
                               type: string
                               type: string
                             name:
                             name:
                               description: Specify the name of the generator resource
                               description: Specify the name of the generator resource
@@ -654,6 +655,7 @@ spec:
                               - UUID
                               - UUID
                               - VaultDynamicSecret
                               - VaultDynamicSecret
                               - Webhook
                               - Webhook
+                              - Grafana
                               type: string
                               type: string
                             name:
                             name:
                               description: Specify the name of the generator resource
                               description: Specify the name of the generator resource
@@ -913,6 +915,70 @@ spec:
                   - type
                   - type
                   type: object
                   type: object
                 type: array
                 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:
               refreshTime:
                 description: |-
                 description: |-
                   refreshTime is the time and date the external secret was fetched and
                   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
                         - UUID
                         - VaultDynamicSecret
                         - VaultDynamicSecret
                         - Webhook
                         - Webhook
+                        - Grafana
                         type: string
                         type: string
                       name:
                       name:
                         description: Specify the name of the generator resource
                         description: Specify the name of the generator resource
@@ -375,6 +376,70 @@ spec:
                   - type
                   - type
                   type: object
                   type: object
                 type: array
                 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:
               refreshTime:
                 description: |-
                 description: |-
                   refreshTime is the time and date the external secret was fetched and
                   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
                     - auth
                     - installID
                     - installID
                     type: object
                     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:
                   passwordSpec:
                     description: PasswordSpec controls the behavior of the password
                     description: PasswordSpec controls the behavior of the password
                       generator.
                       generator.
@@ -1690,6 +1747,7 @@ spec:
                 - UUID
                 - UUID
                 - VaultDynamicSecret
                 - VaultDynamicSecret
                 - Webhook
                 - Webhook
+                - Grafana
                 type: string
                 type: string
             required:
             required:
             - generator
             - 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_fakes.yaml
   - generators.external-secrets.io_gcraccesstokens.yaml
   - generators.external-secrets.io_gcraccesstokens.yaml
   - generators.external-secrets.io_githubaccesstokens.yaml
   - generators.external-secrets.io_githubaccesstokens.yaml
+  - generators.external-secrets.io_grafanas.yaml
   - generators.external-secrets.io_passwords.yaml
   - generators.external-secrets.io_passwords.yaml
   - generators.external-secrets.io_stssessiontokens.yaml
   - generators.external-secrets.io_stssessiontokens.yaml
   - generators.external-secrets.io_uuids.yaml
   - generators.external-secrets.io_uuids.yaml

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

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

+ 293 - 0
deploy/crds/bundle.yaml

@@ -160,6 +160,7 @@ spec:
                                       - UUID
                                       - UUID
                                       - VaultDynamicSecret
                                       - VaultDynamicSecret
                                       - Webhook
                                       - Webhook
+                                      - Grafana
                                     type: string
                                     type: string
                                   name:
                                   name:
                                     description: Specify the name of the generator resource
                                     description: Specify the name of the generator resource
@@ -346,6 +347,7 @@ spec:
                                       - UUID
                                       - UUID
                                       - VaultDynamicSecret
                                       - VaultDynamicSecret
                                       - Webhook
                                       - Webhook
+                                      - Grafana
                                     type: string
                                     type: string
                                   name:
                                   name:
                                     description: Specify the name of the generator resource
                                     description: Specify the name of the generator resource
@@ -6944,6 +6946,7 @@ spec:
                                   - UUID
                                   - UUID
                                   - VaultDynamicSecret
                                   - VaultDynamicSecret
                                   - Webhook
                                   - Webhook
+                                  - Grafana
                                 type: string
                                 type: string
                               name:
                               name:
                                 description: Specify the name of the generator resource
                                 description: Specify the name of the generator resource
@@ -7130,6 +7133,7 @@ spec:
                                   - UUID
                                   - UUID
                                   - VaultDynamicSecret
                                   - VaultDynamicSecret
                                   - Webhook
                                   - Webhook
+                                  - Grafana
                                 type: string
                                 type: string
                               name:
                               name:
                                 description: Specify the name of the generator resource
                                 description: Specify the name of the generator resource
@@ -7382,6 +7386,66 @@ spec:
                       - type
                       - type
                     type: object
                     type: object
                   type: array
                   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:
                 refreshTime:
                   description: |-
                   description: |-
                     refreshTime is the time and date the external secret was fetched and
                     refreshTime is the time and date the external secret was fetched and
@@ -7598,6 +7662,7 @@ spec:
                             - UUID
                             - UUID
                             - VaultDynamicSecret
                             - VaultDynamicSecret
                             - Webhook
                             - Webhook
+                            - Grafana
                           type: string
                           type: string
                         name:
                         name:
                           description: Specify the name of the generator resource
                           description: Specify the name of the generator resource
@@ -7775,6 +7840,66 @@ spec:
                       - type
                       - type
                     type: object
                     type: object
                   type: array
                   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:
                 refreshTime:
                   description: |-
                   description: |-
                     refreshTime is the time and date the external secret was fetched and
                     refreshTime is the time and date the external secret was fetched and
@@ -14337,6 +14462,61 @@ spec:
                         - auth
                         - auth
                         - installID
                         - installID
                       type: object
                       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:
                     passwordSpec:
                       description: PasswordSpec controls the behavior of the password generator.
                       description: PasswordSpec controls the behavior of the password generator.
                       properties:
                       properties:
@@ -15450,6 +15630,7 @@ spec:
                     - UUID
                     - UUID
                     - VaultDynamicSecret
                     - VaultDynamicSecret
                     - Webhook
                     - Webhook
+                    - Grafana
                   type: string
                   type: string
               required:
               required:
                 - generator
                 - generator
@@ -16015,6 +16196,118 @@ spec:
 ---
 ---
 apiVersion: apiextensions.k8s.io/v1
 apiVersion: apiextensions.k8s.io/v1
 kind: CustomResourceDefinition
 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:
 metadata:
   annotations:
   annotations:
     controller-gen.kubebuilder.io/version: v0.16.5
     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>
 <p>Binding represents a servicebinding.io Provisioned Service reference to the secret</p>
 </td>
 </td>
 </tr>
 </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>
 </tbody>
 </table>
 </table>
 <h3 id="external-secrets.io/v1beta1.ExternalSecretStatusCondition">ExternalSecretStatusCondition
 <h3 id="external-secrets.io/v1beta1.ExternalSecretStatusCondition">ExternalSecretStatusCondition
@@ -4547,6 +4560,61 @@ string
 </tr>
 </tr>
 </tbody>
 </tbody>
 </table>
 </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 id="external-secrets.io/v1beta1.GeneratorRef">GeneratorRef
 </h3>
 </h3>
 <p>
 <p>
@@ -4600,6 +4668,101 @@ string
 </tr>
 </tr>
 </tbody>
 </tbody>
 </table>
 </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 id="external-secrets.io/v1beta1.GenericStore">GenericStore
 </h3>
 </h3>
 <p>
 <p>

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

@@ -16,6 +16,7 @@ package addon
 
 
 import (
 import (
 	"os"
 	"os"
+	"time"
 
 
 	// nolint
 	// nolint
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/ginkgo/v2"
@@ -172,7 +173,7 @@ func WithCRDs() MutationFunc {
 }
 }
 
 
 func (l *ESO) Install() error {
 func (l *ESO) Install() error {
-	By("Installing eso\n")
+	By("Installing eso")
 	err := l.HelmChart.Install()
 	err := l.HelmChart.Install()
 	if err != nil {
 	if err != nil {
 		return err
 		return err
@@ -182,13 +183,23 @@ func (l *ESO) Install() error {
 }
 }
 
 
 func (l *ESO) Uninstall() 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")
 	By("Uninstalling eso")
 	err := l.HelmChart.Uninstall()
 	err := l.HelmChart.Uninstall()
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
-	if l.HelmChart.HasVar(installCRDsVar, "true") {
-		return uninstallCRDs(l.config)
-	}
 	return nil
 	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.
 // Uninstall removes the chart aswell as the repo.
 func (c *ArgoCDApplication) Uninstall() error {
 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 {
 	if err != nil {
 		return err
 		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 {
 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.
 // Uninstall removes the chart aswell as the repo.
 func (c *FluxHelmRelease) Uninstall() error {
 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{
 		ObjectMeta: metav1.ObjectMeta{
 			Name:      c.Name,
 			Name:      c.Name,
 			Namespace: c.Namespace,
 			Namespace: c.Namespace,

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

@@ -20,24 +20,21 @@ import (
 	"github.com/onsi/ginkgo/v2"
 	"github.com/onsi/ginkgo/v2"
 	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
 	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
-	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 )
 )
 
 
 func uninstallCRDs(cfg *Config) error {
 func uninstallCRDs(cfg *Config) error {
 	ginkgo.By("Uninstalling eso CRDs")
 	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) {
 		if err != nil && !apierrors.IsNotFound(err) {
 			return err
 			return err
 		}
 		}

+ 9 - 0
go.mod

@@ -80,8 +80,10 @@ require (
 	github.com/cenkalti/backoff/v4 v4.3.0
 	github.com/cenkalti/backoff/v4 v4.3.0
 	github.com/cyberark/conjur-api-go v0.12.7
 	github.com/cyberark/conjur-api-go v0.12.7
 	github.com/fortanix/sdkms-client-go v0.4.0
 	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/go-openapi/strfmt v0.23.0
 	github.com/golang-jwt/jwt/v5 v5.2.1
 	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/golang-lru v1.0.2
 	github.com/hashicorp/vault/api/auth/aws v0.8.0
 	github.com/hashicorp/vault/api/auth/aws v0.8.0
 	github.com/hashicorp/vault/api/auth/userpass 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/gabriel-vasile/mimetype v1.4.7 // indirect
 	github.com/go-jose/go-jose/v4 v4.0.4 // indirect
 	github.com/go-jose/go-jose/v4 v4.0.4 // indirect
 	github.com/go-logr/stdr v1.2.2 // 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-playground/validator/v10 v10.23.0 // indirect
 	github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
 	github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
 	github.com/godbus/dbus/v5 v5.1.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/google/s2a-go v0.1.8 // indirect
 	github.com/hashicorp/go-secure-stdlib/awsutil v0.3.0 // indirect
 	github.com/hashicorp/go-secure-stdlib/awsutil v0.3.0 // indirect
 	github.com/hashicorp/go-uuid v1.0.3 // 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/klauspost/compress v1.17.11 // indirect
 	github.com/lestrrat-go/httprc v1.0.6 // indirect
 	github.com/lestrrat-go/httprc v1.0.6 // indirect
 	github.com/nxadm/tail v1.4.11 // 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/segmentio/asm v1.2.0 // indirect
 	github.com/sirupsen/logrus v1.9.3 // indirect
 	github.com/sirupsen/logrus v1.9.3 // indirect
 	github.com/tjfoc/gmsm v1.4.1 // 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/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 h1:yvOSijEBWAQtRbBPj9hz1atEJUU6HckPc7AaEyZXnLg=
 github.com/go-chef/chef v0.30.1/go.mod h1:7RU1oCrRErTrkmIszkhJ9vHw7Bv2hZ1Vv1C1qKj01fc=
 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 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-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/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/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 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ=
 github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg=
 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 h1:c4xY/OLxUBSTiepAg3j/MHuAv5mJhnf53LLMWFB+u/w=
 github.com/go-openapi/errors v0.22.0/go.mod h1:J3DmZScxCDufmIMsdOuDHxJbdOGC0xtUynjIx092vXE=
 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 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
 github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
 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 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
 github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
 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 h1:nlUS6BCqcnAk0pyhi9Y+kdDVZdZMHfEKQiS4HaMgO/c=
 github.com/go-openapi/strfmt v0.23.0/go.mod h1:NrtIpfKtWIygRkKVsxh7XQMDQW5HKQl6S5ik2elW+K4=
 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 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
 github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
 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 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
 github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
 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=
 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/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-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
 github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/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.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 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
 github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
 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 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 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
 github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
 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 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
 github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
 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=
 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/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 h1:HHDteefn6ZkTtY5fGUE8tj8uy85AHk6zP7CpzIAM0y4=
 github.com/redis/go-redis/v9 v9.6.1/go.mod h1:0C0c6ycQsdpVNQpxb1njEQIqkx5UcsM8FJCQLgE9+RA=
 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.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.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
 github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
 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.
 	// Metrics.
 	"github.com/external-secrets/external-secrets/pkg/controllers/externalsecret/esmetrics"
 	"github.com/external-secrets/external-secrets/pkg/controllers/externalsecret/esmetrics"
 	ctrlmetrics "github.com/external-secrets/external-secrets/pkg/controllers/metrics"
 	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"
 	"github.com/external-secrets/external-secrets/pkg/utils/resolvers"
 	"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/generator/register"
 	// Loading registered providers.
 	// Loading registered providers.
 	_ "github.com/external-secrets/external-secrets/pkg/provider/register"
 	_ "github.com/external-secrets/external-secrets/pkg/provider/register"
@@ -83,6 +84,7 @@ const (
 	msgErrorUpdateImmutable = "could not update secret, target is immutable"
 	msgErrorUpdateImmutable = "could not update secret, target is immutable"
 	msgErrorBecomeOwner     = "failed to take ownership of target secret"
 	msgErrorBecomeOwner     = "failed to take ownership of target secret"
 	msgErrorIsOwned         = "target is owned by another ExternalSecret"
 	msgErrorIsOwned         = "target is owned by another ExternalSecret"
+	msgErrorGarbageCollect  = "could not garbage collect generator state"
 
 
 	// log messages.
 	// log messages.
 	logErrorGetES                = "unable to get ExternalSecret"
 	logErrorGetES                = "unable to get ExternalSecret"
@@ -117,6 +119,7 @@ var (
 )
 )
 
 
 const indexESTargetSecretNameField = ".metadata.targetSecretName"
 const indexESTargetSecretNameField = ".metadata.targetSecretName"
+const externalSecretFinalizer = "externalsecret.externalsecrets.io/finalizer"
 
 
 // Reconciler reconciles a ExternalSecret object.
 // Reconciler reconciles a ExternalSecret object.
 type Reconciler struct {
 type Reconciler struct {
@@ -172,6 +175,40 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ct
 		return ctrl.Result{}, err
 		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
 	// skip reconciliation if deletion timestamp is set on external secret
 	if !externalSecret.GetDeletionTimestamp().IsZero() {
 	if !externalSecret.GetDeletionTimestamp().IsZero() {
 		log.V(1).Info("skipping ExternalSecret, it is marked for deletion")
 		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 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,
 	// 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.
 	//       so otherwise the `equality.Semantic.DeepEqual` will always return false.
-	currentStatus := *externalSecret.Status.DeepCopy()
+	currentStatus := externalSecret.Status.DeepCopy()
+
 	defer func() {
 	defer func() {
 		// if the status has not changed, we don't need to update it
 		// if the status has not changed, we don't need to update it
 		if equality.Semantic.DeepEqual(currentStatus, externalSecret.Status) {
 		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
 		return nil
 	}
 	}
 
 
+	//nolint:nolintlint
 	switch externalSecret.Spec.Target.CreationPolicy { //nolint:exhaustive
 	switch externalSecret.Spec.Target.CreationPolicy { //nolint:exhaustive
 	case esv1beta1.CreatePolicyMerge:
 	case esv1beta1.CreatePolicyMerge:
 		// update the secret, if it exists
 		// 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
 		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)
 	r.markAsDone(externalSecret, start, log, esv1beta1.ConditionReasonSecretSynced, msgSynced)
 	return r.getRequeueResult(externalSecret), nil
 	return r.getRequeueResult(externalSecret), nil
 }
 }
@@ -560,7 +604,7 @@ func (r *Reconciler) markAsDone(externalSecret *esv1beta1.ExternalSecret, start
 	SetExternalSecretCondition(externalSecret, *newReadyCondition)
 	SetExternalSecretCondition(externalSecret, *newReadyCondition)
 
 
 	externalSecret.Status.RefreshTime = metav1.NewTime(start)
 	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 the status or reason has changed, log at the appropriate verbosity level
 	if oldReadyCondition == nil || oldReadyCondition.Status != newReadyCondition.Status || oldReadyCondition.Reason != newReadyCondition.Reason {
 	if oldReadyCondition == nil || oldReadyCondition.Status != newReadyCondition.Status || oldReadyCondition.Reason != newReadyCondition.Reason {
@@ -755,23 +799,6 @@ func getManagedFieldKeys(
 	return keys, nil
 	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 {
 func shouldSkipClusterSecretStore(r *Reconciler, es *esv1beta1.ExternalSecret) bool {
 	return !r.ClusterSecretStoreEnabled && es.Spec.SecretStoreRef.Kind == esv1beta1.ClusterSecretStoreKind
 	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 the ExternalSecret has been updated, we should refresh
-	if es.Status.SyncedResourceVersion != getResourceVersion(es) {
+	if es.Status.SyncedResourceVersion != util.GetResourceVersion(es.ObjectMeta) {
 		return true
 		return true
 	}
 	}
 
 
@@ -946,11 +973,11 @@ func (r *Reconciler) findObjectsForSecret(ctx context.Context, secret client.Obj
 	}
 	}
 
 
 	requests := make([]reconcile.Request, len(externalSecretsList.Items))
 	requests := make([]reconcile.Request, len(externalSecretsList.Items))
-	for i, item := range externalSecretsList.Items {
+	for i := range externalSecretsList.Items {
 		requests[i] = reconcile.Request{
 		requests[i] = reconcile.Request{
 			NamespacedName: types.NamespacedName{
 			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"
 	"encoding/json"
 	"errors"
 	"errors"
 	"fmt"
 	"fmt"
+	"strconv"
 
 
 	v1 "k8s.io/api/core/v1"
 	v1 "k8s.io/api/core/v1"
 	apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/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"
 	esv1beta1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1"
 	genv1alpha1 "github.com/external-secrets/external-secrets/apis/generators/v1alpha1"
 	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/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"
 	"github.com/external-secrets/external-secrets/pkg/utils/resolvers"
 	"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/generator/register"
 	_ "github.com/external-secrets/external-secrets/pkg/provider/register"
 	_ "github.com/external-secrets/external-secrets/pkg/provider/register"
 )
 )
 
 
 // getProviderSecretData returns the provider's secret data with the provided ExternalSecret.
 // 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) {
 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)
 	// We MUST NOT create multiple instances of a provider client (mostly due to limitations with GCP)
 	// Clientmanager keeps track of the client instances
 	// Clientmanager keeps track of the client instances
 	// that are created during the fetching process and closes clients
 	// that are created during the fetching process and closes clients
 	// if needed.
 	// if needed.
 	mgr := secretstore.NewManager(r.Client, r.ControllerClass, r.EnableFloodGate)
 	mgr := secretstore.NewManager(r.Client, r.ControllerClass, r.EnableFloodGate)
 	defer mgr.Close(ctx)
 	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)
 	providerData := make(map[string][]byte)
 	for i, remoteRef := range externalSecret.Spec.DataFrom {
 	for i, remoteRef := range externalSecret.Spec.DataFrom {
 		var secretMap map[string][]byte
 		var secretMap map[string][]byte
-		var err error
 
 
 		if remoteRef.Find != nil {
 		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 {
 		} 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 {
 		} 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 {
 		if errors.Is(err, esv1beta1.NoSecretErr) && externalSecret.Spec.Target.DeletionPolicy != esv1beta1.DeletionPolicyRetain {
 			r.recorder.Event(
 			r.recorder.Event(
@@ -71,7 +83,7 @@ func (r *Reconciler) getProviderSecretData(ctx context.Context, externalSecret *
 	}
 	}
 
 
 	for i, secretRef := range externalSecret.Spec.Data {
 	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 {
 		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))
 			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
 			continue
@@ -84,7 +96,7 @@ func (r *Reconciler) getProviderSecretData(ctx context.Context, externalSecret *
 	return providerData, nil
 	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))
 	client, err := cmgr.Get(ctx, externalSecret.Spec.SecretStoreRef, externalSecret.Namespace, toStoreGenSourceRef(secretRef.SourceRef))
 	if err != nil {
 	if err != nil {
 		return err
 		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 {
 	if err != nil {
 		return nil, fmt.Errorf("unable to resolve generator: %w", err)
 		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 {
 	if err != nil {
 		return nil, fmt.Errorf(errGenerate, i, err)
 		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)
 	secretMap, err = utils.RewriteMap(remoteRef.Rewrite, secretMap)
 	if err != nil {
 	if err != nil {
 		return nil, fmt.Errorf(errRewrite, i, err)
 		return nil, fmt.Errorf(errRewrite, i, err)
@@ -131,7 +146,11 @@ func (r *Reconciler) handleGenerateSecrets(ctx context.Context, namespace string
 	return secretMap, err
 	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)
 	client, err := cmgr.Get(ctx, externalSecret.Spec.SecretStoreRef, externalSecret.Namespace, remoteRef.SourceRef)
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
@@ -157,10 +176,11 @@ func (r *Reconciler) handleExtractSecrets(ctx context.Context, externalSecret *e
 	if err != nil {
 	if err != nil {
 		return nil, fmt.Errorf(errDecode, "spec.dataFrom", i, err)
 		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)
 	client, err := cmgr.Get(ctx, externalSecret.Spec.SecretStoreRef, externalSecret.Namespace, remoteRef.SourceRef)
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
@@ -188,7 +208,8 @@ func (r *Reconciler) handleFindAllSecrets(ctx context.Context, externalSecret *e
 	if err != nil {
 	if err != nil {
 		return nil, fmt.Errorf(errDecode, "spec.dataFrom", i, err)
 		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) {
 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"
 	ctest "github.com/external-secrets/external-secrets/pkg/controllers/commontest"
 	"github.com/external-secrets/external-secrets/pkg/controllers/externalsecret/esmetrics"
 	"github.com/external-secrets/external-secrets/pkg/controllers/externalsecret/esmetrics"
 	ctrlmetrics "github.com/external-secrets/external-secrets/pkg/controllers/metrics"
 	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/provider/testing/fake"
 	"github.com/external-secrets/external-secrets/pkg/utils"
 	"github.com/external-secrets/external-secrets/pkg/utils"
 
 
@@ -2394,7 +2395,7 @@ var _ = Describe("ExternalSecret refresh logic", func() {
 					RefreshTime: metav1.Now(),
 					RefreshTime: metav1.Now(),
 				},
 				},
 			}
 			}
-			es.Status.SyncedResourceVersion = getResourceVersion(es)
+			es.Status.SyncedResourceVersion = util.GetResourceVersion(es.ObjectMeta)
 			// this should not refresh, rv matches object
 			// this should not refresh, rv matches object
 			Expect(shouldRefresh(es)).To(BeFalse())
 			Expect(shouldRefresh(es)).To(BeFalse())
 
 
@@ -2418,7 +2419,7 @@ var _ = Describe("ExternalSecret refresh logic", func() {
 					RefreshTime: metav1.Now(),
 					RefreshTime: metav1.Now(),
 				},
 				},
 			}
 			}
-			es.Status.SyncedResourceVersion = getResourceVersion(es)
+			es.Status.SyncedResourceVersion = util.GetResourceVersion(es.ObjectMeta)
 			// this should not refresh, rv matches object
 			// this should not refresh, rv matches object
 			Expect(shouldRefresh(es)).To(BeFalse())
 			Expect(shouldRefresh(es)).To(BeFalse())
 
 
@@ -2439,7 +2440,7 @@ var _ = Describe("ExternalSecret refresh logic", func() {
 					RefreshTime: metav1.Now(),
 					RefreshTime: metav1.Now(),
 				},
 				},
 			}
 			}
-			es.Status.SyncedResourceVersion = getResourceVersion(es)
+			es.Status.SyncedResourceVersion = util.GetResourceVersion(es.ObjectMeta)
 			Expect(shouldRefresh(es)).To(BeFalse())
 			Expect(shouldRefresh(es)).To(BeFalse())
 
 
 			// update gen -> refresh
 			// update gen -> refresh
@@ -2458,7 +2459,7 @@ var _ = Describe("ExternalSecret refresh logic", func() {
 				Status: esv1beta1.ExternalSecretStatus{},
 				Status: esv1beta1.ExternalSecretStatus{},
 			}
 			}
 			// resource version matches
 			// resource version matches
-			es.Status.SyncedResourceVersion = getResourceVersion(es)
+			es.Status.SyncedResourceVersion = util.GetResourceVersion(es.ObjectMeta)
 			Expect(shouldRefresh(es)).To(BeFalse())
 			Expect(shouldRefresh(es)).To(BeFalse())
 		})
 		})
 
 
@@ -2475,7 +2476,7 @@ var _ = Describe("ExternalSecret refresh logic", func() {
 				},
 				},
 			}
 			}
 			// resource version matches
 			// resource version matches
-			es.Status.SyncedResourceVersion = getResourceVersion(es)
+			es.Status.SyncedResourceVersion = util.GetResourceVersion(es.ObjectMeta)
 			Expect(shouldRefresh(es)).To(BeTrue())
 			Expect(shouldRefresh(es)).To(BeTrue())
 		})
 		})
 
 
@@ -2490,20 +2491,20 @@ var _ = Describe("ExternalSecret refresh logic", func() {
 				Status: esv1beta1.ExternalSecretStatus{},
 				Status: esv1beta1.ExternalSecretStatus{},
 			}
 			}
 			// resource version matches
 			// resource version matches
-			es.Status.SyncedResourceVersion = getResourceVersion(es)
+			es.Status.SyncedResourceVersion = util.GetResourceVersion(es.ObjectMeta)
 			Expect(shouldRefresh(es)).To(BeTrue())
 			Expect(shouldRefresh(es)).To(BeTrue())
 		})
 		})
 
 
 	})
 	})
 	Context("objectmeta hash", func() {
 	Context("objectmeta hash", func() {
 		It("should produce different hashes for different k/v pairs", func() {
 		It("should produce different hashes for different k/v pairs", func() {
-			h1 := hashMeta(metav1.ObjectMeta{
+			h1 := util.HashMeta(metav1.ObjectMeta{
 				Generation: 1,
 				Generation: 1,
 				Annotations: map[string]string{
 				Annotations: map[string]string{
 					"foo": "bar",
 					"foo": "bar",
 				},
 				},
 			})
 			})
-			h2 := hashMeta(metav1.ObjectMeta{
+			h2 := util.HashMeta(metav1.ObjectMeta{
 				Generation: 1,
 				Generation: 1,
 				Annotations: map[string]string{
 				Annotations: map[string]string{
 					"foo": "bing",
 					"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() {
 		It("should produce different hashes for different generations but same label/annotations", func() {
-			h1 := hashMeta(metav1.ObjectMeta{
+			h1 := util.HashMeta(metav1.ObjectMeta{
 				Generation: 1,
 				Generation: 1,
 				Annotations: map[string]string{
 				Annotations: map[string]string{
 					"foo": "bar",
 					"foo": "bar",
@@ -2522,7 +2523,7 @@ var _ = Describe("ExternalSecret refresh logic", func() {
 					"foo": "bar",
 					"foo": "bar",
 				},
 				},
 			})
 			})
-			h2 := hashMeta(metav1.ObjectMeta{
+			h2 := util.HashMeta(metav1.ObjectMeta{
 				Generation: 2,
 				Generation: 2,
 				Annotations: map[string]string{
 				Annotations: map[string]string{
 					"foo": "bar",
 					"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() {
 		It("should produce the same hash for the same k/v pairs", func() {
-			h1 := hashMeta(metav1.ObjectMeta{
+			h1 := util.HashMeta(metav1.ObjectMeta{
 				Generation: 1,
 				Generation: 1,
 			})
 			})
-			h2 := hashMeta(metav1.ObjectMeta{
+			h2 := util.HashMeta(metav1.ObjectMeta{
 				Generation: 1,
 				Generation: 1,
 			})
 			})
 			Expect(h1).To(Equal(h2))
 			Expect(h1).To(Equal(h2))
 
 
-			h1 = hashMeta(metav1.ObjectMeta{
+			h1 = util.HashMeta(metav1.ObjectMeta{
 				Generation: 1,
 				Generation: 1,
 				Annotations: map[string]string{
 				Annotations: map[string]string{
 					"foo": "bar",
 					"foo": "bar",
 				},
 				},
 			})
 			})
-			h2 = hashMeta(metav1.ObjectMeta{
+			h2 = util.HashMeta(metav1.ObjectMeta{
 				Generation: 1,
 				Generation: 1,
 				Annotations: map[string]string{
 				Annotations: map[string]string{
 					"foo": "bar",
 					"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"
 	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/pushsecret/psmetrics"
 	"github.com/external-secrets/external-secrets/pkg/controllers/secretstore"
 	"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/provider/util/locks"
 	"github.com/external-secrets/external-secrets/pkg/utils"
 	"github.com/external-secrets/external-secrets/pkg/utils"
 	"github.com/external-secrets/external-secrets/pkg/utils/resolvers"
 	"github.com/external-secrets/external-secrets/pkg/utils/resolvers"
 
 
-	// load generators.
 	_ "github.com/external-secrets/external-secrets/pkg/generator/register"
 	_ "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:
 	case esapi.PushSecretDeletionPolicyDelete:
 		// finalizer logic. Only added if we should delete the secrets
 		// finalizer logic. Only added if we should delete the secrets
 		if ps.ObjectMeta.DeletionTimestamp.IsZero() {
 		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 {
 				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{}, 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:
 	case esapi.PushSecretDeletionPolicyNone:
 		if controllerutil.ContainsFinalizer(&ps, pushSecretFinalizer) {
 		if controllerutil.ContainsFinalizer(&ps, pushSecretFinalizer) {
@@ -156,7 +158,17 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
 	default:
 	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 {
 	if err != nil {
 		r.markAsFailed(errFailedGetSecret, &ps, nil)
 		r.markAsFailed(errFailedGetSecret, &ps, nil)
 
 
@@ -208,11 +220,31 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
 	default:
 	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
 	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) {
 func (r *Reconciler) markAsFailed(msg string, ps *esapi.PushSecret, syncState esapi.SyncedPushSecretsMap) {
 	cond := newPushSecretCondition(esapi.PushSecretReady, v1.ConditionFalse, esapi.ReasonErrored, msg)
 	cond := newPushSecretCondition(esapi.PushSecretReady, v1.ConditionFalse, esapi.ReasonErrored, msg)
 	setPushSecretCondition(ps, *cond)
 	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)
 	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"
 	msg := "PushSecret synced successfully"
 	if ps.Spec.UpdatePolicy == esapi.PushSecretUpdatePolicyIfNotExists {
 	if ps.Spec.UpdatePolicy == esapi.PushSecretUpdatePolicyIfNotExists {
 		msg += ". Existing secrets in providers unchanged."
 		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)
 	cond := newPushSecretCondition(esapi.PushSecretReady, v1.ConditionTrue, esapi.ReasonSynced, msg)
 	setPushSecretCondition(ps, *cond)
 	setPushSecretCondition(ps, *cond)
 	r.setSecrets(ps, secrets)
 	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)
 	r.recorder.Event(ps, v1.EventTypeNormal, esapi.ReasonSynced, msg)
 }
 }
 
 
@@ -355,7 +389,23 @@ func secretKeyExists(key string, secret *v1.Secret) bool {
 	return key == "" || ok
 	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 {
 	if ps.Spec.Selector.Secret != nil {
 		secretName := types.NamespacedName{Name: ps.Spec.Selector.Secret.Name, Namespace: ps.Namespace}
 		secretName := types.NamespacedName{Name: ps.Spec.Selector.Secret.Name, Namespace: ps.Namespace}
 		secret := &v1.Secret{}
 		secret := &v1.Secret{}
@@ -363,23 +413,28 @@ func (r *Reconciler) resolveSecret(ctx context.Context, ps esapi.PushSecret) (*v
 		if err != nil {
 		if err != nil {
 			return nil, err
 			return nil, err
 		}
 		}
+		generatorState.EnqueueFlagLatestStateForGC(defaultGeneratorStateKey)
 		return secret, nil
 		return secret, nil
 	}
 	}
 	if ps.Spec.Selector.GeneratorRef != 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")
 	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 {
 	if err != nil {
 		return nil, fmt.Errorf("unable to resolve generator: %w", err)
 		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 {
 	if err != nil {
 		return nil, fmt.Errorf("unable to generate: %w", err)
 		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{
 	return &v1.Secret{
 		ObjectMeta: metav1.ObjectMeta{
 		ObjectMeta: metav1.ObjectMeta{
 			Name:      "___generated-secret",
 			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)
 // * 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
 // * 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
 // 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()
 	cfg, err := ctrlcfg.GetConfig()
 	if err != nil {
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 	}
 	kubeClient, err := kubernetes.NewForConfig(cfg)
 	kubeClient, err := kubernetes.NewForConfig(cfg)
 	if err != nil {
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 	}
 	g.clientSecretCreds = func(tenantID, clientID, clientSecret string, options *azidentity.ClientSecretCredentialOptions) (TokenGetter, error) {
 	g.clientSecretCreds = func(tenantID, clientID, clientSecret string, options *azidentity.ClientSecretCredentialOptions) (TokenGetter, error) {
 		return azidentity.NewClientSecretCredential(tenantID, clientID, clientSecret, options)
 		return azidentity.NewClientSecretCredential(tenantID, clientID, clientSecret, options)
@@ -93,6 +93,10 @@ func (g *Generator) Generate(ctx context.Context, jsonSpec *apiextensions.JSON,
 		fetchACRRefreshToken)
 		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(
 func (g *Generator) generate(
 	ctx context.Context,
 	ctx context.Context,
 	jsonSpec *apiextensions.JSON,
 	jsonSpec *apiextensions.JSON,
@@ -100,13 +104,13 @@ func (g *Generator) generate(
 	namespace string,
 	namespace string,
 	kubeClient kubernetes.Interface,
 	kubeClient kubernetes.Interface,
 	fetchAccessToken accessTokenFetcher,
 	fetchAccessToken accessTokenFetcher,
-	fetchRefreshToken refreshTokenFetcher) (map[string][]byte, error) {
+	fetchRefreshToken refreshTokenFetcher) (map[string][]byte, genv1alpha1.GeneratorProviderState, error) {
 	if jsonSpec == nil {
 	if jsonSpec == nil {
-		return nil, errors.New(errNoSpec)
+		return nil, nil, errors.New(errNoSpec)
 	}
 	}
 	res, err := parseSpec(jsonSpec.Raw)
 	res, err := parseSpec(jsonSpec.Raw)
 	if err != nil {
 	if err != nil {
-		return nil, fmt.Errorf(errParseSpec, err)
+		return nil, nil, fmt.Errorf(errParseSpec, err)
 	}
 	}
 	var accessToken string
 	var accessToken string
 	// pick authentication strategy to create an AAD access token
 	// pick authentication strategy to create an AAD access token
@@ -136,27 +140,27 @@ func (g *Generator) generate(
 			namespace,
 			namespace,
 		)
 		)
 	} else {
 	} else {
-		return nil, errors.New("unexpeted configuration")
+		return nil, nil, errors.New("unexpeted configuration")
 	}
 	}
 	if err != nil {
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 	}
 	var acrToken string
 	var acrToken string
 	acrToken, err = fetchRefreshToken(accessToken, res.Spec.TenantID, res.Spec.ACRRegistry)
 	acrToken, err = fetchRefreshToken(accessToken, res.Spec.TenantID, res.Spec.ACRRegistry)
 	if err != nil {
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 	}
 	if res.Spec.Scope != "" {
 	if res.Spec.Scope != "" {
 		acrToken, err = fetchAccessToken(acrToken, res.Spec.TenantID, res.Spec.ACRRegistry, res.Spec.Scope)
 		acrToken, err = fetchAccessToken(acrToken, res.Spec.TenantID, res.Spec.ACRRegistry, res.Spec.Scope)
 		if err != nil {
 		if err != nil {
-			return nil, err
+			return nil, nil, err
 		}
 		}
 	}
 	}
 
 
 	return map[string][]byte{
 	return map[string][]byte{
 		"username": []byte(defaultLoginUsername),
 		"username": []byte(defaultLoginUsername),
 		"password": []byte(acrToken),
 		"password": []byte(acrToken),
-	}, nil
+	}, nil, nil
 }
 }
 
 
 type accessTokenFetcher func(acrRefreshToken, tenantID, registryURL, scope string) (string, error)
 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{
 			g := &Generator{
 				clientSecretCreds: tt.args.clientSecretCreds,
 				clientSecretCreds: tt.args.clientSecretCreds,
 			}
 			}
-			got, err := g.generate(
+			got, _, err := g.generate(
 				tt.args.ctx,
 				tt.args.ctx,
 				tt.args.jsonSpec,
 				tt.args.jsonSpec,
 				tt.args.crClient,
 				tt.args.crClient,

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

@@ -43,23 +43,27 @@ const (
 	errGetToken   = "unable to get authorization token: %w"
 	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)
 	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(
 func (g *Generator) generate(
 	ctx context.Context,
 	ctx context.Context,
 	jsonSpec *apiextensions.JSON,
 	jsonSpec *apiextensions.JSON,
 	kube client.Client,
 	kube client.Client,
 	namespace string,
 	namespace string,
 	ecrFunc ecrFactoryFunc,
 	ecrFunc ecrFactoryFunc,
-) (map[string][]byte, error) {
+) (map[string][]byte, genv1alpha1.GeneratorProviderState, error) {
 	if jsonSpec == nil {
 	if jsonSpec == nil {
-		return nil, errors.New(errNoSpec)
+		return nil, nil, errors.New(errNoSpec)
 	}
 	}
 	res, err := parseSpec(jsonSpec.Raw)
 	res, err := parseSpec(jsonSpec.Raw)
 	if err != nil {
 	if err != nil {
-		return nil, fmt.Errorf(errParseSpec, err)
+		return nil, nil, fmt.Errorf(errParseSpec, err)
 	}
 	}
 	sess, err := awsauth.NewGeneratorSession(
 	sess, err := awsauth.NewGeneratorSession(
 		ctx,
 		ctx,
@@ -74,25 +78,25 @@ func (g *Generator) generate(
 		awsauth.DefaultSTSProvider,
 		awsauth.DefaultSTSProvider,
 		awsauth.DefaultJWTProvider)
 		awsauth.DefaultJWTProvider)
 	if err != nil {
 	if err != nil {
-		return nil, fmt.Errorf(errCreateSess, err)
+		return nil, nil, fmt.Errorf(errCreateSess, err)
 	}
 	}
 	client := ecrFunc(sess)
 	client := ecrFunc(sess)
 	out, err := client.GetAuthorizationToken(&ecr.GetAuthorizationTokenInput{})
 	out, err := client.GetAuthorizationToken(&ecr.GetAuthorizationTokenInput{})
 	if err != nil {
 	if err != nil {
-		return nil, fmt.Errorf(errGetToken, err)
+		return nil, nil, fmt.Errorf(errGetToken, err)
 	}
 	}
 	if len(out.AuthorizationData) != 1 {
 	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
 	// AuthorizationToken is base64 encoded {username}:{password} string
 	decodedToken, err := base64.StdEncoding.DecodeString(*out.AuthorizationData[0].AuthorizationToken)
 	decodedToken, err := base64.StdEncoding.DecodeString(*out.AuthorizationData[0].AuthorizationToken)
 	if err != nil {
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 	}
 	parts := strings.Split(string(decodedToken), ":")
 	parts := strings.Split(string(decodedToken), ":")
 	if len(parts) != 2 {
 	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()
 	exp := out.AuthorizationData[0].ExpiresAt.UTC().Unix()
@@ -101,7 +105,7 @@ func (g *Generator) generate(
 		"password":       []byte(parts[1]),
 		"password":       []byte(parts[1]),
 		"proxy_endpoint": []byte(*out.AuthorizationData[0].ProxyEndpoint),
 		"proxy_endpoint": []byte(*out.AuthorizationData[0].ProxyEndpoint),
 		"expires_at":     []byte(strconv.FormatInt(exp, 10)),
 		"expires_at":     []byte(strconv.FormatInt(exp, 10)),
-	}, nil
+	}, nil, nil
 }
 }
 
 
 type ecrFactoryFunc func(aws *session.Session) ecriface.ECRAPI
 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 {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
 			g := &Generator{}
 			g := &Generator{}
-			got, err := g.generate(
+			got, _, err := g.generate(
 				tt.args.ctx,
 				tt.args.ctx,
 				tt.args.jsonSpec,
 				tt.args.jsonSpec,
 				tt.args.kube,
 				tt.args.kube,

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

@@ -34,19 +34,23 @@ const (
 	errGetToken  = "unable to get authorization token: %w"
 	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 {
 	if jsonSpec == nil {
-		return nil, errors.New(errNoSpec)
+		return nil, nil, errors.New(errNoSpec)
 	}
 	}
 	res, err := parseSpec(jsonSpec.Raw)
 	res, err := parseSpec(jsonSpec.Raw)
 	if err != nil {
 	if err != nil {
-		return nil, fmt.Errorf(errParseSpec, err)
+		return nil, nil, fmt.Errorf(errParseSpec, err)
 	}
 	}
 	out := make(map[string][]byte)
 	out := make(map[string][]byte)
 	for k, v := range res.Spec.Data {
 	for k, v := range res.Spec.Data {
 		out[k] = []byte(v)
 		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) {
 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 {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
 			g := &Generator{}
 			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 {
 			if (err != nil) != tt.wantErr {
 				t.Errorf("Generator.Generate() error = %v, wantErr %v", err, tt.wantErr)
 				t.Errorf("Generator.Generate() error = %v, wantErr %v", err, tt.wantErr)
 				return
 				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"
 	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(
 	return g.generate(
 		ctx,
 		ctx,
 		jsonSpec,
 		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(
 func (g *Generator) generate(
 	ctx context.Context,
 	ctx context.Context,
 	jsonSpec *apiextensions.JSON,
 	jsonSpec *apiextensions.JSON,
 	kube client.Client,
 	kube client.Client,
 	namespace string,
 	namespace string,
-	tokenSource tokenSourceFunc) (map[string][]byte, error) {
+	tokenSource tokenSourceFunc) (map[string][]byte, genv1alpha1.GeneratorProviderState, error) {
 	if jsonSpec == nil {
 	if jsonSpec == nil {
-		return nil, errors.New(errNoSpec)
+		return nil, nil, errors.New(errNoSpec)
 	}
 	}
 	res, err := parseSpec(jsonSpec.Raw)
 	res, err := parseSpec(jsonSpec.Raw)
 	if err != nil {
 	if err != nil {
-		return nil, fmt.Errorf(errParseSpec, err)
+		return nil, nil, fmt.Errorf(errParseSpec, err)
 	}
 	}
 	ts, err := tokenSource(ctx, esv1beta1.GCPSMAuth{
 	ts, err := tokenSource(ctx, esv1beta1.GCPSMAuth{
 		SecretRef:        (*esv1beta1.GCPSMAuthSecretRef)(res.Spec.Auth.SecretRef),
 		SecretRef:        (*esv1beta1.GCPSMAuthSecretRef)(res.Spec.Auth.SecretRef),
 		WorkloadIdentity: (*esv1beta1.GCPWorkloadIdentity)(res.Spec.Auth.WorkloadIdentity),
 		WorkloadIdentity: (*esv1beta1.GCPWorkloadIdentity)(res.Spec.Auth.WorkloadIdentity),
 	}, res.Spec.ProjectID, resolvers.EmptyStoreKind, kube, namespace)
 	}, res.Spec.ProjectID, resolvers.EmptyStoreKind, kube, namespace)
 	if err != nil {
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 	}
 	token, err := ts.Token()
 	token, err := ts.Token()
 	if err != nil {
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 	}
 	exp := strconv.FormatInt(token.Expiry.UTC().Unix(), 10)
 	exp := strconv.FormatInt(token.Expiry.UTC().Unix(), 10)
 	return map[string][]byte{
 	return map[string][]byte{
 		"username": []byte(defaultLoginUsername),
 		"username": []byte(defaultLoginUsername),
 		"password": []byte(token.AccessToken),
 		"password": []byte(token.AccessToken),
 		"expiry":   []byte(exp),
 		"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)
 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 {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
 			g := &Generator{}
 			g := &Generator{}
-			got, err := g.generate(
+			got, _, err := g.generate(
 				tt.args.ctx,
 				tt.args.ctx,
 				tt.args.jsonSpec,
 				tt.args.jsonSpec,
 				tt.args.kube,
 				tt.args.kube,

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

@@ -60,7 +60,7 @@ const (
 	httpClientTimeout = 5 * time.Second
 	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(
 	return g.generate(
 		ctx,
 		ctx,
 		jsonSpec,
 		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(
 func (g *Generator) generate(
 	ctx context.Context,
 	ctx context.Context,
 	jsonSpec *apiextensions.JSON,
 	jsonSpec *apiextensions.JSON,
 	kube client.Client,
 	kube client.Client,
-	namespace string) (map[string][]byte, error) {
+	namespace string) (map[string][]byte, genv1alpha1.GeneratorProviderState, error) {
 	if jsonSpec == nil {
 	if jsonSpec == nil {
-		return nil, errors.New(errNoSpec)
+		return nil, nil, errors.New(errNoSpec)
 	}
 	}
 	ctx, cancel := context.WithTimeout(ctx, contextTimeout)
 	ctx, cancel := context.WithTimeout(ctx, contextTimeout)
 	defer cancel()
 	defer cancel()
 
 
 	gh, err := newGHClient(ctx, kube, namespace, g.httpClient, jsonSpec)
 	gh, err := newGHClient(ctx, kube, namespace, g.httpClient, jsonSpec)
 	if err != nil {
 	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{})
 	payload := make(map[string]interface{})
@@ -97,7 +101,7 @@ func (g *Generator) generate(
 	if len(payload) > 0 {
 	if len(payload) > 0 {
 		bodyBytes, err := json.Marshal(payload)
 		bodyBytes, err := json.Marshal(payload)
 		if err != nil {
 		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)
 		body = bytes.NewReader(bodyBytes)
@@ -106,30 +110,30 @@ func (g *Generator) generate(
 	// Github api expects POST request
 	// Github api expects POST request
 	req, err := http.NewRequestWithContext(ctx, http.MethodPost, gh.URL, body)
 	req, err := http.NewRequestWithContext(ctx, http.MethodPost, gh.URL, body)
 	if err != nil {
 	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("Authorization", "Bearer "+gh.InstallTkn)
 	req.Header.Add("Accept", "application/vnd.github.v3+json")
 	req.Header.Add("Accept", "application/vnd.github.v3+json")
 
 
 	resp, err := gh.HTTP.Do(req)
 	resp, err := gh.HTTP.Do(req)
 	if err != nil {
 	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()
 	defer resp.Body.Close()
 
 
 	// git access token
 	// git access token
 	var gat map[string]any
 	var gat map[string]any
 	if err := json.NewDecoder(resp.Body).Decode(&gat); err != nil && resp.StatusCode >= 200 && resp.StatusCode < 300 {
 	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)
 	accessToken, ok := gat["token"].(string)
 	if !ok {
 	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{
 	return map[string][]byte{
 		defaultLoginUsername: []byte(accessToken),
 		defaultLoginUsername: []byte(accessToken),
-	}, nil
+	}, nil, nil
 }
 }
 
 
 func newGHClient(ctx context.Context, k client.Client, n string, hc *http.Client,
 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 {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
 			g := &Generator{httpClient: server.Client()}
 			g := &Generator{httpClient: server.Client()}
-			got, err := g.generate(
+			got, _, err := g.generate(
 				tt.args.ctx,
 				tt.args.ctx,
 				tt.args.jsonSpec,
 				tt.args.jsonSpec,
 				tt.args.kube,
 				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,
 	allowRepeat bool,
 ) (string, error)
 ) (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(
 	return g.generate(
 		jsonSpec,
 		jsonSpec,
 		generateSafePassword,
 		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 {
 	if jsonSpec == nil {
-		return nil, errors.New(errNoSpec)
+		return nil, nil, errors.New(errNoSpec)
 	}
 	}
 	res, err := parseSpec(jsonSpec.Raw)
 	res, err := parseSpec(jsonSpec.Raw)
 	if err != nil {
 	if err != nil {
-		return nil, fmt.Errorf(errParseSpec, err)
+		return nil, nil, fmt.Errorf(errParseSpec, err)
 	}
 	}
 	symbolCharacters := defaultSymbolChars
 	symbolCharacters := defaultSymbolChars
 	if res.Spec.SymbolCharacters != nil {
 	if res.Spec.SymbolCharacters != nil {
@@ -89,11 +93,11 @@ func (g *Generator) generate(jsonSpec *apiextensions.JSON, passGen generateFunc)
 		res.Spec.AllowRepeat,
 		res.Spec.AllowRepeat,
 	)
 	)
 	if err != nil {
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 	}
 	return map[string][]byte{
 	return map[string][]byte{
 		"password": []byte(pass),
 		"password": []byte(pass),
-	}, nil
+	}, nil, nil
 }
 }
 
 
 func generateSafePassword(
 func generateSafePassword(

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

@@ -112,7 +112,7 @@ func TestGenerate(t *testing.T) {
 	for _, tt := range tests {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
 			g := &Generator{}
 			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 {
 			if (err != nil) != tt.wantErr {
 				t.Errorf("Generator.Generate() error = %v, wantErr %v", err, tt.wantErr)
 				t.Errorf("Generator.Generate() error = %v, wantErr %v", err, tt.wantErr)
 				return
 				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/fake"
 	_ "github.com/external-secrets/external-secrets/pkg/generator/gcr"
 	_ "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/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/password"
 	_ "github.com/external-secrets/external-secrets/pkg/generator/sts"
 	_ "github.com/external-secrets/external-secrets/pkg/generator/sts"
 	_ "github.com/external-secrets/external-secrets/pkg/generator/uuid"
 	_ "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"
 	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)
 	return g.generate(ctx, jsonSpec, kube, namespace, stsFactory)
 }
 }
 
 
@@ -51,13 +51,13 @@ func (g *Generator) generate(
 	kube client.Client,
 	kube client.Client,
 	namespace string,
 	namespace string,
 	stsFunc stsFactoryFunc,
 	stsFunc stsFactoryFunc,
-) (map[string][]byte, error) {
+) (map[string][]byte, genv1alpha1.GeneratorProviderState, error) {
 	if jsonSpec == nil {
 	if jsonSpec == nil {
-		return nil, errors.New(errNoSpec)
+		return nil, nil, errors.New(errNoSpec)
 	}
 	}
 	res, err := parseSpec(jsonSpec.Raw)
 	res, err := parseSpec(jsonSpec.Raw)
 	if err != nil {
 	if err != nil {
-		return nil, fmt.Errorf(errParseSpec, err)
+		return nil, nil, fmt.Errorf(errParseSpec, err)
 	}
 	}
 	sess, err := awsauth.NewGeneratorSession(
 	sess, err := awsauth.NewGeneratorSession(
 		ctx,
 		ctx,
@@ -72,7 +72,7 @@ func (g *Generator) generate(
 		awsauth.DefaultSTSProvider,
 		awsauth.DefaultSTSProvider,
 		awsauth.DefaultJWTProvider)
 		awsauth.DefaultJWTProvider)
 	if err != nil {
 	if err != nil {
-		return nil, fmt.Errorf(errCreateSess, err)
+		return nil, nil, fmt.Errorf(errCreateSess, err)
 	}
 	}
 	client := stsFunc(sess)
 	client := stsFunc(sess)
 	input := &sts.GetSessionTokenInput{}
 	input := &sts.GetSessionTokenInput{}
@@ -83,10 +83,10 @@ func (g *Generator) generate(
 	}
 	}
 	out, err := client.GetSessionToken(input)
 	out, err := client.GetSessionToken(input)
 	if err != nil {
 	if err != nil {
-		return nil, fmt.Errorf(errGetToken, err)
+		return nil, nil, fmt.Errorf(errGetToken, err)
 	}
 	}
 	if out.Credentials == nil {
 	if out.Credentials == nil {
-		return nil, errors.New("no credentials found")
+		return nil, nil, errors.New("no credentials found")
 	}
 	}
 
 
 	return map[string][]byte{
 	return map[string][]byte{
@@ -94,7 +94,11 @@ func (g *Generator) generate(
 		"expiration":        []byte(strconv.FormatInt(out.Credentials.Expiration.Unix(), 10)),
 		"expiration":        []byte(strconv.FormatInt(out.Credentials.Expiration.Unix(), 10)),
 		"secret_access_key": []byte(*out.Credentials.SecretAccessKey),
 		"secret_access_key": []byte(*out.Credentials.SecretAccessKey),
 		"session_token":     []byte(*out.Credentials.SessionToken),
 		"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
 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 {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
 			g := &Generator{}
 			g := &Generator{}
-			got, err := g.generate(
+			got, _, err := g.generate(
 				tt.args.ctx,
 				tt.args.ctx,
 				tt.args.jsonSpec,
 				tt.args.jsonSpec,
 				tt.args.kube,
 				tt.args.kube,

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

@@ -29,21 +29,25 @@ type Generator struct{}
 
 
 type generateFunc func() (string, error)
 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(
 	return g.generate(
 		jsonSpec,
 		jsonSpec,
 		generateUUID,
 		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()
 	uuid, err := uuidGen()
 	if err != nil {
 	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{
 	return map[string][]byte{
 		"uuid": []byte(uuid),
 		"uuid": []byte(uuid),
-	}, nil
+	}, nil, nil
 }
 }
 
 
 func generateUUID() (string, error) {
 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 {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
 			g := &Generator{}
 			g := &Generator{}
-			got, err := g.generate(tt.args.jsonSpec, generateUUID)
+			got, _, err := g.generate(tt.args.jsonSpec, generateUUID)
 			if (err != nil) != tt.wantErr {
 			if (err != nil) != tt.wantErr {
 				t.Errorf("Generator.Generate() error = %v, wantErr %v", err, tt.wantErr)
 				t.Errorf("Generator.Generate() error = %v, wantErr %v", err, tt.wantErr)
 				return
 				return

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

@@ -42,7 +42,7 @@ const (
 	errGetSecret   = "unable to get dynamic secret: %w"
 	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}
 	c := &provider.Provider{NewVaultClient: provider.NewVaultClient}
 
 
 	// controller-runtime/client does not support TokenRequest or other subresource APIs
 	// 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)
 	// (for Kubernetes service account token auth)
 	restCfg, err := ctrlcfg.GetConfig()
 	restCfg, err := ctrlcfg.GetConfig()
 	if err != nil {
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 	}
 	clientset, err := kubernetes.NewForConfig(restCfg)
 	clientset, err := kubernetes.NewForConfig(restCfg)
 	if err != nil {
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 	}
 
 
 	return g.generate(ctx, c, jsonSpec, kube, clientset.CoreV1(), namespace)
 	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 {
 	if jsonSpec == nil {
-		return nil, errors.New(errNoSpec)
+		return nil, nil, errors.New(errNoSpec)
 	}
 	}
 	res, err := parseSpec(jsonSpec.Raw)
 	res, err := parseSpec(jsonSpec.Raw)
 	if err != nil {
 	if err != nil {
-		return nil, fmt.Errorf(errParseSpec, err)
+		return nil, nil, fmt.Errorf(errParseSpec, err)
 	}
 	}
 	if res == nil || res.Spec.Provider == nil {
 	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)
 	cl, err := c.NewGeneratorClient(ctx, kube, corev1, res.Spec.Provider, namespace, res.Spec.RetrySettings)
 	if err != nil {
 	if err != nil {
-		return nil, fmt.Errorf(errVaultClient, err)
+		return nil, nil, fmt.Errorf(errVaultClient, err)
 	}
 	}
 
 
 	var result *vault.Secret
 	var result *vault.Secret
@@ -88,16 +92,16 @@ func (g *Generator) generate(ctx context.Context, c *provider.Provider, jsonSpec
 		if res.Spec.Parameters != nil {
 		if res.Spec.Parameters != nil {
 			err = json.Unmarshal(res.Spec.Parameters.Raw, &params)
 			err = json.Unmarshal(res.Spec.Parameters.Raw, &params)
 			if err != nil {
 			if err != nil {
-				return nil, err
+				return nil, nil, err
 			}
 			}
 		}
 		}
 		result, err = cl.Logical().WriteWithContext(ctx, res.Spec.Path, params)
 		result, err = cl.Logical().WriteWithContext(ctx, res.Spec.Path, params)
 	}
 	}
 	if err != nil {
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 	}
 	if result == nil {
 	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)
 	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 {
 	if res.Spec.ResultType == genv1alpha1.VaultDynamicSecretResultTypeAuth {
 		authJSON, err := json.Marshal(result.Auth)
 		authJSON, err := json.Marshal(result.Auth)
 		if err != nil {
 		if err != nil {
-			return nil, err
+			return nil, nil, err
 		}
 		}
 		err = json.Unmarshal(authJSON, &data)
 		err = json.Unmarshal(authJSON, &data)
 		if err != nil {
 		if err != nil {
-			return nil, err
+			return nil, nil, err
 		}
 		}
 	} else {
 	} else {
 		data = result.Data
 		data = result.Data
@@ -118,10 +122,10 @@ func (g *Generator) generate(ctx context.Context, c *provider.Provider, jsonSpec
 	for k := range data {
 	for k := range data {
 		response[k], err = utils.GetByteValueFromMap(data, k)
 		response[k], err = utils.GetByteValueFromMap(data, k)
 		if err != nil {
 		if err != nil {
-			return nil, err
+			return nil, nil, err
 		}
 		}
 	}
 	}
-	return response, nil
+	return response, nil, nil
 }
 }
 
 
 func parseSpec(data []byte) (*genv1alpha1.VaultDynamicSecret, error) {
 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) {
 		t.Run(name, func(t *testing.T) {
 			c := &provider.Provider{NewVaultClient: fake.ClientWithLoginMock}
 			c := &provider.Provider{NewVaultClient: fake.ClientWithLoginMock}
 			gen := &Generator{}
 			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 != "" {
 			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)
 				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
 	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.EnforceLabels = true
 	w.wh.ClusterScoped = false
 	w.wh.ClusterScoped = false
 	provider, err := parseSpec(jsonSpec.Raw)
 	provider, err := parseSpec(jsonSpec.Raw)
 	w.wh = webhook.Webhook{}
 	w.wh = webhook.Webhook{}
 	if err != nil {
 	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.wh.Namespace = ns
 	w.url = provider.URL
 	w.url = provider.URL
 	w.wh.Kube = kclient
 	w.wh.Kube = kclient
 	w.wh.HTTP, err = w.wh.GetHTTPClient(ctx, provider)
 	w.wh.HTTP, err = w.wh.GetHTTPClient(ctx, provider)
 	if err != nil {
 	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) {
 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) {
 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 := ""
 	errStr := ""
 	if err != nil {
 	if err != nil {
 		errStr = err.Error()
 		errStr = err.Error()

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

@@ -208,6 +208,13 @@ func clusterGeneratorToVirtual(gen *genv1alpha1.ClusterGenerator) (client.Object
 		return &genv1alpha1.Webhook{
 		return &genv1alpha1.Webhook{
 			Spec: *gen.Spec.Generator.WebhookSpec,
 			Spec: *gen.Spec.Generator.WebhookSpec,
 		}, nil
 		}, 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:
 	default:
 		return nil, fmt.Errorf("unknown kind %s", gen.Spec.Kind)
 		return nil, fmt.Errorf("unknown kind %s", gen.Spec.Kind)
 	}
 	}