-
Notifications
You must be signed in to change notification settings - Fork 320
fix: detect manual changes on CRDs and CRs with three-way-merge #923
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 6 commits
115630c
6610be9
a460e71
efb7e0a
a59ce03
5d14d97
f5b13c2
2ff4b31
1076d7d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -170,8 +170,63 @@ func createPatch(originalObj, currentObj runtime.Object, target *resource.Info) | |||||||||||
| _, isCRD := versionedObject.(*apiextv1.CustomResourceDefinition) | ||||||||||||
|
|
||||||||||||
| if isUnstructured || isCRD { | ||||||||||||
| // fall back to generic JSON merge patch | ||||||||||||
| patch, err := jsonpatch.CreateMergePatch(oldData, newData) | ||||||||||||
| // For unstructured objects (CRDs, CRs), we need to perform a three-way merge | ||||||||||||
| // to detect manual changes made in the cluster. | ||||||||||||
| // | ||||||||||||
| // The approach is: | ||||||||||||
| // 1. Create a patch from old -> new (chart changes) | ||||||||||||
| // 2. Apply this patch to current (live state with manual changes) | ||||||||||||
| // 3. Create a patch from current -> merged result | ||||||||||||
| // 4. If chart changed (old != new), return step 3's patch | ||||||||||||
| // 5. If chart unchanged (old == new), build desired state by applying new onto current | ||||||||||||
| // (preserving live-only fields), then diff current -> desired to detect drift | ||||||||||||
|
|
||||||||||||
| // Clean metadata fields that shouldn't be compared (they're server-managed) | ||||||||||||
| // This prevents "resourceVersion: Invalid value: 0" errors when dry-running patches | ||||||||||||
| cleanedOldData, err := cleanMetadataForPatch(oldData) | ||||||||||||
| if err != nil { | ||||||||||||
| return nil, types.MergePatchType, fmt.Errorf("cleaning old metadata: %w", err) | ||||||||||||
| } | ||||||||||||
| cleanedNewData, err := cleanMetadataForPatch(newData) | ||||||||||||
| if err != nil { | ||||||||||||
| return nil, types.MergePatchType, fmt.Errorf("cleaning new metadata: %w", err) | ||||||||||||
| } | ||||||||||||
| cleanedCurrentData, err := cleanMetadataForPatch(currentData) | ||||||||||||
| if err != nil { | ||||||||||||
| return nil, types.MergePatchType, fmt.Errorf("cleaning current metadata: %w", err) | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| // Step 1: Create patch from old -> new (what the chart wants to change) | ||||||||||||
| chartChanges, err := jsonpatch.CreateMergePatch(cleanedOldData, cleanedNewData) | ||||||||||||
| if err != nil { | ||||||||||||
| return nil, types.MergePatchType, fmt.Errorf("creating chart changes patch: %w", err) | ||||||||||||
| } | ||||||||||||
|
Comment on lines
+199
to
+203
|
||||||||||||
|
|
||||||||||||
| // Check if chart actually changed anything | ||||||||||||
| chartChanged := !isPatchEmpty(chartChanges) | ||||||||||||
|
|
||||||||||||
| if chartChanged { | ||||||||||||
| // Step 2: Apply chart changes to current (merge chart changes with live state) | ||||||||||||
| mergedData, err := jsonpatch.MergePatch(cleanedCurrentData, chartChanges) | ||||||||||||
| if err != nil { | ||||||||||||
| return nil, types.MergePatchType, fmt.Errorf("applying chart changes to current: %w", err) | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| // Step 3: Create patch from current -> merged (what to apply to current) | ||||||||||||
| // This patch, when applied to current, will produce the merged result | ||||||||||||
| patch, err := jsonpatch.CreateMergePatch(cleanedCurrentData, mergedData) | ||||||||||||
| return patch, types.MergePatchType, err | ||||||||||||
|
||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| // Chart didn't change (old == new), but we need to detect if current diverges | ||||||||||||
| // from the chart state on chart-owned fields. | ||||||||||||
| // Build desired state by applying new onto current (preserves live-only additions), | ||||||||||||
| // then diff current -> desired to detect drift on chart-owned fields. | ||||||||||||
| desiredData, err := jsonpatch.MergePatch(cleanedCurrentData, cleanedNewData) | ||||||||||||
| if err != nil { | ||||||||||||
| return nil, types.MergePatchType, fmt.Errorf("building desired state: %w", err) | ||||||||||||
| } | ||||||||||||
| patch, err := jsonpatch.CreateMergePatch(cleanedCurrentData, desiredData) | ||||||||||||
| return patch, types.MergePatchType, err | ||||||||||||
|
||||||||||||
| return patch, types.MergePatchType, err | |
| if err != nil { | |
| return nil, types.MergePatchType, fmt.Errorf("creating patch from current to desired: %w", err) | |
| } | |
| return patch, types.MergePatchType, nil |
Copilot
AI
Feb 14, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
cleanMetadataForPatch largely duplicates deleteStatusAndTidyMetadata (same metadata/status stripping + annotation cleanup) but now exists as a separate implementation with different JSON codec and no direct unit tests. To avoid the two clean-up paths drifting over time, consider extracting a shared helper (or reusing deleteStatusAndTidyMetadata and re-marshalling) so metadata stripping behavior is defined in one place and covered by the existing util tests.
Copilot
AI
Feb 14, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
cleanMetadataForPatch largely overlaps with deleteStatusAndTidyMetadata (manifest/util.go) but removes a different set of metadata fields/annotations (e.g., meta.helm.sh/* and deployment.kubernetes.io/revision are handled in the other helper but not here). Having two slightly different “tidy metadata” implementations risks inconsistent behavior between patch generation and manifest rendering. Suggest reusing/extracting the existing helper (or at least aligning the exact fields removed) so the same metadata is ignored everywhere.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
cleanMetadataForPatchcallsdeleteStatusAndTidyMetadata, which assumes the JSON decodes to a map containing ametadataobject. IfcurrentObjis evernil(the function comment above still claims this is possible),json.Marshal(currentObj)becomesnull, which will cause a panic whendeleteStatusAndTidyMetadatadoesobjectMap["metadata"].(map[string]interface{}). Consider explicitly handlingnull/empty input incleanMetadataForPatch(e.g., treat it as{}or skip cleaning) and/or update the comment aboutcurrentObjpotentially being nil so behavior stays consistent and panic-free.