github.com/stefanmcshane/helm@v0.0.0-20221213002717-88a4a2c6e77d/cmd/helm/search_repo.go (about)

     1  /*
     2  Copyright The Helm 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 main
    18  
    19  import (
    20  	"bufio"
    21  	"bytes"
    22  	"fmt"
    23  	"io"
    24  	"io/ioutil"
    25  	"os"
    26  	"path/filepath"
    27  	"strings"
    28  
    29  	"github.com/Masterminds/semver/v3"
    30  	"github.com/gosuri/uitable"
    31  	"github.com/pkg/errors"
    32  	"github.com/spf13/cobra"
    33  
    34  	"github.com/stefanmcshane/helm/cmd/helm/search"
    35  	"github.com/stefanmcshane/helm/pkg/cli/output"
    36  	"github.com/stefanmcshane/helm/pkg/helmpath"
    37  	"github.com/stefanmcshane/helm/pkg/repo"
    38  )
    39  
    40  const searchRepoDesc = `
    41  Search reads through all of the repositories configured on the system, and
    42  looks for matches. Search of these repositories uses the metadata stored on
    43  the system.
    44  
    45  It will display the latest stable versions of the charts found. If you
    46  specify the --devel flag, the output will include pre-release versions.
    47  If you want to search using a version constraint, use --version.
    48  
    49  Examples:
    50  
    51      # Search for stable release versions matching the keyword "nginx"
    52      $ helm search repo nginx
    53  
    54      # Search for release versions matching the keyword "nginx", including pre-release versions
    55      $ helm search repo nginx --devel
    56  
    57      # Search for the latest stable release for nginx-ingress with a major version of 1
    58      $ helm search repo nginx-ingress --version ^1.0.0
    59  
    60  Repositories are managed with 'helm repo' commands.
    61  `
    62  
    63  // searchMaxScore suggests that any score higher than this is not considered a match.
    64  const searchMaxScore = 25
    65  
    66  type searchRepoOptions struct {
    67  	versions     bool
    68  	regexp       bool
    69  	devel        bool
    70  	version      string
    71  	maxColWidth  uint
    72  	repoFile     string
    73  	repoCacheDir string
    74  	outputFormat output.Format
    75  }
    76  
    77  func newSearchRepoCmd(out io.Writer) *cobra.Command {
    78  	o := &searchRepoOptions{}
    79  
    80  	cmd := &cobra.Command{
    81  		Use:   "repo [keyword]",
    82  		Short: "search repositories for a keyword in charts",
    83  		Long:  searchRepoDesc,
    84  		RunE: func(cmd *cobra.Command, args []string) error {
    85  			o.repoFile = settings.RepositoryConfig
    86  			o.repoCacheDir = settings.RepositoryCache
    87  			return o.run(out, args)
    88  		},
    89  	}
    90  
    91  	f := cmd.Flags()
    92  	f.BoolVarP(&o.regexp, "regexp", "r", false, "use regular expressions for searching repositories you have added")
    93  	f.BoolVarP(&o.versions, "versions", "l", false, "show the long listing, with each version of each chart on its own line, for repositories you have added")
    94  	f.BoolVar(&o.devel, "devel", false, "use development versions (alpha, beta, and release candidate releases), too. Equivalent to version '>0.0.0-0'. If --version is set, this is ignored")
    95  	f.StringVar(&o.version, "version", "", "search using semantic versioning constraints on repositories you have added")
    96  	f.UintVar(&o.maxColWidth, "max-col-width", 50, "maximum column width for output table")
    97  	bindOutputFlag(cmd, &o.outputFormat)
    98  
    99  	return cmd
   100  }
   101  
   102  func (o *searchRepoOptions) run(out io.Writer, args []string) error {
   103  	o.setupSearchedVersion()
   104  
   105  	index, err := o.buildIndex()
   106  	if err != nil {
   107  		return err
   108  	}
   109  
   110  	var res []*search.Result
   111  	if len(args) == 0 {
   112  		res = index.All()
   113  	} else {
   114  		q := strings.Join(args, " ")
   115  		res, err = index.Search(q, searchMaxScore, o.regexp)
   116  		if err != nil {
   117  			return err
   118  		}
   119  	}
   120  
   121  	search.SortScore(res)
   122  	data, err := o.applyConstraint(res)
   123  	if err != nil {
   124  		return err
   125  	}
   126  
   127  	return o.outputFormat.Write(out, &repoSearchWriter{data, o.maxColWidth})
   128  }
   129  
   130  func (o *searchRepoOptions) setupSearchedVersion() {
   131  	debug("Original chart version: %q", o.version)
   132  
   133  	if o.version != "" {
   134  		return
   135  	}
   136  
   137  	if o.devel { // search for releases and prereleases (alpha, beta, and release candidate releases).
   138  		debug("setting version to >0.0.0-0")
   139  		o.version = ">0.0.0-0"
   140  	} else { // search only for stable releases, prerelease versions will be skip
   141  		debug("setting version to >0.0.0")
   142  		o.version = ">0.0.0"
   143  	}
   144  }
   145  
   146  func (o *searchRepoOptions) applyConstraint(res []*search.Result) ([]*search.Result, error) {
   147  	if o.version == "" {
   148  		return res, nil
   149  	}
   150  
   151  	constraint, err := semver.NewConstraint(o.version)
   152  	if err != nil {
   153  		return res, errors.Wrap(err, "an invalid version/constraint format")
   154  	}
   155  
   156  	data := res[:0]
   157  	foundNames := map[string]bool{}
   158  	for _, r := range res {
   159  		// if not returning all versions and already have found a result,
   160  		// you're done!
   161  		if !o.versions && foundNames[r.Name] {
   162  			continue
   163  		}
   164  		v, err := semver.NewVersion(r.Chart.Version)
   165  		if err != nil {
   166  			continue
   167  		}
   168  		if constraint.Check(v) {
   169  			data = append(data, r)
   170  			foundNames[r.Name] = true
   171  		}
   172  	}
   173  
   174  	return data, nil
   175  }
   176  
   177  func (o *searchRepoOptions) buildIndex() (*search.Index, error) {
   178  	// Load the repositories.yaml
   179  	rf, err := repo.LoadFile(o.repoFile)
   180  	if isNotExist(err) || len(rf.Repositories) == 0 {
   181  		return nil, errors.New("no repositories configured")
   182  	}
   183  
   184  	i := search.NewIndex()
   185  	for _, re := range rf.Repositories {
   186  		n := re.Name
   187  		f := filepath.Join(o.repoCacheDir, helmpath.CacheIndexFile(n))
   188  		ind, err := repo.LoadIndexFile(f)
   189  		if err != nil {
   190  			warning("Repo %q is corrupt or missing. Try 'helm repo update'.", n)
   191  			warning("%s", err)
   192  			continue
   193  		}
   194  
   195  		i.AddRepo(n, ind, o.versions || len(o.version) > 0)
   196  	}
   197  	return i, nil
   198  }
   199  
   200  type repoChartElement struct {
   201  	Name        string `json:"name"`
   202  	Version     string `json:"version"`
   203  	AppVersion  string `json:"app_version"`
   204  	Description string `json:"description"`
   205  }
   206  
   207  type repoSearchWriter struct {
   208  	results     []*search.Result
   209  	columnWidth uint
   210  }
   211  
   212  func (r *repoSearchWriter) WriteTable(out io.Writer) error {
   213  	if len(r.results) == 0 {
   214  		_, err := out.Write([]byte("No results found\n"))
   215  		if err != nil {
   216  			return fmt.Errorf("unable to write results: %s", err)
   217  		}
   218  		return nil
   219  	}
   220  	table := uitable.New()
   221  	table.MaxColWidth = r.columnWidth
   222  	table.AddRow("NAME", "CHART VERSION", "APP VERSION", "DESCRIPTION")
   223  	for _, r := range r.results {
   224  		table.AddRow(r.Name, r.Chart.Version, r.Chart.AppVersion, r.Chart.Description)
   225  	}
   226  	return output.EncodeTable(out, table)
   227  }
   228  
   229  func (r *repoSearchWriter) WriteJSON(out io.Writer) error {
   230  	return r.encodeByFormat(out, output.JSON)
   231  }
   232  
   233  func (r *repoSearchWriter) WriteYAML(out io.Writer) error {
   234  	return r.encodeByFormat(out, output.YAML)
   235  }
   236  
   237  func (r *repoSearchWriter) encodeByFormat(out io.Writer, format output.Format) error {
   238  	// Initialize the array so no results returns an empty array instead of null
   239  	chartList := make([]repoChartElement, 0, len(r.results))
   240  
   241  	for _, r := range r.results {
   242  		chartList = append(chartList, repoChartElement{r.Name, r.Chart.Version, r.Chart.AppVersion, r.Chart.Description})
   243  	}
   244  
   245  	switch format {
   246  	case output.JSON:
   247  		return output.EncodeJSON(out, chartList)
   248  	case output.YAML:
   249  		return output.EncodeYAML(out, chartList)
   250  	}
   251  
   252  	// Because this is a non-exported function and only called internally by
   253  	// WriteJSON and WriteYAML, we shouldn't get invalid types
   254  	return nil
   255  }
   256  
   257  // Provides the list of charts that are part of the specified repo, and that starts with 'prefix'.
   258  func compListChartsOfRepo(repoName string, prefix string) []string {
   259  	var charts []string
   260  
   261  	path := filepath.Join(settings.RepositoryCache, helmpath.CacheChartsFile(repoName))
   262  	content, err := ioutil.ReadFile(path)
   263  	if err == nil {
   264  		scanner := bufio.NewScanner(bytes.NewReader(content))
   265  		for scanner.Scan() {
   266  			fullName := fmt.Sprintf("%s/%s", repoName, scanner.Text())
   267  			if strings.HasPrefix(fullName, prefix) {
   268  				charts = append(charts, fullName)
   269  			}
   270  		}
   271  		return charts
   272  	}
   273  
   274  	if isNotExist(err) {
   275  		// If there is no cached charts file, fallback to the full index file.
   276  		// This is much slower but can happen after the caching feature is first
   277  		// installed but before the user  does a 'helm repo update' to generate the
   278  		// first cached charts file.
   279  		path = filepath.Join(settings.RepositoryCache, helmpath.CacheIndexFile(repoName))
   280  		if indexFile, err := repo.LoadIndexFile(path); err == nil {
   281  			for name := range indexFile.Entries {
   282  				fullName := fmt.Sprintf("%s/%s", repoName, name)
   283  				if strings.HasPrefix(fullName, prefix) {
   284  					charts = append(charts, fullName)
   285  				}
   286  			}
   287  			return charts
   288  		}
   289  	}
   290  
   291  	return []string{}
   292  }
   293  
   294  // Provide dynamic auto-completion for commands that operate on charts (e.g., helm show)
   295  // When true, the includeFiles argument indicates that completion should include local files (e.g., local charts)
   296  func compListCharts(toComplete string, includeFiles bool) ([]string, cobra.ShellCompDirective) {
   297  	cobra.CompDebugln(fmt.Sprintf("compListCharts with toComplete %s", toComplete), settings.Debug)
   298  
   299  	noSpace := false
   300  	noFile := false
   301  	var completions []string
   302  
   303  	// First check completions for repos
   304  	repos := compListRepos("", nil)
   305  	for _, repoInfo := range repos {
   306  		// Split name from description
   307  		repoInfo := strings.Split(repoInfo, "\t")
   308  		repo := repoInfo[0]
   309  		repoDesc := ""
   310  		if len(repoInfo) > 1 {
   311  			repoDesc = repoInfo[1]
   312  		}
   313  		repoWithSlash := fmt.Sprintf("%s/", repo)
   314  		if strings.HasPrefix(toComplete, repoWithSlash) {
   315  			// Must complete with charts within the specified repo.
   316  			// Don't filter on toComplete to allow for shell fuzzy matching
   317  			completions = append(completions, compListChartsOfRepo(repo, "")...)
   318  			noSpace = false
   319  			break
   320  		} else if strings.HasPrefix(repo, toComplete) {
   321  			// Must complete the repo name with the slash, followed by the description
   322  			completions = append(completions, fmt.Sprintf("%s\t%s", repoWithSlash, repoDesc))
   323  			noSpace = true
   324  		}
   325  	}
   326  	cobra.CompDebugln(fmt.Sprintf("Completions after repos: %v", completions), settings.Debug)
   327  
   328  	// Now handle completions for url prefixes
   329  	for _, url := range []string{"oci://\tChart OCI prefix", "https://\tChart URL prefix", "http://\tChart URL prefix", "file://\tChart local URL prefix"} {
   330  		if strings.HasPrefix(toComplete, url) {
   331  			// The user already put in the full url prefix; we don't have
   332  			// anything to add, but make sure the shell does not default
   333  			// to file completion since we could be returning an empty array.
   334  			noFile = true
   335  			noSpace = true
   336  		} else if strings.HasPrefix(url, toComplete) {
   337  			// We are completing a url prefix
   338  			completions = append(completions, url)
   339  			noSpace = true
   340  		}
   341  	}
   342  	cobra.CompDebugln(fmt.Sprintf("Completions after urls: %v", completions), settings.Debug)
   343  
   344  	// Finally, provide file completion if we need to.
   345  	// We only do this if:
   346  	// 1- There are other completions found (if there are no completions,
   347  	//    the shell will do file completion itself)
   348  	// 2- If there is some input from the user (or else we will end up
   349  	//    listing the entire content of the current directory which will
   350  	//    be too many choices for the user to find the real repos)
   351  	if includeFiles && len(completions) > 0 && len(toComplete) > 0 {
   352  		if files, err := os.ReadDir("."); err == nil {
   353  			for _, file := range files {
   354  				if strings.HasPrefix(file.Name(), toComplete) {
   355  					// We are completing a file prefix
   356  					completions = append(completions, file.Name())
   357  				}
   358  			}
   359  		}
   360  	}
   361  	cobra.CompDebugln(fmt.Sprintf("Completions after files: %v", completions), settings.Debug)
   362  
   363  	// If the user didn't provide any input to completion,
   364  	// we provide a hint that a path can also be used
   365  	if includeFiles && len(toComplete) == 0 {
   366  		completions = append(completions, "./\tRelative path prefix to local chart", "/\tAbsolute path prefix to local chart")
   367  	}
   368  	cobra.CompDebugln(fmt.Sprintf("Completions after checking empty input: %v", completions), settings.Debug)
   369  
   370  	directive := cobra.ShellCompDirectiveDefault
   371  	if noFile {
   372  		directive = directive | cobra.ShellCompDirectiveNoFileComp
   373  	}
   374  	if noSpace {
   375  		directive = directive | cobra.ShellCompDirectiveNoSpace
   376  	}
   377  	if !includeFiles {
   378  		// If we should not include files in the completions,
   379  		// we should disable file completion
   380  		directive = directive | cobra.ShellCompDirectiveNoFileComp
   381  	}
   382  	return completions, directive
   383  }