mirror of https://github.com/helm/helm
Switch 'helm search' from file crawling to using the indices. Also add scorable indexing, forward porting the search code I originally wrote for Helm Classic. Closes #1226 Partially addresses #1199pull/1229/head
parent
c0d33afc81
commit
446d555178
@ -0,0 +1,183 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2016 The Kubernetes Authors All rights reserved.
|
||||||
|
|
||||||
|
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 search provides client-side repository searching.
|
||||||
|
|
||||||
|
This supports building an in-memory search index based on the contents of
|
||||||
|
multiple repositories, and then using string matching or regular expressions
|
||||||
|
to find matches.
|
||||||
|
*/
|
||||||
|
package search
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"k8s.io/helm/pkg/repo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Result is a search result.
|
||||||
|
//
|
||||||
|
// Score indicates how close it is to match. The higher the score, the longer
|
||||||
|
// the distance.
|
||||||
|
type Result struct {
|
||||||
|
Name string
|
||||||
|
Score int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index is a searchable index of chart information.
|
||||||
|
type Index struct {
|
||||||
|
lines map[string]string
|
||||||
|
charts map[string]*repo.ChartRef
|
||||||
|
}
|
||||||
|
|
||||||
|
const sep = "\v"
|
||||||
|
|
||||||
|
// NewIndex creats a new Index.
|
||||||
|
func NewIndex() *Index {
|
||||||
|
return &Index{lines: map[string]string{}, charts: map[string]*repo.ChartRef{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddRepo adds a repository index to the search index.
|
||||||
|
func (i *Index) AddRepo(rname string, ind *repo.IndexFile) {
|
||||||
|
for name, ref := range ind.Entries {
|
||||||
|
fname := filepath.Join(rname, name)
|
||||||
|
i.lines[fname] = indstr(rname, ref)
|
||||||
|
i.charts[fname] = ref
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entries returns the entries in an index.
|
||||||
|
func (i *Index) Entries() map[string]*repo.ChartRef {
|
||||||
|
return i.charts
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search searches an index for the given term.
|
||||||
|
//
|
||||||
|
// Threshold indicates the maximum score a term may have before being marked
|
||||||
|
// irrelevant. (Low score means higher relevance. Golf, not bowling.)
|
||||||
|
//
|
||||||
|
// If regexp is true, the term is treated as a regular expression. Otherwise,
|
||||||
|
// term is treated as a literal string.
|
||||||
|
func (i *Index) Search(term string, threshold int, regexp bool) ([]*Result, error) {
|
||||||
|
if regexp == true {
|
||||||
|
return i.SearchRegexp(term, threshold)
|
||||||
|
}
|
||||||
|
return i.SearchLiteral(term, threshold), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// calcScore calculates a score for a match.
|
||||||
|
func (i *Index) calcScore(index int, matchline string) int {
|
||||||
|
|
||||||
|
// This is currently tied to the fact that sep is a single char.
|
||||||
|
splits := []int{}
|
||||||
|
s := rune(sep[0])
|
||||||
|
for i, ch := range matchline {
|
||||||
|
if ch == s {
|
||||||
|
splits = append(splits, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, pos := range splits {
|
||||||
|
if index > pos {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
return len(splits)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchLiteral does a literal string search (no regexp).
|
||||||
|
func (i *Index) SearchLiteral(term string, threshold int) []*Result {
|
||||||
|
term = strings.ToLower(term)
|
||||||
|
buf := []*Result{}
|
||||||
|
for k, v := range i.lines {
|
||||||
|
res := strings.Index(v, term)
|
||||||
|
if score := i.calcScore(res, v); res != -1 && score < threshold {
|
||||||
|
buf = append(buf, &Result{Name: k, Score: score})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchRegexp searches using a regular expression.
|
||||||
|
func (i *Index) SearchRegexp(re string, threshold int) ([]*Result, error) {
|
||||||
|
matcher, err := regexp.Compile(re)
|
||||||
|
if err != nil {
|
||||||
|
return []*Result{}, err
|
||||||
|
}
|
||||||
|
buf := []*Result{}
|
||||||
|
for k, v := range i.lines {
|
||||||
|
ind := matcher.FindStringIndex(v)
|
||||||
|
if len(ind) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if score := i.calcScore(ind[0], v); ind[0] >= 0 && score < threshold {
|
||||||
|
buf = append(buf, &Result{Name: k, Score: score})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chart returns the ChartRef for a particular name.
|
||||||
|
func (i *Index) Chart(name string) (*repo.ChartRef, error) {
|
||||||
|
c, ok := i.charts[name]
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("no such chart")
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SortScore does an in-place sort of the results.
|
||||||
|
//
|
||||||
|
// Lowest scores are highest on the list. Matching scores are subsorted alphabetically.
|
||||||
|
func SortScore(r []*Result) {
|
||||||
|
sort.Sort(scoreSorter(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
// scoreSorter sorts results by score, and subsorts by alpha Name.
|
||||||
|
type scoreSorter []*Result
|
||||||
|
|
||||||
|
// Len returns the length of this scoreSorter.
|
||||||
|
func (s scoreSorter) Len() int { return len(s) }
|
||||||
|
|
||||||
|
// Swap performs an in-place swap.
|
||||||
|
func (s scoreSorter) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
|
||||||
|
|
||||||
|
// Less compares a to b, and returns true if a is less than b.
|
||||||
|
func (s scoreSorter) Less(a, b int) bool {
|
||||||
|
first := s[a]
|
||||||
|
second := s[b]
|
||||||
|
|
||||||
|
if first.Score > second.Score {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if first.Score < second.Score {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return first.Name < second.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
func indstr(name string, ref *repo.ChartRef) string {
|
||||||
|
i := ref.Name + sep + name + "/" + ref.Name + sep
|
||||||
|
if ref.Chartfile != nil {
|
||||||
|
i += ref.Chartfile.Description + sep + strings.Join(ref.Chartfile.Keywords, sep)
|
||||||
|
}
|
||||||
|
return strings.ToLower(i)
|
||||||
|
}
|
@ -0,0 +1,232 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2016 The Kubernetes Authors All rights reserved.
|
||||||
|
|
||||||
|
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 search
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"k8s.io/helm/pkg/proto/hapi/chart"
|
||||||
|
"k8s.io/helm/pkg/repo"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSortScore(t *testing.T) {
|
||||||
|
in := []*Result{
|
||||||
|
{Name: "bbb", Score: 0},
|
||||||
|
{Name: "aaa", Score: 5},
|
||||||
|
{Name: "abb", Score: 5},
|
||||||
|
{Name: "aab", Score: 0},
|
||||||
|
{Name: "bab", Score: 5},
|
||||||
|
}
|
||||||
|
expect := []string{"aab", "bbb", "aaa", "abb", "bab"}
|
||||||
|
expectScore := []int{0, 0, 5, 5, 5}
|
||||||
|
SortScore(in)
|
||||||
|
|
||||||
|
// Test Score
|
||||||
|
for i := 0; i < len(expectScore); i++ {
|
||||||
|
if expectScore[i] != in[i].Score {
|
||||||
|
t.Errorf("Sort error on index %d: expected %d, got %d", i, expectScore[i], in[i].Score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Test Name
|
||||||
|
for i := 0; i < len(expect); i++ {
|
||||||
|
if expect[i] != in[i].Name {
|
||||||
|
t.Errorf("Sort error: expected %s, got %s", expect[i], in[i].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var testCacheDir = "../testdata/"
|
||||||
|
|
||||||
|
var indexfileEntries = map[string]*repo.ChartRef{
|
||||||
|
"niña-0.1.0": {
|
||||||
|
Name: "niña",
|
||||||
|
URL: "http://example.com/charts/nina-0.1.0.tgz",
|
||||||
|
Chartfile: &chart.Metadata{
|
||||||
|
Name: "niña",
|
||||||
|
Version: "0.1.0",
|
||||||
|
Description: "One boat",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"pinta-0.1.0": {
|
||||||
|
Name: "pinta",
|
||||||
|
URL: "http://example.com/charts/pinta-0.1.0.tgz",
|
||||||
|
Chartfile: &chart.Metadata{
|
||||||
|
Name: "pinta",
|
||||||
|
Version: "0.1.0",
|
||||||
|
Description: "Two ship",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"santa-maria-1.2.3": {
|
||||||
|
Name: "santa-maria",
|
||||||
|
URL: "http://example.com/charts/santa-maria-1.2.3.tgz",
|
||||||
|
Chartfile: &chart.Metadata{
|
||||||
|
Name: "santa-maria",
|
||||||
|
Version: "1.2.3",
|
||||||
|
Description: "Three boat",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadTestIndex(t *testing.T) *Index {
|
||||||
|
i := NewIndex()
|
||||||
|
i.AddRepo("testing", &repo.IndexFile{Entries: indexfileEntries})
|
||||||
|
i.AddRepo("ztesting", &repo.IndexFile{Entries: map[string]*repo.ChartRef{
|
||||||
|
"pinta-2.0.0": {
|
||||||
|
Name: "pinta",
|
||||||
|
URL: "http://example.com/charts/pinta-2.0.0.tgz",
|
||||||
|
Chartfile: &chart.Metadata{
|
||||||
|
Name: "pinta",
|
||||||
|
Version: "2.0.0",
|
||||||
|
Description: "Two ship, version two",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchByName(t *testing.T) {
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
query string
|
||||||
|
expect []*Result
|
||||||
|
regexp bool
|
||||||
|
fail bool
|
||||||
|
failMsg string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "basic search for one result",
|
||||||
|
query: "santa-maria",
|
||||||
|
expect: []*Result{
|
||||||
|
{Name: "testing/santa-maria-1.2.3"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "basic search for two results",
|
||||||
|
query: "pinta",
|
||||||
|
expect: []*Result{
|
||||||
|
{Name: "testing/pinta-0.1.0"},
|
||||||
|
{Name: "ztesting/pinta-2.0.0"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "repo-specific search for one result",
|
||||||
|
query: "ztesting/pinta",
|
||||||
|
expect: []*Result{
|
||||||
|
{Name: "ztesting/pinta-2.0.0"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "partial name search",
|
||||||
|
query: "santa",
|
||||||
|
expect: []*Result{
|
||||||
|
{Name: "testing/santa-maria-1.2.3"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "description search, one result",
|
||||||
|
query: "Three",
|
||||||
|
expect: []*Result{
|
||||||
|
{Name: "testing/santa-maria-1.2.3"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "description search, two results",
|
||||||
|
query: "two",
|
||||||
|
expect: []*Result{
|
||||||
|
{Name: "testing/pinta-0.1.0"},
|
||||||
|
{Name: "ztesting/pinta-2.0.0"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nothing found",
|
||||||
|
query: "mayflower",
|
||||||
|
expect: []*Result{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "regexp, one result",
|
||||||
|
query: "th[ref]*",
|
||||||
|
expect: []*Result{
|
||||||
|
{Name: "testing/santa-maria-1.2.3"},
|
||||||
|
},
|
||||||
|
regexp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "regexp, fail compile",
|
||||||
|
query: "th[",
|
||||||
|
expect: []*Result{},
|
||||||
|
regexp: true,
|
||||||
|
fail: true,
|
||||||
|
failMsg: "error parsing regexp:",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
i := loadTestIndex(t)
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
|
||||||
|
charts, err := i.Search(tt.query, 100, tt.regexp)
|
||||||
|
if err != nil {
|
||||||
|
if tt.fail {
|
||||||
|
if !strings.Contains(err.Error(), tt.failMsg) {
|
||||||
|
t.Fatalf("%s: Unexpected error message: %s", tt.name, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t.Fatalf("%s: %s", tt.name, err)
|
||||||
|
}
|
||||||
|
// Give us predictably ordered results.
|
||||||
|
SortScore(charts)
|
||||||
|
|
||||||
|
l := len(charts)
|
||||||
|
if l != len(tt.expect) {
|
||||||
|
t.Fatalf("%s: Expected %d result, got %d", tt.name, len(tt.expect), l)
|
||||||
|
}
|
||||||
|
// For empty result sets, just keep going.
|
||||||
|
if l == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, got := range charts {
|
||||||
|
ex := tt.expect[i]
|
||||||
|
if got.Name != ex.Name {
|
||||||
|
t.Errorf("%s[%d]: Expected name %q, got %q", tt.name, i, ex.Name, got.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalcScore(t *testing.T) {
|
||||||
|
i := NewIndex()
|
||||||
|
|
||||||
|
fields := []string{"aaa", "bbb", "ccc", "ddd"}
|
||||||
|
matchline := strings.Join(fields, sep)
|
||||||
|
if r := i.calcScore(2, matchline); r != 0 {
|
||||||
|
t.Errorf("Expected 0, got %d", r)
|
||||||
|
}
|
||||||
|
if r := i.calcScore(5, matchline); r != 1 {
|
||||||
|
t.Errorf("Expected 1, got %d", r)
|
||||||
|
}
|
||||||
|
if r := i.calcScore(10, matchline); r != 2 {
|
||||||
|
t.Errorf("Expected 2, got %d", r)
|
||||||
|
}
|
||||||
|
if r := i.calcScore(14, matchline); r != 3 {
|
||||||
|
t.Errorf("Expected 3, got %d", r)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,54 @@
|
|||||||
|
alpine-0.1.0:
|
||||||
|
name: alpine
|
||||||
|
url: http://storage.googleapis.com/kubernetes-charts/alpine-0.1.0.tgz
|
||||||
|
created: 2016-09-06 21:58:44.211261566 +0000 UTC
|
||||||
|
checksum: 0e6661f193211d7a5206918d42f5c2a9470b737d
|
||||||
|
chartfile:
|
||||||
|
name: alpine
|
||||||
|
home: https://k8s.io/helm
|
||||||
|
sources:
|
||||||
|
- https://github.com/kubernetes/helm
|
||||||
|
version: 0.1.0
|
||||||
|
description: Deploy a basic Alpine Linux pod
|
||||||
|
keywords: []
|
||||||
|
maintainers: []
|
||||||
|
engine: ""
|
||||||
|
icon: ""
|
||||||
|
alpine-0.2.0:
|
||||||
|
name: alpine
|
||||||
|
url: http://storage.googleapis.com/kubernetes-charts/alpine-0.2.0.tgz
|
||||||
|
created: 2016-09-06 21:58:44.211261566 +0000 UTC
|
||||||
|
checksum: 0e6661f193211d7a5206918d42f5c2a9470b737d
|
||||||
|
chartfile:
|
||||||
|
name: alpine
|
||||||
|
home: https://k8s.io/helm
|
||||||
|
sources:
|
||||||
|
- https://github.com/kubernetes/helm
|
||||||
|
version: 0.2.0
|
||||||
|
description: Deploy a basic Alpine Linux pod
|
||||||
|
keywords: []
|
||||||
|
maintainers: []
|
||||||
|
engine: ""
|
||||||
|
icon: ""
|
||||||
|
mariadb-0.3.0:
|
||||||
|
name: mariadb
|
||||||
|
url: http://storage.googleapis.com/kubernetes-charts/mariadb-0.3.0.tgz
|
||||||
|
created: 2016-09-06 21:58:44.211870222 +0000 UTC
|
||||||
|
checksum: 65229f6de44a2be9f215d11dbff311673fc8ba56
|
||||||
|
chartfile:
|
||||||
|
name: mariadb
|
||||||
|
home: https://mariadb.org
|
||||||
|
sources:
|
||||||
|
- https://github.com/bitnami/bitnami-docker-mariadb
|
||||||
|
version: 0.3.0
|
||||||
|
description: Chart for MariaDB
|
||||||
|
keywords:
|
||||||
|
- mariadb
|
||||||
|
- mysql
|
||||||
|
- database
|
||||||
|
- sql
|
||||||
|
maintainers:
|
||||||
|
- name: Bitnami
|
||||||
|
email: containers@bitnami.com
|
||||||
|
engine: gotpl
|
||||||
|
icon: ""
|
@ -0,0 +1 @@
|
|||||||
|
testing: http://example.com/charts
|
Loading…
Reference in new issue