github.com/oam-dev/kubevela@v1.9.11/pkg/utils/helm/helm_helper.go (about)

     1  /*
     2  Copyright 2021 The KubeVela Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8  	http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  package helm
    18  
    19  import (
    20  	"bytes"
    21  	"context"
    22  	"fmt"
    23  	"io"
    24  	"net/url"
    25  	"os"
    26  	"path"
    27  	"path/filepath"
    28  	"regexp"
    29  	"strings"
    30  	"time"
    31  
    32  	"github.com/pkg/errors"
    33  	"helm.sh/helm/v3/pkg/action"
    34  	"helm.sh/helm/v3/pkg/chart"
    35  	"helm.sh/helm/v3/pkg/chart/loader"
    36  	"helm.sh/helm/v3/pkg/chartutil"
    37  	"helm.sh/helm/v3/pkg/cli"
    38  	"helm.sh/helm/v3/pkg/downloader"
    39  	"helm.sh/helm/v3/pkg/getter"
    40  	"helm.sh/helm/v3/pkg/kube"
    41  	"helm.sh/helm/v3/pkg/release"
    42  	relutil "helm.sh/helm/v3/pkg/releaseutil"
    43  	"helm.sh/helm/v3/pkg/repo"
    44  	"helm.sh/helm/v3/pkg/storage"
    45  	"helm.sh/helm/v3/pkg/storage/driver"
    46  	appsv1 "k8s.io/api/apps/v1"
    47  	crdv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
    48  	kyaml "k8s.io/apimachinery/pkg/util/yaml"
    49  	"k8s.io/client-go/rest"
    50  	k8scmdutil "k8s.io/kubectl/pkg/cmd/util"
    51  	"sigs.k8s.io/yaml"
    52  
    53  	"github.com/oam-dev/kubevela/pkg/utils"
    54  	"github.com/oam-dev/kubevela/pkg/utils/common"
    55  	cmdutil "github.com/oam-dev/kubevela/pkg/utils/util"
    56  )
    57  
    58  const (
    59  	repoPatten   = " repoUrl: %s"
    60  	valuesPatten = "repoUrl: %s, chart: %s, version: %s"
    61  )
    62  
    63  // ChartValues contain all values files in chart and default chart values
    64  type ChartValues struct {
    65  	Data   map[string]string
    66  	Values map[string]interface{}
    67  }
    68  
    69  // Helper provides helper functions for common Helm operations
    70  type Helper struct {
    71  	cache *utils.MemoryCacheStore
    72  }
    73  
    74  // NewHelper creates a Helper
    75  func NewHelper() *Helper {
    76  	return &Helper{}
    77  }
    78  
    79  // NewHelperWithCache creates a Helper with cache usually used by apiserver
    80  func NewHelperWithCache() *Helper {
    81  	return &Helper{
    82  		cache: utils.NewMemoryCacheStore(context.Background()),
    83  	}
    84  }
    85  
    86  // LoadCharts load helm chart from local or remote
    87  func (h *Helper) LoadCharts(chartRepoURL string, opts *common.HTTPOption) (*chart.Chart, error) {
    88  	var err error
    89  	var chart *chart.Chart
    90  	if utils.IsValidURL(chartRepoURL) {
    91  		chartBytes, err := common.HTTPGetWithOption(context.Background(), chartRepoURL, opts)
    92  		if err != nil {
    93  			return nil, errors.New("error retrieving Helm Chart at " + chartRepoURL + ": " + err.Error())
    94  		}
    95  		ch, err := loader.LoadArchive(bytes.NewReader(chartBytes))
    96  		if err != nil {
    97  			return nil, errors.New("error retrieving Helm Chart at " + chartRepoURL + ": " + err.Error())
    98  		}
    99  		return ch, err
   100  	}
   101  	chart, err = loader.Load(chartRepoURL)
   102  	if err != nil {
   103  		return nil, err
   104  	}
   105  	return chart, nil
   106  }
   107  
   108  // UpgradeChartOptions options for upgrade chart
   109  type UpgradeChartOptions struct {
   110  	Config      *rest.Config
   111  	Detail      bool
   112  	Logging     cmdutil.IOStreams
   113  	Wait        bool
   114  	ReuseValues bool
   115  }
   116  
   117  // UpgradeChart install or upgrade helm chart
   118  func (h *Helper) UpgradeChart(ch *chart.Chart, releaseName, namespace string, values map[string]interface{}, config UpgradeChartOptions) (*release.Release, error) {
   119  	if ch == nil || len(ch.Templates) == 0 {
   120  		return nil, fmt.Errorf("empty chart provided for %s", releaseName)
   121  	}
   122  	config.Logging.Infof("Start upgrading Helm Chart %s in namespace %s\n", releaseName, namespace)
   123  
   124  	cfg, err := newActionConfig(config.Config, namespace, config.Detail, config.Logging)
   125  	if err != nil {
   126  		return nil, err
   127  	}
   128  	histClient := action.NewHistory(cfg)
   129  	var newRelease *release.Release
   130  	timeoutInMinutes := 18
   131  	releases, err := histClient.Run(releaseName)
   132  	if err != nil || len(releases) == 0 {
   133  		if errors.Is(err, driver.ErrReleaseNotFound) {
   134  			// fresh install
   135  			install := action.NewInstall(cfg)
   136  			install.Namespace = namespace
   137  			install.ReleaseName = releaseName
   138  			install.Wait = config.Wait
   139  			install.Timeout = time.Duration(timeoutInMinutes) * time.Minute
   140  			newRelease, err = install.Run(ch, values)
   141  		} else {
   142  			return nil, fmt.Errorf("could not retrieve history of releases associated to %s: %w", releaseName, err)
   143  		}
   144  	} else {
   145  		config.Logging.Infof("Found existing installation, overwriting...")
   146  
   147  		// check if the previous installation is still pending (e.g., waiting to complete)
   148  		for _, r := range releases {
   149  			if r.Info.Status == release.StatusPendingInstall || r.Info.Status == release.StatusPendingUpgrade ||
   150  				r.Info.Status == release.StatusPendingRollback {
   151  				return nil, fmt.Errorf("previous installation (e.g., using vela install or helm upgrade) is still in progress. Please try again in %d minutes", timeoutInMinutes)
   152  			}
   153  		}
   154  
   155  		// merge un-existing values into the values as user-input, because the helm chart upgrade didn't handle the new default values in the chart.
   156  		// the new default values <= the old custom values <= the new custom values
   157  		if config.ReuseValues {
   158  			// sort will sort the release by revision from old to new
   159  			relutil.SortByRevision(releases)
   160  			rel := releases[len(releases)-1]
   161  			// merge new values as the user input, follow the new user input for --set
   162  			values = chartutil.CoalesceTables(values, rel.Config)
   163  		}
   164  
   165  		// overwrite existing installation
   166  		install := action.NewUpgrade(cfg)
   167  		install.Namespace = namespace
   168  		install.Wait = config.Wait
   169  		install.Timeout = time.Duration(timeoutInMinutes) * time.Minute
   170  		// use the new default value set.
   171  		install.ReuseValues = false
   172  		newRelease, err = install.Run(releaseName, ch, values)
   173  	}
   174  	// check if install/upgrade worked
   175  	if err != nil {
   176  		return nil, fmt.Errorf("error when installing/upgrading Helm Chart %s in namespace %s: %w",
   177  			releaseName, namespace, err)
   178  	}
   179  	if newRelease == nil {
   180  		return nil, fmt.Errorf("failed to install release %s", releaseName)
   181  	}
   182  	return newRelease, nil
   183  }
   184  
   185  // UninstallRelease uninstalls the provided release
   186  func (h *Helper) UninstallRelease(releaseName, namespace string, config *rest.Config, showDetail bool, logging cmdutil.IOStreams) error {
   187  	cfg, err := newActionConfig(config, namespace, showDetail, logging)
   188  	if err != nil {
   189  		return err
   190  	}
   191  
   192  	iCli := action.NewUninstall(cfg)
   193  	_, err = iCli.Run(releaseName)
   194  
   195  	if err != nil {
   196  		return fmt.Errorf("error when uninstalling Helm release %s in namespace %s: %w",
   197  			releaseName, namespace, err)
   198  	}
   199  	return nil
   200  }
   201  
   202  // ListVersions list available versions from repo
   203  func (h *Helper) ListVersions(repoURL string, chartName string, skipCache bool, opts *common.HTTPOption) (repo.ChartVersions, error) {
   204  	i, err := h.GetIndexInfo(repoURL, skipCache, opts)
   205  	if err != nil {
   206  		return nil, err
   207  	}
   208  	return i.Entries[chartName], nil
   209  }
   210  
   211  // GetIndexInfo get index.yaml form given repo url
   212  func (h *Helper) GetIndexInfo(repoURL string, skipCache bool, opts *common.HTTPOption) (*repo.IndexFile, error) {
   213  	repoURL = utils.Sanitize(repoURL)
   214  	if h.cache != nil && !skipCache {
   215  		if i := h.cache.Get(fmt.Sprintf(repoPatten, repoURL)); i != nil {
   216  			return i.(*repo.IndexFile), nil
   217  		}
   218  	}
   219  	var body []byte
   220  	if utils.IsValidURL(repoURL) {
   221  		indexURL, err := utils.JoinURL(repoURL, "index.yaml")
   222  		if err != nil {
   223  			return nil, err
   224  		}
   225  		body, err = common.HTTPGetWithOption(context.Background(), indexURL, opts)
   226  		if err != nil {
   227  			return nil, fmt.Errorf("download index file from %s failure %w", repoURL, err)
   228  		}
   229  	} else {
   230  		var err error
   231  		body, err = os.ReadFile(path.Join(filepath.Clean(repoURL), "index.yaml"))
   232  		if err != nil {
   233  			return nil, fmt.Errorf("read index file from %s failure %w", repoURL, err)
   234  		}
   235  	}
   236  	i := &repo.IndexFile{}
   237  	if err := yaml.UnmarshalStrict(body, i); err != nil {
   238  		return nil, fmt.Errorf("parse index file from %s failure", repoURL)
   239  	}
   240  
   241  	if h.cache != nil {
   242  		h.cache.Put(fmt.Sprintf(repoPatten, repoURL), i, calculateCacheTimeFromIndex(len(i.Entries)))
   243  	}
   244  	return i, nil
   245  }
   246  
   247  // GetDeploymentsFromManifest get deployment from helm manifest
   248  func GetDeploymentsFromManifest(helmManifest string) []*appsv1.Deployment {
   249  	deployments := []*appsv1.Deployment{}
   250  	dec := kyaml.NewYAMLToJSONDecoder(strings.NewReader(helmManifest))
   251  	for {
   252  		var deployment appsv1.Deployment
   253  		err := dec.Decode(&deployment)
   254  		if errors.Is(err, io.EOF) {
   255  			break
   256  		}
   257  		if err != nil {
   258  			continue
   259  		}
   260  		if strings.EqualFold(deployment.Kind, "deployment") {
   261  			deployments = append(deployments, &deployment)
   262  		}
   263  	}
   264  	return deployments
   265  }
   266  
   267  // GetCRDFromChart get crd from helm chart
   268  func GetCRDFromChart(chart *chart.Chart) []*crdv1.CustomResourceDefinition {
   269  	crds := []*crdv1.CustomResourceDefinition{}
   270  	for _, crdFile := range chart.CRDs() {
   271  		var crd crdv1.CustomResourceDefinition
   272  		err := kyaml.Unmarshal(crdFile.Data, &crd)
   273  		if err != nil {
   274  			continue
   275  		}
   276  		crds = append(crds, &crd)
   277  	}
   278  	return crds
   279  }
   280  
   281  func newActionConfig(config *rest.Config, namespace string, showDetail bool, logging cmdutil.IOStreams) (*action.Configuration, error) {
   282  	restClientGetter := cmdutil.NewRestConfigGetterByConfig(config, namespace)
   283  	log := func(format string, a ...interface{}) {
   284  		if showDetail {
   285  			logging.Infof(format+"\n", a...)
   286  		}
   287  	}
   288  	kubeClient := &kube.Client{
   289  		Factory: k8scmdutil.NewFactory(restClientGetter),
   290  		Log:     log,
   291  	}
   292  	client, err := kubeClient.Factory.KubernetesClientSet()
   293  	if err != nil {
   294  		return nil, err
   295  	}
   296  	s := driver.NewSecrets(client.CoreV1().Secrets(namespace))
   297  	s.Log = log
   298  	return &action.Configuration{
   299  		RESTClientGetter: restClientGetter,
   300  		Releases:         storage.Init(s),
   301  		KubeClient:       kubeClient,
   302  		Log:              log,
   303  	}, nil
   304  }
   305  
   306  // ListChartsFromRepo list available helm charts in a repo
   307  func (h *Helper) ListChartsFromRepo(repoURL string, skipCache bool, opts *common.HTTPOption) ([]string, error) {
   308  	i, err := h.GetIndexInfo(repoURL, skipCache, opts)
   309  	if err != nil {
   310  		return nil, err
   311  	}
   312  	res := make([]string, len(i.Entries))
   313  	j := 0
   314  	for s := range i.Entries {
   315  		res[j] = s
   316  		j++
   317  	}
   318  	return res, nil
   319  }
   320  
   321  // GetValuesFromChart will extract the parameter from a helm chart
   322  func (h *Helper) GetValuesFromChart(repoURL string, chartName string, version string, skipCache bool, repoType string, opts *common.HTTPOption) (*ChartValues, error) {
   323  	if h.cache != nil && !skipCache {
   324  		if v := h.cache.Get(fmt.Sprintf(valuesPatten, repoURL, chartName, version)); v != nil {
   325  			return v.(*ChartValues), nil
   326  		}
   327  	}
   328  	if repoType == "oci" {
   329  		v, err := fetchChartValuesFromOciRepo(repoURL, chartName, version, opts)
   330  		if err != nil {
   331  			return nil, err
   332  		}
   333  		if h.cache != nil {
   334  			h.cache.Put(fmt.Sprintf(valuesPatten, repoURL, chartName, version), v, 20*time.Minute)
   335  		}
   336  		return v, nil
   337  	}
   338  	i, err := h.GetIndexInfo(repoURL, skipCache, opts)
   339  	if err != nil {
   340  		return nil, err
   341  	}
   342  	chartVersions, ok := i.Entries[chartName]
   343  	if !ok {
   344  		return nil, fmt.Errorf("cannot find chart %s in this repo", chartName)
   345  	}
   346  	var urls []string
   347  	for _, chartVersion := range chartVersions {
   348  		if chartVersion.Version == version {
   349  			for _, url := range chartVersion.URLs {
   350  				if !(strings.HasPrefix(url, "https://") || strings.HasPrefix(url, "http://")) {
   351  					urls = append(urls, fmt.Sprintf("%s/%s", repoURL, url))
   352  				} else {
   353  					urls = append(urls, url)
   354  				}
   355  			}
   356  		}
   357  	}
   358  	for _, u := range urls {
   359  		c, err := h.LoadCharts(u, opts)
   360  		if err != nil {
   361  			continue
   362  		}
   363  		v := &ChartValues{
   364  			Data:   loadValuesYamlFile(c),
   365  			Values: c.Values,
   366  		}
   367  		if err != nil {
   368  			return nil, err
   369  		}
   370  		if h.cache != nil {
   371  			h.cache.Put(fmt.Sprintf(valuesPatten, repoURL, chartName, version), v, calculateCacheTimeFromIndex(len(i.Entries)))
   372  		}
   373  		return v, nil
   374  	}
   375  	return nil, fmt.Errorf("cannot load chart from chart repo")
   376  }
   377  
   378  // ValidateRepo will validate the helm repository
   379  func (h *Helper) ValidateRepo(ctx context.Context, repo *Repository) (bool, error) {
   380  	parsedURL, err := url.Parse(repo.URL)
   381  	if err != nil {
   382  		return false, err
   383  	}
   384  	userInfo := parsedURL.User
   385  	if len(repo.Username) > 0 && len(repo.Password) > 0 {
   386  		userInfo = url.UserPassword(repo.Username, repo.Password)
   387  	}
   388  	var cred = &RepoCredential{}
   389  	// TODO: support S3Config validation
   390  	if strings.HasPrefix(repo.URL, "https://") || strings.HasPrefix(repo.URL, "http://") {
   391  		if userInfo != nil {
   392  			cred.Username = userInfo.Username()
   393  			cred.Password, _ = userInfo.Password()
   394  		}
   395  	}
   396  
   397  	_, err = LoadRepoIndex(ctx, repo.URL, cred)
   398  	if err != nil {
   399  		return false, err
   400  	}
   401  	return true, nil
   402  }
   403  
   404  func calculateCacheTimeFromIndex(length int) time.Duration {
   405  	cacheTime := 3 * time.Minute
   406  	if length > 20 {
   407  		// huge helm repo like https://charts.bitnami.com/bitnami have too many(106) charts, generally user cannot modify it.
   408  		// need more cache time
   409  		cacheTime = 1 * time.Hour
   410  	}
   411  	return cacheTime
   412  }
   413  
   414  // nolint
   415  func fetchChartValuesFromOciRepo(repoURL string, chartName string, version string, opts *common.HTTPOption) (*ChartValues, error) {
   416  	d := downloader.ChartDownloader{
   417  		Verify:  downloader.VerifyNever,
   418  		Getters: getter.All(cli.New()),
   419  	}
   420  
   421  	if opts != nil {
   422  		d.Options = append(d.Options, getter.WithInsecureSkipVerifyTLS(opts.InsecureSkipTLS),
   423  			getter.WithTLSClientConfig(opts.CertFile, opts.KeyFile, opts.CaFile),
   424  			getter.WithBasicAuth(opts.Username, opts.Password))
   425  	}
   426  
   427  	var err error
   428  	dest, err := os.MkdirTemp("", "helm-")
   429  	if err != nil {
   430  		return nil, errors.Wrap(err, "failed to fetch values file")
   431  	}
   432  	defer os.RemoveAll(dest)
   433  
   434  	chartRef := fmt.Sprintf("%s/%s", repoURL, chartName)
   435  	saved, _, err := d.DownloadTo(chartRef, version, dest)
   436  	if err != nil {
   437  		return nil, err
   438  	}
   439  	c, err := loader.Load(saved)
   440  	if err != nil {
   441  		return nil, errors.Wrap(err, "failed to fetch values file")
   442  	}
   443  	return &ChartValues{
   444  		Data:   loadValuesYamlFile(c),
   445  		Values: c.Values,
   446  	}, nil
   447  }
   448  
   449  func loadValuesYamlFile(chart *chart.Chart) map[string]string {
   450  	result := map[string]string{}
   451  	re := regexp.MustCompile(`.*yaml$`)
   452  	for _, f := range chart.Raw {
   453  		if re.MatchString(f.Name) && !strings.Contains(f.Name, "/") && f.Name != "Chart.yaml" {
   454  			result[f.Name] = string(f.Data)
   455  		}
   456  	}
   457  	return result
   458  }