Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add wildcard support in OCI Helm Repositories targetRevision (#6686) #10641

Merged
merged 42 commits into from
Nov 25, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
cfcf8f9
Add wildcard support in OCI Helm Repositories
alexef Sep 19, 2022
b9696f5
Fix unittest missing mock
alexef Sep 19, 2022
ea62a2f
Fix release resolution also in Manual Sync dialog
alexef Sep 27, 2022
10ab998
Show target revision in application list. Tiles and Table updated
alexef Sep 27, 2022
d10521e
Merge branch 'master' into oci-versions
alexef Sep 27, 2022
58a6c47
Merge branch 'master' into oci-versions
alexef Sep 30, 2022
1a6a2ad
Merge branch 'master' into oci-versions
alexef Oct 3, 2022
134a70c
Merge branch 'master' into oci-versions
alexef Oct 4, 2022
60c05e4
Merge branch 'master' into oci-versions
alexef Oct 5, 2022
32eb41b
Merge branch 'master' into oci-versions
alexef Oct 5, 2022
7e0ca65
Follow Link rel=next in tags response for tag list completion (signed)
alexef Oct 6, 2022
6af2d31
Merge branch 'master' into oci-versions
alexef Oct 6, 2022
8809d44
Wrap errors into fmt.Errorf according to PR review
alexef Oct 7, 2022
c577b27
Merge branch 'master' into oci-versions
alexef Oct 7, 2022
edad159
Merge branch 'master' into oci-versions
alexef Oct 8, 2022
83105a1
Address PR comments, add test for tags MaxVersion and pagination
alexef Oct 10, 2022
4844344
Merge branch 'master' into oci-versions
alexef Oct 10, 2022
6876031
Merge branch 'master' into oci-versions
alexef Oct 11, 2022
9af8464
Merge branch 'master' into oci-versions
alexef Oct 16, 2022
5774f54
Merge branch 'master' into oci-versions
alexef Oct 19, 2022
1b0da93
Merge branch 'master' into oci-versions
alexef Oct 20, 2022
c05ac90
Merge branch 'master' into oci-versions
alexef Oct 21, 2022
2f2d85e
Merge branch 'master' into oci-versions
alexef Oct 23, 2022
30d07ce
Merge branch 'master' into oci-versions
alexef Oct 28, 2022
ce878c5
Merge branch 'master' into oci-versions
alexef Oct 30, 2022
62f0d62
Merge branch 'master' into oci-versions
alexef Nov 1, 2022
21eace5
Merge branch 'master' into oci-versions
alexef Nov 7, 2022
9524a83
Merge branch 'master' into oci-versions
alexef Nov 9, 2022
556dd54
Apply suggestions from code review
alexef Nov 9, 2022
12bd121
more feedback from pr
alexef Nov 9, 2022
2992701
Revert url.JoinPath change - only available in 1.19
alexef Nov 9, 2022
e293b3a
use strings.Join. add unittest
alexef Nov 10, 2022
a281a8b
Merge branch 'master' into oci-versions
alexef Nov 10, 2022
dbd1f03
Merge branch 'master' into oci-versions
alexef Nov 15, 2022
b9220c9
Merge branch 'master' into oci-versions
alexef Nov 16, 2022
7fd19ea
Safe access to app.status.sync object
alexef Nov 16, 2022
c1d1ab7
Merge branch 'master' into oci-versions
alexef Nov 17, 2022
59bff60
Merge branch 'master' into oci-versions
alexef Nov 21, 2022
a219b53
Merge branch 'master' into oci-versions
alexef Nov 25, 2022
494d30b
Remove status.revision from UI. It doesn't bring much value and it do…
alexef Nov 25, 2022
89dcaa5
Update util/helm/client.go
crenshaw-dev Nov 25, 2022
22f023b
Update util/helm/client.go
crenshaw-dev Nov 25, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
39 changes: 18 additions & 21 deletions reposerver/repository/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ export const ApplicationTiles = ({applications, syncApplication, refreshApplicat
<div className='columns small-3' title='Target Revision:'>
Target Revision:
</div>
<div className='columns small-9'>{app.spec.source.targetRevision}</div>
<div className='columns small-9'>{app.spec.source.targetRevision || 'HEAD'}</div>
</div>
{app.spec.source.path && (
<div className='row'>
Expand Down
148 changes: 148 additions & 0 deletions util/helm/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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)
}

Expand Down Expand Up @@ -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) {
crenshaw-dev marked this conversation as resolved.
Show resolved Hide resolved
req, err := http.NewRequest("GET", tagsURL, nil)
crenshaw-dev marked this conversation as resolved.
Show resolved Hide resolved
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)
crenshaw-dev marked this conversation as resolved.
Show resolved Hide resolved
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
}
53 changes: 53 additions & 0 deletions util/helm/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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("<https://%s%s?token=next-token>; 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("<https://my.repo.com/v2/chart/tags/list?token=123>; 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")
}
23 changes: 23 additions & 0 deletions util/helm/mocks/Client.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

42 changes: 42 additions & 0 deletions util/helm/tags.go
Original file line number Diff line number Diff line change
@@ -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) {
crenshaw-dev marked this conversation as resolved.
Show resolved Hide resolved
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
}