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