Skip to content

Commit 4e349e6

Browse files
authored
🌱 Use Patch instead of Update for finalizer operations (#2328)
* Use Patch instead of Update for finalizer operations Refactor all controllers to use Patch() instead of Update() when adding or removing finalizers to improve performance, and to avoid removing non-cached fields erroneously. This is necesary because we no longer cache the last-applied-configuration annotation, so when we add/remove the finalizers, we are removing that field from the metadata. This causes issues with clients when they don't see that annotation (e.g. apply the same ClusterExtension twice). Update ClusterCatalog and ClusterExtension controllers to use Patch-based funalizer management (ClusterExtensionRevision already uses Patch()) Signed-off-by: Todd Short <tshort@redhat.com> * Revert "Restore last-applied-config annotation in cache (#2338)" This reverts commit 39cbdbe. --------- Signed-off-by: Todd Short <tshort@redhat.com>
1 parent 34394ce commit 4e349e6

File tree

8 files changed

+405
-25
lines changed

8 files changed

+405
-25
lines changed

cmd/catalogd/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import (
5959
"github.com/operator-framework/operator-controller/internal/catalogd/storage"
6060
"github.com/operator-framework/operator-controller/internal/catalogd/webhook"
6161
sharedcontrollers "github.com/operator-framework/operator-controller/internal/shared/controllers"
62+
cacheutil "github.com/operator-framework/operator-controller/internal/shared/util/cache"
6263
fsutil "github.com/operator-framework/operator-controller/internal/shared/util/fs"
6364
httputil "github.com/operator-framework/operator-controller/internal/shared/util/http"
6465
imageutil "github.com/operator-framework/operator-controller/internal/shared/util/image"
@@ -254,6 +255,8 @@ func run(ctx context.Context) error {
254255

255256
cacheOptions := crcache.Options{
256257
ByObject: map[client.Object]crcache.ByObject{},
258+
// Memory optimization: strip managed fields and large annotations from cached objects
259+
DefaultTransform: cacheutil.StripManagedFieldsAndAnnotations(),
257260
}
258261

259262
saKey, err := sautil.GetServiceAccount()

cmd/operator-controller/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ import (
7878
"github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render/registryv1"
7979
"github.com/operator-framework/operator-controller/internal/operator-controller/scheme"
8080
sharedcontrollers "github.com/operator-framework/operator-controller/internal/shared/controllers"
81+
cacheutil "github.com/operator-framework/operator-controller/internal/shared/util/cache"
8182
fsutil "github.com/operator-framework/operator-controller/internal/shared/util/fs"
8283
httputil "github.com/operator-framework/operator-controller/internal/shared/util/http"
8384
imageutil "github.com/operator-framework/operator-controller/internal/shared/util/image"
@@ -257,6 +258,8 @@ func run() error {
257258
cfg.systemNamespace: {LabelSelector: k8slabels.Everything()},
258259
},
259260
DefaultLabelSelector: k8slabels.Nothing(),
261+
// Memory optimization: strip managed fields and large annotations from cached objects
262+
DefaultTransform: cacheutil.StripAnnotations(),
260263
}
261264

262265
if features.OperatorControllerFeatureGate.Enabled(features.BoxcutterRuntime) {

internal/catalogd/controllers/core/clustercatalog_controller.go

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import (
4141
ocv1 "github.com/operator-framework/operator-controller/api/v1"
4242
"github.com/operator-framework/operator-controller/internal/catalogd/storage"
4343
imageutil "github.com/operator-framework/operator-controller/internal/shared/util/image"
44+
k8sutil "github.com/operator-framework/operator-controller/internal/shared/util/k8s"
4445
)
4546

4647
const (
@@ -107,16 +108,16 @@ func (r *ClusterCatalogReconciler) Reconcile(ctx context.Context, req ctrl.Reque
107108
// Do checks before any Update()s, as Update() may modify the resource structure!
108109
updateStatus := !equality.Semantic.DeepEqual(existingCatsrc.Status, reconciledCatsrc.Status)
109110
updateFinalizers := !equality.Semantic.DeepEqual(existingCatsrc.Finalizers, reconciledCatsrc.Finalizers)
110-
unexpectedFieldsChanged := checkForUnexpectedFieldChange(existingCatsrc, *reconciledCatsrc)
111+
unexpectedFieldsChanged := k8sutil.CheckForUnexpectedFieldChange(&existingCatsrc, reconciledCatsrc)
111112

112113
if unexpectedFieldsChanged {
113114
panic("spec or metadata changed by reconciler")
114115
}
115116

116117
// Save the finalizers off to the side. If we update the status, the reconciledCatsrc will be updated
117118
// to contain the new state of the ClusterCatalog, which contains the status update, but (critically)
118-
// does not contain the finalizers. After the status update, we need to re-add the finalizers to the
119-
// reconciledCatsrc before updating the object.
119+
// does not contain the finalizers. After the status update, we will use the saved finalizers in the
120+
// CreateOrPatch()
120121
finalizers := reconciledCatsrc.Finalizers
121122

122123
if updateStatus {
@@ -125,10 +126,12 @@ func (r *ClusterCatalogReconciler) Reconcile(ctx context.Context, req ctrl.Reque
125126
}
126127
}
127128

128-
reconciledCatsrc.Finalizers = finalizers
129-
130129
if updateFinalizers {
131-
if err := r.Update(ctx, reconciledCatsrc); err != nil {
130+
// Use CreateOrPatch to update finalizers on the server
131+
if _, err := controllerutil.CreateOrPatch(ctx, r.Client, reconciledCatsrc, func() error {
132+
reconciledCatsrc.Finalizers = finalizers
133+
return nil
134+
}); err != nil {
132135
reconcileErr = errors.Join(reconcileErr, fmt.Errorf("error updating finalizers: %v", err))
133136
}
134137
}
@@ -415,13 +418,6 @@ func (r *ClusterCatalogReconciler) needsPoll(lastSuccessfulPoll time.Time, catal
415418
return nextPoll.Before(time.Now())
416419
}
417420

418-
// Compare resources - ignoring status & metadata.finalizers
419-
func checkForUnexpectedFieldChange(a, b ocv1.ClusterCatalog) bool {
420-
a.Status, b.Status = ocv1.ClusterCatalogStatus{}, ocv1.ClusterCatalogStatus{}
421-
a.Finalizers, b.Finalizers = []string{}, []string{}
422-
return !equality.Semantic.DeepEqual(a, b)
423-
}
424-
425421
type finalizerFunc func(ctx context.Context, obj client.Object) (crfinalizer.Result, error)
426422

427423
func (f finalizerFunc) Finalize(ctx context.Context, obj client.Object) (crfinalizer.Result, error) {

internal/operator-controller/applier/boxcutter.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727

2828
ocv1 "github.com/operator-framework/operator-controller/api/v1"
2929
"github.com/operator-framework/operator-controller/internal/operator-controller/labels"
30+
"github.com/operator-framework/operator-controller/internal/shared/util/cache"
3031
)
3132

3233
const (
@@ -66,6 +67,9 @@ func (r *SimpleRevisionGenerator) GenerateRevisionFromHelmRelease(
6667
maps.Copy(labels, objectLabels)
6768
obj.SetLabels(labels)
6869

70+
// Memory optimization: strip large annotations
71+
// Note: ApplyStripTransform never returns an error in practice
72+
_ = cache.ApplyStripAnnotationsTransform(&obj)
6973
sanitizedUnstructured(ctx, &obj)
7074

7175
objs = append(objs, ocv1.ClusterExtensionRevisionObject{
@@ -117,6 +121,10 @@ func (r *SimpleRevisionGenerator) GenerateRevision(
117121
unstr := unstructured.Unstructured{Object: unstrObj}
118122
unstr.SetGroupVersionKind(gvk)
119123

124+
// Memory optimization: strip large annotations
125+
if err := cache.ApplyStripAnnotationsTransform(&unstr); err != nil {
126+
return nil, err
127+
}
120128
sanitizedUnstructured(ctx, &unstr)
121129

122130
objs = append(objs, ocv1.ClusterExtensionRevisionObject{

internal/operator-controller/controllers/clusterextension_controller.go

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import (
3535
"sigs.k8s.io/controller-runtime/pkg/builder"
3636
"sigs.k8s.io/controller-runtime/pkg/client"
3737
crcontroller "sigs.k8s.io/controller-runtime/pkg/controller"
38+
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
3839
"sigs.k8s.io/controller-runtime/pkg/event"
3940
crhandler "sigs.k8s.io/controller-runtime/pkg/handler"
4041
"sigs.k8s.io/controller-runtime/pkg/log"
@@ -48,6 +49,7 @@ import (
4849
ocv1 "github.com/operator-framework/operator-controller/api/v1"
4950
"github.com/operator-framework/operator-controller/internal/operator-controller/conditionsets"
5051
"github.com/operator-framework/operator-controller/internal/operator-controller/labels"
52+
k8sutil "github.com/operator-framework/operator-controller/internal/shared/util/k8s"
5153
)
5254

5355
const (
@@ -135,25 +137,28 @@ func (r *ClusterExtensionReconciler) Reconcile(ctx context.Context, req ctrl.Req
135137
updateFinalizers := !equality.Semantic.DeepEqual(existingExt.Finalizers, reconciledExt.Finalizers)
136138

137139
// If any unexpected fields have changed, panic before updating the resource
138-
unexpectedFieldsChanged := checkForUnexpectedClusterExtensionFieldChange(*existingExt, *reconciledExt)
140+
unexpectedFieldsChanged := k8sutil.CheckForUnexpectedFieldChange(existingExt, reconciledExt)
139141
if unexpectedFieldsChanged {
140142
panic("spec or metadata changed by reconciler")
141143
}
142144

143145
// Save the finalizers off to the side. If we update the status, the reconciledExt will be updated
144146
// to contain the new state of the ClusterExtension, which contains the status update, but (critically)
145-
// does not contain the finalizers. After the status update, we need to re-add the finalizers to the
146-
// reconciledExt before updating the object.
147+
// does not contain the finalizers. After the status update, we will use the saved finalizers in the
148+
// CreateOrPatch()
147149
finalizers := reconciledExt.Finalizers
148150
if updateStatus {
149151
if err := r.Client.Status().Update(ctx, reconciledExt); err != nil {
150152
reconcileErr = errors.Join(reconcileErr, fmt.Errorf("error updating status: %v", err))
151153
}
152154
}
153-
reconciledExt.Finalizers = finalizers
154155

155156
if updateFinalizers {
156-
if err := r.Update(ctx, reconciledExt); err != nil {
157+
// Use CreateOrPatch to update finalizers on the server
158+
if _, err := controllerutil.CreateOrPatch(ctx, r.Client, reconciledExt, func() error {
159+
reconciledExt.Finalizers = finalizers
160+
return nil
161+
}); err != nil {
157162
reconcileErr = errors.Join(reconcileErr, fmt.Errorf("error updating finalizers: %v", err))
158163
}
159164
}
@@ -179,13 +184,6 @@ func ensureAllConditionsWithReason(ext *ocv1.ClusterExtension, reason v1alpha1.C
179184
}
180185
}
181186

182-
// Compare resources - ignoring status & metadata.finalizers
183-
func checkForUnexpectedClusterExtensionFieldChange(a, b ocv1.ClusterExtension) bool {
184-
a.Status, b.Status = ocv1.ClusterExtensionStatus{}, ocv1.ClusterExtensionStatus{}
185-
a.Finalizers, b.Finalizers = []string{}, []string{}
186-
return !equality.Semantic.DeepEqual(a, b)
187-
}
188-
189187
// SetDeprecationStatus will set the appropriate deprecation statuses for a ClusterExtension
190188
// based on the provided bundle
191189
func SetDeprecationStatus(ext *ocv1.ClusterExtension, bundleName string, deprecation *declcfg.Deprecation) {
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package cache
2+
3+
import (
4+
"maps"
5+
6+
toolscache "k8s.io/client-go/tools/cache"
7+
crcache "sigs.k8s.io/controller-runtime/pkg/cache"
8+
"sigs.k8s.io/controller-runtime/pkg/client"
9+
)
10+
11+
// stripAnnotations removes memory-heavy annotations that aren't needed for controller operations.
12+
func stripAnnotations(obj interface{}) (interface{}, error) {
13+
if metaObj, ok := obj.(client.Object); ok {
14+
// Remove the last-applied-configuration annotation which can be very large
15+
// Clone the annotations map to avoid modifying shared references
16+
annotations := metaObj.GetAnnotations()
17+
if annotations != nil {
18+
annotations = maps.Clone(annotations)
19+
delete(annotations, "kubectl.kubernetes.io/last-applied-configuration")
20+
if len(annotations) == 0 {
21+
metaObj.SetAnnotations(nil)
22+
} else {
23+
metaObj.SetAnnotations(annotations)
24+
}
25+
}
26+
}
27+
return obj, nil
28+
}
29+
30+
// StripManagedFieldsAndAnnotations returns a cache transform function that removes
31+
// memory-heavy fields that aren't needed for controller operations.
32+
// This significantly reduces memory usage in informer caches by removing:
33+
// - Managed fields (can be several KB per object)
34+
// - kubectl.kubernetes.io/last-applied-configuration annotation (can be very large)
35+
//
36+
// Use this function as a DefaultTransform in controller-runtime cache.Options
37+
// to reduce memory overhead across all cached objects.
38+
//
39+
// Example:
40+
//
41+
// cacheOptions := cache.Options{
42+
// DefaultTransform: cacheutil.StripManagedFieldsAndAnnotations(),
43+
// }
44+
func StripManagedFieldsAndAnnotations() toolscache.TransformFunc {
45+
// Use controller-runtime's built-in TransformStripManagedFields and compose it
46+
// with our custom annotation stripping transform
47+
managedFieldsTransform := crcache.TransformStripManagedFields()
48+
49+
return func(obj interface{}) (interface{}, error) {
50+
// First strip managed fields using controller-runtime's transform
51+
obj, err := managedFieldsTransform(obj)
52+
if err != nil {
53+
return obj, err
54+
}
55+
56+
// Then strip the large annotations
57+
return stripAnnotations(obj)
58+
}
59+
}
60+
61+
// StripAnnotations returns a cache transform function that removes
62+
// memory-heavy annotation fields that aren't needed for controller operations.
63+
// This significantly reduces memory usage in informer caches by removing:
64+
// - kubectl.kubernetes.io/last-applied-configuration annotation (can be very large)
65+
//
66+
// Use this function as a DefaultTransform in controller-runtime cache.Options
67+
// to reduce memory overhead across all cached objects.
68+
//
69+
// Example:
70+
//
71+
// cacheOptions := cache.Options{
72+
// DefaultTransform: cacheutil.StripAnnotations(),
73+
// }
74+
func StripAnnotations() toolscache.TransformFunc {
75+
return func(obj interface{}) (interface{}, error) {
76+
// Strip the large annotations
77+
return stripAnnotations(obj)
78+
}
79+
}
80+
81+
// ApplyStripAnnotationsTransform applies the strip transform directly to an object.
82+
// This is a convenience function for cases where you need to strip fields
83+
// from an object outside of the cache transform context.
84+
//
85+
// Note: This function never returns an error in practice, but returns error
86+
// to satisfy the TransformFunc interface.
87+
func ApplyStripAnnotationsTransform(obj client.Object) error {
88+
transform := StripAnnotations()
89+
_, err := transform(obj)
90+
return err
91+
}

internal/shared/util/k8s/k8s.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package k8s
2+
3+
import (
4+
"reflect"
5+
6+
"k8s.io/apimachinery/pkg/api/equality"
7+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
8+
)
9+
10+
// CheckForUnexpectedFieldChange compares two Kubernetes objects and returns true
11+
// if their annotations, labels, or spec have changed. This is useful for detecting
12+
// unexpected modifications during reconciliation.
13+
//
14+
// The function compares:
15+
// - Annotations (via GetAnnotations)
16+
// - Labels (via GetLabels)
17+
// - Spec (using reflection to access the Spec field, with semantic equality)
18+
//
19+
// Status and finalizers are intentionally not compared, as these are expected
20+
// to change during reconciliation.
21+
//
22+
// This function uses reflection to access the Spec field, so no explicit GetSpec()
23+
// method is required. The objects must have a field named "Spec".
24+
func CheckForUnexpectedFieldChange(a, b metav1.Object) bool {
25+
if !equality.Semantic.DeepEqual(a.GetAnnotations(), b.GetAnnotations()) {
26+
return true
27+
}
28+
if !equality.Semantic.DeepEqual(a.GetLabels(), b.GetLabels()) {
29+
return true
30+
}
31+
32+
// Use reflection to access the Spec field
33+
aVal := reflect.ValueOf(a)
34+
bVal := reflect.ValueOf(b)
35+
36+
// Handle pointer types
37+
if aVal.Kind() == reflect.Ptr {
38+
aVal = aVal.Elem()
39+
}
40+
if bVal.Kind() == reflect.Ptr {
41+
bVal = bVal.Elem()
42+
}
43+
44+
// Get the Spec field from both objects
45+
aSpec := aVal.FieldByName("Spec")
46+
bSpec := bVal.FieldByName("Spec")
47+
48+
// If either Spec field is invalid, return false (no change detected)
49+
if !aSpec.IsValid() || !bSpec.IsValid() {
50+
return false
51+
}
52+
53+
return !equality.Semantic.DeepEqual(aSpec.Interface(), bSpec.Interface())
54+
}

0 commit comments

Comments
 (0)