Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd suggest putting the expect value in the test case, and adding cases that start from different values. For example: from a version without a release.

Also related: do we need to add code that knows how to parse the new Release field of the olm.package property (around here:

vr, err := bundle.NewLegacyRegistryV1VersionRelease(pkg.Version)
)?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

iirc, @grokspawn's design called for a specific precedence when handling both release and build metadata that we will need to account for.

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))
})
}
18 changes: 15 additions & 3 deletions internal/operator-controller/catalogmetadata/filter/successors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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).
Comment on lines +38 to +40
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if the re-release version is already in the explicit upgrade graph? In that case, should we ignore it and let its explicit channel entry take precedence? That would preserve existing behavior.

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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package filter

import (
"fmt"
"slices"
"testing"

Expand All @@ -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"
)

Expand Down Expand Up @@ -240,3 +242,81 @@ func TestLegacySuccessor(t *testing.T) {
assert.True(t, f(b5))
assert.False(t, f(emptyBundle))
}

// 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"},
{
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))
}

// 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
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=%t", features.ReleaseVersionPriority, prevEnabled)))
})

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))
}
11 changes: 11 additions & 0 deletions internal/operator-controller/features/features.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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()
Expand Down
Loading