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