-
Notifications
You must be signed in to change notification settings - Fork 318
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 all commits
115630c
6610be9
a460e71
efb7e0a
a59ce03
5d14d97
f5b13c2
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 | ||
|
Comment on lines
221
to
230
|
||
| } | ||
|
|
||
|
|
@@ -184,6 +239,18 @@ func createPatch(originalObj, currentObj runtime.Object, target *resource.Info) | |
| return patch, types.StrategicMergePatchType, err | ||
| } | ||
|
|
||
| func isPatchEmpty(patch []byte) bool { | ||
| return len(patch) == 0 || string(patch) == "{}" || string(patch) == "null" | ||
| } | ||
|
|
||
| func cleanMetadataForPatch(data []byte) ([]byte, error) { | ||
| objMap, err := deleteStatusAndTidyMetadata(data) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| return json.Marshal(objMap) | ||
| } | ||
|
Comment on lines
246
to
252
|
||
|
|
||
| func objectKey(r *resource.Info) string { | ||
| gvk := r.Object.GetObjectKind().GroupVersionKind() | ||
| return fmt.Sprintf("%s/%s/%s/%s", gvk.GroupVersion().String(), gvk.Kind, r.Namespace, r.Name) | ||
|
|
||
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.