chore(repo-server): unify semver resolution in new versions subpackage (#20216)

Signed-off-by: Paul Larsen <pnvlarsen@gmail.com>
Signed-off-by: Blake Pettersson <blake.pettersson@gmail.com>
Signed-off-by: Alexandre Gaudreault <alexandre_gaudreault@intuit.com>
Co-authored-by: Blake Pettersson <blake.pettersson@gmail.com>
Co-authored-by: Alexandre Gaudreault <alexandre_gaudreault@intuit.com>
This commit is contained in:
Paul Larsen 2025-05-08 08:10:28 +01:00 committed by GitHub
parent 3f3ac06fd1
commit 6625d07859
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 230 additions and 292 deletions

View file

@ -16,7 +16,6 @@ import (
"strings"
"time"
"github.com/Masterminds/semver/v3"
"github.com/TomOnTime/utfutil"
"github.com/argoproj/gitops-engine/pkg/utils/kube"
textutils "github.com/argoproj/gitops-engine/pkg/utils/text"
@ -60,6 +59,7 @@ import (
"github.com/argoproj/argo-cd/v3/util/kustomize"
"github.com/argoproj/argo-cd/v3/util/manifeststream"
"github.com/argoproj/argo-cd/v3/util/text"
"github.com/argoproj/argo-cd/v3/util/versions"
)
const (
@ -2404,40 +2404,37 @@ func (s *Service) newClientResolveRevision(repo *v1alpha1.Repository, revision s
func (s *Service) newHelmClientResolveRevision(repo *v1alpha1.Repository, revision string, chart string, noRevisionCache bool) (helm.Client, string, error) {
enableOCI := repo.EnableOCI || helm.IsHelmOciRepo(repo.Repo)
helmClient := s.newHelmClient(repo.Repo, repo.GetHelmCreds(), enableOCI, repo.Proxy, repo.NoProxy, helm.WithIndexCache(s.cache), helm.WithChartPaths(s.chartPaths))
if helm.IsVersion(revision) {
// Note: This check runs the risk of returning a version which is not found in the helm registry.
if versions.IsVersion(revision) {
return helmClient, revision, nil
}
constraints, err := semver.NewConstraint(revision)
if err != nil {
return nil, "", fmt.Errorf("invalid revision '%s': %w", revision, err)
}
var tags []string
if enableOCI {
tags, err := helmClient.GetTags(chart, noRevisionCache)
var err error
tags, err = helmClient.GetTags(chart, noRevisionCache)
if err != nil {
return nil, "", fmt.Errorf("unable to get tags: %w", err)
}
version, err := tags.MaxVersion(constraints)
} else {
index, err := helmClient.GetIndex(noRevisionCache, s.initConstants.HelmRegistryMaxIndexSize)
if err != nil {
return nil, "", fmt.Errorf("no version for constraints: %w", err)
return nil, "", err
}
return helmClient, version.String(), nil
entries, err := index.GetEntries(chart)
if err != nil {
return nil, "", err
}
tags = entries.Tags()
}
index, err := helmClient.GetIndex(noRevisionCache, s.initConstants.HelmRegistryMaxIndexSize)
maxV, err := versions.MaxVersion(revision, tags)
if err != nil {
return nil, "", err
return nil, "", fmt.Errorf("invalid revision: %w", err)
}
entries, err := index.GetEntries(chart)
if err != nil {
return nil, "", err
}
version, err := entries.MaxVersion(constraints)
if err != nil {
return nil, "", err
}
return helmClient, version.String(), nil
return helmClient, maxV, nil
}
// directoryPermissionInitializer ensures the directory has read/write/execute permissions and returns
@ -2565,13 +2562,10 @@ func (s *Service) GetHelmCharts(_ context.Context, q *apiclient.HelmChartsReques
}
res := apiclient.HelmChartsResponse{}
for chartName, entries := range index.Entries {
chart := apiclient.HelmChart{
Name: chartName,
}
for _, entry := range entries {
chart.Versions = append(chart.Versions, entry.Version)
}
res.Items = append(res.Items, &chart)
res.Items = append(res.Items, &apiclient.HelmChart{
Name: chartName,
Versions: entries.Tags(),
})
}
return &res, nil
}

View file

@ -126,6 +126,7 @@ func newServiceWithMocks(t *testing.T, root string, signed bool) (*Service, *git
chart: {{Version: "1.0.0"}, {Version: version}},
oobChart: {{Version: "1.0.0"}, {Version: version}},
}}, nil)
helmClient.On("GetTags", mock.Anything, mock.Anything).Return(nil, nil)
helmClient.On("ExtractChart", chart, version, false, int64(0), false).Return("./testdata/my-chart", io.NopCloser, nil)
helmClient.On("ExtractChart", oobChart, version, false, int64(0), false).Return("./testdata2/out-of-bounds-chart", io.NopCloser, nil)
helmClient.On("CleanChartCache", chart, version).Return(nil)
@ -1826,12 +1827,12 @@ func TestService_newHelmClientResolveRevision(t *testing.T) {
service := newService(t, ".")
t.Run("EmptyRevision", func(t *testing.T) {
_, _, err := service.newHelmClientResolveRevision(&v1alpha1.Repository{}, "", "", true)
assert.EqualError(t, err, "invalid revision '': improper constraint: ")
_, _, err := service.newHelmClientResolveRevision(&v1alpha1.Repository{}, "", "my-chart", true)
assert.EqualError(t, err, "invalid revision: failed to determine semver constraint: improper constraint: ")
})
t.Run("InvalidRevision", func(t *testing.T) {
_, _, err := service.newHelmClientResolveRevision(&v1alpha1.Repository{}, "???", "", true)
assert.EqualError(t, err, "invalid revision '???': improper constraint: ???", true)
_, _, err := service.newHelmClientResolveRevision(&v1alpha1.Repository{}, "???", "my-chart", true)
assert.EqualError(t, err, "invalid revision: failed to determine semver constraint: improper constraint: ???")
})
}
@ -4101,7 +4102,7 @@ func TestGetRefs_CacheLockTryLockGitRefCacheError(t *testing.T) {
}
func TestGetRevisionChartDetails(t *testing.T) {
t.Run("Test revision semvar", func(t *testing.T) {
t.Run("Test revision semver", func(t *testing.T) {
root := t.TempDir()
service := newService(t, root)
_, err := service.GetRevisionChartDetails(t.Context(), &apiclient.RepoServerRevisionChartDetailsRequest{

View file

@ -17,7 +17,6 @@ import (
"syscall"
"time"
"github.com/Masterminds/semver/v3"
"github.com/bmatcuk/doublestar/v4"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
@ -37,6 +36,7 @@ import (
"github.com/argoproj/argo-cd/v3/util/env"
executil "github.com/argoproj/argo-cd/v3/util/exec"
"github.com/argoproj/argo-cd/v3/util/proxy"
"github.com/argoproj/argo-cd/v3/util/versions"
)
var ErrInvalidRepoURL = errors.New("repo URL is invalid")
@ -634,6 +634,16 @@ func (m *nativeGitClient) LsRemote(revision string) (res string, err error) {
return
}
func getGitTags(refs []*plumbing.Reference) []string {
var tags []string
for _, ref := range refs {
if ref.Name().IsTag() {
tags = append(tags, ref.Name().Short())
}
}
return tags
}
func (m *nativeGitClient) lsRemote(revision string) (string, error) {
if IsCommitSHA(revision) {
return revision, nil
@ -648,9 +658,9 @@ func (m *nativeGitClient) lsRemote(revision string) (string, error) {
revision = "HEAD"
}
semverSha := m.resolveSemverRevision(revision, refs)
if semverSha != "" {
return semverSha, nil
maxV, err := versions.MaxVersion(revision, getGitTags(refs))
if err == nil {
revision = maxV
}
// refToHash keeps a maps of remote refs to their hash
@ -699,59 +709,6 @@ func (m *nativeGitClient) lsRemote(revision string) (string, error) {
return "", fmt.Errorf("unable to resolve '%s' to a commit SHA", revision)
}
// resolveSemverRevision is a part of the lsRemote method workflow.
// When the user correctly configures the Git repository revision, and that revision is a valid semver constraint, we
// use this logic path rather than the standard lsRemote revision resolution loop.
// Some examples to illustrate the actual behavior - if the revision is:
// * "v0.1.2"/"0.1.2" or "v0.1"/"0.1", then this is not a constraint, it's a pinned version - so we fall back to the standard tag matching in the lsRemote loop.
// * "v0.1.*"/"0.1.*", and there's a tag matching that constraint, then we find the latest matching version and return its commit hash.
// * "v0.1.*"/"0.1.*", and there is *no* tag matching that constraint, then we fall back to the standard tag matching in the lsRemote loop.
// * "custom-tag", only the lsRemote loop will run - because that revision is an invalid semver;
// * "master-branch", only the lsRemote loop will run because that revision is an invalid semver;
func (m *nativeGitClient) resolveSemverRevision(revision string, refs []*plumbing.Reference) string {
if _, err := semver.NewVersion(revision); err == nil {
// If the revision is a valid version, then we know it isn't a constraint; it's just a pin.
// In which case, we should use standard tag resolution mechanisms.
return ""
}
constraint, err := semver.NewConstraint(revision)
if err != nil {
log.Debugf("Revision '%s' is not a valid semver constraint, skipping semver resolution.", revision)
return ""
}
maxVersion := semver.New(0, 0, 0, "", "")
maxVersionHash := plumbing.ZeroHash
for _, ref := range refs {
if !ref.Name().IsTag() {
continue
}
tag := ref.Name().Short()
version, err := semver.NewVersion(tag)
if err != nil {
log.Debugf("Error parsing version for tag: '%s': %v", tag, err)
// Skip this tag and continue to the next one
continue
}
if constraint.Check(version) {
if version.GreaterThan(maxVersion) {
maxVersion = version
maxVersionHash = ref.Hash()
}
}
}
if maxVersionHash.IsZero() {
return ""
}
log.Debugf("Semver constraint '%s' resolved to tag '%s', at reference '%s'", revision, maxVersion.Original(), maxVersionHash.String())
return maxVersionHash.String()
}
// CommitSHA returns current commit sha from `git rev-parse HEAD`
func (m *nativeGitClient) CommitSHA() (string, error) {
out, err := m.runCmd("rev-parse", "HEAD")

View file

@ -327,7 +327,7 @@ func Test_SemverTags(t *testing.T) {
// However, if one specifies the minor/patch versions, semver constraints can be used to match non-semver tags.
// 2024-banana is considered as "2024.0.0-banana" in semver-ish, and banana > apple, so it's a match.
// Note: this is more for documentation and future reference than real testing, as it seems like quite odd behaviour.
name: "semver constraints on non-semver tags",
name: "semver constraints on semver tags",
ref: "> 2024.0.0-apple",
expected: mapTagRefs["2024-banana"],
}} {

View file

@ -49,7 +49,7 @@ type Client interface {
CleanChartCache(chart string, version string) error
ExtractChart(chart string, version string, passCredentials bool, manifestMaxExtractedSize int64, disableManifestMaxExtractedSize bool) (string, argoio.Closer, error)
GetIndex(noCache bool, maxIndexSize int64) (*Index, error)
GetTags(chart string, noCache bool) (*TagsList, error)
GetTags(chart string, noCache bool) ([]string, error)
TestHelmOCI() (bool, error)
}
@ -416,7 +416,7 @@ func getIndexURL(rawURL string) (string, error) {
return repoURL.String(), nil
}
func (c *nativeHelmChart) GetTags(chart string, noCache bool) (*TagsList, error) {
func (c *nativeHelmChart) GetTags(chart string, noCache bool) ([]string, error) {
if !c.enableOci {
return nil, ErrOCINotEnabled
}
@ -432,7 +432,11 @@ func (c *nativeHelmChart) GetTags(chart string, noCache bool) (*TagsList, error)
}
}
tags := &TagsList{}
type entriesStruct struct {
Tags []string
}
entries := &entriesStruct{}
if len(data) == 0 {
start := time.Now()
repo, err := remote.NewRepository(tagsURL)
@ -479,7 +483,7 @@ func (c *nativeHelmChart) GetTags(chart string, noCache bool) (*TagsList, error)
for _, tag := range tagsResult {
// By convention: Change underscore (_) back to plus (+) to get valid SemVer
convertedTag := strings.ReplaceAll(tag, "_", "+")
tags.Tags = append(tags.Tags, convertedTag)
entries.Tags = append(entries.Tags, convertedTag)
}
return nil
@ -497,11 +501,11 @@ func (c *nativeHelmChart) GetTags(chart string, noCache bool) (*TagsList, error)
}
}
} else {
err := json.Unmarshal(data, tags)
err := json.Unmarshal(data, entries)
if err != nil {
return nil, fmt.Errorf("failed to decode tags: %w", err)
}
}
return tags, nil
return entries.Tags, nil
}

View file

@ -25,6 +25,10 @@ type fakeIndexCache struct {
data []byte
}
type fakeTagsList struct {
Tags []string `json:"tags"`
}
func (f *fakeIndexCache) SetHelmIndex(_ string, indexData []byte) error {
f.data = indexData
return nil
@ -170,19 +174,23 @@ func TestGetTagsFromUrl(t *testing.T) {
t.Run("should return tags correctly while following the link header", func(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Logf("called %s", r.URL.Path)
responseTags := TagsList{}
var responseTags fakeTagsList
w.Header().Set("Content-Type", "application/json")
if !strings.Contains(r.URL.String(), "token") {
w.Header().Set("Link", fmt.Sprintf("<https://%s%s?token=next-token>; rel=next", r.Host, r.URL.Path))
responseTags.Tags = []string{"first"}
responseTags = fakeTagsList{
Tags: []string{"first"},
}
} else {
responseTags.Tags = []string{
"second",
"2.8.0",
"2.8.0-prerelease",
"2.8.0_build",
"2.8.0-prerelease_build",
"2.8.0-prerelease.1_build.1234",
responseTags = fakeTagsList{
Tags: []string{
"second",
"2.8.0",
"2.8.0-prerelease",
"2.8.0_build",
"2.8.0-prerelease_build",
"2.8.0-prerelease.1_build.1234",
},
}
}
w.WriteHeader(http.StatusOK)
@ -193,7 +201,7 @@ func TestGetTagsFromUrl(t *testing.T) {
tags, err := client.GetTags("mychart", true)
require.NoError(t, err)
assert.ElementsMatch(t, tags.Tags, []string{
assert.ElementsMatch(t, tags, []string{
"first",
"second",
"2.8.0",
@ -229,7 +237,7 @@ func TestGetTagsFromURLPrivateRepoAuthentication(t *testing.T) {
assert.Equal(t, expectedAuthorization, authorization)
responseTags := TagsList{
responseTags := fakeTagsList{
Tags: []string{
"2.8.0",
"2.8.0-prerelease",
@ -281,7 +289,7 @@ func TestGetTagsFromURLPrivateRepoAuthentication(t *testing.T) {
tags, err := client.GetTags("mychart", true)
require.NoError(t, err)
assert.ElementsMatch(t, tags.Tags, []string{
assert.ElementsMatch(t, tags, []string{
"2.8.0",
"2.8.0-prerelease",
"2.8.0+build",
@ -326,7 +334,7 @@ func TestGetTagsFromURLPrivateRepoWithAzureWorkloadIdentityAuthentication(t *tes
assert.Equal(t, expectedAuthorization, authorization)
responseTags := TagsList{
responseTags := fakeTagsList{
Tags: []string{
"2.8.0",
"2.8.0-prerelease",
@ -379,7 +387,7 @@ func TestGetTagsFromURLPrivateRepoWithAzureWorkloadIdentityAuthentication(t *tes
tags, err := client.GetTags("mychart", true)
require.NoError(t, err)
assert.ElementsMatch(t, tags.Tags, []string{
assert.ElementsMatch(t, tags, []string{
"2.8.0",
"2.8.0-prerelease",
"2.8.0+build",
@ -405,7 +413,7 @@ func TestGetTagsFromURLEnvironmentAuthentication(t *testing.T) {
assert.Equal(t, expectedAuthorization, authorization)
responseTags := TagsList{
responseTags := fakeTagsList{
Tags: []string{
"2.8.0",
"2.8.0-prerelease",
@ -462,7 +470,7 @@ func TestGetTagsFromURLEnvironmentAuthentication(t *testing.T) {
tags, err := client.GetTags("mychart", true)
require.NoError(t, err)
assert.ElementsMatch(t, tags.Tags, []string{
assert.ElementsMatch(t, tags, []string{
"2.8.0",
"2.8.0-prerelease",
"2.8.0+build",

View file

@ -1,13 +1,8 @@
package helm
import (
"errors"
"fmt"
"time"
log "github.com/sirupsen/logrus"
"github.com/Masterminds/semver/v3"
)
type Entry struct {
@ -15,6 +10,16 @@ type Entry struct {
Created time.Time
}
type Entries []Entry
func (es Entries) Tags() []string {
tags := make([]string, len(es))
for i, e := range es {
tags[i] = e.Version
}
return tags
}
type Index struct {
Entries map[string]Entries
}
@ -26,34 +31,3 @@ func (i *Index) GetEntries(chart string) (Entries, error) {
}
return entries, nil
}
type Entries []Entry
func (e Entries) MaxVersion(constraints *semver.Constraints) (*semver.Version, error) {
versions := semver.Collection{}
for _, entry := range e {
v, err := semver.NewVersion(entry.Version)
// Invalid semantic version ignored
if errors.Is(err, semver.ErrInvalidSemVer) {
log.Debugf("Invalid sementic version: %s", entry.Version)
continue
}
if err != nil {
return nil, fmt.Errorf("invalid constraint in index: %w", err)
}
if constraints.Check(v) {
versions = append(versions, v)
}
}
if len(versions) == 0 {
return nil, errors.New("constraint not found in index")
}
maxVersion := versions[0]
for _, v := range versions {
if v.GreaterThan(maxVersion) {
maxVersion = v
}
}
return maxVersion, nil
}

View file

@ -3,7 +3,6 @@ package helm
import (
"testing"
"github.com/Masterminds/semver/v3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -12,10 +11,10 @@ var index = Index{
Entries: map[string]Entries{
"argo-cd": {
{Version: "~0.7.3"},
{Version: "0.7.2"},
{Version: "0.7.1"},
{Version: "0.5.4"},
{Version: "0.5.3"},
{Version: "0.7.2"},
{Version: "0.5.2"},
{Version: "~0.5.2"},
{Version: "0.5.1"},
@ -30,53 +29,8 @@ func TestIndex_GetEntries(t *testing.T) {
require.EqualError(t, err, "chart 'foo' not found in index")
})
t.Run("Found", func(t *testing.T) {
entries, err := index.GetEntries("argo-cd")
ts, err := index.GetEntries("argo-cd")
require.NoError(t, err)
assert.Len(t, entries, 9)
})
}
func TestEntries_MaxVersion(t *testing.T) {
entries, _ := index.GetEntries("argo-cd")
t.Run("NotFound", func(t *testing.T) {
constraints, _ := semver.NewConstraint("0.8.1")
_, err := entries.MaxVersion(constraints)
require.EqualError(t, err, "constraint not found in index")
})
t.Run("Exact", func(t *testing.T) {
constraints, _ := semver.NewConstraint("0.5.3")
version, err := entries.MaxVersion(constraints)
require.NoError(t, err)
assert.Equal(t, semver.MustParse("0.5.3"), version)
})
t.Run("Constraint", func(t *testing.T) {
constraints, _ := semver.NewConstraint("> 0.5.3")
version, err := entries.MaxVersion(constraints)
require.NoError(t, err)
assert.Equal(t, semver.MustParse("0.7.2"), version)
})
t.Run("Constraint", func(t *testing.T) {
constraints, _ := semver.NewConstraint("> 0.0.0")
version, err := entries.MaxVersion(constraints)
require.NoError(t, err)
assert.Equal(t, semver.MustParse("0.7.2"), version)
})
t.Run("Constraint", func(t *testing.T) {
constraints, _ := semver.NewConstraint(">0.5.0,<0.7.0")
version, err := entries.MaxVersion(constraints)
require.NoError(t, err)
assert.Equal(t, semver.MustParse("0.5.4"), version)
})
t.Run("Constraint", func(t *testing.T) {
constraints, _ := semver.NewConstraint("0.7.*")
version, err := entries.MaxVersion(constraints)
require.NoError(t, err)
assert.Equal(t, semver.MustParse("0.7.2"), version)
})
t.Run("Constraint", func(t *testing.T) {
constraints, _ := semver.NewConstraint("*")
version, err := entries.MaxVersion(constraints)
require.NoError(t, err)
assert.Equal(t, semver.MustParse("0.7.2"), version)
assert.Len(t, ts, len(index.Entries["argo-cd"]))
})
}

View file

@ -100,23 +100,23 @@ func (_m *Client) GetIndex(noCache bool, maxIndexSize int64) (*helm.Index, error
}
// GetTags provides a mock function with given fields: chart, noCache
func (_m *Client) GetTags(chart string, noCache bool) (*helm.TagsList, error) {
func (_m *Client) GetTags(chart string, noCache bool) ([]string, error) {
ret := _m.Called(chart, noCache)
if len(ret) == 0 {
panic("no return value specified for GetTags")
}
var r0 *helm.TagsList
var r0 []string
var r1 error
if rf, ok := ret.Get(0).(func(string, bool) (*helm.TagsList, error)); ok {
if rf, ok := ret.Get(0).(func(string, bool) ([]string, error)); ok {
return rf(chart, noCache)
}
if rf, ok := ret.Get(0).(func(string, bool) *helm.TagsList); ok {
if rf, ok := ret.Get(0).(func(string, bool) []string); ok {
r0 = rf(chart, noCache)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*helm.TagsList)
r0 = ret.Get(0).([]string)
}
}

View file

@ -1,43 +0,0 @@
package helm
import (
"errors"
"fmt"
log "github.com/sirupsen/logrus"
"github.com/Masterminds/semver/v3"
)
type TagsList struct {
Tags []string
}
func (t TagsList) MaxVersion(constraints *semver.Constraints) (*semver.Version, error) {
versions := semver.Collection{}
for _, tag := range t.Tags {
v, err := semver.NewVersion(tag)
// Invalid semantic version ignored
if errors.Is(err, semver.ErrInvalidSemVer) {
log.Debugf("Invalid semantic version: %s", tag)
continue
}
if err != nil {
return nil, fmt.Errorf("invalid constraint in tags: %w", err)
}
if constraints.Check(v) {
versions = append(versions, v)
}
}
if len(versions) == 0 {
return nil, fmt.Errorf("constraint not found in %v tags", len(t.Tags))
}
maxVersion := versions[0]
for _, v := range versions {
if v.GreaterThan(maxVersion) {
maxVersion = v
}
}
return maxVersion, nil
}

View file

@ -1,43 +0,0 @@
package helm
import (
"testing"
"github.com/Masterminds/semver/v3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var tags = TagsList{
Tags: []string{
"~0.7.3",
"0.7.1",
"0.5.4",
"0.5.3",
"0.7.2",
"0.5.2",
"~0.5.2",
"0.5.1",
"0.5.0",
},
}
func TestTagsList_MaxVersion(t *testing.T) {
t.Run("NotFound", func(t *testing.T) {
constraints, _ := semver.NewConstraint("0.8.1")
_, err := tags.MaxVersion(constraints)
assert.EqualError(t, err, "constraint not found in 9 tags")
})
t.Run("Exact", func(t *testing.T) {
constraints, _ := semver.NewConstraint("0.5.3")
version, err := tags.MaxVersion(constraints)
require.NoError(t, err)
assert.Equal(t, semver.MustParse("0.5.3"), version)
})
t.Run("Constraint", func(t *testing.T) {
constraints, _ := semver.NewConstraint("> 0.5.3")
version, err := tags.MaxVersion(constraints)
require.NoError(t, err)
assert.Equal(t, semver.MustParse("0.7.2"), version)
})
}

64
util/versions/tags.go Normal file
View file

@ -0,0 +1,64 @@
package versions
import (
"errors"
"fmt"
log "github.com/sirupsen/logrus"
"github.com/Masterminds/semver/v3"
)
// MaxVersion takes a revision and a list of tags.
// If the revision is a version, it returns that version, even if it is not in the list of tags.
// If the revision is not a version, but is also not a constraint, it returns that revision, even if it is not in the list of tags.
// If the revision is a constraint, it iterates over the list of tags to find the "maximum" tag which satisfies that
// constraint.
// If the revision is a constraint, but no tag satisfies that constraint, then it returns an error.
func MaxVersion(revision string, tags []string) (string, error) {
if v, err := semver.NewVersion(revision); err == nil {
// If the revision is a valid version, then we know it isn't a constraint; it's just a pin.
// In which case, we should use standard tag resolution mechanisms and return the original value.
// For example, the following are considered valid versions, and therefore should match an exact tag:
// - "v1.0.0"/"1.0.0"
// - "v1.0"/"1.0"
return v.Original(), nil
}
constraints, err := semver.NewConstraint(revision)
if err != nil {
log.Debugf("Revision '%s' is not a valid semver constraint, resolving via basic string equality.", revision)
// If this is also an invalid constraint, we just iterate over available tags to determine if it is valid/invalid.
for _, tag := range tags {
if tag == revision {
return revision, nil
}
}
return "", fmt.Errorf("failed to determine semver constraint: %w", err)
}
var maxVersion *semver.Version
for _, tag := range tags {
v, err := semver.NewVersion(tag)
// Invalid semantic version ignored
if errors.Is(err, semver.ErrInvalidSemVer) {
log.Debugf("Invalid semantic version: %s", tag)
continue
}
if err != nil {
return "", fmt.Errorf("invalid semver version in tags: %w", err)
}
if constraints.Check(v) {
if maxVersion == nil || v.GreaterThan(maxVersion) {
maxVersion = v
}
}
}
if maxVersion == nil {
return "", fmt.Errorf("version matching constraint not found in %d tags", len(tags))
}
log.Debugf("Semver constraint '%s' resolved to version '%s'", constraints.String(), maxVersion.Original())
return maxVersion.Original(), nil
}

View file

@ -0,0 +1,67 @@
package versions
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var tags = []string{
"0.7.1",
"0.5.4",
"0.5.3",
"0.7.2",
"0.5.2",
"0.5.1",
"0.5.0",
"2024.03-LTS-RC19",
}
func TestTags_MaxVersion(t *testing.T) {
t.Run("Exact", func(t *testing.T) {
version, err := MaxVersion("0.5.3", tags)
require.NoError(t, err)
assert.Equal(t, "0.5.3", version)
})
t.Run("Exact nonsemver", func(t *testing.T) {
version, err := MaxVersion("2024.03-LTS-RC19", tags)
require.NoError(t, err)
assert.Equal(t, "2024.03-LTS-RC19", version)
})
t.Run("Exact missing", func(t *testing.T) {
// Passing an exact version which is not in the list of tags still returns that version
version, err := MaxVersion("99.99", []string{})
require.NoError(t, err)
assert.Equal(t, "99.99", version)
})
t.Run("Constraint", func(t *testing.T) {
version, err := MaxVersion("> 0.5.3", tags)
require.NoError(t, err)
assert.Equal(t, "0.7.2", version)
})
t.Run("Constraint", func(t *testing.T) {
version, err := MaxVersion("> 0.0.0", tags)
require.NoError(t, err)
assert.Equal(t, "0.7.2", version)
})
t.Run("Constraint", func(t *testing.T) {
version, err := MaxVersion(">0.5.0,<0.7.0", tags)
require.NoError(t, err)
assert.Equal(t, "0.5.4", version)
})
t.Run("Constraint", func(t *testing.T) {
version, err := MaxVersion("0.7.*", tags)
require.NoError(t, err)
assert.Equal(t, "0.7.2", version)
})
t.Run("Constraint", func(t *testing.T) {
version, err := MaxVersion("*", tags)
require.NoError(t, err)
assert.Equal(t, "0.7.2", version)
})
t.Run("Constraint missing", func(t *testing.T) {
_, err := MaxVersion("0.7.*", []string{})
require.Error(t, err)
})
}

View file

@ -1,4 +1,4 @@
package helm
package versions
import "github.com/Masterminds/semver/v3"

View file

@ -1,4 +1,4 @@
package helm
package versions
import (
"testing"
@ -10,5 +10,6 @@ func TestIsVersion(t *testing.T) {
assert.False(t, IsVersion("*"))
assert.False(t, IsVersion("1.*"))
assert.False(t, IsVersion("1.0.*"))
assert.True(t, IsVersion("1.0"))
assert.True(t, IsVersion("1.0.0"))
}