Skip to content

Commit 9c8f9af

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 9c8f9af

File tree

5 files changed

+352
-45
lines changed

5 files changed

+352
-45
lines changed

cmd/operator-controller/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,7 @@ func setupBoxcutter(
588588
Client: mgr.GetClient(),
589589
ActionClientGetter: acg,
590590
RevisionGenerator: rg,
591+
Scheme: mgr.GetScheme(),
591592
}
592593

593594
baseDiscoveryClient, err := discovery.NewDiscoveryClientForConfig(mgr.GetConfig())

internal/operator-controller/applier/boxcutter.go

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,17 +153,37 @@ func (r *SimpleRevisionGenerator) buildClusterExtensionRevision(
153153
}
154154
}
155155

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

162178
type boxcutterStorageMigratorClient interface {
163179
List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error
164180
Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error
165181
}
166182

183+
// Migrate performs a one-time migration from Helm storage to ClusterExtensionRevision storage.
184+
//
185+
// This enables upgrades from older operator-controller versions that used Helm for state management.
186+
// The migration is idempotent - it checks for existing revisions before attempting to migrate.
167187
func (m *BoxcutterStorageMigrator) Migrate(ctx context.Context, ext *ocv1.ClusterExtension, objectLabels map[string]string) error {
168188
existingRevisionList := ocv1.ClusterExtensionRevisionList{}
169189
if err := m.Client.List(ctx, &existingRevisionList, client.MatchingLabels{
@@ -172,29 +192,37 @@ func (m *BoxcutterStorageMigrator) Migrate(ctx context.Context, ext *ocv1.Cluste
172192
return fmt.Errorf("listing ClusterExtensionRevisions before attempting migration: %w", err)
173193
}
174194
if len(existingRevisionList.Items) != 0 {
175-
// No migration needed.
195+
// No migration needed - already using ClusterExtensionRevision storage.
176196
return nil
177197
}
178198

199+
// Try to find a Helm release to migrate from
179200
ac, err := m.ActionClientGetter.ActionClientFor(ctx, ext)
180201
if err != nil {
181202
return err
182203
}
183204

184205
helmRelease, err := ac.Get(ext.GetName())
185206
if errors.Is(err, driver.ErrReleaseNotFound) {
186-
// no Helm Release -> no prior installation.
207+
// No Helm release exists - this is a fresh installation, no migration needed.
187208
return nil
188209
}
189210
if err != nil {
190211
return err
191212
}
192213

214+
// Create a ClusterExtensionRevision from the Helm release
193215
rev, err := m.RevisionGenerator.GenerateRevisionFromHelmRelease(helmRelease, ext, objectLabels)
194216
if err != nil {
195217
return err
196218
}
197219

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

internal/operator-controller/applier/boxcutter_test.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -808,13 +808,17 @@ func TestBoxcutter_Apply(t *testing.T) {
808808

809809
func TestBoxcutterStorageMigrator(t *testing.T) {
810810
t.Run("creates revision", func(t *testing.T) {
811+
testScheme := runtime.NewScheme()
812+
require.NoError(t, ocv1.AddToScheme(testScheme))
813+
811814
brb := &mockBundleRevisionBuilder{}
812815
mag := &mockActionGetter{}
813816
client := &clientMock{}
814817
sm := &applier.BoxcutterStorageMigrator{
815818
RevisionGenerator: brb,
816819
ActionClientGetter: mag,
817820
Client: client,
821+
Scheme: testScheme,
818822
}
819823

820824
ext := &ocv1.ClusterExtension{
@@ -836,13 +840,17 @@ func TestBoxcutterStorageMigrator(t *testing.T) {
836840
})
837841

838842
t.Run("does not create revision when revisions exist", func(t *testing.T) {
843+
testScheme := runtime.NewScheme()
844+
require.NoError(t, ocv1.AddToScheme(testScheme))
845+
839846
brb := &mockBundleRevisionBuilder{}
840847
mag := &mockActionGetter{}
841848
client := &clientMock{}
842849
sm := &applier.BoxcutterStorageMigrator{
843850
RevisionGenerator: brb,
844851
ActionClientGetter: mag,
845852
Client: client,
853+
Scheme: testScheme,
846854
}
847855

848856
ext := &ocv1.ClusterExtension{
@@ -866,6 +874,9 @@ func TestBoxcutterStorageMigrator(t *testing.T) {
866874
})
867875

868876
t.Run("does not create revision when no helm release", func(t *testing.T) {
877+
testScheme := runtime.NewScheme()
878+
require.NoError(t, ocv1.AddToScheme(testScheme))
879+
869880
brb := &mockBundleRevisionBuilder{}
870881
mag := &mockActionGetter{
871882
getClientErr: driver.ErrReleaseNotFound,
@@ -875,6 +886,7 @@ func TestBoxcutterStorageMigrator(t *testing.T) {
875886
RevisionGenerator: brb,
876887
ActionClientGetter: mag,
877888
Client: client,
889+
Scheme: testScheme,
878890
}
879891

880892
ext := &ocv1.ClusterExtension{
@@ -905,7 +917,15 @@ func (m *mockBundleRevisionBuilder) GenerateRevisionFromHelmRelease(
905917
helmRelease *release.Release, ext *ocv1.ClusterExtension,
906918
objectLabels map[string]string,
907919
) (*ocv1.ClusterExtensionRevision, error) {
908-
return nil, nil
920+
return &ocv1.ClusterExtensionRevision{
921+
ObjectMeta: metav1.ObjectMeta{
922+
Name: "test-revision",
923+
Labels: map[string]string{
924+
controllers.ClusterExtensionRevisionOwnerLabel: ext.Name,
925+
},
926+
},
927+
Spec: ocv1.ClusterExtensionRevisionSpec{},
928+
}, nil
909929
}
910930

911931
type clientMock struct {

internal/operator-controller/controllers/clusterextensionrevision_controller.go

Lines changed: 67 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,56 @@ 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+
//
452+
// This method uses label-based lookups instead of the revision's Spec.Previous field to find
453+
// related revisions. This approach leverages the cache and is more efficient than tracking
454+
// explicit revision chains.
455+
//
456+
// Used by boxcutter for:
457+
// - Collision detection during rollout
458+
// - Finding revisions to archive after successful deployment
459+
func (c *ClusterExtensionRevisionReconciler) ListPreviousRevisions(ctx context.Context, rev *ocv1.ClusterExtensionRevision) ([]client.Object, error) {
460+
ownerLabel, ok := rev.Labels[ClusterExtensionRevisionOwnerLabel]
461+
if !ok {
462+
// No owner label means this revision isn't properly labeled - return empty list
463+
return nil, nil
464+
}
465+
466+
revList := &ocv1.ClusterExtensionRevisionList{}
467+
if err := c.Client.List(ctx, revList, client.MatchingLabels{
468+
ClusterExtensionRevisionOwnerLabel: ownerLabel,
469+
}); err != nil {
470+
return nil, fmt.Errorf("listing revisions: %w", err)
471+
}
472+
473+
previous := make([]client.Object, 0, len(revList.Items))
474+
for i := range revList.Items {
475+
r := &revList.Items[i]
476+
if r.Name == rev.Name {
477+
continue
478+
}
479+
// Skip archived or deleting revisions
480+
if r.Spec.LifecycleState == ocv1.ClusterExtensionRevisionLifecycleStateArchived ||
481+
!r.DeletionTimestamp.IsZero() {
482+
continue
483+
}
484+
previous = append(previous, r)
485+
}
486+
487+
return previous, nil
488+
}
489+
490+
func (c *ClusterExtensionRevisionReconciler) toBoxcutterRevision(ctx context.Context, rev *ocv1.ClusterExtensionRevision) (*boxcutter.Revision, []boxcutter.RevisionReconcileOption, error) {
491+
previous, err := c.ListPreviousRevisions(ctx, rev)
492+
if err != nil {
493+
return nil, nil, fmt.Errorf("listing previous revisions: %w", err)
439494
}
440495

441496
opts := []boxcutter.RevisionReconcileOption{
@@ -476,7 +531,7 @@ func toBoxcutterRevision(rev *ocv1.ClusterExtensionRevision) (*boxcutter.Revisio
476531
if rev.Spec.LifecycleState == ocv1.ClusterExtensionRevisionLifecycleStatePaused {
477532
opts = append(opts, boxcutter.WithPaused{})
478533
}
479-
return r, opts, previous
534+
return r, opts, nil
480535
}
481536

482537
var (

0 commit comments

Comments
 (0)