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 }