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

     1  package helm
     2  
     3  import (
     4  	"bytes"
     5  	"crypto/tls"
     6  	"crypto/x509"
     7  	"errors"
     8  	"fmt"
     9  	"io/ioutil"
    10  	"net/http"
    11  	"net/url"
    12  	"os"
    13  	"os/exec"
    14  	"path"
    15  	"path/filepath"
    16  	"strings"
    17  	"time"
    18  
    19  	"github.com/Masterminds/semver"
    20  	"github.com/argoproj/pkg/sync"
    21  	log "github.com/sirupsen/logrus"
    22  	"gopkg.in/yaml.v2"
    23  
    24  	executil "github.com/argoproj/argo-cd/util/exec"
    25  	"github.com/argoproj/argo-cd/util/io"
    26  )
    27  
    28  var (
    29  	globalLock = sync.NewKeyLock()
    30  )
    31  
    32  type Creds struct {
    33  	Username           string
    34  	Password           string
    35  	CAPath             string
    36  	CertData           []byte
    37  	KeyData            []byte
    38  	InsecureSkipVerify bool
    39  }
    40  
    41  type Client interface {
    42  	CleanChartCache(chart string, version *semver.Version) error
    43  	ExtractChart(chart string, version *semver.Version) (string, io.Closer, error)
    44  	GetIndex() (*Index, error)
    45  	TestHelmOCI() (bool, error)
    46  }
    47  
    48  func NewClient(repoURL string, creds Creds, enableOci bool) Client {
    49  	return NewClientWithLock(repoURL, creds, globalLock, enableOci)
    50  }
    51  
    52  func NewClientWithLock(repoURL string, creds Creds, repoLock sync.KeyLock, enableOci bool) Client {
    53  	return &nativeHelmChart{
    54  		repoURL:   repoURL,
    55  		creds:     creds,
    56  		repoPath:  filepath.Join(os.TempDir(), strings.Replace(repoURL, "/", "_", -1)),
    57  		repoLock:  repoLock,
    58  		enableOci: enableOci,
    59  	}
    60  }
    61  
    62  type nativeHelmChart struct {
    63  	repoPath  string
    64  	repoURL   string
    65  	creds     Creds
    66  	repoLock  sync.KeyLock
    67  	enableOci bool
    68  }
    69  
    70  func fileExist(filePath string) (bool, error) {
    71  	if _, err := os.Stat(filePath); err != nil {
    72  		if os.IsNotExist(err) {
    73  			return false, nil
    74  		} else {
    75  			return false, err
    76  		}
    77  	}
    78  	return true, nil
    79  }
    80  
    81  func (c *nativeHelmChart) ensureHelmChartRepoPath() error {
    82  	c.repoLock.Lock(c.repoPath)
    83  	defer c.repoLock.Unlock(c.repoPath)
    84  
    85  	err := os.Mkdir(c.repoPath, 0700)
    86  	if err != nil && !os.IsExist(err) {
    87  		return err
    88  	}
    89  	return nil
    90  }
    91  
    92  func (c *nativeHelmChart) CleanChartCache(chart string, version *semver.Version) error {
    93  	return os.RemoveAll(c.getCachedChartPath(chart, version))
    94  }
    95  
    96  func (c *nativeHelmChart) ExtractChart(chart string, version *semver.Version) (string, io.Closer, error) {
    97  	err := c.ensureHelmChartRepoPath()
    98  	if err != nil {
    99  		return "", nil, err
   100  	}
   101  
   102  	// always use Helm V3 since we don't have chart content to determine correct Helm version
   103  	helmCmd, err := NewCmdWithVersion(c.repoPath, HelmV3, c.enableOci)
   104  
   105  	if err != nil {
   106  		return "", nil, err
   107  	}
   108  	defer helmCmd.Close()
   109  
   110  	_, err = helmCmd.Init()
   111  	if err != nil {
   112  		return "", nil, err
   113  	}
   114  
   115  	// throw away temp directory that stores extracted chart and should be deleted as soon as no longer needed by returned closer
   116  	tempDir, err := ioutil.TempDir("", "helm")
   117  	if err != nil {
   118  		return "", nil, err
   119  	}
   120  
   121  	cachedChartPath := c.getCachedChartPath(chart, version)
   122  
   123  	c.repoLock.Lock(cachedChartPath)
   124  	defer c.repoLock.Unlock(cachedChartPath)
   125  
   126  	// check if chart tar is already downloaded
   127  	exists, err := fileExist(cachedChartPath)
   128  	if err != nil {
   129  		return "", nil, err
   130  	}
   131  
   132  	if !exists {
   133  		// create empty temp directory to extract chart from the registry
   134  		tempDest, err := ioutil.TempDir("", "helm")
   135  		if err != nil {
   136  			return "", nil, err
   137  		}
   138  		defer func() { _ = os.RemoveAll(tempDest) }()
   139  
   140  		if c.enableOci {
   141  			if c.creds.Password != "" && c.creds.Username != "" {
   142  				_, err = helmCmd.Login(c.repoURL, c.creds)
   143  				if err != nil {
   144  					return "", nil, err
   145  				}
   146  
   147  				defer func() {
   148  					_, _ = helmCmd.Logout(c.repoURL, c.creds)
   149  				}()
   150  			}
   151  
   152  			// 'helm chart pull' ensures that chart is downloaded into local repository cache
   153  			_, err = helmCmd.ChartPull(c.repoURL, chart, version.String())
   154  			if err != nil {
   155  				return "", nil, err
   156  			}
   157  
   158  			// 'helm chart export' copies cached chart into temp directory
   159  			_, err = helmCmd.ChartExport(c.repoURL, chart, version.String(), tempDest)
   160  			if err != nil {
   161  				return "", nil, err
   162  			}
   163  
   164  			// use downloaded chart content to produce tar file in expected cache location
   165  			cmd := exec.Command("tar", "-zcvf", cachedChartPath, normalizeChartName(chart))
   166  			cmd.Dir = tempDest
   167  			_, err = executil.Run(cmd)
   168  			if err != nil {
   169  				return "", nil, err
   170  			}
   171  		} else {
   172  			_, err = helmCmd.Fetch(c.repoURL, chart, version.String(), tempDest, c.creds)
   173  			if err != nil {
   174  				return "", nil, err
   175  			}
   176  
   177  			// 'helm fetch' file downloads chart into the tgz file and we move that to where we want it
   178  			infos, err := ioutil.ReadDir(tempDest)
   179  			if err != nil {
   180  				return "", nil, err
   181  			}
   182  			if len(infos) != 1 {
   183  				return "", nil, fmt.Errorf("expected 1 file, found %v", len(infos))
   184  			}
   185  			err = os.Rename(filepath.Join(tempDest, infos[0].Name()), cachedChartPath)
   186  			if err != nil {
   187  				return "", nil, err
   188  			}
   189  		}
   190  
   191  	}
   192  
   193  	cmd := exec.Command("tar", "-zxvf", cachedChartPath)
   194  	cmd.Dir = tempDir
   195  	_, err = executil.Run(cmd)
   196  	if err != nil {
   197  		_ = os.RemoveAll(tempDir)
   198  		return "", nil, err
   199  	}
   200  	return path.Join(tempDir, normalizeChartName(chart)), io.NewCloser(func() error {
   201  		return os.RemoveAll(tempDir)
   202  	}), nil
   203  }
   204  
   205  func (c *nativeHelmChart) GetIndex() (*Index, error) {
   206  	start := time.Now()
   207  
   208  	data, err := c.loadRepoIndex()
   209  	if err != nil {
   210  		return nil, err
   211  	}
   212  
   213  	index := &Index{}
   214  	err = yaml.NewDecoder(bytes.NewBuffer(data)).Decode(index)
   215  	if err != nil {
   216  		return nil, err
   217  	}
   218  
   219  	log.WithFields(log.Fields{"seconds": time.Since(start).Seconds()}).Info("took to get index")
   220  
   221  	return index, nil
   222  }
   223  
   224  func (c *nativeHelmChart) TestHelmOCI() (bool, error) {
   225  	start := time.Now()
   226  
   227  	tmpDir, err := ioutil.TempDir("", "helm")
   228  	if err != nil {
   229  		return false, err
   230  	}
   231  	defer func() { _ = os.RemoveAll(tmpDir) }()
   232  
   233  	helmCmd, err := NewCmdWithVersion(tmpDir, HelmV3, c.enableOci)
   234  	if err != nil {
   235  		return false, err
   236  	}
   237  	defer helmCmd.Close()
   238  
   239  	// Looks like there is no good way to test access to OCI repo if credentials are not provided
   240  	// just assume it is accessible
   241  	if c.creds.Username != "" && c.creds.Password != "" {
   242  		_, err = helmCmd.Login(c.repoURL, c.creds)
   243  		if err != nil {
   244  			return false, err
   245  		}
   246  		defer func() {
   247  			_, _ = helmCmd.Logout(c.repoURL, c.creds)
   248  		}()
   249  
   250  		log.WithFields(log.Fields{"seconds": time.Since(start).Seconds()}).Info("took to test helm oci repository")
   251  	}
   252  	return true, nil
   253  }
   254  
   255  func (c *nativeHelmChart) loadRepoIndex() ([]byte, error) {
   256  	repoURL, err := url.Parse(c.repoURL)
   257  	if err != nil {
   258  		return nil, err
   259  	}
   260  	repoURL.Path = path.Join(repoURL.Path, "index.yaml")
   261  
   262  	req, err := http.NewRequest("GET", repoURL.String(), nil)
   263  	if err != nil {
   264  		return nil, err
   265  	}
   266  	if c.creds.Username != "" || c.creds.Password != "" {
   267  		// only basic supported
   268  		req.SetBasicAuth(c.creds.Username, c.creds.Password)
   269  	}
   270  
   271  	tlsConf, err := newTLSConfig(c.creds)
   272  	if err != nil {
   273  		return nil, err
   274  	}
   275  	tr := &http.Transport{
   276  		Proxy:           http.ProxyFromEnvironment,
   277  		TLSClientConfig: tlsConf,
   278  	}
   279  	client := http.Client{Transport: tr}
   280  	resp, err := client.Do(req)
   281  	if err != nil {
   282  		return nil, err
   283  	}
   284  	defer func() { _ = resp.Body.Close() }()
   285  
   286  	if resp.StatusCode != 200 {
   287  		return nil, errors.New("failed to get index: " + resp.Status)
   288  	}
   289  	return ioutil.ReadAll(resp.Body)
   290  }
   291  
   292  func newTLSConfig(creds Creds) (*tls.Config, error) {
   293  	tlsConfig := &tls.Config{InsecureSkipVerify: creds.InsecureSkipVerify}
   294  
   295  	if creds.CAPath != "" {
   296  		caData, err := ioutil.ReadFile(creds.CAPath)
   297  		if err != nil {
   298  			return nil, err
   299  		}
   300  		caCertPool := x509.NewCertPool()
   301  		caCertPool.AppendCertsFromPEM(caData)
   302  		tlsConfig.RootCAs = caCertPool
   303  	}
   304  
   305  	// If a client cert & key is provided then configure TLS config accordingly.
   306  	if len(creds.CertData) > 0 && len(creds.KeyData) > 0 {
   307  		cert, err := tls.X509KeyPair(creds.CertData, creds.KeyData)
   308  		if err != nil {
   309  			return nil, err
   310  		}
   311  		tlsConfig.Certificates = []tls.Certificate{cert}
   312  	}
   313  	// nolint:staticcheck
   314  	tlsConfig.BuildNameToCertificate()
   315  
   316  	return tlsConfig, nil
   317  }
   318  
   319  // Normalize a chart name for file system use, that is, if chart name is foo/bar/baz, returns the last component as chart name.
   320  func normalizeChartName(chart string) string {
   321  	strings.Join(strings.Split(chart, "/"), "_")
   322  	_, nc := path.Split(chart)
   323  	// We do not want to return the empty string or something else related to filesystem access
   324  	// Instead, return original string
   325  	if nc == "" || nc == "." || nc == ".." {
   326  		return chart
   327  	}
   328  	return nc
   329  }
   330  
   331  func (c *nativeHelmChart) getCachedChartPath(chart string, version *semver.Version) string {
   332  	return path.Join(c.repoPath, fmt.Sprintf("%s-%v.tgz", strings.ReplaceAll(chart, "/", "_"), version))
   333  }
   334  
   335  // Only OCI registries support storing charts under sub-directories.
   336  func IsHelmOciChart(chart string) bool {
   337  	return strings.Contains(chart, "/")
   338  }