From 72c3a345c1a323c81a4d072d5f98f904c5db89fe Mon Sep 17 00:00:00 2001 From: Joe Lanford Date: Thu, 16 Oct 2025 09:49:01 -0400 Subject: [PATCH 1/2] Add support for build metadata precedence in bundle version comparison MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change fixes an issue to ensure that operator-controller properly handles and compares registry+v1 bundle versions that include build metadata as specified in the semver version. The intention is that we only treat build metadata as a release value for registry+v1 bundles, which already have this precedent set. If/when operator-controller gains support for new bundle types, the intention is to avoid continuing the practice (and semver violation) of treating version build metadata as comparable/orderable. Key changes: - Introduce VersionRelease type combining semver version with release metadata - Update bundle comparison logic to consider build metadata when present - Add exact version matching for pinned versions with build metadata - Replace GetVersion with GetVersionAndRelease across the codebase - Ensure successors are determined based on exact version+release matching This is particularly important for registry+v1 bundles that encode release information in the build metadata field of their version strings. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- api/v1/clusterextension_types.go | 8 + docs/api-reference/olmv1-api-reference.md | 2 +- ...peratorframework.io_clusterextensions.yaml | 8 + ...peratorframework.io_clusterextensions.yaml | 8 + .../bundle/versionrelease.go | 116 ++++++ .../bundle/versionrelease_test.go | 358 ++++++++++++++++++ .../operator-controller/bundleutil/bundle.go | 12 +- .../bundleutil/bundle_test.go | 14 +- .../catalogmetadata/compare/compare.go | 78 +++- .../catalogmetadata/compare/compare_test.go | 73 +++- .../filter/bundle_predicates.go | 27 +- .../filter/bundle_predicates_test.go | 8 +- .../catalogmetadata/filter/successors.go | 18 +- .../catalogmetadata/filter/successors_test.go | 27 +- .../clusterextension_controller.go | 10 +- .../clusterextension_controller_test.go | 59 +-- .../operator-controller/resolve/catalog.go | 25 +- .../resolve/catalog_test.go | 27 +- .../operator-controller/resolve/resolver.go | 9 +- internal/shared/util/slices/slices.go | 9 + manifests/experimental-e2e.yaml | 8 + manifests/experimental.yaml | 8 + manifests/standard-e2e.yaml | 8 + manifests/standard.yaml | 8 + 24 files changed, 813 insertions(+), 115 deletions(-) create mode 100644 internal/operator-controller/bundle/versionrelease.go create mode 100644 internal/operator-controller/bundle/versionrelease_test.go create mode 100644 internal/shared/util/slices/slices.go diff --git a/api/v1/clusterextension_types.go b/api/v1/clusterextension_types.go index e331ec63e1..63651be69c 100644 --- a/api/v1/clusterextension_types.go +++ b/api/v1/clusterextension_types.go @@ -235,6 +235,14 @@ type CatalogFilter struct { // "0.6.0", which means "only install version 0.6.0 and never // upgrade from this version". // + // For registry+v1 bundles that include release information in the build + // metadata field (e.g., "1.0.0+20230101"), you can pin to an exact version + // including the release by specifying the full version string with build + // metadata (e.g., "1.0.0+20230101"). This ensures an exact match of both + // the semver version and the release. If you specify a version without build + // metadata (e.g., "1.0.0"), it will match all bundles with that version + // regardless of their release information. + // // # Basic Comparison Operators // // The basic comparison operators and their meanings are: diff --git a/docs/api-reference/olmv1-api-reference.md b/docs/api-reference/olmv1-api-reference.md index 1b1ad66565..484c208975 100644 --- a/docs/api-reference/olmv1-api-reference.md +++ b/docs/api-reference/olmv1-api-reference.md @@ -97,7 +97,7 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | | `packageName` _string_ | packageName is a reference to the name of the package to be installed
and is used to filter the content from catalogs.

packageName is required, immutable, 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.

Some examples of valid values are:
- some-package
- 123-package
- 1-package-2
- somepackage

Some examples of invalid values are:
- -some-package
- some-package-
- thisisareallylongpackagenamethatisgreaterthanthemaximumlength
- some.package

[RFC 1123]: https://tools.ietf.org/html/rfc1123 | | MaxLength: 253
Required: \{\}
| -| `version` _string_ | version is an optional semver constraint (a specific version or range of versions). When unspecified, the latest version available will be installed.

Acceptable version ranges are no longer than 64 characters.
Version ranges are composed of comma- or space-delimited values and one or
more comparison operators, known as comparison strings. Additional
comparison strings can be added using the OR operator (\|\|).

# Range Comparisons

To specify a version range, you can use a comparison string like ">=3.0,
<3.6". When specifying a range, automatic updates will occur within that
range. The example comparison string means "install any version greater than
or equal to 3.0.0 but less than 3.6.0.". It also states intent that if any
upgrades are available within the version range after initial installation,
those upgrades should be automatically performed.

# Pinned Versions

To specify an exact version to install you can use a version range that
"pins" to a specific version. When pinning to a specific version, no
automatic updates will occur. An example of a pinned version range is
"0.6.0", which means "only install version 0.6.0 and never
upgrade from this version".

# Basic Comparison Operators

The basic comparison operators and their meanings are:
- "=", equal (not aliased to an operator)
- "!=", not equal
- "<", less than
- ">", greater than
- ">=", greater than OR equal to
- "<=", less than OR equal to

# Wildcard Comparisons

You can use the "x", "X", and "*" characters as wildcard characters in all
comparison operations. Some examples of using the wildcard characters:
- "1.2.x", "1.2.X", and "1.2.*" is equivalent to ">=1.2.0, < 1.3.0"
- ">= 1.2.x", ">= 1.2.X", and ">= 1.2.*" is equivalent to ">= 1.2.0"
- "<= 2.x", "<= 2.X", and "<= 2.*" is equivalent to "< 3"
- "x", "X", and "*" is equivalent to ">= 0.0.0"

# Patch Release Comparisons

When you want to specify a minor version up to the next major version you
can use the "~" character to perform patch comparisons. Some examples:
- "~1.2.3" is equivalent to ">=1.2.3, <1.3.0"
- "~1" and "~1.x" is equivalent to ">=1, <2"
- "~2.3" is equivalent to ">=2.3, <2.4"
- "~1.2.x" is equivalent to ">=1.2.0, <1.3.0"

# Major Release Comparisons

You can use the "^" character to make major release comparisons after a
stable 1.0.0 version is published. If there is no stable version published, // minor versions define the stability level. Some examples:
- "^1.2.3" is equivalent to ">=1.2.3, <2.0.0"
- "^1.2.x" is equivalent to ">=1.2.0, <2.0.0"
- "^2.3" is equivalent to ">=2.3, <3"
- "^2.x" is equivalent to ">=2.0.0, <3"
- "^0.2.3" is equivalent to ">=0.2.3, <0.3.0"
- "^0.2" is equivalent to ">=0.2.0, <0.3.0"
- "^0.0.3" is equvalent to ">=0.0.3, <0.0.4"
- "^0.0" is equivalent to ">=0.0.0, <0.1.0"
- "^0" is equivalent to ">=0.0.0, <1.0.0"

# OR Comparisons
You can use the "\|\|" character to represent an OR operation in the version
range. Some examples:
- ">=1.2.3, <2.0.0 \|\| >3.0.0"
- "^0 \|\| ^3 \|\| ^5"

For more information on semver, please see https://semver.org/ | | MaxLength: 64
| +| `version` _string_ | version is an optional semver constraint (a specific version or range of versions). When unspecified, the latest version available will be installed.

Acceptable version ranges are no longer than 64 characters.
Version ranges are composed of comma- or space-delimited values and one or
more comparison operators, known as comparison strings. Additional
comparison strings can be added using the OR operator (\|\|).

# Range Comparisons

To specify a version range, you can use a comparison string like ">=3.0,
<3.6". When specifying a range, automatic updates will occur within that
range. The example comparison string means "install any version greater than
or equal to 3.0.0 but less than 3.6.0.". It also states intent that if any
upgrades are available within the version range after initial installation,
those upgrades should be automatically performed.

# Pinned Versions

To specify an exact version to install you can use a version range that
"pins" to a specific version. When pinning to a specific version, no
automatic updates will occur. An example of a pinned version range is
"0.6.0", which means "only install version 0.6.0 and never
upgrade from this version".

For registry+v1 bundles that include release information in the build
metadata field (e.g., "1.0.0+20230101"), you can pin to an exact version
including the release by specifying the full version string with build
metadata (e.g., "1.0.0+20230101"). This ensures an exact match of both
the semver version and the release. If you specify a version without build
metadata (e.g., "1.0.0"), it will match all bundles with that version
regardless of their release information.

# Basic Comparison Operators

The basic comparison operators and their meanings are:
- "=", equal (not aliased to an operator)
- "!=", not equal
- "<", less than
- ">", greater than
- ">=", greater than OR equal to
- "<=", less than OR equal to

# Wildcard Comparisons

You can use the "x", "X", and "*" characters as wildcard characters in all
comparison operations. Some examples of using the wildcard characters:
- "1.2.x", "1.2.X", and "1.2.*" is equivalent to ">=1.2.0, < 1.3.0"
- ">= 1.2.x", ">= 1.2.X", and ">= 1.2.*" is equivalent to ">= 1.2.0"
- "<= 2.x", "<= 2.X", and "<= 2.*" is equivalent to "< 3"
- "x", "X", and "*" is equivalent to ">= 0.0.0"

# Patch Release Comparisons

When you want to specify a minor version up to the next major version you
can use the "~" character to perform patch comparisons. Some examples:
- "~1.2.3" is equivalent to ">=1.2.3, <1.3.0"
- "~1" and "~1.x" is equivalent to ">=1, <2"
- "~2.3" is equivalent to ">=2.3, <2.4"
- "~1.2.x" is equivalent to ">=1.2.0, <1.3.0"

# Major Release Comparisons

You can use the "^" character to make major release comparisons after a
stable 1.0.0 version is published. If there is no stable version published, // minor versions define the stability level. Some examples:
- "^1.2.3" is equivalent to ">=1.2.3, <2.0.0"
- "^1.2.x" is equivalent to ">=1.2.0, <2.0.0"
- "^2.3" is equivalent to ">=2.3, <3"
- "^2.x" is equivalent to ">=2.0.0, <3"
- "^0.2.3" is equivalent to ">=0.2.3, <0.3.0"
- "^0.2" is equivalent to ">=0.2.0, <0.3.0"
- "^0.0.3" is equvalent to ">=0.0.3, <0.0.4"
- "^0.0" is equivalent to ">=0.0.0, <0.1.0"
- "^0" is equivalent to ">=0.0.0, <1.0.0"

# OR Comparisons
You can use the "\|\|" character to represent an OR operation in the version
range. Some examples:
- ">=1.2.3, <2.0.0 \|\| >3.0.0"
- "^0 \|\| ^3 \|\| ^5"

For more information on semver, please see https://semver.org/ | | MaxLength: 64
| | `channels` _string array_ | channels is an optional reference to a set of channels belonging to
the package specified in the packageName field.

A "channel" is a package-author-defined stream of updates for an extension.

Each channel in the list must follow 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. No more than 256 channels can be specified.

When specified, it is used to constrain the set of installable bundles and
the automated upgrade path. This constraint is an AND operation with the
version field. For example:
- Given channel is set to "foo"
- Given version is set to ">=1.0.0, <1.5.0"
- Only bundles that exist in channel "foo" AND satisfy the version range comparison will be considered installable
- Automatic upgrades will be constrained to upgrade edges defined by the selected channel

When unspecified, upgrade edges across all channels will be used to identify valid automatic upgrade paths.

Some examples of valid values are:
- 1.1.x
- alpha
- stable
- stable-v1
- v1-stable
- dev-preview
- preview
- community

Some examples of invalid values are:
- -some-channel
- some-channel-
- thisisareallylongchannelnamethatisgreaterthanthemaximumlength
- original_40
- --default-channel

[RFC 1123]: https://tools.ietf.org/html/rfc1123 | | MaxItems: 256
| | `selector` _[LabelSelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#labelselector-v1-meta)_ | selector is an optional field that can be used
to filter the set of ClusterCatalogs used in the bundle
selection process.

When unspecified, all ClusterCatalogs will be used in
the bundle selection process. | | | | `upgradeConstraintPolicy` _[UpgradeConstraintPolicy](#upgradeconstraintpolicy)_ | upgradeConstraintPolicy is an optional field that controls whether
the upgrade path(s) defined in the catalog are enforced for the package
referenced in the packageName field.

Allowed values are: "CatalogProvided" or "SelfCertified", or omitted.

When this field is set to "CatalogProvided", automatic upgrades will only occur
when upgrade constraints specified by the package author are met.

When this field is set to "SelfCertified", the upgrade constraints specified by
the package author are ignored. This allows for upgrades and downgrades to
any version of the package. This is considered a dangerous operation as it
can lead to unknown and potentially disastrous outcomes, such as data
loss. It is assumed that users have independently verified changes when
using this option.

When this field is omitted, the default value is "CatalogProvided". | CatalogProvided | Enum: [CatalogProvided SelfCertified]
| 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 4cae796a6e..182be1e451 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 @@ -413,6 +413,14 @@ spec: "0.6.0", which means "only install version 0.6.0 and never upgrade from this version". + For registry+v1 bundles that include release information in the build + metadata field (e.g., "1.0.0+20230101"), you can pin to an exact version + including the release by specifying the full version string with build + metadata (e.g., "1.0.0+20230101"). This ensures an exact match of both + the semver version and the release. If you specify a version without build + metadata (e.g., "1.0.0"), it will match all bundles with that version + regardless of their release information. + # Basic Comparison Operators The basic comparison operators and their meanings are: 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 a0983e41f9..459ee1f650 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 @@ -379,6 +379,14 @@ spec: "0.6.0", which means "only install version 0.6.0 and never upgrade from this version". + For registry+v1 bundles that include release information in the build + metadata field (e.g., "1.0.0+20230101"), you can pin to an exact version + including the release by specifying the full version string with build + metadata (e.g., "1.0.0+20230101"). This ensures an exact match of both + the semver version and the release. If you specify a version without build + metadata (e.g., "1.0.0"), it will match all bundles with that version + regardless of their release information. + # Basic Comparison Operators The basic comparison operators and their meanings are: diff --git a/internal/operator-controller/bundle/versionrelease.go b/internal/operator-controller/bundle/versionrelease.go new file mode 100644 index 0000000000..ef63a7bddc --- /dev/null +++ b/internal/operator-controller/bundle/versionrelease.go @@ -0,0 +1,116 @@ +package bundle + +import ( + "errors" + "fmt" + "strings" + + bsemver "github.com/blang/semver/v4" + + slicesutil "github.com/operator-framework/operator-controller/internal/shared/util/slices" +) + +// NewLegacyRegistryV1VersionRelease parses a registry+v1 bundle version string and returns +// a VersionRelease. For registry+v1 bundles, the build metadata field of the semver version +// is treated as release information (a semver spec violation maintained for backward compatibility). +// The returned VersionRelease has the build metadata extracted into the Release field, and the +// Version field has its Build metadata cleared. +func NewLegacyRegistryV1VersionRelease(vStr string) (*VersionRelease, error) { + vers, err := bsemver.Parse(vStr) + if err != nil { + return nil, err + } + + rel, err := NewRelease(strings.Join(vers.Build, ".")) + if err != nil { + return nil, err + } + vers.Build = nil + + return &VersionRelease{ + Version: vers, + Release: rel, + }, nil +} + +type VersionRelease struct { + Version bsemver.Version + Release Release +} + +// Compare compares two VersionRelease values. It returns: +// +// -1 if vr < other +// 0 if vr == other +// +1 if vr > other +// +// Comparison is done first by Version, then by Release if versions are equal. +func (vr *VersionRelease) Compare(other VersionRelease) int { + if vCmp := vr.Version.Compare(other.Version); vCmp != 0 { + return vCmp + } + return vr.Release.Compare(other.Release) +} + +func (vr *VersionRelease) AsLegacyRegistryV1Version() bsemver.Version { + return bsemver.Version{ + Major: vr.Version.Major, + Minor: vr.Version.Minor, + Patch: vr.Version.Patch, + Pre: vr.Version.Pre, + Build: slicesutil.Map(vr.Release, func(i bsemver.PRVersion) string { return i.String() }), + } +} + +type Release []bsemver.PRVersion + +// Compare compares two Release values. It returns: +// +// -1 if r < other +// 0 if r == other +// +1 if r > other +// +// Comparison is done segment by segment from left to right. Numeric segments are +// compared numerically, and alphanumeric segments are compared lexically in ASCII +// sort order. A shorter release is considered less than a longer release if all +// corresponding segments are equal. +func (r Release) Compare(other Release) int { + if len(r) == 0 && len(other) > 0 { + return -1 + } + if len(other) == 0 && len(r) > 0 { + return 1 + } + a := bsemver.Version{Pre: r} + b := bsemver.Version{Pre: other} + return a.Compare(b) +} + +// NewRelease parses a release string into a Release. The release string should be +// a dot-separated sequence of non-empty identifiers, where each identifier contains +// only ASCII alphanumerics and hyphens [0-9A-Za-z-]. Numeric identifiers (those +// containing only digits) must not have leading zeros. An empty string returns a nil +// Release. Returns an error if any segment is invalid. +func NewRelease(relStr string) (Release, error) { + if relStr == "" { + return nil, nil + } + + var ( + segments = strings.Split(relStr, ".") + r = make(Release, 0, len(segments)) + errs []error + ) + for i, segment := range segments { + prVer, err := bsemver.NewPRVersion(segment) + if err != nil { + errs = append(errs, fmt.Errorf("segment %d: %v", i, err)) + continue + } + r = append(r, prVer) + } + if err := errors.Join(errs...); err != nil { + return nil, fmt.Errorf("invalid release %q: %v", relStr, err) + } + return r, nil +} diff --git a/internal/operator-controller/bundle/versionrelease_test.go b/internal/operator-controller/bundle/versionrelease_test.go new file mode 100644 index 0000000000..06bc39318e --- /dev/null +++ b/internal/operator-controller/bundle/versionrelease_test.go @@ -0,0 +1,358 @@ +package bundle_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/operator-framework/operator-controller/internal/operator-controller/bundle" +) + +func TestLegacyRegistryV1VersionRelease_Compare(t *testing.T) { + type testCase struct { + name string + v1 string + v2 string + expect int + } + for _, tc := range []testCase{ + { + name: "lower major version", + v1: "1.0.0-0+0", + v2: "2.0.0-0+0", + expect: -1, + }, + { + name: "lower minor version", + v1: "0.1.0-0+0", + v2: "0.2.0-0+0", + expect: -1, + }, + { + name: "lower patch version", + v1: "0.0.1-0+0", + v2: "0.0.2-0+0", + expect: -1, + }, + { + name: "lower prerelease version", + v1: "0.0.0-1+0", + v2: "0.0.0-2+0", + expect: -1, + }, + { + name: "lower build metadata", + v1: "0.0.0-0+1", + v2: "0.0.0-0+2", + expect: -1, + }, + { + name: "same major version", + v1: "1.0.0-0+0", + v2: "1.0.0-0+0", + expect: 0, + }, + { + name: "same minor version", + v1: "0.1.0-0+0", + v2: "0.1.0-0+0", + expect: 0, + }, + { + name: "same patch version", + v1: "0.0.1-0+0", + v2: "0.0.1-0+0", + expect: 0, + }, + { + name: "same prerelease version", + v1: "0.0.0-1+0", + v2: "0.0.0-1+0", + expect: 0, + }, + { + name: "same build metadata", + v1: "0.0.0-0+1", + v2: "0.0.0-0+1", + expect: 0, + }, + { + name: "higher major version", + v1: "2.0.0-0+0", + v2: "1.0.0-0+0", + expect: 1, + }, + { + name: "higher minor version", + v1: "0.2.0-0+0", + v2: "0.1.0-0+0", + expect: 1, + }, + { + name: "higher patch version", + v1: "0.0.2-0+0", + v2: "0.0.1-0+0", + expect: 1, + }, + { + name: "higher prerelease version", + v1: "0.0.0-2+0", + v2: "0.0.0-1+0", + expect: 1, + }, + { + name: "higher build metadata", + v1: "0.0.0-0+2", + v2: "0.0.0-0+1", + expect: 1, + }, + } { + t.Run(tc.name, func(t *testing.T) { + vr1, err1 := bundle.NewLegacyRegistryV1VersionRelease(tc.v1) + vr2, err2 := bundle.NewLegacyRegistryV1VersionRelease(tc.v2) + require.NoError(t, err1) + require.NoError(t, err2) + + actual := vr1.Compare(*vr2) + assert.Equal(t, tc.expect, actual) + }) + } +} + +func TestNewRelease(t *testing.T) { + type testCase struct { + name string + input string + expectErr bool + } + for _, tc := range []testCase{ + { + name: "empty string", + input: "", + expectErr: false, + }, + { + name: "single numeric segment", + input: "9", + expectErr: false, + }, + { + name: "single alphanumeric segment", + input: "alpha", + expectErr: false, + }, + { + name: "multiple segments", + input: "9.10.3", + expectErr: false, + }, + { + name: "mixed numeric and alphanumeric", + input: "9.alpha.10", + expectErr: false, + }, + { + name: "segment with hyphens in middle", + input: "alpha-beta", + expectErr: false, + }, + { + name: "segment starting with hyphen", + input: "-alpha", + expectErr: false, + }, + { + name: "segment ending with hyphen", + input: "alpha-", + expectErr: false, + }, + { + name: "segment with only hyphens", + input: "--", + expectErr: false, + }, + { + name: "numeric segment with leading zero (single)", + input: "0", + expectErr: false, + }, + { + name: "numeric segment with leading zeros", + input: "01", + expectErr: true, + }, + { + name: "alphanumeric segment with leading zeros", + input: "01alpha", + expectErr: false, + }, + { + name: "alphanumeric segment with number prefix", + input: "pre9", + expectErr: false, + }, + { + name: "alphanumeric segment with number suffix", + input: "9pre", + expectErr: false, + }, + { + name: "multiple segments with one having leading zeros", + input: "9.010.3", + expectErr: true, + }, + { + name: "empty segment at start", + input: ".alpha", + expectErr: true, + }, + { + name: "empty segment at end", + input: "alpha.", + expectErr: true, + }, + { + name: "empty segment in middle", + input: "alpha..beta", + expectErr: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + result, err := bundle.NewRelease(tc.input) + if tc.expectErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + if tc.input == "" { + assert.Nil(t, result) + } else { + assert.NotNil(t, result) + } + } + }) + } +} + +func TestRelease_Compare(t *testing.T) { + type testCase struct { + name string + r1 string + r2 string + expect int + } + for _, tc := range []testCase{ + { + name: "both empty", + r1: "", + r2: "", + expect: 0, + }, + { + name: "first empty, second not", + r1: "", + r2: "9", + expect: -1, + }, + { + name: "first not empty, second empty", + r1: "9", + r2: "", + expect: 1, + }, + { + name: "equal numeric segments", + r1: "9", + r2: "9", + expect: 0, + }, + { + name: "lower numeric segment", + r1: "9", + r2: "10", + expect: -1, + }, + { + name: "higher numeric segment", + r1: "10", + r2: "9", + expect: 1, + }, + { + name: "equal alphanumeric segments", + r1: "alpha", + r2: "alpha", + expect: 0, + }, + { + name: "lower alphanumeric segment", + r1: "alpha", + r2: "beta", + expect: -1, + }, + { + name: "higher alphanumeric segment", + r1: "beta", + r2: "alpha", + expect: 1, + }, + { + name: "numeric vs alphanumeric (numeric is less)", + r1: "9", + r2: "alpha", + expect: -1, + }, + { + name: "alphanumeric vs numeric (alphanumeric is greater)", + r1: "alpha", + r2: "9", + expect: 1, + }, + { + name: "shorter release (all segments equal)", + r1: "9.10", + r2: "9.10.3", + expect: -1, + }, + { + name: "longer release (all segments equal)", + r1: "9.10.3", + r2: "9.10", + expect: 1, + }, + { + name: "complex equal releases", + r1: "9.alpha.10.beta", + r2: "9.alpha.10.beta", + expect: 0, + }, + { + name: "segment with hyphens", + r1: "alpha-beta", + r2: "alpha-gamma", + expect: -1, + }, + { + name: "alphanumeric with numbers prefix (lexicographic)", + r1: "pre9", + r2: "pre10", + expect: 1, // "pre9" > "pre10" lexicographically + }, + { + name: "alphanumeric with numbers suffix (lexicographic)", + r1: "9pre", + r2: "10pre", + expect: 1, // "9pre" > "10pre" lexicographically + }, + } { + t.Run(tc.name, func(t *testing.T) { + rel1, err := bundle.NewRelease(tc.r1) + require.NoError(t, err) + rel2, err := bundle.NewRelease(tc.r2) + require.NoError(t, err) + + actual := rel1.Compare(rel2) + assert.Equal(t, tc.expect, actual) + }) + } +} diff --git a/internal/operator-controller/bundleutil/bundle.go b/internal/operator-controller/bundleutil/bundle.go index e123680687..2771c52593 100644 --- a/internal/operator-controller/bundleutil/bundle.go +++ b/internal/operator-controller/bundleutil/bundle.go @@ -10,20 +10,26 @@ 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" ) -func GetVersion(b declcfg.Bundle) (*bsemver.Version, error) { +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) } - vers, err := bsemver.Parse(pkg.Version) + + // 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 &vers, nil + return vr, nil } } return nil, fmt.Errorf("no package property found in bundle %q", b.Name) diff --git a/internal/operator-controller/bundleutil/bundle_test.go b/internal/operator-controller/bundleutil/bundle_test.go index 77b7e3bbe6..2496e2fa7d 100644 --- a/internal/operator-controller/bundleutil/bundle_test.go +++ b/internal/operator-controller/bundleutil/bundle_test.go @@ -12,7 +12,7 @@ import ( "github.com/operator-framework/operator-controller/internal/operator-controller/bundleutil" ) -func TestGetVersion(t *testing.T) { +func TestGetVersionAndRelease(t *testing.T) { tests := []struct { name string pkgProperty *property.Property @@ -22,7 +22,7 @@ func TestGetVersion(t *testing.T) { name: "valid version", pkgProperty: &property.Property{ Type: property.TypePackage, - Value: json.RawMessage(`{"version": "1.0.0"}`), + Value: json.RawMessage(`{"version": "1.0.0-pre+1.alpha.2"}`), }, wantErr: false, }, @@ -34,6 +34,14 @@ func TestGetVersion(t *testing.T) { }, wantErr: true, }, + { + name: "invalid release - build metadata with leading zeros", + pkgProperty: &property.Property{ + Type: property.TypePackage, + Value: json.RawMessage(`{"version": "1.0.0+001"}`), + }, + wantErr: true, + }, { name: "invalid json", pkgProperty: &property.Property{ @@ -61,7 +69,7 @@ func TestGetVersion(t *testing.T) { Properties: properties, } - _, err := bundleutil.GetVersion(bundle) + _, err := bundleutil.GetVersionAndRelease(bundle) if tc.wantErr { require.Error(t, err) } else { diff --git a/internal/operator-controller/catalogmetadata/compare/compare.go b/internal/operator-controller/catalogmetadata/compare/compare.go index 4c52eda4e1..b0e6a4e754 100644 --- a/internal/operator-controller/catalogmetadata/compare/compare.go +++ b/internal/operator-controller/catalogmetadata/compare/compare.go @@ -1,25 +1,71 @@ package compare import ( + "slices" + "strings" + + mmsemver "github.com/Masterminds/semver/v3" + bsemver "github.com/blang/semver/v4" "k8s.io/apimachinery/pkg/util/sets" "github.com/operator-framework/operator-registry/alpha/declcfg" "github.com/operator-framework/operator-controller/internal/operator-controller/bundleutil" + slicesutil "github.com/operator-framework/operator-controller/internal/shared/util/slices" ) -// ByVersion is a sort "less" function that orders bundles -// in inverse version order (higher versions on top). -func ByVersion(b1, b2 declcfg.Bundle) int { - v1, err1 := bundleutil.GetVersion(b1) - v2, err2 := bundleutil.GetVersion(b2) - if err1 != nil || err2 != nil { - return compareErrors(err1, err2) +// NewVersionRange returns a function that tests whether a semver version is in the +// provided versionRange. The versionRange provided to this function can be any valid semver +// version string or any range constraint. +// +// When the provided version range is a valid semver version that includes build metadata, then the +// returned function will only match an identical version with the same build metadata. +// +// When the provided version range is a valid semver version that does NOT include build metadata, +// then the returned function will match any version that matches the semver version, ignoring the +// build metadata of matched versions. +// +// This function is intended to be used to parse the ClusterExtension.spec.source.catalog.version +// field. See the API documentation for more details on the supported syntax. +func NewVersionRange(versionRange string) (bsemver.Range, error) { + if versionPin, err := bsemver.Parse(versionRange); err == nil && len(versionPin.Build) > 0 { + return exactVersionMatcher(versionPin), nil } + return newMastermindsRange(versionRange) +} - // Check for "greater than" because - // we want higher versions on top - return v2.Compare(*v1) +func exactVersionMatcher(pin bsemver.Version) bsemver.Range { + return func(v bsemver.Version) bool { + return pin.Compare(v) == 0 && slices.Compare(pin.Build, v.Build) == 0 + } +} + +func newMastermindsRange(versionRange string) (bsemver.Range, error) { + constraint, err := mmsemver.NewConstraint(versionRange) + if err != nil { + return nil, err + } + return func(in bsemver.Version) bool { + pre := slicesutil.Map(in.Pre, func(pr bsemver.PRVersion) string { return pr.String() }) + mmVer := mmsemver.New(in.Major, in.Minor, in.Patch, strings.Join(pre, "."), strings.Join(in.Build, ".")) + return constraint.Check(mmVer) + }, nil +} + +// 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. +func ByVersionAndRelease(b1, b2 declcfg.Bundle) int { + vr1, err1 := bundleutil.GetVersionAndRelease(b1) + vr2, err2 := bundleutil.GetVersionAndRelease(b2) + + // We don't really expect errors, because we expect well-formed/validated + // FBC as input. However, just in case we'll check the errors and sort + // invalid bundles as "lower" than valid bundles. + if err1 != nil || err2 != nil { + return compareErrors(err2, err1) + } + return vr2.Compare(*vr1) } func ByDeprecationFunc(deprecation declcfg.Deprecation) func(a, b declcfg.Bundle) int { @@ -42,16 +88,16 @@ func ByDeprecationFunc(deprecation declcfg.Deprecation) func(a, b declcfg.Bundle } } -// compareErrors returns 0 if both errors are either nil or not nil -// -1 if err1 is nil and err2 is not nil -// +1 if err1 is not nil and err2 is nil +// compareErrors returns 0 if both errors are either nil or not nil, +// -1 if err1 is not nil and err2 is nil, and +// +1 if err1 is nil and err2 is not nil +// The semantic is that errors are "less than" non-errors. func compareErrors(err1 error, err2 error) int { if err1 != nil && err2 == nil { - return 1 + return -1 } - if err1 == nil && err2 != nil { - return -1 + return 1 } return 0 } diff --git a/internal/operator-controller/catalogmetadata/compare/compare_test.go b/internal/operator-controller/catalogmetadata/compare/compare_test.go index c5d1735dc2..da738d55fc 100644 --- a/internal/operator-controller/catalogmetadata/compare/compare_test.go +++ b/internal/operator-controller/catalogmetadata/compare/compare_test.go @@ -5,7 +5,9 @@ import ( "slices" "testing" + bsemver "github.com/blang/semver/v4" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/operator-framework/operator-registry/alpha/declcfg" "github.com/operator-framework/operator-registry/alpha/property" @@ -13,7 +15,49 @@ import ( "github.com/operator-framework/operator-controller/internal/operator-controller/catalogmetadata/compare" ) -func TestByVersion(t *testing.T) { +func TestNewVersionRange(t *testing.T) { + type testCase struct { + name string + versionRange string + inputVersion bsemver.Version + expect bool + } + for _, tc := range []testCase{ + { + versionRange: "1.0.0+1", + inputVersion: bsemver.MustParse("1.0.0"), + expect: false, + }, + { + versionRange: "1.0.0+1", + inputVersion: bsemver.MustParse("1.0.0+2"), + expect: false, + }, + { + versionRange: "1.0.0+1", + inputVersion: bsemver.MustParse("1.0.0+1"), + expect: true, + }, + { + versionRange: "1.0.0", + inputVersion: bsemver.MustParse("1.0.0"), + expect: true, + }, + { + versionRange: "1.0.0", + inputVersion: bsemver.MustParse("1.0.0+1"), + expect: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + versionRange, err := compare.NewVersionRange(tc.versionRange) + require.NoError(t, err) + assert.Equal(t, tc.expect, versionRange(tc.inputVersion)) + }) + } +} + +func TestByVersionAndRelease(t *testing.T) { b1 := declcfg.Bundle{ Name: "package1.v1.0.0", Properties: []property.Property{ @@ -32,12 +76,21 @@ func TestByVersion(t *testing.T) { }, }, } - b3 := declcfg.Bundle{ - Name: "package1.v1.0.0-alpha+001", + b3_1 := declcfg.Bundle{ + Name: "package1.v1.0.0-alpha+1", + Properties: []property.Property{ + { + Type: property.TypePackage, + Value: json.RawMessage(`{"packageName": "package1", "version": "1.0.0-alpha+1"}`), + }, + }, + } + b3_2 := declcfg.Bundle{ + Name: "package1.v1.0.0-alpha+2", Properties: []property.Property{ { Type: property.TypePackage, - Value: json.RawMessage(`{"packageName": "package1", "version": "1.0.0-alpha+001"}`), + Value: json.RawMessage(`{"packageName": "package1", "version": "1.0.0-alpha+2"}`), }, }, } @@ -55,15 +108,15 @@ func TestByVersion(t *testing.T) { } t.Run("all bundles valid", func(t *testing.T) { - toSort := []declcfg.Bundle{b3, b2, b1} - slices.SortStableFunc(toSort, compare.ByVersion) - assert.Equal(t, []declcfg.Bundle{b1, b3, b2}, toSort) + toSort := []declcfg.Bundle{b3_1, b2, b3_2, b1} + slices.SortStableFunc(toSort, compare.ByVersionAndRelease) + assert.Equal(t, []declcfg.Bundle{b1, b3_2, b3_1, b2}, toSort) }) t.Run("some bundles are missing version", func(t *testing.T) { - toSort := []declcfg.Bundle{b3, b4noVersion, b2, b5empty, b1} - slices.SortStableFunc(toSort, compare.ByVersion) - assert.Equal(t, []declcfg.Bundle{b1, b3, b2, b4noVersion, b5empty}, toSort) + toSort := []declcfg.Bundle{b3_1, b4noVersion, b2, b3_2, b5empty, b1} + slices.SortStableFunc(toSort, compare.ByVersionAndRelease) + assert.Equal(t, []declcfg.Bundle{b1, b3_2, b3_1, b2, b4noVersion, b5empty}, toSort) }) } diff --git a/internal/operator-controller/catalogmetadata/filter/bundle_predicates.go b/internal/operator-controller/catalogmetadata/filter/bundle_predicates.go index ecea3783b7..56a17ff8ce 100644 --- a/internal/operator-controller/catalogmetadata/filter/bundle_predicates.go +++ b/internal/operator-controller/catalogmetadata/filter/bundle_predicates.go @@ -1,29 +1,38 @@ package filter import ( - mmsemver "github.com/Masterminds/semver/v3" + bsemver "github.com/blang/semver/v4" "github.com/operator-framework/operator-registry/alpha/declcfg" + "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/shared/util/filter" ) -func InMastermindsSemverRange(semverRange *mmsemver.Constraints) filter.Predicate[declcfg.Bundle] { +// ExactVersionRelease returns a predicate that matches bundles with an exact +// version and release match. Both the semver version and the release must match +// exactly for the predicate to return true. +func ExactVersionRelease(expect bundle.VersionRelease) filter.Predicate[declcfg.Bundle] { return func(b declcfg.Bundle) bool { - bVersion, err := bundleutil.GetVersion(b) + actual, err := bundleutil.GetVersionAndRelease(b) if err != nil { return false } - // No error should occur here because the simple version was successfully parsed by blang - // We are unaware of any tests cases that would cause one to fail but not the other - // This will cause code coverage to drop for this line. We don't ignore the error because - // there might be that one extreme edge case that might cause one to fail but not the other - mVersion, err := mmsemver.NewVersion(bVersion.String()) + return expect.Compare(*actual) == 0 + } +} + +// InSemverRange returns a predicate that matches bundles whose version falls within +// the provided semver range. The range is applied only to the semver version portion, +// ignoring the release metadata. +func InSemverRange(versionRange bsemver.Range) filter.Predicate[declcfg.Bundle] { + return func(b declcfg.Bundle) bool { + vr, err := bundleutil.GetVersionAndRelease(b) if err != nil { return false } - return semverRange.Check(mVersion) + return versionRange(vr.Version) } } diff --git a/internal/operator-controller/catalogmetadata/filter/bundle_predicates_test.go b/internal/operator-controller/catalogmetadata/filter/bundle_predicates_test.go index da47b961f7..4839190cf0 100644 --- a/internal/operator-controller/catalogmetadata/filter/bundle_predicates_test.go +++ b/internal/operator-controller/catalogmetadata/filter/bundle_predicates_test.go @@ -4,17 +4,17 @@ import ( "encoding/json" "testing" - mmsemver "github.com/Masterminds/semver/v3" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "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/catalogmetadata/compare" "github.com/operator-framework/operator-controller/internal/operator-controller/catalogmetadata/filter" ) -func TestInMastermindsSemverRange(t *testing.T) { +func TestInSemverRange(t *testing.T) { b1 := declcfg.Bundle{ Properties: []property.Property{ { @@ -40,10 +40,10 @@ func TestInMastermindsSemverRange(t *testing.T) { }, } - vRange, err := mmsemver.NewConstraint(">=1.0.0") + vRange, err := compare.NewVersionRange(">=1.0.0") require.NoError(t, err) - f := filter.InMastermindsSemverRange(vRange) + f := filter.InSemverRange(vRange) assert.True(t, f(b1)) assert.False(t, f(b2)) diff --git a/internal/operator-controller/catalogmetadata/filter/successors.go b/internal/operator-controller/catalogmetadata/filter/successors.go index c4abb32589..975c8cb39f 100644 --- a/internal/operator-controller/catalogmetadata/filter/successors.go +++ b/internal/operator-controller/catalogmetadata/filter/successors.go @@ -3,24 +3,24 @@ package filter import ( "fmt" - mmsemver "github.com/Masterminds/semver/v3" bsemver "github.com/blang/semver/v4" "github.com/operator-framework/operator-registry/alpha/declcfg" 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/shared/util/filter" ) func SuccessorsOf(installedBundle ocv1.BundleMetadata, channels ...declcfg.Channel) (filter.Predicate[declcfg.Bundle], error) { - installedBundleVersion, err := mmsemver.NewVersion(installedBundle.Version) + // 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("parsing installed bundle %q version %q: %w", installedBundle.Name, installedBundle.Version, err) - } - - installedVersionConstraint, err := mmsemver.NewConstraint(installedBundleVersion.String()) - if err != nil { - return nil, fmt.Errorf("parsing installed version constraint %q: %w", installedBundleVersion.String(), err) + return nil, fmt.Errorf("failed to get version and release of installed bundle: %v", err) } successorsPredicate, err := legacySuccessor(installedBundle, channels...) @@ -31,7 +31,7 @@ func SuccessorsOf(installedBundle ocv1.BundleMetadata, channels ...declcfg.Chann // We need either successors or current version (no upgrade) return filter.Or( successorsPredicate, - InMastermindsSemverRange(installedVersionConstraint), + ExactVersionRelease(*installedVersionRelease), ), nil } diff --git a/internal/operator-controller/catalogmetadata/filter/successors_test.go b/internal/operator-controller/catalogmetadata/filter/successors_test.go index 0d3fb45d2b..d22a1fdb2f 100644 --- a/internal/operator-controller/catalogmetadata/filter/successors_test.go +++ b/internal/operator-controller/catalogmetadata/filter/successors_test.go @@ -36,6 +36,7 @@ func TestSuccessorsPredicate(t *testing.T) { { Name: "test-package.v2.2.0", Replaces: "test-package.v2.1.0", + Skips: []string{"test-package.v2.0.0+1"}, }, { Name: "test-package.v2.2.1", @@ -64,6 +65,14 @@ func TestSuccessorsPredicate(t *testing.T) { property.MustBuildPackage(testPackageName, "2.0.0"), }, }, + "test-package.v2.0.0+1": { + Name: "test-package.v2.0.0+1", + Package: testPackageName, + Image: "registry.io/repo/test-package@v2.0.0+1", + Properties: []property.Property{ + property.MustBuildPackage(testPackageName, "2.0.0+1"), + }, + }, "test-package.v2.1.0": { Name: "test-package.v2.1.0", Package: testPackageName, @@ -144,6 +153,22 @@ func TestSuccessorsPredicate(t *testing.T) { bundleSet["test-package.v2.3.0"], }, }, + { + name: "installed bundle matcher is exact", + installedBundle: bundleutil.MetadataFor("test-package.v2.0.0+1", bsemver.MustParse("2.0.0+1")), + expectedResult: []declcfg.Bundle{ + // Must only have two bundle: + // - the one which is skips the current version + // - the current version (to allow to stay on the current version) + // + // We specifically _do not_ want to see test-package.v2.1.0 here because: + // - the successor determination is based on an exact match of the version, including build metadata if present + // - 2.1.0 updates from 2.0.0, not 2.0.0+1. Semver would say that both of these are the same. In our case, + // for registry+v1 only, they are not the same. + bundleSet["test-package.v2.2.0"], + bundleSet["test-package.v2.0.0+1"], + }, + }, { name: "installed bundle not found", installedBundle: ocv1.BundleMetadata{ @@ -164,7 +189,7 @@ func TestSuccessorsPredicate(t *testing.T) { result := filter.InPlace(allBundles, successors) // sort before comparison for stable order - slices.SortFunc(result, compare.ByVersion) + slices.SortFunc(result, compare.ByVersionAndRelease) gocmpopts := []cmp.Option{ cmpopts.IgnoreUnexported(declcfg.Bundle{}), diff --git a/internal/operator-controller/controllers/clusterextension_controller.go b/internal/operator-controller/controllers/clusterextension_controller.go index 7bcedde656..f658d80b68 100644 --- a/internal/operator-controller/controllers/clusterextension_controller.go +++ b/internal/operator-controller/controllers/clusterextension_controller.go @@ -261,9 +261,13 @@ func (r *ClusterExtensionReconciler) reconcile(ctx context.Context, ext *ocv1.Cl // all catalogs? SetDeprecationStatus(ext, resolvedBundle.Name, resolvedDeprecation) resolvedRevisionMetadata = &RevisionMetadata{ - Package: resolvedBundle.Package, - Image: resolvedBundle.Image, - BundleMetadata: bundleutil.MetadataFor(resolvedBundle.Name, *resolvedBundleVersion), + 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()), } } else { resolvedRevisionMetadata = revisionStates.RollingOut[0] diff --git a/internal/operator-controller/controllers/clusterextension_controller_test.go b/internal/operator-controller/controllers/clusterextension_controller_test.go index 437f62dcec..c73f74d93b 100644 --- a/internal/operator-controller/controllers/clusterextension_controller_test.go +++ b/internal/operator-controller/controllers/clusterextension_controller_test.go @@ -29,6 +29,7 @@ import ( ocv1 "github.com/operator-framework/operator-controller/api/v1" "github.com/operator-framework/operator-controller/internal/operator-controller/authentication" + "github.com/operator-framework/operator-controller/internal/operator-controller/bundle" "github.com/operator-framework/operator-controller/internal/operator-controller/conditionsets" "github.com/operator-framework/operator-controller/internal/operator-controller/controllers" "github.com/operator-framework/operator-controller/internal/operator-controller/finalizers" @@ -124,7 +125,7 @@ func TestClusterExtensionShortCircuitsReconcileDuringDeletion(t *testing.T) { func TestClusterExtensionResolutionFails(t *testing.T) { pkgName := fmt.Sprintf("non-existent-%s", rand.String(6)) cl, reconciler := newClientAndReconciler(t) - reconciler.Resolver = resolve.Func(func(_ context.Context, _ *ocv1.ClusterExtension, _ *ocv1.BundleMetadata) (*declcfg.Bundle, *bsemver.Version, *declcfg.Deprecation, error) { + reconciler.Resolver = resolve.Func(func(_ context.Context, _ *ocv1.ClusterExtension, _ *ocv1.BundleMetadata) (*declcfg.Bundle, *bundle.VersionRelease, *declcfg.Deprecation, error) { return nil, nil, nil, fmt.Errorf("no package %q found", pkgName) }) ctx := context.Background() @@ -228,13 +229,15 @@ func TestClusterExtensionResolutionSuccessfulUnpackFails(t *testing.T) { t.Log("It sets resolution success status") t.Log("By running reconcile") - reconciler.Resolver = resolve.Func(func(_ context.Context, _ *ocv1.ClusterExtension, _ *ocv1.BundleMetadata) (*declcfg.Bundle, *bsemver.Version, *declcfg.Deprecation, error) { - v := bsemver.MustParse("1.0.0") + reconciler.Resolver = resolve.Func(func(_ context.Context, _ *ocv1.ClusterExtension, _ *ocv1.BundleMetadata) (*declcfg.Bundle, *bundle.VersionRelease, *declcfg.Deprecation, error) { + vr := &bundle.VersionRelease{ + Version: bsemver.MustParse("1.0.0"), + } return &declcfg.Bundle{ Name: "prometheus.v1.0.0", Package: "prometheus", Image: "quay.io/operatorhubio/prometheus@fake1.0.0", - }, &v, nil, nil + }, vr, nil, nil }) res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) require.Equal(t, ctrl.Result{}, res) @@ -308,13 +311,15 @@ func TestClusterExtensionResolutionAndUnpackSuccessfulApplierFails(t *testing.T) t.Log("It sets resolution success status") t.Log("By running reconcile") - reconciler.Resolver = resolve.Func(func(_ context.Context, _ *ocv1.ClusterExtension, _ *ocv1.BundleMetadata) (*declcfg.Bundle, *bsemver.Version, *declcfg.Deprecation, error) { - v := bsemver.MustParse("1.0.0") + reconciler.Resolver = resolve.Func(func(_ context.Context, _ *ocv1.ClusterExtension, _ *ocv1.BundleMetadata) (*declcfg.Bundle, *bundle.VersionRelease, *declcfg.Deprecation, error) { + vr := &bundle.VersionRelease{ + Version: bsemver.MustParse("1.0.0"), + } return &declcfg.Bundle{ Name: "prometheus.v1.0.0", Package: "prometheus", Image: "quay.io/operatorhubio/prometheus@fake1.0.0", - }, &v, nil, nil + }, vr, nil, nil }) reconciler.Applier = &MockApplier{ err: errors.New("apply failure"), @@ -439,13 +444,15 @@ func TestClusterExtensionApplierFailsWithBundleInstalled(t *testing.T) { t.Log("It sets resolution success status") t.Log("By running reconcile") - reconciler.Resolver = resolve.Func(func(_ context.Context, _ *ocv1.ClusterExtension, _ *ocv1.BundleMetadata) (*declcfg.Bundle, *bsemver.Version, *declcfg.Deprecation, error) { - v := bsemver.MustParse("1.0.0") + reconciler.Resolver = resolve.Func(func(_ context.Context, _ *ocv1.ClusterExtension, _ *ocv1.BundleMetadata) (*declcfg.Bundle, *bundle.VersionRelease, *declcfg.Deprecation, error) { + vr := &bundle.VersionRelease{ + Version: bsemver.MustParse("1.0.0"), + } return &declcfg.Bundle{ Name: "prometheus.v1.0.0", Package: "prometheus", Image: "quay.io/operatorhubio/prometheus@fake1.0.0", - }, &v, nil, nil + }, vr, nil, nil }) reconciler.RevisionStatesGetter = &MockRevisionStatesGetter{ @@ -534,13 +541,15 @@ func TestClusterExtensionManagerFailed(t *testing.T) { t.Log("It sets resolution success status") t.Log("By running reconcile") - reconciler.Resolver = resolve.Func(func(_ context.Context, _ *ocv1.ClusterExtension, _ *ocv1.BundleMetadata) (*declcfg.Bundle, *bsemver.Version, *declcfg.Deprecation, error) { - v := bsemver.MustParse("1.0.0") + reconciler.Resolver = resolve.Func(func(_ context.Context, _ *ocv1.ClusterExtension, _ *ocv1.BundleMetadata) (*declcfg.Bundle, *bundle.VersionRelease, *declcfg.Deprecation, error) { + vr := &bundle.VersionRelease{ + Version: bsemver.MustParse("1.0.0"), + } return &declcfg.Bundle{ Name: "prometheus.v1.0.0", Package: "prometheus", Image: "quay.io/operatorhubio/prometheus@fake1.0.0", - }, &v, nil, nil + }, vr, nil, nil }) reconciler.Applier = &MockApplier{ installCompleted: true, @@ -611,13 +620,15 @@ func TestClusterExtensionManagedContentCacheWatchFail(t *testing.T) { t.Log("It sets resolution success status") t.Log("By running reconcile") - reconciler.Resolver = resolve.Func(func(_ context.Context, _ *ocv1.ClusterExtension, _ *ocv1.BundleMetadata) (*declcfg.Bundle, *bsemver.Version, *declcfg.Deprecation, error) { - v := bsemver.MustParse("1.0.0") + reconciler.Resolver = resolve.Func(func(_ context.Context, _ *ocv1.ClusterExtension, _ *ocv1.BundleMetadata) (*declcfg.Bundle, *bundle.VersionRelease, *declcfg.Deprecation, error) { + vr := &bundle.VersionRelease{ + Version: bsemver.MustParse("1.0.0"), + } return &declcfg.Bundle{ Name: "prometheus.v1.0.0", Package: "prometheus", Image: "quay.io/operatorhubio/prometheus@fake1.0.0", - }, &v, nil, nil + }, vr, nil, nil }) reconciler.Applier = &MockApplier{ installCompleted: true, @@ -687,13 +698,15 @@ func TestClusterExtensionInstallationSucceeds(t *testing.T) { t.Log("It sets resolution success status") t.Log("By running reconcile") - reconciler.Resolver = resolve.Func(func(_ context.Context, _ *ocv1.ClusterExtension, _ *ocv1.BundleMetadata) (*declcfg.Bundle, *bsemver.Version, *declcfg.Deprecation, error) { - v := bsemver.MustParse("1.0.0") + reconciler.Resolver = resolve.Func(func(_ context.Context, _ *ocv1.ClusterExtension, _ *ocv1.BundleMetadata) (*declcfg.Bundle, *bundle.VersionRelease, *declcfg.Deprecation, error) { + vr := &bundle.VersionRelease{ + Version: bsemver.MustParse("1.0.0"), + } return &declcfg.Bundle{ Name: "prometheus.v1.0.0", Package: "prometheus", Image: "quay.io/operatorhubio/prometheus@fake1.0.0", - }, &v, nil, nil + }, vr, nil, nil }) reconciler.Applier = &MockApplier{ installCompleted: true, @@ -761,13 +774,15 @@ func TestClusterExtensionDeleteFinalizerFails(t *testing.T) { require.NoError(t, err) t.Log("It sets resolution success status") t.Log("By running reconcile") - reconciler.Resolver = resolve.Func(func(_ context.Context, _ *ocv1.ClusterExtension, _ *ocv1.BundleMetadata) (*declcfg.Bundle, *bsemver.Version, *declcfg.Deprecation, error) { - v := bsemver.MustParse("1.0.0") + reconciler.Resolver = resolve.Func(func(_ context.Context, _ *ocv1.ClusterExtension, _ *ocv1.BundleMetadata) (*declcfg.Bundle, *bundle.VersionRelease, *declcfg.Deprecation, error) { + vr := &bundle.VersionRelease{ + Version: bsemver.MustParse("1.0.0"), + } return &declcfg.Bundle{ Name: "prometheus.v1.0.0", Package: "prometheus", Image: "quay.io/operatorhubio/prometheus@fake1.0.0", - }, &v, nil, nil + }, vr, nil, nil }) fakeFinalizer := "fake.testfinalizer.io" finalizersMessage := "still have finalizers" diff --git a/internal/operator-controller/resolve/catalog.go b/internal/operator-controller/resolve/catalog.go index 8cd1ebe81d..f0d4da6fab 100644 --- a/internal/operator-controller/resolve/catalog.go +++ b/internal/operator-controller/resolve/catalog.go @@ -7,7 +7,6 @@ import ( "sort" "strings" - mmsemver "github.com/Masterminds/semver/v3" bsemver "github.com/blang/semver/v4" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" @@ -18,10 +17,12 @@ import ( "github.com/operator-framework/operator-registry/alpha/declcfg" 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/operator-controller/catalogmetadata/filter" filterutil "github.com/operator-framework/operator-controller/internal/shared/util/filter" + slicesutil "github.com/operator-framework/operator-controller/internal/shared/util/slices" ) type ValidationFunc func(*declcfg.Bundle) error @@ -38,7 +39,7 @@ type foundBundle struct { } // Resolve returns a Bundle from a catalog that needs to get installed on the cluster. -func (r *CatalogResolver) Resolve(ctx context.Context, ext *ocv1.ClusterExtension, installedBundle *ocv1.BundleMetadata) (*declcfg.Bundle, *bsemver.Version, *declcfg.Deprecation, error) { +func (r *CatalogResolver) Resolve(ctx context.Context, ext *ocv1.ClusterExtension, installedBundle *ocv1.BundleMetadata) (*declcfg.Bundle, *bundle.VersionRelease, *declcfg.Deprecation, error) { l := log.FromContext(ctx) packageName := ext.Spec.Source.Catalog.PackageName versionRange := ext.Spec.Source.Catalog.Version @@ -58,9 +59,9 @@ func (r *CatalogResolver) Resolve(ctx context.Context, ext *ocv1.ClusterExtensio } } - var versionRangeConstraints *mmsemver.Constraints + var versionRangeConstraints bsemver.Range if versionRange != "" { - versionRangeConstraints, err = mmsemver.NewConstraint(versionRange) + versionRangeConstraints, err = compare.NewVersionRange(versionRange) if err != nil { return nil, nil, nil, fmt.Errorf("desired version range %q is invalid: %w", versionRange, err) } @@ -106,7 +107,7 @@ func (r *CatalogResolver) Resolve(ctx context.Context, ext *ocv1.ClusterExtensio } if versionRangeConstraints != nil { - predicates = append(predicates, filter.InMastermindsSemverRange(versionRangeConstraints)) + predicates = append(predicates, filter.InSemverRange(versionRangeConstraints)) } if ext.Spec.Source.Catalog.UpgradeConstraintPolicy != ocv1.UpgradeConstraintPolicySelfCertified && installedBundle != nil { @@ -140,7 +141,7 @@ func (r *CatalogResolver) Resolve(ctx context.Context, ext *ocv1.ClusterExtensio if lessDep := byDeprecation(a, b); lessDep != 0 { return lessDep } - return compare.ByVersion(a, b) + return compare.ByVersionAndRelease(a, b) }) thisBundle := packageFBC.Bundles[0] @@ -189,7 +190,7 @@ func (r *CatalogResolver) Resolve(ctx context.Context, ext *ocv1.ClusterExtensio } } resolvedBundle := resolvedBundles[0].bundle - resolvedBundleVersion, err := bundleutil.GetVersion(*resolvedBundle) + resolvedBundleVersion, err := bundleutil.GetVersionAndRelease(*resolvedBundle) if err != nil { return nil, nil, nil, fmt.Errorf("error getting resolved bundle version for bundle %q: %w", resolvedBundle.Name, err) } @@ -282,7 +283,7 @@ func CatalogWalker( return false }) - availableCatalogNames := mapSlice(catalogs, func(c ocv1.ClusterCatalog) string { return c.Name }) + availableCatalogNames := slicesutil.Map(catalogs, func(c ocv1.ClusterCatalog) string { return c.Name }) l.Info("using ClusterCatalogs for resolution", "catalogs", availableCatalogNames) for i := range catalogs { @@ -306,11 +307,3 @@ func isFBCEmpty(fbc *declcfg.DeclarativeConfig) bool { } return len(fbc.Packages) == 0 && len(fbc.Channels) == 0 && len(fbc.Bundles) == 0 && len(fbc.Deprecations) == 0 && len(fbc.Others) == 0 } - -func mapSlice[I any, O any](in []I, f func(I) O) []O { - out := make([]O, len(in)) - for i := range in { - out[i] = f(in[i]) - } - return out -} diff --git a/internal/operator-controller/resolve/catalog_test.go b/internal/operator-controller/resolve/catalog_test.go index 21232bc4df..2ec3192b69 100644 --- a/internal/operator-controller/resolve/catalog_test.go +++ b/internal/operator-controller/resolve/catalog_test.go @@ -19,6 +19,7 @@ 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" ) func TestInvalidClusterExtensionVersionRange(t *testing.T) { @@ -89,7 +90,7 @@ func TestPackageExists(t *testing.T) { gotBundle, gotVersion, gotDeprecation, err := r.Resolve(context.Background(), ce, nil) require.NoError(t, err) assert.Equal(t, genBundle(pkgName, "3.0.0"), *gotBundle) - assert.Equal(t, bsemver.MustParse("3.0.0"), *gotVersion) + assert.Equal(t, bundle.VersionRelease{Version: bsemver.MustParse("3.0.0")}, *gotVersion) assert.Equal(t, ptr.To(packageDeprecation(pkgName)), gotDeprecation) } @@ -156,7 +157,7 @@ func TestVersionExists(t *testing.T) { gotBundle, gotVersion, gotDeprecation, err := r.Resolve(context.Background(), ce, nil) require.NoError(t, err) assert.Equal(t, genBundle(pkgName, "1.0.2"), *gotBundle) - assert.Equal(t, bsemver.MustParse("1.0.2"), *gotVersion) + assert.Equal(t, bundle.VersionRelease{Version: bsemver.MustParse("1.0.2")}, *gotVersion) assert.Equal(t, ptr.To(packageDeprecation(pkgName)), gotDeprecation) } @@ -197,7 +198,7 @@ func TestChannelExists(t *testing.T) { gotBundle, gotVersion, gotDeprecation, err := r.Resolve(context.Background(), ce, nil) require.NoError(t, err) assert.Equal(t, genBundle(pkgName, "1.0.2"), *gotBundle) - assert.Equal(t, bsemver.MustParse("1.0.2"), *gotVersion) + assert.Equal(t, bundle.VersionRelease{Version: bsemver.MustParse("1.0.2")}, *gotVersion) assert.Equal(t, ptr.To(packageDeprecation(pkgName)), gotDeprecation) } @@ -257,7 +258,7 @@ func TestChannelAndVersionExist(t *testing.T) { gotBundle, gotVersion, gotDeprecation, err := r.Resolve(context.Background(), ce, nil) require.NoError(t, err) assert.Equal(t, genBundle(pkgName, "0.1.0"), *gotBundle) - assert.Equal(t, bsemver.MustParse("0.1.0"), *gotVersion) + assert.Equal(t, bundle.VersionRelease{Version: bsemver.MustParse("0.1.0")}, *gotVersion) assert.Equal(t, ptr.To(packageDeprecation(pkgName)), gotDeprecation) } @@ -279,7 +280,7 @@ func TestPreferNonDeprecated(t *testing.T) { gotBundle, gotVersion, gotDeprecation, err := r.Resolve(context.Background(), ce, nil) require.NoError(t, err) assert.Equal(t, genBundle(pkgName, "0.1.0"), *gotBundle) - assert.Equal(t, bsemver.MustParse("0.1.0"), *gotVersion) + assert.Equal(t, bundle.VersionRelease{Version: bsemver.MustParse("0.1.0")}, *gotVersion) assert.Equal(t, ptr.To(packageDeprecation(pkgName)), gotDeprecation) } @@ -301,7 +302,7 @@ func TestAcceptDeprecated(t *testing.T) { gotBundle, gotVersion, gotDeprecation, err := r.Resolve(context.Background(), ce, nil) require.NoError(t, err) assert.Equal(t, genBundle(pkgName, "1.0.1"), *gotBundle) - assert.Equal(t, bsemver.MustParse("1.0.1"), *gotVersion) + assert.Equal(t, bundle.VersionRelease{Version: bsemver.MustParse("1.0.1")}, *gotVersion) assert.Equal(t, ptr.To(packageDeprecation(pkgName)), gotDeprecation) } @@ -385,7 +386,7 @@ func TestPackageVariationsBetweenCatalogs(t *testing.T) { require.NoError(t, err) // We choose the only non-deprecated package assert.Equal(t, genBundle(pkgName, "1.0.2").Name, gotBundle.Name) - assert.Equal(t, bsemver.MustParse("1.0.2"), *gotVersion) + assert.Equal(t, bundle.VersionRelease{Version: bsemver.MustParse("1.0.2")}, *gotVersion) assert.Equal(t, (*declcfg.Deprecation)(nil), gotDeprecation) }) @@ -417,7 +418,7 @@ func TestPackageVariationsBetweenCatalogs(t *testing.T) { require.NoError(t, err) // Bundles within one catalog for a package will be sorted by semver and deprecation and the best is returned assert.Equal(t, genBundle(pkgName, "1.0.5").Name, gotBundle.Name) - assert.Equal(t, bsemver.MustParse("1.0.5"), *gotVersion) + assert.Equal(t, bundle.VersionRelease{Version: bsemver.MustParse("1.0.5")}, *gotVersion) assert.Equal(t, (*declcfg.Deprecation)(nil), gotDeprecation) }) } @@ -445,7 +446,7 @@ func TestUpgradeFoundLegacy(t *testing.T) { gotBundle, gotVersion, gotDeprecation, err := r.Resolve(context.Background(), ce, installedBundle) require.NoError(t, err) assert.Equal(t, genBundle(pkgName, "1.0.2"), *gotBundle) - assert.Equal(t, bsemver.MustParse("1.0.2"), *gotVersion) + assert.Equal(t, bundle.VersionRelease{Version: bsemver.MustParse("1.0.2")}, *gotVersion) assert.Equal(t, ptr.To(packageDeprecation(pkgName)), gotDeprecation) } @@ -497,7 +498,7 @@ func TestDowngradeFound(t *testing.T) { gotBundle, gotVersion, gotDeprecation, err := r.Resolve(context.Background(), ce, installedBundle) require.NoError(t, err) assert.Equal(t, genBundle(pkgName, "0.1.0"), *gotBundle) - assert.Equal(t, bsemver.MustParse("0.1.0"), *gotVersion) + assert.Equal(t, bundle.VersionRelease{Version: bsemver.MustParse("0.1.0")}, *gotVersion) assert.Equal(t, ptr.To(packageDeprecation(pkgName)), gotDeprecation) } @@ -854,7 +855,7 @@ func TestUnequalPriority(t *testing.T) { ce := buildFooClusterExtension(pkgName, []string{}, "", ocv1.UpgradeConstraintPolicyCatalogProvided) _, gotVersion, _, err := r.Resolve(context.Background(), ce, nil) require.NoError(t, err) - require.Equal(t, bsemver.MustParse("1.0.0"), *gotVersion) + require.Equal(t, bundle.VersionRelease{Version: bsemver.MustParse("1.0.0")}, *gotVersion) } func TestMultiplePriority(t *testing.T) { @@ -899,7 +900,7 @@ func TestMultipleChannels(t *testing.T) { gotBundle, gotVersion, gotDeprecation, err := r.Resolve(context.Background(), ce, nil) require.NoError(t, err) assert.Equal(t, genBundle(pkgName, "2.0.0"), *gotBundle) - assert.Equal(t, bsemver.MustParse("2.0.0"), *gotVersion) + assert.Equal(t, bundle.VersionRelease{Version: bsemver.MustParse("2.0.0")}, *gotVersion) assert.Equal(t, ptr.To(packageDeprecation(pkgName)), gotDeprecation) } @@ -972,5 +973,5 @@ func TestSomeCatalogsDisabled(t *testing.T) { gotBundle, gotVersion, _, err := r.Resolve(context.Background(), ce, nil) require.NoError(t, err) require.NotNil(t, gotBundle) - require.Equal(t, bsemver.MustParse("3.0.0"), *gotVersion) + require.Equal(t, bundle.VersionRelease{Version: bsemver.MustParse("3.0.0")}, *gotVersion) } diff --git a/internal/operator-controller/resolve/resolver.go b/internal/operator-controller/resolve/resolver.go index 625111d631..1fbde0fdea 100644 --- a/internal/operator-controller/resolve/resolver.go +++ b/internal/operator-controller/resolve/resolver.go @@ -3,19 +3,18 @@ package resolve import ( "context" - bsemver "github.com/blang/semver/v4" - "github.com/operator-framework/operator-registry/alpha/declcfg" ocv1 "github.com/operator-framework/operator-controller/api/v1" + "github.com/operator-framework/operator-controller/internal/operator-controller/bundle" ) type Resolver interface { - Resolve(ctx context.Context, ext *ocv1.ClusterExtension, installedBundle *ocv1.BundleMetadata) (*declcfg.Bundle, *bsemver.Version, *declcfg.Deprecation, error) + Resolve(ctx context.Context, ext *ocv1.ClusterExtension, installedBundle *ocv1.BundleMetadata) (*declcfg.Bundle, *bundle.VersionRelease, *declcfg.Deprecation, error) } -type Func func(ctx context.Context, ext *ocv1.ClusterExtension, installedBundle *ocv1.BundleMetadata) (*declcfg.Bundle, *bsemver.Version, *declcfg.Deprecation, error) +type Func func(ctx context.Context, ext *ocv1.ClusterExtension, installedBundle *ocv1.BundleMetadata) (*declcfg.Bundle, *bundle.VersionRelease, *declcfg.Deprecation, error) -func (f Func) Resolve(ctx context.Context, ext *ocv1.ClusterExtension, installedBundle *ocv1.BundleMetadata) (*declcfg.Bundle, *bsemver.Version, *declcfg.Deprecation, error) { +func (f Func) Resolve(ctx context.Context, ext *ocv1.ClusterExtension, installedBundle *ocv1.BundleMetadata) (*declcfg.Bundle, *bundle.VersionRelease, *declcfg.Deprecation, error) { return f(ctx, ext, installedBundle) } diff --git a/internal/shared/util/slices/slices.go b/internal/shared/util/slices/slices.go new file mode 100644 index 0000000000..3c750a1ad8 --- /dev/null +++ b/internal/shared/util/slices/slices.go @@ -0,0 +1,9 @@ +package slices + +func Map[I any, O any](in []I, f func(I) O) []O { + out := make([]O, len(in)) + for i := range in { + out[i] = f(in[i]) + } + return out +} diff --git a/manifests/experimental-e2e.yaml b/manifests/experimental-e2e.yaml index 1bc93321ef..c3bcba1aab 100644 --- a/manifests/experimental-e2e.yaml +++ b/manifests/experimental-e2e.yaml @@ -1211,6 +1211,14 @@ spec: "0.6.0", which means "only install version 0.6.0 and never upgrade from this version". + For registry+v1 bundles that include release information in the build + metadata field (e.g., "1.0.0+20230101"), you can pin to an exact version + including the release by specifying the full version string with build + metadata (e.g., "1.0.0+20230101"). This ensures an exact match of both + the semver version and the release. If you specify a version without build + metadata (e.g., "1.0.0"), it will match all bundles with that version + regardless of their release information. + # Basic Comparison Operators The basic comparison operators and their meanings are: diff --git a/manifests/experimental.yaml b/manifests/experimental.yaml index 69128a8b7b..4415fe4822 100644 --- a/manifests/experimental.yaml +++ b/manifests/experimental.yaml @@ -1176,6 +1176,14 @@ spec: "0.6.0", which means "only install version 0.6.0 and never upgrade from this version". + For registry+v1 bundles that include release information in the build + metadata field (e.g., "1.0.0+20230101"), you can pin to an exact version + including the release by specifying the full version string with build + metadata (e.g., "1.0.0+20230101"). This ensures an exact match of both + the semver version and the release. If you specify a version without build + metadata (e.g., "1.0.0"), it will match all bundles with that version + regardless of their release information. + # Basic Comparison Operators The basic comparison operators and their meanings are: diff --git a/manifests/standard-e2e.yaml b/manifests/standard-e2e.yaml index 72f8b82dcf..88f3d27380 100644 --- a/manifests/standard-e2e.yaml +++ b/manifests/standard-e2e.yaml @@ -970,6 +970,14 @@ spec: "0.6.0", which means "only install version 0.6.0 and never upgrade from this version". + For registry+v1 bundles that include release information in the build + metadata field (e.g., "1.0.0+20230101"), you can pin to an exact version + including the release by specifying the full version string with build + metadata (e.g., "1.0.0+20230101"). This ensures an exact match of both + the semver version and the release. If you specify a version without build + metadata (e.g., "1.0.0"), it will match all bundles with that version + regardless of their release information. + # Basic Comparison Operators The basic comparison operators and their meanings are: diff --git a/manifests/standard.yaml b/manifests/standard.yaml index 75ee176f7f..75b6baf43c 100644 --- a/manifests/standard.yaml +++ b/manifests/standard.yaml @@ -935,6 +935,14 @@ spec: "0.6.0", which means "only install version 0.6.0 and never upgrade from this version". + For registry+v1 bundles that include release information in the build + metadata field (e.g., "1.0.0+20230101"), you can pin to an exact version + including the release by specifying the full version string with build + metadata (e.g., "1.0.0+20230101"). This ensures an exact match of both + the semver version and the release. If you specify a version without build + metadata (e.g., "1.0.0"), it will match all bundles with that version + regardless of their release information. + # Basic Comparison Operators The basic comparison operators and their meanings are: From 637262537b7653c822a2e3b5990c21a3ed8b0799 Mon Sep 17 00:00:00 2001 From: Joe Lanford Date: Thu, 23 Oct 2025 14:03:55 -0400 Subject: [PATCH 2/2] Revert semantic changes to ClusterExtension version selection and improve VersionRelease parsing This commit reverts the user-facing semantic changes to the ClusterExtension version field that were introduced to support exact version pinning with build metadata. The version field now ignores build metadata when matching versions, consistent with semver specification. Additionally, this commit modifies the VersionRelease parsing logic to be more tolerant of semver versions whose build metadata is not a valid release. When build metadata cannot be parsed as a release, the full version (including build metadata) is preserved in the Version field, with an empty Release field. Changes include: - Removed documentation about pinning to exact versions with build metadata - Removed exactVersionMatcher logic that enforced build metadata equality - Updated NewLegacyRegistryV1VersionRelease to tolerate non-release build metadata - Updated test expectations to reflect new behavior --- api/v1/clusterextension_types.go | 8 ---- docs/api-reference/olmv1-api-reference.md | 2 +- ...peratorframework.io_clusterextensions.yaml | 8 ---- ...peratorframework.io_clusterextensions.yaml | 8 ---- .../bundle/versionrelease.go | 39 ++++++++++++------- .../bundleutil/bundle_test.go | 23 +++++++++-- .../catalogmetadata/compare/compare.go | 18 +-------- .../catalogmetadata/compare/compare_test.go | 4 +- manifests/experimental-e2e.yaml | 8 ---- manifests/experimental.yaml | 8 ---- manifests/standard-e2e.yaml | 8 ---- manifests/standard.yaml | 8 ---- 12 files changed, 50 insertions(+), 92 deletions(-) diff --git a/api/v1/clusterextension_types.go b/api/v1/clusterextension_types.go index 63651be69c..e331ec63e1 100644 --- a/api/v1/clusterextension_types.go +++ b/api/v1/clusterextension_types.go @@ -235,14 +235,6 @@ type CatalogFilter struct { // "0.6.0", which means "only install version 0.6.0 and never // upgrade from this version". // - // For registry+v1 bundles that include release information in the build - // metadata field (e.g., "1.0.0+20230101"), you can pin to an exact version - // including the release by specifying the full version string with build - // metadata (e.g., "1.0.0+20230101"). This ensures an exact match of both - // the semver version and the release. If you specify a version without build - // metadata (e.g., "1.0.0"), it will match all bundles with that version - // regardless of their release information. - // // # Basic Comparison Operators // // The basic comparison operators and their meanings are: diff --git a/docs/api-reference/olmv1-api-reference.md b/docs/api-reference/olmv1-api-reference.md index 484c208975..1b1ad66565 100644 --- a/docs/api-reference/olmv1-api-reference.md +++ b/docs/api-reference/olmv1-api-reference.md @@ -97,7 +97,7 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | | `packageName` _string_ | packageName is a reference to the name of the package to be installed
and is used to filter the content from catalogs.

packageName is required, immutable, 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.

Some examples of valid values are:
- some-package
- 123-package
- 1-package-2
- somepackage

Some examples of invalid values are:
- -some-package
- some-package-
- thisisareallylongpackagenamethatisgreaterthanthemaximumlength
- some.package

[RFC 1123]: https://tools.ietf.org/html/rfc1123 | | MaxLength: 253
Required: \{\}
| -| `version` _string_ | version is an optional semver constraint (a specific version or range of versions). When unspecified, the latest version available will be installed.

Acceptable version ranges are no longer than 64 characters.
Version ranges are composed of comma- or space-delimited values and one or
more comparison operators, known as comparison strings. Additional
comparison strings can be added using the OR operator (\|\|).

# Range Comparisons

To specify a version range, you can use a comparison string like ">=3.0,
<3.6". When specifying a range, automatic updates will occur within that
range. The example comparison string means "install any version greater than
or equal to 3.0.0 but less than 3.6.0.". It also states intent that if any
upgrades are available within the version range after initial installation,
those upgrades should be automatically performed.

# Pinned Versions

To specify an exact version to install you can use a version range that
"pins" to a specific version. When pinning to a specific version, no
automatic updates will occur. An example of a pinned version range is
"0.6.0", which means "only install version 0.6.0 and never
upgrade from this version".

For registry+v1 bundles that include release information in the build
metadata field (e.g., "1.0.0+20230101"), you can pin to an exact version
including the release by specifying the full version string with build
metadata (e.g., "1.0.0+20230101"). This ensures an exact match of both
the semver version and the release. If you specify a version without build
metadata (e.g., "1.0.0"), it will match all bundles with that version
regardless of their release information.

# Basic Comparison Operators

The basic comparison operators and their meanings are:
- "=", equal (not aliased to an operator)
- "!=", not equal
- "<", less than
- ">", greater than
- ">=", greater than OR equal to
- "<=", less than OR equal to

# Wildcard Comparisons

You can use the "x", "X", and "*" characters as wildcard characters in all
comparison operations. Some examples of using the wildcard characters:
- "1.2.x", "1.2.X", and "1.2.*" is equivalent to ">=1.2.0, < 1.3.0"
- ">= 1.2.x", ">= 1.2.X", and ">= 1.2.*" is equivalent to ">= 1.2.0"
- "<= 2.x", "<= 2.X", and "<= 2.*" is equivalent to "< 3"
- "x", "X", and "*" is equivalent to ">= 0.0.0"

# Patch Release Comparisons

When you want to specify a minor version up to the next major version you
can use the "~" character to perform patch comparisons. Some examples:
- "~1.2.3" is equivalent to ">=1.2.3, <1.3.0"
- "~1" and "~1.x" is equivalent to ">=1, <2"
- "~2.3" is equivalent to ">=2.3, <2.4"
- "~1.2.x" is equivalent to ">=1.2.0, <1.3.0"

# Major Release Comparisons

You can use the "^" character to make major release comparisons after a
stable 1.0.0 version is published. If there is no stable version published, // minor versions define the stability level. Some examples:
- "^1.2.3" is equivalent to ">=1.2.3, <2.0.0"
- "^1.2.x" is equivalent to ">=1.2.0, <2.0.0"
- "^2.3" is equivalent to ">=2.3, <3"
- "^2.x" is equivalent to ">=2.0.0, <3"
- "^0.2.3" is equivalent to ">=0.2.3, <0.3.0"
- "^0.2" is equivalent to ">=0.2.0, <0.3.0"
- "^0.0.3" is equvalent to ">=0.0.3, <0.0.4"
- "^0.0" is equivalent to ">=0.0.0, <0.1.0"
- "^0" is equivalent to ">=0.0.0, <1.0.0"

# OR Comparisons
You can use the "\|\|" character to represent an OR operation in the version
range. Some examples:
- ">=1.2.3, <2.0.0 \|\| >3.0.0"
- "^0 \|\| ^3 \|\| ^5"

For more information on semver, please see https://semver.org/ | | MaxLength: 64
| +| `version` _string_ | version is an optional semver constraint (a specific version or range of versions). When unspecified, the latest version available will be installed.

Acceptable version ranges are no longer than 64 characters.
Version ranges are composed of comma- or space-delimited values and one or
more comparison operators, known as comparison strings. Additional
comparison strings can be added using the OR operator (\|\|).

# Range Comparisons

To specify a version range, you can use a comparison string like ">=3.0,
<3.6". When specifying a range, automatic updates will occur within that
range. The example comparison string means "install any version greater than
or equal to 3.0.0 but less than 3.6.0.". It also states intent that if any
upgrades are available within the version range after initial installation,
those upgrades should be automatically performed.

# Pinned Versions

To specify an exact version to install you can use a version range that
"pins" to a specific version. When pinning to a specific version, no
automatic updates will occur. An example of a pinned version range is
"0.6.0", which means "only install version 0.6.0 and never
upgrade from this version".

# Basic Comparison Operators

The basic comparison operators and their meanings are:
- "=", equal (not aliased to an operator)
- "!=", not equal
- "<", less than
- ">", greater than
- ">=", greater than OR equal to
- "<=", less than OR equal to

# Wildcard Comparisons

You can use the "x", "X", and "*" characters as wildcard characters in all
comparison operations. Some examples of using the wildcard characters:
- "1.2.x", "1.2.X", and "1.2.*" is equivalent to ">=1.2.0, < 1.3.0"
- ">= 1.2.x", ">= 1.2.X", and ">= 1.2.*" is equivalent to ">= 1.2.0"
- "<= 2.x", "<= 2.X", and "<= 2.*" is equivalent to "< 3"
- "x", "X", and "*" is equivalent to ">= 0.0.0"

# Patch Release Comparisons

When you want to specify a minor version up to the next major version you
can use the "~" character to perform patch comparisons. Some examples:
- "~1.2.3" is equivalent to ">=1.2.3, <1.3.0"
- "~1" and "~1.x" is equivalent to ">=1, <2"
- "~2.3" is equivalent to ">=2.3, <2.4"
- "~1.2.x" is equivalent to ">=1.2.0, <1.3.0"

# Major Release Comparisons

You can use the "^" character to make major release comparisons after a
stable 1.0.0 version is published. If there is no stable version published, // minor versions define the stability level. Some examples:
- "^1.2.3" is equivalent to ">=1.2.3, <2.0.0"
- "^1.2.x" is equivalent to ">=1.2.0, <2.0.0"
- "^2.3" is equivalent to ">=2.3, <3"
- "^2.x" is equivalent to ">=2.0.0, <3"
- "^0.2.3" is equivalent to ">=0.2.3, <0.3.0"
- "^0.2" is equivalent to ">=0.2.0, <0.3.0"
- "^0.0.3" is equvalent to ">=0.0.3, <0.0.4"
- "^0.0" is equivalent to ">=0.0.0, <0.1.0"
- "^0" is equivalent to ">=0.0.0, <1.0.0"

# OR Comparisons
You can use the "\|\|" character to represent an OR operation in the version
range. Some examples:
- ">=1.2.3, <2.0.0 \|\| >3.0.0"
- "^0 \|\| ^3 \|\| ^5"

For more information on semver, please see https://semver.org/ | | MaxLength: 64
| | `channels` _string array_ | channels is an optional reference to a set of channels belonging to
the package specified in the packageName field.

A "channel" is a package-author-defined stream of updates for an extension.

Each channel in the list must follow 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. No more than 256 channels can be specified.

When specified, it is used to constrain the set of installable bundles and
the automated upgrade path. This constraint is an AND operation with the
version field. For example:
- Given channel is set to "foo"
- Given version is set to ">=1.0.0, <1.5.0"
- Only bundles that exist in channel "foo" AND satisfy the version range comparison will be considered installable
- Automatic upgrades will be constrained to upgrade edges defined by the selected channel

When unspecified, upgrade edges across all channels will be used to identify valid automatic upgrade paths.

Some examples of valid values are:
- 1.1.x
- alpha
- stable
- stable-v1
- v1-stable
- dev-preview
- preview
- community

Some examples of invalid values are:
- -some-channel
- some-channel-
- thisisareallylongchannelnamethatisgreaterthanthemaximumlength
- original_40
- --default-channel

[RFC 1123]: https://tools.ietf.org/html/rfc1123 | | MaxItems: 256
| | `selector` _[LabelSelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#labelselector-v1-meta)_ | selector is an optional field that can be used
to filter the set of ClusterCatalogs used in the bundle
selection process.

When unspecified, all ClusterCatalogs will be used in
the bundle selection process. | | | | `upgradeConstraintPolicy` _[UpgradeConstraintPolicy](#upgradeconstraintpolicy)_ | upgradeConstraintPolicy is an optional field that controls whether
the upgrade path(s) defined in the catalog are enforced for the package
referenced in the packageName field.

Allowed values are: "CatalogProvided" or "SelfCertified", or omitted.

When this field is set to "CatalogProvided", automatic upgrades will only occur
when upgrade constraints specified by the package author are met.

When this field is set to "SelfCertified", the upgrade constraints specified by
the package author are ignored. This allows for upgrades and downgrades to
any version of the package. This is considered a dangerous operation as it
can lead to unknown and potentially disastrous outcomes, such as data
loss. It is assumed that users have independently verified changes when
using this option.

When this field is omitted, the default value is "CatalogProvided". | CatalogProvided | Enum: [CatalogProvided SelfCertified]
| 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 182be1e451..4cae796a6e 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 @@ -413,14 +413,6 @@ spec: "0.6.0", which means "only install version 0.6.0 and never upgrade from this version". - For registry+v1 bundles that include release information in the build - metadata field (e.g., "1.0.0+20230101"), you can pin to an exact version - including the release by specifying the full version string with build - metadata (e.g., "1.0.0+20230101"). This ensures an exact match of both - the semver version and the release. If you specify a version without build - metadata (e.g., "1.0.0"), it will match all bundles with that version - regardless of their release information. - # Basic Comparison Operators The basic comparison operators and their meanings are: 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 459ee1f650..a0983e41f9 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 @@ -379,14 +379,6 @@ spec: "0.6.0", which means "only install version 0.6.0 and never upgrade from this version". - For registry+v1 bundles that include release information in the build - metadata field (e.g., "1.0.0+20230101"), you can pin to an exact version - including the release by specifying the full version string with build - metadata (e.g., "1.0.0+20230101"). This ensures an exact match of both - the semver version and the release. If you specify a version without build - metadata (e.g., "1.0.0"), it will match all bundles with that version - regardless of their release information. - # Basic Comparison Operators The basic comparison operators and their meanings are: diff --git a/internal/operator-controller/bundle/versionrelease.go b/internal/operator-controller/bundle/versionrelease.go index ef63a7bddc..7f15da1c7c 100644 --- a/internal/operator-controller/bundle/versionrelease.go +++ b/internal/operator-controller/bundle/versionrelease.go @@ -10,27 +10,40 @@ import ( slicesutil "github.com/operator-framework/operator-controller/internal/shared/util/slices" ) -// NewLegacyRegistryV1VersionRelease parses a registry+v1 bundle version string and returns -// a VersionRelease. For registry+v1 bundles, the build metadata field of the semver version -// is treated as release information (a semver spec violation maintained for backward compatibility). -// The returned VersionRelease has the build metadata extracted into the Release field, and the -// Version field has its Build metadata cleared. +// NewLegacyRegistryV1VersionRelease parses a registry+v1 bundle version string and returns a +// VersionRelease. Some registry+v1 bundles utilize the build metadata field of the semver version +// as release information (a semver spec violation maintained for backward compatibility). When the +// bundle version includes build metadata that is parsable as a release, the returned +// VersionRelease has the build metadata extracted into the Release field, and the Version field +// has its Build metadata cleared. When the bundle version includes build metadata that is NOT +// parseable as a release, the returned VersionRelease has its Version set to the full semver +// version (with build metadata) and its Release left empty. func NewLegacyRegistryV1VersionRelease(vStr string) (*VersionRelease, error) { vers, err := bsemver.Parse(vStr) if err != nil { return nil, err } - rel, err := NewRelease(strings.Join(vers.Build, ".")) - if err != nil { - return nil, err + vr := &VersionRelease{ + Version: vers, } - vers.Build = nil - return &VersionRelease{ - Version: vers, - Release: rel, - }, nil + rel, err := NewRelease(strings.Join(vr.Version.Build, ".")) + if err == nil { + // If the version build metadata parses successfully as a release + // then use it as a release and drop the build metadata + // + // If we don't parse the build metadata as a release successfully, + // that doesn't mean we have an invalid version. It just means + // that we have a valid semver version with valid build metadata, + // but no release value. In this case, we return a VersionRelease + // with: + // - Version: the full version (with build metadata) + // - Release: + vr.Release = rel + vr.Version.Build = nil + } + return vr, nil } type VersionRelease struct { diff --git a/internal/operator-controller/bundleutil/bundle_test.go b/internal/operator-controller/bundleutil/bundle_test.go index 2496e2fa7d..781f5649b9 100644 --- a/internal/operator-controller/bundleutil/bundle_test.go +++ b/internal/operator-controller/bundleutil/bundle_test.go @@ -4,19 +4,23 @@ import ( "encoding/json" "testing" + "github.com/blang/semver/v4" + bsemver "github.com/blang/semver/v4" "github.com/stretchr/testify/require" "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/bundleutil" ) func TestGetVersionAndRelease(t *testing.T) { tests := []struct { - name string - pkgProperty *property.Property - wantErr bool + name string + pkgProperty *property.Property + wantVersionRelease *bundle.VersionRelease + wantErr bool }{ { name: "valid version", @@ -24,6 +28,14 @@ func TestGetVersionAndRelease(t *testing.T) { Type: property.TypePackage, Value: json.RawMessage(`{"version": "1.0.0-pre+1.alpha.2"}`), }, + wantVersionRelease: &bundle.VersionRelease{ + Version: semver.MustParse("1.0.0-pre"), + Release: bundle.Release([]bsemver.PRVersion{ + {VersionNum: 1, IsNum: true}, + {VersionStr: "alpha"}, + {VersionNum: 2, IsNum: true}, + }), + }, wantErr: false, }, { @@ -40,7 +52,10 @@ func TestGetVersionAndRelease(t *testing.T) { Type: property.TypePackage, Value: json.RawMessage(`{"version": "1.0.0+001"}`), }, - wantErr: true, + wantVersionRelease: &bundle.VersionRelease{ + Version: semver.MustParse("1.0.0+001"), + }, + wantErr: false, }, { name: "invalid json", diff --git a/internal/operator-controller/catalogmetadata/compare/compare.go b/internal/operator-controller/catalogmetadata/compare/compare.go index b0e6a4e754..decab58953 100644 --- a/internal/operator-controller/catalogmetadata/compare/compare.go +++ b/internal/operator-controller/catalogmetadata/compare/compare.go @@ -1,7 +1,6 @@ package compare import ( - "slices" "strings" mmsemver "github.com/Masterminds/semver/v3" @@ -18,28 +17,15 @@ import ( // provided versionRange. The versionRange provided to this function can be any valid semver // version string or any range constraint. // -// When the provided version range is a valid semver version that includes build metadata, then the -// returned function will only match an identical version with the same build metadata. -// -// When the provided version range is a valid semver version that does NOT include build metadata, -// then the returned function will match any version that matches the semver version, ignoring the -// build metadata of matched versions. +// When the provided version range is a valid semver version then the returned function will match +// any version that matches the semver version, ignoring the build metadata of matched versions. // // This function is intended to be used to parse the ClusterExtension.spec.source.catalog.version // field. See the API documentation for more details on the supported syntax. func NewVersionRange(versionRange string) (bsemver.Range, error) { - if versionPin, err := bsemver.Parse(versionRange); err == nil && len(versionPin.Build) > 0 { - return exactVersionMatcher(versionPin), nil - } return newMastermindsRange(versionRange) } -func exactVersionMatcher(pin bsemver.Version) bsemver.Range { - return func(v bsemver.Version) bool { - return pin.Compare(v) == 0 && slices.Compare(pin.Build, v.Build) == 0 - } -} - func newMastermindsRange(versionRange string) (bsemver.Range, error) { constraint, err := mmsemver.NewConstraint(versionRange) if err != nil { diff --git a/internal/operator-controller/catalogmetadata/compare/compare_test.go b/internal/operator-controller/catalogmetadata/compare/compare_test.go index da738d55fc..5c57f81b75 100644 --- a/internal/operator-controller/catalogmetadata/compare/compare_test.go +++ b/internal/operator-controller/catalogmetadata/compare/compare_test.go @@ -26,12 +26,12 @@ func TestNewVersionRange(t *testing.T) { { versionRange: "1.0.0+1", inputVersion: bsemver.MustParse("1.0.0"), - expect: false, + expect: true, }, { versionRange: "1.0.0+1", inputVersion: bsemver.MustParse("1.0.0+2"), - expect: false, + expect: true, }, { versionRange: "1.0.0+1", diff --git a/manifests/experimental-e2e.yaml b/manifests/experimental-e2e.yaml index c3bcba1aab..1bc93321ef 100644 --- a/manifests/experimental-e2e.yaml +++ b/manifests/experimental-e2e.yaml @@ -1211,14 +1211,6 @@ spec: "0.6.0", which means "only install version 0.6.0 and never upgrade from this version". - For registry+v1 bundles that include release information in the build - metadata field (e.g., "1.0.0+20230101"), you can pin to an exact version - including the release by specifying the full version string with build - metadata (e.g., "1.0.0+20230101"). This ensures an exact match of both - the semver version and the release. If you specify a version without build - metadata (e.g., "1.0.0"), it will match all bundles with that version - regardless of their release information. - # Basic Comparison Operators The basic comparison operators and their meanings are: diff --git a/manifests/experimental.yaml b/manifests/experimental.yaml index 4415fe4822..69128a8b7b 100644 --- a/manifests/experimental.yaml +++ b/manifests/experimental.yaml @@ -1176,14 +1176,6 @@ spec: "0.6.0", which means "only install version 0.6.0 and never upgrade from this version". - For registry+v1 bundles that include release information in the build - metadata field (e.g., "1.0.0+20230101"), you can pin to an exact version - including the release by specifying the full version string with build - metadata (e.g., "1.0.0+20230101"). This ensures an exact match of both - the semver version and the release. If you specify a version without build - metadata (e.g., "1.0.0"), it will match all bundles with that version - regardless of their release information. - # Basic Comparison Operators The basic comparison operators and their meanings are: diff --git a/manifests/standard-e2e.yaml b/manifests/standard-e2e.yaml index 88f3d27380..72f8b82dcf 100644 --- a/manifests/standard-e2e.yaml +++ b/manifests/standard-e2e.yaml @@ -970,14 +970,6 @@ spec: "0.6.0", which means "only install version 0.6.0 and never upgrade from this version". - For registry+v1 bundles that include release information in the build - metadata field (e.g., "1.0.0+20230101"), you can pin to an exact version - including the release by specifying the full version string with build - metadata (e.g., "1.0.0+20230101"). This ensures an exact match of both - the semver version and the release. If you specify a version without build - metadata (e.g., "1.0.0"), it will match all bundles with that version - regardless of their release information. - # Basic Comparison Operators The basic comparison operators and their meanings are: diff --git a/manifests/standard.yaml b/manifests/standard.yaml index 75b6baf43c..75ee176f7f 100644 --- a/manifests/standard.yaml +++ b/manifests/standard.yaml @@ -935,14 +935,6 @@ spec: "0.6.0", which means "only install version 0.6.0 and never upgrade from this version". - For registry+v1 bundles that include release information in the build - metadata field (e.g., "1.0.0+20230101"), you can pin to an exact version - including the release by specifying the full version string with build - metadata (e.g., "1.0.0+20230101"). This ensures an exact match of both - the semver version and the release. If you specify a version without build - metadata (e.g., "1.0.0"), it will match all bundles with that version - regardless of their release information. - # Basic Comparison Operators The basic comparison operators and their meanings are: