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
11 changes: 11 additions & 0 deletions api/v1/clusterextension_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,17 @@ type BundleMetadata struct {
// +required
// +kubebuilder:validation:XValidation:rule="self.matches(\"^([0-9]+)(\\\\.[0-9]+)?(\\\\.[0-9]+)?(-([-0-9A-Za-z]+(\\\\.[-0-9A-Za-z]+)*))?(\\\\+([-0-9A-Za-z]+(-\\\\.[-0-9A-Za-z]+)*))?\")",message="version must be well-formed semver"
Version string `json:"version"`

// release is an optional field that references the release value for this bundle.
// The release follows pre-release/build metadata syntax as defined in https://semver.org/,
// consisting of dot-separated identifiers where numeric identifiers must not have leading zeros.
// For bundles with explicit pkg.Release metadata, this field contains that release value.
// For registry+v1 bundles, this field contains the release extracted from version's build metadata (e.g., '2' from '1.0.0+2').
// This field may be omitted if there is no explicit release and the version contains no parseable build metadata.
//
// +optional
// +kubebuilder:validation:XValidation:rule="self.matches(\"^$|^(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\\\\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*$\")",message="release must be empty or well-formed pre-release/build metadata (dot-separated identifiers, numeric parts without leading zeros)"
Release string `json:"release,omitempty"`
}

// RevisionStatus defines the observed state of a ClusterObjectSet.
Expand Down
15 changes: 15 additions & 0 deletions applyconfigurations/api/v1/bundlemetadata.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions docs/api-reference/olmv1-api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ _Appears in:_
| --- | --- | --- | --- |
| `name` _string_ | name is required and follows the DNS subdomain standard as defined in [RFC 1123].<br />It must contain only lowercase alphanumeric characters, hyphens (-) or periods (.),<br />start and end with an alphanumeric character, and be no longer than 253 characters. | | Required: \{\} <br /> |
| `version` _string_ | version is required and references the version that this bundle represents.<br />It follows the semantic versioning standard as defined in https://semver.org/. | | Required: \{\} <br /> |
| `release` _string_ | release is an optional field that references the release value for this bundle.<br />The release follows pre-release/build metadata syntax as defined in https://semver.org/,<br />consisting of dot-separated identifiers where numeric identifiers must not have leading zeros.<br />For bundles with explicit pkg.Release metadata, this field contains that release value.<br />For registry+v1 bundles, this field contains the release extracted from version's build metadata (e.g., '2' from '1.0.0+2').<br />This field may be omitted if there is no explicit release and the version contains no parseable build metadata. | | Optional: \{\} <br /> |


#### CRDUpgradeSafetyEnforcement
Expand Down
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
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,20 @@ spec:
hyphens (-) or periods (.), start and end with an alphanumeric
character, and be no longer than 253 characters
rule: self.matches("^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$")
release:
description: |-
release is an optional field that references the release value for this bundle.
The release follows pre-release/build metadata syntax as defined in https://semver.org/,
consisting of dot-separated identifiers where numeric identifiers must not have leading zeros.
For bundles with explicit pkg.Release metadata, this field contains that release value.
For registry+v1 bundles, this field contains the release extracted from version's build metadata (e.g., '2' from '1.0.0+2').
This field may be omitted if there is no explicit release and the version contains no parseable build metadata.
type: string
x-kubernetes-validations:
- message: release must be empty or well-formed pre-release/build
metadata (dot-separated identifiers, numeric parts without
leading zeros)
rule: self.matches("^$|^(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*$")
version:
description: |-
version is required and references the version that this bundle represents.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,20 @@ spec:
hyphens (-) or periods (.), start and end with an alphanumeric
character, and be no longer than 253 characters
rule: self.matches("^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$")
release:
description: |-
release is an optional field that references the release value for this bundle.
The release follows pre-release/build metadata syntax as defined in https://semver.org/,
consisting of dot-separated identifiers where numeric identifiers must not have leading zeros.
For bundles with explicit pkg.Release metadata, this field contains that release value.
For registry+v1 bundles, this field contains the release extracted from version's build metadata (e.g., '2' from '1.0.0+2').
This field may be omitted if there is no explicit release and the version contains no parseable build metadata.
type: string
x-kubernetes-validations:
- message: release must be empty or well-formed pre-release/build
metadata (dot-separated identifiers, numeric parts without
leading zeros)
rule: self.matches("^$|^(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*$")
version:
description: |-
version is required and references the version that this bundle represents.
Expand Down
65 changes: 47 additions & 18 deletions internal/operator-controller/bundleutil/bundle.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package bundleutil
import (
"encoding/json"
"fmt"
"strings"

bsemver "github.com/blang/semver/v4"

Expand All @@ -11,34 +12,62 @@ 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"
slicesutil "github.com/operator-framework/operator-controller/internal/shared/util/slices"
)

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)
}

// MetadataFor returns a BundleMetadata for the given bundle name and version.
func MetadataFor(bundleName string, bundleVersion bsemver.Version) ocv1.BundleMetadata {
return ocv1.BundleMetadata{
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/release.
func MetadataFor(bundleName string, vr bundle.VersionRelease) ocv1.BundleMetadata {
bm := ocv1.BundleMetadata{
Name: bundleName,
Version: bundleVersion.String(),
Version: vr.Version.String(),
}
if len(vr.Release) > 0 {
parts := slicesutil.Map(vr.Release, func(pr bsemver.PRVersion) string { return pr.String() })
bm.Release = strings.Join(parts, ".")
}
return bm
}
137 changes: 136 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,145 @@ 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)
})
}

func TestMetadataFor(t *testing.T) {
t.Run("with release", func(t *testing.T) {
vr := bundle.VersionRelease{
Version: bsemver.MustParse("1.0.0"),
Release: bundle.Release([]bsemver.PRVersion{{VersionNum: 2, IsNum: true}}),
}
result := bundleutil.MetadataFor("test-bundle", vr)
require.Equal(t, "test-bundle", result.Name)
require.Equal(t, "1.0.0", result.Version)
require.Equal(t, "2", result.Release)
})

t.Run("without release", func(t *testing.T) {
vr := bundle.VersionRelease{
Version: bsemver.MustParse("1.0.0"),
Release: nil,
}
result := bundleutil.MetadataFor("test-bundle", vr)
require.Equal(t, "test-bundle", result.Name)
require.Equal(t, "1.0.0", result.Version)
require.Empty(t, result.Release)
})
}
38 changes: 30 additions & 8 deletions internal/operator-controller/catalogmetadata/filter/successors.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,36 @@ import (
)

func SuccessorsOf(installedBundle ocv1.BundleMetadata, channels ...declcfg.Channel) (filter.Predicate[declcfg.Bundle], error) {
// TODO: We do not have an explicit field in our BundleMetadata for a bundle's release value.
// Legacy registry+v1 bundles embed the release value inside their versions as build metadata
// (in violation of the semver spec). If/when we add explicit release metadata to bundles and/or
// we support a new bundle format, we need to revisit the assumption that all bundles are
// 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)
// Construct VersionRelease from BundleMetadata.
// If the Release field is populated, parse version and release separately.
// Otherwise, parse release from version build metadata (registry+v1 legacy format).
var installedVersionRelease *bundle.VersionRelease
var err error

if installedBundle.Release != "" {
// Bundle has explicit release field - parse version and release from separate fields.
// Note: We can't use NewLegacyRegistryV1VersionRelease here because the version might
// already contain build metadata (e.g., "1.0.0+git.abc"), which serves its proper
// semver purpose when using explicit pkg.Release. Concatenating would create invalid
// semver like "1.0.0+git.abc+2".
version, err := bsemver.Parse(installedBundle.Version)
if err != nil {
return nil, fmt.Errorf("failed to parse installed bundle version %q: %w", installedBundle.Version, err)
}
release, err := bundle.NewRelease(installedBundle.Release)
if err != nil {
return nil, fmt.Errorf("failed to parse installed bundle release %q: %w", installedBundle.Release, err)
}
installedVersionRelease = &bundle.VersionRelease{
Version: version,
Release: release,
}
} else {
// Legacy registry+v1: release embedded in version's build metadata
installedVersionRelease, err = bundle.NewLegacyRegistryV1VersionRelease(installedBundle.Version)
if err != nil {
return nil, fmt.Errorf("failed to get version and release of installed bundle: %w", err)
}
}

successorsPredicate, err := legacySuccessor(installedBundle, channels...)
Expand Down
Loading
Loading