diff --git a/api/v1/clusterextension_types.go b/api/v1/clusterextension_types.go index cf5946a408..9fa524e15a 100644 --- a/api/v1/clusterextension_types.go +++ b/api/v1/clusterextension_types.go @@ -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. diff --git a/applyconfigurations/api/v1/bundlemetadata.go b/applyconfigurations/api/v1/bundlemetadata.go index acf9d152f8..d61ffb00f6 100644 --- a/applyconfigurations/api/v1/bundlemetadata.go +++ b/applyconfigurations/api/v1/bundlemetadata.go @@ -29,6 +29,13 @@ type BundleMetadataApplyConfiguration struct { // version is required and references the version that this bundle represents. // It follows the semantic versioning standard as defined in https://semver.org/. Version *string `json:"version,omitempty"` + // 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. + Release *string `json:"release,omitempty"` } // BundleMetadataApplyConfiguration constructs a declarative configuration of the BundleMetadata type for use with @@ -52,3 +59,11 @@ func (b *BundleMetadataApplyConfiguration) WithVersion(value string) *BundleMeta b.Version = &value return b } + +// WithRelease sets the Release field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Release field is set to the value of the last call. +func (b *BundleMetadataApplyConfiguration) WithRelease(value string) *BundleMetadataApplyConfiguration { + b.Release = &value + return b +} diff --git a/docs/api-reference/olmv1-api-reference.md b/docs/api-reference/olmv1-api-reference.md index 5b5eeb2361..e9a8090ccd 100644 --- a/docs/api-reference/olmv1-api-reference.md +++ b/docs/api-reference/olmv1-api-reference.md @@ -67,6 +67,7 @@ _Appears in:_ | --- | --- | --- | --- | | `name` _string_ | name is required and follows the DNS subdomain standard as defined in [RFC 1123].
It must contain only lowercase alphanumeric characters, hyphens (-) or periods (.),
start and end with an alphanumeric character, and be no longer than 253 characters. | | Required: \{\}
| | `version` _string_ | version is required and references the version that this bundle represents.
It follows the semantic versioning standard as defined in https://semver.org/. | | Required: \{\}
| +| `release` _string_ | 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: \{\}
| #### CRDUpgradeSafetyEnforcement diff --git a/helm/experimental.yaml b/helm/experimental.yaml index b158389d48..05bb45f4d8 100644 --- a/helm/experimental.yaml +++ b/helm/experimental.yaml @@ -14,6 +14,7 @@ options: - HelmChartSupport - BoxcutterRuntime - DeploymentConfig + - BundleReleaseSupport disabled: - WebhookProviderOpenshiftServiceCA # List of enabled experimental features for catalogd diff --git a/helm/olmv1/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterextensions.yaml b/helm/olmv1/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterextensions.yaml index aebb9e72be..a1844b7551 100644 --- a/helm/olmv1/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterextensions.yaml +++ b/helm/olmv1/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterextensions.yaml @@ -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. diff --git a/helm/olmv1/base/operator-controller/crd/standard/olm.operatorframework.io_clusterextensions.yaml b/helm/olmv1/base/operator-controller/crd/standard/olm.operatorframework.io_clusterextensions.yaml index aca133f732..8f2737a298 100644 --- a/helm/olmv1/base/operator-controller/crd/standard/olm.operatorframework.io_clusterextensions.yaml +++ b/helm/olmv1/base/operator-controller/crd/standard/olm.operatorframework.io_clusterextensions.yaml @@ -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. diff --git a/internal/operator-controller/bundleutil/bundle.go b/internal/operator-controller/bundleutil/bundle.go index 2771c52593..b5e8b784dd 100644 --- a/internal/operator-controller/bundleutil/bundle.go +++ b/internal/operator-controller/bundleutil/bundle.go @@ -3,6 +3,7 @@ package bundleutil import ( "encoding/json" "fmt" + "strings" bsemver "github.com/blang/semver/v4" @@ -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 } diff --git a/internal/operator-controller/bundleutil/bundle_test.go b/internal/operator-controller/bundleutil/bundle_test.go index 1cdabad054..e82af4d8c9 100644 --- a/internal/operator-controller/bundleutil/bundle_test.go +++ b/internal/operator-controller/bundleutil/bundle_test.go @@ -2,6 +2,7 @@ package bundleutil_test import ( "encoding/json" + "fmt" "testing" bsemver "github.com/blang/semver/v4" @@ -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) { @@ -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) + }) +} diff --git a/internal/operator-controller/catalogmetadata/filter/successors.go b/internal/operator-controller/catalogmetadata/filter/successors.go index 975c8cb39f..c0aa27e317 100644 --- a/internal/operator-controller/catalogmetadata/filter/successors.go +++ b/internal/operator-controller/catalogmetadata/filter/successors.go @@ -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...) diff --git a/internal/operator-controller/catalogmetadata/filter/successors_test.go b/internal/operator-controller/catalogmetadata/filter/successors_test.go index d22a1fdb2f..441cab4e4b 100644 --- a/internal/operator-controller/catalogmetadata/filter/successors_test.go +++ b/internal/operator-controller/catalogmetadata/filter/successors_test.go @@ -4,7 +4,6 @@ import ( "slices" "testing" - bsemver "github.com/blang/semver/v4" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/stretchr/testify/assert" @@ -14,11 +13,22 @@ import ( "github.com/operator-framework/operator-registry/alpha/property" 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/bundleutil" "github.com/operator-framework/operator-controller/internal/operator-controller/catalogmetadata/compare" "github.com/operator-framework/operator-controller/internal/shared/util/filter" ) +// mustVersionRelease is a test helper that parses a version string into a VersionRelease. +// For registry+v1 bundles, build metadata is interpreted as release (e.g., "1.0.0+2" -> Version: 1.0.0, Release: 2). +func mustVersionRelease(versionStr string) bundle.VersionRelease { + vr, err := bundle.NewLegacyRegistryV1VersionRelease(versionStr) + if err != nil { + panic(err) + } + return *vr +} + func TestSuccessorsPredicate(t *testing.T) { const testPackageName = "test-package" channelSet := map[string]declcfg.Channel{ @@ -122,7 +132,7 @@ func TestSuccessorsPredicate(t *testing.T) { }{ { name: "respect replaces directive from catalog", - installedBundle: bundleutil.MetadataFor("test-package.v2.0.0", bsemver.MustParse("2.0.0")), + installedBundle: bundleutil.MetadataFor("test-package.v2.0.0", mustVersionRelease("2.0.0")), expectedResult: []declcfg.Bundle{ // Must only have two bundle: // - the one which replaces the current version @@ -133,7 +143,7 @@ func TestSuccessorsPredicate(t *testing.T) { }, { name: "respect skips directive from catalog", - installedBundle: bundleutil.MetadataFor("test-package.v2.2.1", bsemver.MustParse("2.2.1")), + installedBundle: bundleutil.MetadataFor("test-package.v2.2.1", mustVersionRelease("2.2.1")), expectedResult: []declcfg.Bundle{ // Must only have two bundle: // - the one which skips the current version @@ -144,7 +154,7 @@ func TestSuccessorsPredicate(t *testing.T) { }, { name: "respect skipRange directive from catalog", - installedBundle: bundleutil.MetadataFor("test-package.v2.3.0", bsemver.MustParse("2.3.0")), + installedBundle: bundleutil.MetadataFor("test-package.v2.3.0", mustVersionRelease("2.3.0")), expectedResult: []declcfg.Bundle{ // Must only have two bundle: // - the one which is skipRanges the current version @@ -155,7 +165,7 @@ func TestSuccessorsPredicate(t *testing.T) { }, { name: "installed bundle matcher is exact", - installedBundle: bundleutil.MetadataFor("test-package.v2.0.0+1", bsemver.MustParse("2.0.0+1")), + installedBundle: bundleutil.MetadataFor("test-package.v2.0.0+1", mustVersionRelease("2.0.0+1")), expectedResult: []declcfg.Bundle{ // Must only have two bundle: // - the one which is skips the current version diff --git a/internal/operator-controller/controllers/boxcutter_reconcile_steps.go b/internal/operator-controller/controllers/boxcutter_reconcile_steps.go index 63b8c7ddb2..965f0ac84c 100644 --- a/internal/operator-controller/controllers/boxcutter_reconcile_steps.go +++ b/internal/operator-controller/controllers/boxcutter_reconcile_steps.go @@ -69,6 +69,7 @@ func (d *BoxcutterRevisionStatesGetter) GetRevisionStates(ctx context.Context, e BundleMetadata: ocv1.BundleMetadata{ Name: rev.Annotations[labels.BundleNameKey], Version: rev.Annotations[labels.BundleVersionKey], + Release: rev.Annotations[labels.BundleReleaseKey], }, } @@ -104,6 +105,7 @@ func ApplyBundleWithBoxcutter(apply func(ctx context.Context, contentFS fs.FS, e labels.PackageNameKey: state.resolvedRevisionMetadata.Package, labels.BundleVersionKey: state.resolvedRevisionMetadata.Version, labels.BundleReferenceKey: state.resolvedRevisionMetadata.Image, + labels.BundleReleaseKey: state.resolvedRevisionMetadata.Release, } objLbls := map[string]string{ labels.OwnerKindKey: ocv1.ClusterExtensionKind, diff --git a/internal/operator-controller/controllers/clusterextension_controller.go b/internal/operator-controller/controllers/clusterextension_controller.go index 22b6768514..5251f50d59 100644 --- a/internal/operator-controller/controllers/clusterextension_controller.go +++ b/internal/operator-controller/controllers/clusterextension_controller.go @@ -540,6 +540,7 @@ func (d *HelmRevisionStatesGetter) GetRevisionStates(ctx context.Context, ext *o BundleMetadata: ocv1.BundleMetadata{ Name: rel.Labels[labels.BundleNameKey], Version: rel.Labels[labels.BundleVersionKey], + Release: rel.Labels[labels.BundleReleaseKey], }, } break diff --git a/internal/operator-controller/controllers/clusterextension_controller_test.go b/internal/operator-controller/controllers/clusterextension_controller_test.go index b86c500764..5eef430d72 100644 --- a/internal/operator-controller/controllers/clusterextension_controller_test.go +++ b/internal/operator-controller/controllers/clusterextension_controller_test.go @@ -2596,6 +2596,7 @@ func TestGetInstalledBundleHistory(t *testing.T) { labels.BundleNameKey: "test-ext", labels.BundleVersionKey: "1.0", labels.BundleReferenceKey: "bundle-ref", + labels.BundleReleaseKey: "2", }, }, }, @@ -2604,6 +2605,7 @@ func TestGetInstalledBundleHistory(t *testing.T) { BundleMetadata: ocv1.BundleMetadata{ Name: "test-ext", Version: "1.0", + Release: "2", }, Image: "bundle-ref", }, nil, diff --git a/internal/operator-controller/controllers/clusterextension_reconcile_steps.go b/internal/operator-controller/controllers/clusterextension_reconcile_steps.go index 48507a2b7c..3aaf04429c 100644 --- a/internal/operator-controller/controllers/clusterextension_reconcile_steps.go +++ b/internal/operator-controller/controllers/clusterextension_reconcile_steps.go @@ -191,11 +191,11 @@ func ResolveBundle(r resolve.Resolver, c client.Client) ReconcileStepFunc { state.resolvedRevisionMetadata = &RevisionMetadata{ Package: resolvedBundle.Package, Image: resolvedBundle.Image, - // TODO: Right now, operator-controller only supports registry+v1 bundles and has no concept - // of a "release" field. If/when we add a release field concept or a new bundle format - // we need to re-evaluate use of `AsLegacyRegistryV1Version` so that we avoid propagating - // registry+v1's semver spec violations of treating build metadata as orderable. - BundleMetadata: bundleutil.MetadataFor(resolvedBundle.Name, resolvedBundleVersion.AsLegacyRegistryV1Version()), + // MetadataFor accepts VersionRelease and populates both Version and Release fields. + // - With BundleReleaseSupport enabled + explicit pkg.Release: Release from pkg.Release field + // - Registry+v1 bundles (e.g., "1.0.0+2"): Release extracted from build metadata (e.g., "2") + // - Both result in separate Version and Release fields in BundleMetadata for roundtripping + BundleMetadata: bundleutil.MetadataFor(resolvedBundle.Name, *resolvedBundleVersion), } return nil, nil } @@ -413,6 +413,7 @@ func ApplyBundle(a Applier) ReconcileStepFunc { labels.PackageNameKey: state.resolvedRevisionMetadata.Package, labels.BundleVersionKey: state.resolvedRevisionMetadata.Version, labels.BundleReferenceKey: state.resolvedRevisionMetadata.Image, + labels.BundleReleaseKey: state.resolvedRevisionMetadata.Release, } objLbls := map[string]string{ labels.OwnerKindKey: ocv1.ClusterExtensionKind, diff --git a/internal/operator-controller/features/features.go b/internal/operator-controller/features/features.go index 0f99c1b28e..01e4fe4486 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" + BundleReleaseSupport featuregate.Feature = "BundleReleaseSupport" ) var operatorControllerFeatureGates = map[featuregate.Feature]featuregate.FeatureSpec{ @@ -89,6 +90,17 @@ var operatorControllerFeatureGates = map[featuregate.Feature]featuregate.Feature PreRelease: featuregate.Alpha, LockToDefault: false, }, + + // BundleReleaseSupport enables parsing of the explicit pkg.Release field + // from the olm.package property. When enabled, bundles with an explicit + // pkg.Release field have their release value parsed separately from the version, + // allowing build metadata to serve its proper semver purpose (e.g., git commit). + // When disabled, release values are parsed from version build metadata (registry+v1 legacy). + BundleReleaseSupport: { + Default: false, + PreRelease: featuregate.Alpha, + LockToDefault: false, + }, } var OperatorControllerFeatureGate featuregate.MutableFeatureGate = featuregate.NewFeatureGate() diff --git a/internal/operator-controller/labels/labels.go b/internal/operator-controller/labels/labels.go index 805c34c3b0..8872a462f2 100644 --- a/internal/operator-controller/labels/labels.go +++ b/internal/operator-controller/labels/labels.go @@ -28,6 +28,12 @@ const ( // associated with a ClusterObjectSet. BundleVersionKey = "olm.operatorframework.io/bundle-version" + // BundleReleaseKey is the label key used to record the bundle release value + // associated with a ClusterObjectSet. 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'). + BundleReleaseKey = "olm.operatorframework.io/bundle-release" + // BundleReferenceKey is the label key used to record an external reference // (such as an image or catalog reference) to the bundle for a // ClusterObjectSet. diff --git a/manifests/experimental-e2e.yaml b/manifests/experimental-e2e.yaml index 4a27cf2056..14945915ee 100644 --- a/manifests/experimental-e2e.yaml +++ b/manifests/experimental-e2e.yaml @@ -1300,6 +1300,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. @@ -2759,6 +2773,7 @@ spec: - --feature-gates=HelmChartSupport=true - --feature-gates=BoxcutterRuntime=true - --feature-gates=DeploymentConfig=true + - --feature-gates=BundleReleaseSupport=true - --feature-gates=WebhookProviderOpenshiftServiceCA=false - --tls-cert=/var/certs/tls.crt - --tls-key=/var/certs/tls.key diff --git a/manifests/experimental.yaml b/manifests/experimental.yaml index 9a8fa0b406..f22f8148ec 100644 --- a/manifests/experimental.yaml +++ b/manifests/experimental.yaml @@ -1261,6 +1261,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. @@ -2665,6 +2679,7 @@ spec: - --feature-gates=HelmChartSupport=true - --feature-gates=BoxcutterRuntime=true - --feature-gates=DeploymentConfig=true + - --feature-gates=BundleReleaseSupport=true - --feature-gates=WebhookProviderOpenshiftServiceCA=false - --tls-cert=/var/certs/tls.crt - --tls-key=/var/certs/tls.key diff --git a/manifests/standard-e2e.yaml b/manifests/standard-e2e.yaml index a3a72e89f9..4f6ca0dcdd 100644 --- a/manifests/standard-e2e.yaml +++ b/manifests/standard-e2e.yaml @@ -1168,6 +1168,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. diff --git a/manifests/standard.yaml b/manifests/standard.yaml index ad02f96e2e..f7022b47c9 100644 --- a/manifests/standard.yaml +++ b/manifests/standard.yaml @@ -1129,6 +1129,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.