Browse Source

feat: introduce state for generator and new grafana SA generator (#4203)

* feat: introduce state for generator and new grafana SA generator

Signed-off-by: Moritz Johner <beller.moritz@googlemail.com>

* Update pkg/controllers/generatorstate/generatorstate_controller.go

Co-authored-by: Gergely Brautigam <182850+Skarlso@users.noreply.github.com>
Signed-off-by: Moritz Johner <moolen@users.noreply.github.com>

* fix: do not log here

Signed-off-by: Moritz Johner <beller.moritz@googlemail.com>

* feat: implement generator state conditions

Signed-off-by: Moritz Johner <beller.moritz@googlemail.com>

* fix: address comments

Signed-off-by: Moritz Johner <beller.moritz@googlemail.com>

---------

Signed-off-by: Moritz Johner <beller.moritz@googlemail.com>
Signed-off-by: Moritz Johner <moolen@users.noreply.github.com>
Co-authored-by: Gergely Brautigam <182850+Skarlso@users.noreply.github.com>
Moritz Johner 1 year ago
parent
commit
0814a4a202
65 changed files with 2636 additions and 247 deletions
  1. 3 0
      .github/workflows/e2e.yml
  2. 1 1
      .github/workflows/helm.yml
  3. 1 1
      apis/externalsecrets/v1alpha1/pushsecret_types.go
  4. 1 1
      apis/externalsecrets/v1beta1/externalsecret_types.go
  5. 2 2
      apis/externalsecrets/v1beta1/externalsecret_webhook.go
  6. 18 1
      apis/generators/v1alpha1/generator_interfaces.go
  7. 108 0
      apis/generators/v1alpha1/generator_state_types.go
  8. 11 0
      apis/generators/v1alpha1/register.go
  9. 3 1
      apis/generators/v1alpha1/types_cluster.go
  10. 83 0
      apis/generators/v1alpha1/types_grafana.go
  11. 292 9
      apis/generators/v1alpha1/zz_generated.deepcopy.go
  12. 16 3
      cmd/controller/root.go
  13. 2 0
      config/crds/bases/external-secrets.io_clusterexternalsecrets.yaml
  14. 2 0
      config/crds/bases/external-secrets.io_externalsecrets.yaml
  15. 3 0
      config/crds/bases/external-secrets.io_pushsecrets.yaml
  16. 62 2
      config/crds/bases/generators.external-secrets.io_clustergenerators.yaml
  17. 103 0
      config/crds/bases/generators.external-secrets.io_generatorstates.yaml
  18. 105 0
      config/crds/bases/generators.external-secrets.io_grafanas.yaml
  19. 2 0
      config/crds/bases/kustomization.yaml
  20. 18 0
      deploy/charts/external-secrets/templates/rbac.yaml
  21. 2 0
      deploy/charts/external-secrets/tests/__snapshot__/crds_test.yaml.snap
  22. 293 2
      deploy/crds/bundle.yaml
  23. 155 0
      design/011-generator-state.md
  24. 14 3
      e2e/framework/addon/eso.go
  25. 5 1
      e2e/framework/addon/eso_flux_helm.go
  26. 9 12
      e2e/framework/addon/uninstall_eso_crds.go
  27. 11 0
      e2e/go.mod
  28. 22 0
      e2e/go.sum
  29. 2 0
      e2e/run.sh
  30. 200 0
      e2e/suites/generator/grafana.go
  31. 1 1
      e2e/suites/generator/testcase.go
  32. 6 0
      go.mod
  33. 12 0
      go.sum
  34. 6 22
      pkg/controllers/externalsecret/externalsecret_controller.go
  35. 45 14
      pkg/controllers/externalsecret/externalsecret_controller_secret.go
  36. 15 14
      pkg/controllers/externalsecret/externalsecret_controller_test.go
  37. 141 0
      pkg/controllers/generatorstate/generatorstate_controller.go
  38. 73 0
      pkg/controllers/generatorstate/util.go
  39. 74 30
      pkg/controllers/pushsecret/pushsecret_controller.go
  40. 37 0
      pkg/controllers/util/util.go
  41. 15 11
      pkg/generator/acr/acr.go
  42. 1 1
      pkg/generator/acr/acr_test.go
  43. 20 16
      pkg/generator/ecr/ecr.go
  44. 1 1
      pkg/generator/ecr/ecr_test.go
  45. 8 4
      pkg/generator/fake/fake.go
  46. 1 1
      pkg/generator/fake/fake_test.go
  47. 11 7
      pkg/generator/gcr/gcr.go
  48. 1 1
      pkg/generator/gcr/gcr_test.go
  49. 14 10
      pkg/generator/github/github.go
  50. 1 1
      pkg/generator/github/github_test.go
  51. 184 0
      pkg/generator/grafana/grafana.go
  52. 10 6
      pkg/generator/password/password.go
  53. 1 1
      pkg/generator/password/password_test.go
  54. 12 8
      pkg/generator/quay/quay.go
  55. 1 0
      pkg/generator/register/register.go
  56. 264 0
      pkg/generator/statemanager/statemanager.go
  57. 12 8
      pkg/generator/sts/sts.go
  58. 1 1
      pkg/generator/sts/sts_test.go
  59. 8 4
      pkg/generator/uuid/uuid.go
  60. 1 1
      pkg/generator/uuid/uuid_test.go
  61. 47 39
      pkg/generator/vault/vault.go
  62. 1 1
      pkg/generator/vault/vault_test.go
  63. 9 4
      pkg/generator/webhook/webhook.go
  64. 1 1
      pkg/generator/webhook/webhook_test.go
  65. 52 0
      pkg/utils/resolvers/generator.go

+ 3 - 0
.github/workflows/e2e.yml

@@ -56,6 +56,9 @@ env:
   SECRETSERVER_USERNAME: ${{ secrets.SECRETSERVER_USERNAME }}
   SECRETSERVER_USERNAME: ${{ secrets.SECRETSERVER_USERNAME }}
   SECRETSERVER_PASSWORD: ${{ secrets.SECRETSERVER_PASSWORD }}
   SECRETSERVER_PASSWORD: ${{ secrets.SECRETSERVER_PASSWORD }}
   SECRETSERVER_URL: ${{ secrets.SECRETSERVER_URL }}
   SECRETSERVER_URL: ${{ secrets.SECRETSERVER_URL }}
+
+  GRAFANA_URL: ${{ secrets.GRAFANA_URL }}
+  GRAFANA_TOKEN: ${{ secrets.GRAFANA_TOKEN }}
 jobs:
 jobs:
 
 
   integration-trusted:
   integration-trusted:

+ 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.11
+          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

+ 1 - 1
apis/externalsecrets/v1alpha1/pushsecret_types.go

@@ -221,7 +221,7 @@ type PushSecretStatus struct {
 // +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].reason`
 // +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].reason`
 // +kubebuilder:subresource:status
 // +kubebuilder:subresource:status
 // +kubebuilder:metadata:labels="external-secrets.io/component=controller"
 // +kubebuilder:metadata:labels="external-secrets.io/component=controller"
-// +kubebuilder:resource:scope=Namespaced,categories={external-secrets}
+// +kubebuilder:resource:scope=Namespaced,categories={external-secrets},shortName=ps
 
 
 type PushSecret struct {
 type PushSecret struct {
 	metav1.TypeMeta   `json:",inline"`
 	metav1.TypeMeta   `json:",inline"`

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

@@ -424,7 +424,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;QuayAccessToken;Password;STSSessionToken;UUID;VaultDynamicSecret;Webhook
+	// +kubebuilder:validation:Enum=ACRAccessToken;ClusterGenerator;ECRAuthorizationToken;Fake;GCRAccessToken;GithubAccessToken;QuayAccessToken;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

+ 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()
 }
 }

+ 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

+ 108 - 0
apis/generators/v1alpha1/generator_state_types.go

@@ -0,0 +1,108 @@
+/*
+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 (
+	corev1 "k8s.io/api/core/v1"
+	apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	runtime "k8s.io/apimachinery/pkg/runtime"
+)
+
+// +kubebuilder:object:root=false
+// +kubebuilder:object:generate:false
+// +k8s:deepcopy-gen:interfaces=nil
+// +k8s:deepcopy-gen=nil
+type StatefulResource interface {
+	runtime.Object
+	metav1.Object
+}
+
+const (
+	// The owner key points to the resource which created the generator state.
+	// It is used in the garbage collection process to identify all states
+	// that belong to a specific resource.
+	GeneratorStateLabelOwnerKey = "generators.external-secrets.io/owner-key"
+)
+
+type GeneratorStateSpec struct {
+	// GarbageCollectionDeadline is the time after which the generator state
+	// will be deleted.
+	// It is set by the controller which creates the generator state and
+	// can be set configured by the user.
+	// If the garbage collection deadline is not set the generator state will not be deleted.
+	GarbageCollectionDeadline *metav1.Time `json:"garbageCollectionDeadline,omitempty"`
+
+	// 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"`
+}
+
+type GeneratorStateConditionType string
+
+const (
+	GeneratorStateReady GeneratorStateConditionType = "Ready"
+)
+
+type GeneratorStateStatusCondition struct {
+	Type   GeneratorStateConditionType `json:"type"`
+	Status corev1.ConditionStatus      `json:"status"`
+
+	// +optional
+	Reason string `json:"reason,omitempty"`
+
+	// +optional
+	Message string `json:"message,omitempty"`
+
+	// +optional
+	LastTransitionTime metav1.Time `json:"lastTransitionTime,omitempty"`
+}
+
+const (
+	ConditionReasonCreated = "Created"
+	ConditionReasonError   = "Error"
+)
+
+type GeneratorStateStatus struct {
+	Conditions []GeneratorStateStatusCondition `json:"conditions,omitempty"`
+}
+
+// +kubebuilder:object:root=true
+// +kubebuilder:storageversion
+// +kubebuilder:metadata:labels="external-secrets.io/component=controller"
+// +kubebuilder:printcolumn:name="GC Deadline",type="string",JSONPath=".spec.garbageCollectionDeadline"
+// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"
+// +kubebuilder:resource:scope=Namespaced,categories={external-secrets, external-secrets-generators},shortName=gs
+type GeneratorState struct {
+	metav1.TypeMeta   `json:",inline"`
+	metav1.ObjectMeta `json:"metadata,omitempty"`
+
+	Spec   GeneratorStateSpec   `json:"spec,omitempty"`
+	Status GeneratorStateStatus `json:"status,omitempty"`
+}
+
+// +kubebuilder:object:root=true
+
+// GeneratorStateList contains a list of ExternalSecret resources.
+type GeneratorStateList struct {
+	metav1.TypeMeta `json:",inline"`
+	metav1.ListMeta `json:"metadata,omitempty"`
+	Items           []GeneratorState `json:"items"`
+}

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

@@ -124,6 +124,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()
@@ -133,6 +141,8 @@ var (
 )
 )
 
 
 func init() {
 func init() {
+	SchemeBuilder.Register(&GeneratorState{}, &GeneratorStateList{})
+
 	/*
 	/*
 		===============================================================================
 		===============================================================================
 		 NOTE: when adding support for new kinds of generators:
 		 NOTE: when adding support for new kinds of generators:
@@ -160,4 +170,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;QuayAccessToken;Password;STSSessionToken;UUID;VaultDynamicSecret;Webhook
+// +kubebuilder:validation:Enum=ACRAccessToken;ECRAuthorizationToken;Fake;GCRAccessToken;GithubAccessToken;QuayAccessToken'Password;STSSessionToken;UUID;VaultDynamicSecret;Webhook;Grafana
 type GeneratorKind string
 type GeneratorKind string
 
 
 const (
 const (
@@ -42,6 +42,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
@@ -58,6 +59,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.

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

@@ -0,0 +1,83 @@
+/*
+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 grafana 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 that will be created by ESO.
+	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.
+	// 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/
+	Token SecretKeySelector `json:"token"`
+}
+
+// GrafanaServiceAccountTokenState is the state type produced by the Grafana generator.
+// It contains the service account ID, login and token ID which is enough to
+// identify the service account.
+type GrafanaServiceAccountTokenState struct {
+	ServiceAccount GrafanaStateServiceAccount `json:"serviceAccount"`
+}
+
+// GrafanaStateServiceAccount contains the service account ID, login and token ID.
+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"`
+}

+ 292 - 9
apis/generators/v1alpha1/zz_generated.deepcopy.go

@@ -20,10 +20,10 @@ package v1alpha1
 
 
 import (
 import (
 	"github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1"
 	"github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1"
-	"github.com/external-secrets/external-secrets/apis/meta/v1"
-	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
-	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	runtime "k8s.io/apimachinery/pkg/runtime"
+	metav1 "github.com/external-secrets/external-secrets/apis/meta/v1"
+	"k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+	apismetav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
 )
 )
 
 
 // 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.
@@ -162,7 +162,7 @@ func (in *AWSAuthSecretRef) DeepCopyInto(out *AWSAuthSecretRef) {
 	in.SecretAccessKey.DeepCopyInto(&out.SecretAccessKey)
 	in.SecretAccessKey.DeepCopyInto(&out.SecretAccessKey)
 	if in.SessionToken != nil {
 	if in.SessionToken != nil {
 		in, out := &in.SessionToken, &out.SessionToken
 		in, out := &in.SessionToken, &out.SessionToken
-		*out = new(v1.SecretKeySelector)
+		*out = new(metav1.SecretKeySelector)
 		(*in).DeepCopyInto(*out)
 		(*in).DeepCopyInto(*out)
 	}
 	}
 }
 }
@@ -182,7 +182,7 @@ func (in *AWSJWTAuth) DeepCopyInto(out *AWSJWTAuth) {
 	*out = *in
 	*out = *in
 	if in.ServiceAccountRef != nil {
 	if in.ServiceAccountRef != nil {
 		in, out := &in.ServiceAccountRef, &out.ServiceAccountRef
 		in, out := &in.ServiceAccountRef, &out.ServiceAccountRef
-		*out = new(v1.ServiceAccountSelector)
+		*out = new(metav1.ServiceAccountSelector)
 		(*in).DeepCopyInto(*out)
 		(*in).DeepCopyInto(*out)
 	}
 	}
 }
 }
@@ -250,7 +250,7 @@ func (in *AzureACRWorkloadIdentityAuth) DeepCopyInto(out *AzureACRWorkloadIdenti
 	*out = *in
 	*out = *in
 	if in.ServiceAccountRef != nil {
 	if in.ServiceAccountRef != nil {
 		in, out := &in.ServiceAccountRef, &out.ServiceAccountRef
 		in, out := &in.ServiceAccountRef, &out.ServiceAccountRef
-		*out = new(v1.ServiceAccountSelector)
+		*out = new(metav1.ServiceAccountSelector)
 		(*in).DeepCopyInto(*out)
 		(*in).DeepCopyInto(*out)
 	}
 	}
 }
 }
@@ -698,6 +698,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.
@@ -710,6 +715,132 @@ func (in *GeneratorSpec) DeepCopy() *GeneratorSpec {
 	return 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
+	out.TypeMeta = in.TypeMeta
+	in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+	in.Spec.DeepCopyInto(&out.Spec)
+	in.Status.DeepCopyInto(&out.Status)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GeneratorState.
+func (in *GeneratorState) DeepCopy() *GeneratorState {
+	if in == nil {
+		return nil
+	}
+	out := new(GeneratorState)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *GeneratorState) 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 *GeneratorStateList) DeepCopyInto(out *GeneratorStateList) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ListMeta.DeepCopyInto(&out.ListMeta)
+	if in.Items != nil {
+		in, out := &in.Items, &out.Items
+		*out = make([]GeneratorState, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GeneratorStateList.
+func (in *GeneratorStateList) DeepCopy() *GeneratorStateList {
+	if in == nil {
+		return nil
+	}
+	out := new(GeneratorStateList)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *GeneratorStateList) 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 *GeneratorStateSpec) DeepCopyInto(out *GeneratorStateSpec) {
+	*out = *in
+	if in.GarbageCollectionDeadline != nil {
+		in, out := &in.GarbageCollectionDeadline, &out.GarbageCollectionDeadline
+		*out = (*in).DeepCopy()
+	}
+	if in.Resource != nil {
+		in, out := &in.Resource, &out.Resource
+		*out = new(v1.JSON)
+		(*in).DeepCopyInto(*out)
+	}
+	if in.State != nil {
+		in, out := &in.State, &out.State
+		*out = new(v1.JSON)
+		(*in).DeepCopyInto(*out)
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GeneratorStateSpec.
+func (in *GeneratorStateSpec) DeepCopy() *GeneratorStateSpec {
+	if in == nil {
+		return nil
+	}
+	out := new(GeneratorStateSpec)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *GeneratorStateStatus) DeepCopyInto(out *GeneratorStateStatus) {
+	*out = *in
+	if in.Conditions != nil {
+		in, out := &in.Conditions, &out.Conditions
+		*out = make([]GeneratorStateStatusCondition, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GeneratorStateStatus.
+func (in *GeneratorStateStatus) DeepCopy() *GeneratorStateStatus {
+	if in == nil {
+		return nil
+	}
+	out := new(GeneratorStateStatus)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *GeneratorStateStatusCondition) DeepCopyInto(out *GeneratorStateStatusCondition) {
+	*out = *in
+	in.LastTransitionTime.DeepCopyInto(&out.LastTransitionTime)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GeneratorStateStatusCondition.
+func (in *GeneratorStateStatusCondition) DeepCopy() *GeneratorStateStatusCondition {
+	if in == nil {
+		return nil
+	}
+	out := new(GeneratorStateStatusCondition)
+	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 *GithubAccessToken) DeepCopyInto(out *GithubAccessToken) {
 func (in *GithubAccessToken) DeepCopyInto(out *GithubAccessToken) {
 	*out = *in
 	*out = *in
@@ -828,6 +959,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
@@ -1250,7 +1533,7 @@ func (in *VaultDynamicSecretSpec) DeepCopyInto(out *VaultDynamicSecretSpec) {
 	*out = *in
 	*out = *in
 	if in.Parameters != nil {
 	if in.Parameters != nil {
 		in, out := &in.Parameters, &out.Parameters
 		in, out := &in.Parameters, &out.Parameters
-		*out = new(apiextensionsv1.JSON)
+		*out = new(v1.JSON)
 		(*in).DeepCopyInto(*out)
 		(*in).DeepCopyInto(*out)
 	}
 	}
 	if in.RetrySettings != nil {
 	if in.RetrySettings != nil {
@@ -1396,7 +1679,7 @@ func (in *WebhookSpec) DeepCopyInto(out *WebhookSpec) {
 	}
 	}
 	if in.Timeout != nil {
 	if in.Timeout != nil {
 		in, out := &in.Timeout, &out.Timeout
 		in, out := &in.Timeout, &out.Timeout
-		*out = new(metav1.Duration)
+		*out = new(apismetav1.Duration)
 		**out = **in
 		**out = **in
 	}
 	}
 	out.Result = in.Result
 	out.Result = in.Result

+ 16 - 3
cmd/controller/root.go

@@ -41,6 +41,7 @@ import (
 	ctrlcommon "github.com/external-secrets/external-secrets/pkg/controllers/common"
 	ctrlcommon "github.com/external-secrets/external-secrets/pkg/controllers/common"
 	"github.com/external-secrets/external-secrets/pkg/controllers/externalsecret"
 	"github.com/external-secrets/external-secrets/pkg/controllers/externalsecret"
 	"github.com/external-secrets/external-secrets/pkg/controllers/externalsecret/esmetrics"
 	"github.com/external-secrets/external-secrets/pkg/controllers/externalsecret/esmetrics"
+	"github.com/external-secrets/external-secrets/pkg/controllers/generatorstate"
 	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"
 	"github.com/external-secrets/external-secrets/pkg/controllers/pushsecret"
 	"github.com/external-secrets/external-secrets/pkg/controllers/pushsecret/psmetrics"
 	"github.com/external-secrets/external-secrets/pkg/controllers/pushsecret/psmetrics"
@@ -131,7 +132,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,
@@ -148,11 +149,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)
@@ -201,6 +202,18 @@ var rootCmd = &cobra.Command{
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
 		}
 		}
+		if err = (&generatorstate.Reconciler{
+			Client:     mgr.GetClient(),
+			Log:        ctrl.Log.WithName("controllers").WithName("GeneratorState"),
+			Scheme:     mgr.GetScheme(),
+			RestConfig: mgr.GetConfig(),
+		}).SetupWithManager(mgr, controller.Options{
+			MaxConcurrentReconciles: concurrent,
+			RateLimiter:             ctrlcommon.BuildRateLimiter(),
+		}); err != nil {
+			setupLog.Error(err, errCreateController, "controller", "GeneratorState")
+			os.Exit(1)
+		}
 		if err = (&externalsecret.Reconciler{
 		if err = (&externalsecret.Reconciler{
 			Client:                    mgr.GetClient(),
 			Client:                    mgr.GetClient(),
 			SecretClient:              secretClient,
 			SecretClient:              secretClient,

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

@@ -171,6 +171,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
@@ -367,6 +368,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

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

@@ -467,6 +467,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
@@ -662,6 +663,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

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

@@ -14,6 +14,8 @@ spec:
     kind: PushSecret
     kind: PushSecret
     listKind: PushSecretList
     listKind: PushSecretList
     plural: pushsecrets
     plural: pushsecrets
+    shortNames:
+    - ps
     singular: pushsecret
     singular: pushsecret
   scope: Namespaced
   scope: Namespaced
   versions:
   versions:
@@ -194,6 +196,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

+ 62 - 2
config/crds/bases/generators.external-secrets.io_clustergenerators.yaml

@@ -528,6 +528,66 @@ spec:
                     - auth
                     - auth
                     - installID
                     - installID
                     type: object
                     type: object
+                  grafanaSpec:
+                    description: GrafanaSpec controls the behavior of the grafana
+                      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.
+                              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/
+                            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 that
+                              will be created by ESO.
+                            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.
@@ -1739,12 +1799,12 @@ spec:
                 - Fake
                 - Fake
                 - GCRAccessToken
                 - GCRAccessToken
                 - GithubAccessToken
                 - GithubAccessToken
-                - QuayAccessToken
-                - Password
+                - QuayAccessToken'Password
                 - STSSessionToken
                 - STSSessionToken
                 - UUID
                 - UUID
                 - VaultDynamicSecret
                 - VaultDynamicSecret
                 - Webhook
                 - Webhook
+                - Grafana
                 type: string
                 type: string
             required:
             required:
             - generator
             - generator

+ 103 - 0
config/crds/bases/generators.external-secrets.io_generatorstates.yaml

@@ -0,0 +1,103 @@
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.17.1
+  labels:
+    external-secrets.io/component: controller
+  name: generatorstates.generators.external-secrets.io
+spec:
+  group: generators.external-secrets.io
+  names:
+    categories:
+    - external-secrets
+    - external-secrets-generators
+    kind: GeneratorState
+    listKind: GeneratorStateList
+    plural: generatorstates
+    shortNames:
+    - gs
+    singular: generatorstate
+  scope: Namespaced
+  versions:
+  - additionalPrinterColumns:
+    - jsonPath: .spec.garbageCollectionDeadline
+      name: GC Deadline
+      type: string
+    - jsonPath: .metadata.creationTimestamp
+      name: Age
+      type: date
+    name: v1alpha1
+    schema:
+      openAPIV3Schema:
+        properties:
+          apiVersion:
+            description: |-
+              APIVersion defines the versioned schema of this representation of an object.
+              Servers should convert recognized schemas to the latest internal value, and
+              may reject unrecognized values.
+              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+            type: string
+          kind:
+            description: |-
+              Kind is a string value representing the REST resource this object represents.
+              Servers may infer this from the endpoint the client submits requests to.
+              Cannot be updated.
+              In CamelCase.
+              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+            type: string
+          metadata:
+            type: object
+          spec:
+            properties:
+              garbageCollectionDeadline:
+                description: |-
+                  GarbageCollectionDeadline is the time after which the generator state
+                  will be deleted.
+                  It is set by the controller which creates the generator state and
+                  can be set configured by the user.
+                  If the garbage collection deadline is not set the generator state will not be deleted.
+                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:
+            - resource
+            - state
+            type: object
+          status:
+            properties:
+              conditions:
+                items:
+                  properties:
+                    lastTransitionTime:
+                      format: date-time
+                      type: string
+                    message:
+                      type: string
+                    reason:
+                      type: string
+                    status:
+                      type: string
+                    type:
+                      type: string
+                  required:
+                  - status
+                  - type
+                  type: object
+                type: array
+            type: object
+        type: object
+    served: true
+    storage: true
+    subresources: {}

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

@@ -0,0 +1,105 @@
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.17.1
+  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 grafana 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.
+                      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/
+                    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 that will
+                      be created by ESO.
+                    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: {}

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

@@ -12,7 +12,9 @@ resources:
   - generators.external-secrets.io_ecrauthorizationtokens.yaml
   - generators.external-secrets.io_ecrauthorizationtokens.yaml
   - 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_generatorstates.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_quayaccesstokens.yaml
   - generators.external-secrets.io_quayaccesstokens.yaml
   - generators.external-secrets.io_stssessiontokens.yaml
   - generators.external-secrets.io_stssessiontokens.yaml

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

@@ -47,6 +47,19 @@ rules:
     - "get"
     - "get"
     - "update"
     - "update"
     - "patch"
     - "patch"
+  - apiGroups:
+    - "generators.external-secrets.io"
+    resources:
+    - "generatorstates"
+    verbs:
+    - "get"
+    - "list"
+    - "watch"
+    - "create"
+    - "update"
+    - "patch"
+    - "delete"
+    - "deletecollection"
   - apiGroups:
   - apiGroups:
     - "generators.external-secrets.io"
     - "generators.external-secrets.io"
     resources:
     resources:
@@ -62,6 +75,7 @@ rules:
     - "uuids"
     - "uuids"
     - "vaultdynamicsecrets"
     - "vaultdynamicsecrets"
     - "webhooks"
     - "webhooks"
+    - "grafanas"
     verbs:
     verbs:
     - "get"
     - "get"
     - "list"
     - "list"
@@ -158,6 +172,8 @@ rules:
     - "passwords"
     - "passwords"
     - "vaultdynamicsecrets"
     - "vaultdynamicsecrets"
     - "webhooks"
     - "webhooks"
+    - "grafanas"
+    - "generatorstates"
     verbs:
     verbs:
       - "get"
       - "get"
       - "watch"
       - "watch"
@@ -205,6 +221,8 @@ rules:
     - "passwords"
     - "passwords"
     - "vaultdynamicsecrets"
     - "vaultdynamicsecrets"
     - "webhooks"
     - "webhooks"
+    - "grafanas"
+    - "generatorstates"
     verbs:
     verbs:
       - "create"
       - "create"
       - "delete"
       - "delete"

+ 2 - 0
deploy/charts/external-secrets/tests/__snapshot__/crds_test.yaml.snap

@@ -2833,6 +2833,8 @@ should match snapshot of default values:
                               properties:
                               properties:
                                 apiUrl:
                                 apiUrl:
                                   type: string
                                   type: string
+                                apiVersion:
+                                  type: string
                                 clientTimeOutSeconds:
                                 clientTimeOutSeconds:
                                   description: Timeout specifies a time limit for requests made by this Client. The timeout includes connection time, any redirects, and reading the response body. Defaults to 45 seconds.
                                   description: Timeout specifies a time limit for requests made by this Client. The timeout includes connection time, any redirects, and reading the response body. Defaults to 45 seconds.
                                   type: integer
                                   type: integer

+ 293 - 2
deploy/crds/bundle.yaml

@@ -161,6 +161,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
@@ -348,6 +349,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
@@ -6955,6 +6957,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
@@ -7142,6 +7145,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
@@ -7437,6 +7441,8 @@ spec:
     kind: PushSecret
     kind: PushSecret
     listKind: PushSecretList
     listKind: PushSecretList
     plural: pushsecrets
     plural: pushsecrets
+    shortNames:
+      - ps
     singular: pushsecret
     singular: pushsecret
   scope: Namespaced
   scope: Namespaced
   versions:
   versions:
@@ -7611,6 +7617,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
@@ -14357,6 +14364,63 @@ spec:
                         - auth
                         - auth
                         - installID
                         - installID
                       type: object
                       type: object
+                    grafanaSpec:
+                      description: GrafanaSpec controls the behavior of the grafana 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.
+                                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/
+                              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 that will be created by ESO.
+                              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:
@@ -15509,12 +15573,12 @@ spec:
                     - Fake
                     - Fake
                     - GCRAccessToken
                     - GCRAccessToken
                     - GithubAccessToken
                     - GithubAccessToken
-                    - QuayAccessToken
-                    - Password
+                    - QuayAccessToken'Password
                     - STSSessionToken
                     - STSSessionToken
                     - UUID
                     - UUID
                     - VaultDynamicSecret
                     - VaultDynamicSecret
                     - Webhook
                     - Webhook
+                    - Grafana
                   type: string
                   type: string
               required:
               required:
                 - generator
                 - generator
@@ -15962,6 +16026,119 @@ spec:
 ---
 ---
 apiVersion: apiextensions.k8s.io/v1
 apiVersion: apiextensions.k8s.io/v1
 kind: CustomResourceDefinition
 kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.17.1
+  labels:
+    external-secrets.io/component: controller
+  name: generatorstates.generators.external-secrets.io
+spec:
+  group: generators.external-secrets.io
+  names:
+    categories:
+      - external-secrets
+      - external-secrets-generators
+    kind: GeneratorState
+    listKind: GeneratorStateList
+    plural: generatorstates
+    shortNames:
+      - gs
+    singular: generatorstate
+  scope: Namespaced
+  versions:
+    - additionalPrinterColumns:
+        - jsonPath: .spec.garbageCollectionDeadline
+          name: GC Deadline
+          type: string
+        - jsonPath: .metadata.creationTimestamp
+          name: Age
+          type: date
+      name: v1alpha1
+      schema:
+        openAPIV3Schema:
+          properties:
+            apiVersion:
+              description: |-
+                APIVersion defines the versioned schema of this representation of an object.
+                Servers should convert recognized schemas to the latest internal value, and
+                may reject unrecognized values.
+                More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+              type: string
+            kind:
+              description: |-
+                Kind is a string value representing the REST resource this object represents.
+                Servers may infer this from the endpoint the client submits requests to.
+                Cannot be updated.
+                In CamelCase.
+                More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+              type: string
+            metadata:
+              type: object
+            spec:
+              properties:
+                garbageCollectionDeadline:
+                  description: |-
+                    GarbageCollectionDeadline is the time after which the generator state
+                    will be deleted.
+                    It is set by the controller which creates the generator state and
+                    can be set configured by the user.
+                    If the garbage collection deadline is not set the generator state will not be deleted.
+                  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:
+                - resource
+                - state
+              type: object
+            status:
+              properties:
+                conditions:
+                  items:
+                    properties:
+                      lastTransitionTime:
+                        format: date-time
+                        type: string
+                      message:
+                        type: string
+                      reason:
+                        type: string
+                      status:
+                        type: string
+                      type:
+                        type: string
+                    required:
+                      - status
+                      - type
+                    type: object
+                  type: array
+              type: object
+          type: object
+      served: true
+      storage: true
+      subresources: {}
+  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.17.1
     controller-gen.kubebuilder.io/version: v0.17.1
@@ -16085,6 +16262,120 @@ spec:
 ---
 ---
 apiVersion: apiextensions.k8s.io/v1
 apiVersion: apiextensions.k8s.io/v1
 kind: CustomResourceDefinition
 kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.17.1
+  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 grafana 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.
+                        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/
+                      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 that will be created by ESO.
+                      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.17.1
     controller-gen.kubebuilder.io/version: v0.17.1

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

@@ -0,0 +1,155 @@
+```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 a dedicated custom resource `GeneratorState`.
+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 a `GeneratorState` resource. We identify the "latest" state by it's creationTimestamp. We can  map the GeneratorState to the resource it's originating from by using a label on the GeneratorState which stores a key which points to it.
+- when a secret is **rotated**, a controller sets the `GeneratorState.spec.garbageCollectionDeadline`. That will eventually trigger the deletion of the generator state after a configurable grace period. 
+- A separate GeneratorState controller will ensure that the resource gets eventually deleted after the gc deadline has passed.
+
+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: GeneratorState
+metadata:
+  labels:
+    generators.external-secrets.io/owner-key: "externalsecret-foobar-0"
+  finalizers:
+    - generatorstate.externalsecrets.io/finalizer
+spec: 
+  garbageCollectionDeadline: "2024-12-10T22:30:05Z"
+  resource: { }
+  state: { }
+```

+ 14 - 3
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"
@@ -185,13 +186,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
 }
 }

+ 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
 		}
 		}

+ 11 - 0
e2e/go.mod

@@ -55,6 +55,7 @@ require (
 	github.com/fluxcd/pkg/apis/meta v1.2.0
 	github.com/fluxcd/pkg/apis/meta v1.2.0
 	github.com/fluxcd/source-controller/api v1.2.3
 	github.com/fluxcd/source-controller/api v1.2.3
 	github.com/golang-jwt/jwt/v4 v4.5.1
 	github.com/golang-jwt/jwt/v4 v4.5.1
+	github.com/grafana/grafana-openapi-client-go v0.0.0-20240826142251-d1c93bae4198
 	github.com/hashicorp/vault/api v1.15.0
 	github.com/hashicorp/vault/api v1.15.0
 	github.com/onsi/ginkgo/v2 v2.22.2
 	github.com/onsi/ginkgo/v2 v2.22.2
 	github.com/onsi/gomega v1.36.2
 	github.com/onsi/gomega v1.36.2
@@ -95,6 +96,7 @@ require (
 	github.com/Masterminds/goutils v1.1.1 // indirect
 	github.com/Masterminds/goutils v1.1.1 // indirect
 	github.com/Masterminds/semver/v3 v3.3.1 // indirect
 	github.com/Masterminds/semver/v3 v3.3.1 // indirect
 	github.com/Masterminds/sprig/v3 v3.3.0 // indirect
 	github.com/Masterminds/sprig/v3 v3.3.0 // indirect
+	github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
 	github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect
 	github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect
 	github.com/cenkalti/backoff/v4 v4.3.0 // indirect
 	github.com/cenkalti/backoff/v4 v4.3.0 // indirect
@@ -113,9 +115,16 @@ require (
 	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/logr v1.4.2 // indirect
 	github.com/go-logr/logr v1.4.2 // 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/errors v0.22.0 // indirect
 	github.com/go-openapi/jsonpointer v0.21.0 // indirect
 	github.com/go-openapi/jsonpointer v0.21.0 // indirect
 	github.com/go-openapi/jsonreference v0.21.0 // indirect
 	github.com/go-openapi/jsonreference v0.21.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/strfmt v0.23.0 // indirect
 	github.com/go-openapi/swag v0.23.0 // indirect
 	github.com/go-openapi/swag v0.23.0 // indirect
+	github.com/go-openapi/validate v0.24.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/goccy/go-json v0.10.4 // indirect
 	github.com/goccy/go-json v0.10.4 // indirect
 	github.com/godbus/dbus/v5 v5.1.0 // indirect
 	github.com/godbus/dbus/v5 v5.1.0 // indirect
@@ -167,6 +176,7 @@ require (
 	github.com/modern-go/reflect2 v1.0.2 // indirect
 	github.com/modern-go/reflect2 v1.0.2 // indirect
 	github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
 	github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
 	github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
 	github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
+	github.com/oklog/ulid v1.3.1 // indirect
 	github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect
 	github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect
 	github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
 	github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
 	github.com/pkg/errors v0.9.1 // indirect
 	github.com/pkg/errors v0.9.1 // indirect
@@ -187,6 +197,7 @@ require (
 	github.com/tidwall/sjson v1.2.5 // indirect
 	github.com/tidwall/sjson v1.2.5 // indirect
 	github.com/x448/float16 v0.8.4 // indirect
 	github.com/x448/float16 v0.8.4 // indirect
 	github.com/zalando/go-keyring v0.2.6 // indirect
 	github.com/zalando/go-keyring v0.2.6 // indirect
+	go.mongodb.org/mongo-driver v1.17.2 // indirect
 	go.opentelemetry.io/auto/sdk v1.1.0 // indirect
 	go.opentelemetry.io/auto/sdk v1.1.0 // indirect
 	go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 // indirect
 	go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 // indirect
 	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect
 	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect

+ 22 - 0
e2e/go.sum

@@ -119,6 +119,8 @@ github.com/aliyun/alibaba-cloud-sdk-go v1.62.271 h1:0QmSDMovuCyUbYp70MZHoTi/GYnH
 github.com/aliyun/alibaba-cloud-sdk-go v1.62.271/go.mod h1:Api2AkmMgGaSUAhmk76oaFObkoeCPc/bKAqcyplPODs=
 github.com/aliyun/alibaba-cloud-sdk-go v1.62.271/go.mod h1:Api2AkmMgGaSUAhmk76oaFObkoeCPc/bKAqcyplPODs=
 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
+github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
+github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
 github.com/aws/aws-sdk-go v1.41.13/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
 github.com/aws/aws-sdk-go v1.41.13/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
 github.com/aws/aws-sdk-go v1.55.6 h1:cSg4pvZ3m8dgYcgqB97MrcdjUmZ1BeMYKUxMMB89IPk=
 github.com/aws/aws-sdk-go v1.55.6 h1:cSg4pvZ3m8dgYcgqB97MrcdjUmZ1BeMYKUxMMB89IPk=
 github.com/aws/aws-sdk-go v1.55.6/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
 github.com/aws/aws-sdk-go v1.55.6/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
@@ -201,12 +203,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/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/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-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
 github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
 github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
 github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
 github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho=
 github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho=
@@ -323,6 +339,8 @@ github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrk
 github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
 github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
 github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
 github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
 github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
 github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+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=
@@ -424,6 +442,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
 github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus=
 github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus=
 github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
 github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
+github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4=
+github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
 github.com/onsi/ginkgo/v2 v2.22.2 h1:/3X8Panh8/WwhU/3Ssa6rCKqPLuAkVY2I0RoyDLySlU=
 github.com/onsi/ginkgo/v2 v2.22.2 h1:/3X8Panh8/WwhU/3Ssa6rCKqPLuAkVY2I0RoyDLySlU=
 github.com/onsi/ginkgo/v2 v2.22.2/go.mod h1:oeMosUL+8LtarXBHu/c0bx2D/K9zyQ6uX3cTyztHwsk=
 github.com/onsi/ginkgo/v2 v2.22.2/go.mod h1:oeMosUL+8LtarXBHu/c0bx2D/K9zyQ6uX3cTyztHwsk=
 github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8=
 github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8=
@@ -515,6 +535,8 @@ github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8u
 github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI=
 github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI=
 gitlab.com/gitlab-org/api/client-go v0.120.0 h1:geCJjojDXxWVmUcTxPcOUCenAWElWB5dVfX3HJGeAMc=
 gitlab.com/gitlab-org/api/client-go v0.120.0 h1:geCJjojDXxWVmUcTxPcOUCenAWElWB5dVfX3HJGeAMc=
 gitlab.com/gitlab-org/api/client-go v0.120.0/go.mod h1:ygHmS3AU3TpvK+AC6DYO1QuAxLlv6yxYK+/Votr/WFQ=
 gitlab.com/gitlab-org/api/client-go v0.120.0/go.mod h1:ygHmS3AU3TpvK+AC6DYO1QuAxLlv6yxYK+/Votr/WFQ=
+go.mongodb.org/mongo-driver v1.17.2 h1:gvZyk8352qSfzyZ2UMWcpDpMSGEr1eqE4T793SqyhzM=
+go.mongodb.org/mongo-driver v1.17.2/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
 go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
 go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
 go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
 go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
 go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
 go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=

+ 2 - 0
e2e/run.sh

@@ -87,6 +87,8 @@ kubectl run --rm \
   --env="SECRETSERVER_USERNAME=${SECRETSERVER_USERNAME:-}" \
   --env="SECRETSERVER_USERNAME=${SECRETSERVER_USERNAME:-}" \
   --env="SECRETSERVER_PASSWORD=${SECRETSERVER_PASSWORD:-}" \
   --env="SECRETSERVER_PASSWORD=${SECRETSERVER_PASSWORD:-}" \
   --env="SECRETSERVER_URL=${SECRETSERVER_URL:-}" \
   --env="SECRETSERVER_URL=${SECRETSERVER_URL:-}" \
+  --env="GRAFANA_URL=${GRAFANA_URL:-}" \
+  --env="GRAFANA_TOKEN=${GRAFANA_TOKEN:-}" \
   --env="VERSION=${VERSION}" \
   --env="VERSION=${VERSION}" \
   --env="TEST_SUITES=${TEST_SUITES}" \
   --env="TEST_SUITES=${TEST_SUITES}" \
   --overrides='{ "apiVersion": "v1", "spec":{"serviceAccountName": "external-secrets-e2e"}}' \
   --overrides='{ "apiVersion": "v1", "spec":{"serviceAccountName": "external-secrets-e2e"}}' \

+ 200 - 0
e2e/suites/generator/grafana.go

@@ -0,0 +1,200 @@
+/*
+Copyright 2020 The cert-manager Authors.
+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 generator
+
+import (
+	"context"
+	"os"
+	"strings"
+	"time"
+
+	//nolint
+	. "github.com/onsi/gomega"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	// nolint
+	. "github.com/onsi/ginkgo/v2"
+	v1 "k8s.io/api/core/v1"
+	"k8s.io/utils/ptr"
+
+	"github.com/external-secrets/external-secrets-e2e/framework"
+	esv1beta1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1"
+	genv1alpha1 "github.com/external-secrets/external-secrets/apis/generators/v1alpha1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	grafanaclient "github.com/grafana/grafana-openapi-client-go/client"
+	grafanasearch "github.com/grafana/grafana-openapi-client-go/client/search"
+	grafanasa "github.com/grafana/grafana-openapi-client-go/client/service_accounts"
+)
+
+var _ = Describe("grafana generator", Label("grafana"), func() {
+	f := framework.New("grafana")
+	const grafanaCredsSecretName = "grafana-creds"
+
+	grafanaClient := newGrafanaClient()
+
+	BeforeEach(func() {
+		// grafana instance may need to load for a bit
+		// we'll wake it up here and wait for it to be ready
+		Eventually(func() error {
+			_, err := grafanaClient.Search.Search(&grafanasearch.SearchParams{})
+			return err
+		}).WithPolling(time.Second * 15).WithTimeout(time.Minute * 5).ShouldNot(HaveOccurred())
+	})
+
+	AfterEach(func() {
+		// ESO does clean up tokens, but not the service accounts.
+		accounts, err := grafanaClient.ServiceAccounts.SearchOrgServiceAccountsWithPaging(&grafanasa.SearchOrgServiceAccountsWithPagingParams{
+			Perpage: ptr.To(int64(100)),
+			Page:    ptr.To(int64(1)),
+			Query:   ptr.To(f.Namespace.Name),
+		})
+		Expect(err).ToNot(HaveOccurred())
+		if accounts.GetPayload().ServiceAccounts != nil && len(accounts.GetPayload().ServiceAccounts) > 0 {
+			for _, sa := range accounts.GetPayload().ServiceAccounts {
+				_, err := grafanaClient.ServiceAccounts.DeleteServiceAccount(sa.ID)
+				Expect(err).ToNot(HaveOccurred())
+			}
+		}
+	})
+
+	setupGenerator := func(tc *testCase) {
+		err := f.CRClient.Create(context.Background(), &v1.Secret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      grafanaCredsSecretName,
+				Namespace: f.Namespace.Name,
+			},
+			Data: map[string][]byte{
+				"grafana-token": []byte(os.Getenv("GRAFANA_TOKEN")),
+			},
+		})
+		Expect(err).ToNot(HaveOccurred())
+		tc.Generator = &genv1alpha1.Grafana{
+			TypeMeta: metav1.TypeMeta{
+				APIVersion: genv1alpha1.Group + "/" + genv1alpha1.Version,
+				Kind:       genv1alpha1.GrafanaKind,
+			},
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      generatorName,
+				Namespace: f.Namespace.Name,
+			},
+			Spec: genv1alpha1.GrafanaSpec{
+				URL: os.Getenv("GRAFANA_URL"),
+				ServiceAccount: genv1alpha1.GrafanaServiceAccount{
+					Name: f.Namespace.Name,
+					Role: "Viewer",
+				},
+				Auth: genv1alpha1.GrafanaAuth{
+					Token: genv1alpha1.SecretKeySelector{
+						Name: grafanaCredsSecretName,
+						Key:  "grafana-token",
+					},
+				},
+			},
+		}
+		tc.ExternalSecret.Spec.DataFrom = []esv1beta1.ExternalSecretDataFromRemoteRef{
+			{
+				SourceRef: &esv1beta1.StoreGeneratorSourceRef{
+					GeneratorRef: &esv1beta1.GeneratorRef{
+						Kind: "Grafana",
+						Name: generatorName,
+					},
+				},
+			},
+		}
+	}
+
+	ensureExternalSecretPurgesGeneratorState := func(tc *testCase) {
+		// delete ES to trigger cleanup of generator state
+		err := f.CRClient.Delete(context.Background(), tc.ExternalSecret)
+		Expect(err).ToNot(HaveOccurred())
+
+		By("waiting for generator state to be cleaned up")
+		// wait for generator state to be cleaned up
+		Eventually(func() int {
+			generatorStates := &genv1alpha1.GeneratorStateList{}
+			err := f.CRClient.List(context.Background(), generatorStates, client.InNamespace(f.Namespace.Name))
+			if err != nil {
+				return -1
+			}
+			GinkgoLogr.Info("found generator states", "states", generatorStates.Items)
+			return len(generatorStates.Items)
+		}).WithPolling(time.Second * 1).WithTimeout(time.Minute * 2).Should(BeZero())
+	}
+
+	tokenIsUsable := func(tc *testCase) {
+		tc.AfterSync = func(secret *v1.Secret) {
+			// ensure token exists and is usable
+			Expect(string(secret.Data["token"])).ToNot(BeEmpty())
+
+			_, err := grafanaClient.Search.Search(&grafanasearch.SearchParams{
+				Query: ptr.To(""),
+			})
+			Expect(err).ToNot(HaveOccurred())
+			ensureExternalSecretPurgesGeneratorState(tc)
+		}
+	}
+
+	cleanupServiceAccountsAfterDeletion := func(tc *testCase) {
+		tc.ExternalSecret.Spec.RefreshInterval = &metav1.Duration{Duration: time.Second * 10}
+		tc.AfterSync = func(secret *v1.Secret) {
+			// Wait for ES to be rotated a couple of times,
+			// this should create a couple of service accounts.
+			// This allows us to verify that the service accounts are cleaned up
+			// after the generator is deleted.
+			Eventually(func() bool {
+				generatorStates := &genv1alpha1.GeneratorStateList{}
+				err := f.CRClient.List(context.Background(), generatorStates, client.InNamespace(f.Namespace.Name))
+				Expect(err).ToNot(HaveOccurred())
+				GinkgoLogr.Info("generator states", "states", generatorStates.Items)
+				return len(generatorStates.Items) > 2
+			}).WithPolling(time.Second * 10).WithTimeout(time.Minute * 5).Should(BeTrue())
+
+			ensureExternalSecretPurgesGeneratorState(tc)
+
+			// ensure service accounts are cleaned up
+			saList, err := grafanaClient.ServiceAccounts.SearchOrgServiceAccountsWithPaging(&grafanasa.SearchOrgServiceAccountsWithPagingParams{
+				Perpage: ptr.To(int64(100)),
+				Page:    ptr.To(int64(1)),
+				Query:   ptr.To(f.Namespace.Name),
+			})
+			Expect(err).ToNot(HaveOccurred())
+			Expect(saList.GetPayload().ServiceAccounts).To(HaveLen(1))
+			tokens, err := grafanaClient.ServiceAccounts.ListTokensWithParams(&grafanasa.ListTokensParams{
+				ServiceAccountID: saList.GetPayload().ServiceAccounts[0].ID,
+			})
+			Expect(err).ToNot(HaveOccurred())
+			Expect(tokens.GetPayload()).To(BeEmpty())
+		}
+	}
+
+	DescribeTable("generate secrets with grafana generator", generatorTableFunc,
+		Entry("should generate a token that can be used to access the API", f, setupGenerator, tokenIsUsable),
+		Entry("deleting a generator should cleanup the generated service accounts", f, setupGenerator, cleanupServiceAccountsAfterDeletion),
+	)
+})
+
+func newGrafanaClient() *grafanaclient.GrafanaHTTPAPI {
+	url := strings.TrimPrefix(os.Getenv("GRAFANA_URL"), "https://")
+	return grafanaclient.NewHTTPClientWithConfig(nil, &grafanaclient.TransportConfig{
+		Host:         url,
+		BasePath:     "/api",
+		Schemes:      []string{"https"},
+		APIKey:       os.Getenv("GRAFANA_TOKEN"),
+		NumRetries:   15,
+		RetryTimeout: time.Second * 6,
+	})
+}

+ 1 - 1
e2e/suites/generator/testcase.go

@@ -40,7 +40,7 @@ type testCase struct {
 }
 }
 
 
 var (
 var (
-	generatorName = "myfake"
+	generatorName = "my-generator"
 )
 )
 
 
 func generatorTableFunc(f *framework.Framework, tweaks ...func(*testCase)) {
 func generatorTableFunc(f *framework.Framework, tweaks ...func(*testCase)) {

+ 6 - 0
go.mod

@@ -81,6 +81,7 @@ require (
 	github.com/fortanix/sdkms-client-go v0.4.0
 	github.com/fortanix/sdkms-client-go v0.4.0
 	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 +128,11 @@ require (
 	github.com/gabriel-vasile/mimetype v1.4.8 // indirect
 	github.com/gabriel-vasile/mimetype v1.4.8 // 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.24.0 // indirect
 	github.com/go-playground/validator/v10 v10.24.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

+ 12 - 0
go.sum

@@ -291,16 +291,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=
@@ -428,6 +438,8 @@ github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrk
 github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
 github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
 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=

+ 6 - 22
pkg/controllers/externalsecret/externalsecret_controller.go

@@ -51,6 +51,7 @@ 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/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"
 
 
@@ -574,7 +575,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 {
@@ -773,23 +774,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
 }
 }
@@ -876,7 +860,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
 	}
 	}
 
 
@@ -964,11 +948,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(),
 			},
 			},
 		}
 		}
 	}
 	}

+ 45 - 14
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,6 +27,7 @@ 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"
 
 
@@ -36,6 +38,7 @@ import (
 
 
 // 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
@@ -43,23 +46,37 @@ func (r *Reconciler) getProviderSecretData(ctx context.Context, externalSecret *
 	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)
 
 
+	// statemanager takes care of managing the state of the generators.
+	// Since ExternalSecrets can have multiple generators, we need to keep track of the state of each generator
+	// and if one fails we need to rollback all generated values from this iteration.
+	genState := statemanager.New(ctx, r.Client, r.Scheme, externalSecret.Namespace, externalSecret)
+	defer func() {
+		if err != nil {
+			if rollBackErr := genState.Rollback(); rollBackErr != nil {
+				r.Log.Error(rollBackErr, "error rolling back generator state")
+			}
+			return
+		}
+		if commitErr := genState.Commit(); commitErr != nil {
+			r.Log.Error(commitErr, "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)
+			secretMap, err = r.handleFindAllSecrets(ctx, externalSecret, remoteRef, mgr, genState, i)
 			if err != nil {
 			if err != nil {
 				err = fmt.Errorf("error processing spec.dataFrom[%d].find, err: %w", i, err)
 				err = fmt.Errorf("error processing spec.dataFrom[%d].find, err: %w", i, err)
 			}
 			}
 		} else if remoteRef.Extract != nil {
 		} else if remoteRef.Extract != nil {
-			secretMap, err = r.handleExtractSecrets(ctx, externalSecret, remoteRef, mgr)
+			secretMap, err = r.handleExtractSecrets(ctx, externalSecret, remoteRef, mgr, genState, i)
 			if err != nil {
 			if err != nil {
 				err = fmt.Errorf("error processing spec.dataFrom[%d].extract, err: %w", i, err)
 				err = fmt.Errorf("error processing spec.dataFrom[%d].extract, err: %w", i, err)
 			}
 			}
 		} 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)
+			secretMap, err = r.handleGenerateSecrets(ctx, externalSecret.Namespace, remoteRef, i, genState)
 			if err != nil {
 			if err != nil {
 				err = fmt.Errorf("error processing spec.dataFrom[%d].sourceRef.generatorRef, err: %w", i, err)
 				err = fmt.Errorf("error processing spec.dataFrom[%d].sourceRef.generatorRef, err: %w", i, err)
 			}
 			}
@@ -123,18 +140,23 @@ func toStoreGenSourceRef(ref *esv1beta1.StoreSourceRef) *esv1beta1.StoreGenerato
 	}
 	}
 }
 }
 
 
-func (r *Reconciler) handleGenerateSecrets(ctx context.Context, namespace string, remoteRef esv1beta1.ExternalSecretDataFromRemoteRef) (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) {
+	impl, generatorResource, err := resolvers.GeneratorRef(ctx, r.Client, r.Scheme, namespace, remoteRef.SourceRef.GeneratorRef)
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
-
-	// use the generator
-	secretMap, err := gen.Generate(ctx, obj, r.Client, namespace)
+	latestState, err := generatorState.GetLatestState(generatorStateKey(i))
+	if err != nil {
+		return nil, fmt.Errorf("unable to get latest state: %w", err)
+	}
+	secretMap, newState, err := impl.Generate(ctx, generatorResource, r.Client, namespace)
 	if err != nil {
 	if err != nil {
 		return nil, fmt.Errorf(errGenerate, err)
 		return nil, fmt.Errorf(errGenerate, err)
 	}
 	}
-
+	if latestState != nil {
+		generatorState.EnqueueMoveStateToGC(generatorStateKey(i))
+	}
+	generatorState.EnqueueSetLatest(ctx, generatorStateKey(i), namespace, generatorResource, impl, newState)
 	// rewrite the keys if needed
 	// rewrite the keys if needed
 	secretMap, err = utils.RewriteMap(remoteRef.Rewrite, secretMap)
 	secretMap, err = utils.RewriteMap(remoteRef.Rewrite, secretMap)
 	if err != nil {
 	if err != nil {
@@ -150,7 +172,14 @@ 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) (map[string][]byte, error) {
+// We're using the index of the generator as the key for the generator state
+// this is because we can have multiple generators in the same ExternalSecret
+// and we need to keep track of the state of each generator.
+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
@@ -186,10 +215,11 @@ func (r *Reconciler) handleExtractSecrets(ctx context.Context, externalSecret *e
 		return nil, fmt.Errorf(errDecode, remoteRef.Extract.DecodingStrategy, err)
 		return nil, fmt.Errorf(errDecode, remoteRef.Extract.DecodingStrategy, 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) (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
@@ -224,7 +254,8 @@ func (r *Reconciler) handleFindAllSecrets(ctx context.Context, externalSecret *e
 	if err != nil {
 	if err != nil {
 		return nil, fmt.Errorf(errDecode, remoteRef.Find.DecodingStrategy, err)
 		return nil, fmt.Errorf(errDecode, remoteRef.Find.DecodingStrategy, 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"
 
 
@@ -2403,7 +2404,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())
 
 
@@ -2427,7 +2428,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())
 
 
@@ -2448,7 +2449,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
@@ -2467,7 +2468,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())
 		})
 		})
 
 
@@ -2484,7 +2485,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())
 		})
 		})
 
 
@@ -2499,20 +2500,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",
@@ -2522,7 +2523,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",
@@ -2531,7 +2532,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",
@@ -2544,21 +2545,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",

+ 141 - 0
pkg/controllers/generatorstate/generatorstate_controller.go

@@ -0,0 +1,141 @@
+/*
+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 generatorstate
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	"github.com/go-logr/logr"
+	v1 "k8s.io/api/core/v1"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/client-go/rest"
+	"k8s.io/client-go/tools/record"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/controller"
+	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+
+	genv1alpha1 "github.com/external-secrets/external-secrets/apis/generators/v1alpha1"
+)
+
+type Reconciler struct {
+	client.Client
+
+	Log        logr.Logger
+	Scheme     *runtime.Scheme
+	RestConfig *rest.Config
+	recorder   record.EventRecorder
+}
+
+const generatorStateFinalizer = "generatorstate.externalsecrets.io/finalizer"
+
+func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, err error) {
+	generatorState := &genv1alpha1.GeneratorState{}
+	err = r.Get(ctx, req.NamespacedName, generatorState)
+	if err != nil {
+		if apierrors.IsNotFound(err) {
+			return ctrl.Result{}, nil
+		}
+		return ctrl.Result{}, err
+	}
+
+	requeue, err := r.handleFinalizer(ctx, generatorState)
+	if err != nil {
+		return ctrl.Result{}, err
+	}
+	if requeue {
+		return ctrl.Result{Requeue: true}, nil
+	}
+
+	if generatorState.Spec.GarbageCollectionDeadline != nil {
+		if generatorState.Spec.GarbageCollectionDeadline.Time.Before(time.Now()) {
+			if err := r.Client.Delete(ctx, generatorState, &client.DeleteOptions{}); err != nil {
+				r.markAsFailed("could not delete GeneratorState", err, generatorState)
+				return ctrl.Result{}, fmt.Errorf("could not delete GeneratorState: %w", err)
+			}
+			r.markSuccess("Reached gc deadline", generatorState)
+			return ctrl.Result{}, nil
+		}
+		return ctrl.Result{
+			RequeueAfter: time.Until(generatorState.Spec.GarbageCollectionDeadline.Time),
+		}, nil
+	}
+
+	r.markSuccess("GeneratorState created", generatorState)
+	return ctrl.Result{}, nil
+}
+
+func (r *Reconciler) handleFinalizer(ctx context.Context, generatorState *genv1alpha1.GeneratorState) (bool, error) {
+	if generatorState.ObjectMeta.DeletionTimestamp.IsZero() {
+		if added := controllerutil.AddFinalizer(generatorState, generatorStateFinalizer); added {
+			if err := r.Client.Update(ctx, generatorState, &client.UpdateOptions{}); err != nil {
+				return false, fmt.Errorf("could not update finalizers: %w", err)
+			}
+			return true, nil
+		}
+	} else if controllerutil.ContainsFinalizer(generatorState, generatorStateFinalizer) {
+		gen, err := r.getGenerator(generatorState.Spec.Resource.Raw)
+		if err != nil {
+			r.markAsFailed("could not get generator", err, generatorState)
+			return false, fmt.Errorf("could not get generator: %w", err)
+		}
+
+		if err := gen.Cleanup(ctx, generatorState.Spec.Resource, generatorState.Spec.State, r.Client, generatorState.Namespace); err != nil {
+			r.markAsFailed("could not cleanup generator state", err, generatorState)
+			return false, fmt.Errorf("could not cleanup generator state: %w", err)
+		}
+
+		controllerutil.RemoveFinalizer(generatorState, generatorStateFinalizer)
+		if err := r.Client.Update(ctx, generatorState, &client.UpdateOptions{}); err != nil {
+			return false, fmt.Errorf("could not update finalizers: %w", err)
+		}
+	}
+	return false, nil
+}
+
+func (r *Reconciler) getGenerator(resource []byte) (genv1alpha1.Generator, error) {
+	us := &unstructured.Unstructured{}
+	if err := us.UnmarshalJSON(resource); err != nil {
+		return nil, fmt.Errorf("unable to unmarshal resource: %w", err)
+	}
+	gen, ok := genv1alpha1.GetGeneratorByName(us.GroupVersionKind().Kind)
+	if !ok {
+		return nil, fmt.Errorf("generator not found")
+	}
+	return gen, nil
+}
+
+func (r *Reconciler) markAsFailed(msg string, err error, gs *genv1alpha1.GeneratorState) {
+	conditionSynced := NewGeneratorStateCondition(genv1alpha1.GeneratorStateReady, v1.ConditionFalse, genv1alpha1.ConditionReasonError, fmt.Sprintf("%s: %v", msg, err))
+	SetGeneratorStateCondition(gs, *conditionSynced)
+}
+
+func (r *Reconciler) markSuccess(msg string, gs *genv1alpha1.GeneratorState) {
+	newReadyCondition := NewGeneratorStateCondition(genv1alpha1.GeneratorStateReady, v1.ConditionTrue, genv1alpha1.ConditionReasonCreated, msg)
+	SetGeneratorStateCondition(gs, *newReadyCondition)
+}
+
+// SetupWithManager returns a new controller builder that will be started by the provided Manager.
+func (r *Reconciler) SetupWithManager(mgr ctrl.Manager, opts controller.Options) error {
+	r.recorder = mgr.GetEventRecorderFor("external-secrets")
+	return ctrl.NewControllerManagedBy(mgr).
+		WithOptions(opts).
+		For(&genv1alpha1.GeneratorState{}).
+		Complete(r)
+}

+ 73 - 0
pkg/controllers/generatorstate/util.go

@@ -0,0 +1,73 @@
+/*
+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 generatorstate
+
+import (
+	v1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	genv1alpha1 "github.com/external-secrets/external-secrets/apis/generators/v1alpha1"
+)
+
+// NewgeneratorstateCondition a set of default options for creating an GeneratorState Condition.
+func NewGeneratorStateCondition(condType genv1alpha1.GeneratorStateConditionType, status v1.ConditionStatus, reason, message string) *genv1alpha1.GeneratorStateStatusCondition {
+	return &genv1alpha1.GeneratorStateStatusCondition{
+		Type:               condType,
+		Status:             status,
+		LastTransitionTime: metav1.Now(),
+		Reason:             reason,
+		Message:            message,
+	}
+}
+
+// GetgeneratorstateCondition returns the condition with the provided type.
+func GetGeneratorStateCondition(status genv1alpha1.GeneratorStateStatus, condType genv1alpha1.GeneratorStateConditionType) *genv1alpha1.GeneratorStateStatusCondition {
+	for _, c := range status.Conditions {
+		if c.Type == condType {
+			return &c
+		}
+	}
+	return nil
+}
+
+// SetGeneratorStateCondition updates the GeneratorState to include the provided
+// condition.
+func SetGeneratorStateCondition(gs *genv1alpha1.GeneratorState, condition genv1alpha1.GeneratorStateStatusCondition) {
+	currentCond := GetGeneratorStateCondition(gs.Status, condition.Type)
+
+	if currentCond != nil && currentCond.Status == condition.Status &&
+		currentCond.Reason == condition.Reason && currentCond.Message == condition.Message {
+		return
+	}
+
+	// Do not update lastTransitionTime if the status of the condition doesn't change.
+	if currentCond != nil && currentCond.Status == condition.Status {
+		condition.LastTransitionTime = currentCond.LastTransitionTime
+	}
+
+	gs.Status.Conditions = append(filterOutCondition(gs.Status.Conditions, condition.Type), condition)
+}
+
+// filterOutCondition returns an empty set of conditions with the provided type.
+func filterOutCondition(conditions []genv1alpha1.GeneratorStateStatusCondition, condType genv1alpha1.GeneratorStateConditionType) []genv1alpha1.GeneratorStateStatusCondition {
+	newConditions := make([]genv1alpha1.GeneratorStateStatusCondition, 0, len(conditions))
+	for _, c := range conditions {
+		if c.Type == condType {
+			continue
+		}
+		newConditions = append(newConditions, c)
+	}
+	return newConditions
+}

+ 74 - 30
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,26 @@ 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(errCloudNotUpdateFinalizer, err)
 					return ctrl.Result{}, fmt.Errorf(errCloudNotUpdateFinalizer, 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
-				}
-
-				controllerutil.RemoveFinalizer(&ps, pushSecretFinalizer)
-				if err := r.Client.Update(ctx, &ps, &client.UpdateOptions{}); err != nil {
-					return ctrl.Result{}, fmt.Errorf(errCloudNotUpdateFinalizer, err)
-				}
-
-				return ctrl.Result{}, 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
+			}
+			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 +151,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 +213,24 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
 	default:
 	default:
 	}
 	}
 
 
-	r.markAsDone(&ps, syncedSecrets)
+	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 +240,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 +248,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 +375,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(ctx, 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 +399,31 @@ 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, err := generatorState.GetLatestState(defaultGeneratorStateKey)
+	if err != nil {
+		return nil, fmt.Errorf("unable to get latest state: %w", err)
+	}
+	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, 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,

+ 20 - 16
pkg/generator/ecr/ecr.go

@@ -46,10 +46,14 @@ const (
 	errGetPublicToken  = "unable to get public authorization token: %w"
 	errGetPublicToken  = "unable to get public 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, ecrPrivateFactory, ecrPublicFactory)
 	return g.generate(ctx, jsonSpec, kube, namespace, ecrPrivateFactory, ecrPublicFactory)
 }
 }
 
 
+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,
@@ -57,13 +61,13 @@ func (g *Generator) generate(
 	namespace string,
 	namespace string,
 	ecrPrivateFunc ecrPrivateFactoryFunc,
 	ecrPrivateFunc ecrPrivateFactoryFunc,
 	ecrPublicFunc ecrPublicFactoryFunc,
 	ecrPublicFunc ecrPublicFactoryFunc,
-) (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,
@@ -78,7 +82,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)
 	}
 	}
 
 
 	if res.Spec.Scope == "public" {
 	if res.Spec.Scope == "public" {
@@ -88,24 +92,24 @@ func (g *Generator) generate(
 	return fetchECRPrivateToken(sess, ecrPrivateFunc)
 	return fetchECRPrivateToken(sess, ecrPrivateFunc)
 }
 }
 
 
-func fetchECRPrivateToken(sess *session.Session, ecrPrivateFunc ecrPrivateFactoryFunc) (map[string][]byte, error) {
+func fetchECRPrivateToken(sess *session.Session, ecrPrivateFunc ecrPrivateFactoryFunc) (map[string][]byte, genv1alpha1.GeneratorProviderState, error) {
 	client := ecrPrivateFunc(sess)
 	client := ecrPrivateFunc(sess)
 	out, err := client.GetAuthorizationToken(&ecr.GetAuthorizationTokenInput{})
 	out, err := client.GetAuthorizationToken(&ecr.GetAuthorizationTokenInput{})
 	if err != nil {
 	if err != nil {
-		return nil, fmt.Errorf(errGetPrivateToken, err)
+		return nil, nil, fmt.Errorf(errGetPrivateToken, 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()
@@ -114,23 +118,23 @@ func fetchECRPrivateToken(sess *session.Session, ecrPrivateFunc ecrPrivateFactor
 		"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
 }
 }
 
 
-func fetchECRPublicToken(sess *session.Session, ecrPublicFunc ecrPublicFactoryFunc) (map[string][]byte, error) {
+func fetchECRPublicToken(sess *session.Session, ecrPublicFunc ecrPublicFactoryFunc) (map[string][]byte, genv1alpha1.GeneratorProviderState, error) {
 	client := ecrPublicFunc(sess)
 	client := ecrPublicFunc(sess)
 	out, err := client.GetAuthorizationToken(&ecrpublic.GetAuthorizationTokenInput{})
 	out, err := client.GetAuthorizationToken(&ecrpublic.GetAuthorizationTokenInput{})
 	if err != nil {
 	if err != nil {
-		return nil, fmt.Errorf(errGetPublicToken, err)
+		return nil, nil, fmt.Errorf(errGetPublicToken, err)
 	}
 	}
 
 
 	decodedToken, err := base64.StdEncoding.DecodeString(*out.AuthorizationData.AuthorizationToken)
 	decodedToken, err := base64.StdEncoding.DecodeString(*out.AuthorizationData.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.ExpiresAt.UTC().Unix()
 	exp := out.AuthorizationData.ExpiresAt.UTC().Unix()
@@ -138,7 +142,7 @@ func fetchECRPublicToken(sess *session.Session, ecrPublicFunc ecrPublicFactoryFu
 		"username":   []byte(parts[0]),
 		"username":   []byte(parts[0]),
 		"password":   []byte(parts[1]),
 		"password":   []byte(parts[1]),
 		"expires_at": []byte(strconv.FormatInt(exp, 10)),
 		"expires_at": []byte(strconv.FormatInt(exp, 10)),
-	}, nil
+	}, nil, nil
 }
 }
 
 
 type ecrPrivateFactoryFunc func(aws *session.Session) ecriface.ECRAPI
 type ecrPrivateFactoryFunc func(aws *session.Session) ecriface.ECRAPI

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

@@ -152,7 +152,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

+ 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,

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

@@ -0,0 +1,184 @@
+/*
+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) {
+	saList, err := cl.ServiceAccounts.SearchOrgServiceAccountsWithPaging(&grafanasa.SearchOrgServiceAccountsWithPagingParams{
+		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
+		}
+	}
+
+	res, err := cl.ServiceAccounts.CreateServiceAccount(&grafanasa.CreateServiceAccountParams{
+		Body: &models.CreateServiceAccountForm{
+			Name: gen.Spec.ServiceAccount.Name,
+		},
+	}, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	return &genv1alpha1.GrafanaServiceAccountTokenState{
+		ServiceAccount: genv1alpha1.GrafanaStateServiceAccount{
+			ServiceAccountID:    ptr.To(res.Payload.ID),
+			ServiceAccountLogin: &res.Payload.Login,
+		},
+	}, nil
+}
+
+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

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

@@ -52,7 +52,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,
@@ -61,23 +61,27 @@ func (g *Generator) Generate(ctx context.Context, jsonSpec *apiextensions.JSON,
 	)
 	)
 }
 }
 
 
+func (g *Generator) Cleanup(_ context.Context, jsonSpec *apiextensions.JSON, state genv1alpha1.GeneratorProviderState, _ client.Client, _ string) error {
+	return nil
+}
+
 func (g *Generator) generate(
 func (g *Generator) generate(
 	ctx context.Context,
 	ctx context.Context,
 	jsonSpec *apiextensions.JSON,
 	jsonSpec *apiextensions.JSON,
 	_ client.Client,
 	_ 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)
 	}
 	}
 	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)
 	}
 	}
 
 
 	// Fetch the service account token
 	// Fetch the service account token
 	token, err := fetchServiceAccountToken(ctx, res.Spec.ServiceAccountRef, namespace)
 	token, err := fetchServiceAccountToken(ctx, res.Spec.ServiceAccountRef, namespace)
 	if err != nil {
 	if err != nil {
-		return nil, fmt.Errorf("failed to fetch service account token: %w", err)
+		return nil, nil, fmt.Errorf("failed to fetch service account token: %w", err)
 	}
 	}
 	url := res.Spec.URL
 	url := res.Spec.URL
 	if url == "" {
 	if url == "" {
@@ -87,17 +91,17 @@ func (g *Generator) generate(
 
 
 	accessToken, err := getQuayRobotToken(ctx, token, res.Spec.RobotAccount, url, g.httpClient)
 	accessToken, err := getQuayRobotToken(ctx, token, res.Spec.RobotAccount, url, g.httpClient)
 	if err != nil {
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 	}
 	exp, err := tokenExpiration(accessToken)
 	exp, err := tokenExpiration(accessToken)
 	if err != nil {
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 	}
 	return map[string][]byte{
 	return map[string][]byte{
 		"registry": []byte(url),
 		"registry": []byte(url),
 		"auth":     []byte(b64.StdEncoding.EncodeToString([]byte(res.Spec.RobotAccount + ":" + accessToken))),
 		"auth":     []byte(b64.StdEncoding.EncodeToString([]byte(res.Spec.RobotAccount + ":" + accessToken))),
 		"expiry":   []byte(exp),
 		"expiry":   []byte(exp),
-	}, nil
+	}, nil, nil
 }
 }
 
 
 func getClaims(tokenString string) (map[string]interface{}, error) {
 func getClaims(tokenString string) (map[string]interface{}, error) {

+ 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/quay"
 	_ "github.com/external-secrets/external-secrets/pkg/generator/quay"
 	_ "github.com/external-secrets/external-secrets/pkg/generator/sts"
 	_ "github.com/external-secrets/external-secrets/pkg/generator/sts"

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

@@ -0,0 +1,264 @@
+/*
+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"
+	"strings"
+	"time"
+
+	"github.com/spf13/pflag"
+	apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+
+	genapi "github.com/external-secrets/external-secrets/apis/generators/v1alpha1"
+	"github.com/external-secrets/external-secrets/pkg/feature"
+	"github.com/external-secrets/external-secrets/pkg/utils"
+)
+
+// 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.
+type Manager struct {
+	ctx       context.Context
+	scheme    *runtime.Scheme
+	client    client.Client
+	namespace string
+	resource  genapi.StatefulResource
+
+	queue []QueueItem
+}
+
+type QueueItem struct {
+	Rollback func() error
+	Commit   func() error
+}
+
+var gcGracePeriod time.Duration
+
+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,
+	})
+}
+
+func New(ctx context.Context, client client.Client, scheme *runtime.Scheme, namespace string,
+	resource genapi.StatefulResource) *Manager {
+	return &Manager{
+		ctx:       ctx,
+		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.queue {
+		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.queue {
+		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.queue = append(m.queue, QueueItem{
+		Commit: func() error {
+			return m.disposeState(stateKey)
+		},
+	})
+}
+
+// EnqueueMoveStateToGC will move the generator state to GC if Commit() is called.
+func (m *Manager) EnqueueMoveStateToGC(stateKey string) {
+	m.queue = append(m.queue, QueueItem{
+		Commit: func() error {
+			return m.disposeState(stateKey)
+		},
+	})
+}
+
+// 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, stateKey, namespace string, resource *apiextensions.JSON, gen genapi.Generator, state genapi.GeneratorProviderState) {
+	m.queue = append(m.queue, QueueItem{
+		// Stores the state in GeneratorState resource
+		Commit: func() error {
+			genState, err := m.createGeneratorState(resource, state, namespace, stateKey)
+			if err != nil {
+				return err
+			}
+			return m.client.Create(ctx, genState)
+		},
+		// Rollback by cleaning up the state.
+		// In case of failure, create a new GeneratorState, so it will eventually be cleaned up.
+		// If that also fails we're out of luck :(
+		Rollback: func() error {
+			err := gen.Cleanup(ctx, resource, state, m.client, namespace)
+			if err == nil {
+				return nil
+			}
+			genState, err := m.createGeneratorState(resource, state, namespace, stateKey)
+			if err != nil {
+				return err
+			}
+			genState.Spec.GarbageCollectionDeadline = &metav1.Time{
+				Time: time.Now(),
+			}
+			return m.client.Create(ctx, genState)
+		},
+	})
+}
+
+func (m *Manager) createGeneratorState(resource *apiextensions.JSON, state genapi.GeneratorProviderState, namespace, stateKey string) (*genapi.GeneratorState, error) {
+	genState := &genapi.GeneratorState{
+		ObjectMeta: metav1.ObjectMeta{
+			GenerateName: fmt.Sprintf("gen-%s-%s-", strings.ToLower(m.resource.GetObjectKind().GroupVersionKind().Kind), m.resource.GetName()),
+			Namespace:    namespace,
+			Labels: map[string]string{
+				genapi.GeneratorStateLabelOwnerKey: ownerKey(
+					m.resource,
+					stateKey,
+				),
+			},
+		},
+		Spec: genapi.GeneratorStateSpec{
+			Resource: resource,
+			State:    state,
+		},
+	}
+	if err := controllerutil.SetOwnerReference(m.resource, genState, m.scheme); err != nil {
+		return nil, err
+	}
+	return genState, nil
+}
+
+func ownerKey(resource genapi.StatefulResource, key string) string {
+	return utils.ObjectHash(fmt.Sprintf("%s-%s-%s-%s",
+		resource.GetObjectKind().GroupVersionKind().Kind,
+		resource.GetNamespace(),
+		resource.GetName(),
+		key),
+	)
+}
+
+func (m *Manager) disposeState(key string) error {
+	allStates, err := m.GetAllStates(key)
+	if err != nil {
+		return err
+	}
+
+	latest := getLatest(allStates)
+	if latest == nil {
+		return nil
+	}
+
+	// flag all states for GC except the latest one
+	// This is to ensure that all "old" states are eventually cleaned up.
+	// This is needed due to fast reconciles and working with stale cache.
+	var errs []error
+	for _, state := range allStates {
+		if state.Name == latest.Name {
+			continue
+		}
+		if state.Spec.GarbageCollectionDeadline != nil {
+			continue
+		}
+		state.Spec.GarbageCollectionDeadline = &metav1.Time{
+			Time: time.Now().Add(gcGracePeriod),
+		}
+		if err := m.client.Update(m.ctx, &state); err != nil {
+			errs = append(errs, err)
+		}
+	}
+	return errors.Join(errs...)
+}
+
+// GetLatest returns the latest state for the given key.
+func (m *Manager) GetAllStates(key string) ([]genapi.GeneratorState, error) {
+	var stateList genapi.GeneratorStateList
+	if err := m.client.List(m.ctx, &stateList, &client.MatchingLabels{
+		genapi.GeneratorStateLabelOwnerKey: ownerKey(
+			m.resource,
+			key,
+		),
+	}, client.InNamespace(m.namespace)); err != nil {
+		return nil, err
+	}
+
+	return stateList.Items, nil
+}
+
+// GetLatestState returns the latest state for the given key.
+func (m *Manager) GetLatestState(key string) (*genapi.GeneratorState, error) {
+	var stateList genapi.GeneratorStateList
+	if err := m.client.List(m.ctx, &stateList, &client.MatchingLabels{
+		genapi.GeneratorStateLabelOwnerKey: ownerKey(
+			m.resource,
+			key,
+		),
+	}, client.InNamespace(m.namespace)); err != nil {
+		return nil, err
+	}
+
+	if latestState := getLatest(stateList.Items); latestState != nil {
+		return latestState, nil
+	}
+	return nil, nil
+}
+
+func getLatest(stateList []genapi.GeneratorState) *genapi.GeneratorState {
+	var latest *genapi.GeneratorState
+	for _, state := range stateList {
+		// if the state is already flagged for GC, skip it
+		// It can happen that the latest based on creation timestamp is already flagged for GC.
+		// That is the case when a rollback was performed.
+		if state.Spec.GarbageCollectionDeadline != nil {
+			continue
+		}
+		if latest == nil {
+			latest = &state
+			continue
+		}
+		if state.CreationTimestamp.After(latest.CreationTimestamp.Time) {
+			latest = &state
+		}
+	}
+	return latest
+}

+ 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

+ 47 - 39
pkg/generator/vault/vault.go

@@ -43,7 +43,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
@@ -51,67 +51,49 @@ 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)
+	spec, 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 {
-		return nil, errors.New("no Vault provider config in spec")
+	if spec == nil || spec.Spec.Provider == nil {
+		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, spec.Spec.Provider, namespace, spec.Spec.RetrySettings)
 	if err != nil {
 	if err != nil {
-		return nil, fmt.Errorf(errVaultClient, err)
+		return nil, nil, fmt.Errorf(errVaultClient, err)
 	}
 	}
 
 
-	result, err := g.fetchVaultSecret(ctx, res, cl)
+	result, err := g.fetchVaultSecret(ctx, spec, cl)
 	if err != nil {
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 	}
 
 
-	if result == nil && res.Spec.AllowEmptyResponse {
-		return nil, nil
+	if result == nil && spec.Spec.AllowEmptyResponse {
+		return nil, nil, nil
 	}
 	}
 
 
 	if result == nil {
 	if result == nil {
-		return nil, fmt.Errorf(errGetSecret, errors.New("empty response from Vault"))
-	}
-
-	data := make(map[string]any)
-	response := make(map[string][]byte)
-	if res.Spec.ResultType == genv1alpha1.VaultDynamicSecretResultTypeAuth {
-		authJSON, err := json.Marshal(result.Auth)
-		if err != nil {
-			return nil, err
-		}
-		err = json.Unmarshal(authJSON, &data)
-		if err != nil {
-			return nil, err
-		}
-	} else {
-		data = result.Data
-	}
-
-	for k := range data {
-		response[k], err = utils.GetByteValueFromMap(data, k)
-		if err != nil {
-			return nil, err
-		}
+		return nil, nil, fmt.Errorf(errGetSecret, errors.New("empty response from Vault"))
 	}
 	}
-	return response, nil
+	return g.prepareResponse(spec, result)
 }
 }
 
 
 func (g *Generator) fetchVaultSecret(ctx context.Context, res *genv1alpha1.VaultDynamicSecret, cl util.Client) (*vault.Secret, error) {
 func (g *Generator) fetchVaultSecret(ctx context.Context, res *genv1alpha1.VaultDynamicSecret, cl util.Client) (*vault.Secret, error) {
@@ -140,6 +122,32 @@ func (g *Generator) fetchVaultSecret(ctx context.Context, res *genv1alpha1.Vault
 	return result, err
 	return result, err
 }
 }
 
 
+func (g *Generator) prepareResponse(res *genv1alpha1.VaultDynamicSecret, result *vault.Secret) (map[string][]byte, genv1alpha1.GeneratorProviderState, error) {
+	var err error
+	data := make(map[string]any)
+	response := make(map[string][]byte)
+	if res.Spec.ResultType == genv1alpha1.VaultDynamicSecretResultTypeAuth {
+		authJSON, err := json.Marshal(result.Auth)
+		if err != nil {
+			return nil, nil, err
+		}
+		err = json.Unmarshal(authJSON, &data)
+		if err != nil {
+			return nil, nil, err
+		}
+	} else {
+		data = result.Data
+	}
+
+	for k := range data {
+		response[k], err = utils.GetByteValueFromMap(data, k)
+		if err != nil {
+			return nil, nil, err
+		}
+	}
+	return response, nil, nil
+}
+
 func parseSpec(data []byte) (*genv1alpha1.VaultDynamicSecret, error) {
 func parseSpec(data []byte) (*genv1alpha1.VaultDynamicSecret, error) {
 	var spec genv1alpha1.VaultDynamicSecret
 	var spec genv1alpha1.VaultDynamicSecret
 	err := yaml.Unmarshal(data, &spec)
 	err := yaml.Unmarshal(data, &spec)

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

@@ -241,7 +241,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 err != nil || tc.want.err != nil {
 			if err != nil || tc.want.err != nil {
 				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()

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

@@ -20,6 +20,7 @@ import (
 	"reflect"
 	"reflect"
 
 
 	apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
 	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/apis/meta/v1/unstructured"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/apimachinery/pkg/runtime/schema"
@@ -143,6 +144,10 @@ func clusterGeneratorToVirtual(gen *genv1alpha1.ClusterGenerator) (client.Object
 			return nil, fmt.Errorf("when kind is %s, ACRAccessTokenSpec must be set", gen.Spec.Kind)
 			return nil, fmt.Errorf("when kind is %s, ACRAccessTokenSpec must be set", gen.Spec.Kind)
 		}
 		}
 		return &genv1alpha1.ACRAccessToken{
 		return &genv1alpha1.ACRAccessToken{
+			TypeMeta: metav1.TypeMeta{
+				APIVersion: genv1alpha1.SchemeGroupVersion.String(),
+				Kind:       genv1alpha1.ACRAccessTokenKind,
+			},
 			Spec: *gen.Spec.Generator.ACRAccessTokenSpec,
 			Spec: *gen.Spec.Generator.ACRAccessTokenSpec,
 		}, nil
 		}, nil
 	case genv1alpha1.GeneratorKindECRAuthorizationToken:
 	case genv1alpha1.GeneratorKindECRAuthorizationToken:
@@ -150,6 +155,10 @@ func clusterGeneratorToVirtual(gen *genv1alpha1.ClusterGenerator) (client.Object
 			return nil, fmt.Errorf("when kind is %s, ECRAuthorizationTokenSpec must be set", gen.Spec.Kind)
 			return nil, fmt.Errorf("when kind is %s, ECRAuthorizationTokenSpec must be set", gen.Spec.Kind)
 		}
 		}
 		return &genv1alpha1.ECRAuthorizationToken{
 		return &genv1alpha1.ECRAuthorizationToken{
+			TypeMeta: metav1.TypeMeta{
+				APIVersion: genv1alpha1.SchemeGroupVersion.String(),
+				Kind:       genv1alpha1.ECRAuthorizationTokenKind,
+			},
 			Spec: *gen.Spec.Generator.ECRAuthorizationTokenSpec,
 			Spec: *gen.Spec.Generator.ECRAuthorizationTokenSpec,
 		}, nil
 		}, nil
 	case genv1alpha1.GeneratorKindFake:
 	case genv1alpha1.GeneratorKindFake:
@@ -157,6 +166,10 @@ func clusterGeneratorToVirtual(gen *genv1alpha1.ClusterGenerator) (client.Object
 			return nil, fmt.Errorf("when kind is %s, FakeSpec must be set", gen.Spec.Kind)
 			return nil, fmt.Errorf("when kind is %s, FakeSpec must be set", gen.Spec.Kind)
 		}
 		}
 		return &genv1alpha1.Fake{
 		return &genv1alpha1.Fake{
+			TypeMeta: metav1.TypeMeta{
+				APIVersion: genv1alpha1.SchemeGroupVersion.String(),
+				Kind:       genv1alpha1.FakeKind,
+			},
 			Spec: *gen.Spec.Generator.FakeSpec,
 			Spec: *gen.Spec.Generator.FakeSpec,
 		}, nil
 		}, nil
 	case genv1alpha1.GeneratorKindGCRAccessToken:
 	case genv1alpha1.GeneratorKindGCRAccessToken:
@@ -164,6 +177,10 @@ func clusterGeneratorToVirtual(gen *genv1alpha1.ClusterGenerator) (client.Object
 			return nil, fmt.Errorf("when kind is %s, GCRAccessTokenSpec must be set", gen.Spec.Kind)
 			return nil, fmt.Errorf("when kind is %s, GCRAccessTokenSpec must be set", gen.Spec.Kind)
 		}
 		}
 		return &genv1alpha1.GCRAccessToken{
 		return &genv1alpha1.GCRAccessToken{
+			TypeMeta: metav1.TypeMeta{
+				APIVersion: genv1alpha1.SchemeGroupVersion.String(),
+				Kind:       genv1alpha1.GCRAccessTokenKind,
+			},
 			Spec: *gen.Spec.Generator.GCRAccessTokenSpec,
 			Spec: *gen.Spec.Generator.GCRAccessTokenSpec,
 		}, nil
 		}, nil
 	case genv1alpha1.GeneratorKindGithubAccessToken:
 	case genv1alpha1.GeneratorKindGithubAccessToken:
@@ -171,6 +188,10 @@ func clusterGeneratorToVirtual(gen *genv1alpha1.ClusterGenerator) (client.Object
 			return nil, fmt.Errorf("when kind is %s, GithubAccessTokenSpec must be set", gen.Spec.Kind)
 			return nil, fmt.Errorf("when kind is %s, GithubAccessTokenSpec must be set", gen.Spec.Kind)
 		}
 		}
 		return &genv1alpha1.GithubAccessToken{
 		return &genv1alpha1.GithubAccessToken{
+			TypeMeta: metav1.TypeMeta{
+				APIVersion: genv1alpha1.SchemeGroupVersion.String(),
+				Kind:       genv1alpha1.GithubAccessTokenKind,
+			},
 			Spec: *gen.Spec.Generator.GithubAccessTokenSpec,
 			Spec: *gen.Spec.Generator.GithubAccessTokenSpec,
 		}, nil
 		}, nil
 	case genv1alpha1.GeneratorKindQuayAccessToken:
 	case genv1alpha1.GeneratorKindQuayAccessToken:
@@ -185,6 +206,10 @@ func clusterGeneratorToVirtual(gen *genv1alpha1.ClusterGenerator) (client.Object
 			return nil, fmt.Errorf("when kind is %s, PasswordSpec must be set", gen.Spec.Kind)
 			return nil, fmt.Errorf("when kind is %s, PasswordSpec must be set", gen.Spec.Kind)
 		}
 		}
 		return &genv1alpha1.Password{
 		return &genv1alpha1.Password{
+			TypeMeta: metav1.TypeMeta{
+				APIVersion: genv1alpha1.SchemeGroupVersion.String(),
+				Kind:       genv1alpha1.PasswordKind,
+			},
 			Spec: *gen.Spec.Generator.PasswordSpec,
 			Spec: *gen.Spec.Generator.PasswordSpec,
 		}, nil
 		}, nil
 	case genv1alpha1.GeneratorKindSTSSessionToken:
 	case genv1alpha1.GeneratorKindSTSSessionToken:
@@ -192,6 +217,10 @@ func clusterGeneratorToVirtual(gen *genv1alpha1.ClusterGenerator) (client.Object
 			return nil, fmt.Errorf("when kind is %s, STSSessionTokenSpec must be set", gen.Spec.Kind)
 			return nil, fmt.Errorf("when kind is %s, STSSessionTokenSpec must be set", gen.Spec.Kind)
 		}
 		}
 		return &genv1alpha1.STSSessionToken{
 		return &genv1alpha1.STSSessionToken{
+			TypeMeta: metav1.TypeMeta{
+				APIVersion: genv1alpha1.SchemeGroupVersion.String(),
+				Kind:       genv1alpha1.STSSessionTokenKind,
+			},
 			Spec: *gen.Spec.Generator.STSSessionTokenSpec,
 			Spec: *gen.Spec.Generator.STSSessionTokenSpec,
 		}, nil
 		}, nil
 	case genv1alpha1.GeneratorKindUUID:
 	case genv1alpha1.GeneratorKindUUID:
@@ -199,6 +228,10 @@ func clusterGeneratorToVirtual(gen *genv1alpha1.ClusterGenerator) (client.Object
 			return nil, fmt.Errorf("when kind is %s, UUIDSpec must be set", gen.Spec.Kind)
 			return nil, fmt.Errorf("when kind is %s, UUIDSpec must be set", gen.Spec.Kind)
 		}
 		}
 		return &genv1alpha1.UUID{
 		return &genv1alpha1.UUID{
+			TypeMeta: metav1.TypeMeta{
+				APIVersion: genv1alpha1.SchemeGroupVersion.String(),
+				Kind:       genv1alpha1.UUIDKind,
+			},
 			Spec: *gen.Spec.Generator.UUIDSpec,
 			Spec: *gen.Spec.Generator.UUIDSpec,
 		}, nil
 		}, nil
 	case genv1alpha1.GeneratorKindVaultDynamicSecret:
 	case genv1alpha1.GeneratorKindVaultDynamicSecret:
@@ -206,6 +239,10 @@ func clusterGeneratorToVirtual(gen *genv1alpha1.ClusterGenerator) (client.Object
 			return nil, fmt.Errorf("when kind is %s, VaultDynamicSecretSpec must be set", gen.Spec.Kind)
 			return nil, fmt.Errorf("when kind is %s, VaultDynamicSecretSpec must be set", gen.Spec.Kind)
 		}
 		}
 		return &genv1alpha1.VaultDynamicSecret{
 		return &genv1alpha1.VaultDynamicSecret{
+			TypeMeta: metav1.TypeMeta{
+				APIVersion: genv1alpha1.SchemeGroupVersion.String(),
+				Kind:       genv1alpha1.VaultDynamicSecretKind,
+			},
 			Spec: *gen.Spec.Generator.VaultDynamicSecretSpec,
 			Spec: *gen.Spec.Generator.VaultDynamicSecretSpec,
 		}, nil
 		}, nil
 	case genv1alpha1.GeneratorKindWebhook:
 	case genv1alpha1.GeneratorKindWebhook:
@@ -213,8 +250,23 @@ func clusterGeneratorToVirtual(gen *genv1alpha1.ClusterGenerator) (client.Object
 			return nil, fmt.Errorf("when kind is %s, WebhookSpec must be set", gen.Spec.Kind)
 			return nil, fmt.Errorf("when kind is %s, WebhookSpec must be set", gen.Spec.Kind)
 		}
 		}
 		return &genv1alpha1.Webhook{
 		return &genv1alpha1.Webhook{
+			TypeMeta: metav1.TypeMeta{
+				APIVersion: genv1alpha1.SchemeGroupVersion.String(),
+				Kind:       genv1alpha1.WebhookKind,
+			},
 			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{
+			TypeMeta: metav1.TypeMeta{
+				APIVersion: genv1alpha1.SchemeGroupVersion.String(),
+				Kind:       genv1alpha1.GrafanaKind,
+			},
+			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)
 	}
 	}