diff --git a/client/finalizer.go b/client/finalizer.go index 3e2e250..fa07663 100644 --- a/client/finalizer.go +++ b/client/finalizer.go @@ -27,7 +27,7 @@ func AddFinalizerAndUpdate(ctx context.Context, c client.Client, obj client.Obje if controllerutil.ContainsFinalizer(obj, finalizer) { return nil } - _, err := UpdateOnConflict(ctx, c, c, obj, func(obj client.Object) error { + _, err := PatchOnConflict(ctx, c, c, obj, func(obj client.Object) error { controllerutil.AddFinalizer(obj, finalizer) return nil }) @@ -38,7 +38,7 @@ func RemoveFinalizerAndUpdate(ctx context.Context, c client.Client, obj client.O if !controllerutil.ContainsFinalizer(obj, finalizer) { return nil } - _, err := UpdateOnConflict(ctx, c, c, obj, func(obj client.Object) error { + _, err := PatchOnConflict(ctx, c, c, obj, func(obj client.Object) error { controllerutil.RemoveFinalizer(obj, finalizer) return nil }) diff --git a/client/finalizer_test.go b/client/finalizer_test.go index b840692..d274661 100644 --- a/client/finalizer_test.go +++ b/client/finalizer_test.go @@ -31,7 +31,7 @@ import ( func TestAddFinalizerAndUpdate(t *testing.T) { c := fake.NewClientBuilder().WithScheme(scheme.Scheme).Build() - pod := newTestPod() + pod := newTestPod("test-pod") err := c.Create(context.Background(), pod) require.NoError(t, err) @@ -62,7 +62,7 @@ func TestAddFinalizerAndUpdate(t *testing.T) { func TestRemoveFinalizerAndUpdate(t *testing.T) { c := fake.NewClientBuilder().WithScheme(scheme.Scheme).Build() - pod := newTestPod() + pod := newTestPod("test-pod") err := c.Create(context.Background(), pod) require.NoError(t, err) @@ -95,7 +95,7 @@ func TestRemoveFinalizerAndUpdate(t *testing.T) { func TestRemoveFinalizerAndDelete(t *testing.T) { c := fake.NewClientBuilder().WithScheme(scheme.Scheme).Build() - pod := newTestPod() + pod := newTestPod("test-pod") err := c.Create(context.Background(), pod) require.NoError(t, err) diff --git a/client/suit_test.go b/client/suit_test.go new file mode 100644 index 0000000..5548c86 --- /dev/null +++ b/client/suit_test.go @@ -0,0 +1,29 @@ +/* +Copyright 2025 The KusionStack 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 client + +import ( + "testing" + + "github.com/stretchr/testify/suite" +) + +// In order for 'go test' to run this suite, we need to create +// a normal test function and pass our suite to suite.Run +func TestClientTestSuite(t *testing.T) { + suite.Run(t, new(clientTestSuite)) +} diff --git a/client/util.go b/client/util.go index 11cfd76..9d9875b 100644 --- a/client/util.go +++ b/client/util.go @@ -37,43 +37,48 @@ type UpdateWriter interface { Update(ctx context.Context, obj client.Object, opts ...client.UpdateOption) error } -// UpdateOnConflict attempts to update a resource while avoiding conflicts that may arise from concurrent modifications. -// It utilizes the mutateFn function to apply changes to the original obj and then attempts an update using the writer, -// which can be either client.Writer or client.StatusWriter. -// In case of an update failure due to a conflict, UpdateOnConflict will retrieve the latest version of the object using -// the reader and attempt the update again. -// The retry mechanism adheres to the retry.DefaultBackoff policy. +// UpdateOnConflict attempts to update a resource while avoiding conflicts that may arise +// from concurrent modifications. It utilizes the mutateFn function to apply changes to the +// input obj and then attempts an update using the writer, which can be either client.Writer +// or client.StatusWriter. +// +// In case of an update failure due to a conflict, UpdateOnConflict will retrieve the latest version +// of the object using the reader and attempt the update again. The retry mechanism adheres to the +// retry.DefaultBackoff policy. +// +// NOTE: It must be ensured that the input object has not been modified in any way after being obtained +// from cache or apiserver. func UpdateOnConflict[T client.Object]( ctx context.Context, reader client.Reader, writer UpdateWriter, - original T, + inputObj T, mutateFn MutateFn[T], ) (changed bool, err error) { - key := client.ObjectKeyFromObject(original) + key := client.ObjectKeyFromObject(inputObj) first := true err = retry.RetryOnConflict(retry.DefaultBackoff, func() error { if !first { // refresh object - if innerErr := reader.Get(ctx, key, original); innerErr != nil { + if innerErr := reader.Get(ctx, key, inputObj); innerErr != nil { return innerErr } } else { first = false } - existing := original.DeepCopyObject() - if innerErr := mutate(mutateFn, key, original); innerErr != nil { + original := inputObj.DeepCopyObject() + if innerErr := mutate(mutateFn, key, inputObj); innerErr != nil { return innerErr } - if equality.Semantic.DeepEqual(existing, original) { + if equality.Semantic.DeepEqual(original, inputObj) { // nothing changed, skip update return nil } - if innerErr := writer.Update(ctx, original); innerErr != nil { + if innerErr := writer.Update(ctx, inputObj); innerErr != nil { return innerErr } @@ -106,24 +111,24 @@ func CreateOrUpdateOnConflict[T client.Object]( ctx context.Context, reader client.Reader, writer client.Writer, - original T, + inputObj T, f MutateFn[T], ) (controllerutil.OperationResult, error) { - key := client.ObjectKeyFromObject(original) - if err := reader.Get(ctx, key, original); err != nil { + key := client.ObjectKeyFromObject(inputObj) + if err := reader.Get(ctx, key, inputObj); err != nil { if !apierrors.IsNotFound(err) { return controllerutil.OperationResultNone, err } // not found, create it - if err := mutate(f, key, original); err != nil { + if err := mutate(f, key, inputObj); err != nil { return controllerutil.OperationResultNone, err } - if err := writer.Create(ctx, original); err != nil { + if err := writer.Create(ctx, inputObj); err != nil { return controllerutil.OperationResultNone, err } return controllerutil.OperationResultCreated, nil } - changed, err := UpdateOnConflict(ctx, reader, writer, original, f) + changed, err := UpdateOnConflict(ctx, reader, writer, inputObj, f) if err != nil { return controllerutil.OperationResultNone, err } @@ -132,3 +137,113 @@ func CreateOrUpdateOnConflict[T client.Object]( } return controllerutil.OperationResultUpdated, nil } + +type PatchWriter interface { + // Patch patches the given obj in the Kubernetes cluster. obj must be a + // struct pointer so that obj can be updated with the content returned by the Server. + Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.PatchOption) error +} + +// Patch applies changes to a Kubernetes object using the merge patch strategy. +// It creates a merge patch from the original object, applies the mutateFn to modify the object, +// and then applies the patch if there are actual changes. +// Returns true if changes were applied, false otherwise. +func Patch[T client.Object]( + ctx context.Context, + writer PatchWriter, + inputObj T, + mutateFn MutateFn[T], +) (changed bool, err error) { + original := inputObj.DeepCopyObject().(client.Object) + + mergePatch := client.MergeFrom(original) + + // modify inputObj object + err = mutateFn(inputObj) + if err != nil { + return false, err + } + + // calculate patch data + patchData, err := mergePatch.Data(inputObj) + if err != nil { + return false, err + } + + if len(patchData) == 0 || string(patchData) == "{}" { + // nothing changed, skip update + return false, nil + } + + err = writer.Patch(ctx, inputObj, mergePatch) + if err != nil { + return false, err + } + + // patched + return true, nil +} + +// PatchOnConflict attempts to patch a resource while avoiding conflicts that may arise +// from concurrent modifications. It utilizes the mutateFn function to apply changes to the +// input obj and then attempts an mergePatch using the writer, which can be either client.Writer +// or client.StatusWriter. +// +// In case of an update failure due to a conflict, PatchOnConflict will retrieve the latest version +// of the object using the reader and attempt the update again. The retry mechanism adheres to the +// retry.DefaultBackoff policy. +// +// NOTE: It must be ensured that the input object has not been modified in any way after being obtained +// from cache or apiserver. +func PatchOnConflict[T client.Object]( + ctx context.Context, + reader client.Reader, + writer PatchWriter, + inputObj T, + mutateFn MutateFn[T], +) (changed bool, err error) { + key := client.ObjectKeyFromObject(inputObj) + first := true + + err = retry.RetryOnConflict(retry.DefaultBackoff, func() error { + if !first { + // refresh object + if innerErr := reader.Get(ctx, key, inputObj); innerErr != nil { + return innerErr + } + } else { + first = false + } + + original := inputObj.DeepCopyObject().(client.Object) + + // modify inputObj object + if innerErr := mutateFn(inputObj); innerErr != nil { + return innerErr + } + + mergePatch := client.MergeFrom(original) + + // calculate patch data + patchData, err := mergePatch.Data(inputObj) + if err != nil { + return err + } + + if len(patchData) == 0 || string(patchData) == "{}" { + // nothing changed, skip update + return nil + } + + // use resourceVersion as optimisticLock + mergePatchWithOptimisticLock := client.MergeFromWithOptions(original, client.MergeFromWithOptimisticLock{}) + if innerErr := writer.Patch(ctx, inputObj, mergePatchWithOptimisticLock); innerErr != nil { + return innerErr + } + + changed = true + return nil + }) + + return changed, err +} diff --git a/client/util_test.go b/client/util_test.go index c436ee9..faa62e8 100644 --- a/client/util_test.go +++ b/client/util_test.go @@ -18,22 +18,32 @@ package client import ( "context" - "testing" + "encoding/json" + "fmt" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + jsonpatch "github.com/evanphx/json-patch" + "github.com/stretchr/testify/suite" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/rand" + "k8s.io/apimachinery/pkg/util/strategicpatch" "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" ) -func newTestPod() *corev1.Pod { +func newTestPod(name string) *corev1.Pod { return &corev1.Pod{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "v1", + Kind: "Pod", + }, ObjectMeta: metav1.ObjectMeta{ - Name: "test-pod", + Name: name, Namespace: "default", Labels: map[string]string{ "version": "v1", @@ -50,67 +60,466 @@ func newTestPod() *corev1.Pod { } } -func TestUpdateOnConflict(t *testing.T) { - client := fake.NewClientBuilder().WithScheme(scheme.Scheme).Build() +func newTestRandomPod() *corev1.Pod { + return newTestPod(fmt.Sprintf("test-pod-%s", rand.String(5))) +} + +type fakeClient struct { + client.Client +} + +func (c *fakeClient) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.PatchOption) error { + existingObj := obj.DeepCopyObject().(client.Object) + err := c.Client.Get(ctx, client.ObjectKeyFromObject(existingObj), existingObj) + if err != nil { + return err + } + + existingJSON, err := json.Marshal(existingObj) + if err != nil { + return err + } + + modifiedObj := existingObj.DeepCopyObject().(client.Object) + + patchData, err := patch.Data(obj) + if err != nil { + return err + } + switch patch.Type() { + case types.JSONPatchType: + patch, err := jsonpatch.DecodePatch(patchData) + if err != nil { + return err + } + modified, err := patch.Apply(existingJSON) + if err != nil { + return err + } + + if err := json.Unmarshal(modified, modifiedObj); err != nil { + return err + } + case types.MergePatchType: + modified, err := jsonpatch.MergePatch(existingJSON, patchData) + if err != nil { + return err + } + + if err := json.Unmarshal(modified, modifiedObj); err != nil { + return err + } + case types.StrategicMergePatchType: + mergedByte, err := strategicpatch.StrategicMergePatch(existingJSON, patchData, obj) + if err != nil { + return err + } + if err := json.Unmarshal(mergedByte, modifiedObj); err != nil { + return err + } + default: + return fmt.Errorf("PatchType is not supported") + } + + if existingObj.GetResourceVersion() != modifiedObj.GetResourceVersion() { + return errors.NewConflict(schema.GroupResource{}, obj.GetName(), fmt.Errorf("resource version conflict")) + } + + return c.Client.Patch(ctx, obj, patch, opts...) +} + +type clientTestSuite struct { + suite.Suite + + cli client.Client +} + +func (s *clientTestSuite) SetupSuite() { + s.cli = &fakeClient{Client: fake.NewClientBuilder().WithScheme(scheme.Scheme).Build()} +} + +func (s *clientTestSuite) TearDownTest() { + s.cli.DeleteAllOf(context.Background(), &corev1.Pod{}) +} - pod := newTestPod() - err := client.Create(context.Background(), pod) - require.NoError(t, err) +func (s *clientTestSuite) TestConflict() { + pod := newTestPod("test-pod") + err := s.cli.Create(context.Background(), pod) + s.Require().NoError(err) oldPod := pod.DeepCopy() pod.Labels["version"] = "v2" - err = client.Update(context.Background(), pod) - require.NoError(t, err) - - oldPod.Labels["version"] = "v3" - err = client.Update(context.Background(), oldPod) - assert.True(t, errors.IsConflict(err), "update should fail on conflict") - - // reset old pod and update it again - oldPod.Labels["version"] = "v1" - pod = oldPod.DeepCopy() - changed, err := UpdateOnConflict(context.Background(), client, client, pod, func(obj *corev1.Pod) error { - obj.Labels["version"] = "v3" - return nil - }) - require.NoError(t, err, "update should succeed on conflict") - assert.True(t, changed, "pod should be changed") - assert.Equal(t, "v3", pod.Labels["version"]) - - changed, err = UpdateOnConflict(context.Background(), client, client, pod, func(obj *corev1.Pod) error { - obj.Labels["version"] = "v3" - return nil - }) - require.NoError(t, err, "update should succeed") - assert.False(t, changed, "pod should not be changed") - assert.Equal(t, "v3", pod.Labels["version"]) + err = s.cli.Update(context.Background(), pod) + s.Require().NoError(err, "bump up resource version") + + modified := oldPod.DeepCopy() + modified.Labels["version"] = "v3" + err = s.cli.Update(context.Background(), modified) + s.True(errors.IsConflict(err), "update should fail on conflict") + + modified = oldPod.DeepCopy() + modified.Labels["version"] = "v4" + mergePatch := client.MergeFromWithOptions(oldPod, client.MergeFromWithOptimisticLock{}) + err = s.cli.Patch(context.Background(), modified, mergePatch) + s.True(errors.IsConflict(err), "patch should fail on conflict") } -func TestCreateOrUpdateOnConflict(t *testing.T) { - pod := newTestPod() - client := fake.NewClientBuilder().WithScheme(scheme.Scheme).Build() - - result, err := CreateOrUpdateOnConflict(context.Background(), client, client, pod, func(obj *corev1.Pod) error { - obj.Labels["version"] = "v2" - return nil - }) - require.NoError(t, err, "create should succeed") - assert.Equal(t, controllerutil.OperationResultCreated, result, "pod created") - assert.Equal(t, "v2", pod.Labels["version"]) - - result, err = CreateOrUpdateOnConflict(context.Background(), client, client, pod, func(obj *corev1.Pod) error { - obj.Labels["version"] = "v2" - return nil - }) - require.NoError(t, err) - assert.Equal(t, controllerutil.OperationResultNone, result, "no change") - - result, err = CreateOrUpdateOnConflict(context.Background(), client, client, pod, func(obj *corev1.Pod) error { - obj.Labels["version"] = "v3" - return nil - }) - require.NoError(t, err) - assert.Equal(t, controllerutil.OperationResultUpdated, result) - assert.Equal(t, "v3", pod.Labels["version"]) +func (s *clientTestSuite) TestUpdateOnConflict() { + type testCase struct { + name string + setupFunc func() *corev1.Pod + modifyFunc func(obj *corev1.Pod) error + expectedLabels map[string]string + expectedChange bool + expectedError bool + } + + testCases := []testCase{ + { + name: "successful update on conflict", + setupFunc: func() *corev1.Pod { + pod := newTestRandomPod() + err := s.cli.Create(context.Background(), pod) + s.Require().NoError(err) + + oldPod := pod.DeepCopy() + // update pod to increase resource version + pod.Labels["version"] = "v2" + err = s.cli.Update(context.Background(), pod) + s.Require().NoError(err) + + // return old pod to trigger update on conflict + return oldPod + }, + modifyFunc: func(obj *corev1.Pod) error { + obj.Labels["version"] = "v3" + return nil + }, + expectedLabels: map[string]string{"version": "v3"}, + expectedChange: true, + expectedError: false, + }, + { + name: "no change", + setupFunc: func() *corev1.Pod { + pod := newTestRandomPod() + err := s.cli.Create(context.Background(), pod) + s.Require().NoError(err) + return pod + }, + modifyFunc: func(obj *corev1.Pod) error { + return nil + }, + expectedLabels: map[string]string{"version": "v1"}, + expectedChange: false, + expectedError: false, + }, + } + + for _, tc := range testCases { + s.Run(tc.name, func() { + modified := tc.setupFunc() + changed, err := UpdateOnConflict(context.Background(), s.cli, s.cli, modified, tc.modifyFunc) + + if tc.expectedError { + s.Require().Error(err) + } else { + s.Require().NoError(err) + } + + s.Equal(tc.expectedChange, changed) + + for key, expectedValue := range tc.expectedLabels { + s.Equal(expectedValue, modified.Labels[key]) + } + + if !tc.expectedError { + existing := &corev1.Pod{} + err = s.cli.Get(context.Background(), client.ObjectKeyFromObject(modified), existing) + s.Require().NoError(err) + s.Equal(existing, modified) + } + }) + } +} + +func (s *clientTestSuite) TestCreateOrUpdateOnConflict() { + type testCase struct { + name string + setupFunc func() *corev1.Pod + modifyFunc func(obj *corev1.Pod) error + expectedResult controllerutil.OperationResult + expectedLabels map[string]string + expectedError bool + } + + testCases := []testCase{ + { + name: "create new pod", + setupFunc: newTestRandomPod, + modifyFunc: func(obj *corev1.Pod) error { + obj.Labels["version"] = "v1" + return nil + }, + expectedResult: controllerutil.OperationResultCreated, + expectedLabels: map[string]string{"version": "v1"}, + expectedError: false, + }, + { + name: "no change on existing pod", + setupFunc: func() *corev1.Pod { + pod := newTestRandomPod() + err := s.cli.Create(context.Background(), pod) + s.Require().NoError(err) + return pod + }, + modifyFunc: func(obj *corev1.Pod) error { + return nil + }, + expectedResult: controllerutil.OperationResultNone, + expectedLabels: map[string]string{"version": "v1"}, + expectedError: false, + }, + { + name: "update existing pod", + setupFunc: func() *corev1.Pod { + pod := newTestRandomPod() + err := s.cli.Create(context.Background(), pod) + s.Require().NoError(err) + + oldPod := pod.DeepCopy() + // update pod to increase resource version + pod.Labels["version"] = "v2" + err = s.cli.Update(context.Background(), pod) + s.Require().NoError(err) + + // return old pod to trigger update on conflict + return oldPod + }, + modifyFunc: func(obj *corev1.Pod) error { + obj.Labels["version"] = "v3" + return nil + }, + expectedResult: controllerutil.OperationResultUpdated, + expectedLabels: map[string]string{"version": "v3"}, + expectedError: false, + }, + } + + for _, tc := range testCases { + s.Run(tc.name, func() { + modified := tc.setupFunc() + result, err := CreateOrUpdateOnConflict(context.Background(), s.cli, s.cli, modified, tc.modifyFunc) + + if tc.expectedError { + s.Require().Error(err) + } else { + s.Require().NoError(err) + } + + s.Equal(tc.expectedResult, result) + + for key, expectedValue := range tc.expectedLabels { + s.Equal(expectedValue, modified.Labels[key]) + } + + if !tc.expectedError { + existing := &corev1.Pod{} + err = s.cli.Get(context.Background(), client.ObjectKeyFromObject(modified), existing) + s.Require().NoError(err) + s.Equal(existing, modified) + } + }) + } +} + +func (s *clientTestSuite) TestPatch() { + type testCase struct { + name string + setupFunc func() *corev1.Pod + modifyFunc func(obj *corev1.Pod) error + expectedLabels map[string]string + expectedChange bool + expectedError bool + } + + testCases := []testCase{ + { + name: "patch on current pod", + setupFunc: func() *corev1.Pod { + pod := newTestRandomPod() + err := s.cli.Create(context.Background(), pod) + s.Require().NoError(err) + return pod + }, + modifyFunc: func(obj *corev1.Pod) error { + obj.Labels["version"] = "v2" + return nil + }, + expectedLabels: map[string]string{"version": "v2"}, + expectedChange: true, + expectedError: false, + }, + { + name: "patch on old pod", + setupFunc: func() *corev1.Pod { + pod := newTestRandomPod() + err := s.cli.Create(context.Background(), pod) + s.Require().NoError(err) + + oldPod := pod.DeepCopy() + // update pod to increase resource version + pod.Labels["version"] = "v2" + err = s.cli.Update(context.Background(), pod) + s.Require().NoError(err) + + // return old pod to trigger update on conflict + return oldPod + }, + modifyFunc: func(obj *corev1.Pod) error { + obj.Labels["version"] = "v3" + return nil + }, + expectedLabels: map[string]string{"version": "v3"}, + expectedChange: true, + expectedError: false, + }, + { + name: "patch with no changes", + setupFunc: func() *corev1.Pod { + pod := newTestRandomPod() + err := s.cli.Create(context.Background(), pod) + s.Require().NoError(err) + return pod + }, + modifyFunc: func(obj *corev1.Pod) error { + return nil + }, + expectedLabels: map[string]string{"version": "v1"}, + expectedChange: false, + expectedError: false, + }, + } + + for _, tc := range testCases { + s.Run(tc.name, func() { + modified := tc.setupFunc() + changed, err := Patch(context.Background(), s.cli, modified, tc.modifyFunc) + + if tc.expectedError { + s.Require().Error(err) + } else { + s.Require().NoError(err) + } + + s.Equal(tc.expectedChange, changed) + + for key, expectedValue := range tc.expectedLabels { + s.Equal(expectedValue, modified.Labels[key]) + } + + if !tc.expectedError { + existing := &corev1.Pod{} + err = s.cli.Get(context.Background(), client.ObjectKeyFromObject(modified), existing) + s.Require().NoError(err) + s.Equal(existing, modified) + } + }) + } +} + +func (s *clientTestSuite) TestPatchOnConflict() { + type testCase struct { + name string + setupFunc func() *corev1.Pod + modifyFunc func(obj *corev1.Pod) error + expectedLabels map[string]string + expectedChange bool + expectedError bool + } + + testCases := []testCase{ + { + name: "patch on current pod", + setupFunc: func() *corev1.Pod { + pod := newTestRandomPod() + err := s.cli.Create(context.Background(), pod) + s.Require().NoError(err) + return pod + }, + modifyFunc: func(obj *corev1.Pod) error { + obj.Labels["version"] = "v2" + return nil + }, + expectedLabels: map[string]string{"version": "v2"}, + expectedChange: true, + expectedError: false, + }, + { + name: "patch on old pod", + setupFunc: func() *corev1.Pod { + pod := newTestRandomPod() + err := s.cli.Create(context.Background(), pod) + s.Require().NoError(err) + + oldPod := pod.DeepCopy() + // update pod to increase resource version + pod.Labels["version"] = "v2" + err = s.cli.Update(context.Background(), pod) + s.Require().NoError(err) + + // return old pod to trigger update on conflict + return oldPod + }, + modifyFunc: func(obj *corev1.Pod) error { + obj.Labels["version"] = "v3" + return nil + }, + expectedLabels: map[string]string{"version": "v3"}, + expectedChange: true, + expectedError: false, + }, + { + name: "patch with no changes", + setupFunc: func() *corev1.Pod { + pod := newTestRandomPod() + err := s.cli.Create(context.Background(), pod) + s.Require().NoError(err) + return pod + }, + modifyFunc: func(obj *corev1.Pod) error { + return nil + }, + expectedLabels: map[string]string{"version": "v1"}, + expectedChange: false, + expectedError: false, + }, + } + + for _, tc := range testCases { + s.Run(tc.name, func() { + modified := tc.setupFunc() + changed, err := PatchOnConflict(context.Background(), s.cli, s.cli, modified, tc.modifyFunc) + + if tc.expectedError { + s.Require().Error(err) + } else { + s.Require().NoError(err) + } + + s.Equal(tc.expectedChange, changed) + + for key, expectedValue := range tc.expectedLabels { + s.Equal(expectedValue, modified.Labels[key]) + } + + if !tc.expectedError { + existing := &corev1.Pod{} + err = s.cli.Get(context.Background(), client.ObjectKeyFromObject(modified), existing) + s.Require().NoError(err) + s.Equal(existing, modified) + } + }) + } }