@@ -680,10 +680,18 @@ func TestClusterExtensionBoxcutterApplierFailsDoesNotLeakDeprecationErrors(t *te
680680
681681 cl , reconciler := newClientAndReconciler (t , func (d * deps ) {
682682 // Boxcutter keeps a rolling revision when apply fails. We mirror that state so the test uses
683- // the same inputs the runtime would see.
683+ // the same inputs the runtime would see. The revision metadata must match the spec so the
684+ // controller recognizes this as a still-valid rollout (not a spec change).
684685 d .RevisionStatesGetter = & MockRevisionStatesGetter {
685686 RevisionStates : & controllers.RevisionStates {
686- RollingOut : []* controllers.RevisionMetadata {{}},
687+ RollingOut : []* controllers.RevisionMetadata {{
688+ Package : "prometheus" ,
689+ Image : "quay.io/operatorhubio/prometheus@fake1.0.0" ,
690+ BundleMetadata : ocv1.BundleMetadata {
691+ Name : "prometheus.v1.0.0" ,
692+ Version : "1.0.0" ,
693+ },
694+ }},
687695 },
688696 }
689697 d .Resolver = resolve .Func (func (ctx context.Context , ext * ocv1.ClusterExtension , installedBundle * ocv1.BundleMetadata ) (* declcfg.Bundle , * bundle.VersionRelease , * declcfg.Deprecation , error ) {
@@ -768,6 +776,165 @@ func TestClusterExtensionBoxcutterApplierFailsDoesNotLeakDeprecationErrors(t *te
768776 require .NoError (t , cl .DeleteAllOf (ctx , & ocv1.ClusterExtension {}))
769777}
770778
779+ // TestClusterExtensionSpecChangeWhileRollingOutReResolvesBundle verifies that when a
780+ // ClusterExtension's spec changes while a revision is rolling out, the controller
781+ // re-resolves the bundle from the catalog instead of continuing to use the stale revision.
782+ //
783+ // Scenario:
784+ // - A revision for v1.0.2 is rolling out (e.g., stuck due to deployment probe failure)
785+ // - User patches the ClusterExtension spec to request v1.0.3
786+ // - The controller detects the spec change and re-resolves from the catalog
787+ // - The resolver returns v1.0.3, which is used for the new rollout
788+ func TestClusterExtensionSpecChangeWhileRollingOutReResolvesBundle (t * testing.T ) {
789+ require .NoError (t , features .OperatorControllerFeatureGate .Set (fmt .Sprintf ("%s=true" , features .BoxcutterRuntime )))
790+ t .Cleanup (func () {
791+ require .NoError (t , features .OperatorControllerFeatureGate .Set (fmt .Sprintf ("%s=false" , features .BoxcutterRuntime )))
792+ })
793+
794+ resolverCalled := false
795+ cl , reconciler := newClientAndReconciler (t , func (d * deps ) {
796+ d .RevisionStatesGetter = & MockRevisionStatesGetter {
797+ RevisionStates : & controllers.RevisionStates {
798+ Installed : & controllers.RevisionMetadata {
799+ Package : "nginx88138" ,
800+ Image : "quay.io/olmqe/nginxolm-operator-bundle:v1.0.1-nginxolm88138" ,
801+ BundleMetadata : ocv1.BundleMetadata {
802+ Name : "nginx88138.v1.0.1" ,
803+ Version : "1.0.1" ,
804+ },
805+ },
806+ RollingOut : []* controllers.RevisionMetadata {{
807+ RevisionName : "extension-88138-2" ,
808+ Package : "nginx88138" ,
809+ Image : "quay.io/olmqe/nginxolm-operator-bundle:v1.0.2-nginx88138" ,
810+ BundleMetadata : ocv1.BundleMetadata {
811+ Name : "nginx88138.v1.0.2" ,
812+ Version : "1.0.2" ,
813+ },
814+ }},
815+ },
816+ }
817+ d .Resolver = resolve .Func (func (ctx context.Context , ext * ocv1.ClusterExtension , installedBundle * ocv1.BundleMetadata ) (* declcfg.Bundle , * bundle.VersionRelease , * declcfg.Deprecation , error ) {
818+ resolverCalled = true
819+ v := bundle.VersionRelease {
820+ Version : bsemver .MustParse ("1.0.3" ),
821+ }
822+ return & declcfg.Bundle {
823+ Name : "nginx88138.v1.0.3" ,
824+ Package : "nginx88138" ,
825+ Image : "quay.io/olmqe/nginxolm-operator-bundle:v1.0.3-nginxolm88138" ,
826+ }, & v , nil , nil
827+ })
828+ d .ImagePuller = & imageutil.MockPuller {ImageFS : fstest.MapFS {}}
829+ d .Applier = & MockApplier {installCompleted : true }
830+ })
831+
832+ ctx := context .Background ()
833+ extKey := types.NamespacedName {Name : fmt .Sprintf ("cluster-extension-test-%s" , rand .String (8 ))}
834+
835+ clusterExtension := & ocv1.ClusterExtension {
836+ ObjectMeta : metav1.ObjectMeta {Name : extKey .Name },
837+ Spec : ocv1.ClusterExtensionSpec {
838+ Source : ocv1.SourceConfig {
839+ SourceType : "Catalog" ,
840+ Catalog : & ocv1.CatalogFilter {
841+ PackageName : "nginx88138" ,
842+ Version : "1.0.3" ,
843+ },
844+ },
845+ Namespace : "default" ,
846+ ServiceAccount : ocv1.ServiceAccountReference {
847+ Name : "default" ,
848+ },
849+ },
850+ }
851+ require .NoError (t , cl .Create (ctx , clusterExtension ))
852+
853+ res , err := reconciler .Reconcile (ctx , ctrl.Request {NamespacedName : extKey })
854+ require .Equal (t , ctrl.Result {}, res )
855+ require .NoError (t , err )
856+
857+ require .True (t , resolverCalled ,
858+ "resolver should be called because the rolling out revision (v1.0.2) does not match the spec (v1.0.3)" )
859+
860+ require .NoError (t , cl .Get (ctx , extKey , clusterExtension ))
861+
862+ installedCond := apimeta .FindStatusCondition (clusterExtension .Status .Conditions , ocv1 .TypeInstalled )
863+ require .NotNil (t , installedCond )
864+ require .Equal (t , metav1 .ConditionTrue , installedCond .Status )
865+ require .Contains (t , installedCond .Message , "1.0.3" )
866+
867+ require .NoError (t , cl .DeleteAllOf (ctx , & ocv1.ClusterExtension {}))
868+ }
869+
870+ // TestClusterExtensionRollingOutRevisionMatchesSpecNoReResolve verifies that when a
871+ // revision is rolling out and the spec hasn't changed, the controller uses the existing
872+ // rolling out revision without re-resolving from the catalog.
873+ func TestClusterExtensionRollingOutRevisionMatchesSpecNoReResolve (t * testing.T ) {
874+ require .NoError (t , features .OperatorControllerFeatureGate .Set (fmt .Sprintf ("%s=true" , features .BoxcutterRuntime )))
875+ t .Cleanup (func () {
876+ require .NoError (t , features .OperatorControllerFeatureGate .Set (fmt .Sprintf ("%s=false" , features .BoxcutterRuntime )))
877+ })
878+
879+ resolverCalled := false
880+ cl , reconciler := newClientAndReconciler (t , func (d * deps ) {
881+ d .RevisionStatesGetter = & MockRevisionStatesGetter {
882+ RevisionStates : & controllers.RevisionStates {
883+ RollingOut : []* controllers.RevisionMetadata {{
884+ Package : "nginx88138" ,
885+ Image : "quay.io/olmqe/nginxolm-operator-bundle:v1.0.2-nginx88138" ,
886+ BundleMetadata : ocv1.BundleMetadata {
887+ Name : "nginx88138.v1.0.2" ,
888+ Version : "1.0.2" ,
889+ },
890+ }},
891+ },
892+ }
893+ d .Resolver = resolve .Func (func (ctx context.Context , ext * ocv1.ClusterExtension , installedBundle * ocv1.BundleMetadata ) (* declcfg.Bundle , * bundle.VersionRelease , * declcfg.Deprecation , error ) {
894+ resolverCalled = true
895+ v := bundle.VersionRelease {
896+ Version : bsemver .MustParse ("1.0.2" ),
897+ }
898+ return & declcfg.Bundle {
899+ Name : "nginx88138.v1.0.2" ,
900+ Package : "nginx88138" ,
901+ Image : "quay.io/olmqe/nginxolm-operator-bundle:v1.0.2-nginx88138" ,
902+ }, & v , nil , nil
903+ })
904+ d .ImagePuller = & imageutil.MockPuller {ImageFS : fstest.MapFS {}}
905+ d .Applier = & MockApplier {installCompleted : true }
906+ })
907+
908+ ctx := context .Background ()
909+ extKey := types.NamespacedName {Name : fmt .Sprintf ("cluster-extension-test-%s" , rand .String (8 ))}
910+
911+ clusterExtension := & ocv1.ClusterExtension {
912+ ObjectMeta : metav1.ObjectMeta {Name : extKey .Name },
913+ Spec : ocv1.ClusterExtensionSpec {
914+ Source : ocv1.SourceConfig {
915+ SourceType : "Catalog" ,
916+ Catalog : & ocv1.CatalogFilter {
917+ PackageName : "nginx88138" ,
918+ Version : "1.0.2" ,
919+ },
920+ },
921+ Namespace : "default" ,
922+ ServiceAccount : ocv1.ServiceAccountReference {
923+ Name : "default" ,
924+ },
925+ },
926+ }
927+ require .NoError (t , cl .Create (ctx , clusterExtension ))
928+
929+ _ , err := reconciler .Reconcile (ctx , ctrl.Request {NamespacedName : extKey })
930+ require .NoError (t , err )
931+
932+ require .False (t , resolverCalled ,
933+ "resolver should NOT be called because the rolling out revision (v1.0.2) matches the spec (v1.0.2)" )
934+
935+ require .NoError (t , cl .DeleteAllOf (ctx , & ocv1.ClusterExtension {}))
936+ }
937+
771938func TestValidateClusterExtension (t * testing.T ) {
772939 tests := []struct {
773940 name string
0 commit comments