Browse Source

feat: add CreateOrMerge creation policy for ExternalSecrets (#6641)

Co-authored-by: Gergely Bräutigam <gergely.brautigam@sap.com>
Signed-off-by: Alexander Chernov <alexander@chernov.it>
Alexander Chernov 5 days ago
parent
commit
40b04db454

+ 7 - 1
apis/externalsecrets/v1/externalsecret_types.go

@@ -37,7 +37,7 @@ type SecretStoreRef struct {
 }
 }
 
 
 // ExternalSecretCreationPolicy defines rules on how to create the resulting Secret.
 // ExternalSecretCreationPolicy defines rules on how to create the resulting Secret.
-// +kubebuilder:validation:Enum=Owner;Orphan;Merge;None
+// +kubebuilder:validation:Enum=Owner;Orphan;Merge;None;CreateOrMerge
 type ExternalSecretCreationPolicy string
 type ExternalSecretCreationPolicy string
 
 
 const (
 const (
@@ -53,6 +53,12 @@ const (
 
 
 	// CreatePolicyNone does not create a Secret (future use with injector).
 	// CreatePolicyNone does not create a Secret (future use with injector).
 	CreatePolicyNone ExternalSecretCreationPolicy = "None"
 	CreatePolicyNone ExternalSecretCreationPolicy = "None"
+
+	// CreatePolicyCreateOrMerge creates the Secret if it is missing and merges
+	// data fields into it if it exists, without an ownerReference. A deleted
+	// target is recreated while the ExternalSecret exists, and the Secret is
+	// retained when the ExternalSecret is deleted.
+	CreatePolicyCreateOrMerge ExternalSecretCreationPolicy = "CreateOrMerge"
 )
 )
 
 
 // ExternalSecretDeletionPolicy defines rules on how to delete the resulting Secret.
 // ExternalSecretDeletionPolicy defines rules on how to delete the resulting Secret.

+ 4 - 2
apis/externalsecrets/v1/externalsecret_validator.go

@@ -110,8 +110,10 @@ func validateExtractFindGenerator(ref ExternalSecretDataFromRemoteRef) error {
 
 
 func validatePolicies(es *ExternalSecret) error {
 func validatePolicies(es *ExternalSecret) error {
 	var errs error
 	var errs error
-	if (es.Spec.Target.DeletionPolicy == DeletionPolicyDelete && es.Spec.Target.CreationPolicy == CreatePolicyMerge) ||
-		(es.Spec.Target.DeletionPolicy == DeletionPolicyDelete && es.Spec.Target.CreationPolicy == CreatePolicyNone) {
+	if es.Spec.Target.DeletionPolicy == DeletionPolicyDelete &&
+		(es.Spec.Target.CreationPolicy == CreatePolicyMerge ||
+			es.Spec.Target.CreationPolicy == CreatePolicyNone ||
+			es.Spec.Target.CreationPolicy == CreatePolicyCreateOrMerge) {
 		errs = errors.Join(errs, errors.New("deletionPolicy=Delete must not be used when the controller doesn't own the secret. Please set creationPolicy=Owner"))
 		errs = errors.Join(errs, errors.New("deletionPolicy=Delete must not be used when the controller doesn't own the secret. Please set creationPolicy=Owner"))
 	}
 	}
 
 

+ 15 - 0
apis/externalsecrets/v1/externalsecret_validator_test.go

@@ -52,6 +52,21 @@ func TestValidateExternalSecret(t *testing.T) {
 			},
 			},
 			expectedErr: "deletionPolicy=Delete must not be used when the controller doesn't own the secret. Please set creationPolicy=Owner",
 			expectedErr: "deletionPolicy=Delete must not be used when the controller doesn't own the secret. Please set creationPolicy=Owner",
 		},
 		},
+		{
+			name: "deletion policy delete with create or merge",
+			obj: &ExternalSecret{
+				Spec: ExternalSecretSpec{
+					Target: ExternalSecretTarget{
+						DeletionPolicy: DeletionPolicyDelete,
+						CreationPolicy: CreatePolicyCreateOrMerge,
+					},
+					Data: []ExternalSecretData{
+						{},
+					},
+				},
+			},
+			expectedErr: "deletionPolicy=Delete must not be used when the controller doesn't own the secret. Please set creationPolicy=Owner",
+		},
 		{
 		{
 			name: "deletion policy merge",
 			name: "deletion policy merge",
 			obj: &ExternalSecret{
 			obj: &ExternalSecret{

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

@@ -589,6 +589,7 @@ spec:
                         - Orphan
                         - Orphan
                         - Merge
                         - Merge
                         - None
                         - None
+                        - CreateOrMerge
                         type: string
                         type: string
                       deletionPolicy:
                       deletionPolicy:
                         default: Retain
                         default: Retain

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

@@ -572,6 +572,7 @@ spec:
                     - Orphan
                     - Orphan
                     - Merge
                     - Merge
                     - None
                     - None
+                    - CreateOrMerge
                     type: string
                     type: string
                   deletionPolicy:
                   deletionPolicy:
                     default: Retain
                     default: Retain

+ 2 - 0
deploy/crds/bundle.yaml

@@ -558,6 +558,7 @@ spec:
                             - Orphan
                             - Orphan
                             - Merge
                             - Merge
                             - None
                             - None
+                            - CreateOrMerge
                           type: string
                           type: string
                         deletionPolicy:
                         deletionPolicy:
                           default: Retain
                           default: Retain
@@ -14010,6 +14011,7 @@ spec:
                         - Orphan
                         - Orphan
                         - Merge
                         - Merge
                         - None
                         - None
+                        - CreateOrMerge
                       type: string
                       type: string
                     deletionPolicy:
                     deletionPolicy:
                       default: Retain
                       default: Retain

+ 68 - 5
docs/api/externalsecret.md

@@ -17,13 +17,35 @@ You can control how and when the `ExternalSecret` is refreshed by setting the `s
 
 
 ### CreatedOnce
 ### CreatedOnce
 
 
-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
+With `refreshPolicy: CreatedOnce`, the controller syncs the `Kind=Secret` once per
+`ExternalSecret` object and then stops:
+
+- Runs a single sync on the first reconcile of the `ExternalSecret`, creating or
+  overwriting the target `Kind=Secret`
+- Does not refresh on a schedule or when the source data changes
+- Still re-syncs if the target `Kind=Secret` is changed or deleted while the same
+  `ExternalSecret` object still exists
 - Useful for immutable credentials or when you want to manage updates manually
 - Useful for immutable credentials or when you want to manage updates manually
 
 
+The "once" is tracked on the `ExternalSecret`'s own status (`syncedResourceVersion`
+and `refreshTime`), not on whether the target Secret already exists.
+
+!!! warning "Recreating the ExternalSecret re-syncs the target Secret"
+    `CreatedOnce` does not check whether the target Secret already exists. If the
+    `ExternalSecret` object itself is deleted and recreated (for example by a GitOps
+    controller that prunes and re-applies it), its status resets and the controller
+    performs a fresh sync that overwrites the existing target Secret. With a
+    stateless generator such as the Password generator this produces a brand new
+    value, so the target Secret and any credential already derived from the old
+    value diverge with no way to recover the original.
+
+    `creationPolicy` does not guard against this: `Owner`, `Orphan`, `Merge` and
+    `CreateOrMerge` all rewrite the managed keys on that sync. The only field that prevents an existing
+    target Secret's data from being overwritten is `spec.target.immutable: true`,
+    which skips the data update whenever the Secret already exists. Combine
+    `refreshPolicy: CreatedOnce` with `spec.target.immutable: true` for credentials
+    that must be generated once and never change.
+
 Example:
 Example:
 ```yaml
 ```yaml
 apiVersion: external-secrets.io/v1
 apiVersion: external-secrets.io/v1
@@ -35,6 +57,47 @@ spec:
   # other fields...
   # other fields...
 ```
 ```
 
 
+To generate a credential exactly once and never overwrite it afterward, even if
+the `ExternalSecret` object is recreated, combine `refreshPolicy: CreatedOnce` with
+`spec.target.immutable: true`. This is the correct pattern for a credential that an
+application persists on first run (for example a Keycloak admin password written
+into the application database at bootstrap): regenerating the value later would
+leave the target Secret out of sync with the live credential. `creationPolicy:
+Orphan` keeps the target Secret in place if the `ExternalSecret` is deleted, and
+`immutable: true` stops any later reconcile (including one triggered by recreating
+the `ExternalSecret`) from rewriting the existing data.
+
+```yaml
+apiVersion: generators.external-secrets.io/v1alpha1
+kind: Password
+metadata:
+  name: keycloak-admin-password
+spec:
+  length: 32
+  digits: 5
+  symbols: 5
+  allowRepeat: true
+---
+apiVersion: external-secrets.io/v1
+kind: ExternalSecret
+metadata:
+  name: keycloak-admin
+spec:
+  refreshPolicy: CreatedOnce
+  target:
+    name: keycloak-admin
+    # keep the Secret if this ExternalSecret is ever deleted
+    creationPolicy: Orphan
+    # never rewrite the Secret data once it exists, even on recreation
+    immutable: true
+  dataFrom:
+    - sourceRef:
+        generatorRef:
+          apiVersion: generators.external-secrets.io/v1alpha1
+          kind: Password
+          name: keycloak-admin-password
+```
+
 ### Periodic
 ### Periodic
 
 
 With `refreshPolicy: Periodic` (the default behavior), the controller will:
 With `refreshPolicy: Periodic` (the default behavior), the controller will:

+ 7 - 1
docs/api/spec.md

@@ -4505,7 +4505,13 @@ ExternalSecretStatus
 <th>Description</th>
 <th>Description</th>
 </tr>
 </tr>
 </thead>
 </thead>
-<tbody><tr><td><p>&#34;Merge&#34;</p></td>
+<tbody><tr><td><p>&#34;CreateOrMerge&#34;</p></td>
+<td><p>CreatePolicyCreateOrMerge creates the Secret if it is missing and merges
+data fields into it if it exists, without an ownerReference. A deleted
+target is recreated while the ExternalSecret exists, and the Secret is
+retained when the ExternalSecret is deleted.</p>
+</td>
+</tr><tr><td><p>&#34;Merge&#34;</p></td>
 <td><p>CreatePolicyMerge does not create the Secret, but merges the data fields to the Secret.</p>
 <td><p>CreatePolicyMerge does not create the Secret, but merges the data fields to the Secret.</p>
 </td>
 </td>
 </tr><tr><td><p>&#34;None&#34;</p></td>
 </tr><tr><td><p>&#34;None&#34;</p></td>

+ 99 - 11
docs/guides/ownership-deletion-policy.md

@@ -1,26 +1,40 @@
 # Lifecycle
 # Lifecycle
-The External Secrets Operator manages the lifecycle of secrets in Kubernetes. With `refreshPolicy`,   `creationPolicy` and `deletionPolicy` you get fine-grained control of its lifecycle.
+The External Secrets Operator manages the lifecycle of secrets in Kubernetes. With `refreshPolicy`, `creationPolicy` and `deletionPolicy` you get fine-grained control of its lifecycle.
 
 
 !!! note "Creation/Deletion Policy Combinations"
 !!! note "Creation/Deletion Policy Combinations"
     Some combinations of creationPolicy/deletionPolicy are not allowed as they would delete existing secrets:
     Some combinations of creationPolicy/deletionPolicy are not allowed as they would delete existing secrets:
     <br/>- `deletionPolicy=Delete` & `creationPolicy=Merge`
     <br/>- `deletionPolicy=Delete` & `creationPolicy=Merge`
+    <br/>- `deletionPolicy=Delete` & `creationPolicy=CreateOrMerge`
     <br/>- `deletionPolicy=Delete` & `creationPolicy=None`
     <br/>- `deletionPolicy=Delete` & `creationPolicy=None`
     <br/>- `deletionPolicy=Merge` & `creationPolicy=None`
     <br/>- `deletionPolicy=Merge` & `creationPolicy=None`
 
 
 ## Refresh Policy
 ## Refresh Policy
-The field `spec.refreshPolicy` defines how the operator refreshes the a secret.
+The field `spec.refreshPolicy` defines how the operator refreshes the secret.
 
 
-### Periodic (default) 
+### Periodic (default)
 Refreshes the secret at a fixed interval via `spec.refreshInterval`. Due to backwards compatibility, setting a refresh interval of 0 will result in the same behavior as `CreatedOnce`.
 Refreshes the secret at a fixed interval via `spec.refreshInterval`. Due to backwards compatibility, setting a refresh interval of 0 will result in the same behavior as `CreatedOnce`.
 
 
 ### OnChange
 ### OnChange
-Refreshes the secret only when the ExternalSecret is updated.  
+Refreshes the secret only when the ExternalSecret is updated.
 
 
 ### CreatedOnce
 ### CreatedOnce
-Refreshes the secret only once, when the ExternalSecret is created.
+Refreshes the secret only once per `ExternalSecret` object, on its first reconcile.
+The sync state lives on the `ExternalSecret`'s status, so deleting and recreating the
+`ExternalSecret` resets it and triggers another one-time sync. Depending on the
+creation policy that sync may overwrite the operator-managed keys of an existing
+target Secret; `creationPolicy=None` does not update it, and `spec.target.immutable:
+true` prevents data rewrites.
+
+!!! note "State is not persisted across ExternalSecret objects"
+    ESO is stateless across `ExternalSecret` objects: it does not persist sync claims
+    or reclaim orphaned Secrets when an `ExternalSecret` is recreated. `creationPolicy`
+    (including `Orphan`) does not protect a pre-existing target Secret from being
+    rewritten on that sync. To keep an already-populated Secret from being overwritten
+    on recreation, set `spec.target.immutable: true` (optionally together with
+    `refreshPolicy: CreatedOnce`).
 
 
 ## Creation Policy
 ## Creation Policy
-The field `spec.target.creationPolicy` defines how the operator creates the a secret.
+The field `spec.target.creationPolicy` defines how the operator creates the secret.
 
 
 ### Owner (default)
 ### Owner (default)
 The External Secret Operator creates secret and sets the `ownerReference` field on the Secret. This secret is subject to [garbage collection](https://kubernetes.io/docs/concepts/architecture/garbage-collection/) if the initial `ExternalSecret` is absent. If a secret with the same name already exists that is not owned by the controller it will result in a conflict. The operator will just error out, not claiming the ownership.
 The External Secret Operator creates secret and sets the `ownerReference` field on the Secret. This secret is subject to [garbage collection](https://kubernetes.io/docs/concepts/architecture/garbage-collection/) if the initial `ExternalSecret` is absent. If a secret with the same name already exists that is not owned by the controller it will result in a conflict. The operator will just error out, not claiming the ownership.
@@ -29,10 +43,13 @@ The External Secret Operator creates secret and sets the `ownerReference` field
     If the secret exists and the ownerReference field is not found, the controller treats this secret as orphaned. It will take ownership of this secret by adding an `ownerReference` field and updating it.
     If the secret exists and the ownerReference field is not found, the controller treats this secret as orphaned. It will take ownership of this secret by adding an `ownerReference` field and updating it.
 
 
 ### Orphan
 ### Orphan
-Whenever triggered via `RefreshPolicy` conditions, the operator creates/updates 
-the target Secret according to the provider available information. 
-However, the operator will not watch on Secret Changes (delete/updates), nor trigger 
-[garbage collection](https://kubernetes.io/docs/concepts/architecture/garbage-collection/) when the `ExternalSecret` object is deleted.
+Whenever triggered via `RefreshPolicy` conditions, the operator creates/updates
+the target Secret according to the provider available information.
+It does not set an `ownerReference`, so it does not trigger
+[garbage collection](https://kubernetes.io/docs/concepts/architecture/garbage-collection/) of the Secret when the `ExternalSecret` object is deleted.
+The operator still watches the Secret, but for `Orphan` it re-syncs only when a refresh
+is due (see the warning below and the [behavior matrix](#behavior-matrix)), not on
+every change to the Secret.
 
 
 !!! warning "Unwanted reverts of manual changes"
 !!! warning "Unwanted reverts of manual changes"
     If you set `spec.refreshPolicy` to `Periodic` or `OnChange` and `spec.target.creationPolicy` to `Orphan`,
     If you set `spec.refreshPolicy` to `Periodic` or `OnChange` and `spec.target.creationPolicy` to `Orphan`,
@@ -43,9 +60,26 @@ However, the operator will not watch on Secret Changes (delete/updates), nor tri
 ### Merge
 ### Merge
 The operator does not create a secret. Instead, it expects the secret to already exist. Values from the secret provider will be merged into the existing secret. Note: the controller takes ownership of a field even if it is owned by a different entity. Multiple ExternalSecrets can use `creationPolicy=Merge` with a single secret as long as the fields don't collide - otherwise you end up in an oscillating state.
 The operator does not create a secret. Instead, it expects the secret to already exist. Values from the secret provider will be merged into the existing secret. Note: the controller takes ownership of a field even if it is owned by a different entity. Multiple ExternalSecrets can use `creationPolicy=Merge` with a single secret as long as the fields don't collide - otherwise you end up in an oscillating state.
 
 
+### CreateOrMerge
+Creates the Secret if it does not exist and, if it does, merges the provider values
+into it while preserving keys owned by others (like `Merge`), without setting an
+`ownerReference` (like `Orphan`). Unlike `Merge` it also creates a missing Secret,
+so a deleted target is recreated immediately (driven by the Secret watch) while the
+`ExternalSecret` exists; unlike `Owner` the Secret is retained when the
+`ExternalSecret` is deleted. Combined with `spec.target.immutable: true` it gives
+"create once, freeze, recreate if deleted, keep on ExternalSecret deletion". Note:
+`deletionPolicy=Delete` is not allowed, the controller does not own the Secret.
+
 ### None
 ### None
 The operator does not create or update the secret, this is basically a no-op.
 The operator does not create or update the secret, this is basically a no-op.
 
 
+## Immutable target
+Setting `spec.target.immutable: true` marks the generated `Kind=Secret` as immutable,
+so its data is never rewritten once the Secret exists (Kubernetes itself also blocks
+edits to an immutable Secret's data). It does not affect whether a missing Secret is
+created or recreated, that is governed by `creationPolicy` and `refreshPolicy`. See the
+[behavior matrix](#behavior-matrix) for the full interaction.
+
 ## Deletion Policy
 ## Deletion Policy
 DeletionPolicy defines what should happen if a given secret gets deleted **from the provider**.
 DeletionPolicy defines what should happen if a given secret gets deleted **from the provider**.
 
 
@@ -62,6 +96,8 @@ If a secret gets deleted on the provider side and is not accessible
 anymore this is not considered an error and the ExternalSecret
 anymore this is not considered an error and the ExternalSecret
 does not go into SecretSyncedError status. This is also true for new
 does not go into SecretSyncedError status. This is also true for new
 ExternalSecrets mapping to non-existing secrets in the provider.
 ExternalSecrets mapping to non-existing secrets in the provider.
+`deletionPolicy=Delete` only takes effect with `creationPolicy=Owner`; the operator
+refuses to delete a Secret it does not own and reports a `SecretSyncedError` otherwise.
 
 
 ### Merge
 ### Merge
 Merge removes keys in the secret, but not the secret itself.
 Merge removes keys in the secret, but not the secret itself.
@@ -69,4 +105,56 @@ If a secret gets deleted on the provider side and is not accessible
 anymore this is not considered an error and the ExternalSecret
 anymore this is not considered an error and the ExternalSecret
 does not go into SecretSyncedError status.
 does not go into SecretSyncedError status.
 
 
-
+## Behavior matrix
+
+How `creationPolicy` and `refreshPolicy` combine to drive the Secret operations.
+`deletionPolicy` is a separate axis (covered below the table) that only fires when
+the provider returns no data.
+
+| creationPolicy    | refreshPolicy | Create if missing | Reflect source change | Overwrite on ES re-create | Recreate if Secret deleted | Retain on ES delete   |
+|-------------------|---------------|-------------------|-----------------------|---------------------------|----------------------------|-----------------------|
+| **Owner**         | Periodic      | Yes               | Yes (at interval)     | Yes                       | Yes (immediate)            | No (GC via ownerRef)  |
+| **Owner**         | OnChange      | Yes               | Only on ES change     | Yes                       | Yes (immediate)            | No (GC via ownerRef)  |
+| **Owner**         | CreatedOnce   | Yes               | No                    | Yes                       | Yes (immediate)            | No (GC via ownerRef)  |
+| **Orphan**        | Periodic      | Yes               | Yes (at interval)     | Yes                       | Yes (at interval)          | Yes                   |
+| **Orphan**        | OnChange      | Yes               | Only on ES change     | Yes                       | No (until ES change)       | Yes                   |
+| **Orphan**        | CreatedOnce   | Yes               | No                    | Yes                       | No                         | Yes                   |
+| **Merge**         | Periodic      | No (waits)        | Yes (at interval)     | Yes (if target exists)    | No (never creates)         | Yes                   |
+| **Merge**         | OnChange      | No (waits)        | Only on ES change     | Yes (if target exists)    | No (never creates)         | Yes                   |
+| **Merge**         | CreatedOnce   | No (waits)        | No                    | Yes (if target exists)    | No (never creates)         | Yes                   |
+| **CreateOrMerge** | Periodic      | Yes               | Yes (at interval)     | Yes                       | Yes (immediate)            | Yes                   |
+| **CreateOrMerge** | OnChange      | Yes               | Only on ES change     | Yes                       | Yes (immediate)            | Yes                   |
+| **CreateOrMerge** | CreatedOnce   | Yes               | No                    | Yes                       | Yes (immediate)            | Yes                   |
+| **None**          | any           | No (no-op)        | No                    | No                        | No                         | Yes (nothing created) |
+
+Column meanings:
+
+- **Reflect source change**: a value changing in the provider is pulled into the
+  Secret. ESO is pull-based and does not watch providers, so only `Periodic` polls
+  (at each interval). `OnChange` re-syncs only when the `ExternalSecret` spec or
+  metadata changes, or when you set the `force-sync` annotation. `CreatedOnce` never
+  re-syncs.
+- **Overwrite on ES re-create**: deleting and recreating the `ExternalSecret` object
+  resets its status, so the next sync overwrites the ES-managed keys of an existing
+  target (keys owned by others are preserved). This is independent of `refreshPolicy`;
+  only `spec.target.immutable: true` prevents it.
+- **Recreate if Secret deleted**: you delete the target Secret while the
+  `ExternalSecret` still exists. `Owner` treats a missing Secret as invalid and
+  recreates it immediately via the Secret watch. `Orphan` treats a missing Secret as
+  valid, so it only recreates when a refresh is triggered (a `Periodic` interval);
+  under `CreatedOnce` or `OnChange` it stays deleted. `Merge` never creates.
+- **Retain on ES delete**: whether the target Secret survives deletion of the
+  `ExternalSecret`. Only `Owner` sets an `ownerReference`, so only `Owner` is
+  garbage-collected.
+
+Modifiers that cut across every row:
+
+- **`spec.target.immutable: true`**: once the target Secret exists, its data is never
+  rewritten, so "Reflect source change" and "Overwrite on ES re-create" become No. It
+  does not affect "Create if missing" or "Recreate if Secret deleted", a Secret that
+  does not exist has no data to protect, so it is created or recreated fresh.
+- **`refreshInterval: 0` with `Periodic`** behaves like `CreatedOnce`.
+- **`deletionPolicy`** (Retain, Delete, Merge) only acts when the provider returns no
+  data, which is only detected on a re-sync. So under `CreatedOnce` a provider-side
+  deletion is never detected, under `OnChange` only on an `ExternalSecret` change, and
+  under `Periodic` at the next interval.

+ 1 - 1
docs/introduction/faq.md

@@ -27,7 +27,7 @@ kubectl get es my-external-secret -o yaml | grep refreshTime
 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:
 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
 - `Periodic` (default): Update regularly based on refreshInterval
-- `CreatedOnce`: Create the Secret only once and never update it afterward
+- `CreatedOnce`: Create the Secret only once per ExternalSecret object and never update it afterward (recreating the ExternalSecret re-syncs it, see the [refresh policy docs](../api/externalsecret.md#createdonce))
 - `OnChange`: Only update when the ExternalSecret's metadata or specification changes
 - `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?
 ## How do I know when the status of my secret changed the last time?

+ 13 - 7
pkg/controllers/externalsecret/externalsecret_controller.go

@@ -523,8 +523,11 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ct
 			r.markAsDone(externalSecret, start, log, esv1.ConditionReasonSecretMissing, msgMissing)
 			r.markAsDone(externalSecret, start, log, esv1.ConditionReasonSecretMissing, msgMissing)
 			return r.getRequeueResult(externalSecret), nil
 			return r.getRequeueResult(externalSecret), nil
 		}
 		}
-	case esv1.CreatePolicyOrphan:
-		// create the secret, if it does not exist
+	case esv1.CreatePolicyOrphan, esv1.CreatePolicyCreateOrMerge:
+		// create the secret if it does not exist, otherwise update it.
+		// CreateOrMerge behaves like Orphan here (create-or-update, no
+		// ownerReference); it differs only in that ApplyTemplate keeps existing
+		// keys for it (see externalsecret_controller_template.go).
 		if existingSecret.UID == "" {
 		if existingSecret.UID == "" {
 			err = r.createSecret(ctx, mutationFunc, externalSecret, secretName)
 			err = r.createSecret(ctx, mutationFunc, externalSecret, secretName)
 		} else {
 		} else {
@@ -596,7 +599,8 @@ func (r *Reconciler) reconcileGenericTarget(
 	var existing *unstructured.Unstructured
 	var existing *unstructured.Unstructured
 	if externalSecret.Spec.Target.CreationPolicy == esv1.CreatePolicyMerge ||
 	if externalSecret.Spec.Target.CreationPolicy == esv1.CreatePolicyMerge ||
 		externalSecret.Spec.Target.CreationPolicy == esv1.CreatePolicyOrphan ||
 		externalSecret.Spec.Target.CreationPolicy == esv1.CreatePolicyOrphan ||
-		externalSecret.Spec.Target.CreationPolicy == esv1.CreatePolicyOwner {
+		externalSecret.Spec.Target.CreationPolicy == esv1.CreatePolicyOwner ||
+		externalSecret.Spec.Target.CreationPolicy == esv1.CreatePolicyCreateOrMerge {
 		var getErr error
 		var getErr error
 		existing, getErr = r.getGenericResource(ctx, log, externalSecret)
 		existing, getErr = r.getGenericResource(ctx, log, externalSecret)
 		if getErr != nil && !apierrors.IsNotFound(getErr) {
 		if getErr != nil && !apierrors.IsNotFound(getErr) {
@@ -649,10 +653,12 @@ func (r *Reconciler) reconcileGenericTarget(
 		}
 		}
 	}
 	}
 
 
-	// For Merge policy with existing resource, pass it to applyTemplateToManifest
-	// so templates are applied to the existing resource instead of creating a new one
+	// For Merge and CreateOrMerge with an existing resource, pass it to
+	// applyTemplateToManifest so templates are applied to the existing resource
+	// instead of creating a new one.
 	var baseObj *unstructured.Unstructured
 	var baseObj *unstructured.Unstructured
-	if externalSecret.Spec.Target.CreationPolicy == esv1.CreatePolicyMerge && existing != nil {
+	if (externalSecret.Spec.Target.CreationPolicy == esv1.CreatePolicyMerge ||
+		externalSecret.Spec.Target.CreationPolicy == esv1.CreatePolicyCreateOrMerge) && existing != nil {
 		baseObj = existing
 		baseObj = existing
 	}
 	}
 
 
@@ -681,7 +687,7 @@ func (r *Reconciler) reconcileGenericTarget(
 
 
 		// update the existing resource
 		// update the existing resource
 		err = r.updateGenericResource(ctx, log, externalSecret, obj)
 		err = r.updateGenericResource(ctx, log, externalSecret, obj)
-	case esv1.CreatePolicyOrphan, esv1.CreatePolicyOwner:
+	case esv1.CreatePolicyOrphan, esv1.CreatePolicyOwner, esv1.CreatePolicyCreateOrMerge:
 		if existing != nil {
 		if existing != nil {
 			obj.SetResourceVersion(existing.GetResourceVersion())
 			obj.SetResourceVersion(existing.GetResourceVersion())
 			obj.SetUID(existing.GetUID())
 			obj.SetUID(existing.GetUID())

+ 5 - 3
pkg/controllers/externalsecret/externalsecret_controller_template.go

@@ -36,15 +36,17 @@ import (
 // * template.Data (highest precedence)
 // * template.Data (highest precedence)
 // * template.TemplateFrom
 // * template.TemplateFrom
 // * secret via es.data or es.dataFrom (if template.MergePolicy is Merge, or there is no template)
 // * secret via es.data or es.dataFrom (if template.MergePolicy is Merge, or there is no template)
-// * existing secret keys (if CreationPolicy is Merge).
+// * existing secret keys (if CreationPolicy is Merge or CreateOrMerge).
 func (r *Reconciler) ApplyTemplate(ctx context.Context, es *esv1.ExternalSecret, secret *v1.Secret, dataMap map[string][]byte) error {
 func (r *Reconciler) ApplyTemplate(ctx context.Context, es *esv1.ExternalSecret, secret *v1.Secret, dataMap map[string][]byte) error {
 	// update metadata (labels, annotations, finalizers) of the secret
 	// update metadata (labels, annotations, finalizers) of the secret
 	if err := setMetadata(secret, es); err != nil {
 	if err := setMetadata(secret, es); err != nil {
 		return err
 		return err
 	}
 	}
 
 
-	// we only keep existing keys if creation policy is Merge, otherwise we clear the secret
-	if es.Spec.Target.CreationPolicy != esv1.CreatePolicyMerge {
+	// we only keep existing keys if creation policy is Merge or CreateOrMerge,
+	// otherwise we clear the secret
+	if es.Spec.Target.CreationPolicy != esv1.CreatePolicyMerge &&
+		es.Spec.Target.CreationPolicy != esv1.CreatePolicyCreateOrMerge {
 		secret.Data = make(map[string][]byte)
 		secret.Data = make(map[string][]byte)
 	}
 	}
 
 

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

@@ -193,6 +193,29 @@ var _ = Describe("Kind=secret existence logic", func() {
 			ExternalSecret: makeExternalSecret(esv1.CreatePolicyOrphan),
 			ExternalSecret: makeExternalSecret(esv1.CreatePolicyOrphan),
 			ExpectedOutput: true,
 			ExpectedOutput: true,
 		},
 		},
+		{
+			Name:           "Missing UID Secret is invalid with creation policy CreateOrMerge",
+			Input:          &v1.Secret{},
+			ExternalSecret: makeExternalSecret(esv1.CreatePolicyCreateOrMerge),
+			ExpectedOutput: false,
+		},
+		{
+			Name: "A valid secret with creation policy CreateOrMerge should return true",
+			Input: &v1.Secret{
+				ObjectMeta: metav1.ObjectMeta{
+					UID: "xxx",
+					Labels: map[string]string{
+						esv1.LabelManaged: esv1.LabelManagedValue,
+					},
+					Annotations: map[string]string{
+						esv1.AnnotationDataHash: esutils.ObjectHash(validData),
+					},
+				},
+				Data: validData,
+			},
+			ExternalSecret: makeExternalSecret(esv1.CreatePolicyCreateOrMerge),
+			ExpectedOutput: true,
+		},
 	}
 	}
 
 
 	for _, tt := range tests {
 	for _, tt := range tests {
@@ -2556,6 +2579,392 @@ var _ = Describe("ExternalSecret controller", Serial, func() {
 			noSecretCreatedWhenNamespaceMatchMultipleNonMatchingConditions,
 			noSecretCreatedWhenNamespaceMatchMultipleNonMatchingConditions,
 		),
 		),
 	)
 	)
+
+	// Regression coverage for issue #6640: refreshPolicy CreatedOnce tracks its
+	// "once" on the ExternalSecret's own status, not on whether the target Secret
+	// already exists. Recreating the ExternalSecret object therefore re-syncs and
+	// overwrites the existing target Secret unless target.immutable is set.
+	Context("RefreshPolicy=CreatedOnce recreation (issue #6640)", func() {
+		It("overwrites an existing target Secret when the ExternalSecret is recreated without immutable", func() {
+			ctx := context.Background()
+			const firstVal = "created-once-first"
+			const secondVal = "created-once-second"
+
+			tc := makeDefaultTestcase()
+			tc.externalSecret.Spec.RefreshPolicy = esv1.RefreshPolicyCreatedOnce
+			tc.externalSecret.Spec.Target.CreationPolicy = esv1.CreatePolicyOrphan
+			fakeProvider.WithGetSecret([]byte(firstVal), nil)
+
+			By("creating the secret store and external secret")
+			Expect(k8sClient.Create(ctx, tc.secretStore)).To(Succeed())
+			Expect(k8sClient.Create(ctx, tc.externalSecret)).To(Succeed())
+
+			secretKey := types.NamespacedName{Name: ExternalSecretTargetSecretName, Namespace: ExternalSecretNamespace}
+			esKey := types.NamespacedName{Name: ExternalSecretName, Namespace: ExternalSecretNamespace}
+
+			By("waiting for the target secret to be created with the first value")
+			Eventually(func() string {
+				sec := &v1.Secret{}
+				if err := k8sClient.Get(ctx, secretKey, sec); err != nil {
+					return ""
+				}
+				return string(sec.Data[targetProp])
+			}, timeout, interval).Should(Equal(firstVal))
+
+			By("changing the provider value; CreatedOnce must not refresh the live ExternalSecret")
+			fakeProvider.WithGetSecret([]byte(secondVal), nil)
+			Consistently(func() string {
+				sec := &v1.Secret{}
+				if err := k8sClient.Get(ctx, secretKey, sec); err != nil {
+					return ""
+				}
+				return string(sec.Data[targetProp])
+			}, time.Second*3, interval).Should(Equal(firstVal))
+
+			By("deleting the ExternalSecret; creationPolicy=Orphan keeps the target secret")
+			Expect(k8sClient.Delete(ctx, tc.externalSecret)).To(Succeed())
+			Eventually(func() bool {
+				return apierrors.IsNotFound(k8sClient.Get(ctx, esKey, &esv1.ExternalSecret{}))
+			}, timeout, interval).Should(BeTrue())
+			sec := &v1.Secret{}
+			Expect(k8sClient.Get(ctx, secretKey, sec)).To(Succeed())
+			Expect(string(sec.Data[targetProp])).To(Equal(firstVal))
+
+			By("recreating an identical ExternalSecret")
+			recreated := makeDefaultTestcase().externalSecret
+			recreated.Spec.RefreshPolicy = esv1.RefreshPolicyCreatedOnce
+			recreated.Spec.Target.CreationPolicy = esv1.CreatePolicyOrphan
+			Expect(k8sClient.Create(ctx, recreated)).To(Succeed())
+
+			By("observing the pre-existing target secret is overwritten with the new value")
+			Eventually(func() string {
+				s := &v1.Secret{}
+				if err := k8sClient.Get(ctx, secretKey, s); err != nil {
+					return ""
+				}
+				return string(s.Data[targetProp])
+			}, timeout, interval).Should(Equal(secondVal))
+		})
+
+		It("preserves an existing target Secret when the ExternalSecret is recreated with target.immutable", func() {
+			ctx := context.Background()
+			const firstVal = "immutable-first"
+			const secondVal = "immutable-second"
+
+			tc := makeDefaultTestcase()
+			tc.externalSecret.Spec.RefreshPolicy = esv1.RefreshPolicyCreatedOnce
+			tc.externalSecret.Spec.Target.CreationPolicy = esv1.CreatePolicyOrphan
+			tc.externalSecret.Spec.Target.Immutable = true
+			fakeProvider.WithGetSecret([]byte(firstVal), nil)
+
+			By("creating the secret store and immutable external secret")
+			Expect(k8sClient.Create(ctx, tc.secretStore)).To(Succeed())
+			Expect(k8sClient.Create(ctx, tc.externalSecret)).To(Succeed())
+
+			secretKey := types.NamespacedName{Name: ExternalSecretTargetSecretName, Namespace: ExternalSecretNamespace}
+			esKey := types.NamespacedName{Name: ExternalSecretName, Namespace: ExternalSecretNamespace}
+
+			By("waiting for the immutable target secret to be created")
+			Eventually(func() string {
+				sec := &v1.Secret{}
+				if err := k8sClient.Get(ctx, secretKey, sec); err != nil {
+					return ""
+				}
+				return string(sec.Data[targetProp])
+			}, timeout, interval).Should(Equal(firstVal))
+			sec := &v1.Secret{}
+			Expect(k8sClient.Get(ctx, secretKey, sec)).To(Succeed())
+			Expect(sec.Immutable).ToNot(BeNil())
+			Expect(*sec.Immutable).To(BeTrue())
+
+			By("changing the provider value and deleting the ExternalSecret")
+			fakeProvider.WithGetSecret([]byte(secondVal), nil)
+			Expect(k8sClient.Delete(ctx, tc.externalSecret)).To(Succeed())
+			Eventually(func() bool {
+				return apierrors.IsNotFound(k8sClient.Get(ctx, esKey, &esv1.ExternalSecret{}))
+			}, timeout, interval).Should(BeTrue())
+
+			By("recreating the identical immutable ExternalSecret")
+			recreated := makeDefaultTestcase().externalSecret
+			recreated.Spec.RefreshPolicy = esv1.RefreshPolicyCreatedOnce
+			recreated.Spec.Target.CreationPolicy = esv1.CreatePolicyOrphan
+			recreated.Spec.Target.Immutable = true
+			Expect(k8sClient.Create(ctx, recreated)).To(Succeed())
+
+			By("waiting for the recreated ExternalSecret to reconcile to Ready")
+			Eventually(func() bool {
+				es := &esv1.ExternalSecret{}
+				if err := k8sClient.Get(ctx, esKey, es); err != nil {
+					return false
+				}
+				cond := esv1.GetExternalSecretCondition(es.Status, esv1.ExternalSecretReady)
+				return cond != nil && cond.Status == v1.ConditionTrue
+			}, timeout, interval).Should(BeTrue())
+
+			By("confirming the pre-existing secret data is preserved despite the changed provider value")
+			Consistently(func() string {
+				s := &v1.Secret{}
+				if err := k8sClient.Get(ctx, secretKey, s); err != nil {
+					return ""
+				}
+				return string(s.Data[targetProp])
+			}, time.Second*5, interval).Should(Equal(firstVal))
+		})
+
+		It("recreates a deleted target Secret even with target.immutable set (creationPolicy Owner)", func() {
+			// immutable only blocks overwriting an EXISTING Secret's data; a
+			// deleted Secret no longer exists, so the controller creates a fresh
+			// one on the next reconcile (the by-label Secret watch enqueues it).
+			ctx := context.Background()
+			const val = "immutable-recreate"
+
+			tc := makeDefaultTestcase()
+			tc.externalSecret.Spec.RefreshPolicy = esv1.RefreshPolicyCreatedOnce
+			tc.externalSecret.Spec.Target.CreationPolicy = esv1.CreatePolicyOwner
+			tc.externalSecret.Spec.Target.Immutable = true
+			fakeProvider.WithGetSecret([]byte(val), nil)
+
+			Expect(k8sClient.Create(ctx, tc.secretStore)).To(Succeed())
+			Expect(k8sClient.Create(ctx, tc.externalSecret)).To(Succeed())
+
+			secretKey := types.NamespacedName{Name: ExternalSecretTargetSecretName, Namespace: ExternalSecretNamespace}
+
+			By("waiting for the immutable target secret to be created")
+			secret := &v1.Secret{}
+			Eventually(func() bool {
+				if err := k8sClient.Get(ctx, secretKey, secret); err != nil {
+					return false
+				}
+				return secret.UID != "" && string(secret.Data[targetProp]) == val
+			}, timeout, interval).Should(BeTrue())
+			Expect(secret.Immutable).ToNot(BeNil())
+			Expect(*secret.Immutable).To(BeTrue())
+			oldUID := secret.UID
+
+			By("deleting the target secret")
+			Expect(k8sClient.Delete(ctx, secret)).To(Succeed())
+
+			By("observing the controller recreates it as a new object")
+			Eventually(func() bool {
+				recreated := &v1.Secret{}
+				if err := k8sClient.Get(ctx, secretKey, recreated); err != nil {
+					return false
+				}
+				return recreated.UID != "" && recreated.UID != oldUID
+			}, timeout, interval).Should(BeTrue())
+		})
+
+		It("does not recreate a deleted target Secret with creationPolicy Orphan and refreshPolicy CreatedOnce", func() {
+			// With Orphan the controller treats a missing Secret as valid, and
+			// CreatedOnce never triggers another refresh, so a deleted Secret is
+			// not recreated. immutable is irrelevant to this outcome.
+			ctx := context.Background()
+			const val = "orphan-created-once"
+
+			tc := makeDefaultTestcase()
+			tc.externalSecret.Spec.RefreshPolicy = esv1.RefreshPolicyCreatedOnce
+			tc.externalSecret.Spec.Target.CreationPolicy = esv1.CreatePolicyOrphan
+			tc.externalSecret.Spec.Target.Immutable = true
+			fakeProvider.WithGetSecret([]byte(val), nil)
+
+			Expect(k8sClient.Create(ctx, tc.secretStore)).To(Succeed())
+			Expect(k8sClient.Create(ctx, tc.externalSecret)).To(Succeed())
+
+			secretKey := types.NamespacedName{Name: ExternalSecretTargetSecretName, Namespace: ExternalSecretNamespace}
+
+			By("waiting for the target secret to be created")
+			secret := &v1.Secret{}
+			Eventually(func() bool {
+				if err := k8sClient.Get(ctx, secretKey, secret); err != nil {
+					return false
+				}
+				return secret.UID != "" && string(secret.Data[targetProp]) == val
+			}, timeout, interval).Should(BeTrue())
+
+			By("deleting the target secret")
+			Expect(k8sClient.Delete(ctx, secret)).To(Succeed())
+			Eventually(func() bool {
+				return apierrors.IsNotFound(k8sClient.Get(ctx, secretKey, &v1.Secret{}))
+			}, timeout, interval).Should(BeTrue())
+
+			By("confirming the controller leaves it deleted")
+			Consistently(func() bool {
+				return apierrors.IsNotFound(k8sClient.Get(ctx, secretKey, &v1.Secret{}))
+			}, time.Second*5, interval).Should(BeTrue())
+		})
+
+		It("with Orphan + Periodic + immutable: creates, recreates on delete, and keeps the Secret if the ExternalSecret is deleted", func() {
+			// Orphan keeps the Secret when the ExternalSecret goes away (no
+			// ownerReference), Periodic re-triggers a sync so a deleted Secret is
+			// recreated at the next interval, and immutable stops the periodic
+			// re-sync from rewriting the live value once it exists.
+			ctx := context.Background()
+			const val = "orphan-periodic-immutable"
+
+			tc := makeDefaultTestcase()
+			tc.externalSecret.Spec.RefreshPolicy = esv1.RefreshPolicyPeriodic
+			tc.externalSecret.Spec.RefreshInterval = &metav1.Duration{Duration: time.Second}
+			tc.externalSecret.Spec.Target.CreationPolicy = esv1.CreatePolicyOrphan
+			tc.externalSecret.Spec.Target.Immutable = true
+			fakeProvider.WithGetSecret([]byte(val), nil)
+
+			Expect(k8sClient.Create(ctx, tc.secretStore)).To(Succeed())
+			Expect(k8sClient.Create(ctx, tc.externalSecret)).To(Succeed())
+
+			secretKey := types.NamespacedName{Name: ExternalSecretTargetSecretName, Namespace: ExternalSecretNamespace}
+			esKey := types.NamespacedName{Name: ExternalSecretName, Namespace: ExternalSecretNamespace}
+
+			By("creating and populating an immutable target secret")
+			secret := &v1.Secret{}
+			Eventually(func() bool {
+				if err := k8sClient.Get(ctx, secretKey, secret); err != nil {
+					return false
+				}
+				return secret.UID != "" && string(secret.Data[targetProp]) == val
+			}, timeout, interval).Should(BeTrue())
+			Expect(secret.Immutable).ToNot(BeNil())
+			Expect(*secret.Immutable).To(BeTrue())
+			oldUID := secret.UID
+
+			By("recreating and repopulating it after the target secret is deleted")
+			Expect(k8sClient.Delete(ctx, secret)).To(Succeed())
+			Eventually(func() bool {
+				recreated := &v1.Secret{}
+				if err := k8sClient.Get(ctx, secretKey, recreated); err != nil {
+					return false
+				}
+				return recreated.UID != "" && recreated.UID != oldUID &&
+					string(recreated.Data[targetProp]) == val &&
+					recreated.Immutable != nil && *recreated.Immutable
+			}, timeout, interval).Should(BeTrue())
+
+			By("keeping the target secret after the ExternalSecret is deleted")
+			Expect(k8sClient.Delete(ctx, tc.externalSecret)).To(Succeed())
+			Eventually(func() bool {
+				return apierrors.IsNotFound(k8sClient.Get(ctx, esKey, &esv1.ExternalSecret{}))
+			}, timeout, interval).Should(BeTrue())
+			Consistently(func() bool {
+				return k8sClient.Get(ctx, secretKey, &v1.Secret{}) == nil
+			}, time.Second*5, interval).Should(BeTrue())
+		})
+	})
+
+	Context("creationPolicy=CreateOrMerge", func() {
+		It("merges into an existing Secret and preserves foreign keys", func() {
+			ctx := context.Background()
+			const foreignKey = "owned-elsewhere"
+			const foreignVal = "keepme"
+			const val = "from-provider"
+
+			// a Secret created by another party, with a key ESO does not manage
+			Expect(k8sClient.Create(ctx, &v1.Secret{
+				ObjectMeta: metav1.ObjectMeta{Name: ExternalSecretTargetSecretName, Namespace: ExternalSecretNamespace},
+				Data:       map[string][]byte{foreignKey: []byte(foreignVal)},
+			})).To(Succeed())
+
+			tc := makeDefaultTestcase()
+			tc.externalSecret.Spec.Target.CreationPolicy = esv1.CreatePolicyCreateOrMerge
+			fakeProvider.WithGetSecret([]byte(val), nil)
+
+			Expect(k8sClient.Create(ctx, tc.secretStore)).To(Succeed())
+			Expect(k8sClient.Create(ctx, tc.externalSecret)).To(Succeed())
+
+			secretKey := types.NamespacedName{Name: ExternalSecretTargetSecretName, Namespace: ExternalSecretNamespace}
+			By("merging the provider key while keeping the foreign key")
+			Eventually(func() bool {
+				s := &v1.Secret{}
+				if err := k8sClient.Get(ctx, secretKey, s); err != nil {
+					return false
+				}
+				return string(s.Data[targetProp]) == val && string(s.Data[foreignKey]) == foreignVal
+			}, timeout, interval).Should(BeTrue())
+		})
+
+		It("creates when missing, recreates a deleted target immediately under CreatedOnce, and retains on ES delete", func() {
+			ctx := context.Background()
+			const val = "createormerge-val"
+
+			tc := makeDefaultTestcase()
+			tc.externalSecret.Spec.RefreshPolicy = esv1.RefreshPolicyCreatedOnce
+			tc.externalSecret.Spec.Target.CreationPolicy = esv1.CreatePolicyCreateOrMerge
+			// no RefreshInterval: recreation must not depend on it
+			fakeProvider.WithGetSecret([]byte(val), nil)
+
+			Expect(k8sClient.Create(ctx, tc.secretStore)).To(Succeed())
+			Expect(k8sClient.Create(ctx, tc.externalSecret)).To(Succeed())
+
+			secretKey := types.NamespacedName{Name: ExternalSecretTargetSecretName, Namespace: ExternalSecretNamespace}
+			esKey := types.NamespacedName{Name: ExternalSecretName, Namespace: ExternalSecretNamespace}
+
+			By("creating and populating the target secret")
+			secret := &v1.Secret{}
+			Eventually(func() bool {
+				if err := k8sClient.Get(ctx, secretKey, secret); err != nil {
+					return false
+				}
+				return secret.UID != "" && string(secret.Data[targetProp]) == val
+			}, timeout, interval).Should(BeTrue())
+			oldUID := secret.UID
+
+			By("recreating it immediately after the target secret is deleted")
+			Expect(k8sClient.Delete(ctx, secret)).To(Succeed())
+			Eventually(func() bool {
+				r := &v1.Secret{}
+				if err := k8sClient.Get(ctx, secretKey, r); err != nil {
+					return false
+				}
+				return r.UID != "" && r.UID != oldUID && string(r.Data[targetProp]) == val
+			}, timeout, interval).Should(BeTrue())
+
+			By("keeping the target secret after the ExternalSecret is deleted")
+			Expect(k8sClient.Delete(ctx, tc.externalSecret)).To(Succeed())
+			Eventually(func() bool {
+				return apierrors.IsNotFound(k8sClient.Get(ctx, esKey, &esv1.ExternalSecret{}))
+			}, timeout, interval).Should(BeTrue())
+			Consistently(func() bool {
+				return k8sClient.Get(ctx, secretKey, &v1.Secret{}) == nil
+			}, time.Second*5, interval).Should(BeTrue())
+		})
+
+		It("with immutable, creates and freezes the Secret and recreates it after deletion", func() {
+			ctx := context.Background()
+			const val = "createormerge-immutable"
+
+			tc := makeDefaultTestcase()
+			tc.externalSecret.Spec.RefreshPolicy = esv1.RefreshPolicyCreatedOnce
+			tc.externalSecret.Spec.Target.CreationPolicy = esv1.CreatePolicyCreateOrMerge
+			tc.externalSecret.Spec.Target.Immutable = true
+			fakeProvider.WithGetSecret([]byte(val), nil)
+
+			Expect(k8sClient.Create(ctx, tc.secretStore)).To(Succeed())
+			Expect(k8sClient.Create(ctx, tc.externalSecret)).To(Succeed())
+
+			secretKey := types.NamespacedName{Name: ExternalSecretTargetSecretName, Namespace: ExternalSecretNamespace}
+
+			By("creating a populated, immutable target secret")
+			secret := &v1.Secret{}
+			Eventually(func() bool {
+				if err := k8sClient.Get(ctx, secretKey, secret); err != nil {
+					return false
+				}
+				return secret.UID != "" && string(secret.Data[targetProp]) == val
+			}, timeout, interval).Should(BeTrue())
+			Expect(secret.Immutable).ToNot(BeNil())
+			Expect(*secret.Immutable).To(BeTrue())
+			oldUID := secret.UID
+
+			By("recreating a fresh immutable secret after deletion")
+			Expect(k8sClient.Delete(ctx, secret)).To(Succeed())
+			Eventually(func() bool {
+				r := &v1.Secret{}
+				if err := k8sClient.Get(ctx, secretKey, r); err != nil {
+					return false
+				}
+				return r.UID != "" && r.UID != oldUID && string(r.Data[targetProp]) == val &&
+					r.Immutable != nil && *r.Immutable
+			}, timeout, interval).Should(BeTrue())
+		})
+	})
 })
 })
 
 
 var _ = Describe("ExternalSecret refresh logic", func() {
 var _ = Describe("ExternalSecret refresh logic", func() {