Skip to content

Commit 1b64ad3

Browse files
committed
fix: skip merge patch in normalizeTargetResources for resources without matching ignoreDifferences rules
Fixes #26588 Signed-off-by: Eugene Babichev <eugene.babichev@clickhouse.com>
1 parent 3f15cc6 commit 1b64ad3

5 files changed

Lines changed: 256 additions & 0 deletions

File tree

controller/sync.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,7 @@ func normalizeTargetResources(cr *comparisonResult) ([]*unstructured.Unstructure
429429
if err != nil {
430430
return nil, err
431431
}
432+
idc := diff.NewIgnoreDiffConfig(cr.diffConfig.Ignores(), cr.diffConfig.Overrides())
432433
patchedTargets := []*unstructured.Unstructured{}
433434
for idx, live := range cr.reconciliationResult.Live {
434435
normalizedTarget := normalized.Targets[idx]
@@ -442,6 +443,20 @@ func normalizeTargetResources(cr *comparisonResult) ([]*unstructured.Unstructure
442443
continue
443444
}
444445

446+
// Only compute and apply the merge patch if this resource has matching
447+
// ignoreDifferences rules. The merge patch copies ignored field values
448+
// from the live object into the target so they are not changed during
449+
// sync. Without matching rules there is nothing to copy, and applying
450+
// the patch anyway corrupts fields that use patchStrategy:"replace"
451+
// (e.g. PodDisruptionBudget.spec.selector) or arrays in CRDs that
452+
// fall back to JSON merge patch.
453+
gvk := normalizedTarget.GroupVersionKind()
454+
hasIgnore, _ := idc.HasIgnoreDifference(gvk.Group, gvk.Kind, normalizedTarget.GetName(), normalizedTarget.GetNamespace())
455+
if !hasIgnore {
456+
patchedTargets = append(patchedTargets, originalTarget)
457+
continue
458+
}
459+
445460
var lookupPatchMeta *strategicpatch.PatchMetaFromStruct
446461
versionedObject, err := scheme.Scheme.New(normalizedTarget.GroupVersionKind())
447462
if err == nil {

controller/sync_test.go

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,208 @@ func TestNormalizeTargetResources(t *testing.T) {
485485
})
486486
}
487487

488+
func TestNormalizeTargetResourcesPDB(t *testing.T) {
489+
type fixture struct {
490+
comparisonResult *comparisonResult
491+
}
492+
setup := func(t *testing.T, ignores []v1alpha1.ResourceIgnoreDifferences) *fixture {
493+
t.Helper()
494+
dc, err := diff.NewDiffConfigBuilder().
495+
WithDiffSettings(ignores, nil, true, normalizers.IgnoreNormalizerOpts{}).
496+
WithNoCache().
497+
Build()
498+
require.NoError(t, err)
499+
live := test.YamlToUnstructured(testdata.LivePDBYaml)
500+
target := test.YamlToUnstructured(testdata.TargetPDBYaml)
501+
return &fixture{
502+
&comparisonResult{
503+
reconciliationResult: sync.ReconciliationResult{
504+
Live: []*unstructured.Unstructured{live},
505+
Target: []*unstructured.Unstructured{target},
506+
},
507+
diffConfig: dc,
508+
},
509+
}
510+
}
511+
t.Run("will not corrupt PDB selector when no ignore rules match", func(t *testing.T) {
512+
// PDB.spec.selector has patchStrategy:"replace" which causes
513+
// strategic merge patch to always include the selector in the patch.
514+
// Without the fix, normalizeTargetResources overwrites the target
515+
// selector with the live selector, making sync a permanent no-op.
516+
f := setup(t, []v1alpha1.ResourceIgnoreDifferences{})
517+
518+
// when
519+
targets, err := normalizeTargetResources(f.comparisonResult)
520+
521+
// then
522+
require.NoError(t, err)
523+
require.Len(t, targets, 1)
524+
selector, ok, err := unstructured.NestedStringMap(targets[0].Object, "spec", "selector", "matchLabels")
525+
require.NoError(t, err)
526+
require.True(t, ok)
527+
// Target selector must be preserved, not overwritten with live values
528+
assert.Equal(t, "coredns", selector["app.kubernetes.io/instance"])
529+
assert.Equal(t, "coredns", selector["app.kubernetes.io/name"])
530+
assert.Equal(t, "kube-dns", selector["k8s-app"])
531+
// Must NOT contain the live-only label
532+
_, hasEksLabel := selector["eks.amazonaws.com/component"]
533+
assert.False(t, hasEksLabel, "target selector should not contain live-only label eks.amazonaws.com/component")
534+
})
535+
t.Run("will not corrupt PDB selector when unrelated ignore rules exist", func(t *testing.T) {
536+
// Even with ignore rules present, if they don't match the PDB,
537+
// the target must not be modified.
538+
ignores := []v1alpha1.ResourceIgnoreDifferences{
539+
{
540+
Group: "apps",
541+
Kind: "Deployment",
542+
JSONPointers: []string{"/spec/replicas"},
543+
},
544+
}
545+
f := setup(t, ignores)
546+
547+
// when
548+
targets, err := normalizeTargetResources(f.comparisonResult)
549+
550+
// then
551+
require.NoError(t, err)
552+
require.Len(t, targets, 1)
553+
selector, ok, err := unstructured.NestedStringMap(targets[0].Object, "spec", "selector", "matchLabels")
554+
require.NoError(t, err)
555+
require.True(t, ok)
556+
assert.Equal(t, "coredns", selector["app.kubernetes.io/instance"])
557+
assert.Equal(t, "coredns", selector["app.kubernetes.io/name"])
558+
assert.Equal(t, "kube-dns", selector["k8s-app"])
559+
_, hasEksLabel := selector["eks.amazonaws.com/component"]
560+
assert.False(t, hasEksLabel, "target selector should not contain live-only label eks.amazonaws.com/component")
561+
})
562+
t.Run("will return nil for target when target is nil (prune)", func(t *testing.T) {
563+
f := setup(t, []v1alpha1.ResourceIgnoreDifferences{})
564+
// Simulate a resource that exists live but has no target (will be pruned)
565+
f.comparisonResult.reconciliationResult.Target = []*unstructured.Unstructured{nil}
566+
567+
targets, err := normalizeTargetResources(f.comparisonResult)
568+
569+
require.NoError(t, err)
570+
require.Len(t, targets, 1)
571+
assert.Nil(t, targets[0])
572+
})
573+
t.Run("will preserve both resources in mixed list with partial ignore match", func(t *testing.T) {
574+
// A Deployment with matching ignore rules should get normalized,
575+
// while a PDB without matching rules should pass through unchanged.
576+
ignores := []v1alpha1.ResourceIgnoreDifferences{
577+
{
578+
Group: "apps",
579+
Kind: "Deployment",
580+
ManagedFieldsManagers: []string{"janitor"},
581+
},
582+
}
583+
dc, err := diff.NewDiffConfigBuilder().
584+
WithDiffSettings(ignores, nil, true, normalizers.IgnoreNormalizerOpts{}).
585+
WithNoCache().
586+
Build()
587+
require.NoError(t, err)
588+
liveDeployment := test.YamlToUnstructured(testdata.LiveDeploymentYaml)
589+
targetDeployment := test.YamlToUnstructured(testdata.TargetDeploymentYaml)
590+
livePDB := test.YamlToUnstructured(testdata.LivePDBYaml)
591+
targetPDB := test.YamlToUnstructured(testdata.TargetPDBYaml)
592+
593+
cr := &comparisonResult{
594+
reconciliationResult: sync.ReconciliationResult{
595+
Live: []*unstructured.Unstructured{liveDeployment, livePDB},
596+
Target: []*unstructured.Unstructured{targetDeployment, targetPDB},
597+
},
598+
diffConfig: dc,
599+
}
600+
601+
targets, err := normalizeTargetResources(cr)
602+
603+
require.NoError(t, err)
604+
require.Len(t, targets, 2)
605+
606+
// Deployment (idx 0): has matching ignore rule, gets normalized
607+
iksmVersion := targets[0].GetAnnotations()["iksm-version"]
608+
assert.Equal(t, "2.0", iksmVersion)
609+
610+
// PDB (idx 1): no matching ignore rule, selector must be preserved
611+
selector, ok, err := unstructured.NestedStringMap(targets[1].Object, "spec", "selector", "matchLabels")
612+
require.NoError(t, err)
613+
require.True(t, ok)
614+
assert.Equal(t, "coredns", selector["app.kubernetes.io/instance"])
615+
_, hasEksLabel := selector["eks.amazonaws.com/component"]
616+
assert.False(t, hasEksLabel, "PDB selector should not be corrupted by unrelated ignore rules")
617+
})
618+
t.Run("known limitation: PDB selector corrupted when ignore rules match the PDB", func(t *testing.T) {
619+
t.Skip("known limitation: merge patch overwrites patchStrategy:replace fields even when they are not in the ignore rule")
620+
// If the PDB has matching ignore rules (e.g. on annotations), the
621+
// merge patch runs and drags in spec.selector as collateral damage
622+
// due to patchStrategy:"replace". This documents the problem for a
623+
// follow-up fix.
624+
ignores := []v1alpha1.ResourceIgnoreDifferences{
625+
{
626+
Group: "policy",
627+
Kind: "PodDisruptionBudget",
628+
JSONPointers: []string{"/metadata/annotations"},
629+
},
630+
}
631+
f := setup(t, ignores)
632+
livePDB := test.YamlToUnstructured(testdata.LivePDBYaml)
633+
targetPDB := test.YamlToUnstructured(testdata.TargetPDBYaml)
634+
f.comparisonResult.reconciliationResult.Live = []*unstructured.Unstructured{livePDB}
635+
f.comparisonResult.reconciliationResult.Target = []*unstructured.Unstructured{targetPDB}
636+
637+
targets, err := normalizeTargetResources(f.comparisonResult)
638+
639+
require.NoError(t, err)
640+
require.Len(t, targets, 1)
641+
selector, ok, err := unstructured.NestedStringMap(targets[0].Object, "spec", "selector", "matchLabels")
642+
require.NoError(t, err)
643+
require.True(t, ok)
644+
// Ideally the selector should be preserved, but the merge patch
645+
// overwrites it because patchStrategy:"replace" forces inclusion.
646+
assert.Equal(t, "coredns", selector["app.kubernetes.io/instance"])
647+
})
648+
}
649+
650+
func TestNormalizeTargetResourcesCRD(t *testing.T) {
651+
// CRDs are not in the k8s scheme, so normalizeTargetResources falls back
652+
// to JSON merge patch which replaces arrays wholesale. Without matching
653+
// ignore rules the merge patch must be skipped entirely.
654+
setup := func(t *testing.T, ignores []v1alpha1.ResourceIgnoreDifferences) *comparisonResult {
655+
t.Helper()
656+
dc, err := diff.NewDiffConfigBuilder().
657+
WithDiffSettings(ignores, nil, true, normalizers.IgnoreNormalizerOpts{}).
658+
WithNoCache().
659+
Build()
660+
require.NoError(t, err)
661+
live := test.YamlToUnstructured(testdata.LiveHTTPProxy)
662+
target := test.YamlToUnstructured(testdata.TargetHTTPProxy)
663+
return &comparisonResult{
664+
reconciliationResult: sync.ReconciliationResult{
665+
Live: []*unstructured.Unstructured{live},
666+
Target: []*unstructured.Unstructured{target},
667+
},
668+
diffConfig: dc,
669+
}
670+
}
671+
t.Run("will not corrupt CRD arrays when no ignore rules match", func(t *testing.T) {
672+
cr := setup(t, []v1alpha1.ResourceIgnoreDifferences{})
673+
674+
targets, err := normalizeTargetResources(cr)
675+
676+
require.NoError(t, err)
677+
require.Len(t, targets, 1)
678+
// Target HTTPProxy has 2 descriptors; live has 1.
679+
// Without fix the JSON merge patch would replace the array with live's single entry.
680+
descriptors, ok, err := unstructured.NestedSlice(targets[0].Object, "spec", "routes")
681+
require.NoError(t, err)
682+
require.True(t, ok)
683+
route := descriptors[0].(map[string]any)
684+
global := route["rateLimitPolicy"].(map[string]any)["global"].(map[string]any)
685+
descs := global["descriptors"].([]any)
686+
assert.Len(t, descs, 2, "target should retain both descriptors, not be overwritten with live's single entry")
687+
})
688+
}
689+
488690
func TestNormalizeTargetResourcesWithList(t *testing.T) {
489691
type fixture struct {
490692
comparisonResult *comparisonResult

controller/testdata/data.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,10 @@ var (
3232

3333
//go:embed additional-image-replicas-deployment.yaml
3434
AdditionalImageReplicaDeploymentYaml string
35+
36+
//go:embed live-pdb.yaml
37+
LivePDBYaml string
38+
39+
//go:embed target-pdb.yaml
40+
TargetPDBYaml string
3541
)

controller/testdata/live-pdb.yaml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
apiVersion: policy/v1
2+
kind: PodDisruptionBudget
3+
metadata:
4+
name: coredns
5+
namespace: kube-system
6+
labels:
7+
eks.amazonaws.com/component: coredns
8+
annotations:
9+
argocd.argoproj.io/tracking-id: "coredns-wrapper:policy/PodDisruptionBudget:kube-system/coredns"
10+
spec:
11+
maxUnavailable: 1
12+
selector:
13+
matchLabels:
14+
eks.amazonaws.com/component: coredns
15+
k8s-app: kube-dns
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
apiVersion: policy/v1
2+
kind: PodDisruptionBudget
3+
metadata:
4+
name: coredns
5+
namespace: kube-system
6+
labels:
7+
app.kubernetes.io/instance: coredns
8+
app.kubernetes.io/managed-by: Helm
9+
app.kubernetes.io/name: coredns
10+
annotations:
11+
argocd.argoproj.io/tracking-id: "coredns-wrapper:policy/PodDisruptionBudget:kube-system/coredns"
12+
spec:
13+
maxUnavailable: 1
14+
selector:
15+
matchLabels:
16+
app.kubernetes.io/instance: coredns
17+
app.kubernetes.io/name: coredns
18+
k8s-app: kube-dns

0 commit comments

Comments
 (0)