@@ -150,7 +150,7 @@ func Diff(logger logr.Logger, plan Plan, bd *fleet.BundleDeployment, ns string,
150150
151151 // Some normalization operations, unlike those called from Diff (vendored ArgoCD code), must only be applied
152152 // to actual, in-cluster objects, not to desired ones.
153- emptied , err := normalizeActual (live , uActual , key , & patch )
153+ emptied , err := normalizeActual (live , desiredObj .( * unstructured. Unstructured ), uActual , key , & patch )
154154 if err != nil {
155155 errs = append (errs , err )
156156 continue
@@ -234,16 +234,282 @@ func newNormalizers(live objectset.ObjectByGVK, bd *fleet.BundleDeployment) (dif
234234}
235235
236236// normalizeActual encapsulates patch normalization operations which are only run against a live object (uActual),
237- // possibly requiring knowledge of other live resources.
237+ // possibly requiring knowledge of other live resources or the desired state .
238238func normalizeActual (
239239 live objectset.ObjectByGVK ,
240+ desired * unstructured.Unstructured ,
240241 uActual * unstructured.Unstructured ,
241242 key objectset.ObjectKey ,
242243 patch * []byte ,
243244) (bool , error ) {
245+ // Normalize webhook caBundle fields
246+ emptied , err := normalizeWebhookCABundlePatch (desired , uActual , patch )
247+ if err != nil || emptied {
248+ return emptied , err
249+ }
250+
244251 return normalizeReplicasPatch (live , uActual , key , patch )
245252}
246253
254+ // normalizeWebhookCABundlePatch removes caBundle fields from the patch when they are not present in the desired state.
255+ func normalizeWebhookCABundlePatch (
256+ desired * unstructured.Unstructured ,
257+ actual * unstructured.Unstructured ,
258+ patch * []byte ,
259+ ) (bool , error ) {
260+ if ! isWebhookConfiguration (actual .GroupVersionKind ()) {
261+ return false , nil
262+ }
263+
264+ patchData , webhooksList , err := extractWebhooksPatch (patch )
265+ if err != nil {
266+ return false , err
267+ }
268+ if patchData == nil {
269+ return false , nil
270+ }
271+
272+ desiredWebhooks , err := getDesiredWebhooks (desired )
273+ if err != nil {
274+ return false , err
275+ }
276+ if desiredWebhooks == nil {
277+ return false , nil
278+ }
279+
280+ // Check if this is a nested patch or wholesale replacement
281+ if hasNestedCABundleKey (webhooksList ) {
282+ // Process nested caBundle keys
283+ return processNestedCABundles (webhooksList , desiredWebhooks , patchData , patch )
284+ }
285+
286+ return handleWholesaleReplacement (actual , desiredWebhooks , patchData , patch )
287+ }
288+
289+ func isWebhookConfiguration (gvk schema.GroupVersionKind ) bool {
290+ return gvk .Group == "admissionregistration.k8s.io" &&
291+ (gvk .Kind == "MutatingWebhookConfiguration" || gvk .Kind == "ValidatingWebhookConfiguration" )
292+ }
293+
294+ func extractWebhooksPatch (patch * []byte ) (map [string ]any , []any , error ) {
295+ var patchData map [string ]any
296+ if err := json .Unmarshal (* patch , & patchData ); err != nil {
297+ return nil , nil , fmt .Errorf ("failed to unmarshal patch: %w" , err )
298+ }
299+
300+ webhooksPatch , hasWebhooks := patchData ["webhooks" ]
301+ if ! hasWebhooks {
302+ return nil , nil , nil
303+ }
304+
305+ webhooksList , ok := webhooksPatch .([]any )
306+ if ! ok {
307+ return nil , nil , nil
308+ }
309+
310+ return patchData , webhooksList , nil
311+ }
312+
313+ func getDesiredWebhooks (desired * unstructured.Unstructured ) ([]any , error ) {
314+ desiredWebhooks , found , err := unstructured .NestedSlice (desired .Object , "webhooks" )
315+ if err != nil {
316+ return nil , fmt .Errorf ("failed to get desired webhooks: %w" , err )
317+ }
318+ if ! found {
319+ return nil , nil
320+ }
321+ return desiredWebhooks , nil
322+ }
323+
324+ func hasNestedCABundleKey (webhooksList []any ) bool {
325+ for _ , webhookPatch := range webhooksList {
326+ webhookMap , ok := webhookPatch .(map [string ]any )
327+ if ! ok {
328+ continue
329+ }
330+ if clientConfig , ok := webhookMap ["clientConfig" ].(map [string ]any ); ok {
331+ if _ , hasCAbundle := clientConfig ["caBundle" ]; hasCAbundle {
332+ return true
333+ }
334+ }
335+ }
336+ return false
337+ }
338+
339+ func handleWholesaleReplacement (
340+ actual * unstructured.Unstructured ,
341+ desiredWebhooks []any ,
342+ patchData map [string ]any ,
343+ patch * []byte ,
344+ ) (bool , error ) {
345+ actualWebhooks , found , err := unstructured .NestedSlice (actual .Object , "webhooks" )
346+ if err != nil {
347+ return false , fmt .Errorf ("failed to get actual webhooks: %w" , err )
348+ }
349+ if ! found {
350+ return false , nil
351+ }
352+ if onlyCABundleDiffers (desiredWebhooks , actualWebhooks ) {
353+ delete (patchData , "webhooks" )
354+ * patch = []byte ("{}" )
355+ return true , nil
356+ }
357+ return false , nil
358+ }
359+
360+ func processNestedCABundles (
361+ webhooksList []any ,
362+ desiredWebhooks []any ,
363+ patchData map [string ]any ,
364+ patch * []byte ,
365+ ) (bool , error ) {
366+ modified := normalizeCABundlesInPatch (webhooksList , desiredWebhooks )
367+ if ! modified {
368+ return false , nil
369+ }
370+
371+ filteredWebhooks := filterEmptyWebhooks (webhooksList )
372+ updatePatchWithWebhooks (patchData , filteredWebhooks )
373+
374+ newPatch , err := json .Marshal (patchData )
375+ if err != nil {
376+ return false , fmt .Errorf ("failed to marshal normalized patch: %w" , err )
377+ }
378+
379+ * patch = newPatch
380+ return len (patchData ) == 0 || string (newPatch ) == "{}" , nil
381+ }
382+
383+ func normalizeCABundlesInPatch (webhooksList []any , desiredWebhooks []any ) bool {
384+ modified := false
385+ for i , webhookPatch := range webhooksList {
386+ webhookMap , ok := webhookPatch .(map [string ]any )
387+ if ! ok || i >= len (desiredWebhooks ) {
388+ continue
389+ }
390+
391+ clientConfigMap , ok := webhookMap ["clientConfig" ].(map [string ]any )
392+ if ! ok {
393+ continue
394+ }
395+
396+ if _ , hasCAbundle := clientConfigMap ["caBundle" ]; ! hasCAbundle {
397+ continue
398+ }
399+
400+ desiredWebhook , ok := desiredWebhooks [i ].(map [string ]any )
401+ if ! ok {
402+ continue
403+ }
404+
405+ if shouldRemoveCABundle (desiredWebhook ) {
406+ delete (clientConfigMap , "caBundle" )
407+ modified = true
408+ if len (clientConfigMap ) == 0 {
409+ delete (webhookMap , "clientConfig" )
410+ }
411+ }
412+ }
413+ return modified
414+ }
415+
416+ func shouldRemoveCABundle (desiredWebhook map [string ]any ) bool {
417+ desiredClientConfig , hasCC := desiredWebhook ["clientConfig" ]
418+ if ! hasCC {
419+ return true
420+ }
421+
422+ desiredCC , ok := desiredClientConfig .(map [string ]any )
423+ if ! ok {
424+ return false
425+ }
426+
427+ _ , desiredHasCABundle := desiredCC ["caBundle" ]
428+ return ! desiredHasCABundle
429+ }
430+
431+ func filterEmptyWebhooks (webhooksList []any ) []any {
432+ filteredWebhooks := make ([]any , 0 , len (webhooksList ))
433+ for _ , webhook := range webhooksList {
434+ webhookMap , ok := webhook .(map [string ]any )
435+ if ! ok {
436+ // Preserve non-map entries as-is to avoid data loss. The Kubernetes API expects
437+ // webhook entries to be objects, but we defensively handle other types in case
438+ // of malformed patches or future API changes.
439+ filteredWebhooks = append (filteredWebhooks , webhook )
440+ continue
441+ }
442+ if len (webhookMap ) > 0 {
443+ filteredWebhooks = append (filteredWebhooks , webhook )
444+ }
445+ }
446+ return filteredWebhooks
447+ }
448+
449+ func updatePatchWithWebhooks (patchData map [string ]any , filteredWebhooks []any ) {
450+ if len (filteredWebhooks ) > 0 {
451+ patchData ["webhooks" ] = filteredWebhooks
452+ } else {
453+ delete (patchData , "webhooks" )
454+ }
455+ }
456+
457+ // onlyCABundleDiffers checks if the only difference between desired and actual webhooks is the caBundle field
458+ // in webhooks where desired doesn't specify caBundle (controller-injected).
459+ func onlyCABundleDiffers (desired , actual []any ) bool {
460+ if len (desired ) != len (actual ) {
461+ return false
462+ }
463+
464+ for i := range desired {
465+ desiredWebhook , ok := desired [i ].(map [string ]any )
466+ if ! ok {
467+ return false
468+ }
469+ actualWebhook , ok := actual [i ].(map [string ]any )
470+ if ! ok {
471+ return false
472+ }
473+
474+ // Check if desired has caBundle - if yes, we need to detect drift, so return false
475+ if desiredCC , ok := desiredWebhook ["clientConfig" ].(map [string ]any ); ok {
476+ if _ , hasCABundle := desiredCC ["caBundle" ]; hasCABundle {
477+ // User-managed caBundle, drift matters
478+ return false
479+ }
480+ }
481+
482+ // Strip caBundle from actual for comparison
483+ actualCopy := make (map [string ]any )
484+ for k , v := range actualWebhook {
485+ if k == "clientConfig" {
486+ if cc , ok := v .(map [string ]any ); ok {
487+ ccCopy := make (map [string ]any )
488+ for cck , ccv := range cc {
489+ if cck != "caBundle" {
490+ ccCopy [cck ] = ccv
491+ }
492+ }
493+ if len (ccCopy ) > 0 {
494+ actualCopy [k ] = ccCopy
495+ }
496+ }
497+ } else {
498+ actualCopy [k ] = v
499+ }
500+ }
501+
502+ // Compare desired vs actual (with caBundle stripped)
503+ desiredJSON , err1 := json .Marshal (desiredWebhook )
504+ actualJSON , err2 := json .Marshal (actualCopy )
505+ if err1 != nil || err2 != nil || string (desiredJSON ) != string (actualJSON ) {
506+ return false
507+ }
508+ }
509+
510+ return true
511+ }
512+
247513// normalizeReplicasPatch handles removal of a diff patch's `.spec.replicas` field on a Deployment or a StatefulSet.
248514// Processing involves checking whether live objects include HPAs referencing the object.
249515// Both v1 and v2 of the autoscaling API are supported.
0 commit comments