-
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 3 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,44 @@ 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) but current != new, return new->current patch to restore chart state | ||||||||||||
| // (i.e., detect and revert manual changes to chart-owned fields) | ||||||||||||
|
|
||||||||||||
| // Step 1: Create patch from old -> new (what the chart wants to change) | ||||||||||||
| chartChanges, err := jsonpatch.CreateMergePatch(oldData, newData) | ||||||||||||
| 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(currentData, 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(currentData, mergedData) | ||||||||||||
| return patch, types.MergePatchType, err | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| // Chart didn't change (old == new), but we need to detect if current diverges | ||||||||||||
| // from the chart state. This is the case where manual changes were made to | ||||||||||||
| // chart-owned fields. | ||||||||||||
| // Create a patch from current -> new to detect and restore drift | ||||||||||||
| patch, err := jsonpatch.CreateMergePatch(currentData, newData) | ||||||||||||
| 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 |
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,320 @@ | ||||||||||
| package manifest | ||||||||||
|
|
||||||||||
| import ( | ||||||||||
| "testing" | ||||||||||
|
|
||||||||||
| "github.com/stretchr/testify/require" | ||||||||||
| "k8s.io/apimachinery/pkg/api/meta" | ||||||||||
| "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" | ||||||||||
| "k8s.io/apimachinery/pkg/runtime" | ||||||||||
| "k8s.io/apimachinery/pkg/runtime/schema" | ||||||||||
| "k8s.io/apimachinery/pkg/types" | ||||||||||
| "k8s.io/cli-runtime/pkg/resource" | ||||||||||
| ) | ||||||||||
|
|
||||||||||
| // TestCreatePatchForUnstructured tests the three-way merge implementation for unstructured objects (CRDs, CRs). | ||||||||||
| // This tests the fix for issue #917 where manual changes to CRs were not being detected. | ||||||||||
| func TestCreatePatchForUnstructured(t *testing.T) { | ||||||||||
|
Comment on lines
+17
to
+19
|
||||||||||
| tests := []struct { | ||||||||||
| name string | ||||||||||
| original runtime.Object | ||||||||||
| current runtime.Object | ||||||||||
| target *resource.Info | ||||||||||
|
Comment on lines
+20
to
+24
|
||||||||||
| expectedChange bool | ||||||||||
| description string | ||||||||||
| }{ | ||||||||||
| { | ||||||||||
| name: "CR with manual annotation in current (chart doesn't change anything)", | ||||||||||
| original: &unstructured.Unstructured{ | ||||||||||
| Object: map[string]interface{}{ | ||||||||||
| "apiVersion": "keda.sh/v1alpha1", | ||||||||||
| "kind": "ScaledObject", | ||||||||||
| "metadata": map[string]interface{}{ | ||||||||||
| "name": "test-so", | ||||||||||
| "namespace": "default", | ||||||||||
| }, | ||||||||||
| "spec": map[string]interface{}{ | ||||||||||
| "maxReplicaCount": 10, | ||||||||||
| }, | ||||||||||
| }, | ||||||||||
| }, | ||||||||||
| current: &unstructured.Unstructured{ | ||||||||||
| Object: map[string]interface{}{ | ||||||||||
| "apiVersion": "keda.sh/v1alpha1", | ||||||||||
| "kind": "ScaledObject", | ||||||||||
| "metadata": map[string]interface{}{ | ||||||||||
| "name": "test-so", | ||||||||||
| "namespace": "default", | ||||||||||
| "annotations": map[string]interface{}{ | ||||||||||
| "manual-change": "true", | ||||||||||
| }, | ||||||||||
| }, | ||||||||||
| "spec": map[string]interface{}{ | ||||||||||
| "maxReplicaCount": 10, | ||||||||||
| }, | ||||||||||
| }, | ||||||||||
| }, | ||||||||||
| target: &resource.Info{ | ||||||||||
| Mapping: &meta.RESTMapping{ | ||||||||||
| GroupVersionKind: schema.GroupVersionKind{ | ||||||||||
| Group: "keda.sh", | ||||||||||
| Version: "v1alpha1", | ||||||||||
| Kind: "ScaledObject", | ||||||||||
| }, | ||||||||||
| }, | ||||||||||
| Object: &unstructured.Unstructured{ | ||||||||||
| Object: map[string]interface{}{ | ||||||||||
| "apiVersion": "keda.sh/v1alpha1", | ||||||||||
| "kind": "ScaledObject", | ||||||||||
| "metadata": map[string]interface{}{ | ||||||||||
| "name": "test-so", | ||||||||||
| "namespace": "default", | ||||||||||
| }, | ||||||||||
| "spec": map[string]interface{}{ | ||||||||||
| "maxReplicaCount": 10, | ||||||||||
| }, | ||||||||||
| }, | ||||||||||
| }, | ||||||||||
| }, | ||||||||||
| expectedChange: true, | ||||||||||
| description: "Manual annotation that is not in chart will cause diff to remove it (JSON merge patch semantics)", | ||||||||||
|
||||||||||
| expectedChange: true, | |
| description: "Manual annotation that is not in chart will cause diff to remove it (JSON merge patch semantics)", | |
| expectedChange: false, | |
| description: "Manual annotation not present in chart should be preserved; no effective change expected from three-way merge", |
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.
In the step-by-step comment for the unstructured/CRD three-way merge, step 5 says "return new->current patch to restore chart state", but the implementation below calls
jsonpatch.CreateMergePatch(currentData, newData), i.e. it creates a patch from current -> new. To avoid confusion for future readers, the comment should be updated to describe the same direction as the code (patch from current to new, applied to the current/live object).