Browse Source

feat: ​add refreshPolicy field to ExternalSecret for enhanced synchronization control​ (#4594)

* feat: refresh policy support for externalsecret

Signed-off-by: Sn0rt <wangguohao.2009@gmail.com>

* opt: change refreshpolicy with explicit RefreshPolicyPeriodic

Signed-off-by: Sn0rt <wangguohao.2009@gmail.com>

* fix(gocritic): remove empty case containing only fallthrough to default case

Signed-off-by: Sn0rt <wangguohao.2009@gmail.com>

* docs: update doc for refreshPolicy of ExternalSecret

Signed-off-by: Sn0rt <wangguohao.2009@gmail.com>

* ci(exhaustive): missing cases in switch of type v1beta1.ExternalSecretRefreshPolicy

Signed-off-by: Sn0rt <wangguohao.2009@gmail.com>

* fix: change handleSecretData parameter type to pointer

Signed-off-by: Sn0rt <wangguohao.2009@gmail.com>

* docs: update spec.md

Signed-off-by: Sn0rt <wangguohao.2009@gmail.com>

* test: update externalsecret/clusterexternalsecret

Signed-off-by: Sn0rt <wangguohao.2009@gmail.com>

---------

Signed-off-by: Sn0rt <wangguohao.2009@gmail.com>
Sn0rt 1 year ago
parent
commit
818fc37ee7

+ 17 - 0
apis/externalsecrets/v1beta1/externalsecret_types.go

@@ -360,6 +360,15 @@ type FindName struct {
 	RegExp string `json:"regexp,omitempty"`
 }
 
+// +kubebuilder:validation:Enum=CreatedOnce;Periodic;OnChange
+type ExternalSecretRefreshPolicy string
+
+const (
+	RefreshPolicyCreatedOnce ExternalSecretRefreshPolicy = "CreatedOnce"
+	RefreshPolicyPeriodic    ExternalSecretRefreshPolicy = "Periodic"
+	RefreshPolicyOnChange    ExternalSecretRefreshPolicy = "OnChange"
+)
+
 // ExternalSecretSpec defines the desired state of ExternalSecret.
 type ExternalSecretSpec struct {
 	// +optional
@@ -369,6 +378,14 @@ type ExternalSecretSpec struct {
 	// +optional
 	Target ExternalSecretTarget `json:"target,omitempty"`
 
+	// RefreshPolicy determines how the ExternalSecret should be refreshed:
+	// - CreatedOnce: Creates the Secret only if it does not exist and does not update it thereafter
+	// - Periodic: Synchronizes the Secret from the external source at regular intervals specified by refreshInterval.
+	//   No periodic updates occur if refreshInterval is 0.
+	// - OnChange: Only synchronizes the Secret when the ExternalSecret's metadata or specification changes
+	// +optional
+	RefreshPolicy ExternalSecretRefreshPolicy `json:"refreshPolicy,omitempty"`
+
 	// RefreshInterval is the amount of time before the values are read again from the SecretStore provider,
 	// specified as Golang Duration strings.
 	// Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h"

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

@@ -411,6 +411,18 @@ spec:
                       Example values: "1h", "2h30m", "10s"
                       May be set to zero to fetch and create it once. Defaults to 1h.
                     type: string
+                  refreshPolicy:
+                    description: |-
+                      RefreshPolicy determines how the ExternalSecret should be refreshed:
+                      - CreatedOnce: Creates the Secret only if it does not exist and does not update it thereafter
+                      - Periodic: Synchronizes the Secret from the external source at regular intervals specified by refreshInterval.
+                        No periodic updates occur if refreshInterval is 0.
+                      - OnChange: Only synchronizes the Secret when the ExternalSecret's metadata or specification changes
+                    enum:
+                    - CreatedOnce
+                    - Periodic
+                    - OnChange
+                    type: string
                   secretStoreRef:
                     description: SecretStoreRef defines which SecretStore to fetch
                       the ExternalSecret data.

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

@@ -706,6 +706,18 @@ spec:
                   Example values: "1h", "2h30m", "10s"
                   May be set to zero to fetch and create it once. Defaults to 1h.
                 type: string
+              refreshPolicy:
+                description: |-
+                  RefreshPolicy determines how the ExternalSecret should be refreshed:
+                  - CreatedOnce: Creates the Secret only if it does not exist and does not update it thereafter
+                  - Periodic: Synchronizes the Secret from the external source at regular intervals specified by refreshInterval.
+                    No periodic updates occur if refreshInterval is 0.
+                  - OnChange: Only synchronizes the Secret when the ExternalSecret's metadata or specification changes
+                enum:
+                - CreatedOnce
+                - Periodic
+                - OnChange
+                type: string
               secretStoreRef:
                 description: SecretStoreRef defines which SecretStore to fetch the
                   ExternalSecret data.

+ 24 - 0
deploy/crds/bundle.yaml

@@ -391,6 +391,18 @@ spec:
                         Example values: "1h", "2h30m", "10s"
                         May be set to zero to fetch and create it once. Defaults to 1h.
                       type: string
+                    refreshPolicy:
+                      description: |-
+                        RefreshPolicy determines how the ExternalSecret should be refreshed:
+                        - CreatedOnce: Creates the Secret only if it does not exist and does not update it thereafter
+                        - Periodic: Synchronizes the Secret from the external source at regular intervals specified by refreshInterval.
+                          No periodic updates occur if refreshInterval is 0.
+                        - OnChange: Only synchronizes the Secret when the ExternalSecret's metadata or specification changes
+                      enum:
+                        - CreatedOnce
+                        - Periodic
+                        - OnChange
+                      type: string
                     secretStoreRef:
                       description: SecretStoreRef defines which SecretStore to fetch the ExternalSecret data.
                       properties:
@@ -7878,6 +7890,18 @@ spec:
                     Example values: "1h", "2h30m", "10s"
                     May be set to zero to fetch and create it once. Defaults to 1h.
                   type: string
+                refreshPolicy:
+                  description: |-
+                    RefreshPolicy determines how the ExternalSecret should be refreshed:
+                    - CreatedOnce: Creates the Secret only if it does not exist and does not update it thereafter
+                    - Periodic: Synchronizes the Secret from the external source at regular intervals specified by refreshInterval.
+                      No periodic updates occur if refreshInterval is 0.
+                    - OnChange: Only synchronizes the Secret when the ExternalSecret's metadata or specification changes
+                  enum:
+                    - CreatedOnce
+                    - Periodic
+                    - OnChange
+                  type: string
                 secretStoreRef:
                   description: SecretStoreRef defines which SecretStore to fetch the ExternalSecret data.
                   properties:

+ 62 - 6
docs/api/externalsecret.md

@@ -11,15 +11,71 @@ be transformed and saved as a `Kind=Secret`:
 
 When the controller reconciles the `ExternalSecret` it will use the `spec.template` as a blueprint to construct a new `Kind=Secret`. You can use golang templates to define the blueprint and use template functions to transform secret values. You can also pull in `ConfigMaps` that contain golang-template data using `templateFrom`. See [advanced templating](../guides/templating.md) for details.
 
-## Update Behavior
+## Update behavior with 3 different refresh policies
 
-The `Kind=Secret` is updated when one of the following conditions is met and `spec.refreshInterval` is not `0`:
+You can control how and when the `ExternalSecret` is refreshed by setting the `spec.refreshPolicy` field. If not specified, the default behavior is `Periodic`.
 
-* the `spec.refreshInterval` has passed
-* the `ExternalSecret`'s `labels` or `annotations` are changed
-* the `ExternalSecret`'s `spec` has been changed
+### CreatedOnce
 
-You can trigger a secret refresh by using kubectl or any other kubernetes api client:
+With `refreshPolicy: CreatedOnce`, the controller will:
+- Create the `Kind=Secret` only if it does not exist yet
+- Never update the `Kind=Secret` afterwards if the source data changes
+- Update/ Recreate the `Kind=Secret` if it gets changed/Deleted
+- Useful for immutable credentials or when you want to manage updates manually
+
+Example:
+```yaml
+apiVersion: external-secrets.io/v1beta1
+kind: ExternalSecret
+metadata:
+  name: example
+spec:
+  refreshPolicy: CreatedOnce
+  # other fields...
+```
+
+### Periodic
+
+With `refreshPolicy: Periodic` (the default behavior), the controller will:
+- Create the `Kind=Secret` if it doesn't exist
+- Update the `Kind=Secret` regularly based on the `spec.refreshInterval` duration
+- When `spec.refreshInterval` is set to zero, it will only create the secret once and not update it afterward
+- When `spec.refreshInterval` is set to a value greater than zero, the controller will update the `Kind=Secret` at the specified interval or when the `ExternalSecret` specification changes
+
+Example:
+```yaml
+apiVersion: external-secrets.io/v1beta1
+kind: ExternalSecret
+metadata:
+  name: example
+spec:
+  refreshPolicy: Periodic
+  refreshInterval: 1h  # Update every hour
+  # other fields...
+```
+
+### OnChange
+
+With `refreshPolicy: OnChange`, the controller will:
+- Create the `Kind=Secret` if it doesn't exist
+- Update the `Kind=Secret` only when the `ExternalSecret`'s metadata or specification changes
+- This policy is independent of the `refreshInterval` value
+- Useful when you want to manually control when the secret is updated, by modifying the `ExternalSecret` resource
+
+Example:
+```yaml
+apiVersion: external-secrets.io/v1beta1
+kind: ExternalSecret
+metadata:
+  name: example
+spec:
+  refreshPolicy: OnChange
+  # other fields...
+```
+
+## Manual Refresh
+
+Regardless of the refresh policy, you can always manually trigger a refresh of the `Kind=Secret` by updating the annotations of the `ExternalSecret`:
 
 ```
 kubectl annotate es my-es force-sync=$(date +%s) --overwrite

+ 59 - 0
docs/api/spec.md

@@ -3143,6 +3143,24 @@ ExternalSecretTarget
 </tr>
 <tr>
 <td>
+<code>refreshPolicy</code></br>
+<em>
+<a href="#external-secrets.io/v1beta1.ExternalSecretRefreshPolicy">
+ExternalSecretRefreshPolicy
+</a>
+</em>
+</td>
+<td>
+<em>(Optional)</em>
+<p>RefreshPolicy determines how the ExternalSecret should be refreshed:
+- CreatedOnce: Creates the Secret only if it does not exist and does not update it thereafter
+- Periodic: Synchronizes the Secret from the external source at regular intervals specified by refreshInterval.
+No periodic updates occur if refreshInterval is 0.
+- OnChange: Only synchronizes the Secret when the ExternalSecret&rsquo;s metadata or specification changes</p>
+</td>
+</tr>
+<tr>
+<td>
 <code>refreshInterval</code></br>
 <em>
 <a href="https://pkg.go.dev/k8s.io/apimachinery/pkg/apis/meta/v1#Duration">
@@ -3721,6 +3739,29 @@ map[string]string
 <td></td>
 </tr></tbody>
 </table>
+<h3 id="external-secrets.io/v1beta1.ExternalSecretRefreshPolicy">ExternalSecretRefreshPolicy
+(<code>string</code> alias)</p></h3>
+<p>
+(<em>Appears on:</em>
+<a href="#external-secrets.io/v1beta1.ExternalSecretSpec">ExternalSecretSpec</a>)
+</p>
+<p>
+</p>
+<table>
+<thead>
+<tr>
+<th>Value</th>
+<th>Description</th>
+</tr>
+</thead>
+<tbody><tr><td><p>&#34;CreatedOnce&#34;</p></td>
+<td></td>
+</tr><tr><td><p>&#34;OnChange&#34;</p></td>
+<td></td>
+</tr><tr><td><p>&#34;Periodic&#34;</p></td>
+<td></td>
+</tr></tbody>
+</table>
 <h3 id="external-secrets.io/v1beta1.ExternalSecretRewrite">ExternalSecretRewrite
 </h3>
 <p>
@@ -3885,6 +3926,24 @@ ExternalSecretTarget
 </tr>
 <tr>
 <td>
+<code>refreshPolicy</code></br>
+<em>
+<a href="#external-secrets.io/v1beta1.ExternalSecretRefreshPolicy">
+ExternalSecretRefreshPolicy
+</a>
+</em>
+</td>
+<td>
+<em>(Optional)</em>
+<p>RefreshPolicy determines how the ExternalSecret should be refreshed:
+- CreatedOnce: Creates the Secret only if it does not exist and does not update it thereafter
+- Periodic: Synchronizes the Secret from the external source at regular intervals specified by refreshInterval.
+No periodic updates occur if refreshInterval is 0.
+- OnChange: Only synchronizes the Secret when the ExternalSecret&rsquo;s metadata or specification changes</p>
+</td>
+</tr>
+<tr>
+<td>
 <code>refreshInterval</code></br>
 <em>
 <a href="https://pkg.go.dev/k8s.io/apimachinery/pkg/apis/meta/v1#Duration">

+ 5 - 2
docs/introduction/faq.md

@@ -10,14 +10,17 @@ kubectl annotate es my-es force-sync=$(date +%s) --overwrite
 ## How do I know when my secret was last synced?
 
 
-The last synchronization timestamp of an ExternalSecret can be retrieved from the field `refreshTime`. 
+The last synchronization timestamp of an ExternalSecret can be retrieved from the field `refreshTime`.
 
 ```
 kubectl get es my-external-secret -o yaml | grep refreshTime
   refreshTime: "2022-05-21T23:02:47Z"
 ```
 
-The interval can be changed by the `spec.refreshInterval` in the ExternalSecret.
+The interval can be changed by the `spec.refreshInterval` in the ExternalSecret. You can also control the refresh behavior by setting `spec.refreshPolicy` to one of the following options:
+- `Periodic` (default): Update regularly based on refreshInterval
+- `CreatedOnce`: Create the Secret only once and never update it afterward
+- `OnChange`: Only update when the ExternalSecret's metadata or specification changes
 
 ## How do I know when the status of my secret changed the last time?
 

+ 9 - 3
docs/snippets/full-cluster-external-secret.yaml

@@ -33,6 +33,12 @@ spec:
       name: secret-store-name
       kind: SecretStore
 
+    # RefreshPolicy determines how the ExternalSecret should be refreshed:
+    # - CreatedOnce: Creates the Secret only if it does not exist and does not update it afterward
+    # - Periodic: (default) Synchronizes the Secret at intervals specified by refreshInterval
+    # - OnChange: Only synchronizes when the ExternalSecret's metadata or specification changes
+    refreshPolicy: Periodic
+
     refreshInterval: "1h"
     target:
       name: my-secret
@@ -71,13 +77,13 @@ status:
     - namespace: "matching-ns-1"
       # This is one of the possible messages, and likely the most common
       reason: "external secret already exists in namespace"
-  
+
   # You can find all matching and successfully deployed namespaces here
   provisionedNamespaces:
     - "matching-ns-3"
     - "matching-ns-2"
-  
-  # The condition can be Ready, PartiallyReady, or NotReady 
+
+  # The condition can be Ready, PartiallyReady, or NotReady
   # PartiallyReady would indicate an error in 1 or more namespaces
   # NotReady would indicate errors in all namespaces meaning all ExternalSecrets resulted in errors
   conditions:

+ 6 - 0
docs/snippets/full-external-secret.yaml

@@ -18,6 +18,12 @@ spec:
     name: aws-store
     kind: SecretStore  # or ClusterSecretStore
 
+  # RefreshPolicy determines how the ExternalSecret should be refreshed.
+  # - CreatedOnce: Creates the Secret only if it does not exist and does not update it afterward
+  # - Periodic: (default) Synchronizes the Secret at intervals specified by refreshInterval
+  # - OnChange: Only synchronizes when the ExternalSecret's metadata or specification changes
+  refreshPolicy: Periodic
+
   # RefreshInterval is the amount of time before the values reading again from the SecretStore provider
   # Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h" (from time.ParseDuration)
   # May be set to zero to fetch and create it once

+ 23 - 0
pkg/controllers/externalsecret/externalsecret_controller.go

@@ -856,6 +856,29 @@ func shouldSkipUnmanagedStore(ctx context.Context, namespace string, r *Reconcil
 }
 
 func shouldRefresh(es *esv1beta1.ExternalSecret) bool {
+	switch es.Spec.RefreshPolicy {
+	case esv1beta1.RefreshPolicyCreatedOnce:
+		if es.Status.SyncedResourceVersion == "" || es.Status.RefreshTime.IsZero() {
+			return true
+		}
+		return false
+
+	case esv1beta1.RefreshPolicyOnChange:
+		if es.Status.SyncedResourceVersion == "" || es.Status.RefreshTime.IsZero() {
+			return true
+		}
+
+		return es.Status.SyncedResourceVersion != util.GetResourceVersion(es.ObjectMeta)
+
+	case esv1beta1.RefreshPolicyPeriodic:
+		return shouldRefreshPeriodic(es)
+
+	default:
+		return shouldRefreshPeriodic(es)
+	}
+}
+
+func shouldRefreshPeriodic(es *esv1beta1.ExternalSecret) bool {
 	// if the refresh interval is 0, and we have synced previously, we should not refresh
 	if es.Spec.RefreshInterval.Duration <= 0 && es.Status.SyncedResourceVersion != "" {
 		return false

+ 2 - 2
pkg/controllers/externalsecret/externalsecret_controller_secret.go

@@ -100,7 +100,7 @@ func (r *Reconciler) getProviderSecretData(ctx context.Context, externalSecret *
 	}
 
 	for i, secretRef := range externalSecret.Spec.Data {
-		err := r.handleSecretData(ctx, *externalSecret, secretRef, providerData, mgr)
+		err := r.handleSecretData(ctx, externalSecret, secretRef, providerData, mgr)
 		if errors.Is(err, esv1beta1.NoSecretErr) && externalSecret.Spec.Target.DeletionPolicy != esv1beta1.DeletionPolicyRetain {
 			r.recorder.Eventf(externalSecret, v1.EventTypeNormal, esv1beta1.ReasonMissingProviderSecret, eventMissingProviderSecretKey, i, secretRef.RemoteRef.Key)
 			continue
@@ -113,7 +113,7 @@ func (r *Reconciler) getProviderSecretData(ctx context.Context, externalSecret *
 	return providerData, nil
 }
 
-func (r *Reconciler) handleSecretData(ctx context.Context, externalSecret esv1beta1.ExternalSecret, secretRef esv1beta1.ExternalSecretData, providerData map[string][]byte, cmgr *secretstore.Manager) error {
+func (r *Reconciler) handleSecretData(ctx context.Context, externalSecret *esv1beta1.ExternalSecret, secretRef esv1beta1.ExternalSecretData, providerData map[string][]byte, cmgr *secretstore.Manager) error {
 	client, err := cmgr.Get(ctx, externalSecret.Spec.SecretStoreRef, externalSecret.Namespace, toStoreGenSourceRef(secretRef.SourceRef))
 	if err != nil {
 		return err

+ 387 - 0
pkg/controllers/externalsecret/externalsecret_controller_test.go

@@ -2570,6 +2570,393 @@ var _ = Describe("ExternalSecret refresh logic", func() {
 	})
 })
 
+var _ = Describe("ExternalSecret refresh policy", func() {
+	Context("RefreshPolicy=CreatedOnce", func() {
+		It("should refresh when SyncedResourceVersion is empty", func() {
+			es := &esv1beta1.ExternalSecret{
+				Spec: esv1beta1.ExternalSecretSpec{
+					RefreshPolicy: esv1beta1.RefreshPolicyCreatedOnce,
+				},
+				Status: esv1beta1.ExternalSecretStatus{
+					SyncedResourceVersion: "",
+				},
+			}
+			Expect(shouldRefresh(es)).To(BeTrue())
+		})
+
+		It("should refresh when RefreshTime is zero", func() {
+			es := &esv1beta1.ExternalSecret{
+				Spec: esv1beta1.ExternalSecretSpec{
+					RefreshPolicy: esv1beta1.RefreshPolicyCreatedOnce,
+				},
+				Status: esv1beta1.ExternalSecretStatus{
+					SyncedResourceVersion: "some-version",
+					RefreshTime:           metav1.Time{},
+				},
+			}
+			Expect(shouldRefresh(es)).To(BeTrue())
+		})
+
+		It("should not refresh when already synced", func() {
+			es := &esv1beta1.ExternalSecret{
+				Spec: esv1beta1.ExternalSecretSpec{
+					RefreshPolicy: esv1beta1.RefreshPolicyCreatedOnce,
+				},
+				Status: esv1beta1.ExternalSecretStatus{
+					SyncedResourceVersion: "some-version",
+					RefreshTime:           metav1.Now(),
+				},
+			}
+			Expect(shouldRefresh(es)).To(BeFalse())
+		})
+	})
+
+	Context("RefreshPolicy=OnChange", func() {
+		It("should refresh when SyncedResourceVersion is empty", func() {
+			es := &esv1beta1.ExternalSecret{
+				Spec: esv1beta1.ExternalSecretSpec{
+					RefreshPolicy: esv1beta1.RefreshPolicyOnChange,
+				},
+				Status: esv1beta1.ExternalSecretStatus{
+					SyncedResourceVersion: "",
+				},
+			}
+			Expect(shouldRefresh(es)).To(BeTrue())
+		})
+
+		It("should refresh when RefreshTime is zero", func() {
+			es := &esv1beta1.ExternalSecret{
+				Spec: esv1beta1.ExternalSecretSpec{
+					RefreshPolicy: esv1beta1.RefreshPolicyOnChange,
+				},
+				Status: esv1beta1.ExternalSecretStatus{
+					SyncedResourceVersion: "some-version",
+					RefreshTime:           metav1.Time{},
+				},
+			}
+			Expect(shouldRefresh(es)).To(BeTrue())
+		})
+
+		It("should refresh when resource version changes", func() {
+			es := &esv1beta1.ExternalSecret{
+				ObjectMeta: metav1.ObjectMeta{
+					Generation: 1,
+					Annotations: map[string]string{
+						"foo": "bar",
+					},
+				},
+				Spec: esv1beta1.ExternalSecretSpec{
+					RefreshPolicy: esv1beta1.RefreshPolicyOnChange,
+				},
+				Status: esv1beta1.ExternalSecretStatus{
+					RefreshTime:           metav1.Now(),
+					SyncedResourceVersion: "old-version",
+				},
+			}
+			// The temp annotation is added in the shouldRefresh function
+			Expect(shouldRefresh(es)).To(BeTrue())
+		})
+
+		It("should not refresh when resource version matches", func() {
+			es := &esv1beta1.ExternalSecret{
+				ObjectMeta: metav1.ObjectMeta{
+					Generation: 1,
+				},
+				Spec: esv1beta1.ExternalSecretSpec{
+					RefreshPolicy: esv1beta1.RefreshPolicyOnChange,
+				},
+				Status: esv1beta1.ExternalSecretStatus{
+					RefreshTime: metav1.Now(),
+				},
+			}
+			// Set the synced resource version to match the current resource version
+			es.Status.SyncedResourceVersion = util.GetResourceVersion(es.ObjectMeta)
+			Expect(shouldRefresh(es)).To(BeFalse())
+		})
+
+		It("should refresh when annotations change", func() {
+			es := &esv1beta1.ExternalSecret{
+				ObjectMeta: metav1.ObjectMeta{
+					Generation: 1,
+					Annotations: map[string]string{
+						"foo": "bar",
+					},
+				},
+				Spec: esv1beta1.ExternalSecretSpec{
+					RefreshPolicy: esv1beta1.RefreshPolicyOnChange,
+				},
+				Status: esv1beta1.ExternalSecretStatus{
+					RefreshTime: metav1.Now(),
+				},
+			}
+			// Set the synced resource version to match the current resource version
+			es.Status.SyncedResourceVersion = util.GetResourceVersion(es.ObjectMeta)
+			Expect(shouldRefresh(es)).To(BeFalse())
+
+			es.Annotations["foo"] = "bar1"
+			Expect(shouldRefresh(es)).To(BeTrue())
+		})
+
+		It("should refresh when spec changes", func() {
+			es := &esv1beta1.ExternalSecret{
+				ObjectMeta: metav1.ObjectMeta{
+					Generation: 1,
+				},
+				Spec: esv1beta1.ExternalSecretSpec{
+					RefreshPolicy: esv1beta1.RefreshPolicyOnChange,
+					Data: []esv1beta1.ExternalSecretData{
+						{
+							SecretKey: "key1",
+							RemoteRef: esv1beta1.ExternalSecretDataRemoteRef{
+								Key: "remote-key1",
+							},
+						},
+					},
+				},
+				Status: esv1beta1.ExternalSecretStatus{
+					RefreshTime: metav1.Now(),
+				},
+			}
+			// Set the synced resource version to match the current resource version
+			es.Status.SyncedResourceVersion = util.GetResourceVersion(es.ObjectMeta)
+
+			// Initially should not refresh
+			Expect(shouldRefresh(es)).To(BeFalse())
+
+			// Update the spec by adding a new data item
+			es.ObjectMeta.Generation = 2
+			es.Spec.Data = append(es.Spec.Data, esv1beta1.ExternalSecretData{
+				SecretKey: "key2",
+				RemoteRef: esv1beta1.ExternalSecretDataRemoteRef{
+					Key: "remote-key2",
+				},
+			})
+
+			// The temp annotation is added in the shouldRefresh function to track spec hash
+			Expect(shouldRefresh(es)).To(BeTrue())
+		})
+	})
+
+	Context("Default refresh policy (Periodic)", func() {
+		It("should behave like Periodic when RefreshPolicy is not set", func() {
+			es := &esv1beta1.ExternalSecret{
+				ObjectMeta: metav1.ObjectMeta{
+					Generation: 1,
+				},
+				Spec: esv1beta1.ExternalSecretSpec{
+					// No RefreshPolicy set, should default to Periodic behavior
+					RefreshInterval: &metav1.Duration{Duration: time.Minute},
+				},
+				Status: esv1beta1.ExternalSecretStatus{
+					RefreshTime: metav1.Now(),
+				},
+			}
+			es.Status.SyncedResourceVersion = util.GetResourceVersion(es.ObjectMeta)
+			Expect(shouldRefresh(es)).To(BeFalse())
+
+			// When refresh interval has passed
+			es.Status.RefreshTime = metav1.NewTime(metav1.Now().Add(-time.Minute * 2))
+			Expect(shouldRefresh(es)).To(BeTrue())
+		})
+	})
+
+	Context("RefreshPolicy=Periodic", func() {
+		It("should not refresh when refreshInterval is 0 and already synced", func() {
+			es := &esv1beta1.ExternalSecret{
+				ObjectMeta: metav1.ObjectMeta{
+					Generation: 1,
+				},
+				Spec: esv1beta1.ExternalSecretSpec{
+					RefreshPolicy:   esv1beta1.RefreshPolicyPeriodic,
+					RefreshInterval: &metav1.Duration{Duration: 0},
+				},
+				Status: esv1beta1.ExternalSecretStatus{
+					SyncedResourceVersion: "some-version",
+				},
+			}
+			// Set the synced resource version to match the current resource version
+			es.Status.SyncedResourceVersion = util.GetResourceVersion(es.ObjectMeta)
+			Expect(shouldRefresh(es)).To(BeFalse())
+		})
+
+		It("should refresh when resource version changes", func() {
+			es := &esv1beta1.ExternalSecret{
+				ObjectMeta: metav1.ObjectMeta{
+					Generation: 2,
+				},
+				Spec: esv1beta1.ExternalSecretSpec{
+					RefreshPolicy:   esv1beta1.RefreshPolicyPeriodic,
+					RefreshInterval: &metav1.Duration{Duration: time.Minute},
+				},
+				Status: esv1beta1.ExternalSecretStatus{
+					RefreshTime:           metav1.Now(),
+					SyncedResourceVersion: "old-version",
+				},
+			}
+			Expect(shouldRefresh(es)).To(BeTrue())
+		})
+
+		It("should refresh when refresh interval has passed", func() {
+			es := &esv1beta1.ExternalSecret{
+				ObjectMeta: metav1.ObjectMeta{
+					Generation: 1,
+				},
+				Spec: esv1beta1.ExternalSecretSpec{
+					RefreshPolicy:   esv1beta1.RefreshPolicyPeriodic,
+					RefreshInterval: &metav1.Duration{Duration: time.Second},
+				},
+				Status: esv1beta1.ExternalSecretStatus{
+					RefreshTime: metav1.NewTime(metav1.Now().Add(-time.Second * 5)),
+				},
+			}
+			// Resource version matches
+			es.Status.SyncedResourceVersion = util.GetResourceVersion(es.ObjectMeta)
+			Expect(shouldRefresh(es)).To(BeTrue())
+		})
+
+		It("should refresh when no refresh time was set", func() {
+			es := &esv1beta1.ExternalSecret{
+				ObjectMeta: metav1.ObjectMeta{
+					Generation: 1,
+				},
+				Spec: esv1beta1.ExternalSecretSpec{
+					RefreshPolicy:   esv1beta1.RefreshPolicyPeriodic,
+					RefreshInterval: &metav1.Duration{Duration: time.Second},
+				},
+				Status: esv1beta1.ExternalSecretStatus{},
+			}
+			// Resource version matches
+			es.Status.SyncedResourceVersion = util.GetResourceVersion(es.ObjectMeta)
+			Expect(shouldRefresh(es)).To(BeTrue())
+		})
+
+		It("should refresh when refresh time is in the future", func() {
+			es := &esv1beta1.ExternalSecret{
+				ObjectMeta: metav1.ObjectMeta{
+					Generation: 1,
+				},
+				Spec: esv1beta1.ExternalSecretSpec{
+					RefreshPolicy:   esv1beta1.RefreshPolicyPeriodic,
+					RefreshInterval: &metav1.Duration{Duration: time.Second},
+				},
+				Status: esv1beta1.ExternalSecretStatus{
+					RefreshTime: metav1.NewTime(time.Now().Add(time.Hour)), // Future time
+				},
+			}
+			// Resource version matches
+			es.Status.SyncedResourceVersion = util.GetResourceVersion(es.ObjectMeta)
+			Expect(shouldRefresh(es)).To(BeTrue())
+		})
+
+		It("should refresh when refreshInterval not 0 and spec changes", func() {
+			es := &esv1beta1.ExternalSecret{
+				ObjectMeta: metav1.ObjectMeta{
+					Generation: 1,
+				},
+				Spec: esv1beta1.ExternalSecretSpec{
+					RefreshPolicy:   esv1beta1.RefreshPolicyPeriodic,
+					RefreshInterval: &metav1.Duration{Duration: 10 * time.Second},
+					Data: []esv1beta1.ExternalSecretData{
+						{
+							SecretKey: "key1",
+							RemoteRef: esv1beta1.ExternalSecretDataRemoteRef{
+								Key: "remote-key1",
+							},
+						},
+					},
+				},
+				Status: esv1beta1.ExternalSecretStatus{
+					SyncedResourceVersion: "some-version",
+					RefreshTime:           metav1.NewTime(metav1.Now().Add(-time.Second * 5)),
+				},
+			}
+			es.Status.SyncedResourceVersion = util.GetResourceVersion(es.ObjectMeta)
+			Expect(shouldRefresh(es)).To(BeFalse())
+
+			es.ObjectMeta.Generation = 2
+			es.Spec.Data = append(es.Spec.Data, esv1beta1.ExternalSecretData{
+				SecretKey: "key2",
+				RemoteRef: esv1beta1.ExternalSecretDataRemoteRef{
+					Key: "remote-key2",
+				},
+			})
+
+			Expect(shouldRefresh(es)).To(BeTrue())
+		})
+
+		It("should not refresh when refreshInterval is 0 even if spec changes", func() {
+			es := &esv1beta1.ExternalSecret{
+				ObjectMeta: metav1.ObjectMeta{
+					Generation: 1,
+				},
+				Spec: esv1beta1.ExternalSecretSpec{
+					RefreshPolicy:   esv1beta1.RefreshPolicyPeriodic,
+					RefreshInterval: &metav1.Duration{Duration: 0},
+					Data: []esv1beta1.ExternalSecretData{
+						{
+							SecretKey: "key1",
+							RemoteRef: esv1beta1.ExternalSecretDataRemoteRef{
+								Key: "remote-key1",
+							},
+						},
+					},
+				},
+				Status: esv1beta1.ExternalSecretStatus{
+					SyncedResourceVersion: "some-version",
+					RefreshTime:           metav1.Now(),
+				},
+			}
+			// Set the synced resource version to match the current resource version
+			es.Status.SyncedResourceVersion = util.GetResourceVersion(es.ObjectMeta)
+			Expect(shouldRefresh(es)).To(BeFalse())
+
+			// Update the spec by adding a new data item
+			es.ObjectMeta.Generation = 2
+			es.Spec.Data = append(es.Spec.Data, esv1beta1.ExternalSecretData{
+				SecretKey: "key2",
+				RemoteRef: esv1beta1.ExternalSecretDataRemoteRef{
+					Key: "remote-key2",
+				},
+			})
+
+			// Should still not refresh because interval is 0
+			Expect(shouldRefresh(es)).To(BeFalse())
+		})
+
+		It("should not refresh when refreshInterval is 0 even if labels or annotations change", func() {
+			es := &esv1beta1.ExternalSecret{
+				ObjectMeta: metav1.ObjectMeta{
+					Generation: 1,
+					Labels: map[string]string{
+						"original-label": "value",
+					},
+					Annotations: map[string]string{
+						"original-annotation": "value",
+					},
+				},
+				Spec: esv1beta1.ExternalSecretSpec{
+					RefreshPolicy:   esv1beta1.RefreshPolicyPeriodic,
+					RefreshInterval: &metav1.Duration{Duration: 0},
+				},
+				Status: esv1beta1.ExternalSecretStatus{
+					SyncedResourceVersion: "some-version",
+					RefreshTime:           metav1.Now(),
+				},
+			}
+			// Set the synced resource version to match the current resource version
+			es.Status.SyncedResourceVersion = util.GetResourceVersion(es.ObjectMeta)
+			Expect(shouldRefresh(es)).To(BeFalse())
+
+			// Update labels and annotations
+			es.ObjectMeta.Labels["new-label"] = "new-value"
+			es.ObjectMeta.Annotations["new-annotation"] = "new-value"
+
+			// Should still not refresh because interval is 0
+			Expect(shouldRefresh(es)).To(BeFalse())
+		})
+	})
+})
+
 func externalSecretConditionShouldBe(name, ns string, ct esv1beta1.ExternalSecretConditionType, cs v1.ConditionStatus, v float64) bool {
 	return Eventually(func() float64 {
 		Expect(testExternalSecretCondition.WithLabelValues(name, ns, string(ct), string(cs)).Write(&metric)).To(Succeed())

+ 1 - 0
tests/__snapshot__/clusterexternalsecret-v1beta1.yaml

@@ -54,6 +54,7 @@ spec:
           kind: "SecretStore" # "SecretStore", "ClusterSecretStore"
           name: string
     refreshInterval: "1h"
+    refreshPolicy: "CreatedOnce" # "CreatedOnce", "Periodic", "OnChange"
     secretStoreRef:
       kind: "SecretStore" # "SecretStore", "ClusterSecretStore"
       name: string

+ 1 - 0
tests/__snapshot__/externalsecret-v1beta1.yaml

@@ -49,6 +49,7 @@ spec:
         kind: "SecretStore" # "SecretStore", "ClusterSecretStore"
         name: string
   refreshInterval: "1h"
+  refreshPolicy: "CreatedOnce" # "CreatedOnce", "Periodic", "OnChange"
   secretStoreRef:
     kind: "SecretStore" # "SecretStore", "ClusterSecretStore"
     name: string