Skip to content

Commit 6076304

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 6076304

File tree

5 files changed

+426
-25
lines changed

5 files changed

+426
-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: 62 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

internal/operator-controller/applier/boxcutter_test.go

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

763859
for _, tc := range testCases {
@@ -780,7 +876,15 @@ func TestBoxcutter_Apply(t *testing.T) {
780876
testFS := fstest.MapFS{}
781877

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

785889
// Assert
786890
if tc.expectedErr != "" {
@@ -808,13 +912,17 @@ func TestBoxcutter_Apply(t *testing.T) {
808912

809913
func TestBoxcutterStorageMigrator(t *testing.T) {
810914
t.Run("creates revision", func(t *testing.T) {
915+
testScheme := runtime.NewScheme()
916+
require.NoError(t, ocv1.AddToScheme(testScheme))
917+
811918
brb := &mockBundleRevisionBuilder{}
812919
mag := &mockActionGetter{}
813920
client := &clientMock{}
814921
sm := &applier.BoxcutterStorageMigrator{
815922
RevisionGenerator: brb,
816923
ActionClientGetter: mag,
817924
Client: client,
925+
Scheme: testScheme,
818926
}
819927

820928
ext := &ocv1.ClusterExtension{
@@ -828,6 +936,10 @@ func TestBoxcutterStorageMigrator(t *testing.T) {
828936
On("Create", mock.Anything, mock.AnythingOfType("*v1.ClusterExtensionRevision"), mock.Anything).
829937
Once().
830938
Return(nil)
939+
client.
940+
On("Update", mock.Anything, mock.AnythingOfType("*v1.ClusterExtensionRevision"), mock.Anything).
941+
Once().
942+
Return(nil)
831943

832944
err := sm.Migrate(t.Context(), ext, map[string]string{"my-label": "my-value"})
833945
require.NoError(t, err)
@@ -836,13 +948,17 @@ func TestBoxcutterStorageMigrator(t *testing.T) {
836948
})
837949

838950
t.Run("does not create revision when revisions exist", func(t *testing.T) {
951+
testScheme := runtime.NewScheme()
952+
require.NoError(t, ocv1.AddToScheme(testScheme))
953+
839954
brb := &mockBundleRevisionBuilder{}
840955
mag := &mockActionGetter{}
841956
client := &clientMock{}
842957
sm := &applier.BoxcutterStorageMigrator{
843958
RevisionGenerator: brb,
844959
ActionClientGetter: mag,
845960
Client: client,
961+
Scheme: testScheme,
846962
}
847963

848964
ext := &ocv1.ClusterExtension{
@@ -866,6 +982,9 @@ func TestBoxcutterStorageMigrator(t *testing.T) {
866982
})
867983

868984
t.Run("does not create revision when no helm release", func(t *testing.T) {
985+
testScheme := runtime.NewScheme()
986+
require.NoError(t, ocv1.AddToScheme(testScheme))
987+
869988
brb := &mockBundleRevisionBuilder{}
870989
mag := &mockActionGetter{
871990
getClientErr: driver.ErrReleaseNotFound,
@@ -875,6 +994,7 @@ func TestBoxcutterStorageMigrator(t *testing.T) {
875994
RevisionGenerator: brb,
876995
ActionClientGetter: mag,
877996
Client: client,
997+
Scheme: testScheme,
878998
}
879999

8801000
ext := &ocv1.ClusterExtension{
@@ -905,7 +1025,15 @@ func (m *mockBundleRevisionBuilder) GenerateRevisionFromHelmRelease(
9051025
helmRelease *release.Release, ext *ocv1.ClusterExtension,
9061026
objectLabels map[string]string,
9071027
) (*ocv1.ClusterExtensionRevision, error) {
908-
return nil, nil
1028+
return &ocv1.ClusterExtensionRevision{
1029+
ObjectMeta: metav1.ObjectMeta{
1030+
Name: "test-revision",
1031+
Labels: map[string]string{
1032+
controllers.ClusterExtensionRevisionOwnerLabel: ext.Name,
1033+
},
1034+
},
1035+
Spec: ocv1.ClusterExtensionRevisionSpec{},
1036+
}, nil
9091037
}
9101038

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

0 commit comments

Comments
 (0)