From 489a9e60f1a0f6004f5f75240bc1583749cdbd1a Mon Sep 17 00:00:00 2001 From: Predrag Knezevic Date: Tue, 13 Jan 2026 19:07:32 +0100 Subject: [PATCH] Prevent showing duplicate entry under .status.activeRevisions Updating `ClusterExtension` with duplicate entry under `.status.activeRevisions` fails. Thus, we repopulate it from the installed and rolling out revisions. --- .../controllers/boxcutter_reconcile_steps.go | 1 + .../boxcutter_reconcile_steps_apply_test.go | 157 ++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 internal/operator-controller/controllers/boxcutter_reconcile_steps_apply_test.go diff --git a/internal/operator-controller/controllers/boxcutter_reconcile_steps.go b/internal/operator-controller/controllers/boxcutter_reconcile_steps.go index 01bb2232d6..0927a5f001 100644 --- a/internal/operator-controller/controllers/boxcutter_reconcile_steps.go +++ b/internal/operator-controller/controllers/boxcutter_reconcile_steps.go @@ -115,6 +115,7 @@ func ApplyBundleWithBoxcutter(a Applier) ReconcileStepFunc { return nil, err } + ext.Status.ActiveRevisions = []ocv1.RevisionStatus{} // Mirror Available/Progressing conditions from the installed revision if i := state.revisionStates.Installed; i != nil { for _, cndType := range []string{ocv1.ClusterExtensionRevisionTypeAvailable, ocv1.ClusterExtensionRevisionTypeProgressing} { diff --git a/internal/operator-controller/controllers/boxcutter_reconcile_steps_apply_test.go b/internal/operator-controller/controllers/boxcutter_reconcile_steps_apply_test.go new file mode 100644 index 0000000000..87e4490a5b --- /dev/null +++ b/internal/operator-controller/controllers/boxcutter_reconcile_steps_apply_test.go @@ -0,0 +1,157 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controllers + +import ( + "context" + "io/fs" + "testing" + "testing/fstest" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + ocv1 "github.com/operator-framework/operator-controller/api/v1" +) + +type mockApplier struct { + err error +} + +func (m *mockApplier) Apply(_ context.Context, _ fs.FS, _ *ocv1.ClusterExtension, _ map[string]string, _ map[string]string) (bool, string, error) { + return true, "", m.err +} + +func TestApplyBundleWithBoxcutter(t *testing.T) { + type args struct { + activeRevisions []ocv1.RevisionStatus + revisionStates *RevisionStates + } + type want struct { + activeRevisions []ocv1.RevisionStatus + } + + for _, tc := range []struct { + name string + args args + want want + }{ + { + name: "two active revisions during update", + args: args{ + activeRevisions: []ocv1.RevisionStatus{ + {Name: "ce-1"}, + }, + revisionStates: &RevisionStates{ + Installed: &RevisionMetadata{ + RevisionName: "ce-1", + BundleMetadata: ocv1.BundleMetadata{ + Name: "test-bundle", + Version: "1.0.0", + }, + }, + RollingOut: []*RevisionMetadata{ + {RevisionName: "ce-2"}, + }, + }, + }, + want: want{ + activeRevisions: []ocv1.RevisionStatus{ + {Name: "ce-1"}, + {Name: "ce-2"}, + }, + }, + }, + { + name: "replaces existing with new active revisions", + args: args{ + activeRevisions: []ocv1.RevisionStatus{ + {Name: "ce-1"}, + }, + revisionStates: &RevisionStates{ + Installed: &RevisionMetadata{ + RevisionName: "ce-2", + BundleMetadata: ocv1.BundleMetadata{ + Name: "test-bundle", + Version: "1.0.1", + }, + }, + }, + }, + want: want{ + activeRevisions: []ocv1.RevisionStatus{ + {Name: "ce-2"}, + }, + }, + }, + { + name: "ongoing installation", + args: args{ + activeRevisions: []ocv1.RevisionStatus{ + {Name: "ce-1"}, + }, + revisionStates: &RevisionStates{ + RollingOut: []*RevisionMetadata{ + {RevisionName: "ce-1"}, + }, + }, + }, + want: want{ + activeRevisions: []ocv1.RevisionStatus{ + {Name: "ce-1"}, + }, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + applier := &mockApplier{} + + ext := &ocv1.ClusterExtension{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ext", + Generation: 1, + }, + Status: ocv1.ClusterExtensionStatus{ + ActiveRevisions: tc.args.activeRevisions, + }, + } + + state := &reconcileState{ + revisionStates: tc.args.revisionStates, + resolvedRevisionMetadata: &RevisionMetadata{ + BundleMetadata: ocv1.BundleMetadata{ + Name: "test-bundle", + Version: "1.0.0", + }, + }, + imageFS: fstest.MapFS{}, + } + + stepFunc := ApplyBundleWithBoxcutter(applier) + result, err := stepFunc(ctx, state, ext) + require.NoError(t, err) + require.Nil(t, result) + + require.Len(t, ext.Status.ActiveRevisions, len(tc.want.activeRevisions)) + for i, expected := range tc.want.activeRevisions { + require.Equal(t, expected.Name, ext.Status.ActiveRevisions[i].Name, + "ActiveRevisions[%d].Name mismatch", i) + } + }) + } +}