Skip to content

Commit 8d4751d

Browse files
authored
Fix false-positive diffs for controller-injected webhook caBundle (#5369)
* Add conditional patch normalization in the function * fix diff tests and apply suggestions * test non webhook resource normalization
1 parent 93da04c commit 8d4751d

2 files changed

Lines changed: 671 additions & 2 deletions

File tree

internal/cmd/agent/deployer/desiredset/diff.go

Lines changed: 268 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
238238
func 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

Comments
 (0)