diff --git a/README.md b/README.md
index a27c370..03ff483 100644
--- a/README.md
+++ b/README.md
@@ -17,8 +17,10 @@ Tested in production environments managing 1000+ mirrors across 200+ namespaces
- [Usage Examples](#usage-examples)
- [Mirror a Secret to Specific Namespaces](#mirror-a-secret-to-specific-namespaces)
- [Mirror to Pattern-Matched Namespaces](#mirror-to-pattern-matched-namespaces)
+ - [Mirror to All Namespaces](#mirror-to-all-namespaces)
- [Mirror to All Labeled Namespaces](#mirror-to-all-labeled-namespaces)
- [Mirror Custom Resources (CRDs)](#mirror-custom-resources-crds)
+ - [Using with ExternalSecrets Operator](#using-with-externalsecrets-operator)
- [Configuration](#configuration)
- [Helm Chart Values](#helm-chart-values)
- [Command-line Flags](#command-line-flags)
@@ -65,9 +67,9 @@ KubeMirror solves this with:
| **Resources** | Mirror any Kubernetes resource type - Secrets, ConfigMaps, Ingresses, Services, CRDs, and more |
| **Resources** | Auto-discovery of all mirrorable resources with periodic refresh |
| **Resources** | Safety deny list prevents mirroring dangerous resources (Pods, Events, Nodes) |
-| **Targeting** | Mirror to specific namespaces, pattern-matched namespaces (`app-*`), or all labeled namespaces |
+| **Targeting** | Mirror to specific namespaces, patterns (`app-*`), `all` namespaces, or `all-labeled` (opt-in) |
| **Targeting** | Configurable maximum targets per source (default: 100) |
-| **Targeting** | Namespace opt-in required for "all-labeled" mirrors |
+| **Targeting** | `all-labeled` requires namespace opt-in via `kubemirror.raczylo.com/allow-mirrors` label |
| **Sync** | Multi-layer change detection: generation field + SHA256 content hash |
| **Sync** | Automatic drift detection and correction for manually modified mirrors |
| **Sync** | Finalizer-based cleanup ensures mirrors are deleted with source |
@@ -207,8 +209,33 @@ data:
api_url: "https://api.example.com"
```
+### Mirror to All Namespaces
+
+Use the `all` keyword to mirror to every namespace in the cluster (except the source):
+
+**Source Resource:**
+```yaml
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: global-config
+ namespace: default
+ labels:
+ kubemirror.raczylo.com/enabled: "true"
+ annotations:
+ kubemirror.raczylo.com/sync: "true"
+ kubemirror.raczylo.com/target-namespaces: "all"
+data:
+ cluster_name: "production"
+ region: "us-west-2"
+```
+
+> **⚠️ Use with caution:** The `all` keyword mirrors to ALL namespaces (including kube-system, kube-public, etc.) except the source namespace. Consider using `all-labeled` for safer opt-in behavior.
+
### Mirror to All Labeled Namespaces
+Use `all-labeled` for opt-in mirroring where target namespaces must explicitly allow mirrors:
+
**Source Resource:**
```yaml
apiVersion: v1
@@ -265,6 +292,88 @@ spec:
- text/event-stream
```
+### Using with ExternalSecrets Operator
+
+KubeMirror works seamlessly with the [ExternalSecrets Operator](https://external-secrets.io/) to distribute secrets from external stores (like 1Password, Vault, AWS Secrets Manager) across multiple namespaces.
+
+**Example - Distribute Docker Registry Credentials from 1Password:**
+
+```yaml
+apiVersion: external-secrets.io/v1
+kind: ExternalSecret
+metadata:
+ name: 1p-docker-config
+ namespace: default
+spec:
+ # Pull secrets from 1Password/Vault/etc
+ secretStoreRef:
+ kind: ClusterSecretStore
+ name: 1password-homecluster
+
+ target:
+ creationPolicy: Owner # Standard ExternalSecrets setting - KubeMirror strips ownerReferences from mirrors
+ deletionPolicy: Retain
+ name: multi-registry-secret
+
+ # Include KubeMirror annotations in the secret template
+ template:
+ metadata:
+ labels:
+ kubemirror.raczylo.com/enabled: "true"
+ annotations:
+ kubemirror.raczylo.com/sync: "true"
+ kubemirror.raczylo.com/target-namespaces: "all" # or specific namespaces
+
+ type: kubernetes.io/dockerconfigjson
+ data:
+ .dockerconfigjson: |
+ {
+ "auths": {
+ "ghcr.io": {
+ "username": "{{ .ghcrUsername | toString }}",
+ "auth": "{{ printf "%s:%s" .ghcrUsername .ghcrPassword | b64enc }}"
+ }
+ }
+ }
+
+ data:
+ - remoteRef:
+ key: DockerAuth/ghcrio_username
+ secretKey: ghcrUsername
+ - remoteRef:
+ key: DockerAuth/ghcrio_password
+ secretKey: ghcrPassword
+
+ refreshInterval: 24h
+```
+
+**How it Works:**
+
+1. **ExternalSecrets creates the source secret** with KubeMirror labels/annotations (source can be owned by any controller)
+2. **KubeMirror detects the source** via the `kubemirror.raczylo.com/enabled` label
+3. **KubeMirror creates mirrors** in target namespaces with:
+ - Labels identifying them as KubeMirror-managed mirrors
+ - Annotations linking back to the source (namespace, name, UID, content hash)
+ - **No ownerReferences** - preventing conflicts with source controllers
+4. **ExternalSecrets refreshes the source** every 24h, updating only the source secret
+5. **KubeMirror detects content changes** via hash comparison and updates all mirrors
+6. Each controller manages its own resources independently - no conflicts
+
+**Verification:**
+
+```bash
+# Check source secret was created by ExternalSecrets
+kubectl get secret multi-registry-secret -n default -o jsonpath='{.metadata.annotations}'
+
+# Verify mirrors were created by KubeMirror
+kubectl get secrets --all-namespaces -l kubemirror.raczylo.com/mirror=true
+
+# Check sync status on source
+kubectl get secret multi-registry-secret -n default -o jsonpath='{.metadata.annotations.kubemirror\.raczylo\.com/sync-status}'
+```
+
+See [examples/externalsecret-dockerconfig.yaml](examples/externalsecret-dockerconfig.yaml) for a complete working example.
+
### Transformation Rules
KubeMirror supports powerful transformation rules that modify resources during mirroring. This enables environment-specific configurations, security hardening, and dynamic value generation.
diff --git a/docs/index.html b/docs/index.html
index 2697043..2c89714 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -224,7 +224,7 @@
KubeMirror's Solution
- Define your resource once in a source namespace. KubeMirror automatically copies it to all target namespaces and keeps them synchronized.
+ Define your resource once in a source namespace. KubeMirror automatically copies it to target namespaces (specific list, patterns like app-*, or all) and keeps them synchronized.
Transform values per environment (e.g., preprod-* namespaces get preprod API URLs, prod-* get production URLs).
Works with any Kubernetes resource type.
@@ -332,9 +332,13 @@
app-*,prod-*
Pattern matching
+
+ all
+ All namespaces (no labels required)
+
all-labeled
- All labeled namespaces
+ Only namespaces with opt-in label
diff --git a/examples/README.md b/examples/README.md
index 7382a68..75ba45a 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -249,6 +249,137 @@ data:
config: "value"
```
+## ExternalSecrets Integration
+
+KubeMirror integrates seamlessly with the [ExternalSecrets Operator](https://external-secrets.io/) to distribute secrets from external stores (1Password, Vault, AWS Secrets Manager, etc.) across multiple namespaces.
+
+### Overview
+
+The `externalsecret-dockerconfig.yaml` example demonstrates how to:
+1. Sync secrets from 1Password/Vault/etc using ExternalSecrets
+2. Mirror those secrets to multiple namespaces using KubeMirror
+3. Avoid race conditions between the two controllers
+
+### Prerequisites
+
+```bash
+# Install ExternalSecrets Operator
+helm repo add external-secrets https://charts.external-secrets.io
+helm install external-secrets external-secrets/external-secrets -n external-secrets-system --create-namespace
+
+# Configure your ClusterSecretStore (example for 1Password)
+kubectl apply -f your-clustersecretstore.yaml
+```
+
+### Quick Start
+
+```bash
+# Apply the ExternalSecret example
+kubectl apply -f examples/externalsecret-dockerconfig.yaml
+
+# Verify the source secret was created
+kubectl get secret multi-registry-secret -n default
+
+# Verify mirrors were created by KubeMirror
+kubectl get secrets --all-namespaces -l kubemirror.raczylo.com/mirror=true
+
+# Check sync status
+kubectl get secret multi-registry-secret -n default \
+ -o jsonpath='{.metadata.annotations.kubemirror\.raczylo\.com/sync-status}'
+```
+
+### Key Configuration
+
+**Ownership Model**
+
+KubeMirror uses **labels and annotations** to manage mirrors, not ownerReferences. This allows it to work with sources managed by any controller (ExternalSecrets, ArgoCD, etc.):
+
+```yaml
+target:
+ creationPolicy: Owner # Source can be owned by ExternalSecrets
+ name: multi-registry-secret
+ template:
+ metadata:
+ labels:
+ kubemirror.raczylo.com/enabled: "true" # KubeMirror detection
+ annotations:
+ kubemirror.raczylo.com/sync: "true"
+ kubemirror.raczylo.com/target-namespaces: "all"
+```
+
+**Separation of Concerns:**
+
+- **Source Secret**: Owned by ExternalSecrets (or any other controller) via `ownerReferences`
+- **Mirror Secrets**: Managed by KubeMirror via labels + annotations (no ownerReferences copied)
+- **Relationship**: Mirrors link to source via annotations (`source-namespace`, `source-name`, `source-uid`)
+- **Result**: Each controller manages its own resources independently
+
+### Examples in externalsecret-dockerconfig.yaml
+
+The example file contains three complete examples:
+
+1. **Docker Registry Credentials** - Mirrors Docker config to all namespaces using `target-namespaces: "all"`
+2. **Database Credentials** - Uses `all-labeled` for opt-in mirroring with namespace labels
+3. **Namespace with Opt-In Label** - Shows how to label namespaces to receive mirrors
+
+### Verification
+
+```bash
+# Check ExternalSecret status
+kubectl get externalsecret 1p-docker-config -n default
+
+# View the created secret
+kubectl get secret multi-registry-secret -n default -o yaml
+
+# Verify KubeMirror picked it up
+kubectl logs -n kubemirror-system -l app.kubernetes.io/name=kubemirror | grep multi-registry-secret
+
+# Count how many namespaces received the mirror
+kubectl get secrets --all-namespaces -l kubemirror.raczylo.com/mirror=true \
+ --field-selector metadata.name=multi-registry-secret | wc -l
+
+# Check a specific mirror
+kubectl get secret multi-registry-secret -n production-app -o yaml
+```
+
+### Testing Updates
+
+Test that ExternalSecrets refreshes propagate to all mirrors:
+
+```bash
+# Force ExternalSecret refresh
+kubectl annotate externalsecret 1p-docker-config -n default \
+ force-sync="$(date +%s)" --overwrite
+
+# Wait for ExternalSecret to sync (check status)
+kubectl get externalsecret 1p-docker-config -n default -w
+
+# Verify mirrors are updated (check generation or content hash)
+kubectl get secret multi-registry-secret -n production-app \
+ -o jsonpath='{.metadata.annotations.kubemirror\.raczylo\.com/source-content-hash}'
+```
+
+### Common Issues
+
+1. **Mirrors Not Created**
+ - Verify the secret has `kubemirror.raczylo.com/enabled: "true"` label
+ - Check that KubeMirror annotations are in the ExternalSecret template
+ - View controller logs: `kubectl logs -n kubemirror-system -l app.kubernetes.io/name=kubemirror`
+
+2. **ExternalSecret Not Syncing**
+ - Check ExternalSecret status: `kubectl describe externalsecret -n `
+ - Verify ClusterSecretStore is configured: `kubectl get clustersecretstore`
+ - Check external-secrets-operator logs
+
+### Alternative Backends
+
+The example includes commented configurations for:
+- AWS Secrets Manager
+- HashiCorp Vault
+- Google Secret Manager
+
+Uncomment and configure the ClusterSecretStore for your backend.
+
## Transformation Rules
KubeMirror supports transformation rules that modify resources during mirroring. This enables environment-specific configurations, security hardening, and dynamic value generation.
diff --git a/examples/externalsecret-dockerconfig.yaml b/examples/externalsecret-dockerconfig.yaml
new file mode 100644
index 0000000..e3d44f2
--- /dev/null
+++ b/examples/externalsecret-dockerconfig.yaml
@@ -0,0 +1,227 @@
+# Example: Using KubeMirror with ExternalSecrets Operator
+#
+# This example demonstrates how to use KubeMirror to distribute secrets from
+# external secret stores (1Password, Vault, AWS Secrets Manager, etc.) across
+# multiple namespaces.
+#
+# KubeMirror automatically strips ownerReferences from mirrors, so you can use
+# the standard ExternalSecrets creationPolicy: Owner without conflicts.
+#
+# Prerequisites:
+# 1. ExternalSecrets Operator installed: https://external-secrets.io/
+# 2. ClusterSecretStore configured for your secret backend
+# 3. KubeMirror installed and running
+#
+# Apply this manifest:
+# kubectl apply -f externalsecret-dockerconfig.yaml
+#
+# Verify:
+# kubectl get externalsecret 1p-docker-config -n default
+# kubectl get secret multi-registry-secret -n default
+# kubectl get secrets --all-namespaces -l kubemirror.raczylo.com/mirror=true
+
+---
+apiVersion: external-secrets.io/v1
+kind: ExternalSecret
+metadata:
+ name: 1p-docker-config
+ namespace: default
+ annotations:
+ description: "Pulls Docker registry credentials from 1Password and mirrors to all namespaces"
+spec:
+ # Secret backend configuration
+ secretStoreRef:
+ kind: ClusterSecretStore
+ name: 1password-homecluster # Replace with your ClusterSecretStore name
+
+ # Refresh interval - how often to sync from external store
+ refreshInterval: 24h
+
+ # Target secret configuration
+ target:
+ # Standard ExternalSecrets setting - KubeMirror automatically strips ownerReferences from mirrors
+ creationPolicy: Owner
+
+ # Deletion policy - what happens when ExternalSecret is deleted
+ # - Retain: Keep the secret (recommended with KubeMirror)
+ # - Delete: Remove the secret
+ deletionPolicy: Retain
+
+ # Name of the Kubernetes secret to create
+ name: multi-registry-secret
+
+ # Template for the secret - includes KubeMirror annotations
+ template:
+ type: kubernetes.io/dockerconfigjson
+
+ # Metadata to include in the created secret
+ metadata:
+ labels:
+ # REQUIRED: Server-side filtering label for KubeMirror
+ kubemirror.raczylo.com/enabled: "true"
+
+ # Optional: Additional labels
+ app: registry-credentials
+ managed-by: external-secrets
+
+ annotations:
+ # REQUIRED: Enable mirroring
+ kubemirror.raczylo.com/sync: "true"
+
+ # Target namespaces - choose one of:
+ # - Specific namespaces: "app1,app2,app3"
+ # - Pattern matching: "app-*,prod-*"
+ # - All namespaces: "all"
+ # - Labeled namespaces only: "all-labeled"
+ kubemirror.raczylo.com/target-namespaces: "all"
+
+ # Optional: Add description
+ description: "Docker registry credentials synced from 1Password"
+
+ # Docker config JSON template using ExternalSecrets templating
+ data:
+ .dockerconfigjson: |
+ {
+ "auths": {
+ "ghcr.io": {
+ "username": "{{ .ghcrUsername | toString }}",
+ "auth": "{{ printf "%s:%s" .ghcrUsername .ghcrPassword | b64enc }}"
+ },
+ "https://index.docker.io/v1/": {
+ "username": "{{ .dockerUsername | toString }}",
+ "auth": "{{ printf "%s:%s" .dockerUsername .dockerPassword | b64enc }}"
+ }
+ }
+ }
+
+ # Data mappings - what to fetch from external secret store
+ data:
+ # GitHub Container Registry credentials
+ - remoteRef:
+ key: DockerAuth/ghcrio_username
+ property: username # Optional: if secret has multiple properties
+ secretKey: ghcrUsername
+
+ - remoteRef:
+ key: DockerAuth/ghcrio_password
+ secretKey: ghcrPassword
+
+ # Docker Hub credentials
+ - remoteRef:
+ key: DockerAuth/dockerio_username
+ secretKey: dockerUsername
+
+ - remoteRef:
+ key: DockerAuth/dockerio_password
+ secretKey: dockerPassword
+
+---
+# Example: Using with all-labeled for opt-in mirroring
+apiVersion: external-secrets.io/v1
+kind: ExternalSecret
+metadata:
+ name: 1p-database-credentials
+ namespace: shared-resources
+spec:
+ secretStoreRef:
+ kind: ClusterSecretStore
+ name: 1password-homecluster
+
+ refreshInterval: 24h
+
+ target:
+ creationPolicy: Owner # Standard setting - KubeMirror strips ownerReferences
+ deletionPolicy: Retain
+ name: postgres-credentials
+
+ template:
+ type: Opaque
+ metadata:
+ labels:
+ kubemirror.raczylo.com/enabled: "true"
+ annotations:
+ kubemirror.raczylo.com/sync: "true"
+ # Only mirror to namespaces with allow-mirrors label
+ kubemirror.raczylo.com/target-namespaces: "all-labeled"
+
+ stringData:
+ # ExternalSecrets templating for connection strings
+ DATABASE_URL: "postgres://{{ .username }}:{{ .password }}@postgres.shared-resources.svc:5432/mydb"
+ DB_USER: "{{ .username }}"
+ DB_PASSWORD: "{{ .password }}"
+
+ data:
+ - remoteRef:
+ key: Database/postgres_username
+ secretKey: username
+
+ - remoteRef:
+ key: Database/postgres_password
+ secretKey: password
+
+---
+# Example namespace with allow-mirrors label (for all-labeled targeting)
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: production-app
+ labels:
+ # Opt-in to receive mirrored secrets
+ kubemirror.raczylo.com/allow-mirrors: "true"
+ environment: production
+
+---
+# Alternative ClusterSecretStore examples for different backends
+# Uncomment and configure based on your secret backend
+
+# # AWS Secrets Manager
+# apiVersion: external-secrets.io/v1beta1
+# kind: ClusterSecretStore
+# metadata:
+# name: aws-secrets-manager
+# spec:
+# provider:
+# aws:
+# service: SecretsManager
+# region: us-west-2
+# auth:
+# jwt:
+# serviceAccountRef:
+# name: external-secrets
+# namespace: external-secrets
+
+# # HashiCorp Vault
+# apiVersion: external-secrets.io/v1beta1
+# kind: ClusterSecretStore
+# metadata:
+# name: vault-backend
+# spec:
+# provider:
+# vault:
+# server: "https://vault.example.com"
+# path: "secret"
+# version: "v2"
+# auth:
+# kubernetes:
+# mountPath: "kubernetes"
+# role: "external-secrets"
+# serviceAccountRef:
+# name: external-secrets
+# namespace: external-secrets
+
+# # Google Secret Manager
+# apiVersion: external-secrets.io/v1beta1
+# kind: ClusterSecretStore
+# metadata:
+# name: google-secret-manager
+# spec:
+# provider:
+# gcpsm:
+# projectID: "my-project-id"
+# auth:
+# workloadIdentity:
+# clusterLocation: us-central1
+# clusterName: my-cluster
+# serviceAccountRef:
+# name: external-secrets
+# namespace: external-secrets
diff --git a/pkg/controller/mirror.go b/pkg/controller/mirror.go
index b775652..5739ab5 100644
--- a/pkg/controller/mirror.go
+++ b/pkg/controller/mirror.go
@@ -150,6 +150,11 @@ func createUnstructuredMirror(source runtime.Object, targetNamespace, sourceHash
mirror.SetGeneration(0)
mirror.SetCreationTimestamp(metav1.Time{})
mirror.SetFinalizers(nil) // Mirrors should not have finalizers
+ // IMPORTANT: Mirrors should never have ownerReferences from source.
+ // KubeMirror manages mirrors via labels/annotations, not ownership.
+ // This allows sources to be owned by other controllers (ExternalSecrets, ArgoCD, etc.)
+ // while KubeMirror independently manages the mirrors.
+ mirror.SetOwnerReferences(nil)
return mirror, nil
}
@@ -318,6 +323,10 @@ func updateUnstructuredMirror(mirror, source runtime.Object, sourceHash string)
// Ensure mirrors never have finalizers (even if they were added before this fix)
m.SetFinalizers(nil)
+ // Ensure mirrors never have ownerReferences (clean up mirrors from before this fix)
+ // KubeMirror uses labels/annotations for management, not ownerReferences
+ m.SetOwnerReferences(nil)
+
return nil
}
diff --git a/pkg/controller/mirror_test.go b/pkg/controller/mirror_test.go
index e103a9f..3b69ed9 100644
--- a/pkg/controller/mirror_test.go
+++ b/pkg/controller/mirror_test.go
@@ -155,6 +155,140 @@ func TestCreateMirror_Unstructured(t *testing.T) {
assert.Equal(t, "3", annotations[constants.AnnotationSourceGeneration])
}
+func TestCreateMirror_Unstructured_StripsOwnerReferences(t *testing.T) {
+ // Create source with ownerReferences (e.g., managed by ExternalSecrets)
+ source := &unstructured.Unstructured{
+ Object: map[string]interface{}{
+ "apiVersion": "v1",
+ "kind": "Secret",
+ "metadata": map[string]interface{}{
+ "name": "external-secret",
+ "namespace": "default",
+ "uid": "secret-uid-123",
+ "resourceVersion": "100",
+ "generation": int64(1),
+ // Source has ownerReferences (e.g., set by ExternalSecrets operator)
+ "ownerReferences": []interface{}{
+ map[string]interface{}{
+ "apiVersion": "external-secrets.io/v1",
+ "kind": "ExternalSecret",
+ "name": "1p-docker-config",
+ "uid": "externalsecret-uid-456",
+ "controller": true,
+ },
+ },
+ // Source has finalizers
+ "finalizers": []interface{}{
+ "externalsecrets.external-secrets.io/externalsecret-cleanup",
+ },
+ },
+ "data": map[string]interface{}{
+ "password": "c2VjcmV0",
+ },
+ },
+ }
+
+ mirror, err := CreateMirror(source, "target-ns")
+ require.NoError(t, err)
+ require.NotNil(t, mirror)
+
+ uMirror, ok := mirror.(*unstructured.Unstructured)
+ require.True(t, ok, "mirror should be Unstructured")
+
+ // CRITICAL: Verify ownerReferences are NOT copied to mirror
+ ownerRefs := uMirror.GetOwnerReferences()
+ assert.Nil(t, ownerRefs, "mirror should not have ownerReferences from source")
+
+ // CRITICAL: Verify finalizers are NOT copied to mirror
+ finalizers := uMirror.GetFinalizers()
+ assert.Nil(t, finalizers, "mirror should not have finalizers from source")
+
+ // Verify mirror is properly managed by KubeMirror via labels/annotations
+ assert.Equal(t, constants.ControllerName, uMirror.GetLabels()[constants.LabelManagedBy])
+ assert.Equal(t, "true", uMirror.GetLabels()[constants.LabelMirror])
+ assert.Equal(t, "default", uMirror.GetAnnotations()[constants.AnnotationSourceNamespace])
+ assert.Equal(t, "external-secret", uMirror.GetAnnotations()[constants.AnnotationSourceName])
+ assert.Equal(t, "secret-uid-123", uMirror.GetAnnotations()[constants.AnnotationSourceUID])
+}
+
+func TestUpdateMirror_Unstructured_ClearsOwnerReferences(t *testing.T) {
+ // Create mirror that somehow has ownerReferences (e.g., from before the fix)
+ mirror := &unstructured.Unstructured{
+ Object: map[string]interface{}{
+ "apiVersion": "traefik.io/v1alpha1",
+ "kind": "Middleware",
+ "metadata": map[string]interface{}{
+ "name": "test-middleware",
+ "namespace": "target-ns",
+ "labels": map[string]interface{}{
+ constants.LabelManagedBy: constants.ControllerName,
+ constants.LabelMirror: "true",
+ },
+ "annotations": map[string]interface{}{
+ constants.AnnotationSourceNamespace: "default",
+ constants.AnnotationSourceName: "test-middleware",
+ constants.AnnotationSourceContentHash: "oldhash",
+ },
+ // Mirror has ownerReferences (from before fix or external modification)
+ "ownerReferences": []interface{}{
+ map[string]interface{}{
+ "apiVersion": "external-secrets.io/v1",
+ "kind": "ExternalSecret",
+ "name": "1p-docker-config",
+ "uid": "externalsecret-uid-456",
+ },
+ },
+ // Mirror has finalizers (from before fix or external modification)
+ "finalizers": []interface{}{
+ "some-finalizer",
+ },
+ },
+ "spec": map[string]interface{}{
+ "basicAuth": map[string]interface{}{
+ "secret": "old-secret",
+ },
+ },
+ },
+ }
+
+ source := &unstructured.Unstructured{
+ Object: map[string]interface{}{
+ "apiVersion": "traefik.io/v1alpha1",
+ "kind": "Middleware",
+ "metadata": map[string]interface{}{
+ "name": "test-middleware",
+ "namespace": "default",
+ "generation": int64(2),
+ },
+ "spec": map[string]interface{}{
+ "basicAuth": map[string]interface{}{
+ "secret": "new-secret",
+ },
+ },
+ },
+ }
+
+ err := UpdateMirror(mirror, source)
+ require.NoError(t, err)
+
+ // CRITICAL: Verify ownerReferences are cleared from mirror
+ ownerRefs := mirror.GetOwnerReferences()
+ assert.Nil(t, ownerRefs, "mirror should not have ownerReferences after update")
+
+ // CRITICAL: Verify finalizers are cleared from mirror
+ finalizers := mirror.GetFinalizers()
+ assert.Nil(t, finalizers, "mirror should not have finalizers after update")
+
+ // Verify spec was updated
+ secret, found, err := unstructured.NestedString(mirror.Object, "spec", "basicAuth", "secret")
+ require.NoError(t, err)
+ require.True(t, found)
+ assert.Equal(t, "new-secret", secret)
+
+ // Verify hash was updated
+ assert.NotEqual(t, "oldhash", mirror.GetAnnotations()[constants.AnnotationSourceContentHash])
+}
+
func TestUpdateMirror_Secret(t *testing.T) {
mirror := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
diff --git a/pkg/controller/source_reconciler.go b/pkg/controller/source_reconciler.go
index 9e739a3..7f91497 100644
--- a/pkg/controller/source_reconciler.go
+++ b/pkg/controller/source_reconciler.go
@@ -131,9 +131,9 @@ func (r *SourceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
"errors", errorCount,
"total", len(targetNamespaces))
- // Requeue if there were errors
+ // Return error if there were errors (controller-runtime will automatically requeue with exponential backoff)
if errorCount > 0 {
- return ctrl.Result{Requeue: true}, fmt.Errorf("failed to reconcile %d/%d mirrors", errorCount, len(targetNamespaces))
+ return ctrl.Result{}, fmt.Errorf("failed to reconcile %d/%d mirrors", errorCount, len(targetNamespaces))
}
return ctrl.Result{}, nil