diff --git a/nomad/search_endpoint.go b/nomad/search_endpoint.go index 8ad88c36db54..a795e3cba68b 100644 --- a/nomad/search_endpoint.go +++ b/nomad/search_endpoint.go @@ -182,6 +182,14 @@ func (s *Search) getFuzzyMatches(iter memdb.ResultIterator, text string) (map[st return m, truncations } +// fuzzyIndex returns the index of text in name, ignoring case. +// text is assumed to be lower case. +// -1 is returned if name does not contain text. +func fuzzyIndex(name, text string) int { + lower := strings.ToLower(name) + return strings.Index(lower, text) +} + // fuzzySingleMatch determines if the ID of raw is a fuzzy match with text. // Returns the context and score or nil if there is no match. func (s *Search) fuzzyMatchSingle(raw interface{}, text string) (structs.Context, *fuzzyMatch) { @@ -208,7 +216,7 @@ func (s *Search) fuzzyMatchSingle(raw interface{}, text string) (structs.Context ctx = structs.Plugins } - if idx := strings.Index(name, text); idx >= 0 { + if idx := fuzzyIndex(name, text); idx >= 0 { return ctx, &fuzzyMatch{ id: name, score: idx, @@ -235,32 +243,32 @@ func (*Search) fuzzyMatchesJob(j *structs.Job, text string) map[structs.Context] job := j.ID // job.name - if idx := strings.Index(j.Name, text); idx >= 0 { + if idx := fuzzyIndex(j.Name, text); idx >= 0 { sm[structs.Jobs] = append(sm[structs.Jobs], score(job, ns, idx)) } // job|group.name for _, group := range j.TaskGroups { - if idx := strings.Index(group.Name, text); idx >= 0 { + if idx := fuzzyIndex(group.Name, text); idx >= 0 { sm[structs.Groups] = append(sm[structs.Groups], score(group.Name, ns, idx, job)) } // job|group|service.name for _, service := range group.Services { - if idx := strings.Index(service.Name, text); idx >= 0 { + if idx := fuzzyIndex(service.Name, text); idx >= 0 { sm[structs.Services] = append(sm[structs.Services], score(service.Name, ns, idx, job, group.Name)) } } // job|group|task.name for _, task := range group.Tasks { - if idx := strings.Index(task.Name, text); idx >= 0 { + if idx := fuzzyIndex(task.Name, text); idx >= 0 { sm[structs.Tasks] = append(sm[structs.Tasks], score(task.Name, ns, idx, job, group.Name)) } // job|group|task|service.name for _, service := range task.Services { - if idx := strings.Index(service.Name, text); idx >= 0 { + if idx := fuzzyIndex(service.Name, text); idx >= 0 { sm[structs.Services] = append(sm[structs.Services], score(service.Name, ns, idx, job, group.Name, task.Name)) } } @@ -269,17 +277,17 @@ func (*Search) fuzzyMatchesJob(j *structs.Job, text string) map[structs.Context] switch task.Driver { case "docker": image := getConfigParam(task.Config, "image") - if idx := strings.Index(image, text); idx >= 0 { + if idx := fuzzyIndex(image, text); idx >= 0 { sm[structs.Images] = append(sm[structs.Images], score(image, ns, idx, job, group.Name, task.Name)) } case "exec", "raw_exec": command := getConfigParam(task.Config, "command") - if idx := strings.Index(command, text); idx >= 0 { + if idx := fuzzyIndex(command, text); idx >= 0 { sm[structs.Commands] = append(sm[structs.Commands], score(command, ns, idx, job, group.Name, task.Name)) } case "java": class := getConfigParam(task.Config, "class") - if idx := strings.Index(class, text); idx >= 0 { + if idx := fuzzyIndex(class, text); idx >= 0 { sm[structs.Classes] = append(sm[structs.Classes], score(class, ns, idx, job, group.Name, task.Name)) } } @@ -621,6 +629,10 @@ func (s *Search) FuzzySearch(args *structs.FuzzySearchRequest, reply *structs.Fu return fmt.Errorf("fuzzy search query must be at least %d characters, got %d", min, n) } + // for case-insensitive searching, lower-case the search term once and reuse + text := strings.ToLower(args.Text) + + // accumulate fuzzy search results and any truncations reply.Matches = make(map[structs.Context][]structs.FuzzyMatch) reply.Truncations = make(map[structs.Context]bool) @@ -685,7 +697,7 @@ func (s *Search) FuzzySearch(args *structs.FuzzySearchRequest, reply *structs.Fu // the response for negative results reply.Truncations[iterCtx] = false - matches, truncations := s.getFuzzyMatches(iter, args.Text) + matches, truncations := s.getFuzzyMatches(iter, text) for ctx := range matches { reply.Matches[ctx] = matches[ctx] } diff --git a/nomad/search_endpoint_test.go b/nomad/search_endpoint_test.go index ee59eeefdd04..22ebbd12f418 100644 --- a/nomad/search_endpoint_test.go +++ b/nomad/search_endpoint_test.go @@ -1402,6 +1402,37 @@ func TestSearch_FuzzySearch_Namespace(t *testing.T) { require.Equal(t, uint64(2000), resp.Index) } +func TestSearch_FuzzySearch_Namespace_caseInsensitive(t *testing.T) { + t.Parallel() + + s, cleanup := TestServer(t, func(c *Config) { + c.NumSchedulers = 0 + }) + defer cleanup() + codec := rpcClient(t, s) + testutil.WaitForLeader(t, s.RPC) + + ns := mock.Namespace() + ns.Name = "TheFooNamespace" + require.NoError(t, s.fsm.State().UpsertNamespaces(2000, []*structs.Namespace{ns})) + + req := &structs.FuzzySearchRequest{ + Text: "foon", + Context: structs.Namespaces, + QueryOptions: structs.QueryOptions{ + Region: "global", + }, + } + + var resp structs.FuzzySearchResponse + require.NoError(t, msgpackrpc.CallWithCodec(codec, "Search.FuzzySearch", req, &resp)) + + require.Len(t, resp.Matches[structs.Namespaces], 1) + require.Equal(t, ns.Name, resp.Matches[structs.Namespaces][0].ID) + require.False(t, resp.Truncations[structs.Namespaces]) + require.Equal(t, uint64(2000), resp.Index) +} + func TestSearch_FuzzySearch_ScalingPolicy(t *testing.T) { t.Parallel() @@ -1992,3 +2023,17 @@ func TestSearch_FuzzySearch_Job(t *testing.T) { }}, m[structs.Classes]) }) } + +func TestSearch_FuzzySearch_fuzzyIndex(t *testing.T) { + for _, tc := range []struct { + name, text string + exp int + }{ + {name: "foo-bar-baz", text: "bar", exp: 4}, + {name: "Foo-Bar-Baz", text: "bar", exp: 4}, + {name: "foo-bar-baz", text: "zap", exp: -1}, + } { + result := fuzzyIndex(tc.name, tc.text) + require.Equal(t, tc.exp, result, "name: %s, text: %s, exp: %d, got: %d", tc.name, tc.text, tc.exp, result) + } +}