Skip to content

Commit cc11ac4

Browse files
Lucaberjoelanfordhensur
authored
pkg/helm/release/manager.go: refactor to use ThreeWayMergePatch for native k8s objects (#2869)
A follow-up PR to #2808 and #2777. @joelanford In this PR we are using the Kubernetes strategic three-way merge patch for patches by the helm-operator for native Kubernetes objects. This ensures that we are using the correct merge strategy that is defined in the OpenAPI schema of the object. #2808 However, we do not have the required schema information for custom resources(CRs). So we need to fall back to the previous manual json-patch. To detect CRs we are using the same method as helm https://github.com/helm/helm/blob/b21b00978539ea8270e6b672bc1e6bc07af61475/pkg/kube/client.go#L391 Co-authored-by: Joe Lanford <[email protected]> Co-authored-by: Henning Surmeier <[email protected]>
1 parent 894b81b commit cc11ac4

File tree

3 files changed

+185
-52
lines changed

3 files changed

+185
-52
lines changed
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# entries is a list of entries to include in
2+
# release notes and/or the migration guide
3+
entries:
4+
- description: >
5+
In Helm-based operators, reconcile logic now uses three-way strategic merge patches
6+
for native kubernetes objects so that array patch strategies are correctly honored
7+
and applied.
8+
9+
# kind is one of:
10+
# - addition
11+
# - change
12+
# - deprecation
13+
# - removal
14+
# - bugfix
15+
kind: change
16+
17+
# Is this a breaking change?
18+
breaking: false
19+

pkg/helm/release/manager.go

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ import (
2323
"strings"
2424

2525
"gomodules.xyz/jsonpatch/v3"
26+
helmkube "helm.sh/helm/v3/pkg/kube"
27+
apiextv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1"
28+
2629
"helm.sh/helm/v3/pkg/action"
2730
cpb "helm.sh/helm/v3/pkg/chart"
2831
"helm.sh/helm/v3/pkg/kube"
@@ -33,6 +36,7 @@ import (
3336
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
3437
"k8s.io/apimachinery/pkg/runtime"
3538
apitypes "k8s.io/apimachinery/pkg/types"
39+
"k8s.io/apimachinery/pkg/util/strategicpatch"
3640
"k8s.io/cli-runtime/pkg/resource"
3741
"k8s.io/client-go/rest"
3842

@@ -241,52 +245,87 @@ func reconcileRelease(ctx context.Context, kubeClient kube.Interface, expectedMa
241245
}
242246
return expectedInfos.Visit(func(expected *resource.Info, err error) error {
243247
if err != nil {
244-
return err
248+
return fmt.Errorf("visit error: %w", err)
245249
}
246250

247251
expectedClient := resource.NewClientWithOptions(expected.Client, func(r *rest.Request) {
248252
*r = *r.Context(ctx)
249253
})
250254
helper := resource.NewHelper(expectedClient, expected.Mapping)
251255

252-
existing, err := helper.Get(expected.Namespace, expected.Name, false)
256+
existing, err := helper.Get(expected.Namespace, expected.Name, expected.Export)
253257
if apierrors.IsNotFound(err) {
254258
if _, err := helper.Create(expected.Namespace, true, expected.Object,
255259
&metav1.CreateOptions{}); err != nil {
256260
return fmt.Errorf("create error: %s", err)
257261
}
258262
return nil
259263
} else if err != nil {
260-
return err
264+
return fmt.Errorf("could not get object: %w", err)
261265
}
262266

263-
patch, err := generatePatch(existing, expected.Object)
267+
// Replicate helm's patch creation, which will create a Three-Way-Merge patch for
268+
// native kubernetes Objects and fall back to a JSON merge patch for unstructured Objects such as CRDs
269+
// We also extend the JSON merge patch by ignoring "remove" operations for fields added by kubernetes
270+
// Reference in the helm source code:
271+
// https://github.com/helm/helm/blob/1c9b54ad7f62a5ce12f87c3ae55136ca20f09c98/pkg/kube/client.go#L392
272+
patch, patchType, err := createPatch(existing, expected)
264273
if err != nil {
265-
return fmt.Errorf("failed to marshal JSON patch: %w", err)
274+
return fmt.Errorf("error creating patch: %w", err)
266275
}
267276

268277
if patch == nil {
278+
// nothing to do
269279
return nil
270280
}
271281

272-
_, err = helper.Patch(expected.Namespace, expected.Name, apitypes.JSONPatchType, patch, &metav1.PatchOptions{})
282+
_, err = helper.Patch(expected.Namespace, expected.Name, patchType, patch,
283+
&metav1.PatchOptions{})
273284
if err != nil {
274285
return fmt.Errorf("patch error: %w", err)
275286
}
276287
return nil
277288
})
278289
}
279290

280-
func generatePatch(existing, expected runtime.Object) ([]byte, error) {
291+
func createPatch(existing runtime.Object, expected *resource.Info) ([]byte, apitypes.PatchType, error) {
281292
existingJSON, err := json.Marshal(existing)
282293
if err != nil {
283-
return nil, err
294+
return nil, apitypes.StrategicMergePatchType, err
284295
}
285-
expectedJSON, err := json.Marshal(expected)
296+
expectedJSON, err := json.Marshal(expected.Object)
286297
if err != nil {
287-
return nil, err
298+
return nil, apitypes.StrategicMergePatchType, err
288299
}
289300

301+
// Get a versioned object
302+
versionedObject := helmkube.AsVersioned(expected)
303+
304+
// Unstructured objects, such as CRDs, may not have an not registered error
305+
// returned from ConvertToVersion. Anything that's unstructured should
306+
// use the jsonpatch.CreateMergePatch. Strategic Merge Patch is not supported
307+
// on objects like CRDs.
308+
_, isUnstructured := versionedObject.(runtime.Unstructured)
309+
310+
// On newer K8s versions, CRDs aren't unstructured but has this dedicated type
311+
_, isCRD := versionedObject.(*apiextv1beta1.CustomResourceDefinition)
312+
313+
if isUnstructured || isCRD {
314+
// fall back to generic JSON merge patch
315+
patch, err := createJSONMergePatch(existingJSON, expectedJSON)
316+
return patch, apitypes.JSONPatchType, err
317+
}
318+
319+
patchMeta, err := strategicpatch.NewPatchMetaFromStruct(versionedObject)
320+
if err != nil {
321+
return nil, apitypes.StrategicMergePatchType, err
322+
}
323+
324+
patch, err := strategicpatch.CreateThreeWayMergePatch(expectedJSON, expectedJSON, existingJSON, patchMeta, true)
325+
return patch, apitypes.StrategicMergePatchType, err
326+
}
327+
328+
func createJSONMergePatch(existingJSON, expectedJSON []byte) ([]byte, error) {
290329
ops, err := jsonpatch.CreatePatch(existingJSON, expectedJSON)
291330
if err != nil {
292331
return nil, err

pkg/helm/release/manager_test.go

Lines changed: 117 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,25 @@
1515
package release
1616

1717
import (
18-
"encoding/json"
1918
"testing"
2019

21-
"github.com/stretchr/testify/assert"
2220
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
21+
apitypes "k8s.io/apimachinery/pkg/types"
22+
"k8s.io/cli-runtime/pkg/resource"
23+
24+
appsv1 "k8s.io/api/apps/v1"
25+
v1 "k8s.io/api/core/v1"
26+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
27+
28+
"github.com/stretchr/testify/assert"
29+
"k8s.io/apimachinery/pkg/runtime"
2330
)
2431

25-
func newTestDeployment(containers []interface{}) *unstructured.Unstructured {
32+
func newTestUnstructured(containers []interface{}) *unstructured.Unstructured {
2633
return &unstructured.Unstructured{
2734
Object: map[string]interface{}{
28-
"kind": "Deployment",
29-
"apiVersion": "apps/v1",
35+
"kind": "MyResource",
36+
"apiVersion": "myApi",
3037
"metadata": map[string]interface{}{
3138
"name": "test",
3239
"namespace": "ns",
@@ -42,99 +49,167 @@ func newTestDeployment(containers []interface{}) *unstructured.Unstructured {
4249
}
4350
}
4451

45-
func TestManagerGeneratePatch(t *testing.T) {
52+
func newTestDeployment(containers []v1.Container) *appsv1.Deployment {
53+
return &appsv1.Deployment{
54+
TypeMeta: metav1.TypeMeta{Kind: "Deployment", APIVersion: "apps/v1"},
55+
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "ns"},
56+
Spec: appsv1.DeploymentSpec{
57+
Template: v1.PodTemplateSpec{
58+
Spec: v1.PodSpec{
59+
Containers: containers,
60+
},
61+
},
62+
},
63+
}
64+
}
65+
66+
func TestManagerGenerateStrategicMergePatch(t *testing.T) {
4667

4768
tests := []struct {
48-
o1 *unstructured.Unstructured
49-
o2 *unstructured.Unstructured
50-
patch []map[string]interface{}
69+
o1 runtime.Object
70+
o2 runtime.Object
71+
patch string
72+
patchType apitypes.PatchType
5173
}{
5274
{
53-
o1: newTestDeployment([]interface{}{
75+
o1: newTestUnstructured([]interface{}{
5476
map[string]interface{}{
5577
"name": "test1",
5678
},
5779
map[string]interface{}{
5880
"name": "test2",
5981
},
6082
}),
61-
o2: newTestDeployment([]interface{}{
83+
o2: newTestUnstructured([]interface{}{
6284
map[string]interface{}{
6385
"name": "test1",
6486
},
6587
}),
66-
patch: []map[string]interface{}{},
88+
patch: ``,
89+
patchType: apitypes.JSONPatchType,
6790
},
6891
{
69-
o1: newTestDeployment([]interface{}{
92+
o1: newTestUnstructured([]interface{}{
7093
map[string]interface{}{
7194
"name": "test1",
7295
},
7396
}),
74-
o2: newTestDeployment([]interface{}{
97+
o2: newTestUnstructured([]interface{}{
7598
map[string]interface{}{
7699
"name": "test1",
77100
},
78101
map[string]interface{}{
79102
"name": "test2",
80103
},
81104
}),
82-
patch: []map[string]interface{}{
83-
{
84-
"op": "add",
85-
"path": "/spec/template/spec/containers/1",
86-
"value": map[string]interface{}{
87-
"name": string("test2"),
88-
},
89-
},
90-
},
105+
patch: `[{"op":"add","path":"/spec/template/spec/containers/1","value":{"name":"test2"}}]`,
106+
patchType: apitypes.JSONPatchType,
91107
},
92108
{
93-
o1: newTestDeployment([]interface{}{
109+
o1: newTestUnstructured([]interface{}{
94110
map[string]interface{}{
95111
"name": "test1",
96112
},
97113
}),
98-
o2: newTestDeployment([]interface{}{
114+
o2: newTestUnstructured([]interface{}{
99115
map[string]interface{}{
100116
"name": "test1",
101117
"test": nil,
102118
},
103119
}),
104-
patch: []map[string]interface{}{},
120+
patch: ``,
121+
patchType: apitypes.JSONPatchType,
105122
},
106123
{
107-
o1: newTestDeployment([]interface{}{
124+
o1: newTestUnstructured([]interface{}{
108125
map[string]interface{}{
109126
"name": "test1",
110127
},
111128
}),
112-
o2: newTestDeployment([]interface{}{
129+
o2: newTestUnstructured([]interface{}{
113130
map[string]interface{}{
114131
"name": "test2",
115132
},
116133
}),
117-
patch: []map[string]interface{}{
118-
{
119-
"op": "replace",
120-
"path": "/spec/template/spec/containers/0/name",
121-
"value": "test2",
134+
patch: `[{"op":"replace","path":"/spec/template/spec/containers/0/name","value":"test2"}]`,
135+
patchType: apitypes.JSONPatchType,
136+
},
137+
{
138+
o1: newTestDeployment([]v1.Container{
139+
{Name: "test1"},
140+
{Name: "test2"},
141+
}),
142+
o2: newTestDeployment([]v1.Container{
143+
{Name: "test1"},
144+
}),
145+
patch: `{"spec":{"template":{"spec":{"$setElementOrder/containers":[{"name":"test1"}]}}}}`, //nolint:lll
146+
patchType: apitypes.StrategicMergePatchType,
147+
},
148+
{
149+
o1: newTestDeployment([]v1.Container{
150+
{Name: "test1"},
151+
}),
152+
o2: newTestDeployment([]v1.Container{
153+
{Name: "test1"},
154+
{Name: "test2"},
155+
}),
156+
patch: `{"spec":{"template":{"spec":{"$setElementOrder/containers":[{"name":"test1"},{"name":"test2"}],"containers":[{"name":"test2","resources":{}}]}}}}`, //nolint:lll
157+
patchType: apitypes.StrategicMergePatchType,
158+
},
159+
{
160+
o1: newTestDeployment([]v1.Container{
161+
{Name: "test1"},
162+
}),
163+
o2: newTestDeployment([]v1.Container{
164+
{Name: "test1", LivenessProbe: nil},
165+
}),
166+
patch: `{}`,
167+
patchType: apitypes.StrategicMergePatchType,
168+
},
169+
{
170+
o1: newTestDeployment([]v1.Container{
171+
{Name: "test1"},
172+
}),
173+
o2: newTestDeployment([]v1.Container{
174+
{Name: "test2"},
175+
}),
176+
patch: `{"spec":{"template":{"spec":{"$setElementOrder/containers":[{"name":"test2"}],"containers":[{"name":"test2","resources":{}}]}}}}`, //nolint:lll
177+
patchType: apitypes.StrategicMergePatchType,
178+
},
179+
{
180+
o1: &appsv1.Deployment{
181+
TypeMeta: metav1.TypeMeta{Kind: "Deployment", APIVersion: "apps/v1"},
182+
ObjectMeta: metav1.ObjectMeta{
183+
Name: "test",
184+
Namespace: "ns",
185+
Annotations: map[string]string{
186+
"testannotation": "testvalue",
187+
},
122188
},
189+
Spec: appsv1.DeploymentSpec{},
123190
},
191+
o2: &appsv1.Deployment{
192+
TypeMeta: metav1.TypeMeta{Kind: "Deployment", APIVersion: "apps/v1"},
193+
ObjectMeta: metav1.ObjectMeta{
194+
Name: "test",
195+
Namespace: "ns",
196+
},
197+
Spec: appsv1.DeploymentSpec{},
198+
},
199+
patch: `{}`, //nolint:lll
200+
patchType: apitypes.StrategicMergePatchType,
124201
},
125202
}
126203

127204
for _, test := range tests {
128-
diff, err := generatePatch(test.o1, test.o2)
129-
assert.NoError(t, err)
130205

131-
if len(test.patch) == 0 {
132-
assert.Equal(t, 0, len(test.patch))
133-
} else {
134-
x := []map[string]interface{}{}
135-
err = json.Unmarshal(diff, &x)
136-
assert.NoError(t, err)
137-
assert.Equal(t, test.patch, x)
206+
o2Info := &resource.Info{
207+
Object: test.o2,
138208
}
209+
210+
diff, patchType, err := createPatch(test.o1, o2Info)
211+
assert.NoError(t, err)
212+
assert.Equal(t, test.patchType, patchType)
213+
assert.Equal(t, test.patch, string(diff))
139214
}
140215
}

0 commit comments

Comments
 (0)