diff --git a/reposerver/repository/repository.go b/reposerver/repository/repository.go index 8b31f660f78c7..3429a00f20134 100644 --- a/reposerver/repository/repository.go +++ b/reposerver/repository/repository.go @@ -1956,14 +1956,27 @@ 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, helm.WithIndexCache(s.cache), helm.WithChartPaths(s.chartPaths)) - // OCI helm registers don't support semver ranges. Assuming that given revision is exact version - if helm.IsVersion(revision) || enableOCI { + if helm.IsVersion(revision) { return helmClient, revision, nil } constraints, err := semver.NewConstraint(revision) if err != nil { return nil, "", fmt.Errorf("invalid revision '%s': %v", revision, err) } + + if enableOCI { + tags, err := helmClient.GetTags(chart, noRevisionCache) + if err != nil { + return nil, "", fmt.Errorf("unable to get tags: %v", err) + } + + version, err := tags.MaxVersion(constraints) + if err != nil { + return nil, "", fmt.Errorf("no version for constraints: %v", err) + } + return helmClient, version.String(), nil + } + index, err := helmClient.GetIndex(noRevisionCache) if err != nil { return nil, "", err @@ -2099,30 +2112,14 @@ func (s *Service) ResolveRevision(ctx context.Context, q *apiclient.ResolveRevis ambiguousRevision := q.AmbiguousRevision var revision string if app.Spec.Source.IsHelm() { + _, revision, err := s.newHelmClientResolveRevision(repo, ambiguousRevision, app.Spec.Source.Chart, true) - if helm.IsVersion(ambiguousRevision) { - return &apiclient.ResolveRevisionResponse{Revision: ambiguousRevision, AmbiguousRevision: ambiguousRevision}, nil - } - client := helm.NewClient(repo.Repo, repo.GetHelmCreds(), repo.EnableOCI || app.Spec.Source.IsHelmOci(), repo.Proxy, helm.WithChartPaths(s.chartPaths)) - index, err := client.GetIndex(false) - if err != nil { - return &apiclient.ResolveRevisionResponse{Revision: "", AmbiguousRevision: ""}, err - } - entries, err := index.GetEntries(app.Spec.Source.Chart) - if err != nil { - return &apiclient.ResolveRevisionResponse{Revision: "", AmbiguousRevision: ""}, err - } - constraints, err := semver.NewConstraint(ambiguousRevision) - if err != nil { - return &apiclient.ResolveRevisionResponse{Revision: "", AmbiguousRevision: ""}, err - } - version, err := entries.MaxVersion(constraints) if err != nil { return &apiclient.ResolveRevisionResponse{Revision: "", AmbiguousRevision: ""}, err } return &apiclient.ResolveRevisionResponse{ - Revision: version.String(), - AmbiguousRevision: fmt.Sprintf("%v (%v)", ambiguousRevision, version.String()), + Revision: revision, + AmbiguousRevision: fmt.Sprintf("%v (%v)", ambiguousRevision, revision), }, nil } else { gitClient, err := git.NewClient(repo.Repo, repo.GetGitCreds(s.gitCredsStore), repo.IsInsecure(), repo.IsLFSEnabled(), repo.Proxy) diff --git a/ui/src/app/applications/components/applications-list/applications-list.tsx b/ui/src/app/applications/components/applications-list/applications-list.tsx index d3f76ffd7211b..eff091e4c61ea 100644 --- a/ui/src/app/applications/components/applications-list/applications-list.tsx +++ b/ui/src/app/applications/components/applications-list/applications-list.tsx @@ -40,6 +40,7 @@ const APP_FIELDS = [ 'spec', 'operation.sync', 'status.sync.status', + 'status.sync.revision', 'status.health', 'status.operationState.phase', 'status.operationState.operation.sync', diff --git a/ui/src/app/applications/components/applications-list/applications-tiles.tsx b/ui/src/app/applications/components/applications-list/applications-tiles.tsx index d8c0c081a6c12..4658900d0718d 100644 --- a/ui/src/app/applications/components/applications-list/applications-tiles.tsx +++ b/ui/src/app/applications/components/applications-list/applications-tiles.tsx @@ -224,7 +224,7 @@ export const ApplicationTiles = ({applications, syncApplication, refreshApplicat
Target Revision:
-
{app.spec.source.targetRevision}
+
{app.spec.source.targetRevision || 'HEAD'}
{app.spec.source.path && (
diff --git a/util/helm/client.go b/util/helm/client.go index e0075e24329a9..7ef14e2cba36c 100644 --- a/util/helm/client.go +++ b/util/helm/client.go @@ -21,11 +21,13 @@ import ( log "github.com/sirupsen/logrus" "gopkg.in/yaml.v2" + "github.com/argoproj/argo-cd/v2/common" "github.com/argoproj/argo-cd/v2/util/cache" executil "github.com/argoproj/argo-cd/v2/util/exec" argoio "github.com/argoproj/argo-cd/v2/util/io" "github.com/argoproj/argo-cd/v2/util/io/files" "github.com/argoproj/argo-cd/v2/util/proxy" + "github.com/argoproj/argo-cd/v2/util/text" ) var ( @@ -51,6 +53,7 @@ type Client interface { CleanChartCache(chart string, version string) error ExtractChart(chart string, version string, passCredentials bool) (string, argoio.Closer, error) GetIndex(noCache bool) (*Index, error) + GetTags(chart string, noCache bool) (*TagsList, error) TestHelmOCI() (bool, error) } @@ -381,3 +384,148 @@ func getIndexURL(rawURL string) (string, error) { repoURL.RawPath = path.Join(repoURL.RawPath, indexFile) return repoURL.String(), nil } + +func getTagsListURL(rawURL string, chart string) (string, error) { + repoURL, err := url.Parse(strings.Trim(rawURL, "/")) + if err != nil { + return "", fmt.Errorf("unable to parse repo url: %v", err) + } + repoURL.Scheme = "https" + tagsList := strings.Join([]string{"v2", url.PathEscape(chart), "tags/list"}, "/") + repoURL.Path = strings.Join([]string{repoURL.Path, tagsList}, "/") + repoURL.RawPath = strings.Join([]string{repoURL.RawPath, tagsList}, "/") + return repoURL.String(), nil +} + +func (c *nativeHelmChart) getTags(chart string) ([]byte, error) { + nextURL, err := getTagsListURL(c.repoURL, chart) + if err != nil { + return nil, fmt.Errorf("failed to get tag list url: %v", err) + } + + allTags := &TagsList{} + var data []byte + for nextURL != "" { + log.Debugf("fetching %s tags from %s", chart, text.Trunc(nextURL, 100)) + data, nextURL, err = c.getTagsFromUrl(nextURL) + if err != nil { + return nil, fmt.Errorf("failed tags part: %v", err) + } + + tags := &TagsList{} + err := json.Unmarshal(data, tags) + if err != nil { + return nil, fmt.Errorf("unable to decode json: %v", err) + } + allTags.Tags = append(allTags.Tags, tags.Tags...) + } + data, err = json.Marshal(allTags) + if err != nil { + return nil, fmt.Errorf("failed to marshal tag json: %w", err) + } + return data, nil +} + +func getNextUrl(linkHeader string) string { + nextUrl := "" + if linkHeader != "" { + // drop < >; ref= from the Link header, see: https://docs.docker.com/registry/spec/api/#pagination + nextUrl = strings.Split(linkHeader, ";")[0][1:] + nextUrl = nextUrl[:len(nextUrl)-1] + } + return nextUrl +} + +func (c *nativeHelmChart) getTagsFromUrl(tagsURL string) ([]byte, string, error) { + req, err := http.NewRequest("GET", tagsURL, nil) + if err != nil { + return nil, "", fmt.Errorf("failed create request: %v", err) + } + req.Header.Add("Accept", `application/json`) + if c.creds.Username != "" || c.creds.Password != "" { + // only basic supported + req.SetBasicAuth(c.creds.Username, c.creds.Password) + } + + tlsConf, err := newTLSConfig(c.creds) + if err != nil { + return nil, "", fmt.Errorf("failed setup tlsConfig: %v", err) + } + + tr := &http.Transport{ + Proxy: proxy.GetCallback(c.proxy), + TLSClientConfig: tlsConf, + } + client := http.Client{Transport: tr} + resp, err := client.Do(req) + if err != nil { + return nil, "", fmt.Errorf("request failed: %v", err) + } + defer func() { + if err = resp.Body.Close(); err != nil { + log.WithFields(log.Fields{ + common.SecurityField: common.SecurityMedium, + common.SecurityCWEField: 775, + }).Errorf("error closing response %q: %v", text.Trunc(tagsURL, 100), err) + } + }() + + if resp.StatusCode != 200 { + data, err := io.ReadAll(resp.Body) + var responseExcerpt string + if err != nil { + responseExcerpt = fmt.Sprintf("err: %v", err) + } else { + responseExcerpt = text.Trunc(string(data), 100) + } + return nil, "", fmt.Errorf("invalid response: %s %s", resp.Status, responseExcerpt) + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", fmt.Errorf("failed to read body: %v", err) + } + nextUrl := getNextUrl(resp.Header.Get("Link")) + return data, nextUrl, nil +} + +func (c *nativeHelmChart) GetTags(chart string, noCache bool) (*TagsList, error) { + tagsURL, err := getTagsListURL(c.repoURL, chart) + if err != nil { + return nil, fmt.Errorf("invalid tags url: %v", err) + } + indexLock.Lock(tagsURL) + defer indexLock.Unlock(tagsURL) + + var data []byte + if !noCache && c.indexCache != nil { + if err := c.indexCache.GetHelmIndex(tagsURL, &data); err != nil && err != cache.ErrCacheMiss { + log.Warnf("Failed to load index cache for repo: %s: %v", tagsURL, err) + } + } + + if len(data) == 0 { + start := time.Now() + var err error + data, err = c.getTags(chart) + if err != nil { + return nil, fmt.Errorf("failed to get tags: %v", err) + } + log.WithFields( + log.Fields{"seconds": time.Since(start).Seconds(), "chart": chart, "repo": c.repoURL}, + ).Info("took to get tags") + + if c.indexCache != nil { + if err := c.indexCache.SetHelmIndex(tagsURL, data); err != nil { + log.Warnf("Failed to store tags list cache for repo: %s: %v", tagsURL, err) + } + } + } + + tags := &TagsList{} + err = json.Unmarshal(data, tags) + if err != nil { + return nil, fmt.Errorf("failed to decode tags: %v", err) + } + + return tags, nil +} diff --git a/util/helm/client_test.go b/util/helm/client_test.go index 04bcd4a9015ce..8f12eaf403c9e 100644 --- a/util/helm/client_test.go +++ b/util/helm/client_test.go @@ -2,10 +2,15 @@ package helm import ( "bytes" + "encoding/json" "fmt" "os" + "strings" "testing" + "net/http" + "net/http/httptest" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gopkg.in/yaml.v2" @@ -145,3 +150,51 @@ func TestGetIndexURL(t *testing.T) { assert.Error(t, err) }) } + +func TestGetTagsFromUrl(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + responseTags := TagsList{} + 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"} + } else { + responseTags.Tags = []string{"second"} + } + w.WriteHeader(http.StatusOK) + err := json.NewEncoder(w).Encode(responseTags) + if err != nil { + t.Fatal(err) + } + })) + + client := NewClient(server.URL, Creds{InsecureSkipVerify: true}, true, "") + + tags, err := client.GetTags("mychart", true) + assert.NoError(t, err) + assert.Equal(t, tags.Tags[0], "first") + assert.Equal(t, tags.Tags[1], "second") +} + +func Test_getNextUrl(t *testing.T) { + nextUrl := getNextUrl("") + assert.Equal(t, nextUrl, "") + + nextUrl = getNextUrl("; rel=next") + assert.Equal(t, nextUrl, "https://my.repo.com/v2/chart/tags/list?token=123") +} + +func Test_getTagsListURL(t *testing.T) { + tagsListURL, err := getTagsListURL("account.dkr.ecr.eu-central-1.amazonaws.com", "dss") + assert.Nil(t, err) + assert.Equal(t, tagsListURL, "https://account.dkr.ecr.eu-central-1.amazonaws.com/v2/dss/tags/list") + + tagsListURL, err = getTagsListURL("http://account.dkr.ecr.eu-central-1.amazonaws.com", "dss") + assert.Nil(t, err) + assert.Equal(t, tagsListURL, "https://account.dkr.ecr.eu-central-1.amazonaws.com/v2/dss/tags/list") + + // with trailing / + tagsListURL, err = getTagsListURL("https://account.dkr.ecr.eu-central-1.amazonaws.com/", "dss") + assert.Nil(t, err) + assert.Equal(t, tagsListURL, "https://account.dkr.ecr.eu-central-1.amazonaws.com/v2/dss/tags/list") +} diff --git a/util/helm/mocks/Client.go b/util/helm/mocks/Client.go index f39b771e194d7..31b5a11e25110 100644 --- a/util/helm/mocks/Client.go +++ b/util/helm/mocks/Client.go @@ -81,6 +81,29 @@ func (_m *Client) GetIndex(noCache bool) (*helm.Index, error) { return r0, r1 } +// GetTags provides a mock function with given fields: noCache +func (_m *Client) GetTags(chart string, noCache bool) (*helm.TagsList, error) { + ret := _m.Called(chart, noCache) + + var r0 *helm.TagsList + if rf, ok := ret.Get(0).(func(string, bool) *helm.TagsList); ok { + r0 = rf(chart, noCache) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*helm.TagsList) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, bool) error); ok { + r1 = rf(chart, noCache) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // TestHelmOCI provides a mock function with given fields: func (_m *Client) TestHelmOCI() (bool, error) { ret := _m.Called() diff --git a/util/helm/tags.go b/util/helm/tags.go new file mode 100644 index 0000000000000..6c0a9e589f5da --- /dev/null +++ b/util/helm/tags.go @@ -0,0 +1,42 @@ +package helm + +import ( + "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 err == semver.ErrInvalidSemVer { + log.Debugf("Invalid semantic version: %s", tag) + continue + } + if err != nil { + return nil, fmt.Errorf("invalid constraint in tags: %v", 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 new file mode 100644 index 0000000000000..d2eb5c17d08fd --- /dev/null +++ b/util/helm/tags_test.go @@ -0,0 +1,44 @@ +package helm + +import ( + "testing" + + "github.com/Masterminds/semver/v3" + "github.com/stretchr/testify/assert" +) + +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) + assert.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) + assert.NoError(t, err) + assert.Equal(t, semver.MustParse("0.7.2"), version) + }) +}