-
Notifications
You must be signed in to change notification settings - Fork 1.3k
/
twowayspatchhelper.go
230 lines (197 loc) · 8.74 KB
/
twowayspatchhelper.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
/*
Copyright 2021 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package structuredmerge
import (
"bytes"
"context"
"encoding/json"
jsonpatch "github.com/evanphx/json-patch/v5"
"github.com/pkg/errors"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/cluster-api/internal/contract"
"sigs.k8s.io/cluster-api/internal/util/ssa"
"sigs.k8s.io/cluster-api/util"
)
// TwoWaysPatchHelper helps with a patch that yields the modified document when applied to the original document.
type TwoWaysPatchHelper struct {
client client.Client
// original holds the object to which the patch should apply to, to be used in the Patch method.
original client.Object
// patch holds the merge patch in json format.
patch []byte
// hasSpecChanges documents if the patch impacts the object spec
hasSpecChanges bool
}
// NewTwoWaysPatchHelper will return a patch that yields the modified document when applied to the original document
// using the two-ways merge algorithm.
// NOTE: In the case of ClusterTopologyReconciler, original is the current object, modified is the desired object, and
// the patch returns all the changes required to align current to what is defined in desired; fields not managed
// by the topology controller are going to be preserved without changes.
// NOTE: TwoWaysPatch is considered a minimal viable replacement for server side apply during topology dry run, with
// the following limitations:
// - TwoWaysPatch doesn't consider OpenAPI schema extension like +ListMap this can lead to false positive when topology
// dry run is simulating a change to an existing slice
// (TwoWaysPatch always revert external changes, like server side apply when +ListMap=atomic).
// - TwoWaysPatch doesn't consider existing metadata.managedFields, and this can lead to false negative when topology dry run
// is simulating a change to an existing object where the topology controller is dropping an opinion for a field
// (TwoWaysPatch always preserve dropped fields, like server side apply when the field has more than one manager).
// - TwoWaysPatch doesn't generate metadata.managedFields as server side apply does.
//
// NOTE: NewTwoWaysPatchHelper consider changes only in metadata.labels, metadata.annotation and spec; it also respects
// the ignorePath option (same as the server side apply helper).
func NewTwoWaysPatchHelper(original, modified client.Object, c client.Client, opts ...HelperOption) (*TwoWaysPatchHelper, error) {
helperOptions := &HelperOptions{}
helperOptions = helperOptions.ApplyOptions(opts)
helperOptions.AllowedPaths = []contract.Path{
{"metadata", "labels"},
{"metadata", "annotations"},
{"spec"}, // NOTE: The handling of managed path requires/assumes spec to be within allowed path.
}
// In case we are creating an object, we extend the set of allowed fields adding apiVersion, Kind
// metadata.name, metadata.namespace (who are required by the API server) and metadata.ownerReferences
// that gets set to avoid orphaned objects.
if util.IsNil(original) {
helperOptions.AllowedPaths = append(helperOptions.AllowedPaths,
contract.Path{"apiVersion"},
contract.Path{"kind"},
contract.Path{"metadata", "name"},
contract.Path{"metadata", "namespace"},
contract.Path{"metadata", "ownerReferences"},
)
}
// Convert the input objects to json; if original is nil, use empty object so the
// following logic works without panicking.
originalJSON, err := json.Marshal(original)
if err != nil {
return nil, errors.Wrap(err, "failed to marshal original object to json")
}
if util.IsNil(original) {
originalJSON = []byte("{}")
}
modifiedJSON, err := json.Marshal(modified)
if err != nil {
return nil, errors.Wrap(err, "failed to marshal modified object to json")
}
// Apply patch options including:
// - exclude paths (fields to not consider, e.g. status);
// - ignore paths (well known fields owned by something else, e.g. spec.controlPlaneEndpoint in the
// InfrastructureCluster object);
// NOTE: All the above options trigger changes in the modified object so the resulting two ways patch
// includes or not the specific change.
modifiedJSON, err = applyOptions(&applyOptionsInput{
original: originalJSON,
modified: modifiedJSON,
options: helperOptions,
})
if err != nil {
return nil, errors.Wrap(err, "failed to apply options to modified")
}
// Apply the modified object to the original one, merging the values of both;
// in case of conflicts, values from the modified object are preserved.
originalWithModifiedJSON, err := jsonpatch.MergePatch(originalJSON, modifiedJSON)
if err != nil {
return nil, errors.Wrap(err, "failed to apply modified json to original json")
}
// Compute the merge patch that will align the original object to the target
// state defined above.
twoWayPatch, err := jsonpatch.CreateMergePatch(originalJSON, originalWithModifiedJSON)
if err != nil {
return nil, errors.Wrap(err, "failed to create merge patch")
}
twoWayPatchMap := make(map[string]interface{})
if err := json.Unmarshal(twoWayPatch, &twoWayPatchMap); err != nil {
return nil, errors.Wrap(err, "failed to unmarshal two way merge patch")
}
// check if the changes impact the spec field.
hasSpecChanges := twoWayPatchMap["spec"] != nil
return &TwoWaysPatchHelper{
client: c,
patch: twoWayPatch,
hasSpecChanges: hasSpecChanges,
original: original,
}, nil
}
type applyOptionsInput struct {
original []byte
modified []byte
options *HelperOptions
}
// Apply patch options changing the modified object so the resulting two ways patch
// includes or not the specific change.
func applyOptions(in *applyOptionsInput) ([]byte, error) {
originalMap := make(map[string]interface{})
if err := json.Unmarshal(in.original, &originalMap); err != nil {
return nil, errors.Wrap(err, "failed to unmarshal original")
}
modifiedMap := make(map[string]interface{})
if err := json.Unmarshal(in.modified, &modifiedMap); err != nil {
return nil, errors.Wrap(err, "failed to unmarshal modified")
}
// drop changes for exclude paths (fields to not consider, e.g. status);
// Note: for everything not allowed it sets modified equal to original, so the generated patch doesn't include this change
if len(in.options.AllowedPaths) > 0 {
dropDiff(&dropDiffInput{
path: contract.Path{},
original: originalMap,
modified: modifiedMap,
shouldDropDiffFunc: ssa.IsPathNotAllowed(in.options.AllowedPaths),
})
}
// drop changes for ignore paths (well known fields owned by something else, e.g.
// spec.controlPlaneEndpoint in the InfrastructureCluster object);
// Note: for everything ignored it sets modified equal to original, so the generated patch doesn't include this change
if len(in.options.IgnorePaths) > 0 {
dropDiff(&dropDiffInput{
path: contract.Path{},
original: originalMap,
modified: modifiedMap,
shouldDropDiffFunc: ssa.IsPathIgnored(in.options.IgnorePaths),
})
}
modified, err := json.Marshal(&modifiedMap)
if err != nil {
return nil, errors.Wrap(err, "failed to marshal modified")
}
return modified, nil
}
// HasSpecChanges return true if the patch has changes to the spec field.
func (h *TwoWaysPatchHelper) HasSpecChanges() bool {
return h.hasSpecChanges
}
// HasChanges return true if the patch has changes.
func (h *TwoWaysPatchHelper) HasChanges() bool {
return !bytes.Equal(h.patch, []byte("{}"))
}
// Patch will attempt to apply the twoWaysPatch to the original object.
func (h *TwoWaysPatchHelper) Patch(ctx context.Context) error {
if !h.HasChanges() {
return nil
}
log := ctrl.LoggerFrom(ctx)
if util.IsNil(h.original) {
modifiedMap := make(map[string]interface{})
if err := json.Unmarshal(h.patch, &modifiedMap); err != nil {
return errors.Wrap(err, "failed to unmarshal two way merge patch")
}
obj := &unstructured.Unstructured{
Object: modifiedMap,
}
return h.client.Create(ctx, obj)
}
// Note: deepcopy before patching in order to avoid modifications to the original object.
log.V(5).Info("Patching object", "Patch", string(h.patch))
return h.client.Patch(ctx, h.original.DeepCopyObject().(client.Object), client.RawPatch(types.MergePatchType, h.patch))
}