Skip to content

Commit b398c34

Browse files
joelanfordclaude
andcommitted
Add support for build metadata precedence in bundle version comparison
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 <noreply@anthropic.com>
1 parent 6604f2a commit b398c34

File tree

16 files changed

+486
-114
lines changed

16 files changed

+486
-114
lines changed
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package bundle
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"strings"
7+
8+
bsemver "github.com/blang/semver/v4"
9+
10+
slicesutil "github.com/operator-framework/operator-controller/internal/shared/util/slices"
11+
)
12+
13+
func NewLegacyRegistryV1VersionRelease(vStr string) (*VersionRelease, error) {
14+
vers, err := bsemver.Parse(vStr)
15+
if err != nil {
16+
return nil, err
17+
}
18+
19+
rel, err := NewRelease(strings.Join(vers.Build, "."))
20+
if err != nil {
21+
return nil, err
22+
}
23+
vers.Build = nil
24+
25+
return &VersionRelease{
26+
Version: vers,
27+
Release: rel,
28+
}, nil
29+
}
30+
31+
type VersionRelease struct {
32+
Version bsemver.Version
33+
Release Release
34+
}
35+
36+
func (vr *VersionRelease) Compare(other VersionRelease) int {
37+
if vCmp := vr.Version.Compare(other.Version); vCmp != 0 {
38+
return vCmp
39+
}
40+
return vr.Release.Compare(other.Release)
41+
}
42+
43+
func (vr *VersionRelease) AsLegacyRegistryV1Version() bsemver.Version {
44+
return bsemver.Version{
45+
Major: vr.Version.Major,
46+
Minor: vr.Version.Minor,
47+
Patch: vr.Version.Patch,
48+
Pre: vr.Version.Pre,
49+
Build: slicesutil.Map(vr.Release, func(i bsemver.PRVersion) string { return i.String() }),
50+
}
51+
}
52+
53+
type Release []bsemver.PRVersion
54+
55+
func (r Release) Compare(other Release) int {
56+
if len(r) == 0 && len(other) > 0 {
57+
return -1
58+
}
59+
if len(other) == 0 && len(r) > 0 {
60+
return 1
61+
}
62+
a := bsemver.Version{Pre: r}
63+
b := bsemver.Version{Pre: other}
64+
return a.Compare(b)
65+
}
66+
67+
func NewRelease(relStr string) (Release, error) {
68+
if relStr == "" {
69+
return nil, nil
70+
}
71+
72+
var (
73+
segments = strings.Split(relStr, ".")
74+
r = make(Release, 0, len(segments))
75+
errs []error
76+
)
77+
for i, segment := range segments {
78+
prVer, err := bsemver.NewPRVersion(segment)
79+
if err != nil {
80+
errs = append(errs, fmt.Errorf("segment %d: %v", i, err))
81+
continue
82+
}
83+
r = append(r, prVer)
84+
}
85+
if err := errors.Join(errs...); err != nil {
86+
return nil, fmt.Errorf("invalid release %q: %v", relStr, err)
87+
}
88+
return r, nil
89+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package bundle_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
9+
"github.com/operator-framework/operator-controller/internal/operator-controller/bundle"
10+
)
11+
12+
func TestLegacyRegistryV1VersionRelease_Compare(t *testing.T) {
13+
type testCase struct {
14+
name string
15+
v1 string
16+
v2 string
17+
expect int
18+
}
19+
for _, tc := range []testCase{
20+
{
21+
name: "lower major version",
22+
v1: "1.0.0-0+0",
23+
v2: "2.0.0-0+0",
24+
expect: -1,
25+
},
26+
{
27+
name: "lower minor version",
28+
v1: "0.1.0-0+0",
29+
v2: "0.2.0-0+0",
30+
expect: -1,
31+
},
32+
{
33+
name: "lower patch version",
34+
v1: "0.0.1-0+0",
35+
v2: "0.0.2-0+0",
36+
expect: -1,
37+
},
38+
{
39+
name: "lower prerelease version",
40+
v1: "0.0.0-1+0",
41+
v2: "0.0.0-2+0",
42+
expect: -1,
43+
},
44+
{
45+
name: "lower build metadata",
46+
v1: "0.0.0-0+1",
47+
v2: "0.0.0-0+2",
48+
expect: -1,
49+
},
50+
{
51+
name: "same major version",
52+
v1: "1.0.0-0+0",
53+
v2: "1.0.0-0+0",
54+
expect: 0,
55+
},
56+
{
57+
name: "same minor version",
58+
v1: "0.1.0-0+0",
59+
v2: "0.1.0-0+0",
60+
expect: 0,
61+
},
62+
{
63+
name: "same patch version",
64+
v1: "0.0.1-0+0",
65+
v2: "0.0.1-0+0",
66+
expect: 0,
67+
},
68+
{
69+
name: "same prerelease version",
70+
v1: "0.0.0-1+0",
71+
v2: "0.0.0-1+0",
72+
expect: 0,
73+
},
74+
{
75+
name: "same build metadata",
76+
v1: "0.0.0-0+1",
77+
v2: "0.0.0-0+1",
78+
expect: 0,
79+
},
80+
{
81+
name: "higher major version",
82+
v1: "2.0.0-0+0",
83+
v2: "1.0.0-0+0",
84+
expect: 1,
85+
},
86+
{
87+
name: "higher minor version",
88+
v1: "0.2.0-0+0",
89+
v2: "0.1.0-0+0",
90+
expect: 1,
91+
},
92+
{
93+
name: "higher patch version",
94+
v1: "0.0.2-0+0",
95+
v2: "0.0.1-0+0",
96+
expect: 1,
97+
},
98+
{
99+
name: "higher prerelease version",
100+
v1: "0.0.0-2+0",
101+
v2: "0.0.0-1+0",
102+
expect: 1,
103+
},
104+
{
105+
name: "higher build metadata",
106+
v1: "0.0.0-0+2",
107+
v2: "0.0.0-0+1",
108+
expect: 1,
109+
},
110+
} {
111+
t.Run(tc.name, func(t *testing.T) {
112+
vr1, err1 := bundle.NewLegacyRegistryV1VersionRelease(tc.v1)
113+
vr2, err2 := bundle.NewLegacyRegistryV1VersionRelease(tc.v2)
114+
require.NoError(t, err1)
115+
require.NoError(t, err2)
116+
117+
actual := vr1.Compare(*vr2)
118+
assert.Equal(t, tc.expect, actual)
119+
})
120+
}
121+
}

internal/operator-controller/bundleutil/bundle.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,26 @@ import (
1010
"github.com/operator-framework/operator-registry/alpha/property"
1111

1212
ocv1 "github.com/operator-framework/operator-controller/api/v1"
13+
"github.com/operator-framework/operator-controller/internal/operator-controller/bundle"
1314
)
1415

15-
func GetVersion(b declcfg.Bundle) (*bsemver.Version, error) {
16+
func GetVersionAndRelease(b declcfg.Bundle) (*bundle.VersionRelease, error) {
1617
for _, p := range b.Properties {
1718
if p.Type == property.TypePackage {
1819
var pkg property.Package
1920
if err := json.Unmarshal(p.Value, &pkg); err != nil {
2021
return nil, fmt.Errorf("error unmarshalling package property: %w", err)
2122
}
22-
vers, err := bsemver.Parse(pkg.Version)
23+
24+
// TODO: For now, we assume that all bundles are registry+v1 bundles.
25+
// In the future, when we support other bundle formats, we should stop
26+
// using the legacy mechanism (i.e. using build metadata in the version)
27+
// to determine the bundle's release.
28+
vr, err := bundle.NewLegacyRegistryV1VersionRelease(pkg.Version)
2329
if err != nil {
2430
return nil, err
2531
}
26-
return &vers, nil
32+
return vr, nil
2733
}
2834
}
2935
return nil, fmt.Errorf("no package property found in bundle %q", b.Name)

internal/operator-controller/bundleutil/bundle_test.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import (
1212
"github.com/operator-framework/operator-controller/internal/operator-controller/bundleutil"
1313
)
1414

15-
func TestGetVersion(t *testing.T) {
15+
func TestGetVersionAndRelease(t *testing.T) {
1616
tests := []struct {
1717
name string
1818
pkgProperty *property.Property
@@ -22,7 +22,7 @@ func TestGetVersion(t *testing.T) {
2222
name: "valid version",
2323
pkgProperty: &property.Property{
2424
Type: property.TypePackage,
25-
Value: json.RawMessage(`{"version": "1.0.0"}`),
25+
Value: json.RawMessage(`{"version": "1.0.0-pre+1.alpha.2"}`),
2626
},
2727
wantErr: false,
2828
},
@@ -34,6 +34,14 @@ func TestGetVersion(t *testing.T) {
3434
},
3535
wantErr: true,
3636
},
37+
{
38+
name: "invalid release",
39+
pkgProperty: &property.Property{
40+
Type: property.TypePackage,
41+
Value: json.RawMessage(`{"version": "1.0.0+001"}`),
42+
},
43+
wantErr: true,
44+
},
3745
{
3846
name: "invalid json",
3947
pkgProperty: &property.Property{
@@ -61,7 +69,7 @@ func TestGetVersion(t *testing.T) {
6169
Properties: properties,
6270
}
6371

64-
_, err := bundleutil.GetVersion(bundle)
72+
_, err := bundleutil.GetVersionAndRelease(bundle)
6573
if tc.wantErr {
6674
require.Error(t, err)
6775
} else {
Lines changed: 62 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,71 @@
11
package compare
22

33
import (
4+
"slices"
5+
"strings"
6+
7+
mmsemver "github.com/Masterminds/semver/v3"
8+
bsemver "github.com/blang/semver/v4"
49
"k8s.io/apimachinery/pkg/util/sets"
510

611
"github.com/operator-framework/operator-registry/alpha/declcfg"
712

813
"github.com/operator-framework/operator-controller/internal/operator-controller/bundleutil"
14+
slicesutil "github.com/operator-framework/operator-controller/internal/shared/util/slices"
915
)
1016

11-
// ByVersion is a sort "less" function that orders bundles
12-
// in inverse version order (higher versions on top).
13-
func ByVersion(b1, b2 declcfg.Bundle) int {
14-
v1, err1 := bundleutil.GetVersion(b1)
15-
v2, err2 := bundleutil.GetVersion(b2)
16-
if err1 != nil || err2 != nil {
17-
return compareErrors(err1, err2)
17+
// NewVersionRange returns a function that tests whether a semver version is in the
18+
// provided versionRange. The versionRange provided to this function can be any valid semver
19+
// version string or any range that matches the syntax defined in the Masterminds semver library.
20+
//
21+
// When the provided version range is a valid semver version that includes build metadata, then the
22+
// returned function will only match an identical version with the same build metadata.
23+
//
24+
// When the provided version range is a valid semver version that does NOT include build metadata,
25+
// then the returned function will match any version that matches the semver version, ignoring the
26+
// build metadata of matched versions.
27+
//
28+
// Otherwise, Masterminds semver constraints semantics are used to match versions.
29+
// See https://github.com/Masterminds/semver#checking-version-constraints
30+
func NewVersionRange(versionRange string) (bsemver.Range, error) {
31+
if versionPin, err := bsemver.Parse(versionRange); err == nil && len(versionPin.Build) > 0 {
32+
return exactVersionMatcher(versionPin), nil
1833
}
34+
return newMastermindsRange(versionRange)
35+
}
1936

20-
// Check for "greater than" because
21-
// we want higher versions on top
22-
return v2.Compare(*v1)
37+
func exactVersionMatcher(pin bsemver.Version) bsemver.Range {
38+
return func(v bsemver.Version) bool {
39+
return pin.Compare(v) == 0 && slices.Compare(pin.Build, v.Build) == 0
40+
}
41+
}
42+
43+
func newMastermindsRange(versionRange string) (bsemver.Range, error) {
44+
constraint, err := mmsemver.NewConstraint(versionRange)
45+
if err != nil {
46+
return nil, err
47+
}
48+
return func(in bsemver.Version) bool {
49+
pre := slicesutil.Map(in.Pre, func(pr bsemver.PRVersion) string { return pr.String() })
50+
mmVer := mmsemver.New(in.Major, in.Minor, in.Patch, strings.Join(pre, "."), strings.Join(in.Build, "."))
51+
return constraint.Check(mmVer)
52+
}, nil
53+
}
54+
55+
// ByVersionAndRelease is a comparison function that compares bundles by
56+
// version and release. Bundles with lower versions/releases are
57+
// considered less than bundles with higher versions/releases.
58+
func ByVersionAndRelease(b1, b2 declcfg.Bundle) int {
59+
vr1, err1 := bundleutil.GetVersionAndRelease(b1)
60+
vr2, err2 := bundleutil.GetVersionAndRelease(b2)
61+
62+
// We don't really expect errors, because we expect well-formed/validated
63+
// FBC as input. However, just in case we'll check the errors and sort
64+
// invalid bundles as "lower" than valid bundles.
65+
if err1 != nil || err2 != nil {
66+
return compareErrors(err2, err1)
67+
}
68+
return vr2.Compare(*vr1)
2369
}
2470

2571
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
4288
}
4389
}
4490

45-
// compareErrors returns 0 if both errors are either nil or not nil
46-
// -1 if err1 is nil and err2 is not nil
47-
// +1 if err1 is not nil and err2 is nil
91+
// compareErrors returns 0 if both errors are either nil or not nil,
92+
// -1 if err1 is not nil and err2 is nil, and
93+
// +1 if err1 is nil and err2 is not nil
94+
// The semantic is that errors are "less than" non-errors.
4895
func compareErrors(err1 error, err2 error) int {
4996
if err1 != nil && err2 == nil {
50-
return 1
97+
return -1
5198
}
52-
5399
if err1 == nil && err2 != nil {
54-
return -1
100+
return 1
55101
}
56102
return 0
57103
}

0 commit comments

Comments
 (0)