Add MatchSearch to resources for fuzzy search (#9892)

Adds a method to resources MatchSearch, that goes through 
select resource field values, and matches it against a list of 
user provided search values.

Part of RFD 55
This commit is contained in:
Lisa Kim
2022-01-28 09:18:34 -08:00
committed by GitHub
parent e1e86eeabd
commit bbf013ce80
11 changed files with 518 additions and 16 deletions
+9 -1
View File
@@ -26,11 +26,12 @@ import (
"github.com/gravitational/trace"
"github.com/gravitational/teleport/api/constants"
"github.com/gravitational/teleport/api/utils"
)
// Application represents a web app.
type Application interface {
// ResourceWithOrigin provides common resource methods.
// ResourceWithLabels provides common resource methods.
ResourceWithLabels
// GetNamespace returns the app namespace.
GetNamespace() string
@@ -249,6 +250,13 @@ func (a *AppV3) Copy() *AppV3 {
return proto.Clone(a).(*AppV3)
}
// MatchSearch goes through select field values and tries to
// match against the list of search values.
func (a *AppV3) MatchSearch(values []string) bool {
fieldVals := append(utils.MapToStrings(a.GetAllLabels()), a.GetName(), a.GetDescription(), a.GetPublicAddr())
return MatchSearch(fieldVals, values, nil)
}
// setStaticFields sets static resource header and metadata fields.
func (a *AppV3) setStaticFields() {
a.Kind = KindApp
+7 -1
View File
@@ -28,7 +28,7 @@ import (
// AppServer represents a single proxied web app.
type AppServer interface {
// Resource provides common resource methods.
// ResourceWithLabels provides common resource methods.
ResourceWithLabels
// GetNamespace returns server namespace.
GetNamespace() string
@@ -289,6 +289,12 @@ func (s *AppServerV3) Copy() AppServer {
return proto.Clone(s).(*AppServerV3)
}
// MatchSearch goes through select field values and tries to
// match against the list of search values.
func (s *AppServerV3) MatchSearch(values []string) bool {
return MatchSearch(nil, values, nil)
}
// AppServers represents a list of app servers.
type AppServers []AppServer
+19 -4
View File
@@ -24,13 +24,14 @@ import (
"github.com/gogo/protobuf/proto"
"github.com/google/go-cmp/cmp"
"github.com/gravitational/teleport/api/utils"
"github.com/gravitational/trace"
)
// Database represents a database proxied by a database server.
type Database interface {
// ResourceWithOrigin provides common resource methods.
ResourceWithOrigin
// ResourceWithLabels provides common resource methods.
ResourceWithLabels
// GetNamespace returns the database namespace.
GetNamespace() string
// GetStaticLabels returns the database static labels.
@@ -41,8 +42,6 @@ type Database interface {
GetDynamicLabels() map[string]CommandLabel
// SetDynamicLabels sets the database dynamic labels.
SetDynamicLabels(map[string]CommandLabel)
// GetAllLabels returns combined static and dynamic labels.
GetAllLabels() map[string]string
// LabelsString returns all labels as a string.
LabelsString() string
// String returns string representation of the database.
@@ -330,6 +329,22 @@ func (d *DatabaseV3) Copy() *DatabaseV3 {
return proto.Clone(d).(*DatabaseV3)
}
// MatchSearch goes through select field values and tries to
// match against the list of search values.
func (d *DatabaseV3) MatchSearch(values []string) bool {
fieldVals := append(utils.MapToStrings(d.GetAllLabels()), d.GetName(), d.GetDescription(), d.GetProtocol(), d.GetType())
var custom func(string) bool
switch d.GetType() {
case DatabaseTypeCloudSQL:
custom = func(val string) bool {
return strings.EqualFold(val, "cloud") || strings.EqualFold(val, "cloud sql")
}
}
return MatchSearch(fieldVals, values, custom)
}
// setStaticFields sets static resource header and metadata fields.
func (d *DatabaseV3) setStaticFields() {
d.Kind = KindDatabase
+7 -1
View File
@@ -28,7 +28,7 @@ import (
// DatabaseServer represents a database access server.
type DatabaseServer interface {
// Resource provides common resource methods.
// ResourceWithLabels provides common resource methods.
ResourceWithLabels
// GetNamespace returns server namespace.
GetNamespace() string
@@ -276,6 +276,12 @@ func (s *DatabaseServerV3) Copy() DatabaseServer {
return proto.Clone(s).(*DatabaseServerV3)
}
// MatchSearch goes through select field values and tries to
// match against the list of search values.
func (s *DatabaseServerV3) MatchSearch(values []string) bool {
return MatchSearch(nil, values, nil)
}
// DatabaseServers represents a list of database servers.
type DatabaseServers []DatabaseServer
+33 -6
View File
@@ -17,13 +17,14 @@ limitations under the License.
package types
import (
"github.com/gravitational/teleport/api/utils"
"github.com/gravitational/trace"
)
// WindowsDesktopService represents a Windows desktop service instance.
type WindowsDesktopService interface {
// Resource provides common resource methods.
Resource
// ResourceWithLabels provides common resource methods.
ResourceWithLabels
// GetAddr returns the network address of this service.
GetAddr() string
// GetVersion returns the teleport binary version of this service.
@@ -79,14 +80,33 @@ func (s *WindowsDesktopServiceV3) GetTeleportVersion() string {
return s.Spec.TeleportVersion
}
// Origin returns the origin value of the resource.
func (s *WindowsDesktopServiceV3) Origin() string {
return s.Metadata.Origin()
}
// SetOrigin sets the origin value of the resource.
func (s *WindowsDesktopServiceV3) SetOrigin(origin string) {
s.Metadata.SetOrigin(origin)
}
// GetAllLabels returns the resources labels.
func (s *WindowsDesktopServiceV3) GetAllLabels() map[string]string {
return s.Metadata.Labels
}
// MatchSearch goes through select field values and tries to
// match against the list of search values.
func (s *WindowsDesktopServiceV3) MatchSearch(values []string) bool {
return MatchSearch(nil, values, nil)
}
// WindowsDesktop represents a Windows desktop host.
type WindowsDesktop interface {
// Resource provides common resource methods.
Resource
// ResourceWithLabels provides common resource methods.
ResourceWithLabels
// GetAddr returns the network address of this host.
GetAddr() string
// GetAllLabels returns combined static and dynamic labels.
GetAllLabels() map[string]string
// LabelsString returns all labels as a string.
LabelsString() string
// GetDomain returns the ActiveDirectory domain of this host.
@@ -160,3 +180,10 @@ func (d *WindowsDesktopV3) Origin() string {
func (d *WindowsDesktopV3) SetOrigin(o string) {
d.Metadata.Labels[OriginLabel] = o
}
// MatchSearch goes through select field values and tries to
// match against the list of search values.
func (d *WindowsDesktopV3) MatchSearch(values []string) bool {
fieldVals := append(utils.MapToStrings(d.GetAllLabels()), d.GetName(), d.GetAddr())
return MatchSearch(fieldVals, values, nil)
}
+8
View File
@@ -21,6 +21,7 @@ import (
"time"
"github.com/gogo/protobuf/proto"
"github.com/gravitational/teleport/api/utils"
"github.com/gravitational/trace"
)
@@ -164,6 +165,13 @@ func (k *KubernetesClusterV3) Copy() *KubernetesClusterV3 {
return proto.Clone(k).(*KubernetesClusterV3)
}
// MatchSearch goes through select field values and tries to
// match against the list of search values.
func (k *KubernetesClusterV3) MatchSearch(values []string) bool {
fieldVals := append(utils.MapToStrings(k.GetAllLabels()), k.GetName())
return MatchSearch(fieldVals, values, nil)
}
// setStaticFields sets static resource header and metadata fields.
func (k *KubernetesClusterV3) setStaticFields() {
k.Kind = KindKubernetesCluster
+33
View File
@@ -18,6 +18,7 @@ package types
import (
"regexp"
"strings"
"time"
"github.com/gravitational/teleport/api/defaults"
@@ -81,6 +82,9 @@ type ResourceWithLabels interface {
ResourceWithOrigin
// GetAllLabels returns all resource's labels.
GetAllLabels() map[string]string
// MatchSearch goes through select field values of a resource
// and tries to match against the list of search values.
MatchSearch(searchValues []string) bool
}
// ResourcesWithLabels is a list of labeled resources.
@@ -277,3 +281,32 @@ var validLabelKey = regexp.MustCompile(LabelPattern)
func IsValidLabelKey(s string) bool {
return validLabelKey.MatchString(s)
}
// MatchSearch goes through select field values from a resource
// and tries to match against the list of search values, ignoring case and order.
// Returns true if all search vals were matched (or if nil search vals).
// Returns false if no or partial match (or nil field values).
func MatchSearch(fieldVals []string, searchVals []string, customMatch func(val string) bool) bool {
// Case fold all values to avoid repeated case folding while matching.
caseFoldedSearchVals := utils.ToLowerStrings(searchVals)
caseFoldedFieldVals := utils.ToLowerStrings(fieldVals)
Outer:
for _, searchV := range caseFoldedSearchVals {
// Iterate through field values to look for a match.
for _, fieldV := range caseFoldedFieldVals {
if strings.Contains(fieldV, searchV) {
continue Outer
}
}
if customMatch != nil && customMatch(searchV) {
continue
}
// When no fields matched a value, prematurely end if we can.
return false
}
return true
}
+324
View File
@@ -0,0 +1,324 @@
/**
* Copyright 2022 Gravitational, Inc.
*
* 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 types
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestMatchSearch(t *testing.T) {
t.Parallel()
cases := []struct {
name string
expectMatch require.BoolAssertionFunc
fieldVals []string
searchVals []string
customFn func(v string) bool
}{
{
name: "no match",
expectMatch: require.False,
fieldVals: []string{"foo", "bar", "baz"},
searchVals: []string{"cat"},
customFn: func(v string) bool {
return false
},
},
{
name: "no match for partial match",
expectMatch: require.False,
fieldVals: []string{"foo"},
searchVals: []string{"foo", "dog"},
},
{
name: "no match for partial custom match",
expectMatch: require.False,
fieldVals: []string{"foo", "bar", "baz"},
searchVals: []string{"foo", "bee", "rat"},
customFn: func(v string) bool {
return v == "bee"
},
},
{
name: "no match for search phrase",
expectMatch: require.False,
fieldVals: []string{"foo", "dog", "dog foo", "foodog"},
searchVals: []string{"foo dog"},
},
{
name: "match",
expectMatch: require.True,
fieldVals: []string{"foo", "bar", "baz"},
searchVals: []string{"baz"},
},
{
name: "match with nil search values",
expectMatch: require.True,
},
{
name: "match with repeat search vals",
expectMatch: require.True,
fieldVals: []string{"foo", "bar", "baz"},
searchVals: []string{"foo", "foo", "baz"},
},
{
name: "match for a list of search vals contained within one field value",
expectMatch: require.True,
fieldVals: []string{"foo barbaz"},
searchVals: []string{"baz", "foo", "bar"},
},
{
name: "match with mix of single vals and phrases",
expectMatch: require.True,
fieldVals: []string{"foo baz", "bar"},
searchVals: []string{"baz", "foo", "foo baz", "bar"},
},
{
name: "match ignore case",
expectMatch: require.True,
fieldVals: []string{"FOO barBaz"},
searchVals: []string{"baZ", "foo", "BaR"},
},
{
name: "match with custom match",
expectMatch: require.True,
fieldVals: []string{"foo", "bar", "baz"},
searchVals: []string{"foo", "bar", "tunnel"},
customFn: func(v string) bool {
return v == "tunnel"
},
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
matched := MatchSearch(tc.fieldVals, tc.searchVals, tc.customFn)
tc.expectMatch(t, matched)
})
}
}
func TestMatchSearch_ResourceSpecific(t *testing.T) {
t.Parallel()
labels := map[string]string{"env": "prod", "os": "mac"}
cases := []struct {
name string
// searchNotDefined refers to resources where the searcheable field values are not defined.
searchNotDefined bool
matchingSearchVals []string
newResource func() ResourceWithLabels
}{
{
name: "node",
matchingSearchVals: []string{"foo", "bar", "prod", "os"},
newResource: func() ResourceWithLabels {
server, err := NewServerWithLabels("_", KindNode, ServerSpecV2{
Hostname: "foo",
Addr: "bar",
}, labels)
require.NoError(t, err)
return server
},
},
{
name: "node using tunnel",
matchingSearchVals: []string{"tunnel"},
newResource: func() ResourceWithLabels {
server, err := NewServer("_", KindNode, ServerSpecV2{
UseTunnel: true,
})
require.NoError(t, err)
return server
},
},
{
name: "windows desktop",
matchingSearchVals: []string{"foo", "bar", "env", "prod", "os"},
newResource: func() ResourceWithLabels {
desktop, err := NewWindowsDesktopV3("foo", labels, WindowsDesktopSpecV3{
Addr: "bar",
})
require.NoError(t, err)
return desktop
},
},
{
name: "application",
matchingSearchVals: []string{"foo", "bar", "baz", "mac"},
newResource: func() ResourceWithLabels {
app, err := NewAppV3(Metadata{
Name: "foo",
Description: "bar",
Labels: labels,
}, AppSpecV3{
PublicAddr: "baz",
URI: "_",
})
require.NoError(t, err)
return app
},
},
{
name: "kube cluster",
matchingSearchVals: []string{"foo", "prod", "env"},
newResource: func() ResourceWithLabels {
kc, err := NewKubernetesClusterV3FromLegacyCluster("_", &KubernetesCluster{
Name: "foo",
StaticLabels: labels,
})
require.NoError(t, err)
return kc
},
},
{
name: "database",
matchingSearchVals: []string{"foo", "bar", "baz", "prod", DatabaseTypeRedshift},
newResource: func() ResourceWithLabels {
db, err := NewDatabaseV3(Metadata{
Name: "foo",
Description: "bar",
Labels: labels,
}, DatabaseSpecV3{
Protocol: "baz",
URI: "_",
AWS: AWS{
Redshift: Redshift{
ClusterID: "_",
},
},
})
require.NoError(t, err)
return db
},
},
{
name: "database with gcp keywords",
matchingSearchVals: []string{"cloud", "cloud sql"},
newResource: func() ResourceWithLabels {
db, err := NewDatabaseV3(Metadata{
Name: "_",
Labels: labels,
}, DatabaseSpecV3{
Protocol: "_",
URI: "_",
GCP: GCPCloudSQL{
ProjectID: "_",
},
})
require.NoError(t, err)
return db
},
},
{
name: "app server",
searchNotDefined: true,
newResource: func() ResourceWithLabels {
appServer, err := NewAppServerV3(Metadata{
Name: "_",
}, AppServerSpecV3{
HostID: "_",
App: &AppV3{Metadata: Metadata{Name: "_"}, Spec: AppSpecV3{URI: "_"}},
})
require.NoError(t, err)
return appServer
},
},
{
name: "db server",
searchNotDefined: true,
newResource: func() ResourceWithLabels {
dbServer, err := NewDatabaseServerV3(Metadata{
Name: "_",
}, DatabaseServerSpecV3{
HostID: "_",
Hostname: "_",
})
require.NoError(t, err)
return dbServer
},
},
{
name: "kube service",
searchNotDefined: true,
newResource: func() ResourceWithLabels {
kubeServer, err := NewServer("_", KindKubeService, ServerSpecV2{})
require.NoError(t, err)
return kubeServer
},
},
{
name: "desktop service",
searchNotDefined: true,
newResource: func() ResourceWithLabels {
desktopService, err := NewWindowsDesktopServiceV3("_", WindowsDesktopServiceSpecV3{
Addr: "_",
TeleportVersion: "_",
})
require.NoError(t, err)
return desktopService
},
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
resource := tc.newResource()
// Nil search values, should always return true
match := resource.MatchSearch(nil)
require.True(t, match)
switch {
case tc.searchNotDefined:
// Trying to search something in resources without search field values defined
// should always return false.
match := resource.MatchSearch([]string{"_"})
require.False(t, match)
default:
// Test no match.
match := resource.MatchSearch([]string{"foo", "llama"})
require.False(t, match)
// Test match.
match = resource.MatchSearch(tc.matchingSearchVals)
require.True(t, match)
}
})
}
}
+21 -3
View File
@@ -24,6 +24,7 @@ import (
"time"
"github.com/gravitational/teleport/api/constants"
"github.com/gravitational/teleport/api/utils"
"github.com/gogo/protobuf/proto"
"github.com/gravitational/trace"
@@ -31,7 +32,7 @@ import (
// Server represents a Node, Proxy or Auth server in a Teleport cluster
type Server interface {
// Resource provides common resource headers
// ResourceWithLabels provides common resource headers
ResourceWithLabels
// GetTeleportVersion returns the teleport version the server is running on
GetTeleportVersion() string
@@ -41,8 +42,6 @@ type Server interface {
GetHostname() string
// GetNamespace returns server namespace
GetNamespace() string
// GetAllLabels returns server's static and dynamic label values merged together
GetAllLabels() map[string]string
// GetLabels returns server's static label key pairs
GetLabels() map[string]string
// GetCmdLabels gets command labels
@@ -370,6 +369,25 @@ func (s *ServerV2) CheckAndSetDefaults() error {
return nil
}
// MatchSearch goes through select field values and tries to
// match against the list of search values.
func (s *ServerV2) MatchSearch(values []string) bool {
var fieldVals []string
var custom func(val string) bool
if s.GetKind() == KindNode {
fieldVals = append(utils.MapToStrings(s.GetAllLabels()), s.GetName(), s.GetHostname(), s.GetAddr())
if s.GetUseTunnel() {
custom = func(val string) bool {
return strings.EqualFold(val, "tunnel")
}
}
}
return MatchSearch(fieldVals, values, custom)
}
// DeepCopy creates a clone of this server value
func (s *ServerV2) DeepCopy() Server {
return proto.Clone(s).(*ServerV2)