Skip to content

Commit 8c6919c

Browse files
committed
✨ Add Patch MergeFrom optimistic locking option
Signed-off-by: Vince Prignano <[email protected]>
1 parent 676c350 commit 8c6919c

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
@@ -1210,6 +1210,42 @@ var _ = Describe("Client", func() {
12101210
close(done)
12111211
})
12121212

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

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)