Skip to content

Commit

Permalink
fix: Server side diff now works correctly with some fields removal
Browse files Browse the repository at this point in the history
Helps with argoproj/argo-cd#20792

Removed and modified sets may only contain the fields that changed, not including key fields like "name". This can cause merge to fail, since it expects those fields to be present if they are present in the predicted live.
Fortunately, we can inspect the set and derive the key fields necessary. Then they can be added to the set and used during a merge.
Also, have a new test which fails before the fix, but passes now.

Failure of the new test before the fix
```
            	Error:      	Received unexpected error:
            	            	error removing non config mutations for resource Deployment/nginx-deployment: error reverting webhook removed fields in predicted live resource: .spec.template.spec.containers: element 0: associative list with keys has an element that omits key field "name" (and doesn't have default value)
            	Test:       	TestServerSideDiff/will_test_removing_some_field_with_undoing_changes_done_by_webhook
```

Signed-off-by: Andrii Korotkov <[email protected]>
  • Loading branch information
andrii-korotkov-verkada committed Nov 19, 2024
1 parent 847cfc9 commit d6cc3eb
Show file tree
Hide file tree
Showing 6 changed files with 245 additions and 2 deletions.
25 changes: 25 additions & 0 deletions pkg/diff/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ func removeWebhookMutation(predictedLive, live *unstructured.Unstructured, gvkPa
}

if comparison.Modified != nil && !comparison.Modified.Empty() {
appendKeyFields(comparison.Modified)
liveModValues := typedLive.ExtractItems(comparison.Modified)
// revert modified fields not owned by any manager
typedPredictedLive, err = typedPredictedLive.Merge(liveModValues)
Expand All @@ -267,6 +268,7 @@ func removeWebhookMutation(predictedLive, live *unstructured.Unstructured, gvkPa
}

if comparison.Removed != nil && !comparison.Removed.Empty() {
appendKeyFields(comparison.Removed)
liveRmValues := typedLive.ExtractItems(comparison.Removed)
// revert removed fields not owned by any manager
typedPredictedLive, err = typedPredictedLive.Merge(liveRmValues)
Expand All @@ -283,6 +285,29 @@ func removeWebhookMutation(predictedLive, live *unstructured.Unstructured, gvkPa
return &unstructured.Unstructured{Object: pl}, nil
}

// appendKeyFields appends the key fields like "name" to the set elements which have some entries keyed by those fields.
// This is needed to merge properly, see https://github.com/argoproj/argo-cd/issues/20792.
func appendKeyFields(set *fieldpath.Set) {
keyFieldPaths := []fieldpath.Path{}
set.Iterate(func(path fieldpath.Path) {
for i := 0; i < len(path); i++ {
if path[i].Key != nil {
for _, keyField := range *path[i].Key {
pathPartCopy := make([]fieldpath.PathElement, i+1)
copy(pathPartCopy, path[:i+1])
newPath := append(pathPartCopy, fieldpath.PathElement{FieldName: &keyField.Name})
keyFieldPaths = append(keyFieldPaths, newPath)
}
}
}
})
for _, path := range keyFieldPaths {
if !set.Has(path) {
set.Insert(path)
}
}
}

func jsonStrToUnstructured(jsonString string) (*unstructured.Unstructured, error) {
res := make(map[string]interface{})
err := json.Unmarshal([]byte(jsonString), &res)
Expand Down
88 changes: 87 additions & 1 deletion pkg/diff/diff_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import (
"k8s.io/apimachinery/pkg/util/managedfields"
"k8s.io/klog/v2/textlogger"
openapiproto "k8s.io/kube-openapi/pkg/util/proto"
"sigs.k8s.io/structured-merge-diff/v4/fieldpath"
"sigs.k8s.io/structured-merge-diff/v4/value"
"sigs.k8s.io/yaml"
)

Expand Down Expand Up @@ -871,7 +873,7 @@ func TestStructuredMergeDiff(t *testing.T) {
// then
require.NoError(t, err)
assert.NotNil(t, result)
assert.False(t, result.Modified)
assert.True(t, result.Modified)
deploy := YamlToDeploy(t, result.PredictedLive)
assert.Len(t, deploy.Spec.Template.Spec.Containers, 1)
assert.Equal(t, "0", deploy.Spec.Template.Spec.Containers[0].Resources.Requests.Cpu().String())
Expand Down Expand Up @@ -933,6 +935,31 @@ func TestServerSideDiff(t *testing.T) {
assert.Empty(t, liveSVC.Annotations[AnnotationLastAppliedConfig])
assert.Empty(t, predictedSVC.Labels["event"])
})

t.Run("will test removing some field with undoing changes done by webhook", func(t *testing.T) {
// given
t.Parallel()
liveState := StrToUnstructured(testdata.DeploymentLiveYAML)
desiredState := StrToUnstructured(testdata.DeploymentConfigYAML)
opts := buildOpts(testdata.DeploymentPredictedLiveJSONSSD)

// when
result, err := serverSideDiff(desiredState, liveState, opts...)

// then
require.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.Modified)
predictedDeploy := YamlToDeploy(t, result.PredictedLive)
liveDeploy := YamlToDeploy(t, result.NormalizedLive)
assert.Len(t, predictedDeploy.Spec.Template.Spec.Containers, 1)
assert.Len(t, liveDeploy.Spec.Template.Spec.Containers, 1)
assert.Equal(t, "500m", predictedDeploy.Spec.Template.Spec.Containers[0].Resources.Requests.Cpu().String())
assert.Equal(t, "512Mi", predictedDeploy.Spec.Template.Spec.Containers[0].Resources.Requests.Memory().String())
assert.Equal(t, "500m", liveDeploy.Spec.Template.Spec.Containers[0].Resources.Requests.Cpu().String())
assert.Equal(t, "512Mi", liveDeploy.Spec.Template.Spec.Containers[0].Resources.Requests.Memory().String())
})

t.Run("will include mutation webhook modifications", func(t *testing.T) {
// given
t.Parallel()
Expand Down Expand Up @@ -1319,6 +1346,65 @@ spec:
}
}

func TestAppendKeyFields(t *testing.T) {
path1, err := fieldpath.MakePath(
"spec",
"containers",
&value.FieldList{value.Field{Name: "name", Value: value.NewValueInterface("test1")}},
"resources",
)
assert.NoError(t, err)
path2, err := fieldpath.MakePath(
"spec",
"containers",
&value.FieldList{value.Field{Name: "name", Value: value.NewValueInterface("test2")}},
"ports",
&value.FieldList{
value.Field{Name: "containerPort", Value: value.NewValueInterface(8080)},
value.Field{Name: "protocol", Value: value.NewValueInterface("TCP")},
},
"protocol",
)
assert.NoError(t, err)

set := fieldpath.NewSet(path1, path2)
appendKeyFields(set)
assert.Equal(t, set.Size(), 5)
assert.True(t, set.Has(path1))
assert.True(t, set.Has(path2))

path3, err := fieldpath.MakePath(
"spec",
"containers",
&value.FieldList{value.Field{Name: "name", Value: value.NewValueInterface("test1")}},
"name",
)
assert.NoError(t, err)
path4, err := fieldpath.MakePath(
"spec",
"containers",
&value.FieldList{value.Field{Name: "name", Value: value.NewValueInterface("test2")}},
"name",
)
assert.NoError(t, err)
path5, err := fieldpath.MakePath(
"spec",
"containers",
&value.FieldList{value.Field{Name: "name", Value: value.NewValueInterface("test2")}},
"ports",
&value.FieldList{
value.Field{Name: "containerPort", Value: value.NewValueInterface(8080)},
value.Field{Name: "protocol", Value: value.NewValueInterface("TCP")},
},
"containerPort",
)
assert.NoError(t, err)

assert.True(t, set.Has(path3))
assert.True(t, set.Has(path4))
assert.True(t, set.Has(path5))
}

func ExampleDiff() {
expectedResource := unstructured.Unstructured{}
if err := yaml.Unmarshal([]byte(`
Expand Down
3 changes: 3 additions & 0 deletions pkg/diff/testdata/data.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ var (
//go:embed smd-deploy-config.yaml
DeploymentConfigYAML string

//go:embed smd-deploy-predicted-live.json
DeploymentPredictedLiveJSONSSD string

// OpenAPIV2Doc is a binary representation of the openapi
// document available in a given k8s instance. To update
// this file the following commands can be executed:
Expand Down
1 change: 1 addition & 0 deletions pkg/diff/testdata/smd-deploy-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,4 @@ spec:
name: nginx
ports:
- containerPort: 80
protocol: TCP
10 changes: 9 additions & 1 deletion pkg/diff/testdata/smd-deploy-live.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ metadata:
'k:{"containerPort":80,"protocol":"TCP"}':
.: {}
'f:containerPort': {}
'f:protocol': {}
'f:resources':
'f:requests':
'f:cpu': {}
'f:memory': {}
manager: argocd-controller
operation: Apply
time: '2022-09-18T23:50:25Z'
Expand Down Expand Up @@ -120,7 +125,10 @@ spec:
ports:
- containerPort: 80
protocol: TCP
resources: {}
resources:
requests:
memory: 512Mi
cpu: 500m
terminationMessagePath: /dev/termination-log
terminationMessagePolicy: File
dnsPolicy: ClusterFirst
Expand Down
120 changes: 120 additions & 0 deletions pkg/diff/testdata/smd-deploy-predicted-live.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
{
"apiVersion": "apps/v1",
"kind": "Deployment",
"metadata": {
"labels": {
"app": "missing",
"applications.argoproj.io/app-name": "nginx",
"something-else": "bla"
},
"name": "nginx-deployment",
"namespace": "default",
"managedFields": [
{
"apiVersion": "apps/v1",
"fieldsType": "FieldsV1",
"fieldsV1": {
"f:metadata": {
"f:labels": {
"f:app": {},
"f:applications.argoproj.io/app-name": {},
"f:something-else": {}
}
},
"f:spec": {
"f:replicas": {},
"f:selector": {},
"f:template": {
"f:metadata": {
"f:labels": {
"f:app": {},
"f:applications.argoproj.io/app-name": {}
}
},
"f:spec": {
"f:containers": {
"k:{\"name\":\"nginx\"}": {
".": {},
"f:image": {},
"f:imagePullPolicy": {},
"f:livenessProbe": {
"f:exec": {
"f:command": {}
},
"f:initialDelaySeconds": {},
"f:periodSeconds": {}
},
"f:name": {},
"f:ports": {
"k:{\"containerPort\":80,\"protocol\":\"TCP\"}": {
".": {},
"f:containerPort": {},
"f:protocol": {}
}
},
"f:resources": {
"f:requests": {
"f:cpu": {},
"f:memory": {}
}
}
}
}
}
}
}
},
"manager": "argocd-controller",
"operation": "Apply",
"time": "2022-09-18T23:50:25Z"
}
]
},
"spec": {
"replicas": 2,
"selector": {
"matchLabels": {
"app": "nginx"
}
},
"template": {
"metadata": {
"labels": {
"app": "nginx",
"applications.argoproj.io/app-name": "nginx"
}
},
"spec": {
"containers": [
{
"image": "nginx:1.23.1",
"imagePullPolicy": "Never",
"livenessProbe": {
"exec": {
"command": [
"cat",
"non-existent-file"
]
},
"initialDelaySeconds": 5,
"periodSeconds": 180
},
"name": "nginx",
"ports": [
{
"containerPort": 80,
"protocol": "TCP"
}
],
"resources": {
"requests": {
"memory": "512Mi",
"cpu": "500m"
}
}
}
]
}
}
}
}

0 comments on commit d6cc3eb

Please sign in to comment.