Merge pull request #6211 from mattfarina/hub-search

Exposing Helm Hub search via the search command
pull/6219/head
Matt Farina 5 years ago committed by GitHub
commit 63b751ded7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -17,144 +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/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
}
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.BoolVarP(&o.regexp, "regexp", "r", false, "use regular expressions for searching")
f.BoolVarP(&o.versions, "versions", "l", false, "show the long listing, with each version of each chart on its own line")
f.StringVar(&o.version, "version", "", "search using semantic versioning constraints")
cmd.AddCommand(newSearchHubCmd(out))
cmd.AddCommand(newSearchRepoCmd(out))
return cmd
}
func (o *searchOptions) 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 *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(res []*search.Result) string {
if len(res) == 0 {
return "No results found"
}
table := uitable.New()
table.MaxColWidth = 50
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 charts in the Helm Hub or an instance of Monocular",
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, "max-col-width", 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()
}

@ -0,0 +1,53 @@
/*
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"
"net/http"
"net/http/httptest"
"testing"
)
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"}}}}]}`
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, searchResult)
}))
defer ts.Close()
// The expected output has the URL to the mocked search service in it
var expected = fmt.Sprintf(`URL CHART VERSION APP VERSION DESCRIPTION
%s/charts/stable/phpmyadmin 3.0.0 4.9.0-1 phpMyAdmin is an mysql administration frontend
%s/charts/bitnami/phpmyadmin 3.0.0 4.9.0-1 phpMyAdmin is an mysql administration frontend
`, ts.URL, ts.URL)
testcmd := "search hub --endpoint " + ts.URL + " maria"
storage := storageFixture()
_, out, err := executeActionCommandC(storage, testcmd)
if err != nil {
t.Errorf("unexpected error, %s", err)
}
if out != expected {
t.Error("expected and actual output did not match")
t.Log(out)
t.Log(expected)
}
}

@ -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 repositories 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, "max-col-width", 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
}

@ -23,7 +23,7 @@ import (
"helm.sh/helm/pkg/helmpath/xdg"
)
func TestSearchCmd(t *testing.T) {
func TestSearchRepositoriesCmd(t *testing.T) {
defer resetEnv()()
os.Setenv(xdg.CacheHomeEnvVar, "testdata/helmhome")
@ -32,43 +32,43 @@ func TestSearchCmd(t *testing.T) {
tests := []cmdTestCase{{
name: "search for 'maria', expect one match",
cmd: "search maria",
cmd: "search repo maria",
golden: "output/search-single.txt",
}, {
name: "search for 'alpine', expect two matches",
cmd: "search alpine",
cmd: "search repo alpine",
golden: "output/search-multiple.txt",
}, {
name: "search for 'alpine' with versions, expect three matches",
cmd: "search alpine --versions",
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 alpine --version '>= 0.1, < 0.2'",
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 alpine --versions --version '>= 0.1, < 0.2'",
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 alpine --version '>= 0.1'",
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 alpine --versions --version '>= 0.1'",
cmd: "search repo alpine --versions --version '>= 0.1'",
golden: "output/search-multiple-versions-constraints.txt",
}, {
name: "search for 'syzygy', expect no matches",
cmd: "search syzygy",
cmd: "search repo syzygy",
golden: "output/search-not-found.txt",
}, {
name: "search for 'alp[a-z]+', expect two matches",
cmd: "search alp[a-z]+ --regexp",
cmd: "search repo alp[a-z]+ --regexp",
golden: "output/search-regex.txt",
}, {
name: "search for 'alp[', expect failure to compile regexp",
cmd: "search alp[ --regexp",
cmd: "search repo alp[ --regexp",
wantError: true,
}}
runTestCmd(t, tests)
Loading…
Cancel
Save