Skip to content

Commit c7ea251

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 c7ea251

File tree

5 files changed

+450
-25
lines changed

5 files changed

+450
-25
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: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ func (r *SimpleRevisionGenerator) GenerateRevisionFromHelmRelease(
8484
})
8585
rev.Name = fmt.Sprintf("%s-1", ext.Name)
8686
rev.Spec.Revision = 1
87+
// Explicitly set LifecycleState to Active to ensure migrated revision is recognized as active
88+
// even before CRD default is applied by the API server
89+
rev.Spec.LifecycleState = ocv1.ClusterExtensionRevisionLifecycleStateActive
8790
return rev, nil
8891
}
8992

@@ -140,30 +143,57 @@ func (r *SimpleRevisionGenerator) buildClusterExtensionRevision(
140143
ext *ocv1.ClusterExtension,
141144
annotations map[string]string,
142145
) *ocv1.ClusterExtensionRevision {
146+
revisionLabels := map[string]string{
147+
controllers.ClusterExtensionRevisionOwnerLabel: ext.Name,
148+
}
149+
// Package name must be in labels for BoxcutterRevisionStatesGetter to find it
150+
if pkg, ok := annotations[labels.PackageNameKey]; ok {
151+
revisionLabels[labels.PackageNameKey] = pkg
152+
}
153+
143154
return &ocv1.ClusterExtensionRevision{
144155
ObjectMeta: metav1.ObjectMeta{
145156
Annotations: annotations,
146-
Labels: map[string]string{
147-
controllers.ClusterExtensionRevisionOwnerLabel: ext.Name,
148-
},
157+
Labels: revisionLabels,
149158
},
150159
Spec: ocv1.ClusterExtensionRevisionSpec{
151160
Phases: PhaseSort(objects),
152161
},
153162
}
154163
}
155164

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

162187
type boxcutterStorageMigratorClient interface {
163188
List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error
164189
Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error
190+
Status() client.StatusWriter
165191
}
166192

193+
// Migrate performs a one-time migration from Helm storage to ClusterExtensionRevision storage.
194+
//
195+
// This enables upgrades from older operator-controller versions that used Helm for state management.
196+
// The migration is idempotent - it checks for existing revisions before attempting to migrate.
167197
func (m *BoxcutterStorageMigrator) Migrate(ctx context.Context, ext *ocv1.ClusterExtension, objectLabels map[string]string) error {
168198
existingRevisionList := ocv1.ClusterExtensionRevisionList{}
169199
if err := m.Client.List(ctx, &existingRevisionList, client.MatchingLabels{
@@ -195,9 +225,38 @@ func (m *BoxcutterStorageMigrator) Migrate(ctx context.Context, ext *ocv1.Cluste
195225
return err
196226
}
197227

228+
// Set ownerReference to ensure proper lifecycle management and garbage collection.
229+
// This makes the migrated revision consistent with revisions created by normal Boxcutter flow.
230+
if err := controllerutil.SetControllerReference(ext, rev, m.Scheme); err != nil {
231+
return fmt.Errorf("set ownerref: %w", err)
232+
}
233+
198234
if err := m.Client.Create(ctx, rev); err != nil {
199235
return err
200236
}
237+
238+
// Mark the migrated revision as succeeded since the Helm release was already deployed.
239+
// Without Succeeded=True, BoxcutterRevisionStatesGetter treats it as RollingOut instead of Installed,
240+
// which breaks resolution and prevents upgrades.
241+
meta.SetStatusCondition(&rev.Status.Conditions, metav1.Condition{
242+
Type: ocv1.ClusterExtensionRevisionTypeAvailable,
243+
Status: metav1.ConditionTrue,
244+
Reason: ocv1.ClusterExtensionRevisionReasonAvailable,
245+
Message: "Migrated from Helm storage",
246+
ObservedGeneration: rev.Generation,
247+
})
248+
meta.SetStatusCondition(&rev.Status.Conditions, metav1.Condition{
249+
Type: ocv1.ClusterExtensionRevisionTypeSucceeded,
250+
Status: metav1.ConditionTrue,
251+
Reason: ocv1.ClusterExtensionRevisionReasonRolloutSuccess,
252+
Message: "Migrated from Helm storage",
253+
ObservedGeneration: rev.Generation,
254+
})
255+
256+
if err := m.Client.Status().Update(ctx, rev); err != nil {
257+
return fmt.Errorf("updating migrated revision status: %w", err)
258+
}
259+
201260
return nil
202261
}
203262

@@ -270,6 +329,12 @@ func (bc *Boxcutter) apply(ctx context.Context, contentFS fs.FS, ext *ocv1.Clust
270329
case err == nil:
271330
// inplace patch was successful, no changes in phases
272331
state = StateUnchanged
332+
// Re-fetch the revision to get updated metadata and status after the patch
333+
updated := &ocv1.ClusterExtensionRevision{}
334+
if err := bc.Client.Get(ctx, client.ObjectKey{Name: currentRevision.Name}, updated); err != nil {
335+
return false, "", fmt.Errorf("fetching updated revision: %w", err)
336+
}
337+
currentRevision = updated
273338
default:
274339
return false, "", fmt.Errorf("patching %s Revision: %w", desiredRevision.Name, err)
275340
}

internal/operator-controller/applier/boxcutter_test.go

Lines changed: 158 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,12 +68,17 @@ func Test_SimpleRevisionGenerator_GenerateRevisionFromHelmRelease(t *testing.T)
6868
"olm.operatorframework.io/bundle-version": "1.2.0",
6969
"olm.operatorframework.io/package-name": "my-package",
7070
},
71+
// Both owner and package-name labels are required:
72+
// - owner: identifies which ClusterExtension owns this revision (for label-based queries)
73+
// - package-name: needed by BoxcutterRevisionStatesGetter to populate RevisionMetadata.Package field
7174
Labels: map[string]string{
72-
"olm.operatorframework.io/owner": "test-123",
75+
"olm.operatorframework.io/owner": "test-123",
76+
"olm.operatorframework.io/package-name": "my-package",
7377
},
7478
},
7579
Spec: ocv1.ClusterExtensionRevisionSpec{
76-
Revision: 1,
80+
LifecycleState: ocv1.ClusterExtensionRevisionLifecycleStateActive,
81+
Revision: 1,
7782
Phases: []ocv1.ClusterExtensionRevisionPhase{
7883
{
7984
Name: "deploy",
@@ -758,6 +763,100 @@ func TestBoxcutter_Apply(t *testing.T) {
758763
assert.Contains(t, latest.Spec.Previous, ocv1.ClusterExtensionRevisionPrevious{Name: "rev-4"})
759764
},
760765
},
766+
{
767+
name: "annotation-only update (same phases, different annotations)",
768+
mockBuilder: &mockBundleRevisionBuilder{
769+
makeRevisionFunc: func(bundleFS fs.FS, ext *ocv1.ClusterExtension, objectLabels, revisionAnnotations map[string]string) (*ocv1.ClusterExtensionRevision, error) {
770+
// Return revision with UPDATED annotations but SAME phases
771+
revisionLabels := map[string]string{
772+
controllers.ClusterExtensionRevisionOwnerLabel: ext.Name,
773+
}
774+
if pkg, ok := revisionAnnotations[labels.PackageNameKey]; ok {
775+
revisionLabels[labels.PackageNameKey] = pkg
776+
}
777+
return &ocv1.ClusterExtensionRevision{
778+
ObjectMeta: metav1.ObjectMeta{
779+
Annotations: revisionAnnotations,
780+
Labels: revisionLabels,
781+
},
782+
Spec: ocv1.ClusterExtensionRevisionSpec{
783+
Phases: []ocv1.ClusterExtensionRevisionPhase{
784+
{
785+
Name: string(applier.PhaseDeploy),
786+
Objects: []ocv1.ClusterExtensionRevisionObject{
787+
{
788+
Object: unstructured.Unstructured{
789+
Object: map[string]interface{}{
790+
"apiVersion": "v1",
791+
"kind": "ConfigMap",
792+
"metadata": map[string]interface{}{
793+
"name": "test-cm",
794+
},
795+
},
796+
},
797+
},
798+
},
799+
},
800+
},
801+
},
802+
}, nil
803+
},
804+
},
805+
existingObjs: []client.Object{
806+
ext,
807+
&ocv1.ClusterExtensionRevision{
808+
ObjectMeta: metav1.ObjectMeta{
809+
Name: "test-ext-1",
810+
Annotations: map[string]string{
811+
labels.BundleVersionKey: "1.0.0",
812+
labels.PackageNameKey: "test-package",
813+
},
814+
Labels: map[string]string{
815+
controllers.ClusterExtensionRevisionOwnerLabel: ext.Name,
816+
labels.PackageNameKey: "test-package",
817+
},
818+
},
819+
Spec: ocv1.ClusterExtensionRevisionSpec{
820+
Revision: 1,
821+
Phases: []ocv1.ClusterExtensionRevisionPhase{
822+
{
823+
Name: string(applier.PhaseDeploy),
824+
Objects: []ocv1.ClusterExtensionRevisionObject{
825+
{
826+
Object: unstructured.Unstructured{
827+
Object: map[string]interface{}{
828+
"apiVersion": "v1",
829+
"kind": "ConfigMap",
830+
"metadata": map[string]interface{}{
831+
"name": "test-cm",
832+
},
833+
},
834+
},
835+
},
836+
},
837+
},
838+
},
839+
},
840+
},
841+
},
842+
validate: func(t *testing.T, c client.Client) {
843+
revList := &ocv1.ClusterExtensionRevisionList{}
844+
err := c.List(context.Background(), revList, client.MatchingLabels{controllers.ClusterExtensionRevisionOwnerLabel: ext.Name})
845+
require.NoError(t, err)
846+
// Should still be only 1 revision (in-place update, not new revision)
847+
require.Len(t, revList.Items, 1)
848+
849+
rev := revList.Items[0]
850+
assert.Equal(t, "test-ext-1", rev.Name)
851+
assert.Equal(t, int64(1), rev.Spec.Revision)
852+
// Verify annotations were updated
853+
assert.Equal(t, "1.0.1", rev.Annotations[labels.BundleVersionKey])
854+
assert.Equal(t, "test-package", rev.Annotations[labels.PackageNameKey])
855+
// Verify labels still have package name
856+
assert.Equal(t, ext.Name, rev.Labels[controllers.ClusterExtensionRevisionOwnerLabel])
857+
assert.Equal(t, "test-package", rev.Labels[labels.PackageNameKey])
858+
},
859+
},
761860
}
762861

763862
for _, tc := range testCases {
@@ -780,7 +879,15 @@ func TestBoxcutter_Apply(t *testing.T) {
780879
testFS := fstest.MapFS{}
781880

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

785892
// Assert
786893
if tc.expectedErr != "" {
@@ -808,13 +915,17 @@ func TestBoxcutter_Apply(t *testing.T) {
808915

809916
func TestBoxcutterStorageMigrator(t *testing.T) {
810917
t.Run("creates revision", func(t *testing.T) {
918+
testScheme := runtime.NewScheme()
919+
require.NoError(t, ocv1.AddToScheme(testScheme))
920+
811921
brb := &mockBundleRevisionBuilder{}
812922
mag := &mockActionGetter{}
813923
client := &clientMock{}
814924
sm := &applier.BoxcutterStorageMigrator{
815925
RevisionGenerator: brb,
816926
ActionClientGetter: mag,
817927
Client: client,
928+
Scheme: testScheme,
818929
}
819930

820931
ext := &ocv1.ClusterExtension{
@@ -828,6 +939,10 @@ func TestBoxcutterStorageMigrator(t *testing.T) {
828939
On("Create", mock.Anything, mock.AnythingOfType("*v1.ClusterExtensionRevision"), mock.Anything).
829940
Once().
830941
Return(nil)
942+
client.
943+
On("Update", mock.Anything, mock.AnythingOfType("*v1.ClusterExtensionRevision"), mock.Anything).
944+
Once().
945+
Return(nil)
831946

832947
err := sm.Migrate(t.Context(), ext, map[string]string{"my-label": "my-value"})
833948
require.NoError(t, err)
@@ -836,13 +951,17 @@ func TestBoxcutterStorageMigrator(t *testing.T) {
836951
})
837952

838953
t.Run("does not create revision when revisions exist", func(t *testing.T) {
954+
testScheme := runtime.NewScheme()
955+
require.NoError(t, ocv1.AddToScheme(testScheme))
956+
839957
brb := &mockBundleRevisionBuilder{}
840958
mag := &mockActionGetter{}
841959
client := &clientMock{}
842960
sm := &applier.BoxcutterStorageMigrator{
843961
RevisionGenerator: brb,
844962
ActionClientGetter: mag,
845963
Client: client,
964+
Scheme: testScheme,
846965
}
847966

848967
ext := &ocv1.ClusterExtension{
@@ -866,6 +985,9 @@ func TestBoxcutterStorageMigrator(t *testing.T) {
866985
})
867986

868987
t.Run("does not create revision when no helm release", func(t *testing.T) {
988+
testScheme := runtime.NewScheme()
989+
require.NoError(t, ocv1.AddToScheme(testScheme))
990+
869991
brb := &mockBundleRevisionBuilder{}
870992
mag := &mockActionGetter{
871993
getClientErr: driver.ErrReleaseNotFound,
@@ -875,6 +997,7 @@ func TestBoxcutterStorageMigrator(t *testing.T) {
875997
RevisionGenerator: brb,
876998
ActionClientGetter: mag,
877999
Client: client,
1000+
Scheme: testScheme,
8781001
}
8791002

8801003
ext := &ocv1.ClusterExtension{
@@ -905,7 +1028,15 @@ func (m *mockBundleRevisionBuilder) GenerateRevisionFromHelmRelease(
9051028
helmRelease *release.Release, ext *ocv1.ClusterExtension,
9061029
objectLabels map[string]string,
9071030
) (*ocv1.ClusterExtensionRevision, error) {
908-
return nil, nil
1031+
return &ocv1.ClusterExtensionRevision{
1032+
ObjectMeta: metav1.ObjectMeta{
1033+
Name: "test-revision",
1034+
Labels: map[string]string{
1035+
controllers.ClusterExtensionRevisionOwnerLabel: ext.Name,
1036+
},
1037+
},
1038+
Spec: ocv1.ClusterExtensionRevisionSpec{},
1039+
}, nil
9091040
}
9101041

9111042
type clientMock struct {
@@ -921,3 +1052,26 @@ func (m *clientMock) Create(ctx context.Context, obj client.Object, opts ...clie
9211052
args := m.Called(ctx, obj, opts)
9221053
return args.Error(0)
9231054
}
1055+
1056+
func (m *clientMock) Status() client.StatusWriter {
1057+
return &statusWriterMock{mock: &m.Mock}
1058+
}
1059+
1060+
type statusWriterMock struct {
1061+
mock *mock.Mock
1062+
}
1063+
1064+
func (s *statusWriterMock) Update(ctx context.Context, obj client.Object, opts ...client.SubResourceUpdateOption) error {
1065+
args := s.mock.Called(ctx, obj, opts)
1066+
return args.Error(0)
1067+
}
1068+
1069+
func (s *statusWriterMock) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.SubResourcePatchOption) error {
1070+
args := s.mock.Called(ctx, obj, patch, opts)
1071+
return args.Error(0)
1072+
}
1073+
1074+
func (s *statusWriterMock) Create(ctx context.Context, obj client.Object, subResource client.Object, opts ...client.SubResourceCreateOption) error {
1075+
args := s.mock.Called(ctx, obj, subResource, opts)
1076+
return args.Error(0)
1077+
}

0 commit comments

Comments
 (0)