Skip to content
Open
1 change: 1 addition & 0 deletions helm/experimental.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ options:
- HelmChartSupport
- BoxcutterRuntime
- DeploymentConfig
- BundleReleaseSupport
disabled:
- WebhookProviderOpenshiftServiceCA
# List of enabled experimental features for catalogd
Expand Down
50 changes: 36 additions & 14 deletions internal/operator-controller/bundleutil/bundle.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,30 +11,52 @@ 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"
)

func GetVersionAndRelease(b declcfg.Bundle) (*bundle.VersionRelease, error) {
for _, p := range b.Properties {
if p.Type == property.TypePackage {
var pkg property.Package
if err := json.Unmarshal(p.Value, &pkg); err != nil {
return nil, fmt.Errorf("error unmarshalling package property: %w", err)
}

// TODO: For now, we assume that all bundles are registry+v1 bundles.
// In the future, when we support other bundle formats, we should stop
// using the legacy mechanism (i.e. using build metadata in the version)
// to determine the bundle's release.
vr, err := bundle.NewLegacyRegistryV1VersionRelease(pkg.Version)
if err != nil {
return nil, err
}
return vr, nil
return parseVersionRelease(p.Value)
}
}
return nil, fmt.Errorf("no package property found in bundle %q", b.Name)
}

func parseVersionRelease(pkgData json.RawMessage) (*bundle.VersionRelease, error) {
var pkg property.Package
if err := json.Unmarshal(pkgData, &pkg); err != nil {
return nil, fmt.Errorf("error unmarshalling package property: %w", err)
}

// When BundleReleaseSupport is enabled and bundle has explicit release field, use it.
// Note: Build metadata is preserved here because with an explicit release field,
// build metadata serves its proper semver purpose (e.g., git commit, build number).
// In contrast, NewLegacyRegistryV1VersionRelease clears build metadata because it
// interprets build metadata AS the release value for registry+v1 bundles.
if features.OperatorControllerFeatureGate.Enabled(features.BundleReleaseSupport) && pkg.Release != "" {
vers, err := bsemver.Parse(pkg.Version)
if err != nil {
return nil, fmt.Errorf("error parsing version %q: %w", pkg.Version, err)
}
rel, err := bundle.NewRelease(pkg.Release)
if err != nil {
return nil, fmt.Errorf("error parsing release %q: %w", pkg.Release, err)
}
return &bundle.VersionRelease{
Version: vers,
Release: rel,
}, nil
}

// Fall back to legacy registry+v1 behavior (release in build metadata)
vr, err := bundle.NewLegacyRegistryV1VersionRelease(pkg.Version)
if err != nil {
return nil, err
}
return vr, nil
}

// MetadataFor returns a BundleMetadata for the given bundle name and version.
func MetadataFor(bundleName string, bundleVersion bsemver.Version) ocv1.BundleMetadata {
return ocv1.BundleMetadata{
Expand Down
113 changes: 112 additions & 1 deletion internal/operator-controller/bundleutil/bundle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package bundleutil_test

import (
"encoding/json"
"fmt"
"testing"

bsemver "github.com/blang/semver/v4"
Expand All @@ -12,6 +13,7 @@ import (

"github.com/operator-framework/operator-controller/internal/operator-controller/bundle"
"github.com/operator-framework/operator-controller/internal/operator-controller/bundleutil"
"github.com/operator-framework/operator-controller/internal/operator-controller/features"
)

func TestGetVersionAndRelease(t *testing.T) {
Expand Down Expand Up @@ -83,12 +85,121 @@ func TestGetVersionAndRelease(t *testing.T) {
Properties: properties,
}

_, err := bundleutil.GetVersionAndRelease(bundle)
actual, err := bundleutil.GetVersionAndRelease(bundle)
if tc.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, tc.wantVersionRelease, actual)
}
})
}
}

// TestGetVersionAndRelease_WithBundleReleaseSupport tests the feature-gated parsing behavior.
// Explicitly sets the gate to test both enabled and disabled paths.
func TestGetVersionAndRelease_WithBundleReleaseSupport(t *testing.T) {
t.Run("gate enabled - parses explicit release field", func(t *testing.T) {
// Enable the feature gate for this test
prevEnabled := features.OperatorControllerFeatureGate.Enabled(features.BundleReleaseSupport)
require.NoError(t, features.OperatorControllerFeatureGate.Set("BundleReleaseSupport=true"))
t.Cleanup(func() {
require.NoError(t, features.OperatorControllerFeatureGate.Set(fmt.Sprintf("BundleReleaseSupport=%t", prevEnabled)))
})

tests := []struct {
name string
pkgProperty *property.Property
wantVersionRelease *bundle.VersionRelease
wantErr bool
}{
{
name: "explicit release field - takes precedence over build metadata",
pkgProperty: &property.Property{
Type: property.TypePackage,
Value: json.RawMessage(`{"version": "1.0.0+ignored", "release": "2"}`),
},
wantVersionRelease: &bundle.VersionRelease{
Version: bsemver.MustParse("1.0.0+ignored"), // Build metadata preserved - serves its proper semver purpose
Release: bundle.Release([]bsemver.PRVersion{
{VersionNum: 2, IsNum: true},
}),
},
wantErr: false,
},
{
name: "explicit release field - complex release",
pkgProperty: &property.Property{
Type: property.TypePackage,
Value: json.RawMessage(`{"version": "2.1.0", "release": "1.alpha.3"}`),
},
wantVersionRelease: &bundle.VersionRelease{
Version: bsemver.MustParse("2.1.0"),
Release: bundle.Release([]bsemver.PRVersion{
{VersionNum: 1, IsNum: true},
{VersionStr: "alpha"},
{VersionNum: 3, IsNum: true},
}),
},
wantErr: false,
},
{
name: "explicit release field - invalid release",
pkgProperty: &property.Property{
Type: property.TypePackage,
Value: json.RawMessage(`{"version": "1.0.0", "release": "001"}`),
},
wantErr: true,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
bundle := declcfg.Bundle{
Name: "test-bundle",
Properties: []property.Property{*tc.pkgProperty},
}

actual, err := bundleutil.GetVersionAndRelease(bundle)
if tc.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, tc.wantVersionRelease, actual)
}
})
}
})

t.Run("gate disabled - ignores explicit release field, uses build metadata", func(t *testing.T) {
// Disable the feature gate for this test
prevEnabled := features.OperatorControllerFeatureGate.Enabled(features.BundleReleaseSupport)
require.NoError(t, features.OperatorControllerFeatureGate.Set("BundleReleaseSupport=false"))
t.Cleanup(func() {
require.NoError(t, features.OperatorControllerFeatureGate.Set(fmt.Sprintf("BundleReleaseSupport=%t", prevEnabled)))
})

// When gate disabled, explicit release field is ignored and parsing falls back to legacy behavior
bundleWithExplicitRelease := declcfg.Bundle{
Name: "test-bundle",
Properties: []property.Property{
{
Type: property.TypePackage,
Value: json.RawMessage(`{"version": "1.0.0+2", "release": "999"}`),
},
},
}

actual, err := bundleutil.GetVersionAndRelease(bundleWithExplicitRelease)
require.NoError(t, err)

// Should parse build metadata (+2), not explicit release field (999)
expected := &bundle.VersionRelease{
Version: bsemver.MustParse("1.0.0"),
Release: bundle.Release([]bsemver.PRVersion{
{VersionNum: 2, IsNum: true},
}),
}
require.Equal(t, expected, actual)
})
}
37 changes: 35 additions & 2 deletions internal/operator-controller/catalogmetadata/compare/compare.go
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-controller/internal/operator-controller/bundleutil"
"github.com/operator-framework/operator-controller/internal/operator-controller/features"
slicesutil "github.com/operator-framework/operator-controller/internal/shared/util/slices"
)

Expand Down Expand Up @@ -39,9 +40,27 @@ func newMastermindsRange(versionRange string) (bsemver.Range, error) {
}

// ByVersionAndRelease is a comparison function that compares bundles by
// version and release. Bundles with lower versions/releases are
// considered less than bundles with higher versions/releases.
// version and release. When the BundleReleaseSupport feature gate is
// enabled, it uses Bundle.Compare() (which reads pkg.Release from olm.package)
// and falls back to build metadata comparison if equal. When disabled, it uses
// version+release from build metadata (backward compatible).
func ByVersionAndRelease(b1, b2 declcfg.Bundle) int {
if features.OperatorControllerFeatureGate.Enabled(features.BundleReleaseSupport) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This change feels off to me.

It seems like this function shouldn't change at all. The change, as I understand it, boils down to how we build a version/release.

Without the feature gate: Only do it the legacy way by inspecting/parsing build metadata

With the feature gate: If release field is set, parse it directly. Only if unset do we fall back to legacy registry+v1 build metadata parsing.

But then once we have a VersionRelease, the comparison is the same.

Am I missing something else?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Maybe the thing I'm missing is:

  • operator-registry has a CompositeVersion type
  • operator-controller has a VersionRelease type.

These represent the same exact concept, and we're trying to move in the direction of using the operator-registry variant?

IMO, we shouldn't mix them. We should pick one and delete the other.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I completely agree that we need to consolidate to a single representative type.
This PR is not intended to force the type consistency yet, since if it adopted the CompositeVersion type right now it would lack a formal Release type which we determined was essential to encourage broad adoption.
It just provides the featuregate and the sensitivity to an explicit release version.
I'd intended to merge operator-framework/operator-registry#1938 and then we would have follow-ups to op-reg & op-con to adopt the base type consistently throughout.
I was just hoping to be able to merge the two PRs independently.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

So WDYT about this PR focusing on "if release explicitly set, load it into VersionRelease value". And then a later PR comes in a moves everything over to the operator-registry types/comparisons?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Seems reasonable to me.

// Use CompositeVersion comparison (reads pkg.Release from olm.package).
// Note: Bundle.Compare() returns 0 in three cases:
// 1. Bundle names are identical
// 2. Versions and releases are equal
// 3. Parsing errors occur (silently swallowed)
// Catalog validation should catch malformed versions before they reach this code.
result := b2.Compare(&b1)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Comparing at the bundle level seems incorrect to me based on the name of this function. What if the bundle comparison method considers more than the version and release?

Seems like we'd want b2.CompositeVersion.Compare(&b1.CompositeVersion) or something like that?

if result != 0 {
return result
}
// If CompositeVersion comparison is equal, fall back to version+release comparison
return compareByVersionRelease(b1, b2)
}

// Default: use version+release from build metadata (backward compatible)
vr1, err1 := bundleutil.GetVersionAndRelease(b1)
vr2, err2 := bundleutil.GetVersionAndRelease(b2)

Expand All @@ -54,6 +73,20 @@ func ByVersionAndRelease(b1, b2 declcfg.Bundle) int {
return vr2.Compare(*vr1)
}

// compareByVersionRelease compares bundles using GetVersionAndRelease, which
// returns version+release from either explicit pkg.Release field (when feature gate
// enabled and field present) or build metadata (legacy registry+v1 fallback).
// This is used as a fallback when CompositeVersion comparison returns equal.
func compareByVersionRelease(b1, b2 declcfg.Bundle) int {
vr1, err1 := bundleutil.GetVersionAndRelease(b1)
vr2, err2 := bundleutil.GetVersionAndRelease(b2)

if err1 != nil || err2 != nil {
return compareErrors(err2, err1)
}
return vr2.Compare(*vr1)
}

func ByDeprecationFunc(deprecation declcfg.Deprecation) func(a, b declcfg.Bundle) int {
deprecatedBundles := sets.New[string]()
for _, entry := range deprecation.Entries {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package compare_test

import (
"encoding/json"
"fmt"
"slices"
"testing"

Expand All @@ -13,6 +14,7 @@ import (
"github.com/operator-framework/operator-registry/alpha/property"

"github.com/operator-framework/operator-controller/internal/operator-controller/catalogmetadata/compare"
"github.com/operator-framework/operator-controller/internal/operator-controller/features"
)

func TestNewVersionRange(t *testing.T) {
Expand Down Expand Up @@ -168,3 +170,75 @@ func TestByDeprecationFunc(t *testing.T) {
assert.Equal(t, 0, byDeprecation(c, d))
assert.Equal(t, 0, byDeprecation(d, c))
}

// TestByVersionAndRelease_WithBundleReleaseSupport tests the feature-gated hybrid comparison.
// Explicitly sets the gate to test both enabled and disabled paths.
func TestByVersionAndRelease_WithBundleReleaseSupport(t *testing.T) {
t.Run("gate enabled - uses Bundle.Compare with build metadata fallback", func(t *testing.T) {
// Enable the feature gate for this test
prevEnabled := features.OperatorControllerFeatureGate.Enabled(features.BundleReleaseSupport)
require.NoError(t, features.OperatorControllerFeatureGate.Set("BundleReleaseSupport=true"))
t.Cleanup(func() {
require.NoError(t, features.OperatorControllerFeatureGate.Set(fmt.Sprintf("BundleReleaseSupport=%t", prevEnabled)))
})

// Registry+v1 bundles: same version, different build metadata
b1 := declcfg.Bundle{
Name: "package1.v1.0.0+1",
Properties: []property.Property{
{Type: property.TypePackage, Value: json.RawMessage(`{"packageName": "package1", "version": "1.0.0+1"}`)},
},
}
b2 := declcfg.Bundle{
Name: "package1.v1.0.0+2",
Properties: []property.Property{
{Type: property.TypePackage, Value: json.RawMessage(`{"packageName": "package1", "version": "1.0.0+2"}`)},
},
}

result := compare.ByVersionAndRelease(b1, b2)
assert.Positive(t, result, "Bundle.Compare() returns 0 for registry+v1, fallback to build metadata: 1.0.0+2 > 1.0.0+1")

// Test bundles with explicit pkg.Release field
explicitR1 := declcfg.Bundle{
Name: "pkg.v2.0.0-r1",
Properties: []property.Property{
{Type: property.TypePackage, Value: json.RawMessage(`{"packageName": "pkg", "version": "2.0.0", "release": "1"}`)},
},
}
explicitR2 := declcfg.Bundle{
Name: "pkg.v2.0.0-r2",
Properties: []property.Property{
{Type: property.TypePackage, Value: json.RawMessage(`{"packageName": "pkg", "version": "2.0.0", "release": "2"}`)},
},
}
result = compare.ByVersionAndRelease(explicitR1, explicitR2)
assert.Positive(t, result, "2.0.0+release.2 > 2.0.0+release.1 (explicit release field)")
})

t.Run("gate disabled - uses legacy build metadata comparison only", func(t *testing.T) {
// Disable the feature gate for this test
prevEnabled := features.OperatorControllerFeatureGate.Enabled(features.BundleReleaseSupport)
require.NoError(t, features.OperatorControllerFeatureGate.Set("BundleReleaseSupport=false"))
t.Cleanup(func() {
require.NoError(t, features.OperatorControllerFeatureGate.Set(fmt.Sprintf("BundleReleaseSupport=%t", prevEnabled)))
})

// Registry+v1 bundles: same version, different build metadata
b1 := declcfg.Bundle{
Name: "package1.v1.0.0+1",
Properties: []property.Property{
{Type: property.TypePackage, Value: json.RawMessage(`{"packageName": "package1", "version": "1.0.0+1"}`)},
},
}
b2 := declcfg.Bundle{
Name: "package1.v1.0.0+2",
Properties: []property.Property{
{Type: property.TypePackage, Value: json.RawMessage(`{"packageName": "package1", "version": "1.0.0+2"}`)},
},
}

result := compare.ByVersionAndRelease(b1, b2)
assert.Positive(t, result, "should sort by build metadata: 1.0.0+2 > 1.0.0+1")
})
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,22 @@ func SuccessorsOf(installedBundle ocv1.BundleMetadata, channels ...declcfg.Chann
// registry+v1 and embed release in build metadata.
installedVersionRelease, err := bundle.NewLegacyRegistryV1VersionRelease(installedBundle.Version)
if err != nil {
return nil, fmt.Errorf("failed to get version and release of installed bundle: %v", err)
return nil, fmt.Errorf("failed to get version and release of installed bundle: %w", err)
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Does this section of code also need to change similar to parseVersionRelease?

If we resolve a bundle in the catalog that has an explicit release field, we'll install it, but we don't persist it's release value anywhere.

Later (here) it'll show up as installedBundle, but it'll lack a release value (there's not even a field for it). And worse, we'll try to parse the Version field as a legacy registry+v1 version. And since parseVersionRelease preserves the original build metadata, we'll actually try to parse it as the release value here.

TL;DR: I think this PR needs to also add a new field to the installedBundle struct (which I think ends up in the CRD?) so that the release can roundtrip properly.


successorsPredicate, err := legacySuccessor(installedBundle, channels...)
if err != nil {
return nil, fmt.Errorf("getting successorsPredicate: %w", err)
}

// We need either successors or current version (no upgrade)
return filter.Or(
// Bundle matches if it's a channel successor or current version (no upgrade).
// Re-releases must have explicit channel entries to be valid successors.
predicates := []filter.Predicate[declcfg.Bundle]{
successorsPredicate,
ExactVersionRelease(*installedVersionRelease),
), nil
}

return filter.Or(predicates...), nil
}

func legacySuccessor(installedBundle ocv1.BundleMetadata, channels ...declcfg.Channel) (filter.Predicate[declcfg.Bundle], error) {
Expand Down
Loading
Loading