Skip to content

Commit 3b4f1da

Browse files
(chore): Use label-based cache for revision lookups instead of explicit chains
Instead of tracking revision history through Spec.Previous fields, we now find related revisions using labels and the controller-runtime cache. This is more efficient and works better with controller-runtime's caching layer. To support this change, the Helm-to-Boxcutter migration now sets ownerReferences on migrated revisions, ensuring they work identically to newly created revisions. Assisted-by: Cursor
1 parent b3f85d5 commit 3b4f1da

File tree

5 files changed

+370
-24
lines changed

5 files changed

+370
-24
lines changed

cmd/operator-controller/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,7 @@ func setupBoxcutter(
586586
ceReconciler.RevisionStatesGetter = &controllers.BoxcutterRevisionStatesGetter{Reader: mgr.GetClient()}
587587
ceReconciler.StorageMigrator = &applier.BoxcutterStorageMigrator{
588588
Client: mgr.GetClient(),
589+
Scheme: mgr.GetScheme(),
589590
ActionClientGetter: acg,
590591
RevisionGenerator: rg,
591592
}

internal/operator-controller/applier/boxcutter.go

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -140,30 +140,56 @@ func (r *SimpleRevisionGenerator) buildClusterExtensionRevision(
140140
ext *ocv1.ClusterExtension,
141141
annotations map[string]string,
142142
) *ocv1.ClusterExtensionRevision {
143+
revisionLabels := map[string]string{
144+
controllers.ClusterExtensionRevisionOwnerLabel: ext.Name,
145+
}
146+
// Package name must be in labels for BoxcutterRevisionStatesGetter to find it
147+
if pkg, ok := annotations[labels.PackageNameKey]; ok {
148+
revisionLabels[labels.PackageNameKey] = pkg
149+
}
150+
143151
return &ocv1.ClusterExtensionRevision{
144152
ObjectMeta: metav1.ObjectMeta{
145153
Annotations: annotations,
146-
Labels: map[string]string{
147-
controllers.ClusterExtensionRevisionOwnerLabel: ext.Name,
148-
},
154+
Labels: revisionLabels,
149155
},
150156
Spec: ocv1.ClusterExtensionRevisionSpec{
151157
Phases: PhaseSort(objects),
152158
},
153159
}
154160
}
155161

162+
// BoxcutterStorageMigrator migrates ClusterExtensions from Helm-based storage to Boxcutter's
163+
// ClusterExtensionRevision storage.
164+
//
165+
// Scenario: Given a ClusterExtension, determine if migration from Helm storage is needed
166+
// - When ClusterExtensionRevisions already exist, no migration needed (return early)
167+
// - When a Helm release exists but no revisions, create ClusterExtensionRevision from Helm release
168+
// - When no Helm release exists and no revisions, this is a fresh install (no migration needed)
169+
//
170+
// The migrated revision includes:
171+
// - Owner label (olm.operatorframework.io/owner) for label-based lookups
172+
// - OwnerReference to parent ClusterExtension for proper garbage collection
173+
// - All objects from the Helm release manifest
174+
//
175+
// This ensures migrated revisions have the same structure as revisions created by normal
176+
// Boxcutter flow, enabling successful upgrades after migration.
156177
type BoxcutterStorageMigrator struct {
157178
ActionClientGetter helmclient.ActionClientGetter
158179
RevisionGenerator ClusterExtensionRevisionGenerator
159180
Client boxcutterStorageMigratorClient
181+
Scheme *runtime.Scheme // Required for setting ownerReferences on migrated revisions
160182
}
161183

162184
type boxcutterStorageMigratorClient interface {
163185
List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error
164186
Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error
165187
}
166188

189+
// Migrate performs a one-time migration from Helm storage to ClusterExtensionRevision storage.
190+
//
191+
// This enables upgrades from older operator-controller versions that used Helm for state management.
192+
// The migration is idempotent - it checks for existing revisions before attempting to migrate.
167193
func (m *BoxcutterStorageMigrator) Migrate(ctx context.Context, ext *ocv1.ClusterExtension, objectLabels map[string]string) error {
168194
existingRevisionList := ocv1.ClusterExtensionRevisionList{}
169195
if err := m.Client.List(ctx, &existingRevisionList, client.MatchingLabels{
@@ -195,6 +221,12 @@ func (m *BoxcutterStorageMigrator) Migrate(ctx context.Context, ext *ocv1.Cluste
195221
return err
196222
}
197223

224+
// Set ownerReference to ensure proper lifecycle management and garbage collection.
225+
// This makes the migrated revision consistent with revisions created by normal Boxcutter flow.
226+
if err := controllerutil.SetControllerReference(ext, rev, m.Scheme); err != nil {
227+
return fmt.Errorf("set ownerref: %w", err)
228+
}
229+
198230
if err := m.Client.Create(ctx, rev); err != nil {
199231
return err
200232
}

internal/operator-controller/applier/boxcutter_test.go

Lines changed: 126 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,8 @@ func Test_SimpleRevisionGenerator_GenerateRevisionFromHelmRelease(t *testing.T)
6969
"olm.operatorframework.io/package-name": "my-package",
7070
},
7171
Labels: map[string]string{
72-
"olm.operatorframework.io/owner": "test-123",
72+
"olm.operatorframework.io/owner": "test-123",
73+
"olm.operatorframework.io/package-name": "my-package",
7374
},
7475
},
7576
Spec: ocv1.ClusterExtensionRevisionSpec{
@@ -758,6 +759,100 @@ func TestBoxcutter_Apply(t *testing.T) {
758759
assert.Contains(t, latest.Spec.Previous, ocv1.ClusterExtensionRevisionPrevious{Name: "rev-4"})
759760
},
760761
},
762+
{
763+
name: "annotation-only update (same phases, different annotations)",
764+
mockBuilder: &mockBundleRevisionBuilder{
765+
makeRevisionFunc: func(bundleFS fs.FS, ext *ocv1.ClusterExtension, objectLabels, revisionAnnotations map[string]string) (*ocv1.ClusterExtensionRevision, error) {
766+
// Return revision with UPDATED annotations but SAME phases
767+
revisionLabels := map[string]string{
768+
controllers.ClusterExtensionRevisionOwnerLabel: ext.Name,
769+
}
770+
if pkg, ok := revisionAnnotations[labels.PackageNameKey]; ok {
771+
revisionLabels[labels.PackageNameKey] = pkg
772+
}
773+
return &ocv1.ClusterExtensionRevision{
774+
ObjectMeta: metav1.ObjectMeta{
775+
Annotations: revisionAnnotations,
776+
Labels: revisionLabels,
777+
},
778+
Spec: ocv1.ClusterExtensionRevisionSpec{
779+
Phases: []ocv1.ClusterExtensionRevisionPhase{
780+
{
781+
Name: string(applier.PhaseDeploy),
782+
Objects: []ocv1.ClusterExtensionRevisionObject{
783+
{
784+
Object: unstructured.Unstructured{
785+
Object: map[string]interface{}{
786+
"apiVersion": "v1",
787+
"kind": "ConfigMap",
788+
"metadata": map[string]interface{}{
789+
"name": "test-cm",
790+
},
791+
},
792+
},
793+
},
794+
},
795+
},
796+
},
797+
},
798+
}, nil
799+
},
800+
},
801+
existingObjs: []client.Object{
802+
ext,
803+
&ocv1.ClusterExtensionRevision{
804+
ObjectMeta: metav1.ObjectMeta{
805+
Name: "test-ext-1",
806+
Annotations: map[string]string{
807+
labels.BundleVersionKey: "1.0.0",
808+
labels.PackageNameKey: "test-package",
809+
},
810+
Labels: map[string]string{
811+
controllers.ClusterExtensionRevisionOwnerLabel: ext.Name,
812+
labels.PackageNameKey: "test-package",
813+
},
814+
},
815+
Spec: ocv1.ClusterExtensionRevisionSpec{
816+
Revision: 1,
817+
Phases: []ocv1.ClusterExtensionRevisionPhase{
818+
{
819+
Name: string(applier.PhaseDeploy),
820+
Objects: []ocv1.ClusterExtensionRevisionObject{
821+
{
822+
Object: unstructured.Unstructured{
823+
Object: map[string]interface{}{
824+
"apiVersion": "v1",
825+
"kind": "ConfigMap",
826+
"metadata": map[string]interface{}{
827+
"name": "test-cm",
828+
},
829+
},
830+
},
831+
},
832+
},
833+
},
834+
},
835+
},
836+
},
837+
},
838+
validate: func(t *testing.T, c client.Client) {
839+
revList := &ocv1.ClusterExtensionRevisionList{}
840+
err := c.List(context.Background(), revList, client.MatchingLabels{controllers.ClusterExtensionRevisionOwnerLabel: ext.Name})
841+
require.NoError(t, err)
842+
// Should still be only 1 revision (in-place update, not new revision)
843+
require.Len(t, revList.Items, 1)
844+
845+
rev := revList.Items[0]
846+
assert.Equal(t, "test-ext-1", rev.Name)
847+
assert.Equal(t, int64(1), rev.Spec.Revision)
848+
// Verify annotations were updated
849+
assert.Equal(t, "1.0.1", rev.Annotations[labels.BundleVersionKey])
850+
assert.Equal(t, "test-package", rev.Annotations[labels.PackageNameKey])
851+
// Verify labels still have package name
852+
assert.Equal(t, ext.Name, rev.Labels[controllers.ClusterExtensionRevisionOwnerLabel])
853+
assert.Equal(t, "test-package", rev.Labels[labels.PackageNameKey])
854+
},
855+
},
761856
}
762857

763858
for _, tc := range testCases {
@@ -780,7 +875,15 @@ func TestBoxcutter_Apply(t *testing.T) {
780875
testFS := fstest.MapFS{}
781876

782877
// Execute
783-
installSucceeded, installStatus, err := boxcutter.Apply(t.Context(), testFS, ext, nil, nil)
878+
revisionAnnotations := map[string]string{}
879+
if tc.name == "annotation-only update (same phases, different annotations)" {
880+
// For annotation-only update test, pass NEW annotations
881+
revisionAnnotations = map[string]string{
882+
labels.BundleVersionKey: "1.0.1",
883+
labels.PackageNameKey: "test-package",
884+
}
885+
}
886+
installSucceeded, installStatus, err := boxcutter.Apply(t.Context(), testFS, ext, nil, revisionAnnotations)
784887

785888
// Assert
786889
if tc.expectedErr != "" {
@@ -808,13 +911,17 @@ func TestBoxcutter_Apply(t *testing.T) {
808911

809912
func TestBoxcutterStorageMigrator(t *testing.T) {
810913
t.Run("creates revision", func(t *testing.T) {
914+
testScheme := runtime.NewScheme()
915+
require.NoError(t, ocv1.AddToScheme(testScheme))
916+
811917
brb := &mockBundleRevisionBuilder{}
812918
mag := &mockActionGetter{}
813919
client := &clientMock{}
814920
sm := &applier.BoxcutterStorageMigrator{
815921
RevisionGenerator: brb,
816922
ActionClientGetter: mag,
817923
Client: client,
924+
Scheme: testScheme,
818925
}
819926

820927
ext := &ocv1.ClusterExtension{
@@ -836,13 +943,17 @@ func TestBoxcutterStorageMigrator(t *testing.T) {
836943
})
837944

838945
t.Run("does not create revision when revisions exist", func(t *testing.T) {
946+
testScheme := runtime.NewScheme()
947+
require.NoError(t, ocv1.AddToScheme(testScheme))
948+
839949
brb := &mockBundleRevisionBuilder{}
840950
mag := &mockActionGetter{}
841951
client := &clientMock{}
842952
sm := &applier.BoxcutterStorageMigrator{
843953
RevisionGenerator: brb,
844954
ActionClientGetter: mag,
845955
Client: client,
956+
Scheme: testScheme,
846957
}
847958

848959
ext := &ocv1.ClusterExtension{
@@ -866,6 +977,9 @@ func TestBoxcutterStorageMigrator(t *testing.T) {
866977
})
867978

868979
t.Run("does not create revision when no helm release", func(t *testing.T) {
980+
testScheme := runtime.NewScheme()
981+
require.NoError(t, ocv1.AddToScheme(testScheme))
982+
869983
brb := &mockBundleRevisionBuilder{}
870984
mag := &mockActionGetter{
871985
getClientErr: driver.ErrReleaseNotFound,
@@ -875,6 +989,7 @@ func TestBoxcutterStorageMigrator(t *testing.T) {
875989
RevisionGenerator: brb,
876990
ActionClientGetter: mag,
877991
Client: client,
992+
Scheme: testScheme,
878993
}
879994

880995
ext := &ocv1.ClusterExtension{
@@ -905,7 +1020,15 @@ func (m *mockBundleRevisionBuilder) GenerateRevisionFromHelmRelease(
9051020
helmRelease *release.Release, ext *ocv1.ClusterExtension,
9061021
objectLabels map[string]string,
9071022
) (*ocv1.ClusterExtensionRevision, error) {
908-
return nil, nil
1023+
return &ocv1.ClusterExtensionRevision{
1024+
ObjectMeta: metav1.ObjectMeta{
1025+
Name: "test-revision",
1026+
Labels: map[string]string{
1027+
controllers.ClusterExtensionRevisionOwnerLabel: ext.Name,
1028+
},
1029+
},
1030+
Spec: ocv1.ClusterExtensionRevisionSpec{},
1031+
}, nil
9091032
}
9101033

9111034
type clientMock struct {

internal/operator-controller/controllers/clusterextensionrevision_controller.go

Lines changed: 59 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import (
1616
"k8s.io/apimachinery/pkg/api/equality"
1717
"k8s.io/apimachinery/pkg/api/meta"
1818
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
19-
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
2019
"k8s.io/apimachinery/pkg/runtime/schema"
2120
"k8s.io/apimachinery/pkg/types"
2221
"k8s.io/apimachinery/pkg/util/sets"
@@ -116,7 +115,17 @@ func checkForUnexpectedClusterExtensionRevisionFieldChange(a, b ocv1.ClusterExte
116115
func (c *ClusterExtensionRevisionReconciler) reconcile(ctx context.Context, rev *ocv1.ClusterExtensionRevision) (ctrl.Result, error) {
117116
l := log.FromContext(ctx)
118117

119-
revision, opts, previous := toBoxcutterRevision(rev)
118+
revision, opts, err := c.toBoxcutterRevision(ctx, rev)
119+
if err != nil {
120+
meta.SetStatusCondition(&rev.Status.Conditions, metav1.Condition{
121+
Type: ocv1.ClusterExtensionRevisionTypeAvailable,
122+
Status: metav1.ConditionFalse,
123+
Reason: ocv1.ClusterExtensionRevisionReasonReconcileFailure,
124+
Message: err.Error(),
125+
ObservedGeneration: rev.Generation,
126+
})
127+
return ctrl.Result{}, fmt.Errorf("converting to boxcutter revision: %v", err)
128+
}
120129

121130
if !rev.DeletionTimestamp.IsZero() || rev.Spec.LifecycleState == ocv1.ClusterExtensionRevisionLifecycleStateArchived {
122131
return c.teardown(ctx, rev, revision)
@@ -212,7 +221,11 @@ func (c *ClusterExtensionRevisionReconciler) reconcile(ctx context.Context, rev
212221

213222
//nolint:nestif
214223
if rres.IsComplete() {
215-
// Archive other revisions.
224+
// Archive previous revisions
225+
previous, err := c.ListPreviousRevisions(ctx, rev)
226+
if err != nil {
227+
return ctrl.Result{}, fmt.Errorf("listing previous revisions: %v", err)
228+
}
216229
for _, a := range previous {
217230
patch := []byte(`{"spec":{"lifecycleState":"Archived"}}`)
218231
if err := c.Client.Patch(ctx, a, client.RawPatch(types.MergePatchType, patch)); err != nil {
@@ -428,14 +441,48 @@ func (c *ClusterExtensionRevisionReconciler) removeFinalizer(ctx context.Context
428441
return nil
429442
}
430443

431-
func toBoxcutterRevision(rev *ocv1.ClusterExtensionRevision) (*boxcutter.Revision, []boxcutter.RevisionReconcileOption, []client.Object) {
432-
previous := make([]client.Object, 0, len(rev.Spec.Previous))
433-
for _, specPrevious := range rev.Spec.Previous {
434-
prev := &unstructured.Unstructured{}
435-
prev.SetName(specPrevious.Name)
436-
prev.SetUID(specPrevious.UID)
437-
prev.SetGroupVersionKind(ocv1.GroupVersion.WithKind(ocv1.ClusterExtensionRevisionKind))
438-
previous = append(previous, prev)
444+
// ListPreviousRevisions returns active revisions belonging to the same ClusterExtension.
445+
//
446+
// Scenario: Given a ClusterExtensionRevision, find all related active revisions
447+
// - When the revision has no owner label, return empty list (revision not properly labeled)
448+
// - When looking up by owner label, filter out the current revision itself
449+
// - When a revision is archived or being deleted, exclude it from results
450+
// - Otherwise include active and paused revisions with matching owner
451+
func (c *ClusterExtensionRevisionReconciler) ListPreviousRevisions(ctx context.Context, rev *ocv1.ClusterExtensionRevision) ([]client.Object, error) {
452+
ownerLabel, ok := rev.Labels[ClusterExtensionRevisionOwnerLabel]
453+
if !ok {
454+
// No owner label means this revision isn't properly labeled - return empty list
455+
return nil, nil
456+
}
457+
458+
revList := &ocv1.ClusterExtensionRevisionList{}
459+
if err := c.Client.List(ctx, revList, client.MatchingLabels{
460+
ClusterExtensionRevisionOwnerLabel: ownerLabel,
461+
}); err != nil {
462+
return nil, fmt.Errorf("listing revisions: %w", err)
463+
}
464+
465+
previous := make([]client.Object, 0, len(revList.Items))
466+
for i := range revList.Items {
467+
r := &revList.Items[i]
468+
if r.Name == rev.Name {
469+
continue
470+
}
471+
// Skip archived or deleting revisions
472+
if r.Spec.LifecycleState == ocv1.ClusterExtensionRevisionLifecycleStateArchived ||
473+
!r.DeletionTimestamp.IsZero() {
474+
continue
475+
}
476+
previous = append(previous, r)
477+
}
478+
479+
return previous, nil
480+
}
481+
482+
func (c *ClusterExtensionRevisionReconciler) toBoxcutterRevision(ctx context.Context, rev *ocv1.ClusterExtensionRevision) (*boxcutter.Revision, []boxcutter.RevisionReconcileOption, error) {
483+
previous, err := c.ListPreviousRevisions(ctx, rev)
484+
if err != nil {
485+
return nil, nil, fmt.Errorf("listing previous revisions: %w", err)
439486
}
440487

441488
opts := []boxcutter.RevisionReconcileOption{
@@ -476,7 +523,7 @@ func toBoxcutterRevision(rev *ocv1.ClusterExtensionRevision) (*boxcutter.Revisio
476523
if rev.Spec.LifecycleState == ocv1.ClusterExtensionRevisionLifecycleStatePaused {
477524
opts = append(opts, boxcutter.WithPaused{})
478525
}
479-
return r, opts, previous
526+
return r, opts, nil
480527
}
481528

482529
var (

0 commit comments

Comments
 (0)