Skip to content

Commit 2b186fd

Browse files
weicaoapecloud-bot
authored andcommitted
chore: patch CR metadata without owning spec (#10264)
(cherry picked from commit 7e2fd5a)
1 parent 2fc82d0 commit 2b186fd

4 files changed

Lines changed: 247 additions & 5 deletions

File tree

controllers/apps/componentversion_controller.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -265,8 +265,9 @@ func (r *ComponentVersionReconciler) supportedServiceVersions(compVersion *appsv
265265
return strings.Join(versions, ",") // TODO(API): service versions length
266266
}
267267

268-
func (r *ComponentVersionReconciler) updateSupportedCompDefLabels(cli client.Client, rctx intctrlutil.RequestCtx,
268+
func (r *ComponentVersionReconciler) updateSupportedCompDefLabels(cli client.Writer, rctx intctrlutil.RequestCtx,
269269
compVersion *appsv1.ComponentVersion, releaseToCompDefinitions map[string]map[string]*appsv1.ComponentDefinition) error {
270+
patch := client.MergeFromWithOptions(compVersion.DeepCopy(), client.MergeFromWithOptimisticLock{})
270271
if compVersion.Annotations == nil {
271272
compVersion.Annotations = make(map[string]string)
272273
}
@@ -294,7 +295,7 @@ func (r *ComponentVersionReconciler) updateSupportedCompDefLabels(cli client.Cli
294295
}
295296
compVersion.Labels = labels
296297
compVersion.Annotations[compatibleDefinitionsKey] = strings.Join(labelKeys, ",")
297-
return cli.Update(rctx.Ctx, compVersion)
298+
return cli.Patch(rctx.Ctx, compVersion, patch)
298299
}
299300

300301
func (r *ComponentVersionReconciler) validate(compVersion *appsv1.ComponentVersion,

controllers/apps/componentversion_controller_test.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,21 +20,120 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
2020
package apps
2121

2222
import (
23+
"context"
2324
"strings"
25+
"testing"
2426

2527
. "github.com/onsi/ginkgo/v2"
2628
. "github.com/onsi/gomega"
2729

2830
corev1 "k8s.io/api/core/v1"
31+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2932
"k8s.io/apimachinery/pkg/types"
3033
"k8s.io/apimachinery/pkg/util/sets"
3134
"sigs.k8s.io/controller-runtime/pkg/client"
3235

3336
appsv1 "github.com/apecloud/kubeblocks/apis/apps/v1"
37+
intctrlutil "github.com/apecloud/kubeblocks/pkg/controllerutil"
3438
"github.com/apecloud/kubeblocks/pkg/generics"
3539
testapps "github.com/apecloud/kubeblocks/pkg/testutil/apps"
3640
)
3741

42+
type componentVersionPatchWriter struct {
43+
patchCalls int
44+
updateCalls int
45+
patchData []byte
46+
}
47+
48+
func (w *componentVersionPatchWriter) Create(context.Context, client.Object, ...client.CreateOption) error {
49+
return nil
50+
}
51+
52+
func (w *componentVersionPatchWriter) Delete(context.Context, client.Object, ...client.DeleteOption) error {
53+
return nil
54+
}
55+
56+
func (w *componentVersionPatchWriter) Update(context.Context, client.Object, ...client.UpdateOption) error {
57+
w.updateCalls++
58+
return nil
59+
}
60+
61+
func (w *componentVersionPatchWriter) Patch(_ context.Context, obj client.Object, patch client.Patch, _ ...client.PatchOption) error {
62+
var err error
63+
w.patchCalls++
64+
w.patchData, err = patch.Data(obj)
65+
return err
66+
}
67+
68+
func (w *componentVersionPatchWriter) DeleteAllOf(context.Context, client.Object, ...client.DeleteAllOfOption) error {
69+
return nil
70+
}
71+
72+
func TestComponentVersionSupportedCompDefLabelsUsesMetadataPatch(t *testing.T) {
73+
compVersion := &appsv1.ComponentVersion{
74+
ObjectMeta: metav1.ObjectMeta{
75+
Name: "mssql",
76+
ResourceVersion: "7",
77+
Labels: map[string]string{
78+
"old-comp-def": "old-comp-def",
79+
"chart-label": "keep",
80+
},
81+
Annotations: map[string]string{
82+
compatibleDefinitionsKey: "old-comp-def",
83+
},
84+
},
85+
Spec: appsv1.ComponentVersionSpec{
86+
Releases: []appsv1.ComponentVersionRelease{{
87+
Name: "mssql-2022",
88+
ServiceVersion: "16.0.0",
89+
Images: map[string]string{
90+
"mssql": "mssql:old",
91+
},
92+
}},
93+
},
94+
}
95+
writer := &componentVersionPatchWriter{}
96+
releaseToCompDefinitions := map[string]map[string]*appsv1.ComponentDefinition{
97+
"mssql-2022": {
98+
"mssql-new": {ObjectMeta: metav1.ObjectMeta{Name: "mssql-new"}},
99+
},
100+
}
101+
102+
err := (&ComponentVersionReconciler{}).updateSupportedCompDefLabels(writer,
103+
intctrlutil.RequestCtx{Ctx: context.Background()}, compVersion, releaseToCompDefinitions)
104+
if err != nil {
105+
t.Fatalf("updateSupportedCompDefLabels returned error: %v", err)
106+
}
107+
if writer.patchCalls != 1 {
108+
t.Fatalf("expected one patch, got %d", writer.patchCalls)
109+
}
110+
if writer.updateCalls != 0 {
111+
t.Fatalf("expected no whole-object update, got %d", writer.updateCalls)
112+
}
113+
if got := compVersion.Labels["mssql-new"]; got != "mssql-new" {
114+
t.Fatalf("expected new compatible label, got %q", got)
115+
}
116+
if got := compVersion.Labels["chart-label"]; got != "keep" {
117+
t.Fatalf("expected existing chart label to be preserved, got %q", got)
118+
}
119+
if _, ok := compVersion.Labels["old-comp-def"]; ok {
120+
t.Fatalf("expected old compatible label to be removed")
121+
}
122+
if got := compVersion.Annotations[compatibleDefinitionsKey]; got != "mssql-new" {
123+
t.Fatalf("expected compatible definitions annotation to be updated, got %q", got)
124+
}
125+
patchData := string(writer.patchData)
126+
if !strings.Contains(patchData, `"metadata"`) {
127+
t.Fatalf("expected metadata patch, got %s", patchData)
128+
}
129+
if strings.Contains(patchData, `"spec"`) {
130+
t.Fatalf("metadata patch must not include spec, got %s", patchData)
131+
}
132+
if !strings.Contains(patchData, `"resourceVersion":"7"`) {
133+
t.Fatalf("metadata patch must preserve optimistic-locking resourceVersion, got %s", patchData)
134+
}
135+
}
136+
38137
var _ = Describe("ComponentVersion Controller", func() {
39138
var (
40139
compVersionObj *appsv1.ComponentVersion

pkg/controllerutil/controller_common.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,8 @@ func Requeue(logger logr.Logger, msg string, keysAndValues ...interface{}) (reco
105105
}
106106

107107
// HandleCRDeletion handles CR deletion, adds finalizer if found a non-deleting object and removes finalizer during
108-
// deletion process. Passes optional 'deletionHandler' func for external dependency deletion. Returns Result pointer
108+
// deletion process. It patches only finalizer metadata so chart-owned spec fields keep their field ownership.
109+
// Passes optional 'deletionHandler' func for external dependency deletion. Returns Result pointer
109110
// if required to return out of outer 'Reconcile' reconciliation loop.
110111
func HandleCRDeletion(reqCtx RequestCtx,
111112
r client.Writer,
@@ -118,8 +119,9 @@ func HandleCRDeletion(reqCtx RequestCtx,
118119
// then add the finalizer and update the object. This is equivalent to
119120
// registering our finalizer.
120121
if !controllerutil.ContainsFinalizer(cr, finalizer) {
122+
patch := client.MergeFromWithOptions(cr.DeepCopyObject().(client.Object), client.MergeFromWithOptimisticLock{})
121123
controllerutil.AddFinalizer(cr, finalizer)
122-
if err := r.Update(reqCtx.Ctx, cr); err != nil {
124+
if err := r.Patch(reqCtx.Ctx, cr, patch); err != nil {
123125
return ResultToP(CheckedRequeueWithError(err, reqCtx.Log, ""))
124126
}
125127
}
@@ -156,8 +158,9 @@ func HandleCRDeletion(reqCtx RequestCtx,
156158
}
157159
}
158160
// remove our finalizer from the list and update it.
161+
patch := client.MergeFromWithOptions(cr.DeepCopyObject().(client.Object), client.MergeFromWithOptimisticLock{})
159162
if controllerutil.RemoveFinalizer(cr, finalizer) {
160-
if err := r.Update(reqCtx.Ctx, cr); err != nil {
163+
if err := r.Patch(reqCtx.Ctx, cr, patch); err != nil {
161164
return ResultToP(CheckedRequeueWithError(err, reqCtx.Log, ""))
162165
}
163166
// record resources deleted event

pkg/controllerutil/controller_common_test.go

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"errors"
2525
"fmt"
2626
"reflect"
27+
"strings"
2728
"testing"
2829
"time"
2930

@@ -44,6 +45,53 @@ import (
4445

4546
var tlog = ctrl.Log.WithName("controller_testing")
4647

48+
type patchRecordingWriter struct {
49+
patchCalls int
50+
updateCalls int
51+
patchData []byte
52+
}
53+
54+
func (w *patchRecordingWriter) Create(context.Context, client.Object, ...client.CreateOption) error {
55+
return nil
56+
}
57+
58+
func (w *patchRecordingWriter) Delete(context.Context, client.Object, ...client.DeleteOption) error {
59+
return nil
60+
}
61+
62+
func (w *patchRecordingWriter) Update(context.Context, client.Object, ...client.UpdateOption) error {
63+
w.updateCalls++
64+
return nil
65+
}
66+
67+
func (w *patchRecordingWriter) Patch(_ context.Context, obj client.Object, patch client.Patch, _ ...client.PatchOption) error {
68+
var err error
69+
w.patchCalls++
70+
w.patchData, err = patch.Data(obj)
71+
return err
72+
}
73+
74+
func (w *patchRecordingWriter) DeleteAllOf(context.Context, client.Object, ...client.DeleteAllOfOption) error {
75+
return nil
76+
}
77+
78+
type concurrentFinalizerWriter struct {
79+
patchRecordingWriter
80+
currentResourceVersion string
81+
}
82+
83+
func (w *concurrentFinalizerWriter) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.PatchOption) error {
84+
if err := w.patchRecordingWriter.Patch(ctx, obj, patch, opts...); err != nil {
85+
return err
86+
}
87+
staleResourceVersion := fmt.Sprintf(`"resourceVersion":"%s"`, obj.GetResourceVersion())
88+
if strings.Contains(string(w.patchData), staleResourceVersion) && obj.GetResourceVersion() != w.currentResourceVersion {
89+
return apierrors.NewConflict(schema.GroupResource{Resource: "configmaps"}, obj.GetName(),
90+
fmt.Errorf("object resourceVersion changed to %s", w.currentResourceVersion))
91+
}
92+
return nil
93+
}
94+
4795
func TestRequeueWithError(t *testing.T) {
4896
_, err := CheckedRequeueWithError(errors.New("test error"), tlog, "test")
4997
if err == nil {
@@ -116,6 +164,97 @@ func TestResultToP(t *testing.T) {
116164
}
117165
}
118166

167+
func TestHandleCRDeletionPatchesFinalizerMetadata(t *testing.T) {
168+
const finalizer = "finalizer/protection"
169+
reqCtx := RequestCtx{Ctx: context.Background(), Log: tlog}
170+
171+
t.Run("add finalizer", func(t *testing.T) {
172+
writer := &patchRecordingWriter{}
173+
obj := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "cm", ResourceVersion: "1"}}
174+
175+
res, err := HandleCRDeletion(reqCtx, writer, obj, finalizer, nil)
176+
if err != nil {
177+
t.Fatalf("HandleCRDeletion returned error: %v", err)
178+
}
179+
if res != nil {
180+
t.Fatalf("expected no reconcile result, got %v", res)
181+
}
182+
if writer.patchCalls != 1 || writer.updateCalls != 0 {
183+
t.Fatalf("expected one patch and no update, got patch=%d update=%d", writer.patchCalls, writer.updateCalls)
184+
}
185+
assertMetadataOnlyPatch(t, writer.patchData)
186+
assertOptimisticLockPatch(t, writer.patchData, obj.GetResourceVersion())
187+
if !controllerutil.ContainsFinalizer(obj, finalizer) {
188+
t.Fatalf("expected finalizer %q to be added", finalizer)
189+
}
190+
})
191+
192+
t.Run("remove finalizer", func(t *testing.T) {
193+
writer := &patchRecordingWriter{}
194+
now := metav1.Now()
195+
obj := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{
196+
Name: "cm",
197+
ResourceVersion: "2",
198+
Finalizers: []string{finalizer},
199+
DeletionTimestamp: &now,
200+
}}
201+
202+
res, err := HandleCRDeletion(reqCtx, writer, obj, finalizer, nil)
203+
if err != nil {
204+
t.Fatalf("HandleCRDeletion returned error: %v", err)
205+
}
206+
if res == nil {
207+
t.Fatalf("expected reconciled result")
208+
}
209+
if writer.patchCalls != 1 || writer.updateCalls != 0 {
210+
t.Fatalf("expected one patch and no update, got patch=%d update=%d", writer.patchCalls, writer.updateCalls)
211+
}
212+
assertMetadataOnlyPatch(t, writer.patchData)
213+
assertOptimisticLockPatch(t, writer.patchData, obj.GetResourceVersion())
214+
if controllerutil.ContainsFinalizer(obj, finalizer) {
215+
t.Fatalf("expected finalizer %q to be removed", finalizer)
216+
}
217+
})
218+
219+
t.Run("returns conflict on concurrent finalizer update", func(t *testing.T) {
220+
writer := &concurrentFinalizerWriter{currentResourceVersion: "2"}
221+
obj := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{
222+
Name: "cm",
223+
ResourceVersion: "1",
224+
Finalizers: []string{"other/finalizer"},
225+
}}
226+
227+
res, err := HandleCRDeletion(reqCtx, writer, obj, finalizer, nil)
228+
if !apierrors.IsConflict(err) {
229+
t.Fatalf("expected conflict from stale finalizer patch, got res=%v err=%v patch=%s", res, err, writer.patchData)
230+
}
231+
if writer.patchCalls != 1 || writer.updateCalls != 0 {
232+
t.Fatalf("expected one patch and no update, got patch=%d update=%d", writer.patchCalls, writer.updateCalls)
233+
}
234+
assertMetadataOnlyPatch(t, writer.patchData)
235+
assertOptimisticLockPatch(t, writer.patchData, "1")
236+
})
237+
}
238+
239+
func assertMetadataOnlyPatch(t *testing.T, data []byte) {
240+
t.Helper()
241+
patchData := string(data)
242+
if !strings.Contains(patchData, `"metadata"`) {
243+
t.Fatalf("expected metadata patch, got %s", patchData)
244+
}
245+
if strings.Contains(patchData, `"spec"`) {
246+
t.Fatalf("metadata patch must not include spec, got %s", patchData)
247+
}
248+
}
249+
250+
func assertOptimisticLockPatch(t *testing.T, data []byte, resourceVersion string) {
251+
t.Helper()
252+
expected := fmt.Sprintf(`"resourceVersion":"%s"`, resourceVersion)
253+
if !strings.Contains(string(data), expected) {
254+
t.Fatalf("expected optimistic-locking resourceVersion %s in patch, got %s", resourceVersion, data)
255+
}
256+
}
257+
119258
var _ = Describe("Cluster Controller", func() {
120259

121260
const finalizer = "finalizer/protection"

0 commit comments

Comments
 (0)