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_PASSWORD: ${{ secrets.SECRETSERVER_PASSWORD }}
   SECRETSERVER_URL: ${{ secrets.SECRETSERVER_URL }}
+
+  GRAFANA_URL: ${{ secrets.GRAFANA_URL }}
+  GRAFANA_TOKEN: ${{ secrets.GRAFANA_TOKEN }}
 jobs:
 
   integration-trusted:

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

@@ -36,7 +36,7 @@ jobs:
 
       - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
         with:
-          python-version: 3.11
+          python-version: 3.12
 
       - name: Set up chart-testing
         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:subresource:status
 // +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 {
 	metav1.TypeMeta   `json:",inline"`

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

@@ -424,7 +424,7 @@ type GeneratorRef struct {
 	APIVersion string `json:"apiVersion,omitempty"`
 
 	// Specify the Kind of the generator resource
-	// +kubebuilder:validation:Enum=ACRAccessToken;ClusterGenerator;ECRAuthorizationToken;Fake;GCRAccessToken;GithubAccessToken;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"`
 
 	// 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"
 )
 
-func (r *ExternalSecret) SetupWebhookWithManager(mgr ctrl.Manager) error {
+func (es *ExternalSecret) SetupWebhookWithManager(mgr ctrl.Manager) error {
 	return ctrl.NewWebhookManagedBy(mgr).
-		For(r).
+		For(es).
 		WithValidator(&ExternalSecretValidator{}).
 		Complete()
 }

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

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

+ 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)
 )
 
+// Grafana type metadata.
+var (
+	GrafanaKind             = reflect.TypeOf(Grafana{}).Name()
+	GrafanaGroupKind        = schema.GroupKind{Group: Group, Kind: GrafanaKind}.String()
+	GrafanaKindAPIVersion   = GrafanaKind + "." + SchemeGroupVersion.String()
+	GrafanaGroupVersionKind = SchemeGroupVersion.WithKind(GrafanaKind)
+)
+
 // ClusterGenerator type metadata.
 var (
 	ClusterGeneratorKind             = reflect.TypeOf(ClusterGenerator{}).Name()
@@ -133,6 +141,8 @@ var (
 )
 
 func init() {
+	SchemeBuilder.Register(&GeneratorState{}, &GeneratorStateList{})
+
 	/*
 		===============================================================================
 		 NOTE: when adding support for new kinds of generators:
@@ -160,4 +170,5 @@ func init() {
 	SchemeBuilder.Register(&UUID{}, &UUIDList{})
 	SchemeBuilder.Register(&VaultDynamicSecret{}, &VaultDynamicSecretList{})
 	SchemeBuilder.Register(&Webhook{}, &WebhookList{})
+	SchemeBuilder.Register(&Grafana{}, &GrafanaList{})
 }

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

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

+ 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 (
 	"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.
@@ -162,7 +162,7 @@ func (in *AWSAuthSecretRef) DeepCopyInto(out *AWSAuthSecretRef) {
 	in.SecretAccessKey.DeepCopyInto(&out.SecretAccessKey)
 	if in.SessionToken != nil {
 		in, out := &in.SessionToken, &out.SessionToken
-		*out = new(v1.SecretKeySelector)
+		*out = new(metav1.SecretKeySelector)
 		(*in).DeepCopyInto(*out)
 	}
 }
@@ -182,7 +182,7 @@ func (in *AWSJWTAuth) DeepCopyInto(out *AWSJWTAuth) {
 	*out = *in
 	if in.ServiceAccountRef != nil {
 		in, out := &in.ServiceAccountRef, &out.ServiceAccountRef
-		*out = new(v1.ServiceAccountSelector)
+		*out = new(metav1.ServiceAccountSelector)
 		(*in).DeepCopyInto(*out)
 	}
 }
@@ -250,7 +250,7 @@ func (in *AzureACRWorkloadIdentityAuth) DeepCopyInto(out *AzureACRWorkloadIdenti
 	*out = *in
 	if in.ServiceAccountRef != nil {
 		in, out := &in.ServiceAccountRef, &out.ServiceAccountRef
-		*out = new(v1.ServiceAccountSelector)
+		*out = new(metav1.ServiceAccountSelector)
 		(*in).DeepCopyInto(*out)
 	}
 }
@@ -698,6 +698,11 @@ func (in *GeneratorSpec) DeepCopyInto(out *GeneratorSpec) {
 		*out = new(WebhookSpec)
 		(*in).DeepCopyInto(*out)
 	}
+	if in.GrafanaSpec != nil {
+		in, out := &in.GrafanaSpec, &out.GrafanaSpec
+		*out = new(GrafanaSpec)
+		**out = **in
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GeneratorSpec.
@@ -710,6 +715,132 @@ func (in *GeneratorSpec) DeepCopy() *GeneratorSpec {
 	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.
 func (in *GithubAccessToken) DeepCopyInto(out *GithubAccessToken) {
 	*out = *in
@@ -828,6 +959,158 @@ func (in *GithubSecretRef) DeepCopy() *GithubSecretRef {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *Grafana) DeepCopyInto(out *Grafana) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+	out.Spec = in.Spec
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Grafana.
+func (in *Grafana) DeepCopy() *Grafana {
+	if in == nil {
+		return nil
+	}
+	out := new(Grafana)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *Grafana) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *GrafanaAuth) DeepCopyInto(out *GrafanaAuth) {
+	*out = *in
+	out.Token = in.Token
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaAuth.
+func (in *GrafanaAuth) DeepCopy() *GrafanaAuth {
+	if in == nil {
+		return nil
+	}
+	out := new(GrafanaAuth)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *GrafanaList) DeepCopyInto(out *GrafanaList) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ListMeta.DeepCopyInto(&out.ListMeta)
+	if in.Items != nil {
+		in, out := &in.Items, &out.Items
+		*out = make([]Grafana, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaList.
+func (in *GrafanaList) DeepCopy() *GrafanaList {
+	if in == nil {
+		return nil
+	}
+	out := new(GrafanaList)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *GrafanaList) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *GrafanaServiceAccount) DeepCopyInto(out *GrafanaServiceAccount) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaServiceAccount.
+func (in *GrafanaServiceAccount) DeepCopy() *GrafanaServiceAccount {
+	if in == nil {
+		return nil
+	}
+	out := new(GrafanaServiceAccount)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *GrafanaServiceAccountTokenState) DeepCopyInto(out *GrafanaServiceAccountTokenState) {
+	*out = *in
+	in.ServiceAccount.DeepCopyInto(&out.ServiceAccount)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaServiceAccountTokenState.
+func (in *GrafanaServiceAccountTokenState) DeepCopy() *GrafanaServiceAccountTokenState {
+	if in == nil {
+		return nil
+	}
+	out := new(GrafanaServiceAccountTokenState)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *GrafanaSpec) DeepCopyInto(out *GrafanaSpec) {
+	*out = *in
+	out.Auth = in.Auth
+	out.ServiceAccount = in.ServiceAccount
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaSpec.
+func (in *GrafanaSpec) DeepCopy() *GrafanaSpec {
+	if in == nil {
+		return nil
+	}
+	out := new(GrafanaSpec)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *GrafanaStateServiceAccount) DeepCopyInto(out *GrafanaStateServiceAccount) {
+	*out = *in
+	if in.ServiceAccountID != nil {
+		in, out := &in.ServiceAccountID, &out.ServiceAccountID
+		*out = new(int64)
+		**out = **in
+	}
+	if in.ServiceAccountLogin != nil {
+		in, out := &in.ServiceAccountLogin, &out.ServiceAccountLogin
+		*out = new(string)
+		**out = **in
+	}
+	if in.ServiceAccountTokenID != nil {
+		in, out := &in.ServiceAccountTokenID, &out.ServiceAccountTokenID
+		*out = new(int64)
+		**out = **in
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaStateServiceAccount.
+func (in *GrafanaStateServiceAccount) DeepCopy() *GrafanaStateServiceAccount {
+	if in == nil {
+		return nil
+	}
+	out := new(GrafanaStateServiceAccount)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *Password) DeepCopyInto(out *Password) {
 	*out = *in
@@ -1250,7 +1533,7 @@ func (in *VaultDynamicSecretSpec) DeepCopyInto(out *VaultDynamicSecretSpec) {
 	*out = *in
 	if in.Parameters != nil {
 		in, out := &in.Parameters, &out.Parameters
-		*out = new(apiextensionsv1.JSON)
+		*out = new(v1.JSON)
 		(*in).DeepCopyInto(*out)
 	}
 	if in.RetrySettings != nil {
@@ -1396,7 +1679,7 @@ func (in *WebhookSpec) DeepCopyInto(out *WebhookSpec) {
 	}
 	if in.Timeout != nil {
 		in, out := &in.Timeout, &out.Timeout
-		*out = new(metav1.Duration)
+		*out = new(apismetav1.Duration)
 		**out = **in
 	}
 	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"
 	"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/generatorstate"
 	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/psmetrics"
@@ -131,7 +132,7 @@ var rootCmd = &cobra.Command{
 			clientCacheDisableFor = append(clientCacheDisableFor, &v1.ConfigMap{})
 		}
 
-		ctrlOpts := ctrl.Options{
+		mgrOpts := ctrl.Options{
 			Scheme: scheme,
 			Metrics: server.Options{
 				BindAddress: metricsAddr,
@@ -148,11 +149,11 @@ var rootCmd = &cobra.Command{
 			LeaderElectionID: "external-secrets-controller",
 		}
 		if namespace != "" {
-			ctrlOpts.Cache.DefaultNamespaces = map[string]cache.Config{
+			mgrOpts.Cache.DefaultNamespaces = map[string]cache.Config{
 				namespace: {},
 			}
 		}
-		mgr, err := ctrl.NewManager(config, ctrlOpts)
+		mgr, err := ctrl.NewManager(config, mgrOpts)
 		if err != nil {
 			setupLog.Error(err, "unable to start manager")
 			os.Exit(1)
@@ -201,6 +202,18 @@ var rootCmd = &cobra.Command{
 				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{
 			Client:                    mgr.GetClient(),
 			SecretClient:              secretClient,

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

@@ -171,6 +171,7 @@ spec:
                                   - UUID
                                   - VaultDynamicSecret
                                   - Webhook
+                                  - Grafana
                                   type: string
                                 name:
                                   description: Specify the name of the generator resource
@@ -367,6 +368,7 @@ spec:
                                   - UUID
                                   - VaultDynamicSecret
                                   - Webhook
+                                  - Grafana
                                   type: string
                                 name:
                                   description: Specify the name of the generator resource

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

@@ -467,6 +467,7 @@ spec:
                               - UUID
                               - VaultDynamicSecret
                               - Webhook
+                              - Grafana
                               type: string
                             name:
                               description: Specify the name of the generator resource
@@ -662,6 +663,7 @@ spec:
                               - UUID
                               - VaultDynamicSecret
                               - Webhook
+                              - Grafana
                               type: string
                             name:
                               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
     listKind: PushSecretList
     plural: pushsecrets
+    shortNames:
+    - ps
     singular: pushsecret
   scope: Namespaced
   versions:
@@ -194,6 +196,7 @@ spec:
                         - UUID
                         - VaultDynamicSecret
                         - Webhook
+                        - Grafana
                         type: string
                       name:
                         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
                     - installID
                     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:
                     description: PasswordSpec controls the behavior of the password
                       generator.
@@ -1739,12 +1799,12 @@ spec:
                 - Fake
                 - GCRAccessToken
                 - GithubAccessToken
-                - QuayAccessToken
-                - Password
+                - QuayAccessToken'Password
                 - STSSessionToken
                 - UUID
                 - VaultDynamicSecret
                 - Webhook
+                - Grafana
                 type: string
             required:
             - 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_fakes.yaml
   - generators.external-secrets.io_gcraccesstokens.yaml
+  - generators.external-secrets.io_generatorstates.yaml
   - generators.external-secrets.io_githubaccesstokens.yaml
+  - generators.external-secrets.io_grafanas.yaml
   - generators.external-secrets.io_passwords.yaml
   - generators.external-secrets.io_quayaccesstokens.yaml
   - generators.external-secrets.io_stssessiontokens.yaml

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

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

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

@@ -2833,6 +2833,8 @@ should match snapshot of default values:
                               properties:
                                 apiUrl:
                                   type: string
+                                apiVersion:
+                                  type: string
                                 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.
                                   type: integer

+ 293 - 2
deploy/crds/bundle.yaml

@@ -161,6 +161,7 @@ spec:
                                       - UUID
                                       - VaultDynamicSecret
                                       - Webhook
+                                      - Grafana
                                     type: string
                                   name:
                                     description: Specify the name of the generator resource
@@ -348,6 +349,7 @@ spec:
                                       - UUID
                                       - VaultDynamicSecret
                                       - Webhook
+                                      - Grafana
                                     type: string
                                   name:
                                     description: Specify the name of the generator resource
@@ -6955,6 +6957,7 @@ spec:
                                   - UUID
                                   - VaultDynamicSecret
                                   - Webhook
+                                  - Grafana
                                 type: string
                               name:
                                 description: Specify the name of the generator resource
@@ -7142,6 +7145,7 @@ spec:
                                   - UUID
                                   - VaultDynamicSecret
                                   - Webhook
+                                  - Grafana
                                 type: string
                               name:
                                 description: Specify the name of the generator resource
@@ -7437,6 +7441,8 @@ spec:
     kind: PushSecret
     listKind: PushSecretList
     plural: pushsecrets
+    shortNames:
+      - ps
     singular: pushsecret
   scope: Namespaced
   versions:
@@ -7611,6 +7617,7 @@ spec:
                             - UUID
                             - VaultDynamicSecret
                             - Webhook
+                            - Grafana
                           type: string
                         name:
                           description: Specify the name of the generator resource
@@ -14357,6 +14364,63 @@ spec:
                         - auth
                         - installID
                       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:
                       description: PasswordSpec controls the behavior of the password generator.
                       properties:
@@ -15509,12 +15573,12 @@ spec:
                     - Fake
                     - GCRAccessToken
                     - GithubAccessToken
-                    - QuayAccessToken
-                    - Password
+                    - QuayAccessToken'Password
                     - STSSessionToken
                     - UUID
                     - VaultDynamicSecret
                     - Webhook
+                    - Grafana
                   type: string
               required:
                 - generator
@@ -15962,6 +16026,119 @@ spec:
 ---
 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: {}
+  conversion:
+    strategy: Webhook
+    webhook:
+      conversionReviewVersions:
+        - v1
+      clientConfig:
+        service:
+          name: kubernetes
+          namespace: default
+          path: /convert
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
 metadata:
   annotations:
     controller-gen.kubebuilder.io/version: v0.17.1
@@ -16085,6 +16262,120 @@ spec:
 ---
 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: {}
+  conversion:
+    strategy: Webhook
+    webhook:
+      conversionReviewVersions:
+        - v1
+      clientConfig:
+        service:
+          name: kubernetes
+          namespace: default
+          path: /convert
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
 metadata:
   annotations:
     controller-gen.kubebuilder.io/version: v0.17.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 (
 	"os"
+	"time"
 
 	// nolint
 	. "github.com/onsi/ginkgo/v2"
@@ -185,13 +186,23 @@ func (l *ESO) Install() error {
 }
 
 func (l *ESO) Uninstall() error {
+	// uninstalling CRDs will trigger the deletion of all CRs. They block the deletion of the CRDs if
+	// a finalizer is present.
+	// We must uninstall the CRDs before the eso chart,
+	// otherwise ESO will not remove the finalizer.
+	if l.HelmChart.HasVar(installCRDsVar, "true") {
+		By("Uninstalling eso CRDs")
+		err := uninstallCRDs(l.config)
+		if err != nil {
+			return err
+		}
+		// Give ESO a grace period to clean up the CRs
+		<-time.After(time.Minute)
+	}
 	By("Uninstalling eso")
 	err := l.HelmChart.Uninstall()
 	if err != nil {
 		return err
 	}
-	if l.HelmChart.HasVar(installCRDsVar, "true") {
-		return uninstallCRDs(l.config)
-	}
 	return nil
 }

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

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

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

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

+ 11 - 0
e2e/go.mod

@@ -55,6 +55,7 @@ require (
 	github.com/fluxcd/pkg/apis/meta v1.2.0
 	github.com/fluxcd/source-controller/api v1.2.3
 	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/onsi/ginkgo/v2 v2.22.2
 	github.com/onsi/gomega v1.36.2
@@ -95,6 +96,7 @@ require (
 	github.com/Masterminds/goutils v1.1.1 // indirect
 	github.com/Masterminds/semver/v3 v3.3.1 // 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/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // 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-logr/logr v1.4.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/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/validate v0.24.0 // indirect
 	github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
 	github.com/goccy/go-json v0.10.4 // 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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // 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/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
 	github.com/pkg/errors v0.9.1 // indirect
@@ -187,6 +197,7 @@ require (
 	github.com/tidwall/sjson v1.2.5 // indirect
 	github.com/x448/float16 v0.8.4 // 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/contrib/instrumentation/google.golang.org/grpc/otelgrpc 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/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/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.55.6 h1:cSg4pvZ3m8dgYcgqB97MrcdjUmZ1BeMYKUxMMB89IPk=
 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/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ=
 github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg=
+github.com/go-openapi/analysis v0.23.0 h1:aGday7OWupfMs+LbmLZG4k0MYXIANxcuBTYUC03zFCU=
+github.com/go-openapi/analysis v0.23.0/go.mod h1:9mz9ZWaSlV8TvjQHLl2mUW2PbZtemkE8yA5v22ohupo=
+github.com/go-openapi/errors v0.22.0 h1:c4xY/OLxUBSTiepAg3j/MHuAv5mJhnf53LLMWFB+u/w=
+github.com/go-openapi/errors v0.22.0/go.mod h1:J3DmZScxCDufmIMsdOuDHxJbdOGC0xtUynjIx092vXE=
 github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
 github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
 github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
 github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
+github.com/go-openapi/loads v0.22.0 h1:ECPGd4jX1U6NApCGG1We+uEozOAvXvJSF4nnwHZ8Aco=
+github.com/go-openapi/loads v0.22.0/go.mod h1:yLsaTCS92mnSAZX5WWoxszLj0u+Ojl+Zs5Stn1oF+rs=
+github.com/go-openapi/runtime v0.28.0 h1:gpPPmWSNGo214l6n8hzdXYhPuJcGtziTOgUpvsFWGIQ=
+github.com/go-openapi/runtime v0.28.0/go.mod h1:QN7OzcS+XuYmkQLw05akXk0jRH/eZ3kb18+1KwW9gyc=
+github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY=
+github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
+github.com/go-openapi/strfmt v0.23.0 h1:nlUS6BCqcnAk0pyhi9Y+kdDVZdZMHfEKQiS4HaMgO/c=
+github.com/go-openapi/strfmt v0.23.0/go.mod h1:NrtIpfKtWIygRkKVsxh7XQMDQW5HKQl6S5ik2elW+K4=
 github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
 github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
+github.com/go-openapi/validate v0.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3BumrGD58=
+github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ=
 github.com/go-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-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/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
 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.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
 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/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/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/go.mod h1:oeMosUL+8LtarXBHu/c0bx2D/K9zyQ6uX3cTyztHwsk=
 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=
 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=
+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.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
 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_PASSWORD=${SECRETSERVER_PASSWORD:-}" \
   --env="SECRETSERVER_URL=${SECRETSERVER_URL:-}" \
+  --env="GRAFANA_URL=${GRAFANA_URL:-}" \
+  --env="GRAFANA_TOKEN=${GRAFANA_TOKEN:-}" \
   --env="VERSION=${VERSION}" \
   --env="TEST_SUITES=${TEST_SUITES}" \
   --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 (
-	generatorName = "myfake"
+	generatorName = "my-generator"
 )
 
 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/go-openapi/strfmt v0.23.0
 	github.com/golang-jwt/jwt/v5 v5.2.1
+	github.com/grafana/grafana-openapi-client-go v0.0.0-20240826142251-d1c93bae4198
 	github.com/hashicorp/golang-lru v1.0.2
 	github.com/hashicorp/vault/api/auth/aws v0.8.0
 	github.com/hashicorp/vault/api/auth/userpass v0.8.0
@@ -127,6 +128,11 @@ require (
 	github.com/gabriel-vasile/mimetype v1.4.8 // indirect
 	github.com/go-jose/go-jose/v4 v4.0.4 // indirect
 	github.com/go-logr/stdr v1.2.2 // indirect
+	github.com/go-openapi/analysis v0.23.0 // indirect
+	github.com/go-openapi/loads v0.22.0 // indirect
+	github.com/go-openapi/runtime v0.28.0 // indirect
+	github.com/go-openapi/spec v0.21.0 // indirect
+	github.com/go-openapi/validate v0.24.0 // indirect
 	github.com/go-playground/validator/v10 v10.24.0 // indirect
 	github.com/go-task/slim-sprig/v3 v3.0.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/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ=
 github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg=
+github.com/go-openapi/analysis v0.23.0 h1:aGday7OWupfMs+LbmLZG4k0MYXIANxcuBTYUC03zFCU=
+github.com/go-openapi/analysis v0.23.0/go.mod h1:9mz9ZWaSlV8TvjQHLl2mUW2PbZtemkE8yA5v22ohupo=
 github.com/go-openapi/errors v0.22.0 h1:c4xY/OLxUBSTiepAg3j/MHuAv5mJhnf53LLMWFB+u/w=
 github.com/go-openapi/errors v0.22.0/go.mod h1:J3DmZScxCDufmIMsdOuDHxJbdOGC0xtUynjIx092vXE=
 github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
 github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
 github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
 github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
+github.com/go-openapi/loads v0.22.0 h1:ECPGd4jX1U6NApCGG1We+uEozOAvXvJSF4nnwHZ8Aco=
+github.com/go-openapi/loads v0.22.0/go.mod h1:yLsaTCS92mnSAZX5WWoxszLj0u+Ojl+Zs5Stn1oF+rs=
+github.com/go-openapi/runtime v0.28.0 h1:gpPPmWSNGo214l6n8hzdXYhPuJcGtziTOgUpvsFWGIQ=
+github.com/go-openapi/runtime v0.28.0/go.mod h1:QN7OzcS+XuYmkQLw05akXk0jRH/eZ3kb18+1KwW9gyc=
+github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY=
+github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
 github.com/go-openapi/strfmt v0.23.0 h1:nlUS6BCqcnAk0pyhi9Y+kdDVZdZMHfEKQiS4HaMgO/c=
 github.com/go-openapi/strfmt v0.23.0/go.mod h1:NrtIpfKtWIygRkKVsxh7XQMDQW5HKQl6S5ik2elW+K4=
 github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
 github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
+github.com/go-openapi/validate v0.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3BumrGD58=
+github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ=
 github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
 github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
 github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -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/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
 github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
+github.com/grafana/grafana-openapi-client-go v0.0.0-20240826142251-d1c93bae4198 h1:JEoUdaKnBdZ57YsWiDeAERYu52W4c7g7eAAxY2PpWl8=
+github.com/grafana/grafana-openapi-client-go v0.0.0-20240826142251-d1c93bae4198/go.mod h1:hiZnMmXc9KXNUlvkV2BKFsiWuIFF/fF4wGgYWEjBitI=
 github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
 github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
 github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=

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

@@ -51,6 +51,7 @@ import (
 	// Metrics.
 	"github.com/external-secrets/external-secrets/pkg/controllers/externalsecret/esmetrics"
 	ctrlmetrics "github.com/external-secrets/external-secrets/pkg/controllers/metrics"
+	"github.com/external-secrets/external-secrets/pkg/controllers/util"
 	"github.com/external-secrets/external-secrets/pkg/utils"
 	"github.com/external-secrets/external-secrets/pkg/utils/resolvers"
 
@@ -574,7 +575,7 @@ func (r *Reconciler) markAsDone(externalSecret *esv1beta1.ExternalSecret, start
 	SetExternalSecretCondition(externalSecret, *newReadyCondition)
 
 	externalSecret.Status.RefreshTime = metav1.NewTime(start)
-	externalSecret.Status.SyncedResourceVersion = getResourceVersion(externalSecret)
+	externalSecret.Status.SyncedResourceVersion = util.GetResourceVersion(externalSecret.ObjectMeta)
 
 	// if the status or reason has changed, log at the appropriate verbosity level
 	if oldReadyCondition == nil || oldReadyCondition.Status != newReadyCondition.Status || oldReadyCondition.Reason != newReadyCondition.Reason {
@@ -773,23 +774,6 @@ func getManagedFieldKeys(
 	return keys, nil
 }
 
-func getResourceVersion(es *esv1beta1.ExternalSecret) string {
-	return fmt.Sprintf("%d-%s", es.ObjectMeta.GetGeneration(), hashMeta(es.ObjectMeta))
-}
-
-// hashMeta returns a consistent hash of the `metadata.labels` and `metadata.annotations` fields of the given object.
-func hashMeta(m metav1.ObjectMeta) string {
-	type meta struct {
-		annotations map[string]string
-		labels      map[string]string
-	}
-	objectMeta := meta{
-		annotations: m.Annotations,
-		labels:      m.Labels,
-	}
-	return utils.ObjectHash(objectMeta)
-}
-
 func shouldSkipClusterSecretStore(r *Reconciler, es *esv1beta1.ExternalSecret) bool {
 	return !r.ClusterSecretStoreEnabled && es.Spec.SecretStoreRef.Kind == esv1beta1.ClusterSecretStoreKind
 }
@@ -876,7 +860,7 @@ func shouldRefresh(es *esv1beta1.ExternalSecret) bool {
 	}
 
 	// if the ExternalSecret has been updated, we should refresh
-	if es.Status.SyncedResourceVersion != getResourceVersion(es) {
+	if es.Status.SyncedResourceVersion != util.GetResourceVersion(es.ObjectMeta) {
 		return true
 	}
 
@@ -964,11 +948,11 @@ func (r *Reconciler) findObjectsForSecret(ctx context.Context, secret client.Obj
 	}
 
 	requests := make([]reconcile.Request, len(externalSecretsList.Items))
-	for i, item := range externalSecretsList.Items {
+	for i := range externalSecretsList.Items {
 		requests[i] = reconcile.Request{
 			NamespacedName: types.NamespacedName{
-				Name:      item.GetName(),
-				Namespace: item.GetNamespace(),
+				Name:      externalSecretsList.Items[i].GetName(),
+				Namespace: externalSecretsList.Items[i].GetNamespace(),
 			},
 		}
 	}

+ 45 - 14
pkg/controllers/externalsecret/externalsecret_controller_secret.go

@@ -19,6 +19,7 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"strconv"
 
 	v1 "k8s.io/api/core/v1"
 	apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
@@ -26,6 +27,7 @@ import (
 	esv1beta1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1"
 	genv1alpha1 "github.com/external-secrets/external-secrets/apis/generators/v1alpha1"
 	"github.com/external-secrets/external-secrets/pkg/controllers/secretstore"
+	"github.com/external-secrets/external-secrets/pkg/generator/statemanager"
 	"github.com/external-secrets/external-secrets/pkg/utils"
 	"github.com/external-secrets/external-secrets/pkg/utils/resolvers"
 
@@ -36,6 +38,7 @@ import (
 
 // getProviderSecretData returns the provider's secret data with the provided ExternalSecret.
 func (r *Reconciler) getProviderSecretData(ctx context.Context, externalSecret *esv1beta1.ExternalSecret) (map[string][]byte, error) {
+	var err error
 	// We MUST NOT create multiple instances of a provider client (mostly due to limitations with GCP)
 	// Clientmanager keeps track of the client instances
 	// that are created during the fetching process and closes clients
@@ -43,23 +46,37 @@ func (r *Reconciler) getProviderSecretData(ctx context.Context, externalSecret *
 	mgr := secretstore.NewManager(r.Client, r.ControllerClass, r.EnableFloodGate)
 	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)
 	for i, remoteRef := range externalSecret.Spec.DataFrom {
 		var secretMap map[string][]byte
-		var err error
 
 		if remoteRef.Find != nil {
-			secretMap, err = r.handleFindAllSecrets(ctx, externalSecret, remoteRef, mgr)
+			secretMap, err = r.handleFindAllSecrets(ctx, externalSecret, remoteRef, mgr, genState, i)
 			if err != nil {
 				err = fmt.Errorf("error processing spec.dataFrom[%d].find, err: %w", i, err)
 			}
 		} 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 {
 				err = fmt.Errorf("error processing spec.dataFrom[%d].extract, err: %w", i, err)
 			}
 		} 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 {
 				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 {
 		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 {
 		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
 	secretMap, err = utils.RewriteMap(remoteRef.Rewrite, secretMap)
 	if err != nil {
@@ -150,7 +172,14 @@ func (r *Reconciler) handleGenerateSecrets(ctx context.Context, namespace string
 	return secretMap, err
 }
 
-func (r *Reconciler) handleExtractSecrets(ctx context.Context, externalSecret *esv1beta1.ExternalSecret, remoteRef esv1beta1.ExternalSecretDataFromRemoteRef, cmgr *secretstore.Manager) (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)
 	if err != nil {
 		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 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)
 	if err != nil {
 		return nil, err
@@ -224,7 +254,8 @@ func (r *Reconciler) handleFindAllSecrets(ctx context.Context, externalSecret *e
 	if err != nil {
 		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) {

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

@@ -41,6 +41,7 @@ import (
 	ctest "github.com/external-secrets/external-secrets/pkg/controllers/commontest"
 	"github.com/external-secrets/external-secrets/pkg/controllers/externalsecret/esmetrics"
 	ctrlmetrics "github.com/external-secrets/external-secrets/pkg/controllers/metrics"
+	"github.com/external-secrets/external-secrets/pkg/controllers/util"
 	"github.com/external-secrets/external-secrets/pkg/provider/testing/fake"
 	"github.com/external-secrets/external-secrets/pkg/utils"
 
@@ -2403,7 +2404,7 @@ var _ = Describe("ExternalSecret refresh logic", func() {
 					RefreshTime: metav1.Now(),
 				},
 			}
-			es.Status.SyncedResourceVersion = getResourceVersion(es)
+			es.Status.SyncedResourceVersion = util.GetResourceVersion(es.ObjectMeta)
 			// this should not refresh, rv matches object
 			Expect(shouldRefresh(es)).To(BeFalse())
 
@@ -2427,7 +2428,7 @@ var _ = Describe("ExternalSecret refresh logic", func() {
 					RefreshTime: metav1.Now(),
 				},
 			}
-			es.Status.SyncedResourceVersion = getResourceVersion(es)
+			es.Status.SyncedResourceVersion = util.GetResourceVersion(es.ObjectMeta)
 			// this should not refresh, rv matches object
 			Expect(shouldRefresh(es)).To(BeFalse())
 
@@ -2448,7 +2449,7 @@ var _ = Describe("ExternalSecret refresh logic", func() {
 					RefreshTime: metav1.Now(),
 				},
 			}
-			es.Status.SyncedResourceVersion = getResourceVersion(es)
+			es.Status.SyncedResourceVersion = util.GetResourceVersion(es.ObjectMeta)
 			Expect(shouldRefresh(es)).To(BeFalse())
 
 			// update gen -> refresh
@@ -2467,7 +2468,7 @@ var _ = Describe("ExternalSecret refresh logic", func() {
 				Status: esv1beta1.ExternalSecretStatus{},
 			}
 			// resource version matches
-			es.Status.SyncedResourceVersion = getResourceVersion(es)
+			es.Status.SyncedResourceVersion = util.GetResourceVersion(es.ObjectMeta)
 			Expect(shouldRefresh(es)).To(BeFalse())
 		})
 
@@ -2484,7 +2485,7 @@ var _ = Describe("ExternalSecret refresh logic", func() {
 				},
 			}
 			// resource version matches
-			es.Status.SyncedResourceVersion = getResourceVersion(es)
+			es.Status.SyncedResourceVersion = util.GetResourceVersion(es.ObjectMeta)
 			Expect(shouldRefresh(es)).To(BeTrue())
 		})
 
@@ -2499,20 +2500,20 @@ var _ = Describe("ExternalSecret refresh logic", func() {
 				Status: esv1beta1.ExternalSecretStatus{},
 			}
 			// resource version matches
-			es.Status.SyncedResourceVersion = getResourceVersion(es)
+			es.Status.SyncedResourceVersion = util.GetResourceVersion(es.ObjectMeta)
 			Expect(shouldRefresh(es)).To(BeTrue())
 		})
 
 	})
 	Context("objectmeta hash", func() {
 		It("should produce different hashes for different k/v pairs", func() {
-			h1 := hashMeta(metav1.ObjectMeta{
+			h1 := util.HashMeta(metav1.ObjectMeta{
 				Generation: 1,
 				Annotations: map[string]string{
 					"foo": "bar",
 				},
 			})
-			h2 := hashMeta(metav1.ObjectMeta{
+			h2 := util.HashMeta(metav1.ObjectMeta{
 				Generation: 1,
 				Annotations: map[string]string{
 					"foo": "bing",
@@ -2522,7 +2523,7 @@ var _ = Describe("ExternalSecret refresh logic", func() {
 		})
 
 		It("should produce different hashes for different generations but same label/annotations", func() {
-			h1 := hashMeta(metav1.ObjectMeta{
+			h1 := util.HashMeta(metav1.ObjectMeta{
 				Generation: 1,
 				Annotations: map[string]string{
 					"foo": "bar",
@@ -2531,7 +2532,7 @@ var _ = Describe("ExternalSecret refresh logic", func() {
 					"foo": "bar",
 				},
 			})
-			h2 := hashMeta(metav1.ObjectMeta{
+			h2 := util.HashMeta(metav1.ObjectMeta{
 				Generation: 2,
 				Annotations: map[string]string{
 					"foo": "bar",
@@ -2544,21 +2545,21 @@ var _ = Describe("ExternalSecret refresh logic", func() {
 		})
 
 		It("should produce the same hash for the same k/v pairs", func() {
-			h1 := hashMeta(metav1.ObjectMeta{
+			h1 := util.HashMeta(metav1.ObjectMeta{
 				Generation: 1,
 			})
-			h2 := hashMeta(metav1.ObjectMeta{
+			h2 := util.HashMeta(metav1.ObjectMeta{
 				Generation: 1,
 			})
 			Expect(h1).To(Equal(h2))
 
-			h1 = hashMeta(metav1.ObjectMeta{
+			h1 = util.HashMeta(metav1.ObjectMeta{
 				Generation: 1,
 				Annotations: map[string]string{
 					"foo": "bar",
 				},
 			})
-			h2 = hashMeta(metav1.ObjectMeta{
+			h2 = util.HashMeta(metav1.ObjectMeta{
 				Generation: 1,
 				Annotations: map[string]string{
 					"foo": "bar",

+ 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"
 	"github.com/external-secrets/external-secrets/pkg/controllers/pushsecret/psmetrics"
 	"github.com/external-secrets/external-secrets/pkg/controllers/secretstore"
+	"github.com/external-secrets/external-secrets/pkg/controllers/util"
+	"github.com/external-secrets/external-secrets/pkg/generator/statemanager"
 	"github.com/external-secrets/external-secrets/pkg/provider/util/locks"
 	"github.com/external-secrets/external-secrets/pkg/utils"
 	"github.com/external-secrets/external-secrets/pkg/utils/resolvers"
 
-	// load generators.
 	_ "github.com/external-secrets/external-secrets/pkg/generator/register"
 )
 
@@ -119,32 +120,26 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
 	case esapi.PushSecretDeletionPolicyDelete:
 		// finalizer logic. Only added if we should delete the secrets
 		if ps.ObjectMeta.DeletionTimestamp.IsZero() {
-			if !controllerutil.ContainsFinalizer(&ps, pushSecretFinalizer) {
-				controllerutil.AddFinalizer(&ps, pushSecretFinalizer)
+			if added := controllerutil.AddFinalizer(&ps, pushSecretFinalizer); added {
 				if err := r.Client.Update(ctx, &ps, &client.UpdateOptions{}); err != nil {
 					return ctrl.Result{}, fmt.Errorf(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:
 		if controllerutil.ContainsFinalizer(&ps, pushSecretFinalizer) {
@@ -156,7 +151,17 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
 	default:
 	}
 
-	secret, err := r.resolveSecret(ctx, ps)
+	timeSinceLastRefresh := 0 * time.Second
+	if !ps.Status.RefreshTime.IsZero() {
+		timeSinceLastRefresh = time.Since(ps.Status.RefreshTime.Time)
+	}
+	if !shouldRefresh(ps) {
+		refreshInt = (ps.Spec.RefreshInterval.Duration - timeSinceLastRefresh) + 5*time.Second
+		log.V(1).Info("skipping refresh", "rv", util.GetResourceVersion(ps.ObjectMeta), "nr", refreshInt.Seconds())
+		return ctrl.Result{RequeueAfter: refreshInt}, nil
+	}
+
+	secret, err := r.resolveSecret(ctx, &ps)
 	if err != nil {
 		r.markAsFailed(errFailedGetSecret, &ps, nil)
 
@@ -208,11 +213,24 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
 	default:
 	}
 
-	r.markAsDone(&ps, syncedSecrets)
+	r.markAsDone(&ps, syncedSecrets, start)
 
 	return ctrl.Result{RequeueAfter: refreshInt}, nil
 }
 
+func shouldRefresh(ps esapi.PushSecret) bool {
+	if ps.Status.SyncedResourceVersion != util.GetResourceVersion(ps.ObjectMeta) {
+		return true
+	}
+	if ps.Spec.RefreshInterval.Duration == 0 && ps.Status.SyncedResourceVersion != "" {
+		return false
+	}
+	if ps.Status.RefreshTime.IsZero() {
+		return true
+	}
+	return ps.Status.RefreshTime.Add(ps.Spec.RefreshInterval.Duration).Before(time.Now())
+}
+
 func (r *Reconciler) markAsFailed(msg string, ps *esapi.PushSecret, syncState esapi.SyncedPushSecretsMap) {
 	cond := newPushSecretCondition(esapi.PushSecretReady, v1.ConditionFalse, esapi.ReasonErrored, msg)
 	setPushSecretCondition(ps, *cond)
@@ -222,7 +240,7 @@ func (r *Reconciler) markAsFailed(msg string, ps *esapi.PushSecret, syncState es
 	r.recorder.Event(ps, v1.EventTypeWarning, esapi.ReasonErrored, msg)
 }
 
-func (r *Reconciler) markAsDone(ps *esapi.PushSecret, secrets esapi.SyncedPushSecretsMap) {
+func (r *Reconciler) markAsDone(ps *esapi.PushSecret, secrets esapi.SyncedPushSecretsMap, start time.Time) {
 	msg := "PushSecret synced successfully"
 	if ps.Spec.UpdatePolicy == esapi.PushSecretUpdatePolicyIfNotExists {
 		msg += ". Existing secrets in providers unchanged."
@@ -230,6 +248,8 @@ func (r *Reconciler) markAsDone(ps *esapi.PushSecret, secrets esapi.SyncedPushSe
 	cond := newPushSecretCondition(esapi.PushSecretReady, v1.ConditionTrue, esapi.ReasonSynced, msg)
 	setPushSecretCondition(ps, *cond)
 	r.setSecrets(ps, secrets)
+	ps.Status.RefreshTime = metav1.NewTime(start)
+	ps.Status.SyncedResourceVersion = util.GetResourceVersion(ps.ObjectMeta)
 	r.recorder.Event(ps, v1.EventTypeNormal, esapi.ReasonSynced, msg)
 }
 
@@ -355,7 +375,23 @@ func secretKeyExists(key string, secret *v1.Secret) bool {
 	return key == "" || ok
 }
 
-func (r *Reconciler) resolveSecret(ctx context.Context, ps esapi.PushSecret) (*v1.Secret, error) {
+const defaultGeneratorStateKey = "__pushsecret"
+
+func (r *Reconciler) resolveSecret(ctx context.Context, ps *esapi.PushSecret) (*v1.Secret, error) {
+	var err error
+	generatorState := statemanager.New(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 {
 		secretName := types.NamespacedName{Name: ps.Spec.Selector.Secret.Name, Namespace: ps.Namespace}
 		secret := &v1.Secret{}
@@ -363,23 +399,31 @@ func (r *Reconciler) resolveSecret(ctx context.Context, ps esapi.PushSecret) (*v
 		if err != nil {
 			return nil, err
 		}
+		generatorState.EnqueueFlagLatestStateForGC(defaultGeneratorStateKey)
 		return secret, nil
 	}
 	if ps.Spec.Selector.GeneratorRef != nil {
-		return r.resolveSecretFromGenerator(ctx, ps.Namespace, ps.Spec.Selector.GeneratorRef)
+		return r.resolveSecretFromGenerator(ctx, ps.Namespace, ps.Spec.Selector.GeneratorRef, generatorState)
 	}
 	return nil, errors.New("no secret selector provided")
 }
 
-func (r *Reconciler) resolveSecretFromGenerator(ctx context.Context, namespace string, generatorRef *v1beta1.GeneratorRef) (*v1.Secret, error) {
-	gen, obj, err := resolvers.GeneratorRef(ctx, r.Client, r.Scheme, namespace, generatorRef)
+func (r *Reconciler) resolveSecretFromGenerator(ctx context.Context, namespace string, generatorRef *v1beta1.GeneratorRef, generatorState *statemanager.Manager) (*v1.Secret, error) {
+	gen, genResource, err := resolvers.GeneratorRef(ctx, r.Client, r.Scheme, namespace, generatorRef)
 	if err != nil {
 		return nil, fmt.Errorf("unable to resolve generator: %w", err)
 	}
-	secretMap, err := gen.Generate(ctx, obj, r.Client, namespace)
+	prevState, 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 {
 		return nil, fmt.Errorf("unable to generate: %w", err)
 	}
+	if prevState != nil {
+		generatorState.EnqueueSetLatest(ctx, defaultGeneratorStateKey, namespace, genResource, gen, newState)
+	}
 	return &v1.Secret{
 		ObjectMeta: metav1.ObjectMeta{
 			Name:      "___generated-secret",

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

@@ -0,0 +1,37 @@
+/*
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+	http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+package util
+
+import (
+	"fmt"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	"github.com/external-secrets/external-secrets/pkg/utils"
+)
+
+func GetResourceVersion(meta metav1.ObjectMeta) string {
+	return fmt.Sprintf("%d-%s", meta.GetGeneration(), HashMeta(meta))
+}
+
+func HashMeta(m metav1.ObjectMeta) string {
+	type meta struct {
+		annotations map[string]string
+		labels      map[string]string
+	}
+	return utils.ObjectHash(meta{
+		annotations: m.Annotations,
+		labels:      m.Labels,
+	})
+}

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

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

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

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

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

@@ -46,10 +46,14 @@ const (
 	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)
 }
 
+func (g *Generator) Cleanup(ctx context.Context, jsonSpec *apiextensions.JSON, _ genv1alpha1.GeneratorProviderState, crClient client.Client, namespace string) error {
+	return nil
+}
+
 func (g *Generator) generate(
 	ctx context.Context,
 	jsonSpec *apiextensions.JSON,
@@ -57,13 +61,13 @@ func (g *Generator) generate(
 	namespace string,
 	ecrPrivateFunc ecrPrivateFactoryFunc,
 	ecrPublicFunc ecrPublicFactoryFunc,
-) (map[string][]byte, error) {
+) (map[string][]byte, genv1alpha1.GeneratorProviderState, error) {
 	if jsonSpec == nil {
-		return nil, errors.New(errNoSpec)
+		return nil, nil, errors.New(errNoSpec)
 	}
 	res, err := parseSpec(jsonSpec.Raw)
 	if err != nil {
-		return nil, fmt.Errorf(errParseSpec, err)
+		return nil, nil, fmt.Errorf(errParseSpec, err)
 	}
 	sess, err := awsauth.NewGeneratorSession(
 		ctx,
@@ -78,7 +82,7 @@ func (g *Generator) generate(
 		awsauth.DefaultSTSProvider,
 		awsauth.DefaultJWTProvider)
 	if err != nil {
-		return nil, fmt.Errorf(errCreateSess, err)
+		return nil, nil, fmt.Errorf(errCreateSess, err)
 	}
 
 	if res.Spec.Scope == "public" {
@@ -88,24 +92,24 @@ func (g *Generator) generate(
 	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)
 	out, err := client.GetAuthorizationToken(&ecr.GetAuthorizationTokenInput{})
 	if err != nil {
-		return nil, fmt.Errorf(errGetPrivateToken, err)
+		return nil, nil, fmt.Errorf(errGetPrivateToken, err)
 	}
 	if len(out.AuthorizationData) != 1 {
-		return nil, fmt.Errorf("unexpected number of authorization tokens. expected 1, found %d", len(out.AuthorizationData))
+		return nil, nil, fmt.Errorf("unexpected number of authorization tokens. expected 1, found %d", len(out.AuthorizationData))
 	}
 
 	// AuthorizationToken is base64 encoded {username}:{password} string
 	decodedToken, err := base64.StdEncoding.DecodeString(*out.AuthorizationData[0].AuthorizationToken)
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 	parts := strings.Split(string(decodedToken), ":")
 	if len(parts) != 2 {
-		return nil, errors.New("unexpected token format")
+		return nil, nil, errors.New("unexpected token format")
 	}
 
 	exp := out.AuthorizationData[0].ExpiresAt.UTC().Unix()
@@ -114,23 +118,23 @@ func fetchECRPrivateToken(sess *session.Session, ecrPrivateFunc ecrPrivateFactor
 		"password":       []byte(parts[1]),
 		"proxy_endpoint": []byte(*out.AuthorizationData[0].ProxyEndpoint),
 		"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)
 	out, err := client.GetAuthorizationToken(&ecrpublic.GetAuthorizationTokenInput{})
 	if err != nil {
-		return nil, fmt.Errorf(errGetPublicToken, err)
+		return nil, nil, fmt.Errorf(errGetPublicToken, err)
 	}
 
 	decodedToken, err := base64.StdEncoding.DecodeString(*out.AuthorizationData.AuthorizationToken)
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 	parts := strings.Split(string(decodedToken), ":")
 	if len(parts) != 2 {
-		return nil, errors.New("unexpected token format")
+		return nil, nil, errors.New("unexpected token format")
 	}
 
 	exp := out.AuthorizationData.ExpiresAt.UTC().Unix()
@@ -138,7 +142,7 @@ func fetchECRPublicToken(sess *session.Session, ecrPublicFunc ecrPublicFactoryFu
 		"username":   []byte(parts[0]),
 		"password":   []byte(parts[1]),
 		"expires_at": []byte(strconv.FormatInt(exp, 10)),
-	}, nil
+	}, nil, nil
 }
 
 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 {
 		t.Run(tt.name, func(t *testing.T) {
 			g := &Generator{}
-			got, err := g.generate(
+			got, _, err := g.generate(
 				tt.args.ctx,
 				tt.args.jsonSpec,
 				tt.args.kube,

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -52,7 +52,7 @@ const (
 	httpClientTimeout = 5 * time.Second
 )
 
-func (g *Generator) Generate(ctx context.Context, jsonSpec *apiextensions.JSON, kube client.Client, namespace string) (map[string][]byte, error) {
+func (g *Generator) Generate(ctx context.Context, jsonSpec *apiextensions.JSON, kube client.Client, namespace string) (map[string][]byte, genv1alpha1.GeneratorProviderState, error) {
 	return g.generate(
 		ctx,
 		jsonSpec,
@@ -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(
 	ctx context.Context,
 	jsonSpec *apiextensions.JSON,
 	_ client.Client,
-	namespace string) (map[string][]byte, error) {
+	namespace string) (map[string][]byte, genv1alpha1.GeneratorProviderState, error) {
 	if jsonSpec == nil {
-		return nil, errors.New(errNoSpec)
+		return nil, nil, errors.New(errNoSpec)
 	}
 	res, err := parseSpec(jsonSpec.Raw)
 	if err != nil {
-		return nil, fmt.Errorf(errParseSpec, err)
+		return nil, nil, fmt.Errorf(errParseSpec, err)
 	}
 
 	// Fetch the service account token
 	token, err := fetchServiceAccountToken(ctx, res.Spec.ServiceAccountRef, namespace)
 	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
 	if url == "" {
@@ -87,17 +91,17 @@ func (g *Generator) generate(
 
 	accessToken, err := getQuayRobotToken(ctx, token, res.Spec.RobotAccount, url, g.httpClient)
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 	exp, err := tokenExpiration(accessToken)
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 	return map[string][]byte{
 		"registry": []byte(url),
 		"auth":     []byte(b64.StdEncoding.EncodeToString([]byte(res.Spec.RobotAccount + ":" + accessToken))),
 		"expiry":   []byte(exp),
-	}, nil
+	}, nil, nil
 }
 
 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/gcr"
 	_ "github.com/external-secrets/external-secrets/pkg/generator/github"
+	_ "github.com/external-secrets/external-secrets/pkg/generator/grafana"
 	_ "github.com/external-secrets/external-secrets/pkg/generator/password"
 	_ "github.com/external-secrets/external-secrets/pkg/generator/quay"
 	_ "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"
 )
 
-func (g *Generator) Generate(ctx context.Context, jsonSpec *apiextensions.JSON, kube client.Client, namespace string) (map[string][]byte, error) {
+func (g *Generator) Generate(ctx context.Context, jsonSpec *apiextensions.JSON, kube client.Client, namespace string) (map[string][]byte, genv1alpha1.GeneratorProviderState, error) {
 	return g.generate(ctx, jsonSpec, kube, namespace, stsFactory)
 }
 
@@ -51,13 +51,13 @@ func (g *Generator) generate(
 	kube client.Client,
 	namespace string,
 	stsFunc stsFactoryFunc,
-) (map[string][]byte, error) {
+) (map[string][]byte, genv1alpha1.GeneratorProviderState, error) {
 	if jsonSpec == nil {
-		return nil, errors.New(errNoSpec)
+		return nil, nil, errors.New(errNoSpec)
 	}
 	res, err := parseSpec(jsonSpec.Raw)
 	if err != nil {
-		return nil, fmt.Errorf(errParseSpec, err)
+		return nil, nil, fmt.Errorf(errParseSpec, err)
 	}
 	sess, err := awsauth.NewGeneratorSession(
 		ctx,
@@ -72,7 +72,7 @@ func (g *Generator) generate(
 		awsauth.DefaultSTSProvider,
 		awsauth.DefaultJWTProvider)
 	if err != nil {
-		return nil, fmt.Errorf(errCreateSess, err)
+		return nil, nil, fmt.Errorf(errCreateSess, err)
 	}
 	client := stsFunc(sess)
 	input := &sts.GetSessionTokenInput{}
@@ -83,10 +83,10 @@ func (g *Generator) generate(
 	}
 	out, err := client.GetSessionToken(input)
 	if err != nil {
-		return nil, fmt.Errorf(errGetToken, err)
+		return nil, nil, fmt.Errorf(errGetToken, err)
 	}
 	if out.Credentials == nil {
-		return nil, errors.New("no credentials found")
+		return nil, nil, errors.New("no credentials found")
 	}
 
 	return map[string][]byte{
@@ -94,7 +94,11 @@ func (g *Generator) generate(
 		"expiration":        []byte(strconv.FormatInt(out.Credentials.Expiration.Unix(), 10)),
 		"secret_access_key": []byte(*out.Credentials.SecretAccessKey),
 		"session_token":     []byte(*out.Credentials.SessionToken),
-	}, nil
+	}, nil, nil
+}
+
+func (g *Generator) Cleanup(_ context.Context, jsonSpec *apiextensions.JSON, state genv1alpha1.GeneratorProviderState, _ client.Client, _ string) error {
+	return nil
 }
 
 type stsFactoryFunc func(aws *session.Session) stsiface.STSAPI

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

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

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

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

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

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

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

@@ -43,7 +43,7 @@ const (
 	errGetSecret   = "unable to get dynamic secret: %w"
 )
 
-func (g *Generator) Generate(ctx context.Context, jsonSpec *apiextensions.JSON, kube client.Client, namespace string) (map[string][]byte, error) {
+func (g *Generator) Generate(ctx context.Context, jsonSpec *apiextensions.JSON, kube client.Client, namespace string) (map[string][]byte, genv1alpha1.GeneratorProviderState, error) {
 	c := &provider.Provider{NewVaultClient: provider.NewVaultClient}
 
 	// controller-runtime/client does not support TokenRequest or other subresource APIs
@@ -51,67 +51,49 @@ func (g *Generator) Generate(ctx context.Context, jsonSpec *apiextensions.JSON,
 	// (for Kubernetes service account token auth)
 	restCfg, err := ctrlcfg.GetConfig()
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 	clientset, err := kubernetes.NewForConfig(restCfg)
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 
 	return g.generate(ctx, c, jsonSpec, kube, clientset.CoreV1(), namespace)
 }
 
-func (g *Generator) generate(ctx context.Context, c *provider.Provider, jsonSpec *apiextensions.JSON, kube client.Client, corev1 typedcorev1.CoreV1Interface, namespace string) (map[string][]byte, error) {
+func (g *Generator) Cleanup(_ context.Context, jsonSpec *apiextensions.JSON, state genv1alpha1.GeneratorProviderState, _ client.Client, _ string) error {
+	return nil
+}
+
+func (g *Generator) generate(ctx context.Context, c *provider.Provider, jsonSpec *apiextensions.JSON, kube client.Client, corev1 typedcorev1.CoreV1Interface, namespace string) (map[string][]byte, genv1alpha1.GeneratorProviderState, error) {
 	if jsonSpec == nil {
-		return nil, errors.New(errNoSpec)
+		return nil, nil, errors.New(errNoSpec)
 	}
-	res, err := parseSpec(jsonSpec.Raw)
+	spec, err := parseSpec(jsonSpec.Raw)
 	if err != nil {
-		return nil, fmt.Errorf(errParseSpec, err)
+		return nil, nil, fmt.Errorf(errParseSpec, err)
 	}
-	if res == nil || res.Spec.Provider == nil {
-		return nil, errors.New("no Vault provider config in spec")
+	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 {
-		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 {
-		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 {
-		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) {
@@ -140,6 +122,32 @@ func (g *Generator) fetchVaultSecret(ctx context.Context, res *genv1alpha1.Vault
 	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) {
 	var spec genv1alpha1.VaultDynamicSecret
 	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) {
 			c := &provider.Provider{NewVaultClient: fake.ClientWithLoginMock}
 			gen := &Generator{}
-			val, err := gen.generate(context.Background(), c, tc.args.jsonSpec, tc.args.kube, tc.args.corev1, "testing")
+			val, _, err := gen.generate(context.Background(), c, tc.args.jsonSpec, tc.args.kube, tc.args.corev1, "testing")
 			if err != nil || tc.want.err != nil {
 				if diff := cmp.Diff(tc.want.err.Error(), err.Error()); diff != "" {
 					t.Errorf("\n%s\nvault.GetSecret(...): -want error, +got error:\n%s", tc.reason, diff)

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

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

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

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

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

@@ -20,6 +20,7 @@ import (
 	"reflect"
 
 	apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 	"k8s.io/apimachinery/pkg/runtime"
 	"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 &genv1alpha1.ACRAccessToken{
+			TypeMeta: metav1.TypeMeta{
+				APIVersion: genv1alpha1.SchemeGroupVersion.String(),
+				Kind:       genv1alpha1.ACRAccessTokenKind,
+			},
 			Spec: *gen.Spec.Generator.ACRAccessTokenSpec,
 		}, nil
 	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 &genv1alpha1.ECRAuthorizationToken{
+			TypeMeta: metav1.TypeMeta{
+				APIVersion: genv1alpha1.SchemeGroupVersion.String(),
+				Kind:       genv1alpha1.ECRAuthorizationTokenKind,
+			},
 			Spec: *gen.Spec.Generator.ECRAuthorizationTokenSpec,
 		}, nil
 	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 &genv1alpha1.Fake{
+			TypeMeta: metav1.TypeMeta{
+				APIVersion: genv1alpha1.SchemeGroupVersion.String(),
+				Kind:       genv1alpha1.FakeKind,
+			},
 			Spec: *gen.Spec.Generator.FakeSpec,
 		}, nil
 	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 &genv1alpha1.GCRAccessToken{
+			TypeMeta: metav1.TypeMeta{
+				APIVersion: genv1alpha1.SchemeGroupVersion.String(),
+				Kind:       genv1alpha1.GCRAccessTokenKind,
+			},
 			Spec: *gen.Spec.Generator.GCRAccessTokenSpec,
 		}, nil
 	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 &genv1alpha1.GithubAccessToken{
+			TypeMeta: metav1.TypeMeta{
+				APIVersion: genv1alpha1.SchemeGroupVersion.String(),
+				Kind:       genv1alpha1.GithubAccessTokenKind,
+			},
 			Spec: *gen.Spec.Generator.GithubAccessTokenSpec,
 		}, nil
 	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 &genv1alpha1.Password{
+			TypeMeta: metav1.TypeMeta{
+				APIVersion: genv1alpha1.SchemeGroupVersion.String(),
+				Kind:       genv1alpha1.PasswordKind,
+			},
 			Spec: *gen.Spec.Generator.PasswordSpec,
 		}, nil
 	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 &genv1alpha1.STSSessionToken{
+			TypeMeta: metav1.TypeMeta{
+				APIVersion: genv1alpha1.SchemeGroupVersion.String(),
+				Kind:       genv1alpha1.STSSessionTokenKind,
+			},
 			Spec: *gen.Spec.Generator.STSSessionTokenSpec,
 		}, nil
 	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 &genv1alpha1.UUID{
+			TypeMeta: metav1.TypeMeta{
+				APIVersion: genv1alpha1.SchemeGroupVersion.String(),
+				Kind:       genv1alpha1.UUIDKind,
+			},
 			Spec: *gen.Spec.Generator.UUIDSpec,
 		}, nil
 	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 &genv1alpha1.VaultDynamicSecret{
+			TypeMeta: metav1.TypeMeta{
+				APIVersion: genv1alpha1.SchemeGroupVersion.String(),
+				Kind:       genv1alpha1.VaultDynamicSecretKind,
+			},
 			Spec: *gen.Spec.Generator.VaultDynamicSecretSpec,
 		}, nil
 	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 &genv1alpha1.Webhook{
+			TypeMeta: metav1.TypeMeta{
+				APIVersion: genv1alpha1.SchemeGroupVersion.String(),
+				Kind:       genv1alpha1.WebhookKind,
+			},
 			Spec: *gen.Spec.Generator.WebhookSpec,
 		}, nil
+	case genv1alpha1.GeneratorKindGrafana:
+		if gen.Spec.Generator.GrafanaSpec == nil {
+			return nil, fmt.Errorf("when kind is %s, GrafanaSpec must be set", gen.Spec.Kind)
+		}
+		return &genv1alpha1.Grafana{
+			TypeMeta: metav1.TypeMeta{
+				APIVersion: genv1alpha1.SchemeGroupVersion.String(),
+				Kind:       genv1alpha1.GrafanaKind,
+			},
+			Spec: *gen.Spec.Generator.GrafanaSpec,
+		}, nil
 	default:
 		return nil, fmt.Errorf("unknown kind %s", gen.Spec.Kind)
 	}