Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
67b01af
Add navigation item
nicholasmarais1158 Apr 30, 2025
95a3582
Add bot instances page
nicholasmarais1158 May 19, 2025
83fc0e1
Populate page info
nicholasmarais1158 May 19, 2025
e638e5f
Add missing license header
nicholasmarais1158 May 19, 2025
43dc733
Update bot instance nav icon
nicholasmarais1158 May 20, 2025
e6eaa19
Reword hint text
nicholasmarais1158 May 20, 2025
335b6ab
Add active at timestamp tooltip
nicholasmarais1158 May 20, 2025
1066813
Rename active at header
nicholasmarais1158 May 20, 2025
7c4123e
Remove copy and sort
nicholasmarais1158 May 20, 2025
dc08014
Merge branch 'master' into nickmarais/feat/53584-bot-instances-list
nicholasmarais1158 May 20, 2025
153fa1a
Lint and test fixes
nicholasmarais1158 May 20, 2025
d7099f3
Merge branch 'master' into nickmarais/feat/53584-bot-instances-list
nicholasmarais1158 May 20, 2025
271078c
Merge branch 'master' into nickmarais/feat/53584-bot-instances-list
nicholasmarais1158 May 21, 2025
8bad36a
Support backend search term filtering
nicholasmarais1158 May 21, 2025
a34457c
Simplify code
nicholasmarais1158 May 21, 2025
b0a0943
Add search term filter auth tests
nicholasmarais1158 May 21, 2025
f546d13
Merge branch 'master' into nickmarais/feat/53584-bot-instances-list
nicholasmarais1158 May 21, 2025
fa97b72
Allow disabling `<Table />` loading indicator
nicholasmarais1158 May 22, 2025
2b1a95d
Rework frontend to use backend paging and filter (remove sort)
nicholasmarais1158 May 22, 2025
b057e28
Merge branch 'master' into nickmarais/feat/53584-bot-instances-list
nicholasmarais1158 May 22, 2025
dbdc06a
Fix lint (ts)
nicholasmarais1158 May 22, 2025
423f2f8
Remove unused BotInstances icon
nicholasmarais1158 May 22, 2025
07d219e
Add URL state (page and search)
nicholasmarais1158 May 22, 2025
9b4f244
Use nil-safe getters on protos
nicholasmarais1158 May 22, 2025
320e80e
Fix crash on items w/o heartbeat data
nicholasmarais1158 May 22, 2025
9d410f6
Merge branch 'master' into nickmarais/feat/53584-bot-instances-list
nicholasmarais1158 May 22, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions api/gen/proto/go/teleport/machineid/v1/bot_instance_service.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions api/proto/teleport/machineid/v1/bot_instance_service.proto
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ message ListBotInstancesRequest {
// The page_token value returned from a previous ListBotInstances request, if
// any.
string page_token = 3;
// A search term used to filter the results. If non-empty, it's used to match against supported fields.
string filter_search_term = 4;
}

// Response for ListBotInstances.
Expand Down
2 changes: 1 addition & 1 deletion lib/auth/machineid/machineidv1/bot_instance_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ func (b *BotInstanceService) ListBotInstances(ctx context.Context, req *pb.ListB
return nil, trace.Wrap(err)
}

res, nextToken, err := b.backend.ListBotInstances(ctx, req.FilterBotName, int(req.PageSize), req.PageToken)
res, nextToken, err := b.backend.ListBotInstances(ctx, req.FilterBotName, int(req.PageSize), req.PageToken, req.FilterSearchTerm)
if err != nil {
return nil, trace.Wrap(err)
}
Expand Down
2 changes: 1 addition & 1 deletion lib/services/bot_instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,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) ([]*machineidv1.BotInstance, string, error)
ListBotInstances(ctx context.Context, botName string, pageSize int, lastToken string, search string) ([]*machineidv1.BotInstance, string, error)

// DeleteBotInstance
DeleteBotInstance(ctx context.Context, botName, instanceID string) error
Expand Down
44 changes: 37 additions & 7 deletions lib/services/local/bot_instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ package local

import (
"context"
"slices"
"strings"

"github.com/gravitational/trace"
"github.com/jonboulle/clockwork"
Expand Down Expand Up @@ -89,17 +91,45 @@ func (b *BotInstanceService) GetBotInstance(ctx context.Context, botName, instan
return instance, trace.Wrap(err)
}

// ListBotInstances lists all bot instances matching the given bot name filter.
// If an empty bot name is provided, all bot instances will be fetched.
func (b *BotInstanceService) ListBotInstances(ctx context.Context, botName string, pageSize int, lastKey string) ([]*machineidv1.BotInstance, string, error) {
// If botName is empty, return instances for all bots by not using a service prefix
// ListBotInstances lists all matching bot instances. A bot name and/or search terms 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)
func (b *BotInstanceService) ListBotInstances(ctx context.Context, botName string, pageSize int, lastKey string, search string) ([]*machineidv1.BotInstance, string, error) {
Comment thread
strideynet marked this conversation as resolved.
var service *generic.ServiceWrapper[*machineidv1.BotInstance]
if botName == "" {
r, nextToken, err := b.service.ListResources(ctx, pageSize, lastKey)
// If botName is empty, return instances for all bots by not using a service prefix
service = b.service
} else {
service = b.service.WithPrefix(botName)
}

if search == "" {
r, nextToken, err := service.ListResources(ctx, pageSize, lastKey)
return r, nextToken, trace.Wrap(err)
}

serviceWithPrefix := b.service.WithPrefix(botName)
r, nextToken, err := serviceWithPrefix.ListResources(ctx, pageSize, lastKey)
r, nextToken, err := service.ListResourcesWithFilter(ctx, pageSize, lastKey, func(item *machineidv1.BotInstance) bool {
latestHeartbeats := item.GetStatus().GetLatestHeartbeats()
heartbeat := item.Status.InitialHeartbeat // Use initial heartbeat as a fallback
if len(latestHeartbeats) > 0 {
heartbeat = latestHeartbeats[len(latestHeartbeats)-1]
}

values := []string{
item.Spec.BotName,
item.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))
})
})

return r, nextToken, trace.Wrap(err)
}

Expand Down
152 changes: 141 additions & 11 deletions lib/services/local/bot_instance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,73 @@ func withBotInstanceExpiry(expiry time.Time) func(*machineidv1.BotInstance) {
}
}

// withBotInstanceId sets the .Spec.InstanceId field of a bot instance to
// the given value.
func withBotInstanceId(value string) func(*machineidv1.BotInstance) {
return func(bi *machineidv1.BotInstance) {
if bi.Spec == nil {
bi.Spec = &machineidv1.BotInstanceSpec{}
}

bi.Spec.InstanceId = value
}
}

// withBotInstanceHeartbeatJoinMethod sets the .Status.InitialHeartbeat.JoinMethod
// field of a bot instance to the given value.
func withBotInstanceHeartbeatJoinMethod(value string) func(*machineidv1.BotInstance) {
return func(bi *machineidv1.BotInstance) {
if bi.Status == nil {
bi.Status = &machineidv1.BotInstanceStatus{}
}

if bi.Status.InitialHeartbeat == nil {
bi.Status.InitialHeartbeat = &machineidv1.BotInstanceStatusHeartbeat{}
}

bi.Status.InitialHeartbeat.JoinMethod = value
}
}

// withBotInstanceHeartbeatVersion sets the .Status.InitialHeartbeat.Version
// field of a bot instance to the given value.
func withBotInstanceHeartbeatVersion(value string) func(*machineidv1.BotInstance) {
return func(bi *machineidv1.BotInstance) {
if bi.Status == nil {
bi.Status = &machineidv1.BotInstanceStatus{}
}

if bi.Status.InitialHeartbeat == nil {
bi.Status.InitialHeartbeat = &machineidv1.BotInstanceStatusHeartbeat{}
}

bi.Status.InitialHeartbeat.Version = value
}
}

// withBotInstanceHeartbeatHostname sets the .Status.InitialHeartbeat.Hostname
// field of a bot instance to the given value.
func withBotInstanceHeartbeatHostname(value string) func(*machineidv1.BotInstance) {
return func(bi *machineidv1.BotInstance) {
if bi.Status == nil {
bi.Status = &machineidv1.BotInstanceStatus{}
}

if bi.Status.InitialHeartbeat == nil {
bi.Status.InitialHeartbeat = &machineidv1.BotInstanceStatusHeartbeat{}
}

bi.Status.InitialHeartbeat.Hostname = value
}
}

// createInstances creates new bot instances for the named bot with random UUIDs
func createInstances(t *testing.T, ctx context.Context, service *BotInstanceService, botName string, count int) map[string]struct{} {
t.Helper()

ids := map[string]struct{}{}

for i := 0; i < count; i++ {
for range count {
bi := newBotInstance(botName)
_, err := service.CreateBotInstance(ctx, bi)
require.NoError(t, err)
Expand All @@ -99,7 +159,7 @@ func createInstances(t *testing.T, ctx context.Context, service *BotInstanceServ
}

// listInstances fetches all instances from the BotInstanceService matching the botName filter
func listInstances(t *testing.T, ctx context.Context, service *BotInstanceService, botName string) []*machineidv1.BotInstance {
func listInstances(t *testing.T, ctx context.Context, service *BotInstanceService, botName string, searchTerm string) []*machineidv1.BotInstance {
t.Helper()

var resources []*machineidv1.BotInstance
Expand All @@ -108,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)
bis, nextKey, err = service.ListBotInstances(ctx, botName, 0, nextKey, searchTerm)
require.NoError(t, err)

resources = append(resources, bis...)
Expand Down Expand Up @@ -138,7 +198,7 @@ func TestBotInstanceCreateMetadata(t *testing.T) {
name: "non-nil metadata",
instance: newBotInstance("foo", withBotInstanceInvalidMetadata()),
assertError: require.NoError,
assertValue: func(t require.TestingT, i interface{}, _ ...interface{}) {
assertValue: func(t require.TestingT, i any, _ ...any) {
bi, ok := i.(*machineidv1.BotInstance)
require.True(t, ok)

Expand All @@ -151,7 +211,7 @@ func TestBotInstanceCreateMetadata(t *testing.T) {
name: "valid without expiry",
instance: newBotInstance("foo"),
assertError: require.NoError,
assertValue: func(t require.TestingT, i interface{}, _ ...interface{}) {
assertValue: func(t require.TestingT, i any, _ ...any) {
bi, ok := i.(*machineidv1.BotInstance)
require.True(t, ok)

Expand All @@ -163,7 +223,7 @@ func TestBotInstanceCreateMetadata(t *testing.T) {
name: "valid with expiry",
instance: newBotInstance("foo", withBotInstanceExpiry(clock.Now().Add(time.Hour))),
assertError: require.NoError,
assertValue: func(t require.TestingT, i interface{}, _ ...interface{}) {
assertValue: func(t require.TestingT, i any, _ ...any) {
bi, ok := i.(*machineidv1.BotInstance)
require.True(t, ok)

Expand Down Expand Up @@ -247,7 +307,7 @@ func TestBotInstanceCRUD(t *testing.T) {
require.EqualExportedValues(t, patched, bi2)
require.Equal(t, bi.Metadata.Name, bi2.Metadata.Name)

resources := listInstances(t, ctx, service, "example")
resources := listInstances(t, ctx, service, "example", "")

require.Len(t, resources, 1, "must list only 1 bot instance")
require.EqualExportedValues(t, patched, resources[0])
Expand All @@ -273,7 +333,7 @@ func TestBotInstanceCRUD(t *testing.T) {
require.Error(t, service.DeleteBotInstance(ctx, bi.Spec.BotName, bi.Spec.InstanceId))
}

// TestBotInstanceList verifies list and filtering functionality for bot
// TestBotInstanceList verifies list and filtering by bot functionality for bot
// instances.
func TestBotInstanceList(t *testing.T) {
t.Parallel()
Expand All @@ -294,14 +354,14 @@ func TestBotInstanceList(t *testing.T) {
bIds := createInstances(t, ctx, service, "b", 4)

// listing "a" should only return known "a" instances
aInstances := listInstances(t, ctx, service, "a")
aInstances := listInstances(t, ctx, service, "a", "")
require.Len(t, aInstances, 3)
for _, ins := range aInstances {
require.Contains(t, aIds, ins.Spec.InstanceId)
}

// listing "b" should only return known "b" instances
bInstances := listInstances(t, ctx, service, "b")
bInstances := listInstances(t, ctx, service, "b", "")
require.Len(t, bInstances, 4)
for _, ins := range bInstances {
require.Contains(t, bIds, ins.Spec.InstanceId)
Expand All @@ -316,9 +376,79 @@ func TestBotInstanceList(t *testing.T) {
}

// Listing an empty bot name ("") should return all instances.
allInstances := listInstances(t, ctx, service, "")
allInstances := listInstances(t, ctx, service, "", "")
require.Len(t, allInstances, 7)
for _, ins := range allInstances {
require.Contains(t, allIds, ins.Spec.InstanceId)
}
}

// TestBotInstanceListWithSearchFilter verifies list and filtering wit39db3c10-870c-4544-aeec-9fc2e961eca3h search
// term functionality for bot instances.
func TestBotInstanceListWithSearchFilter(t *testing.T) {
t.Parallel()

clock := clockwork.NewFakeClock()

tcs := []struct {
name string
searchTerm string
instance *machineidv1.BotInstance
}{
{
name: "match on bot name",
searchTerm: "nick",
instance: newBotInstance("this-is-nicks-test-bot"),
},
{
name: "match on instance id",
searchTerm: "cb2c352",
instance: newBotInstance("test-bot", withBotInstanceId("cb2c3523-01f6-4258-966b-ace9f38f9862")),
},
{
name: "match on join method",
searchTerm: "uber",
instance: newBotInstance("test-bot", withBotInstanceHeartbeatJoinMethod("kubernetes")),
},
{
name: "match on version",
searchTerm: "1.0.0",
instance: newBotInstance("test-bot", withBotInstanceHeartbeatVersion("1.0.0-dev-a2g3hd")),
},
{
name: "match on version (with v)",
searchTerm: "v1.0.0",
instance: newBotInstance("test-bot", withBotInstanceHeartbeatVersion("1.0.0-dev-a2g3hd")),
},
{
name: "match on hostname",
searchTerm: "tel-123",
instance: newBotInstance("test-bot", withBotInstanceHeartbeatHostname("svr-eu-tel-123-a")),
},
}

for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()

mem, err := memory.New(memory.Config{
Context: ctx,
Clock: clock,
})
require.NoError(t, err)

service, err := NewBotInstanceService(backend.NewSanitizer(mem), clock)
require.NoError(t, err)

_, err = service.CreateBotInstance(ctx, tc.instance)
require.NoError(t, err)
_, err = service.CreateBotInstance(ctx, newBotInstance("bot-not-matched"))
require.NoError(t, err)

instances := listInstances(t, ctx, service, "", tc.searchTerm)

require.Len(t, instances, 1)
require.Equal(t, tc.instance.Spec.InstanceId, instances[0].Spec.InstanceId)
})
}
}
9 changes: 9 additions & 0 deletions lib/utils/slices/slices.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ func FilterMapUnique[T any, S comparable](ts []T, fn func(T) (s S, include bool)
return ss
}

// Map calls the provided function on each element of a slice, and returns a slice containin the results.
func Map[T, S any](items []T, fn func(T) S) []S {
result := make([]S, len(items))
for i, item := range items {
result[i] = fn(item)
}
return result
}

// ToPointers converts a slice of values to a slice of pointers to those values
func ToPointers[T any](in []T) []*T {
out := make([]*T, len(in))
Expand Down
3 changes: 3 additions & 0 deletions lib/web/apiserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -1146,6 +1146,9 @@ func (h *Handler) bindDefaultEndpoints() {
// Delete Machine ID bot
h.DELETE("/webapi/sites/:site/machine-id/bot/:name", h.WithClusterAuth(h.deleteBot))

// GET Machine ID bot instances (paged)
h.GET("/webapi/sites/:site/machine-id/bot-instance", h.WithClusterAuth(h.listBotInstances))

// GET a paginated list of notifications for a user
h.GET("/webapi/sites/:site/notifications", h.WithClusterAuth(h.notificationsGet))
// Upsert the timestamp of the latest notification that the user has seen.
Expand Down
Loading
Loading