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