diff --git a/reposerver/repository/repository.go b/reposerver/repository/repository.go index cdde7d3ebd136..7de52723dd0ee 100644 --- a/reposerver/repository/repository.go +++ b/reposerver/repository/repository.go @@ -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 ( @@ -2403,40 +2403,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 @@ -2564,13 +2561,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 } diff --git a/reposerver/repository/repository_test.go b/reposerver/repository/repository_test.go index ca109b4b3317c..60b5a31f94ce7 100644 --- a/reposerver/repository/repository_test.go +++ b/reposerver/repository/repository_test.go @@ -1828,11 +1828,11 @@ func TestService_newHelmClientResolveRevision(t *testing.T) { t.Run("EmptyRevision", func(t *testing.T) { _, _, err := service.newHelmClientResolveRevision(&v1alpha1.Repository{}, "", "my-chart", true) - assert.EqualError(t, err, "invalid revision '': improper constraint: ") + 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{}, "???", "my-chart", true) - assert.EqualError(t, err, "invalid revision '???': improper constraint: ???") + assert.EqualError(t, err, "invalid revision: failed to determine semver constraint: improper constraint: ???") }) } diff --git a/util/git/client.go b/util/git/client.go index c0a261b153663..66617e305b31e 100644 --- a/util/git/client.go +++ b/util/git/client.go @@ -17,8 +17,6 @@ import ( "syscall" "time" - "github.com/Masterminds/semver/v3" - argoexec "github.com/argoproj/pkg/exec" "github.com/bmatcuk/doublestar/v4" "github.com/go-git/go-git/v5" @@ -39,6 +37,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") @@ -645,6 +644,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 @@ -659,9 +668,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 @@ -710,59 +719,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") diff --git a/util/git/client_test.go b/util/git/client_test.go index 74b077ee2b0d3..c9514259e42f3 100644 --- a/util/git/client_test.go +++ b/util/git/client_test.go @@ -315,7 +315,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"], }} { diff --git a/util/helm/client.go b/util/helm/client.go index ca1ae95b0f15e..83b9aa3323822 100644 --- a/util/helm/client.go +++ b/util/helm/client.go @@ -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, OCINotEnabledErr } @@ -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 } diff --git a/util/helm/client_test.go b/util/helm/client_test.go index 132e6d0a92868..690a3dead89e5 100644 --- a/util/helm/client_test.go +++ b/util/helm/client_test.go @@ -26,6 +26,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 @@ -171,19 +175,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("; 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) @@ -194,7 +202,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", @@ -230,7 +238,7 @@ func TestGetTagsFromURLPrivateRepoAuthentication(t *testing.T) { assert.Equal(t, expectedAuthorization, authorization) - responseTags := TagsList{ + responseTags := fakeTagsList{ Tags: []string{ "2.8.0", "2.8.0-prerelease", @@ -282,7 +290,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", @@ -327,7 +335,7 @@ func TestGetTagsFromURLPrivateRepoWithAzureWorkloadIdentityAuthentication(t *tes assert.Equal(t, expectedAuthorization, authorization) - responseTags := TagsList{ + responseTags := fakeTagsList{ Tags: []string{ "2.8.0", "2.8.0-prerelease", @@ -380,7 +388,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", @@ -406,7 +414,7 @@ func TestGetTagsFromURLEnvironmentAuthentication(t *testing.T) { assert.Equal(t, expectedAuthorization, authorization) - responseTags := TagsList{ + responseTags := fakeTagsList{ Tags: []string{ "2.8.0", "2.8.0-prerelease", @@ -463,7 +471,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", diff --git a/util/helm/index.go b/util/helm/index.go index 43e89653a8207..0c054970a4384 100644 --- a/util/helm/index.go +++ b/util/helm/index.go @@ -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 -} diff --git a/util/helm/index_test.go b/util/helm/index_test.go index c195710ff33cd..b7f3a55b63ce3 100644 --- a/util/helm/index_test.go +++ b/util/helm/index_test.go @@ -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") - 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) + ts, err := index.GetEntries("argo-cd") require.NoError(t, err) - assert.Equal(t, semver.MustParse("0.7.2"), version) + assert.Len(t, ts, len(index.Entries["argo-cd"])) }) } diff --git a/util/helm/mocks/Client.go b/util/helm/mocks/Client.go index 5ef2332392428..0133a048ccccb 100644 --- a/util/helm/mocks/Client.go +++ b/util/helm/mocks/Client.go @@ -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) } } diff --git a/util/helm/tags.go b/util/helm/tags.go deleted file mode 100644 index 6cfa745fb33fc..0000000000000 --- a/util/helm/tags.go +++ /dev/null @@ -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 -} diff --git a/util/helm/tags_test.go b/util/helm/tags_test.go deleted file mode 100644 index 6eb7cf902e63c..0000000000000 --- a/util/helm/tags_test.go +++ /dev/null @@ -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) - }) -} diff --git a/util/versions/tags.go b/util/versions/tags.go new file mode 100644 index 0000000000000..4bb2c2f152b5b --- /dev/null +++ b/util/versions/tags.go @@ -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 +} diff --git a/util/versions/tags_test.go b/util/versions/tags_test.go new file mode 100644 index 0000000000000..80f164c754748 --- /dev/null +++ b/util/versions/tags_test.go @@ -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) + }) +} diff --git a/util/helm/version.go b/util/versions/version.go similarity index 88% rename from util/helm/version.go rename to util/versions/version.go index 89746843525c7..10c7378ed2cda 100644 --- a/util/helm/version.go +++ b/util/versions/version.go @@ -1,4 +1,4 @@ -package helm +package versions import "github.com/Masterminds/semver/v3" diff --git a/util/helm/version_test.go b/util/versions/version_test.go similarity index 82% rename from util/helm/version_test.go rename to util/versions/version_test.go index 7b389caf6fb23..617eb81b0745d 100644 --- a/util/helm/version_test.go +++ b/util/versions/version_test.go @@ -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")) }