github.com/stefanmcshane/helm@v0.0.0-20221213002717-88a4a2c6e77d/pkg/downloader/chart_downloader.go (about)

     1  /*
     2  Copyright The Helm Authors.
     3  Licensed under the Apache License, Version 2.0 (the "License");
     4  you may not use this file except in compliance with the License.
     5  You may obtain a copy of the License at
     6  
     7  http://www.apache.org/licenses/LICENSE-2.0
     8  
     9  Unless required by applicable law or agreed to in writing, software
    10  distributed under the License is distributed on an "AS IS" BASIS,
    11  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  See the License for the specific language governing permissions and
    13  limitations under the License.
    14  */
    15  
    16  package downloader
    17  
    18  import (
    19  	"fmt"
    20  	"io"
    21  	"net/url"
    22  	"os"
    23  	"path/filepath"
    24  	"strings"
    25  
    26  	"github.com/Masterminds/semver/v3"
    27  	"github.com/pkg/errors"
    28  
    29  	"github.com/stefanmcshane/helm/internal/fileutil"
    30  	"github.com/stefanmcshane/helm/internal/urlutil"
    31  	"github.com/stefanmcshane/helm/pkg/getter"
    32  	"github.com/stefanmcshane/helm/pkg/helmpath"
    33  	"github.com/stefanmcshane/helm/pkg/provenance"
    34  	"github.com/stefanmcshane/helm/pkg/registry"
    35  	"github.com/stefanmcshane/helm/pkg/repo"
    36  )
    37  
    38  // VerificationStrategy describes a strategy for determining whether to verify a chart.
    39  type VerificationStrategy int
    40  
    41  const (
    42  	// VerifyNever will skip all verification of a chart.
    43  	VerifyNever VerificationStrategy = iota
    44  	// VerifyIfPossible will attempt a verification, it will not error if verification
    45  	// data is missing. But it will not stop processing if verification fails.
    46  	VerifyIfPossible
    47  	// VerifyAlways will always attempt a verification, and will fail if the
    48  	// verification fails.
    49  	VerifyAlways
    50  	// VerifyLater will fetch verification data, but not do any verification.
    51  	// This is to accommodate the case where another step of the process will
    52  	// perform verification.
    53  	VerifyLater
    54  )
    55  
    56  // ErrNoOwnerRepo indicates that a given chart URL can't be found in any repos.
    57  var ErrNoOwnerRepo = errors.New("could not find a repo containing the given URL")
    58  
    59  // ChartDownloader handles downloading a chart.
    60  //
    61  // It is capable of performing verifications on charts as well.
    62  type ChartDownloader struct {
    63  	// Out is the location to write warning and info messages.
    64  	Out io.Writer
    65  	// Verify indicates what verification strategy to use.
    66  	Verify VerificationStrategy
    67  	// Keyring is the keyring file used for verification.
    68  	Keyring string
    69  	// Getter collection for the operation
    70  	Getters getter.Providers
    71  	// Options provide parameters to be passed along to the Getter being initialized.
    72  	Options          []getter.Option
    73  	RegistryClient   *registry.Client
    74  	RepositoryConfig string
    75  	RepositoryCache  string
    76  }
    77  
    78  // DownloadTo retrieves a chart. Depending on the settings, it may also download a provenance file.
    79  //
    80  // If Verify is set to VerifyNever, the verification will be nil.
    81  // If Verify is set to VerifyIfPossible, this will return a verification (or nil on failure), and print a warning on failure.
    82  // If Verify is set to VerifyAlways, this will return a verification or an error if the verification fails.
    83  // If Verify is set to VerifyLater, this will download the prov file (if it exists), but not verify it.
    84  //
    85  // For VerifyNever and VerifyIfPossible, the Verification may be empty.
    86  //
    87  // Returns a string path to the location where the file was downloaded and a verification
    88  // (if provenance was verified), or an error if something bad happened.
    89  func (c *ChartDownloader) DownloadTo(ref, version, dest string) (string, *provenance.Verification, error) {
    90  	u, err := c.ResolveChartVersion(ref, version)
    91  	if err != nil {
    92  		return "", nil, err
    93  	}
    94  
    95  	g, err := c.Getters.ByScheme(u.Scheme)
    96  	if err != nil {
    97  		return "", nil, err
    98  	}
    99  
   100  	data, err := g.Get(u.String(), c.Options...)
   101  	if err != nil {
   102  		return "", nil, err
   103  	}
   104  
   105  	name := filepath.Base(u.Path)
   106  	if u.Scheme == registry.OCIScheme {
   107  		idx := strings.LastIndexByte(name, ':')
   108  		name = fmt.Sprintf("%s-%s.tgz", name[:idx], name[idx+1:])
   109  	}
   110  
   111  	destfile := filepath.Join(dest, name)
   112  	if err := fileutil.AtomicWriteFile(destfile, data, 0644); err != nil {
   113  		return destfile, nil, err
   114  	}
   115  
   116  	// If provenance is requested, verify it.
   117  	ver := &provenance.Verification{}
   118  	if c.Verify > VerifyNever {
   119  		body, err := g.Get(u.String() + ".prov")
   120  		if err != nil {
   121  			if c.Verify == VerifyAlways {
   122  				return destfile, ver, errors.Errorf("failed to fetch provenance %q", u.String()+".prov")
   123  			}
   124  			fmt.Fprintf(c.Out, "WARNING: Verification not found for %s: %s\n", ref, err)
   125  			return destfile, ver, nil
   126  		}
   127  		provfile := destfile + ".prov"
   128  		if err := fileutil.AtomicWriteFile(provfile, body, 0644); err != nil {
   129  			return destfile, nil, err
   130  		}
   131  
   132  		if c.Verify != VerifyLater {
   133  			ver, err = VerifyChart(destfile, c.Keyring)
   134  			if err != nil {
   135  				// Fail always in this case, since it means the verification step
   136  				// failed.
   137  				return destfile, ver, err
   138  			}
   139  		}
   140  	}
   141  	return destfile, ver, nil
   142  }
   143  
   144  func (c *ChartDownloader) getOciURI(ref, version string, u *url.URL) (*url.URL, error) {
   145  	var tag string
   146  	var err error
   147  
   148  	// Evaluate whether an explicit version has been provided. Otherwise, determine version to use
   149  	_, errSemVer := semver.NewVersion(version)
   150  	if errSemVer == nil {
   151  		tag = version
   152  	} else {
   153  		// Retrieve list of repository tags
   154  		tags, err := c.RegistryClient.Tags(strings.TrimPrefix(ref, fmt.Sprintf("%s://", registry.OCIScheme)))
   155  		if err != nil {
   156  			return nil, err
   157  		}
   158  		if len(tags) == 0 {
   159  			return nil, errors.Errorf("Unable to locate any tags in provided repository: %s", ref)
   160  		}
   161  
   162  		// Determine if version provided
   163  		// If empty, try to get the highest available tag
   164  		// If exact version, try to find it
   165  		// If semver constraint string, try to find a match
   166  		tag, err = registry.GetTagMatchingVersionOrConstraint(tags, version)
   167  		if err != nil {
   168  			return nil, err
   169  		}
   170  	}
   171  
   172  	u.Path = fmt.Sprintf("%s:%s", u.Path, tag)
   173  
   174  	return u, err
   175  }
   176  
   177  // ResolveChartVersion resolves a chart reference to a URL.
   178  //
   179  // It returns the URL and sets the ChartDownloader's Options that can fetch
   180  // the URL using the appropriate Getter.
   181  //
   182  // A reference may be an HTTP URL, an oci reference URL, a 'reponame/chartname'
   183  // reference, or a local path.
   184  //
   185  // A version is a SemVer string (1.2.3-beta.1+f334a6789).
   186  //
   187  //	- For fully qualified URLs, the version will be ignored (since URLs aren't versioned)
   188  //	- For a chart reference
   189  //		* If version is non-empty, this will return the URL for that version
   190  //		* If version is empty, this will return the URL for the latest version
   191  //		* If no version can be found, an error is returned
   192  func (c *ChartDownloader) ResolveChartVersion(ref, version string) (*url.URL, error) {
   193  	u, err := url.Parse(ref)
   194  	if err != nil {
   195  		return nil, errors.Errorf("invalid chart URL format: %s", ref)
   196  	}
   197  
   198  	if registry.IsOCI(u.String()) {
   199  		return c.getOciURI(ref, version, u)
   200  	}
   201  
   202  	rf, err := loadRepoConfig(c.RepositoryConfig)
   203  	if err != nil {
   204  		return u, err
   205  	}
   206  
   207  	if u.IsAbs() && len(u.Host) > 0 && len(u.Path) > 0 {
   208  		// In this case, we have to find the parent repo that contains this chart
   209  		// URL. And this is an unfortunate problem, as it requires actually going
   210  		// through each repo cache file and finding a matching URL. But basically
   211  		// we want to find the repo in case we have special SSL cert config
   212  		// for that repo.
   213  
   214  		rc, err := c.scanReposForURL(ref, rf)
   215  		if err != nil {
   216  			// If there is no special config, return the default HTTP client and
   217  			// swallow the error.
   218  			if err == ErrNoOwnerRepo {
   219  				// Make sure to add the ref URL as the URL for the getter
   220  				c.Options = append(c.Options, getter.WithURL(ref))
   221  				return u, nil
   222  			}
   223  			return u, err
   224  		}
   225  
   226  		// If we get here, we don't need to go through the next phase of looking
   227  		// up the URL. We have it already. So we just set the parameters and return.
   228  		c.Options = append(
   229  			c.Options,
   230  			getter.WithURL(rc.URL),
   231  		)
   232  		if rc.CertFile != "" || rc.KeyFile != "" || rc.CAFile != "" {
   233  			c.Options = append(c.Options, getter.WithTLSClientConfig(rc.CertFile, rc.KeyFile, rc.CAFile))
   234  		}
   235  		if rc.Username != "" && rc.Password != "" {
   236  			c.Options = append(
   237  				c.Options,
   238  				getter.WithBasicAuth(rc.Username, rc.Password),
   239  				getter.WithPassCredentialsAll(rc.PassCredentialsAll),
   240  			)
   241  		}
   242  		return u, nil
   243  	}
   244  
   245  	// See if it's of the form: repo/path_to_chart
   246  	p := strings.SplitN(u.Path, "/", 2)
   247  	if len(p) < 2 {
   248  		return u, errors.Errorf("non-absolute URLs should be in form of repo_name/path_to_chart, got: %s", u)
   249  	}
   250  
   251  	repoName := p[0]
   252  	chartName := p[1]
   253  	rc, err := pickChartRepositoryConfigByName(repoName, rf.Repositories)
   254  
   255  	if err != nil {
   256  		return u, err
   257  	}
   258  
   259  	// Now that we have the chart repository information we can use that URL
   260  	// to set the URL for the getter.
   261  	c.Options = append(c.Options, getter.WithURL(rc.URL))
   262  
   263  	r, err := repo.NewChartRepository(rc, c.Getters)
   264  	if err != nil {
   265  		return u, err
   266  	}
   267  
   268  	if r != nil && r.Config != nil {
   269  		if r.Config.CertFile != "" || r.Config.KeyFile != "" || r.Config.CAFile != "" {
   270  			c.Options = append(c.Options, getter.WithTLSClientConfig(r.Config.CertFile, r.Config.KeyFile, r.Config.CAFile))
   271  		}
   272  		if r.Config.Username != "" && r.Config.Password != "" {
   273  			c.Options = append(c.Options,
   274  				getter.WithBasicAuth(r.Config.Username, r.Config.Password),
   275  				getter.WithPassCredentialsAll(r.Config.PassCredentialsAll),
   276  			)
   277  		}
   278  	}
   279  
   280  	// Next, we need to load the index, and actually look up the chart.
   281  	idxFile := filepath.Join(c.RepositoryCache, helmpath.CacheIndexFile(r.Config.Name))
   282  	i, err := repo.LoadIndexFile(idxFile)
   283  	if err != nil {
   284  		return u, errors.Wrap(err, "no cached repo found. (try 'helm repo update')")
   285  	}
   286  
   287  	cv, err := i.Get(chartName, version)
   288  	if err != nil {
   289  		return u, errors.Wrapf(err, "chart %q matching %s not found in %s index. (try 'helm repo update')", chartName, version, r.Config.Name)
   290  	}
   291  
   292  	if len(cv.URLs) == 0 {
   293  		return u, errors.Errorf("chart %q has no downloadable URLs", ref)
   294  	}
   295  
   296  	// TODO: Seems that picking first URL is not fully correct
   297  	u, err = url.Parse(cv.URLs[0])
   298  	if err != nil {
   299  		return u, errors.Errorf("invalid chart URL format: %s", ref)
   300  	}
   301  
   302  	// If the URL is relative (no scheme), prepend the chart repo's base URL
   303  	if !u.IsAbs() {
   304  		repoURL, err := url.Parse(rc.URL)
   305  		if err != nil {
   306  			return repoURL, err
   307  		}
   308  		q := repoURL.Query()
   309  		// We need a trailing slash for ResolveReference to work, but make sure there isn't already one
   310  		repoURL.RawPath = strings.TrimSuffix(repoURL.RawPath, "/") + "/"
   311  		repoURL.Path = strings.TrimSuffix(repoURL.Path, "/") + "/"
   312  		u = repoURL.ResolveReference(u)
   313  		u.RawQuery = q.Encode()
   314  		// TODO add user-agent
   315  		if _, err := getter.NewHTTPGetter(getter.WithURL(rc.URL)); err != nil {
   316  			return repoURL, err
   317  		}
   318  		return u, err
   319  	}
   320  
   321  	// TODO add user-agent
   322  	return u, nil
   323  }
   324  
   325  // VerifyChart takes a path to a chart archive and a keyring, and verifies the chart.
   326  //
   327  // It assumes that a chart archive file is accompanied by a provenance file whose
   328  // name is the archive file name plus the ".prov" extension.
   329  func VerifyChart(path, keyring string) (*provenance.Verification, error) {
   330  	// For now, error out if it's not a tar file.
   331  	switch fi, err := os.Stat(path); {
   332  	case err != nil:
   333  		return nil, err
   334  	case fi.IsDir():
   335  		return nil, errors.New("unpacked charts cannot be verified")
   336  	case !isTar(path):
   337  		return nil, errors.New("chart must be a tgz file")
   338  	}
   339  
   340  	provfile := path + ".prov"
   341  	if _, err := os.Stat(provfile); err != nil {
   342  		return nil, errors.Wrapf(err, "could not load provenance file %s", provfile)
   343  	}
   344  
   345  	sig, err := provenance.NewFromKeyring(keyring, "")
   346  	if err != nil {
   347  		return nil, errors.Wrap(err, "failed to load keyring")
   348  	}
   349  	return sig.Verify(path, provfile)
   350  }
   351  
   352  // isTar tests whether the given file is a tar file.
   353  //
   354  // Currently, this simply checks extension, since a subsequent function will
   355  // untar the file and validate its binary format.
   356  func isTar(filename string) bool {
   357  	return strings.EqualFold(filepath.Ext(filename), ".tgz")
   358  }
   359  
   360  func pickChartRepositoryConfigByName(name string, cfgs []*repo.Entry) (*repo.Entry, error) {
   361  	for _, rc := range cfgs {
   362  		if rc.Name == name {
   363  			if rc.URL == "" {
   364  				return nil, errors.Errorf("no URL found for repository %s", name)
   365  			}
   366  			return rc, nil
   367  		}
   368  	}
   369  	return nil, errors.Errorf("repo %s not found", name)
   370  }
   371  
   372  // scanReposForURL scans all repos to find which repo contains the given URL.
   373  //
   374  // This will attempt to find the given URL in all of the known repositories files.
   375  //
   376  // If the URL is found, this will return the repo entry that contained that URL.
   377  //
   378  // If all of the repos are checked, but the URL is not found, an ErrNoOwnerRepo
   379  // error is returned.
   380  //
   381  // Other errors may be returned when repositories cannot be loaded or searched.
   382  //
   383  // Technically, the fact that a URL is not found in a repo is not a failure indication.
   384  // Charts are not required to be included in an index before they are valid. So
   385  // be mindful of this case.
   386  //
   387  // The same URL can technically exist in two or more repositories. This algorithm
   388  // will return the first one it finds. Order is determined by the order of repositories
   389  // in the repositories.yaml file.
   390  func (c *ChartDownloader) scanReposForURL(u string, rf *repo.File) (*repo.Entry, error) {
   391  	// FIXME: This is far from optimal. Larger installations and index files will
   392  	// incur a performance hit for this type of scanning.
   393  	for _, rc := range rf.Repositories {
   394  		r, err := repo.NewChartRepository(rc, c.Getters)
   395  		if err != nil {
   396  			return nil, err
   397  		}
   398  
   399  		idxFile := filepath.Join(c.RepositoryCache, helmpath.CacheIndexFile(r.Config.Name))
   400  		i, err := repo.LoadIndexFile(idxFile)
   401  		if err != nil {
   402  			return nil, errors.Wrap(err, "no cached repo found. (try 'helm repo update')")
   403  		}
   404  
   405  		for _, entry := range i.Entries {
   406  			for _, ver := range entry {
   407  				for _, dl := range ver.URLs {
   408  					if urlutil.Equal(u, dl) {
   409  						return rc, nil
   410  					}
   411  				}
   412  			}
   413  		}
   414  	}
   415  	// This means that there is no repo file for the given URL.
   416  	return nil, ErrNoOwnerRepo
   417  }
   418  
   419  func loadRepoConfig(file string) (*repo.File, error) {
   420  	r, err := repo.LoadFile(file)
   421  	if err != nil && !os.IsNotExist(errors.Cause(err)) {
   422  		return nil, err
   423  	}
   424  	return r, nil
   425  }