Repository navigation
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
Changes from all commits
115630c
6610be9
a460e71
efb7e0a
a59ce03
5d14d97
f5b13c2
2ff4b31
1076d7d
3c2830c
548fcc6
a0a4264
06fc4a1
bafc142
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 |
|---|---|---|
|
|
@@ -581,10 +581,69 @@ func createPatch(originalObj, currentObj runtime.Object, target *resource.Info) | |
| } | ||
|
|
||
| if isUnstructured || isCRD { | ||
| // fall back to generic JSON merge patch | ||
| patch.data, 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, err | ||
| return nil, fmt.Errorf("cleaning old metadata: %w", err) | ||
| } | ||
| cleanedNewData, err := cleanMetadataForPatch(newData) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("cleaning new metadata: %w", err) | ||
| } | ||
| cleanedCurrentData, err := cleanMetadataForPatch(currentData) | ||
| if err != nil { | ||
| return nil, 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, fmt.Errorf("creating chart changes patch: %w", err) | ||
| } | ||
|
|
||
| // 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, 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.data, err = jsonpatch.CreateMergePatch(cleanedCurrentData, mergedData) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("creating patch from current to merged: %w", err) | ||
| } | ||
| patch.patchType = types.MergePatchType | ||
| return patch, nil | ||
| } | ||
|
|
||
| // 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, fmt.Errorf("building desired state: %w", err) | ||
| } | ||
| patch.data, err = jsonpatch.CreateMergePatch(cleanedCurrentData, desiredData) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("creating patch from current to desired: %w", err) | ||
| } | ||
| patch.patchType = types.MergePatchType | ||
| return patch, nil | ||
|
|
@@ -604,6 +663,21 @@ func createPatch(originalObj, currentObj runtime.Object, target *resource.Info) | |
| return patch, nil | ||
| } | ||
|
|
||
| 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 | ||
| } | ||
| if objMap == nil { | ||
| return []byte("null"), nil | ||
| } | ||
| return json.Marshal(objMap) | ||
| } | ||
|
Comment on lines
+670
to
+679
|
||
|
|
||
| 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.
The three-way merge implementation for unstructured/CRD objects here will still fail to detect drift when a field is unchanged between
oldDataandnewDatabut modified only in the live object (the scenario described in #917 for values explicitly set in the chart). In that casejsonpatch.CreateMergePatch(oldData, newData)returns an empty patch,mergedDataends up identical tocurrentData, andCreateMergePatch(currentData, mergedData)also returns an empty patch, so no diff is produced even though the live state diverges from the chart. This contradicts the PR description that manual changes to CRDs/CRs are now detected "just like" built-in resources, and it means the fix does not yet cover the core bug scenario. To align behavior with the strategic three-way merge path used for built-in types, the unstructured/CRD branch should compute a patch that also captures differences betweencurrentDataandnewDatafor chart-owned fields, even whennewDataequalsoldData, rather than only replayingold -> newchanges ontocurrentData.