From fe4a1dadb308fbb6f72c3e416bea419964c0efa6 Mon Sep 17 00:00:00 2001
From: Dipti Pai
Date: Tue, 31 Mar 2026 11:44:56 -0700
Subject: [PATCH] Add support for drift detection ignore rules
Signed-off-by: Dipti Pai
---
api/v1/kustomization_types.go | 23 ++
api/v1/zz_generated.deepcopy.go | 32 +++
...mize.toolkit.fluxcd.io_kustomizations.yaml | 67 ++++++
docs/api/v1/kustomize.md | 85 ++++++++
docs/spec/v1/kustomizations.md | 107 ++++++++++
.../controller/kustomization_controller.go | 22 ++
.../kustomization_drift_ignore_test.go | 196 ++++++++++++++++++
7 files changed, 532 insertions(+)
create mode 100644 internal/controller/kustomization_drift_ignore_test.go
diff --git a/api/v1/kustomization_types.go b/api/v1/kustomization_types.go
index 945626983..a4620f66a 100644
--- a/api/v1/kustomization_types.go
+++ b/api/v1/kustomization_types.go
@@ -199,6 +199,13 @@ type KustomizationSpec struct {
// The expressions are evaluated only when Wait or HealthChecks are specified.
// +optional
HealthCheckExprs []kustomize.CustomHealthCheck `json:"healthCheckExprs,omitempty"`
+
+ // Ignore is a list of rules for specifying which changes to ignore
+ // during drift detection. These rules are applied to the resources managed
+ // by the Kustomization and are used to exclude specific JSON pointer paths
+ // from the drift detection and apply process.
+ // +optional
+ Ignore []IgnoreRule `json:"ignore,omitempty"`
}
// BuildMetadataOption defines the supported buildMetadata options.
@@ -225,6 +232,22 @@ type CommonMetadata struct {
Labels map[string]string `json:"labels,omitempty"`
}
+// IgnoreRule defines a rule to selectively disregard specific changes during
+// the drift detection process.
+type IgnoreRule struct {
+ // Paths is a list of JSON Pointer (RFC 6901) paths to be excluded from
+ // consideration in a Kubernetes object.
+ // +required
+ Paths []string `json:"paths"`
+
+ // Target is a selector for specifying Kubernetes objects to which this
+ // rule applies.
+ // If Target is not set, the Paths will be ignored for all Kubernetes
+ // objects within the manifest of the Kustomization.
+ // +optional
+ Target *kustomize.Selector `json:"target,omitempty"`
+}
+
// Decryption defines how decryption is handled for Kubernetes manifests.
type Decryption struct {
// Provider is the name of the decryption engine.
diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go
index 19dfacc25..b48fccea1 100644
--- a/api/v1/zz_generated.deepcopy.go
+++ b/api/v1/zz_generated.deepcopy.go
@@ -91,6 +91,31 @@ func (in *Decryption) DeepCopy() *Decryption {
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *IgnoreRule) DeepCopyInto(out *IgnoreRule) {
+ *out = *in
+ if in.Paths != nil {
+ in, out := &in.Paths, &out.Paths
+ *out = make([]string, len(*in))
+ copy(*out, *in)
+ }
+ if in.Target != nil {
+ in, out := &in.Target, &out.Target
+ *out = new(kustomize.Selector)
+ **out = **in
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IgnoreRule.
+func (in *IgnoreRule) DeepCopy() *IgnoreRule {
+ if in == nil {
+ return nil
+ }
+ out := new(IgnoreRule)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *Kustomization) DeepCopyInto(out *Kustomization) {
*out = *in
@@ -222,6 +247,13 @@ func (in *KustomizationSpec) DeepCopyInto(out *KustomizationSpec) {
*out = make([]kustomize.CustomHealthCheck, len(*in))
copy(*out, *in)
}
+ if in.Ignore != nil {
+ in, out := &in.Ignore, &out.Ignore
+ *out = make([]IgnoreRule, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KustomizationSpec.
diff --git a/config/crd/bases/kustomize.toolkit.fluxcd.io_kustomizations.yaml b/config/crd/bases/kustomize.toolkit.fluxcd.io_kustomizations.yaml
index d079db4d0..1f77cb8d5 100644
--- a/config/crd/bases/kustomize.toolkit.fluxcd.io_kustomizations.yaml
+++ b/config/crd/bases/kustomize.toolkit.fluxcd.io_kustomizations.yaml
@@ -231,6 +231,73 @@ spec:
- name
type: object
type: array
+ ignore:
+ description: |-
+ Ignore is a list of rules for specifying which changes to ignore
+ during drift detection. These rules are applied to the resources managed
+ by the Kustomization and are used to exclude specific JSON pointer paths
+ from the drift detection and apply process.
+ items:
+ description: |-
+ IgnoreRule defines a rule to selectively disregard specific changes during
+ the drift detection process.
+ properties:
+ paths:
+ description: |-
+ Paths is a list of JSON Pointer (RFC 6901) paths to be excluded from
+ consideration in a Kubernetes object.
+ items:
+ type: string
+ type: array
+ target:
+ description: |-
+ Target is a selector for specifying Kubernetes objects to which this
+ rule applies.
+ If Target is not set, the Paths will be ignored for all Kubernetes
+ objects within the manifest of the Kustomization.
+ properties:
+ annotationSelector:
+ description: |-
+ AnnotationSelector is a string that follows the label selection expression
+ https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#api
+ It matches with the resource annotations.
+ type: string
+ group:
+ description: |-
+ Group is the API group to select resources from.
+ Together with Version and Kind it is capable of unambiguously identifying and/or selecting resources.
+ https://github.com/kubernetes/community/blob/master/contributors/design-proposals/api-machinery/api-group.md
+ type: string
+ kind:
+ description: |-
+ Kind of the API Group to select resources from.
+ Together with Group and Version it is capable of unambiguously
+ identifying and/or selecting resources.
+ https://github.com/kubernetes/community/blob/master/contributors/design-proposals/api-machinery/api-group.md
+ type: string
+ labelSelector:
+ description: |-
+ LabelSelector is a string that follows the label selection expression
+ https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#api
+ It matches with the resource labels.
+ type: string
+ name:
+ description: Name to match resources with.
+ type: string
+ namespace:
+ description: Namespace to select resources from.
+ type: string
+ version:
+ description: |-
+ Version of the API Group to select resources from.
+ Together with Group and Kind it is capable of unambiguously identifying and/or selecting resources.
+ https://github.com/kubernetes/community/blob/master/contributors/design-proposals/api-machinery/api-group.md
+ type: string
+ type: object
+ required:
+ - paths
+ type: object
+ type: array
ignoreMissingComponents:
description: |-
IgnoreMissingComponents instructs the controller to ignore Components paths
diff --git a/docs/api/v1/kustomize.md b/docs/api/v1/kustomize.md
index baccead81..f29924825 100644
--- a/docs/api/v1/kustomize.md
+++ b/docs/api/v1/kustomize.md
@@ -442,6 +442,23 @@ health of custom resources using Common Expression Language (CEL).
The expressions are evaluated only when Wait or HealthChecks are specified.
+
+
+ignore
+
+
+[]IgnoreRule
+
+
+ |
+
+(Optional)
+ Ignore is a list of rules for specifying which changes to ignore
+during drift detection. These rules are applied to the resources managed
+by the Kustomization and are used to exclude specific JSON pointer paths
+from the drift detection and apply process.
+ |
+
@@ -647,6 +664,57 @@ field.
+
+
+(Appears on:
+KustomizationSpec)
+
+IgnoreRule defines a rule to selectively disregard specific changes during
+the drift detection process.
+
@@ -1038,6 +1106,23 @@ health of custom resources using Common Expression Language (CEL).
The expressions are evaluated only when Wait or HealthChecks are specified.
+
+
+ignore
+
+
+[]IgnoreRule
+
+
+ |
+
+(Optional)
+ Ignore is a list of rules for specifying which changes to ignore
+during drift detection. These rules are applied to the resources managed
+by the Kustomization and are used to exclude specific JSON pointer paths
+from the drift detection and apply process.
+ |
+
diff --git a/docs/spec/v1/kustomizations.md b/docs/spec/v1/kustomizations.md
index bd6e6f693..481d24628 100644
--- a/docs/spec/v1/kustomizations.md
+++ b/docs/spec/v1/kustomizations.md
@@ -893,6 +893,113 @@ kustomize.toolkit.fluxcd.io/force: enabled
This way, only the targeted resources are force-replaced when immutable field
changes are made. The annotation should be removed after the change is applied.
+### Ignore Rules
+
+`.spec.ignore` is an optional list used to selectively ignore changes
+to specific fields during drift detection and correction. This allows external
+controllers or tools to manage certain fields on Kubernetes resources without
+having those changes reverted by the kustomize-controller during reconciliation.
+
+Each item in the list must have the following fields:
+
+- `paths` (required): A list of [JSON Pointer (RFC 6901)](https://datatracker.ietf.org/doc/html/rfc6901)
+ paths to exclude from drift detection. These paths refer to specific fields
+ within the Kubernetes object manifest.
+- `target` (optional): A selector to scope the rule to specific Kubernetes
+ resources. If not set, the paths are ignored for all resources in the
+ Kustomization.
+
+**Warning:** Omitting the `target` selector causes the rule to match **all**
+objects managed by the Kustomization. Always scope rules to specific resources
+using `target` unless you intentionally want to ignore the specified paths
+across every resource.
+
+The `target` selector supports the following fields:
+
+| Field | Description |
+|----------------------|--------------------------------------|
+| `group` | API group (regex) |
+| `version` | API version (regex) |
+| `kind` | Resource kind (regex) |
+| `name` | Resource name (regex) |
+| `namespace` | Resource namespace (regex) |
+| `labelSelector` | Kubernetes label selector expression |
+| `annotationSelector` | Kubernetes annotation selector expression |
+
+**Note:** The `group`, `version`, `kind`, `name`, and `namespace` fields
+support regex patterns. The `labelSelector` and `annotationSelector` fields
+use the standard Kubernetes
+[label selector](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#label-selectors)
+syntax.
+
+**Note:** For JSON Pointer paths that contain `/` in the key name (e.g.
+annotation keys), the `/` must be escaped as `~1` per
+[RFC 6901](https://datatracker.ietf.org/doc/html/rfc6901#section-3).
+For example, the annotation `external-dns.alpha.kubernetes.io/hostname`
+would be referenced as `/metadata/annotations/external-dns.alpha.kubernetes.io~1hostname`.
+
+To ignore fields only on resources that match a target selector:
+
+```yaml
+---
+apiVersion: kustomize.toolkit.fluxcd.io/v1
+kind: Kustomization
+metadata:
+ name: app
+ namespace: flux-system
+spec:
+ # ...omitted for brevity
+ ignore:
+ - paths:
+ - "/spec/replicas"
+ target:
+ kind: Deployment
+ - paths:
+ - "/metadata/annotations/external-dns.alpha.kubernetes.io~1hostname"
+ target:
+ kind: Service
+ name: my-service
+ - paths:
+ - "/spec/template/spec/containers/0/resources"
+ target:
+ kind: Deployment
+ name: my-app
+```
+
+In the above example:
+
+- The `/spec/replicas` field is ignored on all Deployments, allowing
+ an HPA or other autoscaler to manage the replica count without
+ interference from the kustomize-controller.
+- The `external-dns.alpha.kubernetes.io/hostname` annotation is ignored
+ on a specific Service named `my-service`, allowing external-dns to
+ manage this annotation.
+- The entire `/resources` subtree under container spec is ignored on
+ a specific Deployment named `my-app`, allowing a VPA or other resource
+ management tool to adjust container resources.
+
+**Note:** Changes to ignored fields alone do not trigger a reconciliation.
+The controller excludes ignored paths when comparing the desired state against
+the live object, so modifications made by external controllers to those fields
+will not cause unnecessary applies or resource version bumps.
+
+**Important:** Drift ignore rules work with the
+[server-side apply](https://kubernetes.io/docs/reference/using-api/server-side-apply/)
+field ownership model. When a reconciliation is triggered (e.g. by a source
+revision change or drift in non-ignored fields),
+the controller resolves each ignored path using one of two strategies based on
+field ownership:
+
+- **Strip** — If the ignored field is owned by another Apply-type field manager
+ (e.g. another controller using server-side apply), the field is removed from
+ the apply payload. This relinquishes the controller's ownership and allows the
+ other manager to retain full control of the field.
+- **Adopt** — If the controller is the sole Apply-type field manager for the
+ field, the in-cluster value is copied into the apply payload. This preserves
+ the current value without reverting changes made by Update-type operations
+ (e.g. `kubectl patch`, `kubectl edit`, or client-go Update calls) while
+ keeping the controller's field ownership intact.
+
### KubeConfig (Remote clusters)
With the `.spec.kubeConfig` field a Kustomization
diff --git a/internal/controller/kustomization_controller.go b/internal/controller/kustomization_controller.go
index c6aa48de1..095d0008e 100644
--- a/internal/controller/kustomization_controller.go
+++ b/internal/controller/kustomization_controller.go
@@ -63,6 +63,7 @@ import (
"github.com/fluxcd/pkg/runtime/patch"
"github.com/fluxcd/pkg/runtime/statusreaders"
"github.com/fluxcd/pkg/ssa"
+ "github.com/fluxcd/pkg/ssa/jsondiff"
"github.com/fluxcd/pkg/ssa/normalize"
ssautil "github.com/fluxcd/pkg/ssa/utils"
"github.com/fluxcd/pkg/tar"
@@ -859,6 +860,27 @@ func (r *KustomizationReconciler) apply(ctx context.Context,
applyOpts.CustomStageKinds = r.CustomStageKinds
applyOpts.MigrateAPIVersion = r.MigrateAPIVersion
+ if len(obj.Spec.Ignore) > 0 {
+ ignoreRules := make([]jsondiff.IgnoreRule, len(obj.Spec.Ignore))
+ for i, rule := range obj.Spec.Ignore {
+ ignoreRules[i] = jsondiff.IgnoreRule{
+ Paths: rule.Paths,
+ }
+ if rule.Target != nil {
+ ignoreRules[i].Selector = &jsondiff.Selector{
+ Group: rule.Target.Group,
+ Version: rule.Target.Version,
+ Kind: rule.Target.Kind,
+ Name: rule.Target.Name,
+ Namespace: rule.Target.Namespace,
+ AnnotationSelector: rule.Target.AnnotationSelector,
+ LabelSelector: rule.Target.LabelSelector,
+ }
+ }
+ }
+ applyOpts.DriftIgnoreRules = ignoreRules
+ }
+
fieldManagers := []ssa.FieldManager{
{
// to undo changes made with 'kubectl apply --server-side --force-conflicts'
diff --git a/internal/controller/kustomization_drift_ignore_test.go b/internal/controller/kustomization_drift_ignore_test.go
new file mode 100644
index 000000000..6e404109d
--- /dev/null
+++ b/internal/controller/kustomization_drift_ignore_test.go
@@ -0,0 +1,196 @@
+/*
+Copyright 2024 The Flux authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package controller
+
+import (
+ "context"
+ "fmt"
+ "testing"
+ "time"
+
+ "github.com/fluxcd/pkg/apis/meta"
+ "github.com/fluxcd/pkg/testserver"
+ sourcev1 "github.com/fluxcd/source-controller/api/v1"
+ . "github.com/onsi/gomega"
+ corev1 "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/types"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+
+ kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1"
+)
+
+func TestKustomizationReconciler_DriftIgnoreRules(t *testing.T) {
+ g := NewWithT(t)
+ id := "drift-ignore-" + randStringRunes(5)
+ revision := "v1.0.0"
+
+ err := createNamespace(id)
+ g.Expect(err).NotTo(HaveOccurred(), "failed to create test namespace")
+
+ err = createKubeConfigSecret(id)
+ g.Expect(err).NotTo(HaveOccurred(), "failed to create kubeconfig secret")
+
+ manifests := func(name, data string) []testserver.File {
+ return []testserver.File{
+ {
+ Name: "configmap.yaml",
+ Body: fmt.Sprintf(`---
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: %[1]s
+data:
+ managed-key: "%[2]s"
+ ignored-key: "original"
+`, name, data),
+ },
+ }
+ }
+
+ artifact, err := testServer.ArtifactFromFiles(manifests(id, "v1"))
+ g.Expect(err).NotTo(HaveOccurred(), "failed to create artifact from files")
+
+ repositoryName := types.NamespacedName{
+ Name: fmt.Sprintf("drift-ignore-%s", randStringRunes(5)),
+ Namespace: id,
+ }
+
+ err = applyGitRepository(repositoryName, artifact, revision)
+ g.Expect(err).NotTo(HaveOccurred())
+
+ kustomizationKey := types.NamespacedName{
+ Name: fmt.Sprintf("drift-ignore-%s", randStringRunes(5)),
+ Namespace: id,
+ }
+ kustomization := &kustomizev1.Kustomization{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: kustomizationKey.Name,
+ Namespace: kustomizationKey.Namespace,
+ },
+ Spec: kustomizev1.KustomizationSpec{
+ Interval: metav1.Duration{Duration: reconciliationInterval},
+ Path: "./",
+ KubeConfig: &meta.KubeConfigReference{
+ SecretRef: &meta.SecretKeyReference{
+ Name: "kubeconfig",
+ },
+ },
+ SourceRef: kustomizev1.CrossNamespaceSourceReference{
+ Name: repositoryName.Name,
+ Namespace: repositoryName.Namespace,
+ Kind: sourcev1.GitRepositoryKind,
+ },
+ TargetNamespace: id,
+ Force: false,
+ Ignore: []kustomizev1.IgnoreRule{
+ {
+ Paths: []string{"/data/ignored-key"},
+ },
+ },
+ },
+ }
+
+ g.Expect(k8sClient.Create(context.Background(), kustomization)).To(Succeed())
+
+ resultK := &kustomizev1.Kustomization{}
+ resultCM := &corev1.ConfigMap{}
+
+ t.Run("creates configmap with initial data", func(t *testing.T) {
+ g.Eventually(func() bool {
+ _ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(kustomization), resultK)
+ return resultK.Status.LastAppliedRevision == revision
+ }, timeout, time.Second).Should(BeTrue())
+ logStatus(t, resultK)
+
+ kstatusCheck.CheckErr(ctx, resultK)
+ g.Expect(k8sClient.Get(context.Background(), types.NamespacedName{Name: id, Namespace: id}, resultCM)).Should(Succeed())
+ g.Expect(resultCM.Data["managed-key"]).To(Equal("v1"))
+ g.Expect(resultCM.Data["ignored-key"]).To(Equal("original"))
+ })
+
+ t.Run("out-of-band change to ignored field is preserved on re-reconcile", func(t *testing.T) {
+ // Modify the ignored field out-of-band.
+ patch := client.RawPatch(types.MergePatchType, []byte(`{"data":{"ignored-key":"modified-externally"}}`))
+ configMap := &corev1.ConfigMap{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: id,
+ Namespace: id,
+ },
+ }
+ err = k8sClient.Patch(context.Background(), configMap, patch)
+ g.Expect(err).NotTo(HaveOccurred())
+
+ // Verify the out-of-band change was applied.
+ g.Expect(k8sClient.Get(context.Background(), types.NamespacedName{Name: id, Namespace: id}, resultCM)).Should(Succeed())
+ g.Expect(resultCM.Data["ignored-key"]).To(Equal("modified-externally"))
+
+ // Trigger reconciliation by updating the source revision.
+ artifact, err = testServer.ArtifactFromFiles(manifests(id, "v2"))
+ g.Expect(err).NotTo(HaveOccurred())
+ revision = "v2.0.0"
+ err = applyGitRepository(repositoryName, artifact, revision)
+ g.Expect(err).NotTo(HaveOccurred())
+
+ // Wait for the new revision to be applied.
+ g.Eventually(func() bool {
+ _ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(kustomization), resultK)
+ return resultK.Status.LastAppliedRevision == revision
+ }, timeout, time.Second).Should(BeTrue())
+ logStatus(t, resultK)
+
+ kstatusCheck.CheckErr(ctx, resultK)
+
+ // Verify that the managed field was updated but the ignored field was preserved.
+ g.Expect(k8sClient.Get(context.Background(), types.NamespacedName{Name: id, Namespace: id}, resultCM)).Should(Succeed())
+ g.Expect(resultCM.Data["managed-key"]).To(Equal("v2"))
+ g.Expect(resultCM.Data["ignored-key"]).To(Equal("modified-externally"))
+ })
+
+ t.Run("non-ignored field change is reverted on re-reconcile", func(t *testing.T) {
+ // Modify the managed field out-of-band.
+ patch := client.RawPatch(types.MergePatchType, []byte(`{"data":{"managed-key":"tampered"}}`))
+ configMap := &corev1.ConfigMap{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: id,
+ Namespace: id,
+ },
+ }
+ err = k8sClient.Patch(context.Background(), configMap, patch)
+ g.Expect(err).NotTo(HaveOccurred())
+
+ // Trigger reconciliation by updating the source.
+ artifact, err = testServer.ArtifactFromFiles(manifests(id, "v3"))
+ g.Expect(err).NotTo(HaveOccurred())
+ revision = "v3.0.0"
+ err = applyGitRepository(repositoryName, artifact, revision)
+ g.Expect(err).NotTo(HaveOccurred())
+
+ g.Eventually(func() bool {
+ _ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(kustomization), resultK)
+ return resultK.Status.LastAppliedRevision == revision
+ }, timeout, time.Second).Should(BeTrue())
+ logStatus(t, resultK)
+
+ kstatusCheck.CheckErr(ctx, resultK)
+
+ // The managed field should be restored, the ignored field should still be preserved.
+ g.Expect(k8sClient.Get(context.Background(), types.NamespacedName{Name: id, Namespace: id}, resultCM)).Should(Succeed())
+ g.Expect(resultCM.Data["managed-key"]).To(Equal("v3"))
+ g.Expect(resultCM.Data["ignored-key"]).To(Equal("modified-externally"))
+ })
+}