Skip to content

Commit ef2c006

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 ef2c006

File tree

5 files changed

+444
-25
lines changed

5 files changed

+444
-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: 69 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,39 @@ 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+
// Note: After Create(), rev.Generation is populated by the API server (typically 1 for new objects)
242+
meta.SetStatusCondition(&rev.Status.Conditions, metav1.Condition{
243+
Type: ocv1.ClusterExtensionRevisionTypeAvailable,
244+
Status: metav1.ConditionTrue,
245+
Reason: ocv1.ClusterExtensionRevisionReasonAvailable,
246+
Message: "Migrated from Helm storage",
247+
ObservedGeneration: rev.Generation,
248+
})
249+
meta.SetStatusCondition(&rev.Status.Conditions, metav1.Condition{
250+
Type: ocv1.ClusterExtensionRevisionTypeSucceeded,
251+
Status: metav1.ConditionTrue,
252+
Reason: ocv1.ClusterExtensionRevisionReasonRolloutSuccess,
253+
Message: "Migrated from Helm storage",
254+
ObservedGeneration: rev.Generation,
255+
})
256+
257+
if err := m.Client.Status().Update(ctx, rev); err != nil {
258+
return fmt.Errorf("updating migrated revision status: %w", err)
259+
}
260+
201261
return nil
202262
}
203263

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

internal/operator-controller/applier/boxcutter_test.go

Lines changed: 151 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,99 @@ 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+
revisionLabels := map[string]string{
771+
controllers.ClusterExtensionRevisionOwnerLabel: ext.Name,
772+
}
773+
if pkg, ok := revisionAnnotations[labels.PackageNameKey]; ok {
774+
revisionLabels[labels.PackageNameKey] = pkg
775+
}
776+
return &ocv1.ClusterExtensionRevision{
777+
ObjectMeta: metav1.ObjectMeta{
778+
Annotations: revisionAnnotations,
779+
Labels: revisionLabels,
780+
},
781+
Spec: ocv1.ClusterExtensionRevisionSpec{
782+
Phases: []ocv1.ClusterExtensionRevisionPhase{
783+
{
784+
Name: string(applier.PhaseDeploy),
785+
Objects: []ocv1.ClusterExtensionRevisionObject{
786+
{
787+
Object: unstructured.Unstructured{
788+
Object: map[string]interface{}{
789+
"apiVersion": "v1",
790+
"kind": "ConfigMap",
791+
"metadata": map[string]interface{}{
792+
"name": "test-cm",
793+
},
794+
},
795+
},
796+
},
797+
},
798+
},
799+
},
800+
},
801+
}, nil
802+
},
803+
},
804+
existingObjs: []client.Object{
805+
ext,
806+
&ocv1.ClusterExtensionRevision{
807+
ObjectMeta: metav1.ObjectMeta{
808+
Name: "test-ext-1",
809+
Annotations: map[string]string{
810+
labels.BundleVersionKey: "1.0.0",
811+
labels.PackageNameKey: "test-package",
812+
},
813+
Labels: map[string]string{
814+
controllers.ClusterExtensionRevisionOwnerLabel: ext.Name,
815+
labels.PackageNameKey: "test-package",
816+
},
817+
},
818+
Spec: ocv1.ClusterExtensionRevisionSpec{
819+
Revision: 1,
820+
Phases: []ocv1.ClusterExtensionRevisionPhase{
821+
{
822+
Name: string(applier.PhaseDeploy),
823+
Objects: []ocv1.ClusterExtensionRevisionObject{
824+
{
825+
Object: unstructured.Unstructured{
826+
Object: map[string]interface{}{
827+
"apiVersion": "v1",
828+
"kind": "ConfigMap",
829+
"metadata": map[string]interface{}{
830+
"name": "test-cm",
831+
},
832+
},
833+
},
834+
},
835+
},
836+
},
837+
},
838+
},
839+
},
840+
},
841+
validate: func(t *testing.T, c client.Client) {
842+
revList := &ocv1.ClusterExtensionRevisionList{}
843+
err := c.List(context.Background(), revList, client.MatchingLabels{controllers.ClusterExtensionRevisionOwnerLabel: ext.Name})
844+
require.NoError(t, err)
845+
// Should still be only 1 revision (in-place update, not new revision)
846+
require.Len(t, revList.Items, 1)
847+
848+
rev := revList.Items[0]
849+
assert.Equal(t, "test-ext-1", rev.Name)
850+
assert.Equal(t, int64(1), rev.Spec.Revision)
851+
// Verify annotations were updated
852+
assert.Equal(t, "1.0.1", rev.Annotations[labels.BundleVersionKey])
853+
assert.Equal(t, "test-package", rev.Annotations[labels.PackageNameKey])
854+
// Verify labels still have package name
855+
assert.Equal(t, ext.Name, rev.Labels[controllers.ClusterExtensionRevisionOwnerLabel])
856+
assert.Equal(t, "test-package", rev.Labels[labels.PackageNameKey])
857+
},
858+
},
761859
}
762860

763861
for _, tc := range testCases {
@@ -780,7 +878,15 @@ func TestBoxcutter_Apply(t *testing.T) {
780878
testFS := fstest.MapFS{}
781879

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

785891
// Assert
786892
if tc.expectedErr != "" {
@@ -808,13 +914,17 @@ func TestBoxcutter_Apply(t *testing.T) {
808914

809915
func TestBoxcutterStorageMigrator(t *testing.T) {
810916
t.Run("creates revision", func(t *testing.T) {
917+
testScheme := runtime.NewScheme()
918+
require.NoError(t, ocv1.AddToScheme(testScheme))
919+
811920
brb := &mockBundleRevisionBuilder{}
812921
mag := &mockActionGetter{}
813922
client := &clientMock{}
814923
sm := &applier.BoxcutterStorageMigrator{
815924
RevisionGenerator: brb,
816925
ActionClientGetter: mag,
817926
Client: client,
927+
Scheme: testScheme,
818928
}
819929

820930
ext := &ocv1.ClusterExtension{
@@ -836,13 +946,17 @@ func TestBoxcutterStorageMigrator(t *testing.T) {
836946
})
837947

838948
t.Run("does not create revision when revisions exist", func(t *testing.T) {
949+
testScheme := runtime.NewScheme()
950+
require.NoError(t, ocv1.AddToScheme(testScheme))
951+
839952
brb := &mockBundleRevisionBuilder{}
840953
mag := &mockActionGetter{}
841954
client := &clientMock{}
842955
sm := &applier.BoxcutterStorageMigrator{
843956
RevisionGenerator: brb,
844957
ActionClientGetter: mag,
845958
Client: client,
959+
Scheme: testScheme,
846960
}
847961

848962
ext := &ocv1.ClusterExtension{
@@ -866,6 +980,9 @@ func TestBoxcutterStorageMigrator(t *testing.T) {
866980
})
867981

868982
t.Run("does not create revision when no helm release", func(t *testing.T) {
983+
testScheme := runtime.NewScheme()
984+
require.NoError(t, ocv1.AddToScheme(testScheme))
985+
869986
brb := &mockBundleRevisionBuilder{}
870987
mag := &mockActionGetter{
871988
getClientErr: driver.ErrReleaseNotFound,
@@ -875,6 +992,7 @@ func TestBoxcutterStorageMigrator(t *testing.T) {
875992
RevisionGenerator: brb,
876993
ActionClientGetter: mag,
877994
Client: client,
995+
Scheme: testScheme,
878996
}
879997

880998
ext := &ocv1.ClusterExtension{
@@ -905,7 +1023,15 @@ func (m *mockBundleRevisionBuilder) GenerateRevisionFromHelmRelease(
9051023
helmRelease *release.Release, ext *ocv1.ClusterExtension,
9061024
objectLabels map[string]string,
9071025
) (*ocv1.ClusterExtensionRevision, error) {
908-
return nil, nil
1026+
return &ocv1.ClusterExtensionRevision{
1027+
ObjectMeta: metav1.ObjectMeta{
1028+
Name: "test-revision",
1029+
Labels: map[string]string{
1030+
controllers.ClusterExtensionRevisionOwnerLabel: ext.Name,
1031+
},
1032+
},
1033+
Spec: ocv1.ClusterExtensionRevisionSpec{},
1034+
}, nil
9091035
}
9101036

9111037
type clientMock struct {
@@ -921,3 +1047,24 @@ func (m *clientMock) Create(ctx context.Context, obj client.Object, opts ...clie
9211047
args := m.Called(ctx, obj, opts)
9221048
return args.Error(0)
9231049
}
1050+
1051+
func (m *clientMock) Status() client.StatusWriter {
1052+
return &statusWriterMock{mock: &m.Mock}
1053+
}
1054+
1055+
type statusWriterMock struct {
1056+
mock *mock.Mock
1057+
}
1058+
1059+
func (s *statusWriterMock) Update(ctx context.Context, obj client.Object, opts ...client.SubResourceUpdateOption) error {
1060+
// Status updates are expected during migration - return success by default
1061+
return nil
1062+
}
1063+
1064+
func (s *statusWriterMock) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.SubResourcePatchOption) error {
1065+
return nil
1066+
}
1067+
1068+
func (s *statusWriterMock) Create(ctx context.Context, obj client.Object, subResource client.Object, opts ...client.SubResourceCreateOption) error {
1069+
return nil
1070+
}

0 commit comments

Comments
 (0)