github.com/Racer159/jackal@v0.32.7-0.20240401174413-0bd2339e4f2e/src/internal/packager/helm/repo.go (about)

     1  // SPDX-License-Identifier: Apache-2.0
     2  // SPDX-FileCopyrightText: 2021-Present The Jackal Authors
     3  
     4  // Package helm contains operations for working with helm charts.
     5  package helm
     6  
     7  import (
     8  	"fmt"
     9  	"os"
    10  	"path/filepath"
    11  	"strings"
    12  
    13  	"github.com/Racer159/jackal/src/config"
    14  	"github.com/Racer159/jackal/src/config/lang"
    15  	"github.com/Racer159/jackal/src/internal/packager/git"
    16  	"github.com/Racer159/jackal/src/pkg/message"
    17  	"github.com/Racer159/jackal/src/pkg/transform"
    18  	"github.com/Racer159/jackal/src/pkg/utils"
    19  	"github.com/Racer159/jackal/src/types"
    20  	"github.com/defenseunicorns/pkg/helpers"
    21  	"helm.sh/helm/v3/pkg/action"
    22  	"helm.sh/helm/v3/pkg/chart"
    23  	"helm.sh/helm/v3/pkg/cli"
    24  	"helm.sh/helm/v3/pkg/helmpath"
    25  	"helm.sh/helm/v3/pkg/registry"
    26  	"k8s.io/client-go/util/homedir"
    27  
    28  	"helm.sh/helm/v3/pkg/chart/loader"
    29  	"helm.sh/helm/v3/pkg/downloader"
    30  	"helm.sh/helm/v3/pkg/getter"
    31  	"helm.sh/helm/v3/pkg/repo"
    32  )
    33  
    34  // PackageChart creates a chart archive from a path to a chart on the host os and builds chart dependencies
    35  func (h *Helm) PackageChart(cosignKeyPath string) error {
    36  	if len(h.chart.URL) > 0 {
    37  		url, refPlain, err := transform.GitURLSplitRef(h.chart.URL)
    38  		// check if the chart is a git url with a ref (if an error is returned url will be empty)
    39  		isGitURL := strings.HasSuffix(url, ".git")
    40  		if err != nil {
    41  			message.Debugf("unable to parse the url, continuing with %s", h.chart.URL)
    42  		}
    43  
    44  		if isGitURL {
    45  			// if it is a git url append chart version as if its a tag
    46  			if refPlain == "" {
    47  				h.chart.URL = fmt.Sprintf("%s@%s", h.chart.URL, h.chart.Version)
    48  			}
    49  
    50  			err = h.PackageChartFromGit(cosignKeyPath)
    51  			if err != nil {
    52  				return fmt.Errorf("unable to pull the chart %q from git: %w", h.chart.Name, err)
    53  			}
    54  		} else {
    55  			err = h.DownloadPublishedChart(cosignKeyPath)
    56  			if err != nil {
    57  				return fmt.Errorf("unable to download the published chart %q: %w", h.chart.Name, err)
    58  			}
    59  		}
    60  
    61  	} else {
    62  		err := h.PackageChartFromLocalFiles(cosignKeyPath)
    63  		if err != nil {
    64  			return fmt.Errorf("unable to package the %q chart: %w", h.chart.Name, err)
    65  		}
    66  	}
    67  	return nil
    68  }
    69  
    70  // PackageChartFromLocalFiles creates a chart archive from a path to a chart on the host os.
    71  func (h *Helm) PackageChartFromLocalFiles(cosignKeyPath string) error {
    72  	spinner := message.NewProgressSpinner("Processing helm chart %s:%s from %s", h.chart.Name, h.chart.Version, h.chart.LocalPath)
    73  	defer spinner.Stop()
    74  
    75  	// Load and validate the chart
    76  	cl, _, err := h.loadAndValidateChart(h.chart.LocalPath)
    77  	if err != nil {
    78  		return err
    79  	}
    80  
    81  	// Handle the chart directory or tarball
    82  	var saved string
    83  	temp := filepath.Join(h.chartPath, "temp")
    84  	if _, ok := cl.(loader.DirLoader); ok {
    85  		err = h.buildChartDependencies(spinner)
    86  		if err != nil {
    87  			return fmt.Errorf("unable to build dependencies for the chart: %w", err)
    88  		}
    89  
    90  		client := action.NewPackage()
    91  
    92  		client.Destination = temp
    93  		saved, err = client.Run(h.chart.LocalPath, nil)
    94  	} else {
    95  		saved = filepath.Join(temp, filepath.Base(h.chart.LocalPath))
    96  		err = helpers.CreatePathAndCopy(h.chart.LocalPath, saved)
    97  	}
    98  	defer os.RemoveAll(temp)
    99  
   100  	if err != nil {
   101  		return fmt.Errorf("unable to save the archive and create the package %s: %w", saved, err)
   102  	}
   103  
   104  	// Finalize the chart
   105  	err = h.finalizeChartPackage(saved, cosignKeyPath)
   106  	if err != nil {
   107  		return err
   108  	}
   109  
   110  	spinner.Success()
   111  
   112  	return nil
   113  }
   114  
   115  // PackageChartFromGit is a special implementation of chart archiving that supports the https://p1.dso.mil/#/products/big-bang/ model.
   116  func (h *Helm) PackageChartFromGit(cosignKeyPath string) error {
   117  	spinner := message.NewProgressSpinner("Processing helm chart %s", h.chart.Name)
   118  	defer spinner.Stop()
   119  
   120  	// Retrieve the repo containing the chart
   121  	gitPath, err := DownloadChartFromGitToTemp(h.chart.URL, spinner)
   122  	if err != nil {
   123  		return err
   124  	}
   125  	defer os.RemoveAll(gitPath)
   126  
   127  	// Set the directory for the chart and package it
   128  	h.chart.LocalPath = filepath.Join(gitPath, h.chart.GitPath)
   129  	return h.PackageChartFromLocalFiles(cosignKeyPath)
   130  }
   131  
   132  // DownloadPublishedChart loads a specific chart version from a remote repo.
   133  func (h *Helm) DownloadPublishedChart(cosignKeyPath string) error {
   134  	spinner := message.NewProgressSpinner("Processing helm chart %s:%s from repo %s", h.chart.Name, h.chart.Version, h.chart.URL)
   135  	defer spinner.Stop()
   136  
   137  	// Set up the helm pull config
   138  	pull := action.NewPull()
   139  	pull.Settings = cli.New()
   140  
   141  	var (
   142  		regClient *registry.Client
   143  		chartURL  string
   144  		err       error
   145  	)
   146  	repoFile, err := repo.LoadFile(pull.Settings.RepositoryConfig)
   147  
   148  	// Not returning the error here since the repo file is only needed if we are pulling from a repo that requires authentication
   149  	if err != nil {
   150  		message.Debugf("Unable to load the repo file at %q: %s", pull.Settings.RepositoryConfig, err.Error())
   151  	}
   152  
   153  	var username string
   154  	var password string
   155  
   156  	// Handle OCI registries
   157  	if registry.IsOCI(h.chart.URL) {
   158  		regClient, err = registry.NewClient(registry.ClientOptEnableCache(true))
   159  		if err != nil {
   160  			spinner.Fatalf(err, "Unable to create a new registry client")
   161  		}
   162  		chartURL = h.chart.URL
   163  		// Explicitly set the pull version for OCI
   164  		pull.Version = h.chart.Version
   165  	} else {
   166  		chartName := h.chart.Name
   167  		if h.chart.RepoName != "" {
   168  			chartName = h.chart.RepoName
   169  		}
   170  
   171  		if repoFile != nil {
   172  			// TODO: @AustinAbro321 Currently this selects the last repo with the same url
   173  			// We should introduce a new field in jackal to allow users to specify the local repo they want
   174  			for _, repo := range repoFile.Repositories {
   175  				if repo.URL == h.chart.URL {
   176  					username = repo.Username
   177  					password = repo.Password
   178  				}
   179  			}
   180  		}
   181  
   182  		chartURL, err = repo.FindChartInAuthRepoURL(h.chart.URL, username, password, chartName, h.chart.Version, pull.CertFile, pull.KeyFile, pull.CaFile, getter.All(pull.Settings))
   183  		if err != nil {
   184  			if strings.Contains(err.Error(), "not found") {
   185  				// Intentionally dogsled this error since this is just a nice to have helper
   186  				_ = h.listAvailableChartsAndVersions(pull)
   187  			}
   188  			return fmt.Errorf("unable to pull the helm chart: %w", err)
   189  		}
   190  	}
   191  
   192  	// Set up the chart chartDownloader
   193  	chartDownloader := downloader.ChartDownloader{
   194  		Out:            spinner,
   195  		RegistryClient: regClient,
   196  		// TODO: Further research this with regular/OCI charts
   197  		Verify:  downloader.VerifyNever,
   198  		Getters: getter.All(pull.Settings),
   199  		Options: []getter.Option{
   200  			getter.WithInsecureSkipVerifyTLS(config.CommonOptions.Insecure),
   201  			getter.WithBasicAuth(username, password),
   202  		},
   203  	}
   204  
   205  	// Download the file into a temp directory since we don't control what name helm creates here
   206  	temp := filepath.Join(h.chartPath, "temp")
   207  	if err = helpers.CreateDirectory(temp, helpers.ReadWriteExecuteUser); err != nil {
   208  		return fmt.Errorf("unable to create helm chart temp directory: %w", err)
   209  	}
   210  	defer os.RemoveAll(temp)
   211  
   212  	saved, _, err := chartDownloader.DownloadTo(chartURL, pull.Version, temp)
   213  	if err != nil {
   214  		return fmt.Errorf("unable to download the helm chart: %w", err)
   215  	}
   216  
   217  	// Validate the chart
   218  	_, _, err = h.loadAndValidateChart(saved)
   219  	if err != nil {
   220  		return err
   221  	}
   222  
   223  	// Finalize the chart
   224  	err = h.finalizeChartPackage(saved, cosignKeyPath)
   225  	if err != nil {
   226  		return err
   227  	}
   228  
   229  	spinner.Success()
   230  
   231  	return nil
   232  }
   233  
   234  // DownloadChartFromGitToTemp downloads a chart from git into a temp directory
   235  func DownloadChartFromGitToTemp(url string, spinner *message.Spinner) (string, error) {
   236  	// Create the Git configuration and download the repo
   237  	gitCfg := git.NewWithSpinner(types.GitServerInfo{}, spinner)
   238  
   239  	// Download the git repo to a temporary directory
   240  	err := gitCfg.DownloadRepoToTemp(url)
   241  	if err != nil {
   242  		return "", fmt.Errorf("unable to download the git repo %s: %w", url, err)
   243  	}
   244  
   245  	return gitCfg.GitPath, nil
   246  }
   247  
   248  func (h *Helm) finalizeChartPackage(saved, cosignKeyPath string) error {
   249  	// Ensure the name is consistent for deployments
   250  	destinationTarball := StandardName(h.chartPath, h.chart) + ".tgz"
   251  	err := os.Rename(saved, destinationTarball)
   252  	if err != nil {
   253  		return fmt.Errorf("unable to save the final chart tarball: %w", err)
   254  	}
   255  
   256  	err = h.packageValues(cosignKeyPath)
   257  	if err != nil {
   258  		return fmt.Errorf("unable to process the values for the package: %w", err)
   259  	}
   260  	return nil
   261  }
   262  
   263  func (h *Helm) packageValues(cosignKeyPath string) error {
   264  	for valuesIdx, path := range h.chart.ValuesFiles {
   265  		dst := StandardValuesName(h.valuesPath, h.chart, valuesIdx)
   266  
   267  		if helpers.IsURL(path) {
   268  			if err := utils.DownloadToFile(path, dst, cosignKeyPath); err != nil {
   269  				return fmt.Errorf(lang.ErrDownloading, path, err.Error())
   270  			}
   271  		} else {
   272  			if err := helpers.CreatePathAndCopy(path, dst); err != nil {
   273  				return fmt.Errorf("unable to copy chart values file %s: %w", path, err)
   274  			}
   275  		}
   276  	}
   277  
   278  	return nil
   279  }
   280  
   281  // buildChartDependencies builds the helm chart dependencies
   282  func (h *Helm) buildChartDependencies(spinner *message.Spinner) error {
   283  	// Download and build the specified dependencies
   284  	regClient, err := registry.NewClient(registry.ClientOptEnableCache(true))
   285  	if err != nil {
   286  		spinner.Fatalf(err, "Unable to create a new registry client")
   287  	}
   288  
   289  	h.settings = cli.New()
   290  	defaultKeyring := filepath.Join(homedir.HomeDir(), ".gnupg", "pubring.gpg")
   291  	if v, ok := os.LookupEnv("GNUPGHOME"); ok {
   292  		defaultKeyring = filepath.Join(v, "pubring.gpg")
   293  	}
   294  
   295  	man := &downloader.Manager{
   296  		Out:            &message.DebugWriter{},
   297  		ChartPath:      h.chart.LocalPath,
   298  		Getters:        getter.All(h.settings),
   299  		RegistryClient: regClient,
   300  
   301  		RepositoryConfig: h.settings.RepositoryConfig,
   302  		RepositoryCache:  h.settings.RepositoryCache,
   303  		Debug:            false,
   304  		Verify:           downloader.VerifyIfPossible,
   305  		Keyring:          defaultKeyring,
   306  	}
   307  
   308  	// Build the deps from the helm chart
   309  	err = man.Build()
   310  	if e, ok := err.(downloader.ErrRepoNotFound); ok {
   311  		// If we encounter a repo not found error point the user to `jackal tools helm repo add`
   312  		message.Warnf("%s. Please add the missing repo(s) via the following:", e.Error())
   313  		for _, repository := range e.Repos {
   314  			message.JackalCommand(fmt.Sprintf("tools helm repo add <your-repo-name> %s", repository))
   315  		}
   316  	} else if err != nil {
   317  		// Warn the user of any issues but don't fail - any actual issues will cause a fail during packaging (e.g. the charts we are building may exist already, we just can't get updates)
   318  		message.JackalCommand("tools helm dependency build --verify")
   319  		message.Warnf("Unable to perform a rebuild of Helm dependencies: %s", err.Error())
   320  	}
   321  
   322  	return nil
   323  }
   324  
   325  func (h *Helm) loadAndValidateChart(location string) (loader.ChartLoader, *chart.Chart, error) {
   326  	// Validate the chart
   327  	cl, err := loader.Loader(location)
   328  	if err != nil {
   329  		return cl, nil, fmt.Errorf("unable to load the chart from %s: %w", location, err)
   330  	}
   331  
   332  	chart, err := cl.Load()
   333  	if err != nil {
   334  		return cl, chart, fmt.Errorf("validation failed for chart from %s: %w", location, err)
   335  	}
   336  
   337  	return cl, chart, nil
   338  }
   339  
   340  func (h *Helm) listAvailableChartsAndVersions(pull *action.Pull) error {
   341  	c := repo.Entry{
   342  		URL:      h.chart.URL,
   343  		CertFile: pull.CertFile,
   344  		KeyFile:  pull.KeyFile,
   345  		CAFile:   pull.CaFile,
   346  		Name:     h.chart.Name,
   347  	}
   348  
   349  	r, err := repo.NewChartRepository(&c, getter.All(pull.Settings))
   350  	if err != nil {
   351  		return err
   352  	}
   353  	idx, err := r.DownloadIndexFile()
   354  	if err != nil {
   355  		return fmt.Errorf("looks like %q is not a valid chart repository or cannot be reached: %w", h.chart.URL, err)
   356  	}
   357  	defer func() {
   358  		os.RemoveAll(filepath.Join(r.CachePath, helmpath.CacheChartsFile(r.Config.Name)))
   359  		os.RemoveAll(filepath.Join(r.CachePath, helmpath.CacheIndexFile(r.Config.Name)))
   360  	}()
   361  
   362  	// Read the index file for the repository to get chart information and return chart URL
   363  	repoIndex, err := repo.LoadIndexFile(idx)
   364  	if err != nil {
   365  		return err
   366  	}
   367  
   368  	chartData := [][]string{}
   369  	for name, entries := range repoIndex.Entries {
   370  		versions := ""
   371  		for idx, entry := range entries {
   372  			separator := ""
   373  			if idx < len(entries)-1 {
   374  				separator = ", "
   375  			}
   376  			versions += entry.Version + separator
   377  		}
   378  
   379  		versions = message.Truncate(versions, 75, false)
   380  		chartData = append(chartData, []string{name, versions})
   381  	}
   382  
   383  	message.Notef("Available charts and versions from %q:", h.chart.URL)
   384  
   385  	// Print out the table for the user
   386  	header := []string{"Chart", "Versions"}
   387  	message.Table(header, chartData)
   388  	return nil
   389  }