From 7a8ab66c2012600b96b1f8ced3037b956498d149 Mon Sep 17 00:00:00 2001 From: Rashmi Gottipati Date: Fri, 6 Mar 2026 11:18:56 -0500 Subject: [PATCH 1/4] Add ReleaseVersionPriority feature gate for re-release upgrade support Signed-off-by: Rashmi Gottipati --- .../filter/bundle_predicates.go | 18 ++++ .../filter/bundle_predicates_test.go | 86 +++++++++++++++++++ .../catalogmetadata/filter/successors.go | 18 +++- .../catalogmetadata/filter/successors_test.go | 33 +++++++ .../operator-controller/features/features.go | 11 +++ 5 files changed, 163 insertions(+), 3 deletions(-) diff --git a/internal/operator-controller/catalogmetadata/filter/bundle_predicates.go b/internal/operator-controller/catalogmetadata/filter/bundle_predicates.go index 56a17ff8ce..c7e8e76bcd 100644 --- a/internal/operator-controller/catalogmetadata/filter/bundle_predicates.go +++ b/internal/operator-controller/catalogmetadata/filter/bundle_predicates.go @@ -48,3 +48,21 @@ func InAnyChannel(channels ...declcfg.Channel) filter.Predicate[declcfg.Bundle] return false } } + +// SameVersionHigherRelease returns a predicate that matches bundles with the same +// semantic version as the provided version-release, but with a higher release value. +// This is used to identify re-released bundles (e.g., 2.0.0+2 when 2.0.0+1 is installed). +func SameVersionHigherRelease(expect bundle.VersionRelease) filter.Predicate[declcfg.Bundle] { + return func(b declcfg.Bundle) bool { + actual, err := bundleutil.GetVersionAndRelease(b) + if err != nil { + return false + } + + if expect.Version.Compare(actual.Version) != 0 { + return false + } + + return expect.Release.Compare(actual.Release) < 0 + } +} diff --git a/internal/operator-controller/catalogmetadata/filter/bundle_predicates_test.go b/internal/operator-controller/catalogmetadata/filter/bundle_predicates_test.go index 4839190cf0..43a63f8a40 100644 --- a/internal/operator-controller/catalogmetadata/filter/bundle_predicates_test.go +++ b/internal/operator-controller/catalogmetadata/filter/bundle_predicates_test.go @@ -10,6 +10,7 @@ import ( "github.com/operator-framework/operator-registry/alpha/declcfg" "github.com/operator-framework/operator-registry/alpha/property" + "github.com/operator-framework/operator-controller/internal/operator-controller/bundle" "github.com/operator-framework/operator-controller/internal/operator-controller/catalogmetadata/compare" "github.com/operator-framework/operator-controller/internal/operator-controller/catalogmetadata/filter" ) @@ -68,3 +69,88 @@ func TestInAnyChannel(t *testing.T) { assert.False(t, fStable(b2)) assert.False(t, fStable(b3)) } + +func TestSameVersionHigherRelease(t *testing.T) { + const testPackageName = "test-package" + + // Expected bundle version 2.0.0+1 + expect, err := bundle.NewLegacyRegistryV1VersionRelease("2.0.0+1") + require.NoError(t, err) + + tests := []struct { + name string + bundleVersion string + shouldMatch bool + }{ + { + name: "same version, higher release", + bundleVersion: "2.0.0+2", + shouldMatch: true, + }, + { + name: "same version, same release", + bundleVersion: "2.0.0+1", + shouldMatch: false, + }, + { + name: "same version, lower release", + bundleVersion: "2.0.0+0", + shouldMatch: false, + }, + { + name: "same version, no release", + bundleVersion: "2.0.0", + shouldMatch: false, + }, + { + name: "different version, higher release", + bundleVersion: "2.1.0+2", + shouldMatch: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testBundle := declcfg.Bundle{ + Name: "test-package.v" + tt.bundleVersion, + Package: testPackageName, + Properties: []property.Property{ + property.MustBuildPackage(testPackageName, tt.bundleVersion), + }, + } + + f := filter.SameVersionHigherRelease(*expect) + assert.Equal(t, tt.shouldMatch, f(testBundle), "version %s should match=%v", tt.bundleVersion, tt.shouldMatch) + }) + } + + // Test when expected version has no release (e.g: "2.0.0") + t.Run("expected version without release", func(t *testing.T) { + expectNoRelease, err := bundle.NewLegacyRegistryV1VersionRelease("2.0.0") + require.NoError(t, err) + + // Bundle with release should be considered higher + bundleWithRelease := declcfg.Bundle{ + Name: "test-package.v2.0.0+1", + Package: testPackageName, + Properties: []property.Property{ + property.MustBuildPackage(testPackageName, "2.0.0+1"), + }, + } + + f := filter.SameVersionHigherRelease(*expectNoRelease) + assert.True(t, f(bundleWithRelease), "2.0.0+1 should be higher than 2.0.0") + }) + + // Test error case: invalid bundle (no package property) + t.Run("invalid bundle - no package property", func(t *testing.T) { + testBundle := declcfg.Bundle{ + Name: "test-package.invalid", + Package: testPackageName, + Properties: []property.Property{}, + } + + f := filter.SameVersionHigherRelease(*expect) + assert.False(t, f(testBundle)) + }) +} diff --git a/internal/operator-controller/catalogmetadata/filter/successors.go b/internal/operator-controller/catalogmetadata/filter/successors.go index 975c8cb39f..497fab5efd 100644 --- a/internal/operator-controller/catalogmetadata/filter/successors.go +++ b/internal/operator-controller/catalogmetadata/filter/successors.go @@ -9,6 +9,7 @@ import ( ocv1 "github.com/operator-framework/operator-controller/api/v1" "github.com/operator-framework/operator-controller/internal/operator-controller/bundle" + "github.com/operator-framework/operator-controller/internal/operator-controller/features" "github.com/operator-framework/operator-controller/internal/shared/util/filter" ) @@ -28,11 +29,22 @@ func SuccessorsOf(installedBundle ocv1.BundleMetadata, channels ...declcfg.Chann return nil, fmt.Errorf("getting successorsPredicate: %w", err) } - // We need either successors or current version (no upgrade) - return filter.Or( + // Build the final predicate based on feature gate status + predicates := []filter.Predicate[declcfg.Bundle]{ successorsPredicate, ExactVersionRelease(*installedVersionRelease), - ), nil + } + + // If release version priority is enabled, also consider bundles + // with the same semantic version but higher release as valid successors. + // This allows upgrades to re-released bundles (e.g., 2.0.0+1 -> 2.0.0+2). + if features.OperatorControllerFeatureGate.Enabled(features.ReleaseVersionPriority) { + predicates = append(predicates, SameVersionHigherRelease(*installedVersionRelease)) + } + + // Bundle matches if it's a channel successor, current version (no upgrade), + // or (when feature gate enabled) same version with higher release + return filter.Or(predicates...), nil } func legacySuccessor(installedBundle ocv1.BundleMetadata, channels ...declcfg.Channel) (filter.Predicate[declcfg.Bundle], error) { diff --git a/internal/operator-controller/catalogmetadata/filter/successors_test.go b/internal/operator-controller/catalogmetadata/filter/successors_test.go index d22a1fdb2f..6801ef7739 100644 --- a/internal/operator-controller/catalogmetadata/filter/successors_test.go +++ b/internal/operator-controller/catalogmetadata/filter/successors_test.go @@ -240,3 +240,36 @@ func TestLegacySuccessor(t *testing.T) { assert.True(t, f(b5)) assert.False(t, f(emptyBundle)) } + +// TestSuccessorsOf_WithReleaseVersionSupport_FeatureGateDisabled verifies higher releases +// are NOT successors when ReleaseVersionPriority gate is disabled (default). +// TODO: Feature gate enabled behavior must be tested in E2E tests. +func TestSuccessorsOf_WithReleaseVersionSupport_FeatureGateDisabled(t *testing.T) { + channel := declcfg.Channel{ + Entries: []declcfg.ChannelEntry{ + {Name: "test-package.v1.0.0+1"}, + { + Name: "test-package.v2.0.0", + Replaces: "test-package.v1.0.0+1", + }, + }, + } + installedBundle := ocv1.BundleMetadata{ + Name: "test-package.v1.0.0+1", + Version: "1.0.0+1", + } + + higherRelease := declcfg.Bundle{ + Name: "test-package.v1.0.0+2", + Package: "test-package", + Properties: []property.Property{ + property.MustBuildPackage("test-package", "1.0.0+2"), + }, + } + + predicate, err := SuccessorsOf(installedBundle, channel) + require.NoError(t, err) + + // Higher release should NOT match without feature gate + assert.False(t, predicate(higherRelease)) +} diff --git a/internal/operator-controller/features/features.go b/internal/operator-controller/features/features.go index 53ee2626ae..2cc380741d 100644 --- a/internal/operator-controller/features/features.go +++ b/internal/operator-controller/features/features.go @@ -19,6 +19,7 @@ const ( HelmChartSupport featuregate.Feature = "HelmChartSupport" BoxcutterRuntime featuregate.Feature = "BoxcutterRuntime" DeploymentConfig featuregate.Feature = "DeploymentConfig" + ReleaseVersionPriority featuregate.Feature = "ReleaseVersionPriority" ) var operatorControllerFeatureGates = map[featuregate.Feature]featuregate.FeatureSpec{ @@ -89,6 +90,16 @@ var operatorControllerFeatureGates = map[featuregate.Feature]featuregate.Feature PreRelease: featuregate.Alpha, LockToDefault: false, }, + + // ReleaseVersionPriority enables considering bundles with higher release versions + // as valid upgrade successors. When enabled, bundles with the same semantic version + // but higher release values (e.g., 2.0.0+2 as a successor to 2.0.0+1) are included + // in the upgrade candidate set during resolution. + ReleaseVersionPriority: { + Default: false, + PreRelease: featuregate.Alpha, + LockToDefault: false, + }, } var OperatorControllerFeatureGate featuregate.MutableFeatureGate = featuregate.NewFeatureGate() From 885f015fb9b73e343d7fadd1fd90520ba0ffd661 Mon Sep 17 00:00:00 2001 From: Rashmi Gottipati Date: Fri, 6 Mar 2026 12:03:38 -0500 Subject: [PATCH 2/4] fix lint issues Signed-off-by: Rashmi Gottipati --- .../catalogmetadata/filter/successors_test.go | 4 ++-- internal/operator-controller/features/features.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/operator-controller/catalogmetadata/filter/successors_test.go b/internal/operator-controller/catalogmetadata/filter/successors_test.go index 6801ef7739..81aaacfe9c 100644 --- a/internal/operator-controller/catalogmetadata/filter/successors_test.go +++ b/internal/operator-controller/catalogmetadata/filter/successors_test.go @@ -241,10 +241,10 @@ func TestLegacySuccessor(t *testing.T) { assert.False(t, f(emptyBundle)) } -// TestSuccessorsOf_WithReleaseVersionSupport_FeatureGateDisabled verifies higher releases +// TestSuccessorsOf_WithReleaseVersionPriority_FeatureGateDisabled verifies higher releases // are NOT successors when ReleaseVersionPriority gate is disabled (default). // TODO: Feature gate enabled behavior must be tested in E2E tests. -func TestSuccessorsOf_WithReleaseVersionSupport_FeatureGateDisabled(t *testing.T) { +func TestSuccessorsOf_WithReleaseVersionPriority_FeatureGateDisabled(t *testing.T) { channel := declcfg.Channel{ Entries: []declcfg.ChannelEntry{ {Name: "test-package.v1.0.0+1"}, diff --git a/internal/operator-controller/features/features.go b/internal/operator-controller/features/features.go index 2cc380741d..7092963fcb 100644 --- a/internal/operator-controller/features/features.go +++ b/internal/operator-controller/features/features.go @@ -19,7 +19,7 @@ const ( HelmChartSupport featuregate.Feature = "HelmChartSupport" BoxcutterRuntime featuregate.Feature = "BoxcutterRuntime" DeploymentConfig featuregate.Feature = "DeploymentConfig" - ReleaseVersionPriority featuregate.Feature = "ReleaseVersionPriority" + ReleaseVersionPriority featuregate.Feature = "ReleaseVersionPriority" ) var operatorControllerFeatureGates = map[featuregate.Feature]featuregate.FeatureSpec{ From 6157202577e24802c6938d0bfe869bf4e3dd6846 Mon Sep 17 00:00:00 2001 From: Rashmi Gottipati Date: Fri, 6 Mar 2026 12:33:32 -0500 Subject: [PATCH 3/4] add unit test for feature gate enabled Signed-off-by: Rashmi Gottipati --- .../catalogmetadata/filter/successors_test.go | 43 ++++++++++++++++++- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/internal/operator-controller/catalogmetadata/filter/successors_test.go b/internal/operator-controller/catalogmetadata/filter/successors_test.go index 81aaacfe9c..00ecf9773a 100644 --- a/internal/operator-controller/catalogmetadata/filter/successors_test.go +++ b/internal/operator-controller/catalogmetadata/filter/successors_test.go @@ -1,6 +1,7 @@ package filter import ( + "fmt" "slices" "testing" @@ -16,6 +17,7 @@ import ( ocv1 "github.com/operator-framework/operator-controller/api/v1" "github.com/operator-framework/operator-controller/internal/operator-controller/bundleutil" "github.com/operator-framework/operator-controller/internal/operator-controller/catalogmetadata/compare" + "github.com/operator-framework/operator-controller/internal/operator-controller/features" "github.com/operator-framework/operator-controller/internal/shared/util/filter" ) @@ -242,8 +244,7 @@ func TestLegacySuccessor(t *testing.T) { } // TestSuccessorsOf_WithReleaseVersionPriority_FeatureGateDisabled verifies higher releases -// are NOT successors when ReleaseVersionPriority gate is disabled (default). -// TODO: Feature gate enabled behavior must be tested in E2E tests. +// are NOT successors when ReleaseVersionPriority gate is disabled (testing the default behavior). func TestSuccessorsOf_WithReleaseVersionPriority_FeatureGateDisabled(t *testing.T) { channel := declcfg.Channel{ Entries: []declcfg.ChannelEntry{ @@ -273,3 +274,41 @@ func TestSuccessorsOf_WithReleaseVersionPriority_FeatureGateDisabled(t *testing. // Higher release should NOT match without feature gate assert.False(t, predicate(higherRelease)) } + +// TestSuccessorsOf_WithReleaseVersionPriority_FeatureGateEnabled verifies higher releases +// as valid successors when ReleaseVersionPriority gate is enabled. +func TestSuccessorsOf_WithReleaseVersionPriority_FeatureGateEnabled(t *testing.T) { + // Enable the feature gate for this test + require.NoError(t, features.OperatorControllerFeatureGate.Set(fmt.Sprintf("%s=true", features.ReleaseVersionPriority))) + t.Cleanup(func() { + require.NoError(t, features.OperatorControllerFeatureGate.Set(fmt.Sprintf("%s=false", features.ReleaseVersionPriority))) + }) + + channel := declcfg.Channel{ + Entries: []declcfg.ChannelEntry{ + {Name: "test-package.v1.0.0+1"}, + { + Name: "test-package.v2.0.0", + Replaces: "test-package.v1.0.0+1", + }, + }, + } + installedBundle := ocv1.BundleMetadata{ + Name: "test-package.v1.0.0+1", + Version: "1.0.0+1", + } + + higherRelease := declcfg.Bundle{ + Name: "test-package.v1.0.0+2", + Package: "test-package", + Properties: []property.Property{ + property.MustBuildPackage("test-package", "1.0.0+2"), + }, + } + + predicate, err := SuccessorsOf(installedBundle, channel) + require.NoError(t, err) + + // Higher release should match when feature gate is enabled + assert.True(t, predicate(higherRelease)) +} From 8dcab4970403243b43e08ad463812e03774de804 Mon Sep 17 00:00:00 2001 From: Rashmi Gottipati Date: Fri, 6 Mar 2026 13:02:05 -0500 Subject: [PATCH 4/4] fix feature gat cleanup in ReleaseVersionPriority tests Signed-off-by: Rashmi Gottipati --- .../catalogmetadata/filter/successors_test.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/internal/operator-controller/catalogmetadata/filter/successors_test.go b/internal/operator-controller/catalogmetadata/filter/successors_test.go index 00ecf9773a..dc8b11c3dc 100644 --- a/internal/operator-controller/catalogmetadata/filter/successors_test.go +++ b/internal/operator-controller/catalogmetadata/filter/successors_test.go @@ -246,6 +246,13 @@ func TestLegacySuccessor(t *testing.T) { // TestSuccessorsOf_WithReleaseVersionPriority_FeatureGateDisabled verifies higher releases // are NOT successors when ReleaseVersionPriority gate is disabled (testing the default behavior). func TestSuccessorsOf_WithReleaseVersionPriority_FeatureGateDisabled(t *testing.T) { + // Explicitly disable the feature gate for this test + prevEnabled := features.OperatorControllerFeatureGate.Enabled(features.ReleaseVersionPriority) + require.NoError(t, features.OperatorControllerFeatureGate.Set(fmt.Sprintf("%s=false", features.ReleaseVersionPriority))) + t.Cleanup(func() { + require.NoError(t, features.OperatorControllerFeatureGate.Set(fmt.Sprintf("%s=%t", features.ReleaseVersionPriority, prevEnabled))) + }) + channel := declcfg.Channel{ Entries: []declcfg.ChannelEntry{ {Name: "test-package.v1.0.0+1"}, @@ -279,9 +286,10 @@ func TestSuccessorsOf_WithReleaseVersionPriority_FeatureGateDisabled(t *testing. // as valid successors when ReleaseVersionPriority gate is enabled. func TestSuccessorsOf_WithReleaseVersionPriority_FeatureGateEnabled(t *testing.T) { // Enable the feature gate for this test + prevEnabled := features.OperatorControllerFeatureGate.Enabled(features.ReleaseVersionPriority) require.NoError(t, features.OperatorControllerFeatureGate.Set(fmt.Sprintf("%s=true", features.ReleaseVersionPriority))) t.Cleanup(func() { - require.NoError(t, features.OperatorControllerFeatureGate.Set(fmt.Sprintf("%s=false", features.ReleaseVersionPriority))) + require.NoError(t, features.OperatorControllerFeatureGate.Set(fmt.Sprintf("%s=%t", features.ReleaseVersionPriority, prevEnabled))) }) channel := declcfg.Channel{