@@ -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+
488690func TestNormalizeTargetResourcesWithList (t * testing.T ) {
489691 type fixture struct {
490692 comparisonResult * comparisonResult
0 commit comments