github.com/helmwave/helmwave@v0.36.4-0.20240509190856-b35563eba4c6/pkg/release/chart.go (about)

     1  package release
     2  
     3  import (
     4  	"crypto/sha256"
     5  	"encoding/hex"
     6  	"errors"
     7  	"fmt"
     8  	"io"
     9  	"os"
    10  	"path"
    11  	"path/filepath"
    12  	"slices"
    13  	"strings"
    14  
    15  	"github.com/helmwave/helmwave/pkg/helper"
    16  	log "github.com/sirupsen/logrus"
    17  	"gopkg.in/yaml.v3"
    18  	"helm.sh/helm/v3/pkg/action"
    19  	"helm.sh/helm/v3/pkg/chart"
    20  	"helm.sh/helm/v3/pkg/chart/loader"
    21  	"helm.sh/helm/v3/pkg/downloader"
    22  	"helm.sh/helm/v3/pkg/getter"
    23  	"helm.sh/helm/v3/pkg/helmpath"
    24  	"helm.sh/helm/v3/pkg/registry"
    25  	"helm.sh/helm/v3/pkg/repo"
    26  )
    27  
    28  // Chart is a structure for chart download options.
    29  //
    30  //nolint:lll
    31  type Chart struct {
    32  	Name                  string `yaml:"name" json:"name" jsonschema:"required,description=Name of the chart,example=bitnami/nginx,example=oci://ghcr.io/helmwave/unit-test-oci"`
    33  	CaFile                string `yaml:"ca_file" json:"ca_file" jsonschema:"description=Verify certificates of HTTPS-enabled servers using this CA bundle"`
    34  	CertFile              string `yaml:"cert_file" json:"cert_file" jsonschema:"description=Identify HTTPS client using this SSL certificate file"`
    35  	KeyFile               string `yaml:"key_file" json:"key_file" jsonschema:"description=Identify HTTPS client using this SSL key file"`
    36  	Keyring               string `yaml:"keyring" json:"keyring" jsonschema:"description=Location of public keys used for verification"`
    37  	RepoURL               string `yaml:"repo_url" json:"repo_url" jsonschema:"description=Chart repository url"`
    38  	Username              string `yaml:"username" json:"username" jsonschema:"description=Chart repository username"`
    39  	Password              string `yaml:"password" json:"password" jsonschema:"description=Chart repository password"`
    40  	Version               string `yaml:"version" json:"version" jsonschema:"description=Chart version"`
    41  	InsecureSkipTLSverify bool   `yaml:"insecure" json:"insecure" jsonschema:"description=Connect to server with an insecure way by skipping certificate verification"`
    42  	Verify                bool   `yaml:"verify" json:"verify" jsonschema:"description=Verify the provenance of the chart before using it"`
    43  	PassCredentialsAll    bool   `yaml:"pass_credentials" json:"pass_credentials" jsonschema:"description=Pass credentials to all domains"`
    44  	PlainHTTP             bool   `yaml:"plain_http" json:"plain_http" jsonschema:"description=Connect to server with plain http and not https,default=false"`
    45  	SkipDependencyUpdate  bool   `yaml:"skip_dependency_update" json:"skip_dependency_update" jsonschema:"description=Skip updating and downloading dependencies,default=false"`
    46  	SkipRefresh           bool   `yaml:"skip_refresh,omitempty" json:"skip_refresh,omitempty" jsonschema:"description=Skip refreshing repositories,default=false"`
    47  }
    48  
    49  // CopyOptions is a helper for copy options from Chart to ChartPathOptions.
    50  func (c *Chart) CopyOptions(cpo *action.ChartPathOptions) {
    51  	// I hate private field without normal New(...Options)
    52  	cpo.CaFile = c.CaFile
    53  	cpo.CertFile = c.CertFile
    54  	cpo.KeyFile = c.KeyFile
    55  	cpo.InsecureSkipTLSverify = c.InsecureSkipTLSverify
    56  	cpo.PlainHTTP = c.PlainHTTP
    57  	cpo.Keyring = c.Keyring
    58  	cpo.Password = c.Password
    59  	cpo.PassCredentialsAll = c.PassCredentialsAll
    60  	cpo.RepoURL = c.RepoURL
    61  	cpo.Username = c.Username
    62  	cpo.Verify = c.Verify
    63  	cpo.Version = c.Version
    64  }
    65  
    66  // UnmarshalYAML flexible config.
    67  func (c *Chart) UnmarshalYAML(node *yaml.Node) error {
    68  	type raw Chart
    69  	var err error
    70  
    71  	switch node.Kind {
    72  	case yaml.ScalarNode, yaml.AliasNode:
    73  		err = node.Decode(&(c.Name))
    74  	case yaml.MappingNode:
    75  		err = node.Decode((*raw)(c))
    76  	default:
    77  		err = ErrUnknownFormat
    78  	}
    79  
    80  	if err != nil {
    81  		return fmt.Errorf("failed to decode chart %q from YAML at %d line: %w", node.Value, node.Line, err)
    82  	}
    83  
    84  	return nil
    85  }
    86  
    87  func (c *Chart) IsRemote() bool {
    88  	return !helper.IsExists(filepath.Clean(c.Name))
    89  }
    90  
    91  func (rel *config) LocateChartWithCache() (string, error) {
    92  	if !rel.Chart().IsRemote() {
    93  		return rel.Chart().Name, nil
    94  	}
    95  
    96  	ch, err := rel.findChartInHelmCache()
    97  	if err == nil {
    98  		rel.Logger().WithField("path", ch).Info("❎ found chart in helm cache, using it")
    99  
   100  		return ch, nil
   101  	}
   102  
   103  	rel.Logger().WithError(err).Debug("haven't found chart in helm cache, need to download it")
   104  
   105  	// nice action bro
   106  	client := rel.newInstall()
   107  
   108  	ch, err = client.ChartPathOptions.LocateChart(rel.Chart().Name, rel.Helm())
   109  	if err != nil {
   110  		return "", fmt.Errorf("failed to locate chart %s: %w", rel.Chart().Name, err)
   111  	}
   112  
   113  	return ch, nil
   114  }
   115  
   116  func (rel *config) getDownloader() downloader.ChartDownloader {
   117  	settings := rel.Helm()
   118  	client := rel.newInstall()
   119  
   120  	return downloader.ChartDownloader{
   121  		Getters: getter.All(settings),
   122  		Options: []getter.Option{
   123  			getter.WithPassCredentialsAll(client.ChartPathOptions.PassCredentialsAll),
   124  			getter.WithTLSClientConfig(
   125  				client.ChartPathOptions.CertFile,
   126  				client.ChartPathOptions.KeyFile,
   127  				client.ChartPathOptions.CaFile,
   128  			),
   129  			getter.WithInsecureSkipVerifyTLS(client.ChartPathOptions.InsecureSkipTLSverify),
   130  		},
   131  		RepositoryConfig: settings.RepositoryConfig,
   132  		RepositoryCache:  settings.RepositoryCache,
   133  		RegistryClient:   client.GetRegistryClient(),
   134  	}
   135  }
   136  
   137  // Helm doesn't use its own charts cache, it only stores charts there. So we copypaste some code from
   138  // *downloader.ChartDownloader to find already downloaded charts in our cache.
   139  // We also check chart file digest in case of any collision.
   140  func (rel *config) findChartInHelmCache() (string, error) {
   141  	settings := rel.Helm()
   142  
   143  	dl := rel.getDownloader()
   144  
   145  	u, err := dl.ResolveChartVersion(rel.Chart().Name, rel.Chart().Version)
   146  	if err != nil {
   147  		return "", NewChartCacheError(err)
   148  	}
   149  
   150  	name := filepath.Base(u.Path)
   151  	if u.Scheme == registry.OCIScheme {
   152  		idx := strings.LastIndexByte(name, ':')
   153  		name = fmt.Sprintf("%s-%s.tgz", name[:idx], name[idx+1:])
   154  
   155  		rel.Logger().Debug("digest validation is not supported for OCI charts, skipping it")
   156  
   157  		chartFile := filepath.Join(settings.RepositoryCache, name)
   158  
   159  		_, err := os.Stat(chartFile)
   160  		if err != nil {
   161  			return "", NewChartCacheError(err)
   162  		}
   163  
   164  		return chartFile, nil
   165  	}
   166  
   167  	chartFile := filepath.Join(settings.RepositoryCache, name)
   168  
   169  	ch, err := rel.getChartRepoEntryFromIndex(u.String(), settings.RepositoryCache)
   170  	if err != nil {
   171  		return "", NewChartCacheError(err)
   172  	}
   173  
   174  	digest := ch.Digest
   175  	hasher := sha256.New()
   176  
   177  	f, err := os.Open(chartFile)
   178  	if err != nil {
   179  		return "", NewChartCacheError(err)
   180  	}
   181  	defer func() {
   182  		_ = f.Close()
   183  	}()
   184  
   185  	_, err = io.Copy(hasher, f)
   186  	if err != nil {
   187  		return "", NewChartCacheError(err)
   188  	}
   189  
   190  	hashSum := hex.EncodeToString(hasher.Sum(nil))
   191  
   192  	if hashSum != digest {
   193  		return "", NewChartCacheError(ErrDigestNotMatch)
   194  	}
   195  
   196  	return chartFile, nil
   197  }
   198  
   199  func (rel *config) getChartRepoEntryFromIndex(u, repositoryCache string) (*repo.ChartVersion, error) {
   200  	repoName := strings.SplitN(rel.Chart().Name, "/", 2)[0]
   201  	idxFile := filepath.Join(repositoryCache, helmpath.CacheIndexFile(repoName))
   202  	i, err := repo.LoadIndexFile(idxFile)
   203  	if err != nil {
   204  		return nil, fmt.Errorf("no cached repo found: %w", err)
   205  	}
   206  
   207  	for _, entry := range i.Entries {
   208  		for _, ver := range entry {
   209  			if slices.Contains(ver.URLs, u) {
   210  				return ver, nil
   211  			}
   212  		}
   213  	}
   214  
   215  	return nil, errors.New("repo not found")
   216  }
   217  
   218  func (rel *config) GetChart() (*chart.Chart, error) {
   219  	ch, err := rel.LocateChartWithCache()
   220  	if err != nil {
   221  		return nil, err
   222  	}
   223  
   224  	c, err := loader.Load(ch)
   225  	if err != nil {
   226  		return nil, fmt.Errorf("failed to load chart %s: %w", rel.Chart().Name, err)
   227  	}
   228  
   229  	if err := rel.chartCheck(c); err != nil {
   230  		return nil, err
   231  	}
   232  
   233  	return c, nil
   234  }
   235  
   236  func (rel *config) chartCheck(ch *chart.Chart) error {
   237  	if req := ch.Metadata.Dependencies; req != nil {
   238  		if err := action.CheckDependencies(ch, req); err != nil {
   239  			return fmt.Errorf("failed to check chart %s dependencies: %w", ch.Name(), err)
   240  		}
   241  	}
   242  
   243  	if !(ch.Metadata.Type == "" || ch.Metadata.Type == "application") {
   244  		rel.Logger().Warnf("%s charts are not installable", ch.Metadata.Type)
   245  	}
   246  
   247  	if ch.Metadata.Deprecated {
   248  		rel.Logger().Warnf("⚠️ Chart %s is deprecated. Please update your chart.", ch.Name())
   249  	}
   250  
   251  	return nil
   252  }
   253  
   254  func (rel *config) ChartDepsUpd() error {
   255  	if rel.Chart().IsRemote() {
   256  		rel.Logger().Info("❎ skipping updating dependencies for remote chart")
   257  
   258  		return nil
   259  	}
   260  
   261  	if rel.Chart().SkipDependencyUpdate {
   262  		rel.Logger().Info("❎ forced skipping updating dependencies for local chart")
   263  
   264  		return nil
   265  	}
   266  
   267  	settings := rel.Helm()
   268  
   269  	client := action.NewDependency()
   270  	man := &downloader.Manager{
   271  		Out:              log.StandardLogger().Writer(),
   272  		ChartPath:        filepath.Clean(rel.Chart().Name),
   273  		Keyring:          client.Keyring,
   274  		RegistryClient:   helper.HelmRegistryClient,
   275  		SkipUpdate:       rel.Chart().SkipRefresh,
   276  		Getters:          getter.All(settings),
   277  		RepositoryConfig: settings.RepositoryConfig,
   278  		RepositoryCache:  settings.RepositoryCache,
   279  		Debug:            settings.Debug,
   280  	}
   281  	if client.Verify {
   282  		man.Verify = downloader.VerifyAlways
   283  	}
   284  
   285  	if err := man.Update(); err != nil {
   286  		return fmt.Errorf("failed to update %s chart dependencies: %w", rel.Chart().Name, err)
   287  	}
   288  
   289  	return nil
   290  }
   291  
   292  func (rel *config) DownloadChart(tmpDir string) error {
   293  	if !rel.Chart().IsRemote() {
   294  		rel.Logger().Info("❎ chart is local, skipping exporting")
   295  
   296  		return nil
   297  	}
   298  
   299  	destDir := path.Join(tmpDir, "charts", rel.Uniq().String())
   300  	if err := os.MkdirAll(destDir, 0o750); err != nil {
   301  		return fmt.Errorf("failed to create temporary directory for chart: %w", err)
   302  	}
   303  
   304  	ch, err := rel.LocateChartWithCache()
   305  	if err != nil {
   306  		return err
   307  	}
   308  
   309  	return helper.CopyFile(ch, destDir)
   310  }
   311  
   312  func (rel *config) SetChartName(name string) {
   313  	rel.lock.Lock()
   314  	rel.ChartF.Name = name
   315  	rel.lock.Unlock()
   316  }