github.com/argoproj/argo-cd/v2@v2.10.9/util/helm/client.go (about)

     1  package helm
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"crypto/tls"
     7  	"crypto/x509"
     8  	"encoding/json"
     9  	"errors"
    10  	"fmt"
    11  	"io"
    12  	"net/http"
    13  	"net/url"
    14  	"os"
    15  	"os/exec"
    16  	"path"
    17  	"path/filepath"
    18  	"strings"
    19  	"time"
    20  
    21  	executil "github.com/argoproj/argo-cd/v2/util/exec"
    22  
    23  	"github.com/argoproj/pkg/sync"
    24  	log "github.com/sirupsen/logrus"
    25  	"gopkg.in/yaml.v2"
    26  	"oras.land/oras-go/v2/registry/remote"
    27  	"oras.land/oras-go/v2/registry/remote/auth"
    28  
    29  	"github.com/argoproj/argo-cd/v2/util/cache"
    30  	argoio "github.com/argoproj/argo-cd/v2/util/io"
    31  	"github.com/argoproj/argo-cd/v2/util/io/files"
    32  	"github.com/argoproj/argo-cd/v2/util/proxy"
    33  )
    34  
    35  var (
    36  	globalLock = sync.NewKeyLock()
    37  	indexLock  = sync.NewKeyLock()
    38  
    39  	OCINotEnabledErr = errors.New("could not perform the action when oci is not enabled")
    40  )
    41  
    42  type Creds struct {
    43  	Username           string
    44  	Password           string
    45  	CAPath             string
    46  	CertData           []byte
    47  	KeyData            []byte
    48  	InsecureSkipVerify bool
    49  }
    50  
    51  type indexCache interface {
    52  	SetHelmIndex(repo string, indexData []byte) error
    53  	GetHelmIndex(repo string, indexData *[]byte) error
    54  }
    55  
    56  type Client interface {
    57  	CleanChartCache(chart string, version string) error
    58  	ExtractChart(chart string, version string, passCredentials bool, manifestMaxExtractedSize int64, disableManifestMaxExtractedSize bool) (string, argoio.Closer, error)
    59  	GetIndex(noCache bool, maxIndexSize int64) (*Index, error)
    60  	GetTags(chart string, noCache bool) (*TagsList, error)
    61  	TestHelmOCI() (bool, error)
    62  }
    63  
    64  type ClientOpts func(c *nativeHelmChart)
    65  
    66  func WithIndexCache(indexCache indexCache) ClientOpts {
    67  	return func(c *nativeHelmChart) {
    68  		c.indexCache = indexCache
    69  	}
    70  }
    71  
    72  func WithChartPaths(chartPaths argoio.TempPaths) ClientOpts {
    73  	return func(c *nativeHelmChart) {
    74  		c.chartCachePaths = chartPaths
    75  	}
    76  }
    77  
    78  func NewClient(repoURL string, creds Creds, enableOci bool, proxy string, opts ...ClientOpts) Client {
    79  	return NewClientWithLock(repoURL, creds, globalLock, enableOci, proxy, opts...)
    80  }
    81  
    82  func NewClientWithLock(repoURL string, creds Creds, repoLock sync.KeyLock, enableOci bool, proxy string, opts ...ClientOpts) Client {
    83  	c := &nativeHelmChart{
    84  		repoURL:         repoURL,
    85  		creds:           creds,
    86  		repoLock:        repoLock,
    87  		enableOci:       enableOci,
    88  		proxy:           proxy,
    89  		chartCachePaths: argoio.NewRandomizedTempPaths(os.TempDir()),
    90  	}
    91  	for i := range opts {
    92  		opts[i](c)
    93  	}
    94  	return c
    95  }
    96  
    97  var _ Client = &nativeHelmChart{}
    98  
    99  type nativeHelmChart struct {
   100  	chartCachePaths argoio.TempPaths
   101  	repoURL         string
   102  	creds           Creds
   103  	repoLock        sync.KeyLock
   104  	enableOci       bool
   105  	indexCache      indexCache
   106  	proxy           string
   107  }
   108  
   109  func fileExist(filePath string) (bool, error) {
   110  	if _, err := os.Stat(filePath); err != nil {
   111  		if os.IsNotExist(err) {
   112  			return false, nil
   113  		} else {
   114  			return false, err
   115  		}
   116  	}
   117  	return true, nil
   118  }
   119  
   120  func (c *nativeHelmChart) CleanChartCache(chart string, version string) error {
   121  	cachePath, err := c.getCachedChartPath(chart, version)
   122  	if err != nil {
   123  		return err
   124  	}
   125  	return os.RemoveAll(cachePath)
   126  }
   127  
   128  func untarChart(tempDir string, cachedChartPath string, manifestMaxExtractedSize int64, disableManifestMaxExtractedSize bool) error {
   129  	if disableManifestMaxExtractedSize {
   130  		cmd := exec.Command("tar", "-zxvf", cachedChartPath)
   131  		cmd.Dir = tempDir
   132  		_, err := executil.Run(cmd)
   133  		return err
   134  	}
   135  	reader, err := os.Open(cachedChartPath)
   136  	if err != nil {
   137  		return err
   138  	}
   139  	return files.Untgz(tempDir, reader, manifestMaxExtractedSize, false)
   140  }
   141  
   142  func (c *nativeHelmChart) ExtractChart(chart string, version string, passCredentials bool, manifestMaxExtractedSize int64, disableManifestMaxExtractedSize bool) (string, argoio.Closer, error) {
   143  	// always use Helm V3 since we don't have chart content to determine correct Helm version
   144  	helmCmd, err := NewCmdWithVersion("", HelmV3, c.enableOci, c.proxy)
   145  
   146  	if err != nil {
   147  		return "", nil, err
   148  	}
   149  	defer helmCmd.Close()
   150  
   151  	_, err = helmCmd.Init()
   152  	if err != nil {
   153  		return "", nil, err
   154  	}
   155  
   156  	// throw away temp directory that stores extracted chart and should be deleted as soon as no longer needed by returned closer
   157  	tempDir, err := files.CreateTempDir(os.TempDir())
   158  	if err != nil {
   159  		return "", nil, err
   160  	}
   161  
   162  	cachedChartPath, err := c.getCachedChartPath(chart, version)
   163  	if err != nil {
   164  		return "", nil, err
   165  	}
   166  
   167  	c.repoLock.Lock(cachedChartPath)
   168  	defer c.repoLock.Unlock(cachedChartPath)
   169  
   170  	// check if chart tar is already downloaded
   171  	exists, err := fileExist(cachedChartPath)
   172  	if err != nil {
   173  		return "", nil, err
   174  	}
   175  
   176  	if !exists {
   177  		// create empty temp directory to extract chart from the registry
   178  		tempDest, err := files.CreateTempDir(os.TempDir())
   179  		if err != nil {
   180  			return "", nil, err
   181  		}
   182  		defer func() { _ = os.RemoveAll(tempDest) }()
   183  
   184  		if c.enableOci {
   185  			if c.creds.Password != "" && c.creds.Username != "" {
   186  				_, err = helmCmd.RegistryLogin(c.repoURL, c.creds)
   187  				if err != nil {
   188  					return "", nil, err
   189  				}
   190  
   191  				defer func() {
   192  					_, _ = helmCmd.RegistryLogout(c.repoURL, c.creds)
   193  				}()
   194  			}
   195  
   196  			// 'helm pull' ensures that chart is downloaded into temp directory
   197  			_, err = helmCmd.PullOCI(c.repoURL, chart, version, tempDest, c.creds)
   198  			if err != nil {
   199  				return "", nil, err
   200  			}
   201  		} else {
   202  			_, err = helmCmd.Fetch(c.repoURL, chart, version, tempDest, c.creds, passCredentials)
   203  			if err != nil {
   204  				return "", nil, err
   205  			}
   206  		}
   207  
   208  		// 'helm pull/fetch' file downloads chart into the tgz file and we move that to where we want it
   209  		infos, err := os.ReadDir(tempDest)
   210  		if err != nil {
   211  			return "", nil, err
   212  		}
   213  		if len(infos) != 1 {
   214  			return "", nil, fmt.Errorf("expected 1 file, found %v", len(infos))
   215  		}
   216  
   217  		err = os.Rename(filepath.Join(tempDest, infos[0].Name()), cachedChartPath)
   218  		if err != nil {
   219  			return "", nil, err
   220  		}
   221  	}
   222  
   223  	err = untarChart(tempDir, cachedChartPath, manifestMaxExtractedSize, disableManifestMaxExtractedSize)
   224  	if err != nil {
   225  		_ = os.RemoveAll(tempDir)
   226  		return "", nil, err
   227  	}
   228  	return path.Join(tempDir, normalizeChartName(chart)), argoio.NewCloser(func() error {
   229  		return os.RemoveAll(tempDir)
   230  	}), nil
   231  }
   232  
   233  func (c *nativeHelmChart) GetIndex(noCache bool, maxIndexSize int64) (*Index, error) {
   234  	indexLock.Lock(c.repoURL)
   235  	defer indexLock.Unlock(c.repoURL)
   236  
   237  	var data []byte
   238  	if !noCache && c.indexCache != nil {
   239  		if err := c.indexCache.GetHelmIndex(c.repoURL, &data); err != nil && err != cache.ErrCacheMiss {
   240  			log.Warnf("Failed to load index cache for repo: %s: %v", c.repoURL, err)
   241  		}
   242  	}
   243  
   244  	if len(data) == 0 {
   245  		start := time.Now()
   246  		var err error
   247  		data, err = c.loadRepoIndex(maxIndexSize)
   248  		if err != nil {
   249  			return nil, err
   250  		}
   251  		log.WithFields(log.Fields{"seconds": time.Since(start).Seconds()}).Info("took to get index")
   252  
   253  		if c.indexCache != nil {
   254  			if err := c.indexCache.SetHelmIndex(c.repoURL, data); err != nil {
   255  				log.Warnf("Failed to store index cache for repo: %s: %v", c.repoURL, err)
   256  			}
   257  		}
   258  	}
   259  
   260  	index := &Index{}
   261  	err := yaml.NewDecoder(bytes.NewBuffer(data)).Decode(index)
   262  	if err != nil {
   263  		return nil, err
   264  	}
   265  
   266  	return index, nil
   267  }
   268  
   269  func (c *nativeHelmChart) TestHelmOCI() (bool, error) {
   270  	start := time.Now()
   271  
   272  	tmpDir, err := os.MkdirTemp("", "helm")
   273  	if err != nil {
   274  		return false, err
   275  	}
   276  	defer func() { _ = os.RemoveAll(tmpDir) }()
   277  
   278  	helmCmd, err := NewCmdWithVersion(tmpDir, HelmV3, c.enableOci, c.proxy)
   279  	if err != nil {
   280  		return false, err
   281  	}
   282  	defer helmCmd.Close()
   283  
   284  	// Looks like there is no good way to test access to OCI repo if credentials are not provided
   285  	// just assume it is accessible
   286  	if c.creds.Username != "" && c.creds.Password != "" {
   287  		_, err = helmCmd.RegistryLogin(c.repoURL, c.creds)
   288  		if err != nil {
   289  			return false, err
   290  		}
   291  		defer func() {
   292  			_, _ = helmCmd.RegistryLogout(c.repoURL, c.creds)
   293  		}()
   294  
   295  		log.WithFields(log.Fields{"seconds": time.Since(start).Seconds()}).Info("took to test helm oci repository")
   296  	}
   297  	return true, nil
   298  }
   299  
   300  func (c *nativeHelmChart) loadRepoIndex(maxIndexSize int64) ([]byte, error) {
   301  	indexURL, err := getIndexURL(c.repoURL)
   302  	if err != nil {
   303  		return nil, err
   304  	}
   305  
   306  	req, err := http.NewRequest(http.MethodGet, indexURL, nil)
   307  	if err != nil {
   308  		return nil, err
   309  	}
   310  	if c.creds.Username != "" || c.creds.Password != "" {
   311  		// only basic supported
   312  		req.SetBasicAuth(c.creds.Username, c.creds.Password)
   313  	}
   314  
   315  	tlsConf, err := newTLSConfig(c.creds)
   316  	if err != nil {
   317  		return nil, err
   318  	}
   319  
   320  	tr := &http.Transport{
   321  		Proxy:             proxy.GetCallback(c.proxy),
   322  		TLSClientConfig:   tlsConf,
   323  		DisableKeepAlives: true,
   324  	}
   325  	client := http.Client{Transport: tr}
   326  	resp, err := client.Do(req)
   327  	if err != nil {
   328  		return nil, err
   329  	}
   330  	defer func() { _ = resp.Body.Close() }()
   331  
   332  	if resp.StatusCode != http.StatusOK {
   333  		return nil, errors.New("failed to get index: " + resp.Status)
   334  	}
   335  	return io.ReadAll(io.LimitReader(resp.Body, maxIndexSize))
   336  }
   337  
   338  func newTLSConfig(creds Creds) (*tls.Config, error) {
   339  	tlsConfig := &tls.Config{InsecureSkipVerify: creds.InsecureSkipVerify}
   340  
   341  	if creds.CAPath != "" {
   342  		caData, err := os.ReadFile(creds.CAPath)
   343  		if err != nil {
   344  			return nil, err
   345  		}
   346  		caCertPool := x509.NewCertPool()
   347  		caCertPool.AppendCertsFromPEM(caData)
   348  		tlsConfig.RootCAs = caCertPool
   349  	}
   350  
   351  	// If a client cert & key is provided then configure TLS config accordingly.
   352  	if len(creds.CertData) > 0 && len(creds.KeyData) > 0 {
   353  		cert, err := tls.X509KeyPair(creds.CertData, creds.KeyData)
   354  		if err != nil {
   355  			return nil, err
   356  		}
   357  		tlsConfig.Certificates = []tls.Certificate{cert}
   358  	}
   359  	// nolint:staticcheck
   360  	tlsConfig.BuildNameToCertificate()
   361  
   362  	return tlsConfig, nil
   363  }
   364  
   365  // Normalize a chart name for file system use, that is, if chart name is foo/bar/baz, returns the last component as chart name.
   366  func normalizeChartName(chart string) string {
   367  	strings.Join(strings.Split(chart, "/"), "_")
   368  	_, nc := path.Split(chart)
   369  	// We do not want to return the empty string or something else related to filesystem access
   370  	// Instead, return original string
   371  	if nc == "" || nc == "." || nc == ".." {
   372  		return chart
   373  	}
   374  	return nc
   375  }
   376  
   377  func (c *nativeHelmChart) getCachedChartPath(chart string, version string) (string, error) {
   378  	keyData, err := json.Marshal(map[string]string{"url": c.repoURL, "chart": chart, "version": version})
   379  	if err != nil {
   380  		return "", err
   381  	}
   382  	return c.chartCachePaths.GetPath(string(keyData))
   383  }
   384  
   385  // Ensures that given OCI registries URL does not have protocol
   386  func IsHelmOciRepo(repoURL string) bool {
   387  	if repoURL == "" {
   388  		return false
   389  	}
   390  	parsed, err := url.Parse(repoURL)
   391  	// the URL parser treat hostname as either path or opaque if scheme is not specified, so hostname must be empty
   392  	return err == nil && parsed.Host == ""
   393  }
   394  
   395  func getIndexURL(rawURL string) (string, error) {
   396  	indexFile := "index.yaml"
   397  	repoURL, err := url.Parse(rawURL)
   398  	if err != nil {
   399  		return "", err
   400  	}
   401  	repoURL.Path = path.Join(repoURL.Path, indexFile)
   402  	repoURL.RawPath = path.Join(repoURL.RawPath, indexFile)
   403  	return repoURL.String(), nil
   404  }
   405  
   406  func (c *nativeHelmChart) GetTags(chart string, noCache bool) (*TagsList, error) {
   407  	if !c.enableOci {
   408  		return nil, OCINotEnabledErr
   409  	}
   410  
   411  	tagsURL := strings.Replace(fmt.Sprintf("%s/%s", c.repoURL, chart), "https://", "", 1)
   412  	indexLock.Lock(tagsURL)
   413  	defer indexLock.Unlock(tagsURL)
   414  
   415  	var data []byte
   416  	if !noCache && c.indexCache != nil {
   417  		if err := c.indexCache.GetHelmIndex(tagsURL, &data); err != nil && err != cache.ErrCacheMiss {
   418  			log.Warnf("Failed to load index cache for repo: %s: %v", tagsURL, err)
   419  		}
   420  	}
   421  
   422  	tags := &TagsList{}
   423  	if len(data) == 0 {
   424  		start := time.Now()
   425  		repo, err := remote.NewRepository(tagsURL)
   426  		if err != nil {
   427  			return nil, fmt.Errorf("failed to initialize repository: %v", err)
   428  		}
   429  		tlsConf, err := newTLSConfig(c.creds)
   430  		if err != nil {
   431  			return nil, fmt.Errorf("failed setup tlsConfig: %v", err)
   432  		}
   433  		client := &http.Client{Transport: &http.Transport{
   434  			Proxy:             proxy.GetCallback(c.proxy),
   435  			TLSClientConfig:   tlsConf,
   436  			DisableKeepAlives: true,
   437  		}}
   438  
   439  		repoHost, _, _ := strings.Cut(tagsURL, "/")
   440  		repo.Client = &auth.Client{
   441  			Client: client,
   442  			Cache:  nil,
   443  			Credential: auth.StaticCredential(repoHost, auth.Credential{
   444  				Username: c.creds.Username,
   445  				Password: c.creds.Password,
   446  			}),
   447  		}
   448  
   449  		ctx := context.Background()
   450  		err = repo.Tags(ctx, "", func(tagsResult []string) error {
   451  			for _, tag := range tagsResult {
   452  				// By convention: Change underscore (_) back to plus (+) to get valid SemVer
   453  				convertedTag := strings.ReplaceAll(tag, "_", "+")
   454  				tags.Tags = append(tags.Tags, convertedTag)
   455  			}
   456  
   457  			return nil
   458  		})
   459  
   460  		if err != nil {
   461  			return nil, fmt.Errorf("failed to get tags: %v", err)
   462  		}
   463  		log.WithFields(
   464  			log.Fields{"seconds": time.Since(start).Seconds(), "chart": chart, "repo": c.repoURL},
   465  		).Info("took to get tags")
   466  
   467  		if c.indexCache != nil {
   468  			if err := c.indexCache.SetHelmIndex(tagsURL, data); err != nil {
   469  				log.Warnf("Failed to store tags list cache for repo: %s: %v", tagsURL, err)
   470  			}
   471  		}
   472  	} else {
   473  		err := json.Unmarshal(data, tags)
   474  		if err != nil {
   475  			return nil, fmt.Errorf("failed to decode tags: %v", err)
   476  		}
   477  	}
   478  
   479  	return tags, nil
   480  }