Skip to content

Commit d352bc0

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 d352bc0

File tree

14 files changed

+470
-115
lines changed

14 files changed

+470
-115
lines changed

internal/operator-controller/bundleutil/bundle.go

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,33 +2,118 @@ package bundleutil
22

33
import (
44
"encoding/json"
5+
"errors"
56
"fmt"
7+
"strings"
68

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

911
"github.com/operator-framework/operator-registry/alpha/declcfg"
1012
"github.com/operator-framework/operator-registry/alpha/property"
1113

1214
ocv1 "github.com/operator-framework/operator-controller/api/v1"
15+
slicesutil "github.com/operator-framework/operator-controller/internal/shared/util/slices"
1316
)
1417

15-
func GetVersion(b declcfg.Bundle) (*bsemver.Version, error) {
18+
func GetVersionAndRelease(b declcfg.Bundle) (*VersionRelease, error) {
1619
for _, p := range b.Properties {
1720
if p.Type == property.TypePackage {
1821
var pkg property.Package
1922
if err := json.Unmarshal(p.Value, &pkg); err != nil {
2023
return nil, fmt.Errorf("error unmarshalling package property: %w", err)
2124
}
22-
vers, err := bsemver.Parse(pkg.Version)
25+
26+
// TODO: For now, we assume that all bundles are registry+v1 bundles.
27+
// In the future, when we support other bundle formats, we should stop
28+
// using the legacy mechanism (i.e. using build metadata in the version)
29+
// to determine the bundle's release.
30+
vr, err := NewLegacyRegistryV1VersionRelease(pkg.Version)
2331
if err != nil {
2432
return nil, err
2533
}
26-
return &vers, nil
34+
return vr, nil
2735
}
2836
}
2937
return nil, fmt.Errorf("no package property found in bundle %q", b.Name)
3038
}
3139

40+
func NewLegacyRegistryV1VersionRelease(vStr string) (*VersionRelease, error) {
41+
vers, err := bsemver.Parse(vStr)
42+
if err != nil {
43+
return nil, err
44+
}
45+
46+
rel, err := NewRelease(strings.Join(vers.Build, "."))
47+
if err != nil {
48+
return nil, err
49+
}
50+
51+
return &VersionRelease{
52+
Version: vers,
53+
Release: rel,
54+
}, nil
55+
}
56+
57+
type VersionRelease struct {
58+
Version bsemver.Version
59+
Release Release
60+
}
61+
62+
func (vr VersionRelease) Compare(other VersionRelease) int {
63+
if vCmp := vr.Version.Compare(other.Version); vCmp != 0 {
64+
return vCmp
65+
}
66+
return vr.Release.Compare(other.Release)
67+
}
68+
69+
func (vr VersionRelease) AsLegacyRegistryV1Version() bsemver.Version {
70+
return bsemver.Version{
71+
Major: vr.Version.Major,
72+
Minor: vr.Version.Minor,
73+
Patch: vr.Version.Patch,
74+
Pre: vr.Version.Pre,
75+
Build: slicesutil.Map(vr.Release, func(i bsemver.PRVersion) string { return i.String() }),
76+
}
77+
}
78+
79+
type Release []bsemver.PRVersion
80+
81+
func (r Release) Compare(other Release) int {
82+
if len(r) == 0 && len(other) > 0 {
83+
return -1
84+
}
85+
if len(other) == 0 && len(r) > 0 {
86+
return 1
87+
}
88+
a := bsemver.Version{Pre: r}
89+
b := bsemver.Version{Pre: other}
90+
return a.Compare(b)
91+
}
92+
93+
func NewRelease(relStr string) (Release, error) {
94+
if relStr == "" {
95+
return nil, nil
96+
}
97+
98+
var (
99+
segments = strings.Split(relStr, ".")
100+
r = make(Release, 0, len(segments))
101+
errs []error
102+
)
103+
for i, segment := range segments {
104+
prVer, err := bsemver.NewPRVersion(segment)
105+
if err != nil {
106+
errs = append(errs, fmt.Errorf("segment %d: %v", i, err))
107+
continue
108+
}
109+
r = append(r, prVer)
110+
}
111+
if err := errors.Join(errs...); err != nil {
112+
return nil, fmt.Errorf("invalid release %q: %v", relStr, err)
113+
}
114+
return r, nil
115+
}
116+
32117
// MetadataFor returns a BundleMetadata for the given bundle name and version.
33118
func MetadataFor(bundleName string, bundleVersion bsemver.Version) ocv1.BundleMetadata {
34119
return ocv1.BundleMetadata{

internal/operator-controller/bundleutil/bundle_test.go

Lines changed: 123 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"encoding/json"
55
"testing"
66

7+
"github.com/stretchr/testify/assert"
78
"github.com/stretchr/testify/require"
89

910
"github.com/operator-framework/operator-registry/alpha/declcfg"
@@ -12,7 +13,7 @@ import (
1213
"github.com/operator-framework/operator-controller/internal/operator-controller/bundleutil"
1314
)
1415

15-
func TestGetVersion(t *testing.T) {
16+
func TestGetVersionAndRelease(t *testing.T) {
1617
tests := []struct {
1718
name string
1819
pkgProperty *property.Property
@@ -22,7 +23,7 @@ func TestGetVersion(t *testing.T) {
2223
name: "valid version",
2324
pkgProperty: &property.Property{
2425
Type: property.TypePackage,
25-
Value: json.RawMessage(`{"version": "1.0.0"}`),
26+
Value: json.RawMessage(`{"version": "1.0.0-pre+1.alpha.2"}`),
2627
},
2728
wantErr: false,
2829
},
@@ -34,6 +35,14 @@ func TestGetVersion(t *testing.T) {
3435
},
3536
wantErr: true,
3637
},
38+
{
39+
name: "invalid release",
40+
pkgProperty: &property.Property{
41+
Type: property.TypePackage,
42+
Value: json.RawMessage(`{"version": "1.0.0+001"}`),
43+
},
44+
wantErr: true,
45+
},
3746
{
3847
name: "invalid json",
3948
pkgProperty: &property.Property{
@@ -61,7 +70,7 @@ func TestGetVersion(t *testing.T) {
6170
Properties: properties,
6271
}
6372

64-
_, err := bundleutil.GetVersion(bundle)
73+
_, err := bundleutil.GetVersionAndRelease(bundle)
6574
if tc.wantErr {
6675
require.Error(t, err)
6776
} else {
@@ -70,3 +79,114 @@ func TestGetVersion(t *testing.T) {
7079
})
7180
}
7281
}
82+
83+
func TestLegacyRegistryV1VersionRelease_Compare(t *testing.T) {
84+
type testCase struct {
85+
name string
86+
v1 string
87+
v2 string
88+
expect int
89+
}
90+
for _, tc := range []testCase{
91+
{
92+
name: "lower major version",
93+
v1: "1.0.0-0+0",
94+
v2: "2.0.0-0+0",
95+
expect: -1,
96+
},
97+
{
98+
name: "lower minor version",
99+
v1: "0.1.0-0+0",
100+
v2: "0.2.0-0+0",
101+
expect: -1,
102+
},
103+
{
104+
name: "lower patch version",
105+
v1: "0.0.1-0+0",
106+
v2: "0.0.2-0+0",
107+
expect: -1,
108+
},
109+
{
110+
name: "lower prerelease version",
111+
v1: "0.0.0-1+0",
112+
v2: "0.0.0-2+0",
113+
expect: -1,
114+
},
115+
{
116+
name: "lower build metadata",
117+
v1: "0.0.0-0+1",
118+
v2: "0.0.0-0+2",
119+
expect: -1,
120+
},
121+
{
122+
name: "same major version",
123+
v1: "1.0.0-0+0",
124+
v2: "1.0.0-0+0",
125+
expect: 0,
126+
},
127+
{
128+
name: "same minor version",
129+
v1: "0.1.0-0+0",
130+
v2: "0.1.0-0+0",
131+
expect: 0,
132+
},
133+
{
134+
name: "same patch version",
135+
v1: "0.0.1-0+0",
136+
v2: "0.0.1-0+0",
137+
expect: 0,
138+
},
139+
{
140+
name: "same prerelease version",
141+
v1: "0.0.0-1+0",
142+
v2: "0.0.0-1+0",
143+
expect: 0,
144+
},
145+
{
146+
name: "same build metadata",
147+
v1: "0.0.0-0+1",
148+
v2: "0.0.0-0+1",
149+
expect: 0,
150+
},
151+
{
152+
name: "higher major version",
153+
v1: "2.0.0-0+0",
154+
v2: "1.0.0-0+0",
155+
expect: 1,
156+
},
157+
{
158+
name: "higher minor version",
159+
v1: "0.2.0-0+0",
160+
v2: "0.1.0-0+0",
161+
expect: 1,
162+
},
163+
{
164+
name: "higher patch version",
165+
v1: "0.0.2-0+0",
166+
v2: "0.0.1-0+0",
167+
expect: 1,
168+
},
169+
{
170+
name: "higher prerelease version",
171+
v1: "0.0.0-2+0",
172+
v2: "0.0.0-1+0",
173+
expect: 1,
174+
},
175+
{
176+
name: "higher build metadata",
177+
v1: "0.0.0-0+2",
178+
v2: "0.0.0-0+1",
179+
expect: 1,
180+
},
181+
} {
182+
t.Run(tc.name, func(t *testing.T) {
183+
vr1, err1 := bundleutil.NewLegacyRegistryV1VersionRelease(tc.v1)
184+
vr2, err2 := bundleutil.NewLegacyRegistryV1VersionRelease(tc.v2)
185+
require.NoError(t, err1)
186+
require.NoError(t, err2)
187+
188+
actual := vr1.Compare(*vr2)
189+
assert.Equal(t, tc.expect, actual)
190+
})
191+
}
192+
}
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)