Skip to content

Commit 4fd1847

Browse files
authored
fix: Server-side diff shows incorrect diffs for list related changes (#688)
* fix: Server-side diff shows incorrect diffs for list related changes Signed-off-by: Peter Jiang <[email protected]> * Update docs for removeWebHookMutation Signed-off-by: Peter Jiang <[email protected]> * Update docs Signed-off-by: Peter Jiang <[email protected]> * Update docs Signed-off-by: Peter Jiang <[email protected]> --------- Signed-off-by: Peter Jiang <[email protected]>
1 parent 65db274 commit 4fd1847

10 files changed

+891
-47
lines changed

pkg/diff/diff.go

Lines changed: 56 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -193,13 +193,13 @@ func serverSideDiff(config, live *unstructured.Unstructured, opts ...Option) (*D
193193
return buildDiffResult(predictedLiveBytes, liveBytes), nil
194194
}
195195

196-
// removeWebhookMutation will compare the predictedLive with live to identify
197-
// changes done by mutation webhooks. Webhook mutations are identified by finding
198-
// changes in predictedLive fields not associated with any manager in the
199-
// managedFields. All fields under this condition will be reverted with their state
200-
// from live. If the given predictedLive does not have the managedFields, an error
201-
// will be returned.
202-
func removeWebhookMutation(predictedLive, live *unstructured.Unstructured, gvkParser *managedfields.GvkParser, _ string) (*unstructured.Unstructured, error) {
196+
// removeWebhookMutation will compare the predictedLive with live to identify changes done by mutation webhooks.
197+
// Webhook mutations are removed from predictedLive by removing all fields which are not managed by the given 'manager'.
198+
// At this step, we will only have the fields that are managed by the given 'manager'.
199+
// It is then merged with the live state and re-assigned to predictedLive. This means that any
200+
// fields not managed by the specified manager will be reverted with their state from live, including any webhook mutations.
201+
// If the given predictedLive does not have the managedFields, an error will be returned.
202+
func removeWebhookMutation(predictedLive, live *unstructured.Unstructured, gvkParser *managedfields.GvkParser, manager string) (*unstructured.Unstructured, error) {
203203
plManagedFields := predictedLive.GetManagedFields()
204204
if len(plManagedFields) == 0 {
205205
return nil, fmt.Errorf("predictedLive for resource %s/%s must have the managedFields", predictedLive.GetKind(), predictedLive.GetName())
@@ -220,57 +220,42 @@ func removeWebhookMutation(predictedLive, live *unstructured.Unstructured, gvkPa
220220
return nil, fmt.Errorf("error converting live state from unstructured to %s: %w", gvk, err)
221221
}
222222

223-
// Compare the predicted live with the live resource
224-
comparison, err := typedLive.Compare(typedPredictedLive)
225-
if err != nil {
226-
return nil, fmt.Errorf("error comparing predicted resource to live resource: %w", err)
227-
}
223+
// Initialize an empty fieldpath.Set to aggregate managed fields for the specified manager
224+
managerFieldsSet := &fieldpath.Set{}
228225

229-
// Loop over all existing managers in predicted live resource to identify
230-
// fields mutated (in predicted live) not owned by any manager.
226+
// Iterate over all ManagedFields entries in predictedLive
231227
for _, mfEntry := range plManagedFields {
232-
mfs := &fieldpath.Set{}
233-
err := mfs.FromJSON(bytes.NewReader(mfEntry.FieldsV1.Raw))
228+
managedFieldsSet := &fieldpath.Set{}
229+
err := managedFieldsSet.FromJSON(bytes.NewReader(mfEntry.FieldsV1.Raw))
234230
if err != nil {
235231
return nil, fmt.Errorf("error building managedFields set: %w", err)
236232
}
237-
if comparison.Added != nil && !comparison.Added.Empty() {
238-
// exclude the added fields owned by this manager from the comparison
239-
comparison.Added = comparison.Added.Difference(mfs)
240-
}
241-
if comparison.Modified != nil && !comparison.Modified.Empty() {
242-
// exclude the modified fields owned by this manager from the comparison
243-
comparison.Modified = comparison.Modified.Difference(mfs)
244-
}
245-
if comparison.Removed != nil && !comparison.Removed.Empty() {
246-
// exclude the removed fields owned by this manager from the comparison
247-
comparison.Removed = comparison.Removed.Difference(mfs)
233+
if mfEntry.Manager == manager {
234+
// Union the fields with the aggregated set
235+
managerFieldsSet = managerFieldsSet.Union(managedFieldsSet)
248236
}
249237
}
250-
// At this point, comparison holds all mutations that aren't owned by any
251-
// of the existing managers.
252238

253-
if comparison.Added != nil && !comparison.Added.Empty() {
254-
// remove added fields that aren't owned by any manager
255-
typedPredictedLive = typedPredictedLive.RemoveItems(comparison.Added)
239+
if managerFieldsSet.Empty() {
240+
return nil, fmt.Errorf("no managed fields found for manager: %s", manager)
256241
}
257242

258-
if comparison.Modified != nil && !comparison.Modified.Empty() {
259-
liveModValues := typedLive.ExtractItems(comparison.Modified, typed.WithAppendKeyFields())
260-
// revert modified fields not owned by any manager
261-
typedPredictedLive, err = typedPredictedLive.Merge(liveModValues)
262-
if err != nil {
263-
return nil, fmt.Errorf("error reverting webhook modified fields in predicted live resource: %w", err)
264-
}
243+
predictedLiveFieldSet, err := typedPredictedLive.ToFieldSet()
244+
if err != nil {
245+
return nil, fmt.Errorf("error converting predicted live state to FieldSet: %w", err)
265246
}
266247

267-
if comparison.Removed != nil && !comparison.Removed.Empty() {
268-
liveRmValues := typedLive.ExtractItems(comparison.Removed, typed.WithAppendKeyFields())
269-
// revert removed fields not owned by any manager
270-
typedPredictedLive, err = typedPredictedLive.Merge(liveRmValues)
271-
if err != nil {
272-
return nil, fmt.Errorf("error reverting webhook removed fields in predicted live resource: %w", err)
273-
}
248+
// Remove fields from predicted live that are not managed by the provided manager
249+
nonArgoFieldsSet := predictedLiveFieldSet.Difference(managerFieldsSet)
250+
251+
// In case any of the removed fields cause schema violations, we will keep those fields
252+
nonArgoFieldsSet = safelyRemoveFieldsSet(typedPredictedLive, nonArgoFieldsSet)
253+
typedPredictedLive = typedPredictedLive.RemoveItems(nonArgoFieldsSet)
254+
255+
// Apply the predicted live state to the live state to get a diff without mutation webhook fields
256+
typedPredictedLive, err = typedLive.Merge(typedPredictedLive)
257+
if err != nil {
258+
return nil, fmt.Errorf("error applying predicted live to live state: %w", err)
274259
}
275260

276261
plu := typedPredictedLive.AsValue().Unstructured()
@@ -281,6 +266,31 @@ func removeWebhookMutation(predictedLive, live *unstructured.Unstructured, gvkPa
281266
return &unstructured.Unstructured{Object: pl}, nil
282267
}
283268

269+
// safelyRemoveFieldSet will validate if removing the fieldsToRemove set from predictedLive maintains
270+
// a valid schema. If removing a field in fieldsToRemove is invalid and breaks the schema, it is not safe
271+
// to remove and will be skipped from removal from predictedLive.
272+
func safelyRemoveFieldsSet(predictedLive *typed.TypedValue, fieldsToRemove *fieldpath.Set) *fieldpath.Set {
273+
// In some cases, we cannot remove fields due to violation of the predicted live schema. In such cases we validate the removal
274+
// of each field and only include it if the removal is valid.
275+
testPredictedLive := predictedLive.RemoveItems(fieldsToRemove)
276+
err := testPredictedLive.Validate()
277+
if err != nil {
278+
adjustedFieldsToRemove := fieldpath.NewSet()
279+
fieldsToRemove.Iterate(func(p fieldpath.Path) {
280+
singleFieldSet := fieldpath.NewSet(p)
281+
testSingleRemoval := predictedLive.RemoveItems(singleFieldSet)
282+
// Check if removing this single field maintains a valid schema
283+
if testSingleRemoval.Validate() == nil {
284+
// If valid, add this field to the adjusted set to remove
285+
adjustedFieldsToRemove.Insert(p)
286+
}
287+
})
288+
return adjustedFieldsToRemove
289+
}
290+
// If no violations, return the original set to remove
291+
return fieldsToRemove
292+
}
293+
284294
func jsonStrToUnstructured(jsonString string) (*unstructured.Unstructured, error) {
285295
res := make(map[string]any)
286296
err := json.Unmarshal([]byte(jsonString), &res)

pkg/diff/diff_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -972,6 +972,64 @@ func TestServerSideDiff(t *testing.T) {
972972
assert.Empty(t, liveSVC.Annotations[AnnotationLastAppliedConfig])
973973
assert.NotEmpty(t, predictedSVC.Labels["event"])
974974
})
975+
976+
t.Run("will include nested fields like ports and env", func(t *testing.T) {
977+
// given
978+
t.Parallel()
979+
liveState := StrToUnstructured(testdata.DeploymentNestedLiveYAMLSSD)
980+
desiredState := StrToUnstructured(testdata.DeploymentNestedConfigYAMLSSD)
981+
opts := buildOpts(testdata.DeploymentNestedPredictedLiveJSONSSD)
982+
983+
// when
984+
result, err := serverSideDiff(desiredState, liveState, opts...)
985+
986+
// then
987+
require.NoError(t, err)
988+
assert.NotNil(t, result)
989+
assert.True(t, result.Modified)
990+
991+
predictedDeploy := YamlToDeploy(t, result.PredictedLive)
992+
liveDeploy := YamlToDeploy(t, result.NormalizedLive)
993+
994+
// Check ports
995+
assert.Len(t, predictedDeploy.Spec.Template.Spec.Containers[0].Ports, 2)
996+
assert.Len(t, liveDeploy.Spec.Template.Spec.Containers[0].Ports, 1)
997+
assert.Equal(t, int32(80), predictedDeploy.Spec.Template.Spec.Containers[0].Ports[0].ContainerPort)
998+
assert.Equal(t, int32(443), predictedDeploy.Spec.Template.Spec.Containers[0].Ports[1].ContainerPort)
999+
1000+
// Check env
1001+
assert.Len(t, predictedDeploy.Spec.Template.Spec.Containers[0].Env, 2)
1002+
assert.Len(t, liveDeploy.Spec.Template.Spec.Containers[0].Env, 1)
1003+
assert.Equal(t, "ENV_VAR1", predictedDeploy.Spec.Template.Spec.Containers[0].Env[0].Name)
1004+
assert.Equal(t, "ENV_VAR2", predictedDeploy.Spec.Template.Spec.Containers[0].Env[1].Name)
1005+
})
1006+
1007+
t.Run("will add an extra container using kubectl apply and include mutation webhook", func(t *testing.T) {
1008+
// given
1009+
t.Parallel()
1010+
liveState := StrToUnstructured(testdata.DeploymentApplyLiveYAMLSSD)
1011+
desiredState := StrToUnstructured(testdata.DeploymentApplyConfigYAMLSSD)
1012+
opts := buildOpts(testdata.DeploymentApplyPredictedLiveJSONSSD)
1013+
1014+
// when
1015+
result, err := serverSideDiff(desiredState, liveState, opts...)
1016+
1017+
// then
1018+
require.NoError(t, err)
1019+
assert.NotNil(t, result)
1020+
assert.True(t, result.Modified)
1021+
1022+
predictedDeploy := YamlToDeploy(t, result.PredictedLive)
1023+
liveDeploy := YamlToDeploy(t, result.NormalizedLive)
1024+
1025+
// Check ports are shown in diff and ensure mutation webhook is not shown
1026+
assert.Len(t, predictedDeploy.Spec.Template.Spec.Containers[0].Ports, 2)
1027+
assert.Len(t, liveDeploy.Spec.Template.Spec.Containers[0].Ports, 1)
1028+
assert.Equal(t, int32(80), predictedDeploy.Spec.Template.Spec.Containers[0].Ports[0].ContainerPort)
1029+
assert.Equal(t, int32(40), predictedDeploy.Spec.Template.Spec.Containers[0].Ports[1].ContainerPort)
1030+
assert.Empty(t, predictedDeploy.Annotations[AnnotationLastAppliedConfig])
1031+
assert.Empty(t, liveDeploy.Annotations[AnnotationLastAppliedConfig])
1032+
})
9751033
}
9761034

9771035
func createSecret(data map[string]string) *unstructured.Unstructured {

pkg/diff/testdata/data.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,22 @@ var (
5050

5151
//go:embed ssd-service-predicted-live.json
5252
ServicePredictedLiveJSONSSD string
53+
54+
//go:embed ssd-deploy-nested-config.yaml
55+
DeploymentNestedConfigYAMLSSD string
56+
57+
//go:embed ssd-deploy-nested-live.yaml
58+
DeploymentNestedLiveYAMLSSD string
59+
60+
//go:embed ssd-deploy-nested-predicted-live.json
61+
DeploymentNestedPredictedLiveJSONSSD string
62+
63+
//go:embed ssd-deploy-with-manual-apply-config.yaml
64+
DeploymentApplyConfigYAMLSSD string
65+
66+
//go:embed ssd-deploy-with-manual-apply-live.yaml
67+
DeploymentApplyLiveYAMLSSD string
68+
69+
//go:embed ssd-deploy-with-manual-apply-predicted-live.json
70+
DeploymentApplyPredictedLiveJSONSSD string
5371
)
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
apiVersion: apps/v1
2+
kind: Deployment
3+
metadata:
4+
name: nested-test-deployment
5+
namespace: default
6+
labels:
7+
app: nested-test
8+
applications.argoproj.io/app-name: nested-app
9+
spec:
10+
replicas: 1
11+
selector:
12+
matchLabels:
13+
app: nested-test
14+
template:
15+
metadata:
16+
labels:
17+
app: nested-test
18+
spec:
19+
automountServiceAccountToken: false
20+
containers:
21+
- name: main-container
22+
image: 'nginx:latest'
23+
ports:
24+
- containerPort: 80
25+
name: http
26+
protocol: TCP
27+
- containerPort: 443
28+
name: https
29+
env:
30+
- name: ENV_VAR1
31+
value: "value1"
32+
- name: ENV_VAR2
33+
value: "value2"
34+
resources:
35+
limits:
36+
memory: 100Mi
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
apiVersion: apps/v1
2+
kind: Deployment
3+
metadata:
4+
name: nested-test-deployment
5+
namespace: default
6+
labels:
7+
app: nested-test
8+
applications.argoproj.io/app-name: nested-app
9+
annotations:
10+
deployment.kubernetes.io/revision: '1'
11+
managedFields:
12+
- apiVersion: apps/v1
13+
fieldsType: FieldsV1
14+
fieldsV1:
15+
f:metadata:
16+
f:labels:
17+
f:app: {}
18+
f:applications.argoproj.io/app-name: {}
19+
f:spec:
20+
f:replicas: {}
21+
f:selector: {}
22+
f:template:
23+
f:metadata:
24+
f:labels:
25+
f:app: {}
26+
f:spec:
27+
f:containers:
28+
k:{"name":"main-container"}:
29+
.: {}
30+
f:image: {}
31+
f:name: {}
32+
f:ports:
33+
.: {}
34+
k:{"containerPort":80,"protocol":"TCP"}:
35+
.: {}
36+
f:containerPort: {}
37+
f:name: {}
38+
f:protocol: {}
39+
f:env:
40+
.: {}
41+
k:{"name":"ENV_VAR1"}:
42+
.: {}
43+
f:name: {}
44+
f:value: {}
45+
manager: argocd-controller
46+
operation: Apply
47+
spec:
48+
replicas: 1
49+
selector:
50+
matchLabels:
51+
app: nested-test
52+
template:
53+
metadata:
54+
labels:
55+
app: nested-test
56+
spec:
57+
automountServiceAccountToken: false
58+
containers:
59+
- name: main-container
60+
image: 'nginx:latest'
61+
ports:
62+
- containerPort: 80
63+
name: http
64+
protocol: TCP
65+
env:
66+
- name: ENV_VAR1
67+
value: "value1"
68+
resources:
69+
limits:
70+
memory: "100Mi"

0 commit comments

Comments
 (0)