From def0cb4cc1b94e4f50cf744a68bebe32a1dfa17e Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Fri, 29 Aug 2025 13:24:57 +0100 Subject: [PATCH] poc: Filter bot instances using predicate language --- .../machineid/v1/bot_instance_service.pb.go | 16 +- .../machineid/v1/bot_instance_service.proto | 2 + lib/auth/authclient/api.go | 2 +- .../machineidv1/bot_instance_service.go | 4 +- lib/cache/bot_instance.go | 55 +++---- lib/cache/bot_instance_test.go | 38 ++--- lib/services/bot_instance.go | 2 +- lib/services/local/bot_instance.go | 143 +++++++++++++----- lib/services/local/bot_instance_test.go | 6 +- lib/web/machineid.go | 1 + .../src/BotInstances/BotInstances.tsx | 22 ++- .../BotInstances/List/BotInstancesList.tsx | 10 +- web/packages/teleport/src/services/bot/bot.ts | 6 +- 13 files changed, 202 insertions(+), 105 deletions(-) diff --git a/api/gen/proto/go/teleport/machineid/v1/bot_instance_service.pb.go b/api/gen/proto/go/teleport/machineid/v1/bot_instance_service.pb.go index 71465c318a82b..4238a26f76afe 100644 --- a/api/gen/proto/go/teleport/machineid/v1/bot_instance_service.pb.go +++ b/api/gen/proto/go/teleport/machineid/v1/bot_instance_service.pb.go @@ -110,7 +110,9 @@ type ListBotInstancesRequest struct { // A search term used to filter the results. If non-empty, it's used to match against supported fields. FilterSearchTerm string `protobuf:"bytes,4,opt,name=filter_search_term,json=filterSearchTerm,proto3" json:"filter_search_term,omitempty"` // The sort config to use for the results. If empty, the default sort field and order is used. - Sort *types.SortBy `protobuf:"bytes,5,opt,name=sort,proto3" json:"sort,omitempty"` + Sort *types.SortBy `protobuf:"bytes,5,opt,name=sort,proto3" json:"sort,omitempty"` + // A query in Teleport predicate language used to filter the results. + Query string `protobuf:"bytes,6,opt,name=query,proto3" json:"query,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -180,6 +182,13 @@ func (x *ListBotInstancesRequest) GetSort() *types.SortBy { return nil } +func (x *ListBotInstancesRequest) GetQuery() string { + if x != nil { + return x.Query + } + return "" +} + // Response for ListBotInstances. type ListBotInstancesResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -382,14 +391,15 @@ const file_teleport_machineid_v1_bot_instance_service_proto_rawDesc = "" + "\x15GetBotInstanceRequest\x12\x19\n" + "\bbot_name\x18\x01 \x01(\tR\abotName\x12\x1f\n" + "\vinstance_id\x18\x02 \x01(\tR\n" + - "instanceId\"\xce\x01\n" + + "instanceId\"\xe4\x01\n" + "\x17ListBotInstancesRequest\x12&\n" + "\x0ffilter_bot_name\x18\x01 \x01(\tR\rfilterBotName\x12\x1b\n" + "\tpage_size\x18\x02 \x01(\x05R\bpageSize\x12\x1d\n" + "\n" + "page_token\x18\x03 \x01(\tR\tpageToken\x12,\n" + "\x12filter_search_term\x18\x04 \x01(\tR\x10filterSearchTerm\x12!\n" + - "\x04sort\x18\x05 \x01(\v2\r.types.SortByR\x04sort\"\x8b\x01\n" + + "\x04sort\x18\x05 \x01(\v2\r.types.SortByR\x04sort\x12\x14\n" + + "\x05query\x18\x06 \x01(\tR\x05query\"\x8b\x01\n" + "\x18ListBotInstancesResponse\x12G\n" + "\rbot_instances\x18\x01 \x03(\v2\".teleport.machineid.v1.BotInstanceR\fbotInstances\x12&\n" + "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"V\n" + diff --git a/api/proto/teleport/machineid/v1/bot_instance_service.proto b/api/proto/teleport/machineid/v1/bot_instance_service.proto index f0802486fccd4..276fa536d52b4 100644 --- a/api/proto/teleport/machineid/v1/bot_instance_service.proto +++ b/api/proto/teleport/machineid/v1/bot_instance_service.proto @@ -48,6 +48,8 @@ message ListBotInstancesRequest { string filter_search_term = 4; // The sort config to use for the results. If empty, the default sort field and order is used. types.SortBy sort = 5; + // A query in Teleport predicate language used to filter the results. + string query = 6; } // Response for ListBotInstances. diff --git a/lib/auth/authclient/api.go b/lib/auth/authclient/api.go index 9ff71e452dca6..955ba7567e2fe 100644 --- a/lib/auth/authclient/api.go +++ b/lib/auth/authclient/api.go @@ -1330,7 +1330,7 @@ type Cache interface { GetBotInstance(ctx context.Context, botName, instanceID string) (*machineidv1.BotInstance, error) // ListBotInstances returns a page of BotInstance resources. - ListBotInstances(ctx context.Context, botName string, pageSize int, lastToken string, search string, sort *types.SortBy) ([]*machineidv1.BotInstance, string, error) + ListBotInstances(ctx context.Context, botName string, pageSize int, lastToken string, search string, sort *types.SortBy, query string) ([]*machineidv1.BotInstance, string, error) // ListProvisionTokens returns a paginated list of provision tokens. ListProvisionTokens(ctx context.Context, pageSize int, pageToken string, anyRoles types.SystemRoles, botName string) ([]types.ProvisionToken, string, error) diff --git a/lib/auth/machineid/machineidv1/bot_instance_service.go b/lib/auth/machineid/machineidv1/bot_instance_service.go index 24d59081885e2..4aab780c1f47c 100644 --- a/lib/auth/machineid/machineidv1/bot_instance_service.go +++ b/lib/auth/machineid/machineidv1/bot_instance_service.go @@ -54,7 +54,7 @@ type BotInstancesCache interface { GetBotInstance(ctx context.Context, botName, instanceID string) (*pb.BotInstance, error) // ListBotInstances returns a page of BotInstance resources. - ListBotInstances(ctx context.Context, botName string, pageSize int, lastToken string, search string, sort *types.SortBy) ([]*pb.BotInstance, string, error) + ListBotInstances(ctx context.Context, botName string, pageSize int, lastToken string, search string, sort *types.SortBy, query string) ([]*pb.BotInstance, string, error) } // BotInstanceServiceConfig holds configuration options for the BotInstance gRPC @@ -157,7 +157,7 @@ func (b *BotInstanceService) ListBotInstances(ctx context.Context, req *pb.ListB return nil, trace.Wrap(err) } - res, nextToken, err := b.cache.ListBotInstances(ctx, req.FilterBotName, int(req.PageSize), req.PageToken, req.FilterSearchTerm, req.Sort) + res, nextToken, err := b.cache.ListBotInstances(ctx, req.FilterBotName, int(req.PageSize), req.PageToken, req.FilterSearchTerm, req.Sort, req.Query) if err != nil { return nil, trace.Wrap(err) } diff --git a/lib/cache/bot_instance.go b/lib/cache/bot_instance.go index b55d4905d7b4f..dbe7912cf1071 100644 --- a/lib/cache/bot_instance.go +++ b/lib/cache/bot_instance.go @@ -18,8 +18,6 @@ package cache import ( "context" - "slices" - "strings" "time" "github.com/gravitational/trace" @@ -31,6 +29,8 @@ import ( "github.com/gravitational/teleport/api/utils/clientutils" "github.com/gravitational/teleport/lib/itertools/stream" "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/lib/services/local" + "github.com/gravitational/teleport/lib/utils/typical" ) type botInstanceIndex string @@ -85,7 +85,7 @@ func newBotInstanceCollection(upstream services.BotInstance, w types.WatchKind) fetcher: func(ctx context.Context, loadSecrets bool) ([]*machineidv1.BotInstance, error) { out, err := stream.Collect(clientutils.Resources(ctx, func(ctx context.Context, limit int, start string) ([]*machineidv1.BotInstance, string, error) { - return upstream.ListBotInstances(ctx, "", limit, start, "", nil) + return upstream.ListBotInstances(ctx, "", limit, start, "", nil, "") }, )) return out, trace.Wrap(err) @@ -113,7 +113,7 @@ func (c *Cache) GetBotInstance(ctx context.Context, botName, instanceID string) } // ListBotInstances returns a page of BotInstance resources. -func (c *Cache) ListBotInstances(ctx context.Context, botName string, pageSize int, lastToken string, search string, sort *types.SortBy) ([]*machineidv1.BotInstance, string, error) { +func (c *Cache) ListBotInstances(ctx context.Context, botName string, pageSize int, lastToken string, search string, sort *types.SortBy, query string) ([]*machineidv1.BotInstance, string, error) { ctx, span := c.Tracer.Start(ctx, "cache/ListBotInstances") defer span.End() @@ -135,6 +135,18 @@ func (c *Cache) ListBotInstances(ctx context.Context, botName string, pageSize i } } + var exp typical.Expression[*local.Environment, bool] + if query != "" { + parser, err := local.NewBotInstanceExpressionParser() + if err != nil { + return nil, "", trace.Wrap(err) + } + exp, err = parser.Parse(query) + if err != nil { + return nil, "", trace.Wrap(err) + } + } + lister := genericLister[*machineidv1.BotInstance, botInstanceIndex]{ cache: c, collection: c.collections.botInstances, @@ -142,10 +154,10 @@ func (c *Cache) ListBotInstances(ctx context.Context, botName string, pageSize i isDesc: isDesc, defaultPageSize: defaults.DefaultChunkSize, upstreamList: func(ctx context.Context, limit int, start string) ([]*machineidv1.BotInstance, string, error) { - return c.Config.BotInstanceService.ListBotInstances(ctx, botName, limit, start, search, sort) + return c.Config.BotInstanceService.ListBotInstances(ctx, botName, limit, start, search, sort, query) }, filter: func(b *machineidv1.BotInstance) bool { - return matchBotInstance(b, botName, search) + return local.MatchBotInstance(b, botName, search, exp) }, nextToken: func(b *machineidv1.BotInstance) string { return keyFn(b) @@ -157,34 +169,3 @@ func (c *Cache) ListBotInstances(ctx context.Context, botName string, pageSize i ) return out, next, trace.Wrap(err) } - -func matchBotInstance(b *machineidv1.BotInstance, botName string, search string) bool { - // If updating this, ensure it's consistent with the upstream search logic in `lib/services/local/bot_instance.go`. - - if botName != "" && b.Spec.BotName != botName { - return false - } - - if search == "" { - return true - } - - latestHeartbeats := b.GetStatus().GetLatestHeartbeats() - heartbeat := b.Status.InitialHeartbeat // Use initial heartbeat as a fallback - if len(latestHeartbeats) > 0 { - heartbeat = latestHeartbeats[len(latestHeartbeats)-1] - } - - values := []string{ - b.Spec.BotName, - b.Spec.InstanceId, - } - - if heartbeat != nil { - values = append(values, heartbeat.Hostname, heartbeat.JoinMethod, heartbeat.Version, "v"+heartbeat.Version) - } - - return slices.ContainsFunc(values, func(val string) bool { - return strings.Contains(strings.ToLower(val), strings.ToLower(search)) - }) -} diff --git a/lib/cache/bot_instance_test.go b/lib/cache/bot_instance_test.go index 0503476b3fb2b..9c2e279d61252 100644 --- a/lib/cache/bot_instance_test.go +++ b/lib/cache/bot_instance_test.go @@ -56,7 +56,7 @@ func TestBotInstanceCache(t *testing.T) { return p.cache.GetBotInstance(ctx, "bot-1", key) }, cacheList: func(ctx context.Context) ([]*machineidv1.BotInstance, error) { - results, _, err := p.cache.ListBotInstances(ctx, "", 0, "", "", nil) + results, _, err := p.cache.ListBotInstances(ctx, "", 0, "", "", nil, "") return results, err }, create: func(ctx context.Context, resource *machineidv1.BotInstance) error { @@ -64,7 +64,7 @@ func TestBotInstanceCache(t *testing.T) { return err }, list: func(ctx context.Context) ([]*machineidv1.BotInstance, error) { - results, _, err := p.botInstanceService.ListBotInstances(ctx, "", 0, "", "", nil) + results, _, err := p.botInstanceService.ListBotInstances(ctx, "", 0, "", "", nil, "") return results, err }, update: func(ctx context.Context, bi *machineidv1.BotInstance) error { @@ -107,13 +107,13 @@ func TestBotInstanceCachePaging(t *testing.T) { // Let the cache catch up require.EventuallyWithT(t, func(t *assert.CollectT) { - results, _, err := p.cache.ListBotInstances(ctx, "", 0, "", "", nil) + results, _, err := p.cache.ListBotInstances(ctx, "", 0, "", "", nil, "") require.NoError(t, err) require.Len(t, results, 5) }, 10*time.Second, 100*time.Millisecond) // page size equal to total items - results, nextPageToken, err := p.cache.ListBotInstances(ctx, "", 0, "", "", nil) + results, nextPageToken, err := p.cache.ListBotInstances(ctx, "", 0, "", "", nil, "") require.NoError(t, err) require.Empty(t, nextPageToken) require.Len(t, results, 5) @@ -124,7 +124,7 @@ func TestBotInstanceCachePaging(t *testing.T) { require.Equal(t, "instance-5", results[4].GetMetadata().GetName()) // page size smaller than total items - results, nextPageToken, err = p.cache.ListBotInstances(ctx, "", 3, "", "", nil) + results, nextPageToken, err = p.cache.ListBotInstances(ctx, "", 3, "", "", nil, "") require.NoError(t, err) require.Equal(t, "bot-1/instance-4", nextPageToken) require.Len(t, results, 3) @@ -133,7 +133,7 @@ func TestBotInstanceCachePaging(t *testing.T) { require.Equal(t, "instance-3", results[2].GetMetadata().GetName()) // next page - results, nextPageToken, err = p.cache.ListBotInstances(ctx, "", 3, nextPageToken, "", nil) + results, nextPageToken, err = p.cache.ListBotInstances(ctx, "", 3, nextPageToken, "", nil, "") require.NoError(t, err) require.Empty(t, nextPageToken) require.Len(t, results, 2) @@ -166,12 +166,12 @@ func TestBotInstanceCacheBotFilter(t *testing.T) { // Let the cache catch up require.EventuallyWithT(t, func(t *assert.CollectT) { - results, _, err := p.cache.ListBotInstances(ctx, "", 0, "", "", nil) + results, _, err := p.cache.ListBotInstances(ctx, "", 0, "", "", nil, "") require.NoError(t, err) require.Len(t, results, 10) }, 10*time.Second, 100*time.Millisecond) - results, _, err := p.cache.ListBotInstances(ctx, "bot-1", 0, "", "", nil) + results, _, err := p.cache.ListBotInstances(ctx, "bot-1", 0, "", "", nil, "") require.NoError(t, err) require.Len(t, results, 5) @@ -213,12 +213,12 @@ func TestBotInstanceCacheSearchFilter(t *testing.T) { // Let the cache catch up require.EventuallyWithT(t, func(t *assert.CollectT) { - results, _, err := p.cache.ListBotInstances(ctx, "", 0, "", "", nil) + results, _, err := p.cache.ListBotInstances(ctx, "", 0, "", "", nil, "") require.NoError(t, err) require.Len(t, results, 10) }, 10*time.Second, 100*time.Millisecond) - results, _, err := p.cache.ListBotInstances(ctx, "", 0, "", "host-1", nil) + results, _, err := p.cache.ListBotInstances(ctx, "", 0, "", "host-1", nil, "") require.NoError(t, err) require.Len(t, results, 5) } @@ -268,7 +268,7 @@ func TestBotInstanceCacheSorting(t *testing.T) { // Let the cache catch up require.EventuallyWithT(t, func(t *assert.CollectT) { - results, _, err := p.cache.ListBotInstances(ctx, "", 0, "", "", nil) + results, _, err := p.cache.ListBotInstances(ctx, "", 0, "", "", nil, "") require.NoError(t, err) require.Len(t, results, 3) }, 10*time.Second, 100*time.Millisecond) @@ -277,7 +277,7 @@ func TestBotInstanceCacheSorting(t *testing.T) { results, _, err := p.cache.ListBotInstances(ctx, "", 0, "", "", &types.SortBy{ Field: "active_at_latest", IsDesc: false, - }) + }, "") require.NoError(t, err) require.Len(t, results, 3) require.Equal(t, "instance-3", results[0].GetMetadata().GetName()) @@ -288,7 +288,7 @@ func TestBotInstanceCacheSorting(t *testing.T) { results, _, err = p.cache.ListBotInstances(ctx, "", 0, "", "", &types.SortBy{ Field: "active_at_latest", IsDesc: true, - }) + }, "") require.NoError(t, err) require.Len(t, results, 3) require.Equal(t, "instance-2", results[0].GetMetadata().GetName()) @@ -296,7 +296,7 @@ func TestBotInstanceCacheSorting(t *testing.T) { require.Equal(t, "instance-3", results[2].GetMetadata().GetName()) // sort ascending by bot_name - results, _, err = p.cache.ListBotInstances(ctx, "", 0, "", "", nil) // empty sort should default to `bot_name:asc` + results, _, err = p.cache.ListBotInstances(ctx, "", 0, "", "", nil, "") // empty sort should default to `bot_name:asc` require.NoError(t, err) require.Len(t, results, 3) require.Equal(t, "instance-1", results[0].GetMetadata().GetName()) @@ -307,7 +307,7 @@ func TestBotInstanceCacheSorting(t *testing.T) { results, _, err = p.cache.ListBotInstances(ctx, "", 0, "", "", &types.SortBy{ Field: "bot_name", IsDesc: true, - }) + }, "") require.NoError(t, err) require.Len(t, results, 3) require.Equal(t, "instance-2", results[0].GetMetadata().GetName()) @@ -341,7 +341,7 @@ func TestBotInstanceCacheFallback(t *testing.T) { // Let the cache catch up require.EventuallyWithT(t, func(t *assert.CollectT) { - results, _, err := p.cache.ListBotInstances(ctx, "", 0, "", "", nil) + results, _, err := p.cache.ListBotInstances(ctx, "", 0, "", "", nil, "") require.NoError(t, err) require.Len(t, results, 1) }, 10*time.Second, 100*time.Millisecond) @@ -350,7 +350,7 @@ func TestBotInstanceCacheFallback(t *testing.T) { results, _, err := p.cache.ListBotInstances(ctx, "", 0, "", "", &types.SortBy{ Field: "bot_name", IsDesc: false, - }) + }, "") require.NoError(t, err) // asc by bot_name is the only sort supported by the upstream require.Len(t, results, 1) @@ -358,7 +358,7 @@ func TestBotInstanceCacheFallback(t *testing.T) { _, _, err = p.cache.ListBotInstances(ctx, "", 0, "", "", &types.SortBy{ Field: "bot_name", IsDesc: true, - }) + }, "") require.Error(t, err) require.Equal(t, "unsupported sort, only bot_name:asc is supported, but got \"bot_name\" (desc = true)", err.Error()) @@ -366,7 +366,7 @@ func TestBotInstanceCacheFallback(t *testing.T) { _, _, err = p.cache.ListBotInstances(ctx, "", 0, "", "", &types.SortBy{ Field: "active_at_latest", IsDesc: false, - }) + }, "") require.Error(t, err) require.Equal(t, "unsupported sort, only bot_name:asc is supported, but got \"active_at_latest\" (desc = false)", err.Error()) diff --git a/lib/services/bot_instance.go b/lib/services/bot_instance.go index 50beac8a76472..04a79a41588a7 100644 --- a/lib/services/bot_instance.go +++ b/lib/services/bot_instance.go @@ -34,7 +34,7 @@ type BotInstance interface { GetBotInstance(ctx context.Context, botName, instanceID string) (*machineidv1.BotInstance, error) // ListBotInstances - ListBotInstances(ctx context.Context, botName string, pageSize int, lastToken string, search string, sort *types.SortBy) ([]*machineidv1.BotInstance, string, error) + ListBotInstances(ctx context.Context, botName string, pageSize int, lastToken string, search string, sort *types.SortBy, query string) ([]*machineidv1.BotInstance, string, error) // DeleteBotInstance DeleteBotInstance(ctx context.Context, botName, instanceID string) error diff --git a/lib/services/local/bot_instance.go b/lib/services/local/bot_instance.go index 9240bc5c40b27..f095d9c297a75 100644 --- a/lib/services/local/bot_instance.go +++ b/lib/services/local/bot_instance.go @@ -21,6 +21,7 @@ import ( "slices" "strings" + "github.com/coreos/go-semver/semver" "github.com/gravitational/trace" "github.com/jonboulle/clockwork" @@ -31,6 +32,7 @@ import ( "github.com/gravitational/teleport/lib/backend" "github.com/gravitational/teleport/lib/services" "github.com/gravitational/teleport/lib/services/local/generic" + "github.com/gravitational/teleport/lib/utils/typical" ) const ( @@ -91,12 +93,12 @@ func (b *BotInstanceService) GetBotInstance(ctx context.Context, botName, instan return instance, trace.Wrap(err) } -// ListBotInstances lists all matching bot instances. A bot name and/or search terms can be optionally provided. +// ListBotInstances lists all matching bot instances. A bot name, search terms, and/or query can be optionally provided. // If an non-empty bot name is provided, only instances for that bot will be fetched. // If an non-empty search term is provided, only instances with a value containing the term in supported fields are fetched. // Supported search fields include; bot name, instance id, hostname (latest), tbot version (latest), join method (latest). // Sorting by bot name in ascending order is supported - an error is returned for any other sort type. -func (b *BotInstanceService) ListBotInstances(ctx context.Context, botName string, pageSize int, lastKey string, search string, sort *types.SortBy) ([]*machineidv1.BotInstance, string, error) { +func (b *BotInstanceService) ListBotInstances(ctx context.Context, botName string, pageSize int, lastKey string, search string, sort *types.SortBy, query string) ([]*machineidv1.BotInstance, string, error) { if sort != nil && (sort.Field != "bot_name" || sort.IsDesc != false) { return nil, "", trace.BadParameter("unsupported sort, only bot_name:asc is supported, but got %q (desc = %t)", sort.Field, sort.IsDesc) } @@ -114,44 +116,25 @@ func (b *BotInstanceService) ListBotInstances(ctx context.Context, botName strin return r, nextToken, trace.Wrap(err) } + var exp typical.Expression[*Environment, bool] + if query != "" { + parser, err := NewBotInstanceExpressionParser() + if err != nil { + return nil, "", trace.Wrap(err) + } + exp, err = parser.Parse(query) + if err != nil { + return nil, "", trace.Wrap(err) + } + } + r, nextToken, err := service.ListResourcesWithFilter(ctx, pageSize, lastKey, func(item *machineidv1.BotInstance) bool { - return matchBotInstance(item, botName, search) + return MatchBotInstance(item, botName, search, exp) }) return r, nextToken, trace.Wrap(err) } -func matchBotInstance(b *machineidv1.BotInstance, botName string, search string) bool { - // If updating this, ensure it's consistent with the cache search logic in `lib/cache/bot_instance.go`. - - if botName != "" && b.Spec.BotName != botName { - return false - } - - if search == "" { - return true - } - - latestHeartbeats := b.GetStatus().GetLatestHeartbeats() - heartbeat := b.Status.InitialHeartbeat // Use initial heartbeat as a fallback - if len(latestHeartbeats) > 0 { - heartbeat = latestHeartbeats[len(latestHeartbeats)-1] - } - - values := []string{ - b.Spec.BotName, - b.Spec.InstanceId, - } - - if heartbeat != nil { - values = append(values, heartbeat.Hostname, heartbeat.JoinMethod, heartbeat.Version, "v"+heartbeat.Version) - } - - return slices.ContainsFunc(values, func(val string) bool { - return strings.Contains(strings.ToLower(val), strings.ToLower(search)) - }) -} - // DeleteBotInstance deletes a specific bot instance matching the given bot name // and instance ID. func (b *BotInstanceService) DeleteBotInstance(ctx context.Context, botName, instanceID string) error { @@ -213,3 +196,95 @@ func (b *BotInstanceService) PatchBotInstance( return nil, trace.CompareFailed("failed to update bot instance within %v iterations", iterLimit) } + +func MatchBotInstance(b *machineidv1.BotInstance, botName string, search string, exp typical.Expression[*Environment, bool]) bool { + if botName != "" && b.Spec.BotName != botName { + return false + } + + latestHeartbeats := b.GetStatus().GetLatestHeartbeats() + heartbeat := b.Status.InitialHeartbeat // Use initial heartbeat as a fallback + if len(latestHeartbeats) > 0 { + heartbeat = latestHeartbeats[len(latestHeartbeats)-1] + } + + if exp != nil { + if match, err := exp.Evaluate(&Environment{ + Instance: b, + LatestHeartbeat: heartbeat, + }); err != nil || !match { + return false + } + } + + if search == "" { + return true + } + + values := []string{ + b.Spec.BotName, + b.Spec.InstanceId, + } + + if heartbeat != nil { + values = append(values, heartbeat.Hostname, heartbeat.JoinMethod, heartbeat.Version, "v"+heartbeat.Version) + } + + return slices.ContainsFunc(values, func(val string) bool { + return strings.Contains(strings.ToLower(val), strings.ToLower(search)) + }) +} + +// Environment in which expressions will be evaluated. +type Environment struct { + Instance *machineidv1.BotInstance + LatestHeartbeat *machineidv1.BotInstanceStatusHeartbeat +} + +// message satisfies messageEnv[T]. +// func (env *Environment) message() *pb.BotInstance { return env.instance } + +// TODO Docs for NewBotInstanceExpressionParser +func NewBotInstanceExpressionParser() (*typical.Parser[*Environment, bool], error) { + return typical.NewParser[*Environment, bool](typical.ParserSpec[*Environment]{ + Variables: map[string]typical.Variable{ + "name": typical.DynamicVariable(func(env *Environment) (string, error) { + return env.Instance.Metadata.Name, nil + }), + "version": typical.DynamicVariable(func(env *Environment) (string, error) { + if env.LatestHeartbeat == nil { + return "", nil + } + return env.LatestHeartbeat.Version, nil + }), + }, + Functions: map[string]typical.Function{ + "semver_gte": typical.BinaryFunction[*Environment]( + func(a string, b string) (bool, error) { + va, err := semver.NewVersion(a) + if err != nil { + return false, err + } + vb, err := semver.NewVersion(b) + if err != nil { + return false, err + } + compare := va.Compare(*vb) + return compare >= 0, nil + }), + "semver_lt": typical.BinaryFunction[*Environment]( + func(a string, b string) (bool, error) { + va, err := semver.NewVersion(a) + if err != nil { + return false, err + } + vb, err := semver.NewVersion(b) + if err != nil { + return false, err + } + compare := va.Compare(*vb) + return compare < 0, nil + }), + }, + }) +} diff --git a/lib/services/local/bot_instance_test.go b/lib/services/local/bot_instance_test.go index 7d0b4e49f8f26..77a0007828f87 100644 --- a/lib/services/local/bot_instance_test.go +++ b/lib/services/local/bot_instance_test.go @@ -168,7 +168,7 @@ func listInstances(t *testing.T, ctx context.Context, service *BotInstanceServic var err error for { - bis, nextKey, err = service.ListBotInstances(ctx, botName, 0, nextKey, searchTerm, sort) + bis, nextKey, err = service.ListBotInstances(ctx, botName, 0, nextKey, searchTerm, sort, "") require.NoError(t, err) resources = append(resources, bis...) @@ -473,10 +473,10 @@ func TestBotInstanceListWithSort(t *testing.T) { _, _, err = service.ListBotInstances(ctx, "", 0, "", "", &types.SortBy{ Field: "test_field", IsDesc: true, - }) + }, "") require.Error(t, err) require.Equal(t, "unsupported sort, only bot_name:asc is supported, but got \"test_field\" (desc = true)", err.Error()) - _, _, err = service.ListBotInstances(ctx, "", 0, "", "", nil) + _, _, err = service.ListBotInstances(ctx, "", 0, "", "", nil, "") require.NoError(t, err) } diff --git a/lib/web/machineid.go b/lib/web/machineid.go index 169ad98c69a20..281c309b88ddc 100644 --- a/lib/web/machineid.go +++ b/lib/web/machineid.go @@ -417,6 +417,7 @@ func (h *Handler) listBotInstances(_ http.ResponseWriter, r *http.Request, _ htt PageToken: r.URL.Query().Get("page_token"), FilterSearchTerm: r.URL.Query().Get("search"), Sort: sort, + Query: r.URL.Query().Get("query"), }) if err != nil { return nil, trace.Wrap(err) diff --git a/web/packages/teleport/src/BotInstances/BotInstances.tsx b/web/packages/teleport/src/BotInstances/BotInstances.tsx index f8b1e64dcf293..76fe94d44ec5b 100644 --- a/web/packages/teleport/src/BotInstances/BotInstances.tsx +++ b/web/packages/teleport/src/BotInstances/BotInstances.tsx @@ -52,6 +52,7 @@ export function BotInstances() { const queryParams = new URLSearchParams(location.search); const pageToken = queryParams.get('page') ?? ''; const searchTerm = queryParams.get('search') ?? ''; + const query = queryParams.get('query') ?? ''; const sort = queryParams.get('sort') || 'active_at_latest:desc'; const ctx = useTeleport(); @@ -60,12 +61,13 @@ export function BotInstances() { const { isPending, isFetching, isSuccess, isError, error, data } = useQuery({ enabled: canListInstances, - queryKey: ['bot_instances', 'list', searchTerm, pageToken, sort], + queryKey: ['bot_instances', 'list', searchTerm, query, pageToken, sort], queryFn: () => listBotInstances({ pageSize: 20, pageToken, searchTerm, + query, sort, }), placeholderData: keepPreviousData, @@ -120,6 +122,22 @@ export function BotInstances() { (term: string) => { const search = new URLSearchParams(location.search); search.set('search', term); + search.set('query', ''); + search.set('page', ''); + + history.replace({ + pathname: `${location.pathname}`, + search: search.toString(), + }); + }, + [history, location.pathname, location.search] + ); + + const handleQueryChange = useCallback( + (exp: string) => { + const search = new URLSearchParams(location.search); + search.set('query', exp); + search.set('search', ''); search.set('page', ''); history.replace({ @@ -212,7 +230,9 @@ export function BotInstances() { onFetchNext={hasNextPage ? handleFetchNext : undefined} onFetchPrev={hasPrevPage ? handleFetchPrev : undefined} onSearchChange={handleSearchChange} + onQueryChange={handleQueryChange} searchTerm={searchTerm} + query={query} onItemSelected={onItemSelected} sortType={sortType} onSortChanged={handleSortChanged} diff --git a/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx b/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx index bade432d1792b..f30d5d8df9be9 100644 --- a/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx +++ b/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx @@ -43,14 +43,18 @@ export function BotInstancesList({ onFetchNext, onFetchPrev, searchTerm, + query, onSearchChange, + onQueryChange, onItemSelected, sortType, onSortChanged, }: { data: BotInstanceSummary[]; searchTerm: string; + query: string; onSearchChange: (term: string) => void; + onQueryChange: (term: string) => void; onItemSelected: (item: BotInstanceSummary) => void; sortType: SortType; onSortChanged: (sortType: SortType) => void; @@ -83,9 +87,9 @@ export function BotInstancesList({ serversideSearchPanel: ( ), diff --git a/web/packages/teleport/src/services/bot/bot.ts b/web/packages/teleport/src/services/bot/bot.ts index c6d62ca52435f..b3a457e389e41 100644 --- a/web/packages/teleport/src/services/bot/bot.ts +++ b/web/packages/teleport/src/services/bot/bot.ts @@ -178,10 +178,11 @@ export async function listBotInstances( searchTerm?: string; sort?: string; botName?: string; + query?: string; }, signal?: AbortSignal ) { - const { pageToken, pageSize, searchTerm, sort, botName } = variables; + const { pageToken, pageSize, searchTerm, sort, botName, query } = variables; const path = cfg.getBotInstanceUrl({ action: 'list' }); const qs = new URLSearchParams(); @@ -197,6 +198,9 @@ export async function listBotInstances( if (botName) { qs.set('bot_name', botName); } + if (query) { + qs.set('query', query); + } const data = await api.get(`${path}?${qs.toString()}`, signal);