Breaking up the search command into multiple commands based on type

Signed-off-by: Matt Farina <matt@mattfarina.com>
pull/6211/head
Matt Farina 6 years ago
parent d30d3f6218
commit 90d2bac80c
No known key found for this signature in database
GPG Key ID: 9436E80BFBA46909

@ -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
}

@ -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()
}

@ -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)
}

@ -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
}

@ -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)
}
Loading…
Cancel
Save