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