Skip to content

Commit 5291db5

Browse files
authored
Merge pull request #973 from vincepri/backport969
[0.5] ✨ Add Patch MergeFrom optimistic locking option
2 parents c45adcf + c1e9515 commit 5291db5

File tree

2 files changed

+136
-4
lines changed

2 files changed

+136
-4
lines changed

pkg/client/client_test.go

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1209,6 +1209,42 @@ var _ = Describe("Client", func() {
12091209
close(done)
12101210
})
12111211

1212+
It("should patch an existing object from a go struct, using optimistic locking", func(done Done) {
1213+
cl, err := client.New(cfg, client.Options{})
1214+
Expect(err).NotTo(HaveOccurred())
1215+
Expect(cl).NotTo(BeNil())
1216+
1217+
By("initially creating a Deployment")
1218+
dep, err := clientset.AppsV1().Deployments(ns).Create(dep)
1219+
Expect(err).NotTo(HaveOccurred())
1220+
1221+
By("creating a patch from with optimistic lock")
1222+
patch := client.MergeFromWithOptions(dep.DeepCopy(), client.MergeFromWithOptimisticLock{})
1223+
1224+
By("adding a new annotation")
1225+
dep.Annotations = map[string]string{
1226+
"foo": "bar",
1227+
}
1228+
1229+
By("patching the Deployment")
1230+
err = cl.Patch(context.TODO(), dep, patch)
1231+
Expect(err).NotTo(HaveOccurred())
1232+
1233+
By("validating patched Deployment has new annotation")
1234+
actual, err := clientset.AppsV1().Deployments(ns).Get(dep.Name, metav1.GetOptions{})
1235+
Expect(err).NotTo(HaveOccurred())
1236+
Expect(actual).NotTo(BeNil())
1237+
Expect(actual.Annotations["foo"]).To(Equal("bar"))
1238+
1239+
By("validating that a patch should fail with conflict, when it has an outdated resource version")
1240+
dep.Annotations["should"] = "conflict"
1241+
err = cl.Patch(context.TODO(), dep, patch)
1242+
Expect(err).To(HaveOccurred())
1243+
Expect(apierrors.IsConflict(err)).To(BeTrue())
1244+
1245+
close(done)
1246+
})
1247+
12121248
It("should patch and preserve type information", func(done Done) {
12131249
cl, err := client.New(cfg, client.Options{})
12141250
Expect(err).NotTo(HaveOccurred())
@@ -2655,8 +2691,9 @@ var _ = Describe("Patch", func() {
26552691
BeforeEach(func() {
26562692
cm = &corev1.ConfigMap{
26572693
ObjectMeta: metav1.ObjectMeta{
2658-
Namespace: metav1.NamespaceDefault,
2659-
Name: "cm",
2694+
Namespace: metav1.NamespaceDefault,
2695+
Name: "cm",
2696+
ResourceVersion: "10",
26602697
},
26612698
}
26622699
})
@@ -2685,6 +2722,31 @@ var _ = Describe("Patch", func() {
26852722
By("returning a patch with data only containing the annotation change")
26862723
Expect(data).To(Equal([]byte(fmt.Sprintf(`{"metadata":{"annotations":{"%s":"%s"}}}`, annotationKey, annotationValue))))
26872724
})
2725+
2726+
It("creates a merge patch with the modifications applied during the mutation, using optimistic locking", func() {
2727+
const (
2728+
annotationKey = "test"
2729+
annotationValue = "foo"
2730+
)
2731+
2732+
By("creating a merge patch")
2733+
patch := client.MergeFromWithOptions(cm.DeepCopy(), client.MergeFromWithOptimisticLock{})
2734+
2735+
By("returning a patch with type MergePatch")
2736+
Expect(patch.Type()).To(Equal(types.MergePatchType))
2737+
2738+
By("retrieving modifying the config map")
2739+
metav1.SetMetaDataAnnotation(&cm.ObjectMeta, annotationKey, annotationValue)
2740+
2741+
By("computing the patch data")
2742+
data, err := patch.Data(cm)
2743+
2744+
By("returning no error")
2745+
Expect(err).NotTo(HaveOccurred())
2746+
2747+
By("returning a patch with data containing the annotation change and the resourceVersion change")
2748+
Expect(data).To(Equal([]byte(fmt.Sprintf(`{"metadata":{"annotations":{"%s":"%s"},"resourceVersion":"%s"}}`, annotationKey, annotationValue, cm.ResourceVersion))))
2749+
})
26882750
})
26892751
})
26902752

pkg/client/patch.go

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@ limitations under the License.
1717
package client
1818

1919
import (
20+
"fmt"
21+
2022
jsonpatch "github.com/evanphx/json-patch"
23+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
24+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
2125
"k8s.io/apimachinery/pkg/runtime"
2226
"k8s.io/apimachinery/pkg/types"
2327
"k8s.io/apimachinery/pkg/util/json"
@@ -59,8 +63,39 @@ func ConstantPatch(patchType types.PatchType, data []byte) Patch {
5963
return RawPatch(patchType, data)
6064
}
6165

66+
// MergeFromWithOptimisticLock can be used if clients want to make sure a patch
67+
// is being applied to the latest resource version of an object.
68+
//
69+
// The behavior is similar to what an Update would do, without the need to send the
70+
// whole object. Usually this method is useful if you might have multiple clients
71+
// acting on the same object and the same API version, but with different versions of the Go structs.
72+
//
73+
// For example, an "older" copy of a Widget that has fields A and B, and a "newer" copy with A, B, and C.
74+
// Sending an update using the older struct definition results in C being dropped, whereas using a patch does not.
75+
type MergeFromWithOptimisticLock struct{}
76+
77+
// ApplyToMergeFrom applies this configuration to the given patch options.
78+
func (m MergeFromWithOptimisticLock) ApplyToMergeFrom(in *MergeFromOptions) {
79+
in.OptimisticLock = true
80+
}
81+
82+
// MergeFromOption is some configuration that modifies options for a merge-from patch data.
83+
type MergeFromOption interface {
84+
// ApplyToMergeFrom applies this configuration to the given patch options.
85+
ApplyToMergeFrom(*MergeFromOptions)
86+
}
87+
88+
// MergeFromOptions contains options to generate a merge-from patch data.
89+
type MergeFromOptions struct {
90+
// OptimisticLock, when true, includes `metadata.resourceVersion` into the final
91+
// patch data. If the `resourceVersion` field doesn't match what's stored,
92+
// the operation results in a conflict and clients will need to try again.
93+
OptimisticLock bool
94+
}
95+
6296
type mergeFromPatch struct {
6397
from runtime.Object
98+
opts MergeFromOptions
6499
}
65100

66101
// Type implements patch.
@@ -80,12 +115,47 @@ func (s *mergeFromPatch) Data(obj runtime.Object) ([]byte, error) {
80115
return nil, err
81116
}
82117

83-
return jsonpatch.CreateMergePatch(originalJSON, modifiedJSON)
118+
data, err := jsonpatch.CreateMergePatch(originalJSON, modifiedJSON)
119+
if err != nil {
120+
return nil, err
121+
}
122+
123+
if s.opts.OptimisticLock {
124+
dataMap := map[string]interface{}{}
125+
if err := json.Unmarshal(data, &dataMap); err != nil {
126+
return nil, err
127+
}
128+
fromMeta, ok := s.from.(metav1.Object)
129+
if !ok {
130+
return nil, fmt.Errorf("cannot use OptimisticLock, from object %q is not a valid metav1.Object", s.from)
131+
}
132+
resourceVersion := fromMeta.GetResourceVersion()
133+
if len(resourceVersion) == 0 {
134+
return nil, fmt.Errorf("cannot use OptimisticLock, from object %q does not have any resource version we can use", s.from)
135+
}
136+
u := &unstructured.Unstructured{Object: dataMap}
137+
u.SetResourceVersion(resourceVersion)
138+
data, err = json.Marshal(u)
139+
if err != nil {
140+
return nil, err
141+
}
142+
}
143+
144+
return data, nil
84145
}
85146

86147
// MergeFrom creates a Patch that patches using the merge-patch strategy with the given object as base.
87148
func MergeFrom(obj runtime.Object) Patch {
88-
return &mergeFromPatch{obj}
149+
return &mergeFromPatch{from: obj}
150+
}
151+
152+
// MergeFromWithOptions creates a Patch that patches using the merge-patch strategy with the given object as base.
153+
func MergeFromWithOptions(obj runtime.Object, opts ...MergeFromOption) Patch {
154+
options := &MergeFromOptions{}
155+
for _, opt := range opts {
156+
opt.ApplyToMergeFrom(options)
157+
}
158+
return &mergeFromPatch{from: obj, opts: *options}
89159
}
90160

91161
// mergePatch uses a raw merge strategy to patch the object.

0 commit comments

Comments
 (0)