From 90d2bac80cbdcdb68d2d4b1436d063d22f5802df Mon Sep 17 00:00:00 2001 From: Matt Farina Date: Tue, 13 Aug 2019 13:24:07 -0400 Subject: [PATCH] Breaking up the search command into multiple commands based on type Signed-off-by: Matt Farina --- cmd/helm/search.go | 194 +----------------- cmd/helm/search_hub.go | 102 +++++++++ .../{search_test.go => search_hub_test.go} | 58 +----- cmd/helm/search_repo.go | 163 +++++++++++++++ cmd/helm/search_repo_test.go | 75 +++++++ 5 files changed, 347 insertions(+), 245 deletions(-) create mode 100644 cmd/helm/search_hub.go rename cmd/helm/{search_test.go => search_hub_test.go} (62%) create mode 100644 cmd/helm/search_repo.go create mode 100644 cmd/helm/search_repo_test.go diff --git a/cmd/helm/search.go b/cmd/helm/search.go index d07016a43..240d5e7c7 100644 --- a/cmd/helm/search.go +++ b/cmd/helm/search.go @@ -17,211 +17,27 @@ limitations under the License. package main import ( - "fmt" "io" - "strings" - "github.com/Masterminds/semver" - "github.com/gosuri/uitable" - "github.com/pkg/errors" "github.com/spf13/cobra" - - "helm.sh/helm/cmd/helm/search" - "helm.sh/helm/internal/monocular" - "helm.sh/helm/pkg/helmpath" - "helm.sh/helm/pkg/repo" ) const searchDesc = ` -Search reads through all of the repositories configured on the system, and -looks for matches. - -Repositories are managed with 'helm repo' commands. +Search provides the ability to search for Helm charts in the various places +they can be stored including the Helm Hub and repositories you have added. Use +search subcommands to search different locations for charts. ` -// searchMaxScore suggests that any score higher than this is not considered a match. -const searchMaxScore = 25 - -type searchOptions struct { - versions bool - regexp bool - version string - searchEndpoint string - repositories bool - maxColWidth uint -} - func newSearchCmd(out io.Writer) *cobra.Command { - o := &searchOptions{} cmd := &cobra.Command{ Use: "search [keyword]", Short: "search for a keyword in charts", Long: searchDesc, - RunE: func(cmd *cobra.Command, args []string) error { - return o.run(out, args) - }, } - f := cmd.Flags() - f.StringVar(&o.searchEndpoint, "endpoint", "https://hub.helm.sh", "monocular instance to query for charts") - f.BoolVarP(&o.repositories, "repositories", "r", false, "search repositories you have added instead of monocular") - f.BoolVarP(&o.regexp, "regexp", "", false, "use regular expressions for searching repositories you have added") - 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") - f.StringVar(&o.version, "version", "", "search using semantic versioning constraints on repositories you have added") - f.UintVar(&o.maxColWidth, "maxColumnWidth", 50, "maximum column width for output table") + cmd.AddCommand(newSearchHubCmd(out)) + cmd.AddCommand(newSearchRepoCmd(out)) return cmd } - -func (o *searchOptions) run(out io.Writer, args []string) error { - - // If searching the repositories added via helm repo add follow that path - if o.repositories { - debug("searching repositories") - // If an endpoint was passed in but searching repositories the user should - // know the option is being skipped - if o.searchEndpoint != "https://hub.helm.sh" { - fmt.Fprintln(out, "Notice: Setting the \"endpoint\" flag has no effect when searching repositories you have added") - } - - return o.runRepositories(out, args) - } - - // Search the Helm Hub or other monocular instance - debug("searching monocular") - // If an an option used against repository searches is used the user should - // know the option is being skipped - if o.regexp { - fmt.Fprintln(out, "Notice: Setting the \"regexp\" flag has no effect when searching monocular (e.g., Helm Hub)") - } - if o.versions { - fmt.Fprintln(out, "Notice: Setting the \"versions\" flag has no effect when searching monocular (e.g., Helm Hub)") - } - if o.version != "" { - fmt.Fprintln(out, "Notice: Setting the \"version\" flag has no effect when searching monocular (e.g., Helm Hub)") - } - - c, err := monocular.New(o.searchEndpoint) - if err != nil { - return errors.Wrap(err, fmt.Sprintf("unable to create connection to %q", o.searchEndpoint)) - } - - q := strings.Join(args, " ") - results, err := c.Search(q) - if err != nil { - debug("%s", err) - return fmt.Errorf("unable to perform search against %q", o.searchEndpoint) - } - - fmt.Fprintln(out, o.formatSearchResults(o.searchEndpoint, results)) - - return nil -} - -func (o *searchOptions) runRepositories(out io.Writer, args []string) error { - index, err := o.buildIndex(out) - if err != nil { - return err - } - - var res []*search.Result - if len(args) == 0 { - res = index.All() - } else { - q := strings.Join(args, " ") - res, err = index.Search(q, searchMaxScore, o.regexp) - if err != nil { - return err - } - } - - search.SortScore(res) - data, err := o.applyConstraint(res) - if err != nil { - return err - } - - fmt.Fprintln(out, o.formatRepoSearchResults(data)) - - return nil -} - -func (o *searchOptions) applyConstraint(res []*search.Result) ([]*search.Result, error) { - if len(o.version) == 0 { - return res, nil - } - - constraint, err := semver.NewConstraint(o.version) - if err != nil { - return res, errors.Wrap(err, "an invalid version/constraint format") - } - - data := res[:0] - foundNames := map[string]bool{} - for _, r := range res { - if _, found := foundNames[r.Name]; found { - continue - } - v, err := semver.NewVersion(r.Chart.Version) - if err != nil || constraint.Check(v) { - data = append(data, r) - if !o.versions { - foundNames[r.Name] = true // If user hasn't requested all versions, only show the latest that matches - } - } - } - - return data, nil -} - -func (o *searchOptions) formatSearchResults(endpoint string, res []monocular.SearchResult) string { - if len(res) == 0 { - return "No results found" - } - table := uitable.New() - table.MaxColWidth = o.maxColWidth - table.AddRow("URL", "CHART VERSION", "APP VERSION", "DESCRIPTION") - var url string - for _, r := range res { - url = endpoint + "/charts/" + r.ID - table.AddRow(url, r.Relationships.LatestChartVersion.Data.Version, r.Relationships.LatestChartVersion.Data.AppVersion, r.Attributes.Description) - } - return table.String() -} - -func (o *searchOptions) formatRepoSearchResults(res []*search.Result) string { - if len(res) == 0 { - return "No results found" - } - table := uitable.New() - table.MaxColWidth = o.maxColWidth - table.AddRow("NAME", "CHART VERSION", "APP VERSION", "DESCRIPTION") - for _, r := range res { - table.AddRow(r.Name, r.Chart.Version, r.Chart.AppVersion, r.Chart.Description) - } - return table.String() -} - -func (o *searchOptions) buildIndex(out io.Writer) (*search.Index, error) { - // Load the repositories.yaml - rf, err := repo.LoadFile(helmpath.RepositoryFile()) - if err != nil { - return nil, err - } - - i := search.NewIndex() - for _, re := range rf.Repositories { - n := re.Name - f := helmpath.CacheIndex(n) - ind, err := repo.LoadIndexFile(f) - if err != nil { - // TODO should print to stderr - fmt.Fprintf(out, "WARNING: Repo %q is corrupt or missing. Try 'helm repo update'.", n) - continue - } - - i.AddRepo(n, ind, o.versions || len(o.version) > 0) - } - return i, nil -} diff --git a/cmd/helm/search_hub.go b/cmd/helm/search_hub.go new file mode 100644 index 000000000..92e7f76df --- /dev/null +++ b/cmd/helm/search_hub.go @@ -0,0 +1,102 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "fmt" + "io" + "strings" + + "github.com/gosuri/uitable" + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "helm.sh/helm/internal/monocular" +) + +const searchHubDesc = ` +Search the Helm Hub or an instance of Monocular for Helm charts. + +The Helm Hub provides a centralized search for publicly available distributed +charts. It is maintained by the Helm project. It can be visited at +https://hub.helm.sh + +Monocular is a web-based application that enables the search and discovery of +charts from multiple Helm Chart repositories. It is the codebase that powers the +Helm Hub. You can find it at https://github.com/helm/monocular +` + +type searchHubOptions struct { + searchEndpoint string + maxColWidth uint +} + +func newSearchHubCmd(out io.Writer) *cobra.Command { + o := &searchHubOptions{} + + cmd := &cobra.Command{ + Use: "hub [keyword]", + Short: "search for a keyword in charts", + Long: searchHubDesc, + RunE: func(cmd *cobra.Command, args []string) error { + return o.run(out, args) + }, + } + + f := cmd.Flags() + f.StringVar(&o.searchEndpoint, "endpoint", "https://hub.helm.sh", "monocular instance to query for charts") + f.UintVar(&o.maxColWidth, "maxColumnWidth", 50, "maximum column width for output table") + + return cmd +} + +func (o *searchHubOptions) run(out io.Writer, args []string) error { + + c, err := monocular.New(o.searchEndpoint) + if err != nil { + return errors.Wrap(err, fmt.Sprintf("unable to create connection to %q", o.searchEndpoint)) + } + + q := strings.Join(args, " ") + results, err := c.Search(q) + if err != nil { + debug("%s", err) + return fmt.Errorf("unable to perform search against %q", o.searchEndpoint) + } + + fmt.Fprintln(out, o.formatSearchResults(o.searchEndpoint, results)) + + return nil +} + +func (o *searchHubOptions) formatSearchResults(endpoint string, res []monocular.SearchResult) string { + if len(res) == 0 { + return "No results found" + } + table := uitable.New() + + // The max column width is configurable because a URL could be longer than the + // max value and we want the user to have the ability to display the whole url + table.MaxColWidth = o.maxColWidth + table.AddRow("URL", "CHART VERSION", "APP VERSION", "DESCRIPTION") + var url string + for _, r := range res { + url = endpoint + "/charts/" + r.ID + table.AddRow(url, r.Relationships.LatestChartVersion.Data.Version, r.Relationships.LatestChartVersion.Data.AppVersion, r.Attributes.Description) + } + return table.String() +} diff --git a/cmd/helm/search_test.go b/cmd/helm/search_hub_test.go similarity index 62% rename from cmd/helm/search_test.go rename to cmd/helm/search_hub_test.go index b804fc1d0..dfe0cacc2 100644 --- a/cmd/helm/search_test.go +++ b/cmd/helm/search_hub_test.go @@ -20,13 +20,10 @@ import ( "fmt" "net/http" "net/http/httptest" - "os" "testing" - - "helm.sh/helm/pkg/helmpath/xdg" ) -func TestSearchMonocularCmd(t *testing.T) { +func TestSearchHubCmd(t *testing.T) { // Setup a mock search service var searchResult = `{"data":[{"id":"stable/phpmyadmin","type":"chart","attributes":{"name":"phpmyadmin","repo":{"name":"stable","url":"https://kubernetes-charts.storage.googleapis.com"},"description":"phpMyAdmin is an mysql administration frontend","home":"https://www.phpmyadmin.net/","keywords":["mariadb","mysql","phpmyadmin"],"maintainers":[{"name":"Bitnami","email":"containers@bitnami.com"}],"sources":["https://github.com/bitnami/bitnami-docker-phpmyadmin"],"icon":""},"links":{"self":"/v1/charts/stable/phpmyadmin"},"relationships":{"latestChartVersion":{"data":{"version":"3.0.0","app_version":"4.9.0-1","created":"2019-08-08T17:57:31.38Z","digest":"119c499251bffd4b06ff0cd5ac98c2ce32231f84899fb4825be6c2d90971c742","urls":["https://kubernetes-charts.storage.googleapis.com/phpmyadmin-3.0.0.tgz"],"readme":"/v1/assets/stable/phpmyadmin/versions/3.0.0/README.md","values":"/v1/assets/stable/phpmyadmin/versions/3.0.0/values.yaml"},"links":{"self":"/v1/charts/stable/phpmyadmin/versions/3.0.0"}}}},{"id":"bitnami/phpmyadmin","type":"chart","attributes":{"name":"phpmyadmin","repo":{"name":"bitnami","url":"https://charts.bitnami.com"},"description":"phpMyAdmin is an mysql administration frontend","home":"https://www.phpmyadmin.net/","keywords":["mariadb","mysql","phpmyadmin"],"maintainers":[{"name":"Bitnami","email":"containers@bitnami.com"}],"sources":["https://github.com/bitnami/bitnami-docker-phpmyadmin"],"icon":""},"links":{"self":"/v1/charts/bitnami/phpmyadmin"},"relationships":{"latestChartVersion":{"data":{"version":"3.0.0","app_version":"4.9.0-1","created":"2019-08-08T18:34:13.341Z","digest":"66d77cf6d8c2b52c488d0a294cd4996bd5bad8dc41d3829c394498fb401c008a","urls":["https://charts.bitnami.com/bitnami/phpmyadmin-3.0.0.tgz"],"readme":"/v1/assets/bitnami/phpmyadmin/versions/3.0.0/README.md","values":"/v1/assets/bitnami/phpmyadmin/versions/3.0.0/values.yaml"},"links":{"self":"/v1/charts/bitnami/phpmyadmin/versions/3.0.0"}}}}]}` @@ -41,7 +38,7 @@ func TestSearchMonocularCmd(t *testing.T) { %s/charts/bitnami/phpmyadmin 3.0.0 4.9.0-1 phpMyAdmin is an mysql administration frontend `, ts.URL, ts.URL) - testcmd := "search --endpoint " + ts.URL + " maria" + testcmd := "search hub --endpoint " + ts.URL + " maria" storage := storageFixture() _, out, err := executeActionCommandC(storage, testcmd) if err != nil { @@ -54,54 +51,3 @@ func TestSearchMonocularCmd(t *testing.T) { } } - -func TestSearchRepositoriesCmd(t *testing.T) { - defer resetEnv()() - - os.Setenv(xdg.CacheHomeEnvVar, "testdata/helmhome") - os.Setenv(xdg.ConfigHomeEnvVar, "testdata/helmhome") - os.Setenv(xdg.DataHomeEnvVar, "testdata/helmhome") - - tests := []cmdTestCase{{ - name: "search for 'maria', expect one match", - cmd: "search -r maria", - golden: "output/search-single.txt", - }, { - name: "search for 'alpine', expect two matches", - cmd: "search -r alpine", - golden: "output/search-multiple.txt", - }, { - name: "search for 'alpine' with versions, expect three matches", - cmd: "search -r alpine --versions", - golden: "output/search-multiple-versions.txt", - }, { - name: "search for 'alpine' with version constraint, expect one match with version 0.1.0", - cmd: "search -r alpine --version '>= 0.1, < 0.2'", - golden: "output/search-constraint.txt", - }, { - name: "search for 'alpine' with version constraint, expect one match with version 0.1.0", - cmd: "search -r alpine --versions --version '>= 0.1, < 0.2'", - golden: "output/search-versions-constraint.txt", - }, { - name: "search for 'alpine' with version constraint, expect one match with version 0.2.0", - cmd: "search -r alpine --version '>= 0.1'", - golden: "output/search-constraint-single.txt", - }, { - name: "search for 'alpine' with version constraint and --versions, expect two matches", - cmd: "search -r alpine --versions --version '>= 0.1'", - golden: "output/search-multiple-versions-constraints.txt", - }, { - name: "search for 'syzygy', expect no matches", - cmd: "search -r syzygy", - golden: "output/search-not-found.txt", - }, { - name: "search for 'alp[a-z]+', expect two matches", - cmd: "search -r alp[a-z]+ --regexp", - golden: "output/search-regex.txt", - }, { - name: "search for 'alp[', expect failure to compile regexp", - cmd: "search -r alp[ --regexp", - wantError: true, - }} - runTestCmd(t, tests) -} diff --git a/cmd/helm/search_repo.go b/cmd/helm/search_repo.go new file mode 100644 index 000000000..7e9ecd3b3 --- /dev/null +++ b/cmd/helm/search_repo.go @@ -0,0 +1,163 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "fmt" + "io" + "strings" + + "github.com/Masterminds/semver" + "github.com/gosuri/uitable" + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "helm.sh/helm/cmd/helm/search" + "helm.sh/helm/pkg/helmpath" + "helm.sh/helm/pkg/repo" +) + +const searchRepoDesc = ` +Search reads through all of the repositories configured on the system, and +looks for matches. Search of these repositories uses the metadata stored on +the system. + +Repositories are managed with 'helm repo' commands. +` + +// searchMaxScore suggests that any score higher than this is not considered a match. +const searchMaxScore = 25 + +type searchRepoOptions struct { + versions bool + regexp bool + version string + maxColWidth uint +} + +func newSearchRepoCmd(out io.Writer) *cobra.Command { + o := &searchRepoOptions{} + + cmd := &cobra.Command{ + Use: "repo [keyword]", + Short: "search for a keyword in charts", + Long: searchRepoDesc, + RunE: func(cmd *cobra.Command, args []string) error { + return o.run(out, args) + }, + } + + f := cmd.Flags() + f.BoolVarP(&o.regexp, "regexp", "r", false, "use regular expressions for searching repositories you have added") + 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") + f.StringVar(&o.version, "version", "", "search using semantic versioning constraints on repositories you have added") + f.UintVar(&o.maxColWidth, "maxColumnWidth", 50, "maximum column width for output table") + + return cmd +} + +func (o *searchRepoOptions) run(out io.Writer, args []string) error { + index, err := o.buildIndex(out) + if err != nil { + return err + } + + var res []*search.Result + if len(args) == 0 { + res = index.All() + } else { + q := strings.Join(args, " ") + res, err = index.Search(q, searchMaxScore, o.regexp) + if err != nil { + return err + } + } + + search.SortScore(res) + data, err := o.applyConstraint(res) + if err != nil { + return err + } + + fmt.Fprintln(out, o.formatSearchResults(data)) + + return nil +} + +func (o *searchRepoOptions) applyConstraint(res []*search.Result) ([]*search.Result, error) { + if len(o.version) == 0 { + return res, nil + } + + constraint, err := semver.NewConstraint(o.version) + if err != nil { + return res, errors.Wrap(err, "an invalid version/constraint format") + } + + data := res[:0] + foundNames := map[string]bool{} + for _, r := range res { + if _, found := foundNames[r.Name]; found { + continue + } + v, err := semver.NewVersion(r.Chart.Version) + if err != nil || constraint.Check(v) { + data = append(data, r) + if !o.versions { + foundNames[r.Name] = true // If user hasn't requested all versions, only show the latest that matches + } + } + } + + return data, nil +} + +func (o *searchRepoOptions) formatSearchResults(res []*search.Result) string { + if len(res) == 0 { + return "No results found" + } + table := uitable.New() + table.MaxColWidth = o.maxColWidth + table.AddRow("NAME", "CHART VERSION", "APP VERSION", "DESCRIPTION") + for _, r := range res { + table.AddRow(r.Name, r.Chart.Version, r.Chart.AppVersion, r.Chart.Description) + } + return table.String() +} + +func (o *searchRepoOptions) buildIndex(out io.Writer) (*search.Index, error) { + // Load the repositories.yaml + rf, err := repo.LoadFile(helmpath.RepositoryFile()) + if err != nil { + return nil, err + } + + i := search.NewIndex() + for _, re := range rf.Repositories { + n := re.Name + f := helmpath.CacheIndex(n) + ind, err := repo.LoadIndexFile(f) + if err != nil { + // TODO should print to stderr + fmt.Fprintf(out, "WARNING: Repo %q is corrupt or missing. Try 'helm repo update'.", n) + continue + } + + i.AddRepo(n, ind, o.versions || len(o.version) > 0) + } + return i, nil +} diff --git a/cmd/helm/search_repo_test.go b/cmd/helm/search_repo_test.go new file mode 100644 index 000000000..c6b9f8074 --- /dev/null +++ b/cmd/helm/search_repo_test.go @@ -0,0 +1,75 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "os" + "testing" + + "helm.sh/helm/pkg/helmpath/xdg" +) + +func TestSearchRepositoriesCmd(t *testing.T) { + defer resetEnv()() + + os.Setenv(xdg.CacheHomeEnvVar, "testdata/helmhome") + os.Setenv(xdg.ConfigHomeEnvVar, "testdata/helmhome") + os.Setenv(xdg.DataHomeEnvVar, "testdata/helmhome") + + tests := []cmdTestCase{{ + name: "search for 'maria', expect one match", + cmd: "search repo maria", + golden: "output/search-single.txt", + }, { + name: "search for 'alpine', expect two matches", + cmd: "search repo alpine", + golden: "output/search-multiple.txt", + }, { + name: "search for 'alpine' with versions, expect three matches", + cmd: "search repo alpine --versions", + golden: "output/search-multiple-versions.txt", + }, { + name: "search for 'alpine' with version constraint, expect one match with version 0.1.0", + cmd: "search repo alpine --version '>= 0.1, < 0.2'", + golden: "output/search-constraint.txt", + }, { + name: "search for 'alpine' with version constraint, expect one match with version 0.1.0", + cmd: "search repo alpine --versions --version '>= 0.1, < 0.2'", + golden: "output/search-versions-constraint.txt", + }, { + name: "search for 'alpine' with version constraint, expect one match with version 0.2.0", + cmd: "search repo alpine --version '>= 0.1'", + golden: "output/search-constraint-single.txt", + }, { + name: "search for 'alpine' with version constraint and --versions, expect two matches", + cmd: "search repo alpine --versions --version '>= 0.1'", + golden: "output/search-multiple-versions-constraints.txt", + }, { + name: "search for 'syzygy', expect no matches", + cmd: "search repo syzygy", + golden: "output/search-not-found.txt", + }, { + name: "search for 'alp[a-z]+', expect two matches", + cmd: "search repo alp[a-z]+ --regexp", + golden: "output/search-regex.txt", + }, { + name: "search for 'alp[', expect failure to compile regexp", + cmd: "search repo alp[ --regexp", + wantError: true, + }} + runTestCmd(t, tests) +}