From 2dc2ac231f10db12319b2d28d663ce4e8badc121 Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Tue, 16 Sep 2025 12:39:54 +0100 Subject: [PATCH 01/19] Add version and hostname indexes to cache --- lib/cache/bot_instance.go | 126 +++++++-------- lib/cache/bot_instance_test.go | 244 ++++++++++++++++++++++++----- lib/services/bot_instance.go | 43 +++++ lib/services/local/bot_instance.go | 35 +---- lib/web/machineid.go | 6 +- 5 files changed, 316 insertions(+), 138 deletions(-) diff --git a/lib/cache/bot_instance.go b/lib/cache/bot_instance.go index b55d4905d7b4f..9cfb2a7580755 100644 --- a/lib/cache/bot_instance.go +++ b/lib/cache/bot_instance.go @@ -18,10 +18,10 @@ package cache import ( "context" - "slices" - "strings" + "fmt" "time" + "github.com/coreos/go-semver/semver" "github.com/gravitational/trace" "google.golang.org/protobuf/proto" @@ -38,35 +38,10 @@ type botInstanceIndex string const ( botInstanceNameIndex botInstanceIndex = "name" botInstanceActiveAtIndex botInstanceIndex = "active_at_latest" + botInstanceVersionIndex botInstanceIndex = "version_latest" + botInstanceHostnameIndex botInstanceIndex = "host_name_latest" ) -func keyForNameIndex(botInstance *machineidv1.BotInstance) string { - return makeNameIndexKey( - botInstance.GetSpec().GetBotName(), - botInstance.GetMetadata().GetName(), - ) -} - -func makeNameIndexKey(botName string, instanceID string) string { - return botName + "/" + instanceID -} - -func keyForActiveAtIndex(botInstance *machineidv1.BotInstance) string { - var recordedAt time.Time - - initialHeartbeatTime := botInstance.GetStatus().GetInitialHeartbeat().GetRecordedAt() - if initialHeartbeatTime != nil { - recordedAt = initialHeartbeatTime.AsTime() - } - - latestHeartbeats := botInstance.GetStatus().GetLatestHeartbeats() - if len(latestHeartbeats) > 0 { - recordedAt = latestHeartbeats[len(latestHeartbeats)-1].GetRecordedAt().AsTime() - } - - return recordedAt.Format(time.RFC3339) + "/" + botInstance.GetMetadata().GetName() -} - func newBotInstanceCollection(upstream services.BotInstance, w types.WatchKind) (*collection[*machineidv1.BotInstance, botInstanceIndex], error) { if upstream == nil { return nil, trace.BadParameter("missing parameter upstream (BotInstance)") @@ -81,6 +56,10 @@ func newBotInstanceCollection(upstream services.BotInstance, w types.WatchKind) botInstanceNameIndex: keyForNameIndex, // Index on a combination of most recent heartbeat time and instance name botInstanceActiveAtIndex: keyForActiveAtIndex, + // Index on a combination of most recent heartbeat version and instance name + botInstanceVersionIndex: keyForVersionIndex, + // Index on a combination of most recent heartbeat hostname and instance name + botInstanceHostnameIndex: keyForHostnameIndex, }), fetcher: func(ctx context.Context, loadSecrets bool) ([]*machineidv1.BotInstance, error) { out, err := stream.Collect(clientutils.Resources(ctx, @@ -119,20 +98,24 @@ func (c *Cache) ListBotInstances(ctx context.Context, botName string, pageSize i index := botInstanceNameIndex keyFn := keyForNameIndex - var isDesc bool - if sort != nil { - isDesc = sort.IsDesc - - switch sort.Field { - case "bot_name": - index = botInstanceNameIndex - keyFn = keyForNameIndex - case "active_at_latest": - index = botInstanceActiveAtIndex - keyFn = keyForActiveAtIndex - default: - return nil, "", trace.BadParameter("unsupported sort %q but expected bot_name or active_at_latest", sort.Field) - } + isDesc := options.GetSortDesc() + switch options.GetSortField() { + case "bot_name": + index = botInstanceNameIndex + keyFn = keyForNameIndex + case "active_at_latest": + index = botInstanceActiveAtIndex + keyFn = keyForActiveAtIndex + case "version_latest": + index = botInstanceVersionIndex + keyFn = keyForVersionIndex + case "host_name_latest": + index = botInstanceHostnameIndex + keyFn = keyForHostnameIndex + case "": + // default ordering as defined above + default: + return nil, "", trace.BadParameter("unsupported sort %q but expected bot_name or active_at_latest", options.GetSortField()) } lister := genericLister[*machineidv1.BotInstance, botInstanceIndex]{ @@ -145,7 +128,7 @@ func (c *Cache) ListBotInstances(ctx context.Context, botName string, pageSize i return c.Config.BotInstanceService.ListBotInstances(ctx, botName, limit, start, search, sort) }, filter: func(b *machineidv1.BotInstance) bool { - return matchBotInstance(b, botName, search) + return services.MatchBotInstance(b, botName, search) }, nextToken: func(b *machineidv1.BotInstance) string { return keyFn(b) @@ -158,33 +141,50 @@ 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`. +func keyForNameIndex(botInstance *machineidv1.BotInstance) string { + return makeNameIndexKey( + botInstance.GetSpec().GetBotName(), + botInstance.GetMetadata().GetName(), + ) +} - if botName != "" && b.Spec.BotName != botName { - return false - } +func makeNameIndexKey(botName string, instanceID string) string { + return botName + "/" + instanceID +} - if search == "" { - return true +func keyForActiveAtIndex(botInstance *machineidv1.BotInstance) string { + heartbeat := services.PickBotInstanceRecentHeartbeat(botInstance) + recordedAt := heartbeat.GetRecordedAt().AsTime() + return recordedAt.Format(time.RFC3339) + "/" + botInstance.GetMetadata().GetName() +} + +func keyForVersionIndex(botInstance *machineidv1.BotInstance) string { + version := "000000.000000.000000" + heartbeat := services.PickBotInstanceRecentHeartbeat(botInstance) + if heartbeat == nil { + return version + "/" + botInstance.GetMetadata().GetName() } - latestHeartbeats := b.GetStatus().GetLatestHeartbeats() - heartbeat := b.Status.InitialHeartbeat // Use initial heartbeat as a fallback - if len(latestHeartbeats) > 0 { - heartbeat = latestHeartbeats[len(latestHeartbeats)-1] + sv, err := semver.NewVersion(heartbeat.GetVersion()) + if err != nil { + return version + "/" + botInstance.GetMetadata().GetName() } - values := []string{ - b.Spec.BotName, - b.Spec.InstanceId, + version = fmt.Sprintf("%06d.%06d.%06d", sv.Major, sv.Minor, sv.Patch) + if sv.PreRelease != "" { + version = version + "-" + string(sv.PreRelease) + } + if sv.Metadata != "" { + version = version + "+" + sv.Metadata } + return version + "/" + botInstance.GetMetadata().GetName() +} +func keyForHostnameIndex(botInstance *machineidv1.BotInstance) string { + hostname := "~" + heartbeat := services.PickBotInstanceRecentHeartbeat(botInstance) if heartbeat != nil { - values = append(values, heartbeat.Hostname, heartbeat.JoinMethod, heartbeat.Version, "v"+heartbeat.Version) + hostname = heartbeat.GetHostname() } - - return slices.ContainsFunc(values, func(val string) bool { - return strings.Contains(strings.ToLower(val), strings.ToLower(search)) - }) + return hostname + "/" + botInstance.GetMetadata().GetName() } diff --git a/lib/cache/bot_instance_test.go b/lib/cache/bot_instance_test.go index d9e21b59dda94..a48aeabee49ad 100644 --- a/lib/cache/bot_instance_test.go +++ b/lib/cache/bot_instance_test.go @@ -234,10 +234,12 @@ func TestBotInstanceCacheSorting(t *testing.T) { botName string instanceId string recordedAtSeconds int64 + version string + hostname string }{ - {"bot-1", "instance-1", 2}, - {"bot-1", "instance-3", 1}, - {"bot-2", "instance-2", 3}, + {"bot-1", "instance-1", 2, "3.0.0", "hostname-2"}, + {"bot-1", "instance-3", 1, "2.0.0", "hostname-3"}, + {"bot-2", "instance-2", 3, "1.0.0", "hostname-1"}, } for _, b := range items { @@ -255,6 +257,8 @@ func TestBotInstanceCacheSorting(t *testing.T) { RecordedAt: ×tamppb.Timestamp{ Seconds: b.recordedAtSeconds, }, + Version: b.version, + Hostname: b.hostname, }, }, }, @@ -271,46 +275,110 @@ func TestBotInstanceCacheSorting(t *testing.T) { require.Len(t, results, 3) }, 10*time.Second, 100*time.Millisecond) - // sort ascending by active_at_latest - results, _, err := p.cache.ListBotInstances(ctx, "", 0, "", "", &types.SortBy{ - Field: "active_at_latest", - IsDesc: false, + t.Run("sort ascending by active_at_latest", func(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()) + require.Equal(t, "instance-1", results[1].GetMetadata().GetName()) + require.Equal(t, "instance-2", results[2].GetMetadata().GetName()) }) - require.NoError(t, err) - require.Len(t, results, 3) - require.Equal(t, "instance-3", results[0].GetMetadata().GetName()) - require.Equal(t, "instance-1", results[1].GetMetadata().GetName()) - require.Equal(t, "instance-2", results[2].GetMetadata().GetName()) - // sort descending by active_at_latest - results, _, err = p.cache.ListBotInstances(ctx, "", 0, "", "", &types.SortBy{ - Field: "active_at_latest", - IsDesc: true, + t.Run("sort descending by active_at_latest", func(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()) + require.Equal(t, "instance-1", results[1].GetMetadata().GetName()) + require.Equal(t, "instance-3", results[2].GetMetadata().GetName()) }) - require.NoError(t, err) - require.Len(t, results, 3) - require.Equal(t, "instance-2", results[0].GetMetadata().GetName()) - require.Equal(t, "instance-1", results[1].GetMetadata().GetName()) - 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` - require.NoError(t, err) - require.Len(t, results, 3) - require.Equal(t, "instance-1", results[0].GetMetadata().GetName()) - require.Equal(t, "instance-3", results[1].GetMetadata().GetName()) - require.Equal(t, "instance-2", results[2].GetMetadata().GetName()) + t.Run("sort ascending by bot_name", func(t *testing.T) { + 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()) + require.Equal(t, "instance-3", results[1].GetMetadata().GetName()) + require.Equal(t, "instance-2", results[2].GetMetadata().GetName()) + }) - // sort descending by bot_name - results, _, err = p.cache.ListBotInstances(ctx, "", 0, "", "", &types.SortBy{ - Field: "bot_name", - IsDesc: true, + t.Run("sort descending by bot_name", func(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()) + require.Equal(t, "instance-3", results[1].GetMetadata().GetName()) + require.Equal(t, "instance-1", results[2].GetMetadata().GetName()) + }) + + t.Run("sort ascending by version", func(t *testing.T) { + results, _, err := p.cache.ListBotInstances(ctx, "", 0, "", "", &types.SortBy{ + Field: "version_latest", + IsDesc: false, + }) + require.NoError(t, err) + require.Len(t, results, 3) + require.Equal(t, "instance-2", results[0].GetMetadata().GetName()) + require.Equal(t, "instance-3", results[1].GetMetadata().GetName()) + require.Equal(t, "instance-1", results[2].GetMetadata().GetName()) + }) + + t.Run("sort ascending by version", func(t *testing.T) { + results, _, err := p.cache.ListBotInstances(ctx, "", 0, "", "", &types.SortBy{ + Field: "version_latest", + IsDesc: true, + }) + require.NoError(t, err) + require.Len(t, results, 3) + require.Equal(t, "instance-1", results[0].GetMetadata().GetName()) + require.Equal(t, "instance-3", results[1].GetMetadata().GetName()) + require.Equal(t, "instance-2", results[2].GetMetadata().GetName()) + }) + + t.Run("sort descending by version", func(t *testing.T) { + results, _, err := p.cache.ListBotInstances(ctx, "", 0, "", "", &types.SortBy{ + Field: "version_latest", + IsDesc: false, + }) + require.NoError(t, err) + require.Len(t, results, 3) + require.Equal(t, "instance-2", results[0].GetMetadata().GetName()) + require.Equal(t, "instance-3", results[1].GetMetadata().GetName()) + require.Equal(t, "instance-1", results[2].GetMetadata().GetName()) + }) + + t.Run("sort ascending by hostname", func(t *testing.T) { + results, _, err := p.cache.ListBotInstances(ctx, 0, "", &services.ListBotInstancesRequestOptions{ + SortField: "host_name_latest", + SortDesc: false, + }) + require.NoError(t, err) + require.Len(t, results, 3) + require.Equal(t, "instance-2", results[0].GetMetadata().GetName()) + require.Equal(t, "instance-1", results[1].GetMetadata().GetName()) + require.Equal(t, "instance-3", results[2].GetMetadata().GetName()) + }) + + t.Run("sort descending by hostname", func(t *testing.T) { + results, _, err := p.cache.ListBotInstances(ctx, 0, "", &services.ListBotInstancesRequestOptions{ + SortField: "host_name_latest", + SortDesc: true, + }) + require.NoError(t, err) + require.Len(t, results, 3) + require.Equal(t, "instance-3", results[0].GetMetadata().GetName()) + require.Equal(t, "instance-1", results[1].GetMetadata().GetName()) + require.Equal(t, "instance-2", results[2].GetMetadata().GetName()) }) - require.NoError(t, err) - require.Len(t, results, 3) - require.Equal(t, "instance-2", results[0].GetMetadata().GetName()) - require.Equal(t, "instance-3", results[1].GetMetadata().GetName()) - require.Equal(t, "instance-1", results[2].GetMetadata().GetName()) } // TestBotInstanceCacheFallback tests that requests fallback to the upstream when the cache is unhealthy. @@ -367,5 +435,107 @@ func TestBotInstanceCacheFallback(t *testing.T) { }) require.Error(t, err) require.Equal(t, "unsupported sort, only bot_name:asc is supported, but got \"active_at_latest\" (desc = false)", err.Error()) +} + +func TestKeyForVersionIndex(t *testing.T) { + t.Parallel() + + tcs := []struct { + name string + mutatorFn func(*machineidv1.BotInstance) + key string + }{ + { + name: "zero heartbeats", + mutatorFn: func(b *machineidv1.BotInstance) {}, + key: "000000.000000.000000/bot-instance-1", + }, + { + name: "invalid version", + mutatorFn: func(b *machineidv1.BotInstance) { + b.Status = &machineidv1.BotInstanceStatus{ + InitialHeartbeat: &machineidv1.BotInstanceStatusHeartbeat{ + Version: "a.b.c", + }, + } + }, + key: "000000.000000.000000/bot-instance-1", + }, + { + name: "initial heartbeat", + mutatorFn: func(b *machineidv1.BotInstance) { + b.Status = &machineidv1.BotInstanceStatus{ + InitialHeartbeat: &machineidv1.BotInstanceStatusHeartbeat{ + Version: "1.0.0", + }, + } + }, + key: "000001.000000.000000/bot-instance-1", + }, + { + name: "latest heartbeat", + mutatorFn: func(b *machineidv1.BotInstance) { + b.Status = &machineidv1.BotInstanceStatus{ + LatestHeartbeats: []*machineidv1.BotInstanceStatusHeartbeat{ + { + Version: "1.0.0", + }, + }, + } + }, + key: "000001.000000.000000/bot-instance-1", + }, + { + name: "with release", + mutatorFn: func(b *machineidv1.BotInstance) { + b.Status = &machineidv1.BotInstanceStatus{ + InitialHeartbeat: &machineidv1.BotInstanceStatusHeartbeat{ + Version: "1.0.0-dev", + }, + } + }, + key: "000001.000000.000000-dev/bot-instance-1", + }, + { + name: "with build", + mutatorFn: func(b *machineidv1.BotInstance) { + b.Status = &machineidv1.BotInstanceStatus{ + InitialHeartbeat: &machineidv1.BotInstanceStatusHeartbeat{ + Version: "1.0.0+build1", + }, + } + }, + key: "000001.000000.000000+build1/bot-instance-1", + }, + { + name: "with release and build", + mutatorFn: func(b *machineidv1.BotInstance) { + b.Status = &machineidv1.BotInstanceStatus{ + InitialHeartbeat: &machineidv1.BotInstanceStatusHeartbeat{ + Version: "1.0.0-dev+build1", + }, + } + }, + key: "000001.000000.000000-dev+build1/bot-instance-1", + }, + } + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + instance := &machineidv1.BotInstance{ + Kind: types.KindBotInstance, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "bot-instance-1", + }, + Spec: &machineidv1.BotInstanceSpec{}, + Status: &machineidv1.BotInstanceStatus{}, + } + tc.mutatorFn(instance) + + versionKey := keyForVersionIndex(instance) + + assert.Equal(t, tc.key, versionKey) + }) + } } diff --git a/lib/services/bot_instance.go b/lib/services/bot_instance.go index 50beac8a76472..0722df1c1a5d8 100644 --- a/lib/services/bot_instance.go +++ b/lib/services/bot_instance.go @@ -18,6 +18,8 @@ package services import ( "context" + "slices" + "strings" "github.com/gravitational/trace" @@ -79,3 +81,44 @@ func MarshalBotInstance(object *machineidv1.BotInstance, opts ...MarshalOption) func UnmarshalBotInstance(data []byte, opts ...MarshalOption) (*machineidv1.BotInstance, error) { return UnmarshalProtoResource[*machineidv1.BotInstance](data, opts...) } + +func MatchBotInstance(b *machineidv1.BotInstance, botName string, search string) bool { + 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)) + }) +} + +// PickBotInstanceRecentHeartbeat returns the most recent heartbeat for the +// given bot instance. The initial heartbeat is returned as a fallback if no +// latest heartbeats exist. +func PickBotInstanceRecentHeartbeat(botInstance *machineidv1.BotInstance) *machineidv1.BotInstanceStatusHeartbeat { + heartbeat := botInstance.GetStatus().GetInitialHeartbeat() + latestHeartbeats := botInstance.GetStatus().GetLatestHeartbeats() + if len(latestHeartbeats) > 0 { + heartbeat = latestHeartbeats[len(latestHeartbeats)-1] + } + return heartbeat +} diff --git a/lib/services/local/bot_instance.go b/lib/services/local/bot_instance.go index 9240bc5c40b27..37daa467ffdb4 100644 --- a/lib/services/local/bot_instance.go +++ b/lib/services/local/bot_instance.go @@ -18,8 +18,6 @@ package local import ( "context" - "slices" - "strings" "github.com/gravitational/trace" "github.com/jonboulle/clockwork" @@ -115,43 +113,12 @@ func (b *BotInstanceService) ListBotInstances(ctx context.Context, botName strin } r, nextToken, err := service.ListResourcesWithFilter(ctx, pageSize, lastKey, func(item *machineidv1.BotInstance) bool { - return matchBotInstance(item, botName, search) + return services.MatchBotInstance(item, botName, search) }) 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 { diff --git a/lib/web/machineid.go b/lib/web/machineid.go index 169ad98c69a20..48f54ca6f8022 100644 --- a/lib/web/machineid.go +++ b/lib/web/machineid.go @@ -32,6 +32,7 @@ import ( "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/lib/httplib" "github.com/gravitational/teleport/lib/reversetunnelclient" + "github.com/gravitational/teleport/lib/services" tslices "github.com/gravitational/teleport/lib/utils/slices" ) @@ -423,10 +424,7 @@ func (h *Handler) listBotInstances(_ http.ResponseWriter, r *http.Request, _ htt } uiInstances := tslices.Map(instances.BotInstances, func(instance *machineidv1.BotInstance) BotInstance { - latestHeartbeats := instance.GetStatus().GetLatestHeartbeats() - heartbeat := instance.Status.InitialHeartbeat // Use initial heartbeat as a fallback - if len(latestHeartbeats) > 0 { - heartbeat = latestHeartbeats[len(latestHeartbeats)-1] + heartbeat := services.PickBotInstanceRecentHeartbeat(instance) } uiInstance := BotInstance{ From 5af51511a1aaaee87ff2db551a1f02878e56bdd7 Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Wed, 17 Sep 2025 14:25:11 +0100 Subject: [PATCH 02/19] Add `ListBotInstancesV2` rpc and use request options --- .../machineid/v1/bot_instance_service.pb.go | 195 ++++++++++++++---- .../v1/bot_instance_service_grpc.pb.go | 48 ++++- .../machineid/v1/bot_instance_service.proto | 27 +++ lib/auth/authclient/api.go | 2 +- .../machineidv1/bot_instance_service.go | 27 ++- .../machineidv1/bot_instance_service_test.go | 8 + lib/cache/bot_instance.go | 9 +- lib/cache/bot_instance_test.go | 131 ++++++------ lib/services/bot_instance.go | 46 ++++- lib/services/local/bot_instance.go | 17 +- lib/services/local/bot_instance_test.go | 42 ++-- 11 files changed, 410 insertions(+), 142 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 8fa41fe942e4e..349f22c6ce43b 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 @@ -180,6 +180,106 @@ func (x *ListBotInstancesRequest) GetSort() *types.SortBy { return nil } +// Request for ListBotInstancesV2. +// +// Follows the pagination semantics of +// https://cloud.google.com/apis/design/standard_methods#list +type ListBotInstancesV2Request struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The maximum number of items to return. + // The server may impose a different page size at its discretion. + PageSize int32 `protobuf:"varint,1,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"` + // The page_token value returned from a previous ListBotInstancesV2 request, + // if any. + PageToken string `protobuf:"bytes,2,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"` + // The sort field to use for the results. If empty, the default sort field is + // used. + SortField string `protobuf:"bytes,3,opt,name=sort_field,json=sortField,proto3" json:"sort_field,omitempty"` + // The sort order to use for the results. If empty, the default sort order is + // used. + SortDesc bool `protobuf:"varint,4,opt,name=sort_desc,json=sortDesc,proto3" json:"sort_desc,omitempty"` + // The name of the Bot to list BotInstances for. If empty, all BotInstances + // will be listed. + FilterBotName string `protobuf:"bytes,5,opt,name=filter_bot_name,json=filterBotName,proto3" json:"filter_bot_name,omitempty"` + // A search term used to filter the results. If non-empty, it's used to match + // against supported fields. + FilterSearchTerm string `protobuf:"bytes,6,opt,name=filter_search_term,json=filterSearchTerm,proto3" json:"filter_search_term,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListBotInstancesV2Request) Reset() { + *x = ListBotInstancesV2Request{} + mi := &file_teleport_machineid_v1_bot_instance_service_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListBotInstancesV2Request) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListBotInstancesV2Request) ProtoMessage() {} + +func (x *ListBotInstancesV2Request) ProtoReflect() protoreflect.Message { + mi := &file_teleport_machineid_v1_bot_instance_service_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListBotInstancesV2Request.ProtoReflect.Descriptor instead. +func (*ListBotInstancesV2Request) Descriptor() ([]byte, []int) { + return file_teleport_machineid_v1_bot_instance_service_proto_rawDescGZIP(), []int{2} +} + +func (x *ListBotInstancesV2Request) GetPageSize() int32 { + if x != nil { + return x.PageSize + } + return 0 +} + +func (x *ListBotInstancesV2Request) GetPageToken() string { + if x != nil { + return x.PageToken + } + return "" +} + +func (x *ListBotInstancesV2Request) GetSortField() string { + if x != nil { + return x.SortField + } + return "" +} + +func (x *ListBotInstancesV2Request) GetSortDesc() bool { + if x != nil { + return x.SortDesc + } + return false +} + +func (x *ListBotInstancesV2Request) GetFilterBotName() string { + if x != nil { + return x.FilterBotName + } + return "" +} + +func (x *ListBotInstancesV2Request) GetFilterSearchTerm() string { + if x != nil { + return x.FilterSearchTerm + } + return "" +} + // Response for ListBotInstances. type ListBotInstancesResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -194,7 +294,7 @@ type ListBotInstancesResponse struct { func (x *ListBotInstancesResponse) Reset() { *x = ListBotInstancesResponse{} - mi := &file_teleport_machineid_v1_bot_instance_service_proto_msgTypes[2] + mi := &file_teleport_machineid_v1_bot_instance_service_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -206,7 +306,7 @@ func (x *ListBotInstancesResponse) String() string { func (*ListBotInstancesResponse) ProtoMessage() {} func (x *ListBotInstancesResponse) ProtoReflect() protoreflect.Message { - mi := &file_teleport_machineid_v1_bot_instance_service_proto_msgTypes[2] + mi := &file_teleport_machineid_v1_bot_instance_service_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -219,7 +319,7 @@ func (x *ListBotInstancesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListBotInstancesResponse.ProtoReflect.Descriptor instead. func (*ListBotInstancesResponse) Descriptor() ([]byte, []int) { - return file_teleport_machineid_v1_bot_instance_service_proto_rawDescGZIP(), []int{2} + return file_teleport_machineid_v1_bot_instance_service_proto_rawDescGZIP(), []int{3} } func (x *ListBotInstancesResponse) GetBotInstances() []*BotInstance { @@ -249,7 +349,7 @@ type DeleteBotInstanceRequest struct { func (x *DeleteBotInstanceRequest) Reset() { *x = DeleteBotInstanceRequest{} - mi := &file_teleport_machineid_v1_bot_instance_service_proto_msgTypes[3] + mi := &file_teleport_machineid_v1_bot_instance_service_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -261,7 +361,7 @@ func (x *DeleteBotInstanceRequest) String() string { func (*DeleteBotInstanceRequest) ProtoMessage() {} func (x *DeleteBotInstanceRequest) ProtoReflect() protoreflect.Message { - mi := &file_teleport_machineid_v1_bot_instance_service_proto_msgTypes[3] + mi := &file_teleport_machineid_v1_bot_instance_service_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -274,7 +374,7 @@ func (x *DeleteBotInstanceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteBotInstanceRequest.ProtoReflect.Descriptor instead. func (*DeleteBotInstanceRequest) Descriptor() ([]byte, []int) { - return file_teleport_machineid_v1_bot_instance_service_proto_rawDescGZIP(), []int{3} + return file_teleport_machineid_v1_bot_instance_service_proto_rawDescGZIP(), []int{4} } func (x *DeleteBotInstanceRequest) GetBotName() string { @@ -302,7 +402,7 @@ type SubmitHeartbeatRequest struct { func (x *SubmitHeartbeatRequest) Reset() { *x = SubmitHeartbeatRequest{} - mi := &file_teleport_machineid_v1_bot_instance_service_proto_msgTypes[4] + mi := &file_teleport_machineid_v1_bot_instance_service_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -314,7 +414,7 @@ func (x *SubmitHeartbeatRequest) String() string { func (*SubmitHeartbeatRequest) ProtoMessage() {} func (x *SubmitHeartbeatRequest) ProtoReflect() protoreflect.Message { - mi := &file_teleport_machineid_v1_bot_instance_service_proto_msgTypes[4] + mi := &file_teleport_machineid_v1_bot_instance_service_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -327,7 +427,7 @@ func (x *SubmitHeartbeatRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SubmitHeartbeatRequest.ProtoReflect.Descriptor instead. func (*SubmitHeartbeatRequest) Descriptor() ([]byte, []int) { - return file_teleport_machineid_v1_bot_instance_service_proto_rawDescGZIP(), []int{4} + return file_teleport_machineid_v1_bot_instance_service_proto_rawDescGZIP(), []int{5} } func (x *SubmitHeartbeatRequest) GetHeartbeat() *BotInstanceStatusHeartbeat { @@ -346,7 +446,7 @@ type SubmitHeartbeatResponse struct { func (x *SubmitHeartbeatResponse) Reset() { *x = SubmitHeartbeatResponse{} - mi := &file_teleport_machineid_v1_bot_instance_service_proto_msgTypes[5] + mi := &file_teleport_machineid_v1_bot_instance_service_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -358,7 +458,7 @@ func (x *SubmitHeartbeatResponse) String() string { func (*SubmitHeartbeatResponse) ProtoMessage() {} func (x *SubmitHeartbeatResponse) ProtoReflect() protoreflect.Message { - mi := &file_teleport_machineid_v1_bot_instance_service_proto_msgTypes[5] + mi := &file_teleport_machineid_v1_bot_instance_service_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -371,7 +471,7 @@ func (x *SubmitHeartbeatResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SubmitHeartbeatResponse.ProtoReflect.Descriptor instead. func (*SubmitHeartbeatResponse) Descriptor() ([]byte, []int) { - return file_teleport_machineid_v1_bot_instance_service_proto_rawDescGZIP(), []int{5} + return file_teleport_machineid_v1_bot_instance_service_proto_rawDescGZIP(), []int{6} } var File_teleport_machineid_v1_bot_instance_service_proto protoreflect.FileDescriptor @@ -389,7 +489,16 @@ const file_teleport_machineid_v1_bot_instance_service_proto_rawDesc = "" + "\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\"\xe9\x01\n" + + "\x19ListBotInstancesV2Request\x12\x1b\n" + + "\tpage_size\x18\x01 \x01(\x05R\bpageSize\x12\x1d\n" + + "\n" + + "page_token\x18\x02 \x01(\tR\tpageToken\x12\x1d\n" + + "\n" + + "sort_field\x18\x03 \x01(\tR\tsortField\x12\x1b\n" + + "\tsort_desc\x18\x04 \x01(\bR\bsortDesc\x12&\n" + + "\x0ffilter_bot_name\x18\x05 \x01(\tR\rfilterBotName\x12,\n" + + "\x12filter_search_term\x18\x06 \x01(\tR\x10filterSearchTerm\"\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" + @@ -399,10 +508,11 @@ const file_teleport_machineid_v1_bot_instance_service_proto_rawDesc = "" + "instanceId\"i\n" + "\x16SubmitHeartbeatRequest\x12O\n" + "\theartbeat\x18\x01 \x01(\v21.teleport.machineid.v1.BotInstanceStatusHeartbeatR\theartbeat\"\x19\n" + - "\x17SubmitHeartbeatResponse2\xbd\x03\n" + + "\x17SubmitHeartbeatResponse2\xb6\x04\n" + "\x12BotInstanceService\x12b\n" + "\x0eGetBotInstance\x12,.teleport.machineid.v1.GetBotInstanceRequest\x1a\".teleport.machineid.v1.BotInstance\x12s\n" + - "\x10ListBotInstances\x12..teleport.machineid.v1.ListBotInstancesRequest\x1a/.teleport.machineid.v1.ListBotInstancesResponse\x12\\\n" + + "\x10ListBotInstances\x12..teleport.machineid.v1.ListBotInstancesRequest\x1a/.teleport.machineid.v1.ListBotInstancesResponse\x12w\n" + + "\x12ListBotInstancesV2\x120.teleport.machineid.v1.ListBotInstancesV2Request\x1a/.teleport.machineid.v1.ListBotInstancesResponse\x12\\\n" + "\x11DeleteBotInstance\x12/.teleport.machineid.v1.DeleteBotInstanceRequest\x1a\x16.google.protobuf.Empty\x12p\n" + "\x0fSubmitHeartbeat\x12-.teleport.machineid.v1.SubmitHeartbeatRequest\x1a..teleport.machineid.v1.SubmitHeartbeatResponseBVZTgithub.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1;machineidv1b\x06proto3" @@ -418,36 +528,39 @@ func file_teleport_machineid_v1_bot_instance_service_proto_rawDescGZIP() []byte return file_teleport_machineid_v1_bot_instance_service_proto_rawDescData } -var file_teleport_machineid_v1_bot_instance_service_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_teleport_machineid_v1_bot_instance_service_proto_msgTypes = make([]protoimpl.MessageInfo, 7) var file_teleport_machineid_v1_bot_instance_service_proto_goTypes = []any{ (*GetBotInstanceRequest)(nil), // 0: teleport.machineid.v1.GetBotInstanceRequest (*ListBotInstancesRequest)(nil), // 1: teleport.machineid.v1.ListBotInstancesRequest - (*ListBotInstancesResponse)(nil), // 2: teleport.machineid.v1.ListBotInstancesResponse - (*DeleteBotInstanceRequest)(nil), // 3: teleport.machineid.v1.DeleteBotInstanceRequest - (*SubmitHeartbeatRequest)(nil), // 4: teleport.machineid.v1.SubmitHeartbeatRequest - (*SubmitHeartbeatResponse)(nil), // 5: teleport.machineid.v1.SubmitHeartbeatResponse - (*types.SortBy)(nil), // 6: types.SortBy - (*BotInstance)(nil), // 7: teleport.machineid.v1.BotInstance - (*BotInstanceStatusHeartbeat)(nil), // 8: teleport.machineid.v1.BotInstanceStatusHeartbeat - (*emptypb.Empty)(nil), // 9: google.protobuf.Empty + (*ListBotInstancesV2Request)(nil), // 2: teleport.machineid.v1.ListBotInstancesV2Request + (*ListBotInstancesResponse)(nil), // 3: teleport.machineid.v1.ListBotInstancesResponse + (*DeleteBotInstanceRequest)(nil), // 4: teleport.machineid.v1.DeleteBotInstanceRequest + (*SubmitHeartbeatRequest)(nil), // 5: teleport.machineid.v1.SubmitHeartbeatRequest + (*SubmitHeartbeatResponse)(nil), // 6: teleport.machineid.v1.SubmitHeartbeatResponse + (*types.SortBy)(nil), // 7: types.SortBy + (*BotInstance)(nil), // 8: teleport.machineid.v1.BotInstance + (*BotInstanceStatusHeartbeat)(nil), // 9: teleport.machineid.v1.BotInstanceStatusHeartbeat + (*emptypb.Empty)(nil), // 10: google.protobuf.Empty } var file_teleport_machineid_v1_bot_instance_service_proto_depIdxs = []int32{ - 6, // 0: teleport.machineid.v1.ListBotInstancesRequest.sort:type_name -> types.SortBy - 7, // 1: teleport.machineid.v1.ListBotInstancesResponse.bot_instances:type_name -> teleport.machineid.v1.BotInstance - 8, // 2: teleport.machineid.v1.SubmitHeartbeatRequest.heartbeat:type_name -> teleport.machineid.v1.BotInstanceStatusHeartbeat - 0, // 3: teleport.machineid.v1.BotInstanceService.GetBotInstance:input_type -> teleport.machineid.v1.GetBotInstanceRequest - 1, // 4: teleport.machineid.v1.BotInstanceService.ListBotInstances:input_type -> teleport.machineid.v1.ListBotInstancesRequest - 3, // 5: teleport.machineid.v1.BotInstanceService.DeleteBotInstance:input_type -> teleport.machineid.v1.DeleteBotInstanceRequest - 4, // 6: teleport.machineid.v1.BotInstanceService.SubmitHeartbeat:input_type -> teleport.machineid.v1.SubmitHeartbeatRequest - 7, // 7: teleport.machineid.v1.BotInstanceService.GetBotInstance:output_type -> teleport.machineid.v1.BotInstance - 2, // 8: teleport.machineid.v1.BotInstanceService.ListBotInstances:output_type -> teleport.machineid.v1.ListBotInstancesResponse - 9, // 9: teleport.machineid.v1.BotInstanceService.DeleteBotInstance:output_type -> google.protobuf.Empty - 5, // 10: teleport.machineid.v1.BotInstanceService.SubmitHeartbeat:output_type -> teleport.machineid.v1.SubmitHeartbeatResponse - 7, // [7:11] is the sub-list for method output_type - 3, // [3:7] is the sub-list for method input_type - 3, // [3:3] is the sub-list for extension type_name - 3, // [3:3] is the sub-list for extension extendee - 0, // [0:3] is the sub-list for field type_name + 7, // 0: teleport.machineid.v1.ListBotInstancesRequest.sort:type_name -> types.SortBy + 8, // 1: teleport.machineid.v1.ListBotInstancesResponse.bot_instances:type_name -> teleport.machineid.v1.BotInstance + 9, // 2: teleport.machineid.v1.SubmitHeartbeatRequest.heartbeat:type_name -> teleport.machineid.v1.BotInstanceStatusHeartbeat + 0, // 3: teleport.machineid.v1.BotInstanceService.GetBotInstance:input_type -> teleport.machineid.v1.GetBotInstanceRequest + 1, // 4: teleport.machineid.v1.BotInstanceService.ListBotInstances:input_type -> teleport.machineid.v1.ListBotInstancesRequest + 2, // 5: teleport.machineid.v1.BotInstanceService.ListBotInstancesV2:input_type -> teleport.machineid.v1.ListBotInstancesV2Request + 4, // 6: teleport.machineid.v1.BotInstanceService.DeleteBotInstance:input_type -> teleport.machineid.v1.DeleteBotInstanceRequest + 5, // 7: teleport.machineid.v1.BotInstanceService.SubmitHeartbeat:input_type -> teleport.machineid.v1.SubmitHeartbeatRequest + 8, // 8: teleport.machineid.v1.BotInstanceService.GetBotInstance:output_type -> teleport.machineid.v1.BotInstance + 3, // 9: teleport.machineid.v1.BotInstanceService.ListBotInstances:output_type -> teleport.machineid.v1.ListBotInstancesResponse + 3, // 10: teleport.machineid.v1.BotInstanceService.ListBotInstancesV2:output_type -> teleport.machineid.v1.ListBotInstancesResponse + 10, // 11: teleport.machineid.v1.BotInstanceService.DeleteBotInstance:output_type -> google.protobuf.Empty + 6, // 12: teleport.machineid.v1.BotInstanceService.SubmitHeartbeat:output_type -> teleport.machineid.v1.SubmitHeartbeatResponse + 8, // [8:13] is the sub-list for method output_type + 3, // [3:8] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name } func init() { file_teleport_machineid_v1_bot_instance_service_proto_init() } @@ -462,7 +575,7 @@ func file_teleport_machineid_v1_bot_instance_service_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_teleport_machineid_v1_bot_instance_service_proto_rawDesc), len(file_teleport_machineid_v1_bot_instance_service_proto_rawDesc)), NumEnums: 0, - NumMessages: 6, + NumMessages: 7, NumExtensions: 0, NumServices: 1, }, diff --git a/api/gen/proto/go/teleport/machineid/v1/bot_instance_service_grpc.pb.go b/api/gen/proto/go/teleport/machineid/v1/bot_instance_service_grpc.pb.go index 766dda1c5d9a5..7e351d41edbc9 100644 --- a/api/gen/proto/go/teleport/machineid/v1/bot_instance_service_grpc.pb.go +++ b/api/gen/proto/go/teleport/machineid/v1/bot_instance_service_grpc.pb.go @@ -34,10 +34,11 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - BotInstanceService_GetBotInstance_FullMethodName = "/teleport.machineid.v1.BotInstanceService/GetBotInstance" - BotInstanceService_ListBotInstances_FullMethodName = "/teleport.machineid.v1.BotInstanceService/ListBotInstances" - BotInstanceService_DeleteBotInstance_FullMethodName = "/teleport.machineid.v1.BotInstanceService/DeleteBotInstance" - BotInstanceService_SubmitHeartbeat_FullMethodName = "/teleport.machineid.v1.BotInstanceService/SubmitHeartbeat" + BotInstanceService_GetBotInstance_FullMethodName = "/teleport.machineid.v1.BotInstanceService/GetBotInstance" + BotInstanceService_ListBotInstances_FullMethodName = "/teleport.machineid.v1.BotInstanceService/ListBotInstances" + BotInstanceService_ListBotInstancesV2_FullMethodName = "/teleport.machineid.v1.BotInstanceService/ListBotInstancesV2" + BotInstanceService_DeleteBotInstance_FullMethodName = "/teleport.machineid.v1.BotInstanceService/DeleteBotInstance" + BotInstanceService_SubmitHeartbeat_FullMethodName = "/teleport.machineid.v1.BotInstanceService/SubmitHeartbeat" ) // BotInstanceServiceClient is the client API for BotInstanceService service. @@ -50,6 +51,8 @@ type BotInstanceServiceClient interface { GetBotInstance(ctx context.Context, in *GetBotInstanceRequest, opts ...grpc.CallOption) (*BotInstance, error) // ListBotInstances returns a page of BotInstance resources. ListBotInstances(ctx context.Context, in *ListBotInstancesRequest, opts ...grpc.CallOption) (*ListBotInstancesResponse, error) + // ListBotInstancesV2 returns a page of BotInstance resources. + ListBotInstancesV2(ctx context.Context, in *ListBotInstancesV2Request, opts ...grpc.CallOption) (*ListBotInstancesResponse, error) // DeleteBotInstance hard deletes the specified BotInstance resource. DeleteBotInstance(ctx context.Context, in *DeleteBotInstanceRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) // SubmitHeartbeat submits a heartbeat for a BotInstance. @@ -84,6 +87,16 @@ func (c *botInstanceServiceClient) ListBotInstances(ctx context.Context, in *Lis return out, nil } +func (c *botInstanceServiceClient) ListBotInstancesV2(ctx context.Context, in *ListBotInstancesV2Request, opts ...grpc.CallOption) (*ListBotInstancesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListBotInstancesResponse) + err := c.cc.Invoke(ctx, BotInstanceService_ListBotInstancesV2_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *botInstanceServiceClient) DeleteBotInstance(ctx context.Context, in *DeleteBotInstanceRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(emptypb.Empty) @@ -114,6 +127,8 @@ type BotInstanceServiceServer interface { GetBotInstance(context.Context, *GetBotInstanceRequest) (*BotInstance, error) // ListBotInstances returns a page of BotInstance resources. ListBotInstances(context.Context, *ListBotInstancesRequest) (*ListBotInstancesResponse, error) + // ListBotInstancesV2 returns a page of BotInstance resources. + ListBotInstancesV2(context.Context, *ListBotInstancesV2Request) (*ListBotInstancesResponse, error) // DeleteBotInstance hard deletes the specified BotInstance resource. DeleteBotInstance(context.Context, *DeleteBotInstanceRequest) (*emptypb.Empty, error) // SubmitHeartbeat submits a heartbeat for a BotInstance. @@ -134,6 +149,9 @@ func (UnimplementedBotInstanceServiceServer) GetBotInstance(context.Context, *Ge func (UnimplementedBotInstanceServiceServer) ListBotInstances(context.Context, *ListBotInstancesRequest) (*ListBotInstancesResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method ListBotInstances not implemented") } +func (UnimplementedBotInstanceServiceServer) ListBotInstancesV2(context.Context, *ListBotInstancesV2Request) (*ListBotInstancesResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListBotInstancesV2 not implemented") +} func (UnimplementedBotInstanceServiceServer) DeleteBotInstance(context.Context, *DeleteBotInstanceRequest) (*emptypb.Empty, error) { return nil, status.Errorf(codes.Unimplemented, "method DeleteBotInstance not implemented") } @@ -197,6 +215,24 @@ func _BotInstanceService_ListBotInstances_Handler(srv interface{}, ctx context.C return interceptor(ctx, in, info, handler) } +func _BotInstanceService_ListBotInstancesV2_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListBotInstancesV2Request) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(BotInstanceServiceServer).ListBotInstancesV2(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: BotInstanceService_ListBotInstancesV2_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(BotInstanceServiceServer).ListBotInstancesV2(ctx, req.(*ListBotInstancesV2Request)) + } + return interceptor(ctx, in, info, handler) +} + func _BotInstanceService_DeleteBotInstance_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(DeleteBotInstanceRequest) if err := dec(in); err != nil { @@ -248,6 +284,10 @@ var BotInstanceService_ServiceDesc = grpc.ServiceDesc{ MethodName: "ListBotInstances", Handler: _BotInstanceService_ListBotInstances_Handler, }, + { + MethodName: "ListBotInstancesV2", + Handler: _BotInstanceService_ListBotInstancesV2_Handler, + }, { MethodName: "DeleteBotInstance", Handler: _BotInstanceService_DeleteBotInstance_Handler, diff --git a/api/proto/teleport/machineid/v1/bot_instance_service.proto b/api/proto/teleport/machineid/v1/bot_instance_service.proto index f0802486fccd4..37bb3de2db39b 100644 --- a/api/proto/teleport/machineid/v1/bot_instance_service.proto +++ b/api/proto/teleport/machineid/v1/bot_instance_service.proto @@ -50,6 +50,31 @@ message ListBotInstancesRequest { types.SortBy sort = 5; } +// Request for ListBotInstancesV2. +// +// Follows the pagination semantics of +// https://cloud.google.com/apis/design/standard_methods#list +message ListBotInstancesV2Request { + // The maximum number of items to return. + // The server may impose a different page size at its discretion. + int32 page_size = 1; + // The page_token value returned from a previous ListBotInstancesV2 request, + // if any. + string page_token = 2; + // The sort field to use for the results. If empty, the default sort field is + // used. + string sort_field = 3; + // The sort order to use for the results. If empty, the default sort order is + // used. + bool sort_desc = 4; + // The name of the Bot to list BotInstances for. If empty, all BotInstances + // will be listed. + string filter_bot_name = 5; + // A search term used to filter the results. If non-empty, it's used to match + // against supported fields. + string filter_search_term = 6; +} + // Response for ListBotInstances. message ListBotInstancesResponse { // BotInstance that matched the search. @@ -84,6 +109,8 @@ service BotInstanceService { rpc GetBotInstance(GetBotInstanceRequest) returns (BotInstance); // ListBotInstances returns a page of BotInstance resources. rpc ListBotInstances(ListBotInstancesRequest) returns (ListBotInstancesResponse); + // ListBotInstancesV2 returns a page of BotInstance resources. + rpc ListBotInstancesV2(ListBotInstancesV2Request) returns (ListBotInstancesResponse); // DeleteBotInstance hard deletes the specified BotInstance resource. rpc DeleteBotInstance(DeleteBotInstanceRequest) returns (google.protobuf.Empty); // SubmitHeartbeat submits a heartbeat for a BotInstance. diff --git a/lib/auth/authclient/api.go b/lib/auth/authclient/api.go index 931aea567a03d..6a304fb008491 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, pageSize int, lastToken string, options *services.ListBotInstancesRequestOptions) ([]*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..f4d6bceedc229 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, pageSize int, lastToken string, options *services.ListBotInstancesRequestOptions) ([]*pb.BotInstance, string, error) } // BotInstanceServiceConfig holds configuration options for the BotInstance gRPC @@ -148,6 +148,24 @@ func (b *BotInstanceService) GetBotInstance(ctx context.Context, req *pb.GetBotI // ListBotInstances returns a list of bot instances matching the criteria in the request func (b *BotInstanceService) ListBotInstances(ctx context.Context, req *pb.ListBotInstancesRequest) (*pb.ListBotInstancesResponse, error) { + var sortField string + var sortDesc bool + if req.GetSort() != nil { + sortField = req.GetSort().Field + sortDesc = req.GetSort().IsDesc + } + return b.ListBotInstancesV2(ctx, &pb.ListBotInstancesV2Request{ + PageSize: req.GetPageSize(), + PageToken: req.GetPageToken(), + SortField: sortField, + SortDesc: sortDesc, + FilterBotName: req.GetFilterBotName(), + FilterSearchTerm: req.GetFilterSearchTerm(), + }) +} + +// ListBotInstancesV2 returns a list of bot instances matching the criteria in the request +func (b *BotInstanceService) ListBotInstancesV2(ctx context.Context, req *pb.ListBotInstancesV2Request) (*pb.ListBotInstancesResponse, error) { authCtx, err := b.authorizer.Authorize(ctx) if err != nil { return nil, trace.Wrap(err) @@ -157,7 +175,12 @@ 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, int(req.PageSize), req.PageToken, &services.ListBotInstancesRequestOptions{ + SortField: req.SortField, + SortDesc: req.SortDesc, + FilterBotName: req.FilterBotName, + FilterSearchTerm: req.FilterSearchTerm, + }) if err != nil { return nil, trace.Wrap(err) } diff --git a/lib/auth/machineid/machineidv1/bot_instance_service_test.go b/lib/auth/machineid/machineidv1/bot_instance_service_test.go index 37a4d0a491d76..3c2815b0102da 100644 --- a/lib/auth/machineid/machineidv1/bot_instance_service_test.go +++ b/lib/auth/machineid/machineidv1/bot_instance_service_test.go @@ -69,6 +69,14 @@ func TestBotInstanceServiceAccess(t *testing.T) { }, allowedVerbs: []string{types.VerbRead, types.VerbList}, }, + { + name: "ListBotInstancesV2", + allowedStates: []authz.AdminActionAuthState{ + authz.AdminActionAuthUnauthorized, authz.AdminActionAuthNotRequired, + authz.AdminActionAuthMFAVerified, authz.AdminActionAuthMFAVerifiedWithReuse, + }, + allowedVerbs: []string{types.VerbRead, types.VerbList}, + }, { name: "DeleteBotInstance", allowedStates: []authz.AdminActionAuthState{ diff --git a/lib/cache/bot_instance.go b/lib/cache/bot_instance.go index 9cfb2a7580755..5d9bac4cc2e84 100644 --- a/lib/cache/bot_instance.go +++ b/lib/cache/bot_instance.go @@ -64,7 +64,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) @@ -92,7 +92,8 @@ 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) { +// request *services.ListBotInstancesRequestOptions +func (c *Cache) ListBotInstances(ctx context.Context, pageSize int, lastToken string, options *services.ListBotInstancesRequestOptions) ([]*machineidv1.BotInstance, string, error) { ctx, span := c.Tracer.Start(ctx, "cache/ListBotInstances") defer span.End() @@ -125,10 +126,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, limit, start, options) }, filter: func(b *machineidv1.BotInstance) bool { - return services.MatchBotInstance(b, botName, search) + return services.MatchBotInstance(b, options.GetFilterBotName(), options.GetFilterSearchTerm()) }, nextToken: func(b *machineidv1.BotInstance) string { return keyFn(b) diff --git a/lib/cache/bot_instance_test.go b/lib/cache/bot_instance_test.go index a48aeabee49ad..3f15acf103314 100644 --- a/lib/cache/bot_instance_test.go +++ b/lib/cache/bot_instance_test.go @@ -29,6 +29,7 @@ import ( headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" machineidv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1" "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/services" ) // TestBotInstanceCache tests that CRUD operations on bot instances resources are @@ -56,14 +57,14 @@ func TestBotInstanceCache(t *testing.T) { return p.cache.GetBotInstance(ctx, "bot-1", key) }, cacheList: func(ctx context.Context, pageSize int, pageToken string) ([]*machineidv1.BotInstance, string, error) { - return p.cache.ListBotInstances(ctx, "", pageSize, pageToken, "", nil) + return p.cache.ListBotInstances(ctx, pageSize, pageToken, nil) }, create: func(ctx context.Context, resource *machineidv1.BotInstance) error { _, err := p.botInstanceService.CreateBotInstance(ctx, resource) return err }, list: func(ctx context.Context, pageSize int, pageToken string) ([]*machineidv1.BotInstance, string, error) { - return p.botInstanceService.ListBotInstances(ctx, "", pageSize, pageToken, "", nil) + return p.botInstanceService.ListBotInstances(ctx, pageSize, pageToken, nil) }, update: func(ctx context.Context, bi *machineidv1.BotInstance) error { _, err := p.botInstanceService.PatchBotInstance(ctx, "bot-1", bi.Metadata.GetName(), func(_ *machineidv1.BotInstance) (*machineidv1.BotInstance, error) { @@ -105,13 +106,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) @@ -122,7 +123,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) @@ -131,7 +132,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) @@ -164,12 +165,14 @@ 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, 0, "", &services.ListBotInstancesRequestOptions{ + FilterBotName: "bot-1", + }) require.NoError(t, err) require.Len(t, results, 5) @@ -211,12 +214,14 @@ 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, "", &services.ListBotInstancesRequestOptions{ + FilterSearchTerm: "host-1", + }) require.NoError(t, err) require.Len(t, results, 5) } @@ -270,90 +275,78 @@ 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) t.Run("sort ascending by active_at_latest", func(t *testing.T) { - results, _, err := p.cache.ListBotInstances(ctx, "", 0, "", "", &types.SortBy{ - Field: "active_at_latest", - IsDesc: false, + results, _, err := p.cache.ListBotInstances(ctx, 0, "", &services.ListBotInstancesRequestOptions{ + SortField: "active_at_latest", + SortDesc: false, }) require.NoError(t, err) require.Len(t, results, 3) - require.Equal(t, "instance-3", results[0].GetMetadata().GetName()) - require.Equal(t, "instance-1", results[1].GetMetadata().GetName()) - require.Equal(t, "instance-2", results[2].GetMetadata().GetName()) + assert.Equal(t, "instance-3", results[0].GetMetadata().GetName()) + assert.Equal(t, "instance-1", results[1].GetMetadata().GetName()) + assert.Equal(t, "instance-2", results[2].GetMetadata().GetName()) }) t.Run("sort descending by active_at_latest", func(t *testing.T) { - results, _, err := p.cache.ListBotInstances(ctx, "", 0, "", "", &types.SortBy{ - Field: "active_at_latest", - IsDesc: true, + results, _, err := p.cache.ListBotInstances(ctx, 0, "", &services.ListBotInstancesRequestOptions{ + SortField: "active_at_latest", + SortDesc: true, }) require.NoError(t, err) require.Len(t, results, 3) - require.Equal(t, "instance-2", results[0].GetMetadata().GetName()) - require.Equal(t, "instance-1", results[1].GetMetadata().GetName()) - require.Equal(t, "instance-3", results[2].GetMetadata().GetName()) + assert.Equal(t, "instance-2", results[0].GetMetadata().GetName()) + assert.Equal(t, "instance-1", results[1].GetMetadata().GetName()) + assert.Equal(t, "instance-3", results[2].GetMetadata().GetName()) }) t.Run("sort ascending by bot_name", func(t *testing.T) { - 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()) - require.Equal(t, "instance-3", results[1].GetMetadata().GetName()) - require.Equal(t, "instance-2", results[2].GetMetadata().GetName()) + assert.Equal(t, "instance-1", results[0].GetMetadata().GetName()) + assert.Equal(t, "instance-3", results[1].GetMetadata().GetName()) + assert.Equal(t, "instance-2", results[2].GetMetadata().GetName()) }) t.Run("sort descending by bot_name", func(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()) - require.Equal(t, "instance-3", results[1].GetMetadata().GetName()) - require.Equal(t, "instance-1", results[2].GetMetadata().GetName()) - }) - - t.Run("sort ascending by version", func(t *testing.T) { - results, _, err := p.cache.ListBotInstances(ctx, "", 0, "", "", &types.SortBy{ - Field: "version_latest", - IsDesc: false, + results, _, err := p.cache.ListBotInstances(ctx, 0, "", &services.ListBotInstancesRequestOptions{ + SortField: "bot_name", + SortDesc: true, }) require.NoError(t, err) require.Len(t, results, 3) - require.Equal(t, "instance-2", results[0].GetMetadata().GetName()) - require.Equal(t, "instance-3", results[1].GetMetadata().GetName()) - require.Equal(t, "instance-1", results[2].GetMetadata().GetName()) + assert.Equal(t, "instance-2", results[0].GetMetadata().GetName()) + assert.Equal(t, "instance-3", results[1].GetMetadata().GetName()) + assert.Equal(t, "instance-1", results[2].GetMetadata().GetName()) }) t.Run("sort ascending by version", func(t *testing.T) { - results, _, err := p.cache.ListBotInstances(ctx, "", 0, "", "", &types.SortBy{ - Field: "version_latest", - IsDesc: true, + results, _, err := p.cache.ListBotInstances(ctx, 0, "", &services.ListBotInstancesRequestOptions{ + SortField: "version_latest", + SortDesc: false, }) require.NoError(t, err) require.Len(t, results, 3) - require.Equal(t, "instance-1", results[0].GetMetadata().GetName()) - require.Equal(t, "instance-3", results[1].GetMetadata().GetName()) - require.Equal(t, "instance-2", results[2].GetMetadata().GetName()) + assert.Equal(t, "instance-2", results[0].GetMetadata().GetName()) + assert.Equal(t, "instance-3", results[1].GetMetadata().GetName()) + assert.Equal(t, "instance-1", results[2].GetMetadata().GetName()) }) t.Run("sort descending by version", func(t *testing.T) { - results, _, err := p.cache.ListBotInstances(ctx, "", 0, "", "", &types.SortBy{ - Field: "version_latest", - IsDesc: false, + results, _, err := p.cache.ListBotInstances(ctx, 0, "", &services.ListBotInstancesRequestOptions{ + SortField: "version_latest", + SortDesc: true, }) require.NoError(t, err) require.Len(t, results, 3) - require.Equal(t, "instance-2", results[0].GetMetadata().GetName()) - require.Equal(t, "instance-3", results[1].GetMetadata().GetName()) - require.Equal(t, "instance-1", results[2].GetMetadata().GetName()) + assert.Equal(t, "instance-1", results[0].GetMetadata().GetName()) + assert.Equal(t, "instance-3", results[1].GetMetadata().GetName()) + assert.Equal(t, "instance-2", results[2].GetMetadata().GetName()) }) t.Run("sort ascending by hostname", func(t *testing.T) { @@ -407,34 +400,34 @@ 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) // sort ascending by bot_name - results, _, err := p.cache.ListBotInstances(ctx, "", 0, "", "", &types.SortBy{ - Field: "bot_name", - IsDesc: false, + results, _, err := p.cache.ListBotInstances(ctx, 0, "", &services.ListBotInstancesRequestOptions{ + SortField: "bot_name", + SortDesc: false, }) require.NoError(t, err) // asc by bot_name is the only sort supported by the upstream require.Len(t, results, 1) // sort descending by bot_name - _, _, err = p.cache.ListBotInstances(ctx, "", 0, "", "", &types.SortBy{ - Field: "bot_name", - IsDesc: true, + _, _, err = p.cache.ListBotInstances(ctx, 0, "", &services.ListBotInstancesRequestOptions{ + SortField: "bot_name", + SortDesc: true, }) require.Error(t, err) - require.Equal(t, "unsupported sort, only bot_name:asc is supported, but got \"bot_name\" (desc = true)", err.Error()) + assert.ErrorContains(t, err, "unsupported sort, only ascending order is supported") // sort ascending by active_at_latest - _, _, err = p.cache.ListBotInstances(ctx, "", 0, "", "", &types.SortBy{ - Field: "active_at_latest", - IsDesc: false, + _, _, err = p.cache.ListBotInstances(ctx, 0, "", &services.ListBotInstancesRequestOptions{ + SortField: "active_at_latest", + SortDesc: 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()) + assert.ErrorContains(t, err, "unsupported sort, only bot_name field is supported, but got \"active_at_latest\"") } func TestKeyForVersionIndex(t *testing.T) { diff --git a/lib/services/bot_instance.go b/lib/services/bot_instance.go index 0722df1c1a5d8..551a0f85457b2 100644 --- a/lib/services/bot_instance.go +++ b/lib/services/bot_instance.go @@ -24,7 +24,6 @@ import ( "github.com/gravitational/trace" machineidv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1" - "github.com/gravitational/teleport/api/types" ) // BotInstance is an interface for the BotInstance service. @@ -36,7 +35,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, pageSize int, lastToken string, options *ListBotInstancesRequestOptions) ([]*machineidv1.BotInstance, string, error) // DeleteBotInstance DeleteBotInstance(ctx context.Context, botName, instanceID string) error @@ -122,3 +121,46 @@ func PickBotInstanceRecentHeartbeat(botInstance *machineidv1.BotInstance) *machi } return heartbeat } + +type ListBotInstancesRequestOptions struct { + // The sort field to use for the results. If empty, the default sort field + // is used. + SortField string + // The sort order to use for the results. If empty, the default sort order + // is used. + SortDesc bool + // The name of the Bot to list BotInstances for. If empty, all BotInstances + // will be listed. + FilterBotName string + // A search term used to filter the results. If non-empty, it's used to + // match against supported fields. + FilterSearchTerm string +} + +func (o *ListBotInstancesRequestOptions) GetSortField() string { + if o == nil { + return "" + } + return o.SortField +} + +func (o *ListBotInstancesRequestOptions) GetSortDesc() bool { + if o == nil { + return false + } + return o.SortDesc +} + +func (o *ListBotInstancesRequestOptions) GetFilterBotName() string { + if o == nil { + return "" + } + return o.FilterBotName +} + +func (o *ListBotInstancesRequestOptions) GetFilterSearchTerm() string { + if o == nil { + return "" + } + return o.FilterSearchTerm +} diff --git a/lib/services/local/bot_instance.go b/lib/services/local/bot_instance.go index 37daa467ffdb4..5a144669c016b 100644 --- a/lib/services/local/bot_instance.go +++ b/lib/services/local/bot_instance.go @@ -94,26 +94,29 @@ func (b *BotInstanceService) GetBotInstance(ctx context.Context, botName, instan // 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) { - 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) +func (b *BotInstanceService) ListBotInstances(ctx context.Context, pageSize int, lastKey string, options *services.ListBotInstancesRequestOptions) ([]*machineidv1.BotInstance, string, error) { + if options.GetSortField() != "" && options.GetSortField() != "bot_name" { + return nil, "", trace.CompareFailed("unsupported sort, only bot_name field is supported, but got %q", options.GetSortField()) + } + if options.GetSortDesc() { + return nil, "", trace.CompareFailed("unsupported sort, only ascending order is supported") } var service *generic.ServiceWrapper[*machineidv1.BotInstance] - if botName == "" { + if options.GetFilterBotName() == "" { // If botName is empty, return instances for all bots by not using a service prefix service = b.service } else { - service = b.service.WithPrefix(botName) + service = b.service.WithPrefix(options.GetFilterBotName()) } - if search == "" { + if options.GetFilterSearchTerm() == "" { r, nextToken, err := service.ListResources(ctx, pageSize, lastKey) return r, nextToken, trace.Wrap(err) } r, nextToken, err := service.ListResourcesWithFilter(ctx, pageSize, lastKey, func(item *machineidv1.BotInstance) bool { - return services.MatchBotInstance(item, botName, search) + return services.MatchBotInstance(item, options.GetFilterBotName(), options.GetFilterSearchTerm()) }) return r, nextToken, trace.Wrap(err) diff --git a/lib/services/local/bot_instance_test.go b/lib/services/local/bot_instance_test.go index 7d0b4e49f8f26..6ed785179be4d 100644 --- a/lib/services/local/bot_instance_test.go +++ b/lib/services/local/bot_instance_test.go @@ -34,6 +34,7 @@ import ( "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/lib/backend" "github.com/gravitational/teleport/lib/backend/memory" + "github.com/gravitational/teleport/lib/services" ) // newBotInstance creates (but does not insert) a bot instance that is ready for @@ -159,7 +160,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, searchTerm string, sort *types.SortBy) []*machineidv1.BotInstance { +func listInstances(t *testing.T, ctx context.Context, service *BotInstanceService, options *services.ListBotInstancesRequestOptions) []*machineidv1.BotInstance { t.Helper() var resources []*machineidv1.BotInstance @@ -168,7 +169,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, 0, nextKey, options) require.NoError(t, err) resources = append(resources, bis...) @@ -307,7 +308,9 @@ 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", "", nil) + resources := listInstances(t, ctx, service, &services.ListBotInstancesRequestOptions{ + FilterBotName: "example", + }) require.Len(t, resources, 1, "must list only 1 bot instance") require.EqualExportedValues(t, patched, resources[0]) @@ -354,14 +357,18 @@ 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", "", nil) + aInstances := listInstances(t, ctx, service, &services.ListBotInstancesRequestOptions{ + FilterBotName: "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", "", nil) + bInstances := listInstances(t, ctx, service, &services.ListBotInstancesRequestOptions{ + FilterBotName: "b", + }) require.Len(t, bInstances, 4) for _, ins := range bInstances { require.Contains(t, bIds, ins.Spec.InstanceId) @@ -376,7 +383,9 @@ func TestBotInstanceList(t *testing.T) { } // Listing an empty bot name ("") should return all instances. - allInstances := listInstances(t, ctx, service, "", "", nil) + allInstances := listInstances(t, ctx, service, &services.ListBotInstancesRequestOptions{ + FilterBotName: "", + }) require.Len(t, allInstances, 7) for _, ins := range allInstances { require.Contains(t, allIds, ins.Spec.InstanceId) @@ -445,7 +454,9 @@ func TestBotInstanceListWithSearchFilter(t *testing.T) { _, err = service.CreateBotInstance(ctx, newBotInstance("bot-not-matched")) require.NoError(t, err) - instances := listInstances(t, ctx, service, "", tc.searchTerm, nil) + instances := listInstances(t, ctx, service, &services.ListBotInstancesRequestOptions{ + FilterSearchTerm: tc.searchTerm, + }) require.Len(t, instances, 1) require.Equal(t, tc.instance.Spec.InstanceId, instances[0].Spec.InstanceId) @@ -470,13 +481,20 @@ func TestBotInstanceListWithSort(t *testing.T) { service, err := NewBotInstanceService(backend.NewSanitizer(mem), clock) require.NoError(t, err) - _, _, err = service.ListBotInstances(ctx, "", 0, "", "", &types.SortBy{ - Field: "test_field", - IsDesc: true, + _, _, err = service.ListBotInstances(ctx, 0, "", &services.ListBotInstancesRequestOptions{ + SortField: "test_field", + SortDesc: false, + }) + require.Error(t, err) + require.ErrorContains(t, err, "unsupported sort, only bot_name field is supported, but got \"test_field\"") + + _, _, err = service.ListBotInstances(ctx, 0, "", &services.ListBotInstancesRequestOptions{ + SortField: "bot_name", + SortDesc: true, }) require.Error(t, err) - require.Equal(t, "unsupported sort, only bot_name:asc is supported, but got \"test_field\" (desc = true)", err.Error()) + require.ErrorContains(t, err, "unsupported sort, only ascending order is supported") - _, _, err = service.ListBotInstances(ctx, "", 0, "", "", nil) + _, _, err = service.ListBotInstances(ctx, 0, "", nil) require.NoError(t, err) } From b295941a9ed03c0442961c79c6b4e4c11d97f89a Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Wed, 17 Sep 2025 14:26:22 +0100 Subject: [PATCH 03/19] Add v2 bot instance list endpoint --- lib/web/apiserver.go | 4 + lib/web/machineid.go | 57 ++++++ lib/web/machineid_test.go | 410 +++++++++++++++++++++++--------------- 3 files changed, 305 insertions(+), 166 deletions(-) diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index db8da55320255..d3b74071ad5bb 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -1162,7 +1162,11 @@ func (h *Handler) bindDefaultEndpoints() { // GET Machine ID instance for a bot by id h.GET("/webapi/sites/:site/machine-id/bot/:name/bot-instance/:id", h.WithClusterAuth(h.getBotInstance)) // GET Machine ID bot instances (paged) + // TODO(nicholasmarais1158) DELETE IN v20.0.0 + // Replaced by `PUT /v2/webapi/sites/:site/machine-id/bot-instance`. h.GET("/webapi/sites/:site/machine-id/bot-instance", h.WithClusterAuth(h.listBotInstances)) + // GET Machine ID bot instances (paged) + h.GET("/v2/webapi/sites/:site/machine-id/bot-instance", h.WithClusterAuth(h.listBotInstancesV2)) // GET a paginated list of notifications for a user h.GET("/webapi/sites/:site/notifications", h.WithClusterAuth(h.notificationsGet)) diff --git a/lib/web/machineid.go b/lib/web/machineid.go index 48f54ca6f8022..1a04fed5990bb 100644 --- a/lib/web/machineid.go +++ b/lib/web/machineid.go @@ -19,6 +19,7 @@ package web import ( "net/http" "strconv" + "strings" "time" yaml "github.com/ghodss/yaml" @@ -425,7 +426,63 @@ func (h *Handler) listBotInstances(_ http.ResponseWriter, r *http.Request, _ htt uiInstances := tslices.Map(instances.BotInstances, func(instance *machineidv1.BotInstance) BotInstance { heartbeat := services.PickBotInstanceRecentHeartbeat(instance) + + uiInstance := BotInstance{ + InstanceId: instance.Spec.InstanceId, + BotName: instance.Spec.BotName, + } + + if heartbeat != nil { + uiInstance.JoinMethodLatest = heartbeat.JoinMethod + uiInstance.HostNameLatest = heartbeat.Hostname + uiInstance.VersionLatest = heartbeat.Version + uiInstance.ActiveAtLatest = heartbeat.RecordedAt.AsTime().Format(time.RFC3339) + uiInstance.OSLatest = heartbeat.Os + } + + return uiInstance + }) + + return ListBotInstancesResponse{ + BotInstances: uiInstances, + NextPageToken: instances.NextPageToken, + }, nil +} + +// listBotInstancesV2 returns a list of bot instances for a given cluster site. +func (h *Handler) listBotInstancesV2(_ http.ResponseWriter, r *http.Request, _ httprouter.Params, sctx *SessionContext, cluster reversetunnelclient.Cluster) (any, error) { + clt, err := sctx.GetUserClient(r.Context(), cluster) + if err != nil { + return nil, trace.Wrap(err) + } + + request := &machineidv1.ListBotInstancesV2Request{ + PageToken: r.URL.Query().Get("page_token"), + SortField: r.URL.Query().Get("sort_field"), + FilterBotName: r.URL.Query().Get("bot_name"), + FilterSearchTerm: r.URL.Query().Get("search"), + } + + if r.URL.Query().Has("page_size") { + pageSize, err := strconv.ParseInt(r.URL.Query().Get("page_size"), 10, 32) + if err != nil { + return nil, trace.BadParameter("invalid page size") } + request.PageSize = int32(pageSize) + } + + if r.URL.Query().Has("sort_dir") { + sortDir := r.URL.Query().Get("sort_dir") + request.SortDesc = strings.ToLower(sortDir) == "desc" + } + + instances, err := clt.BotInstanceServiceClient().ListBotInstancesV2(r.Context(), request) + if err != nil { + return nil, trace.Wrap(err) + } + + uiInstances := tslices.Map(instances.BotInstances, func(instance *machineidv1.BotInstance) BotInstance { + heartbeat := services.PickBotInstanceRecentHeartbeat(instance) uiInstance := BotInstance{ InstanceId: instance.Spec.InstanceId, diff --git a/lib/web/machineid_test.go b/lib/web/machineid_test.go index 451c0ad851318..262f676eab505 100644 --- a/lib/web/machineid_test.go +++ b/lib/web/machineid_test.go @@ -554,14 +554,23 @@ func TestListBotInstances(t *testing.T) { proxy := env.proxies[0] pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) clusterName := env.server.ClusterName() - endpoint := pack.clt.Endpoint( - "webapi", - "sites", - clusterName, - "machine-id", - "bot-instance", - ) - + endpoints := []string{ + pack.clt.Endpoint( + "webapi", + "sites", + clusterName, + "machine-id", + "bot-instance", + ), + pack.clt.Endpoint( + "v2", + "webapi", + "sites", + clusterName, + "machine-id", + "bot-instance", + ), + } instanceID := uuid.New().String() _, err := env.server.Auth().CreateBotInstance(ctx, &machineidv1.BotInstance{ @@ -600,27 +609,31 @@ func TestListBotInstances(t *testing.T) { }) require.NoError(t, err) - response, err := pack.clt.Get(ctx, endpoint, url.Values{}) - require.NoError(t, err) - assert.Equal(t, http.StatusOK, response.Code(), "unexpected status code") + for _, endpoint := range endpoints { + t.Run(endpoint, func(t *testing.T) { + response, err := pack.clt.Get(ctx, endpoint, url.Values{}) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, response.Code(), "unexpected status code") - var instances ListBotInstancesResponse - require.NoError(t, json.Unmarshal(response.Bytes(), &instances), "invalid response received") + var instances ListBotInstancesResponse + require.NoError(t, json.Unmarshal(response.Bytes(), &instances), "invalid response received") - assert.Len(t, instances.BotInstances, 1) - require.Empty(t, cmp.Diff(instances, ListBotInstancesResponse{ - BotInstances: []BotInstance{ - { - InstanceId: instanceID, - BotName: "test-bot", - JoinMethodLatest: "test-join-method", - HostNameLatest: "test-hostname", - VersionLatest: "1.0.0", - ActiveAtLatest: "1970-01-01T00:00:03Z", - OSLatest: "linux", - }, - }, - })) + assert.Len(t, instances.BotInstances, 1) + require.Empty(t, cmp.Diff(instances, ListBotInstancesResponse{ + BotInstances: []BotInstance{ + { + InstanceId: instanceID, + BotName: "test-bot", + JoinMethodLatest: "test-join-method", + HostNameLatest: "test-hostname", + VersionLatest: "1.0.0", + ActiveAtLatest: "1970-01-01T00:00:03Z", + OSLatest: "linux", + }, + }, + })) + }) + } } func TestListBotInstancesWithInitialHeartbeat(t *testing.T) { @@ -631,13 +644,23 @@ func TestListBotInstancesWithInitialHeartbeat(t *testing.T) { proxy := env.proxies[0] pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) clusterName := env.server.ClusterName() - endpoint := pack.clt.Endpoint( - "webapi", - "sites", - clusterName, - "machine-id", - "bot-instance", - ) + endpoints := []string{ + pack.clt.Endpoint( + "webapi", + "sites", + clusterName, + "machine-id", + "bot-instance", + ), + pack.clt.Endpoint( + "v2", + "webapi", + "sites", + clusterName, + "machine-id", + "bot-instance", + ), + } instanceID := uuid.New().String() @@ -663,31 +686,59 @@ func TestListBotInstancesWithInitialHeartbeat(t *testing.T) { }) require.NoError(t, err) - response, err := pack.clt.Get(ctx, endpoint, url.Values{}) - require.NoError(t, err) - assert.Equal(t, http.StatusOK, response.Code(), "unexpected status code") + for _, endpoint := range endpoints { + t.Run(endpoint, func(t *testing.T) { + response, err := pack.clt.Get(ctx, endpoint, url.Values{}) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, response.Code(), "unexpected status code") - var instances ListBotInstancesResponse - require.NoError(t, json.Unmarshal(response.Bytes(), &instances), "invalid response received") + var instances ListBotInstancesResponse + require.NoError(t, json.Unmarshal(response.Bytes(), &instances), "invalid response received") - assert.Len(t, instances.BotInstances, 1) - require.Empty(t, cmp.Diff(instances, ListBotInstancesResponse{ - BotInstances: []BotInstance{ - { - InstanceId: instanceID, - BotName: "test-bot", - JoinMethodLatest: "test-join-method", - HostNameLatest: "test-hostname", - VersionLatest: "1.0.0", - ActiveAtLatest: "1970-01-01T00:00:03Z", - }, - }, - })) + assert.Len(t, instances.BotInstances, 1) + require.Empty(t, cmp.Diff(instances, ListBotInstancesResponse{ + BotInstances: []BotInstance{ + { + InstanceId: instanceID, + BotName: "test-bot", + JoinMethodLatest: "test-join-method", + HostNameLatest: "test-hostname", + VersionLatest: "1.0.0", + ActiveAtLatest: "1970-01-01T00:00:03Z", + }, + }, + })) + }) + } } func TestListBotInstancesPaging(t *testing.T) { t.Parallel() + ctx := context.Background() + env := newWebPack(t, 1) + proxy := env.proxies[0] + pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) + clusterName := env.server.ClusterName() + + endpoints := []string{ + pack.clt.Endpoint( + "webapi", + "sites", + clusterName, + "machine-id", + "bot-instance", + ), + pack.clt.Endpoint( + "v2", + "webapi", + "sites", + clusterName, + "machine-id", + "bot-instance", + ), + } + tcs := []struct { name string numInstances int @@ -710,47 +761,42 @@ func TestListBotInstancesPaging(t *testing.T) { }, } - for _, tc := range tcs { - t.Run(tc.name, func(t *testing.T) { - ctx := context.Background() - env := newWebPack(t, 1) - proxy := env.proxies[0] - pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) - clusterName := env.server.ClusterName() - endpoint := pack.clt.Endpoint( - "webapi", - "sites", - clusterName, - "machine-id", - "bot-instance", - ) - - n := 0 - for n < tc.numInstances { - n += 1 - _, err := env.server.Auth().CreateBotInstance(ctx, &machineidv1.BotInstance{ - Kind: types.KindBotInstance, - Version: types.V1, - Spec: &machineidv1.BotInstanceSpec{ - BotName: "bot-1", - InstanceId: uuid.New().String(), - }, - Status: &machineidv1.BotInstanceStatus{}, + for _, endpoint := range endpoints { + t.Run(endpoint, func(t *testing.T) { + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + for n := range tc.numInstances { + _, err := env.server.Auth().CreateBotInstance(ctx, &machineidv1.BotInstance{ + Kind: types.KindBotInstance, + Version: types.V1, + Spec: &machineidv1.BotInstanceSpec{ + BotName: "bot-1", + InstanceId: "instance-" + strconv.Itoa(n), + }, + Status: &machineidv1.BotInstanceStatus{}, + }) + require.NoError(t, err) + } + + response, err := pack.clt.Get(ctx, endpoint, url.Values{ + "page_token": []string{""}, // default to the start + "page_size": []string{strconv.Itoa(tc.pageSize)}, + }) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, response.Code(), "unexpected status code") + + var resp ListBotInstancesResponse + require.NoError(t, json.Unmarshal(response.Bytes(), &resp), "invalid response received") + + assert.Len(t, resp.BotInstances, int(math.Min(float64(tc.numInstances), float64(tc.pageSize)))) + + // remove instances before next test + for n := range tc.numInstances { + err = env.server.Auth().DeleteBotInstance(ctx, "bot-1", "instance-"+strconv.Itoa(n)) + require.NoError(t, err) + } }) - require.NoError(t, err) } - - response, err := pack.clt.Get(ctx, endpoint, url.Values{ - "page_token": []string{""}, // default to the start - "page_size": []string{strconv.Itoa(tc.pageSize)}, - }) - require.NoError(t, err) - assert.Equal(t, http.StatusOK, response.Code(), "unexpected status code") - - var resp ListBotInstancesResponse - require.NoError(t, json.Unmarshal(response.Bytes(), &resp), "invalid response received") - - assert.Len(t, resp.BotInstances, int(math.Min(float64(tc.numInstances), float64(tc.pageSize)))) }) } } @@ -763,13 +809,23 @@ func TestListBotInstancesWithBotFilter(t *testing.T) { proxy := env.proxies[0] pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) clusterName := env.server.ClusterName() - endpoint := pack.clt.Endpoint( - "webapi", - "sites", - clusterName, - "machine-id", - "bot-instance", - ) + endpoints := []string{ + pack.clt.Endpoint( + "webapi", + "sites", + clusterName, + "machine-id", + "bot-instance", + ), + pack.clt.Endpoint( + "v2", + "webapi", + "sites", + clusterName, + "machine-id", + "bot-instance", + ), + } n := 0 for n < 5 { @@ -787,21 +843,48 @@ func TestListBotInstancesWithBotFilter(t *testing.T) { require.NoError(t, err) } - response, err := pack.clt.Get(ctx, endpoint, url.Values{ - "bot_name": []string{"bot-1"}, - }) - require.NoError(t, err) - assert.Equal(t, http.StatusOK, response.Code(), "unexpected status code") + for _, endpoint := range endpoints { + t.Run(endpoint, func(t *testing.T) { + response, err := pack.clt.Get(ctx, endpoint, url.Values{ + "bot_name": []string{"bot-1"}, + }) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, response.Code(), "unexpected status code") - var instances ListBotInstancesResponse - require.NoError(t, json.Unmarshal(response.Bytes(), &instances), "invalid response received") + var instances ListBotInstancesResponse + require.NoError(t, json.Unmarshal(response.Bytes(), &instances), "invalid response received") - assert.Len(t, instances.BotInstances, 3) + assert.Len(t, instances.BotInstances, 3) + }) + } } func TestListBotInstancesWithSearchTermFilter(t *testing.T) { t.Parallel() + ctx := context.Background() + env := newWebPack(t, 1) + proxy := env.proxies[0] + pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) + clusterName := env.server.ClusterName() + endpoints := []string{ + pack.clt.Endpoint( + "webapi", + "sites", + clusterName, + "machine-id", + "bot-instance", + ), + pack.clt.Endpoint( + "v2", + "webapi", + "sites", + clusterName, + "machine-id", + "bot-instance", + ), + } + tcs := []struct { name string searchTerm string @@ -850,67 +933,62 @@ func TestListBotInstancesWithSearchTermFilter(t *testing.T) { }, } - for _, tc := range tcs { - t.Run(tc.name, func(t *testing.T) { - ctx := context.Background() - env := newWebPack(t, 1) - proxy := env.proxies[0] - pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) - clusterName := env.server.ClusterName() - endpoint := pack.clt.Endpoint( - "webapi", - "sites", - clusterName, - "machine-id", - "bot-instance", - ) - - spec := tc.spec - if spec == nil { - spec = &machineidv1.BotInstanceSpec{ - BotName: "test-bot", - InstanceId: "00000000-0000-0000-0000-000000000000", - } + for _, endpoint := range endpoints { + t.Run(endpoint, func(t *testing.T) { + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + spec := tc.spec + if spec == nil { + spec = &machineidv1.BotInstanceSpec{ + BotName: "test-bot", + InstanceId: "00000000-0000-0000-0000-000000000000", + } + } + + _, err := env.server.Auth().CreateBotInstance(ctx, &machineidv1.BotInstance{ + Kind: types.KindBotInstance, + Version: types.V1, + Spec: spec, + Status: &machineidv1.BotInstanceStatus{ + InitialHeartbeat: tc.heartbeat, + }, + }) + require.NoError(t, err) + + _, err = env.server.Auth().CreateBotInstance(ctx, &machineidv1.BotInstance{ + Kind: types.KindBotInstance, + Version: types.V1, + Spec: &machineidv1.BotInstanceSpec{ + BotName: "bot-gone", + InstanceId: uuid.New().String(), + }, + Status: &machineidv1.BotInstanceStatus{ + InitialHeartbeat: &machineidv1.BotInstanceStatusHeartbeat{ + Version: "1.1.1-prod", + Hostname: "test-hostname", + JoinMethod: "test-join-method", + }, + }, + }) + require.NoError(t, err) + + response, err := pack.clt.Get(ctx, endpoint, url.Values{ + "search": []string{tc.searchTerm}, + }) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, response.Code(), "unexpected status code") + + var instances ListBotInstancesResponse + require.NoError(t, json.Unmarshal(response.Bytes(), &instances), "invalid response received") + + assert.Len(t, instances.BotInstances, 1) + assert.Equal(t, "00000000-0000-0000-0000-000000000000", instances.BotInstances[0].InstanceId) + + // remove before next test + err = env.server.Auth().DeleteBotInstance(ctx, spec.BotName, spec.InstanceId) + require.NoError(t, err) + }) } - - _, err := env.server.Auth().CreateBotInstance(ctx, &machineidv1.BotInstance{ - Kind: types.KindBotInstance, - Version: types.V1, - Spec: spec, - Status: &machineidv1.BotInstanceStatus{ - InitialHeartbeat: tc.heartbeat, - }, - }) - require.NoError(t, err) - - _, err = env.server.Auth().CreateBotInstance(ctx, &machineidv1.BotInstance{ - Kind: types.KindBotInstance, - Version: types.V1, - Spec: &machineidv1.BotInstanceSpec{ - BotName: "bot-gone", - InstanceId: uuid.New().String(), - }, - Status: &machineidv1.BotInstanceStatus{ - InitialHeartbeat: &machineidv1.BotInstanceStatusHeartbeat{ - Version: "1.1.1-prod", - Hostname: "test-hostname", - JoinMethod: "test-join-method", - }, - }, - }) - require.NoError(t, err) - - response, err := pack.clt.Get(ctx, endpoint, url.Values{ - "search": []string{tc.searchTerm}, - }) - require.NoError(t, err) - assert.Equal(t, http.StatusOK, response.Code(), "unexpected status code") - - var instances ListBotInstancesResponse - require.NoError(t, json.Unmarshal(response.Bytes(), &instances), "invalid response received") - - assert.Len(t, instances.BotInstances, 1) - assert.Equal(t, "00000000-0000-0000-0000-000000000000", instances.BotInstances[0].InstanceId) }) } } From 144c65943f6e5c8a3f688d1d7c4b4b94c9d34f1b Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Wed, 17 Sep 2025 14:28:18 +0100 Subject: [PATCH 04/19] Use v2 endpoint in web UI --- .../src/BotInstances/BotInstances.tsx | 44 ++++++++++++------- .../BotInstances/List/BotInstancesList.tsx | 12 ++--- .../src/Bots/Details/InstancesPanel.tsx | 35 +++++++-------- web/packages/teleport/src/config.ts | 2 +- web/packages/teleport/src/services/bot/bot.ts | 13 ++++-- .../teleport/src/test/helpers/botInstances.ts | 17 +++---- 6 files changed, 67 insertions(+), 56 deletions(-) diff --git a/web/packages/teleport/src/BotInstances/BotInstances.tsx b/web/packages/teleport/src/BotInstances/BotInstances.tsx index f8b1e64dcf293..247b000f65ac7 100644 --- a/web/packages/teleport/src/BotInstances/BotInstances.tsx +++ b/web/packages/teleport/src/BotInstances/BotInstances.tsx @@ -17,12 +17,11 @@ */ import { keepPreviousData, useQuery } from '@tanstack/react-query'; -import { useCallback, useMemo } from 'react'; +import { useCallback } from 'react'; import { useHistory, useLocation } from 'react-router'; import { Alert } from 'design/Alert/Alert'; import Box from 'design/Box/Box'; -import { formatSortType, parseSortType } from 'design/DataTable/sort'; import { SortType } from 'design/DataTable/types'; import { Indicator } from 'design/Indicator/Indicator'; import { Mark } from 'design/Mark/Mark'; @@ -52,7 +51,8 @@ export function BotInstances() { const queryParams = new URLSearchParams(location.search); const pageToken = queryParams.get('page') ?? ''; const searchTerm = queryParams.get('search') ?? ''; - const sort = queryParams.get('sort') || 'active_at_latest:desc'; + const sortField = queryParams.get('sort_field') || 'active_at_latest'; + const sortDir = queryParams.get('sort_dir') || 'DESC'; const ctx = useTeleport(); const flags = ctx.getFeatureFlags(); @@ -60,14 +60,24 @@ export function BotInstances() { const { isPending, isFetching, isSuccess, isError, error, data } = useQuery({ enabled: canListInstances, - queryKey: ['bot_instances', 'list', searchTerm, pageToken, sort], - queryFn: () => - listBotInstances({ - pageSize: 20, - pageToken, - searchTerm, - sort, - }), + queryKey: [ + 'bot_instances', + 'list', + searchTerm, + pageToken, + sortField, + sortDir, + ], + queryFn: ({ signal }) => + listBotInstances( + { + pageSize: 20, + pageToken, + searchTerm, + sortField, + sortDir, + }, + ), placeholderData: keepPreviousData, staleTime: 30_000, // Cached pages are valid for 30 seconds }); @@ -142,14 +152,16 @@ export function BotInstances() { [history] ); - const sortType = useMemo(() => parseSortType(sort), [sort]); + const sortType: SortType = { + fieldName: sortField, + dir: sortDir.toLowerCase() === 'desc' ? 'DESC' : 'ASC', + }; const handleSortChanged = useCallback( (sortType: SortType) => { - const formattedSortType = formatSortType(sortType); - const search = new URLSearchParams(location.search); - search.set('sort', formattedSortType); + search.set('sort_field', sortType.fieldName); + search.set('sort_dir', sortType.dir); search.set('page', ''); history.replace({ @@ -276,6 +288,6 @@ const InfoGuideReferenceLinks = { }, }; -const isUnsupportedSortError = (error: Error) => { +const isUnsupportedSortError = (error: Error | null | undefined) => { return error?.message && error.message.includes('unsupported sort'); }; diff --git a/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx b/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx index 1a162d7a2949f..82a6af9e11023 100644 --- a/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx +++ b/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx @@ -57,9 +57,9 @@ export function BotInstancesList({ } & Omit) { const tableData = data.map(x => ({ ...x, - hostnameDisplay: x.host_name_latest ?? '-', + host_name_latest: x.host_name_latest ?? '-', instanceIdDisplay: x.instance_id.substring(0, 7), - versionDisplay: x.version_latest ? `v${x.version_latest}` : '-', + version_latest: x.version_latest ? `v${x.version_latest}` : '-', active_at_latest: x.active_at_latest ? `${formatDistanceToNowStrict(parseISO(x.active_at_latest))} ago` : '-', @@ -125,14 +125,14 @@ export function BotInstancesList({ ), }, { - key: 'hostnameDisplay', + key: 'host_name_latest', headerText: 'Hostname', - isSortable: false, + isSortable: true, }, { - key: 'versionDisplay', + key: 'version_latest', headerText: 'Version (tbot)', - isSortable: false, + isSortable: true, }, { key: 'active_at_latest', diff --git a/web/packages/teleport/src/Bots/Details/InstancesPanel.tsx b/web/packages/teleport/src/Bots/Details/InstancesPanel.tsx index 30e5414829eaf..54b8044af3f2a 100644 --- a/web/packages/teleport/src/Bots/Details/InstancesPanel.tsx +++ b/web/packages/teleport/src/Bots/Details/InstancesPanel.tsx @@ -37,9 +37,8 @@ import { PanelTitleText } from './Panel'; export function InstancesPanel(props: { botName: string }) { const { botName } = props; - const [sort, setSort] = useState< - 'active_at_latest:asc' | 'active_at_latest:desc' - >('active_at_latest:desc'); + const [sortField] = useState('active_at_latest'); + const [sortDir, setSortDir] = useState<'ASC' | 'DESC'>('DESC'); const contentRef = React.useRef(null); @@ -58,14 +57,18 @@ export function InstancesPanel(props: { botName: string }) { fetchNextPage, } = useInfiniteQuery({ enabled: hasListPermission, - queryKey: ['bot_instances', 'list', sort, botName], - queryFn: ({ pageParam }) => - listBotInstances({ - pageSize: 32, - pageToken: pageParam, - sort, - botName, - }), + queryKey: ['bot_instances', 'list', sortField, sortDir, botName], + queryFn: ({ pageParam, signal }) => + listBotInstances( + { + pageSize: 32, + pageToken: pageParam, + sortField, + sortDir, + botName, + }, + signal + ), initialPageParam: '', getNextPageParam: data => data?.next_page_token, placeholderData: keepPreviousData, @@ -73,17 +76,13 @@ export function InstancesPanel(props: { botName: string }) { }); const handleToggleSort = () => { - setSort(sort => - sort === 'active_at_latest:desc' - ? 'active_at_latest:asc' - : 'active_at_latest:desc' - ); + setSortDir(dir => (dir === 'DESC' ? 'ASC' : 'DESC')); }; // Scrolls to the top when the selected sort changes useEffect(() => { contentRef.current?.scrollTo({ top: 0, behavior: 'instant' }); - }, [sort]); + }, [sortField, sortDir]); return ( @@ -92,7 +91,7 @@ export function InstancesPanel(props: { botName: string }) { {isSuccess ? ( Recent - {sort === 'active_at_latest:desc' ? ( + {sortDir === 'DESC' ? ( ) : ( diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index b88ac6a305a45..9c043acc4b9ed 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -500,7 +500,7 @@ const cfg = { botInstance: { read: '/v1/webapi/sites/:clusterId/machine-id/bot/:botName/bot-instance/:instanceId', - list: '/v1/webapi/sites/:clusterId/machine-id/bot-instance', + list: '/v2/webapi/sites/:clusterId/machine-id/bot-instance', }, gcpWorkforceConfigurePath: diff --git a/web/packages/teleport/src/services/bot/bot.ts b/web/packages/teleport/src/services/bot/bot.ts index c6d62ca52435f..83a657a730c95 100644 --- a/web/packages/teleport/src/services/bot/bot.ts +++ b/web/packages/teleport/src/services/bot/bot.ts @@ -176,12 +176,14 @@ export async function listBotInstances( pageToken: string; pageSize: number; searchTerm?: string; - sort?: string; + sortField?: string; + sortDir?: string; botName?: string; }, signal?: AbortSignal ) { - const { pageToken, pageSize, searchTerm, sort, botName } = variables; + const { pageToken, pageSize, searchTerm, sortField, sortDir, botName } = + variables; const path = cfg.getBotInstanceUrl({ action: 'list' }); const qs = new URLSearchParams(); @@ -191,8 +193,11 @@ export async function listBotInstances( if (searchTerm) { qs.set('search', searchTerm); } - if (sort) { - qs.set('sort', sort); + if (sortField) { + qs.set('sort_field', sortField); + } + if (sortDir) { + qs.set('sort_dir', sortDir); } if (botName) { qs.set('bot_name', botName); diff --git a/web/packages/teleport/src/test/helpers/botInstances.ts b/web/packages/teleport/src/test/helpers/botInstances.ts index 4271587f501fa..475aa0ab02088 100644 --- a/web/packages/teleport/src/test/helpers/botInstances.ts +++ b/web/packages/teleport/src/test/helpers/botInstances.ts @@ -18,25 +18,20 @@ import { http, HttpResponse } from 'msw'; +import cfg from 'teleport/config'; import { GetBotInstanceResponse, ListBotInstancesResponse, } from 'teleport/services/bot/types'; -const listBotInstancesPath = - '/v1/webapi/sites/:cluster_id/machine-id/bot-instance'; - -const getBotInstancePath = - '/v1/webapi/sites/:cluster_id/machine-id/bot/:bot_name/bot-instance/:id'; - export const listBotInstancesSuccess = (mock: ListBotInstancesResponse) => - http.get(listBotInstancesPath, () => { + http.get(cfg.api.botInstance.list, () => { return HttpResponse.json(mock); }); export const listBotInstancesForever = () => http.get( - listBotInstancesPath, + cfg.api.botInstance.list, () => new Promise(() => { /* never resolved */ @@ -47,16 +42,16 @@ export const listBotInstancesError = ( status: number, error: string | null = null ) => - http.get(listBotInstancesPath, () => { + http.get(cfg.api.botInstance.list, () => { return HttpResponse.json({ error: { message: error } }, { status }); }); export const getBotInstanceSuccess = (mock: GetBotInstanceResponse) => - http.get(getBotInstancePath, () => { + http.get(cfg.api.botInstance.read, () => { return HttpResponse.json(mock); }); export const getBotInstanceError = (status: number) => - http.get(getBotInstancePath, () => { + http.get(cfg.api.botInstance.read, () => { return new HttpResponse(null, { status }); }); From ce44caf96c63835c4a6e88ba6d0b8b3638c1d107 Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Wed, 17 Sep 2025 14:28:47 +0100 Subject: [PATCH 05/19] Pass signal through to support aborting requests --- .../src/BotInstances/BotInstances.test.tsx | 144 +++++++++++------- .../src/BotInstances/BotInstances.tsx | 1 + 2 files changed, 91 insertions(+), 54 deletions(-) diff --git a/web/packages/teleport/src/BotInstances/BotInstances.test.tsx b/web/packages/teleport/src/BotInstances/BotInstances.test.tsx index 587822ee5fba6..dec991d52b9bd 100644 --- a/web/packages/teleport/src/BotInstances/BotInstances.test.tsx +++ b/web/packages/teleport/src/BotInstances/BotInstances.test.tsx @@ -221,34 +221,46 @@ describe('BotInstances', () => { const [nextButton] = screen.getAllByTitle('Next page'); expect(listBotInstances).toHaveBeenCalledTimes(1); - expect(listBotInstances).toHaveBeenLastCalledWith({ - pageSize: 20, - pageToken: '', - searchTerm: '', - sort: 'active_at_latest:desc', - }); + expect(listBotInstances).toHaveBeenLastCalledWith( + { + pageSize: 20, + pageToken: '', + searchTerm: '', + sortDir: 'DESC', + sortField: 'active_at_latest', + }, + expect.anything() + ); await waitFor(() => expect(nextButton).toBeEnabled()); fireEvent.click(nextButton); expect(listBotInstances).toHaveBeenCalledTimes(2); - expect(listBotInstances).toHaveBeenLastCalledWith({ - pageSize: 20, - pageToken: '.next', - searchTerm: '', - sort: 'active_at_latest:desc', - }); + expect(listBotInstances).toHaveBeenLastCalledWith( + { + pageSize: 20, + pageToken: '.next', + searchTerm: '', + sortDir: 'DESC', + sortField: 'active_at_latest', + }, + expect.anything() + ); await waitFor(() => expect(nextButton).toBeEnabled()); fireEvent.click(nextButton); expect(listBotInstances).toHaveBeenCalledTimes(3); - expect(listBotInstances).toHaveBeenLastCalledWith({ - pageSize: 20, - pageToken: '.next.next', - searchTerm: '', - sort: 'active_at_latest:desc', - }); + expect(listBotInstances).toHaveBeenLastCalledWith( + { + pageSize: 20, + pageToken: '.next.next', + searchTerm: '', + sortDir: 'DESC', + sortField: 'active_at_latest', + }, + expect.anything() + ); const [prevButton] = screen.getAllByTitle('Previous page'); @@ -292,24 +304,32 @@ describe('BotInstances', () => { await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); expect(listBotInstances).toHaveBeenCalledTimes(1); - expect(listBotInstances).toHaveBeenLastCalledWith({ - pageSize: 20, - pageToken: '', - searchTerm: '', - sort: 'active_at_latest:desc', - }); + expect(listBotInstances).toHaveBeenLastCalledWith( + { + pageSize: 20, + pageToken: '', + searchTerm: '', + sortDir: 'DESC', + sortField: 'active_at_latest', + }, + expect.anything() + ); const [nextButton] = screen.getAllByTitle('Next page'); await waitFor(() => expect(nextButton).toBeEnabled()); fireEvent.click(nextButton); expect(listBotInstances).toHaveBeenCalledTimes(2); - expect(listBotInstances).toHaveBeenLastCalledWith({ - pageSize: 20, - pageToken: '.next', - searchTerm: '', - sort: 'active_at_latest:desc', - }); + expect(listBotInstances).toHaveBeenLastCalledWith( + { + pageSize: 20, + pageToken: '.next', + searchTerm: '', + sortDir: 'DESC', + sortField: 'active_at_latest', + }, + expect.anything() + ); jest.useRealTimers(); // Required as userEvent.type() uses setTimeout internally @@ -319,12 +339,16 @@ describe('BotInstances', () => { await userEvent.type(search, '{enter}'); expect(listBotInstances).toHaveBeenCalledTimes(3); - expect(listBotInstances).toHaveBeenLastCalledWith({ - pageSize: 20, - pageToken: '', // Search should reset to the first page - searchTerm: 'test-search-term', - sort: 'active_at_latest:desc', - }); + expect(listBotInstances).toHaveBeenLastCalledWith( + { + pageSize: 20, + pageToken: '', // Search should reset to the first page + searchTerm: 'test-search-term', + sortDir: 'DESC', + sortField: 'active_at_latest', + }, + expect.anything() + ); }); it('Allows sorting', async () => { @@ -356,33 +380,45 @@ describe('BotInstances', () => { const lastHeartbeatHeader = screen.getByText('Last heartbeat'); expect(listBotInstances).toHaveBeenCalledTimes(1); - expect(listBotInstances).toHaveBeenLastCalledWith({ - pageSize: 20, - pageToken: '', - searchTerm: '', - sort: 'active_at_latest:desc', - }); + expect(listBotInstances).toHaveBeenLastCalledWith( + { + pageSize: 20, + pageToken: '', + searchTerm: '', + sortDir: 'DESC', + sortField: 'active_at_latest', + }, + expect.anything() + ); fireEvent.click(lastHeartbeatHeader); expect(listBotInstances).toHaveBeenCalledTimes(2); - expect(listBotInstances).toHaveBeenLastCalledWith({ - pageSize: 20, - pageToken: '', - searchTerm: '', - sort: 'active_at_latest:asc', - }); + expect(listBotInstances).toHaveBeenLastCalledWith( + { + pageSize: 20, + pageToken: '', + searchTerm: '', + sortDir: 'ASC', + sortField: 'active_at_latest', + }, + expect.anything() + ); const botHeader = screen.getByText('Bot'); fireEvent.click(botHeader); expect(listBotInstances).toHaveBeenCalledTimes(3); - expect(listBotInstances).toHaveBeenLastCalledWith({ - pageSize: 20, - pageToken: '', - searchTerm: '', - sort: 'bot_name:desc', - }); + expect(listBotInstances).toHaveBeenLastCalledWith( + { + pageSize: 20, + pageToken: '', + searchTerm: '', + sortDir: 'DESC', + sortField: 'bot_name', + }, + expect.anything() + ); }); }); diff --git a/web/packages/teleport/src/BotInstances/BotInstances.tsx b/web/packages/teleport/src/BotInstances/BotInstances.tsx index 247b000f65ac7..0256b2b48c835 100644 --- a/web/packages/teleport/src/BotInstances/BotInstances.tsx +++ b/web/packages/teleport/src/BotInstances/BotInstances.tsx @@ -77,6 +77,7 @@ export function BotInstances() { sortField, sortDir, }, + signal ), placeholderData: keepPreviousData, staleTime: 30_000, // Cached pages are valid for 30 seconds From 877813fa2c978fb27d7e4b46bbf422e959ef40b9 Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Thu, 18 Sep 2025 10:28:42 +0100 Subject: [PATCH 06/19] Fix comment typo --- lib/web/apiserver.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index d3b74071ad5bb..ca0d7aeb935de 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -1163,7 +1163,7 @@ func (h *Handler) bindDefaultEndpoints() { h.GET("/webapi/sites/:site/machine-id/bot/:name/bot-instance/:id", h.WithClusterAuth(h.getBotInstance)) // GET Machine ID bot instances (paged) // TODO(nicholasmarais1158) DELETE IN v20.0.0 - // Replaced by `PUT /v2/webapi/sites/:site/machine-id/bot-instance`. + // Replaced by `GET /v2/webapi/sites/:site/machine-id/bot-instance`. h.GET("/webapi/sites/:site/machine-id/bot-instance", h.WithClusterAuth(h.listBotInstances)) // GET Machine ID bot instances (paged) h.GET("/v2/webapi/sites/:site/machine-id/bot-instance", h.WithClusterAuth(h.listBotInstancesV2)) From 3562bf5781eb39756b32d69a44786fe80c6c932a Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Thu, 18 Sep 2025 10:31:08 +0100 Subject: [PATCH 07/19] Rename util func --- lib/cache/bot_instance.go | 6 +++--- lib/services/bot_instance.go | 4 ++-- lib/web/machineid.go | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/cache/bot_instance.go b/lib/cache/bot_instance.go index 5d9bac4cc2e84..d0f633a9165af 100644 --- a/lib/cache/bot_instance.go +++ b/lib/cache/bot_instance.go @@ -154,14 +154,14 @@ func makeNameIndexKey(botName string, instanceID string) string { } func keyForActiveAtIndex(botInstance *machineidv1.BotInstance) string { - heartbeat := services.PickBotInstanceRecentHeartbeat(botInstance) + heartbeat := services.GetBotInstanceLatestHeartbeat(botInstance) recordedAt := heartbeat.GetRecordedAt().AsTime() return recordedAt.Format(time.RFC3339) + "/" + botInstance.GetMetadata().GetName() } func keyForVersionIndex(botInstance *machineidv1.BotInstance) string { version := "000000.000000.000000" - heartbeat := services.PickBotInstanceRecentHeartbeat(botInstance) + heartbeat := services.GetBotInstanceLatestHeartbeat(botInstance) if heartbeat == nil { return version + "/" + botInstance.GetMetadata().GetName() } @@ -183,7 +183,7 @@ func keyForVersionIndex(botInstance *machineidv1.BotInstance) string { func keyForHostnameIndex(botInstance *machineidv1.BotInstance) string { hostname := "~" - heartbeat := services.PickBotInstanceRecentHeartbeat(botInstance) + heartbeat := services.GetBotInstanceLatestHeartbeat(botInstance) if heartbeat != nil { hostname = heartbeat.GetHostname() } diff --git a/lib/services/bot_instance.go b/lib/services/bot_instance.go index 551a0f85457b2..db00a3b667a73 100644 --- a/lib/services/bot_instance.go +++ b/lib/services/bot_instance.go @@ -110,10 +110,10 @@ func MatchBotInstance(b *machineidv1.BotInstance, botName string, search string) }) } -// PickBotInstanceRecentHeartbeat returns the most recent heartbeat for the +// GetBotInstanceLatestHeartbeat returns the most recent heartbeat for the // given bot instance. The initial heartbeat is returned as a fallback if no // latest heartbeats exist. -func PickBotInstanceRecentHeartbeat(botInstance *machineidv1.BotInstance) *machineidv1.BotInstanceStatusHeartbeat { +func GetBotInstanceLatestHeartbeat(botInstance *machineidv1.BotInstance) *machineidv1.BotInstanceStatusHeartbeat { heartbeat := botInstance.GetStatus().GetInitialHeartbeat() latestHeartbeats := botInstance.GetStatus().GetLatestHeartbeats() if len(latestHeartbeats) > 0 { diff --git a/lib/web/machineid.go b/lib/web/machineid.go index 1a04fed5990bb..f21a5b5a8eafb 100644 --- a/lib/web/machineid.go +++ b/lib/web/machineid.go @@ -425,7 +425,7 @@ func (h *Handler) listBotInstances(_ http.ResponseWriter, r *http.Request, _ htt } uiInstances := tslices.Map(instances.BotInstances, func(instance *machineidv1.BotInstance) BotInstance { - heartbeat := services.PickBotInstanceRecentHeartbeat(instance) + heartbeat := services.GetBotInstanceLatestHeartbeat(instance) uiInstance := BotInstance{ InstanceId: instance.Spec.InstanceId, @@ -482,7 +482,7 @@ func (h *Handler) listBotInstancesV2(_ http.ResponseWriter, r *http.Request, _ h } uiInstances := tslices.Map(instances.BotInstances, func(instance *machineidv1.BotInstance) BotInstance { - heartbeat := services.PickBotInstanceRecentHeartbeat(instance) + heartbeat := services.GetBotInstanceLatestHeartbeat(instance) uiInstance := BotInstance{ InstanceId: instance.Spec.InstanceId, From 77faebce0461ca60c2d1154a2c57fe65019f3470 Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Mon, 22 Sep 2025 10:15:52 +0100 Subject: [PATCH 08/19] Deprecate `ListBotInstances` rpc --- .../go/teleport/machineid/v1/bot_instance_service.pb.go | 6 +++--- .../teleport/machineid/v1/bot_instance_service_grpc.pb.go | 5 +++++ api/proto/teleport/machineid/v1/bot_instance_service.proto | 5 ++++- 3 files changed, 12 insertions(+), 4 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 349f22c6ce43b..150a69f7678bb 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 @@ -508,10 +508,10 @@ const file_teleport_machineid_v1_bot_instance_service_proto_rawDesc = "" + "instanceId\"i\n" + "\x16SubmitHeartbeatRequest\x12O\n" + "\theartbeat\x18\x01 \x01(\v21.teleport.machineid.v1.BotInstanceStatusHeartbeatR\theartbeat\"\x19\n" + - "\x17SubmitHeartbeatResponse2\xb6\x04\n" + + "\x17SubmitHeartbeatResponse2\xbb\x04\n" + "\x12BotInstanceService\x12b\n" + - "\x0eGetBotInstance\x12,.teleport.machineid.v1.GetBotInstanceRequest\x1a\".teleport.machineid.v1.BotInstance\x12s\n" + - "\x10ListBotInstances\x12..teleport.machineid.v1.ListBotInstancesRequest\x1a/.teleport.machineid.v1.ListBotInstancesResponse\x12w\n" + + "\x0eGetBotInstance\x12,.teleport.machineid.v1.GetBotInstanceRequest\x1a\".teleport.machineid.v1.BotInstance\x12x\n" + + "\x10ListBotInstances\x12..teleport.machineid.v1.ListBotInstancesRequest\x1a/.teleport.machineid.v1.ListBotInstancesResponse\"\x03\x88\x02\x01\x12w\n" + "\x12ListBotInstancesV2\x120.teleport.machineid.v1.ListBotInstancesV2Request\x1a/.teleport.machineid.v1.ListBotInstancesResponse\x12\\\n" + "\x11DeleteBotInstance\x12/.teleport.machineid.v1.DeleteBotInstanceRequest\x1a\x16.google.protobuf.Empty\x12p\n" + "\x0fSubmitHeartbeat\x12-.teleport.machineid.v1.SubmitHeartbeatRequest\x1a..teleport.machineid.v1.SubmitHeartbeatResponseBVZTgithub.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1;machineidv1b\x06proto3" diff --git a/api/gen/proto/go/teleport/machineid/v1/bot_instance_service_grpc.pb.go b/api/gen/proto/go/teleport/machineid/v1/bot_instance_service_grpc.pb.go index 7e351d41edbc9..3637522d47a33 100644 --- a/api/gen/proto/go/teleport/machineid/v1/bot_instance_service_grpc.pb.go +++ b/api/gen/proto/go/teleport/machineid/v1/bot_instance_service_grpc.pb.go @@ -49,7 +49,9 @@ const ( type BotInstanceServiceClient interface { // GetBotInstance returns the specified BotInstance resource. GetBotInstance(ctx context.Context, in *GetBotInstanceRequest, opts ...grpc.CallOption) (*BotInstance, error) + // Deprecated: Do not use. // ListBotInstances returns a page of BotInstance resources. + // Deprecated: Use ListBotInstancesV2 instead ListBotInstances(ctx context.Context, in *ListBotInstancesRequest, opts ...grpc.CallOption) (*ListBotInstancesResponse, error) // ListBotInstancesV2 returns a page of BotInstance resources. ListBotInstancesV2(ctx context.Context, in *ListBotInstancesV2Request, opts ...grpc.CallOption) (*ListBotInstancesResponse, error) @@ -77,6 +79,7 @@ func (c *botInstanceServiceClient) GetBotInstance(ctx context.Context, in *GetBo return out, nil } +// Deprecated: Do not use. func (c *botInstanceServiceClient) ListBotInstances(ctx context.Context, in *ListBotInstancesRequest, opts ...grpc.CallOption) (*ListBotInstancesResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(ListBotInstancesResponse) @@ -125,7 +128,9 @@ func (c *botInstanceServiceClient) SubmitHeartbeat(ctx context.Context, in *Subm type BotInstanceServiceServer interface { // GetBotInstance returns the specified BotInstance resource. GetBotInstance(context.Context, *GetBotInstanceRequest) (*BotInstance, error) + // Deprecated: Do not use. // ListBotInstances returns a page of BotInstance resources. + // Deprecated: Use ListBotInstancesV2 instead ListBotInstances(context.Context, *ListBotInstancesRequest) (*ListBotInstancesResponse, error) // ListBotInstancesV2 returns a page of BotInstance resources. ListBotInstancesV2(context.Context, *ListBotInstancesV2Request) (*ListBotInstancesResponse, error) diff --git a/api/proto/teleport/machineid/v1/bot_instance_service.proto b/api/proto/teleport/machineid/v1/bot_instance_service.proto index 37bb3de2db39b..9fcabd83986d8 100644 --- a/api/proto/teleport/machineid/v1/bot_instance_service.proto +++ b/api/proto/teleport/machineid/v1/bot_instance_service.proto @@ -108,7 +108,10 @@ service BotInstanceService { // GetBotInstance returns the specified BotInstance resource. rpc GetBotInstance(GetBotInstanceRequest) returns (BotInstance); // ListBotInstances returns a page of BotInstance resources. - rpc ListBotInstances(ListBotInstancesRequest) returns (ListBotInstancesResponse); + // Deprecated: Use ListBotInstancesV2 instead + rpc ListBotInstances(ListBotInstancesRequest) returns (ListBotInstancesResponse) { + option deprecated = true; + } // ListBotInstancesV2 returns a page of BotInstance resources. rpc ListBotInstancesV2(ListBotInstancesV2Request) returns (ListBotInstancesResponse); // DeleteBotInstance hard deletes the specified BotInstance resource. From 3cde394e9ac604fa5a1c8e17748cb2ed2c7b01cf Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Mon, 22 Sep 2025 10:42:40 +0100 Subject: [PATCH 09/19] Encode hostname in cache key --- lib/cache/bot_instance.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/cache/bot_instance.go b/lib/cache/bot_instance.go index d0f633a9165af..cd41d587b5bd0 100644 --- a/lib/cache/bot_instance.go +++ b/lib/cache/bot_instance.go @@ -18,6 +18,7 @@ package cache import ( "context" + "encoding/base32" "fmt" "time" @@ -187,5 +188,8 @@ func keyForHostnameIndex(botInstance *machineidv1.BotInstance) string { if heartbeat != nil { hostname = heartbeat.GetHostname() } - return hostname + "/" + botInstance.GetMetadata().GetName() + hostname = hostname + "/" + botInstance.GetMetadata().GetName() + return unpaddedBase32hex.EncodeToString([]byte(hostname)) } + +var unpaddedBase32hex = base32.HexEncoding.WithPadding(base32.NoPadding) From ba5d65695c96e3aad4b9c19d638903ffa83aabcd Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Mon, 22 Sep 2025 12:00:01 +0100 Subject: [PATCH 10/19] Address pre-release sorting in version numbers --- lib/cache/bot_instance.go | 12 +++++++----- lib/cache/bot_instance_test.go | 16 ++++++++-------- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/lib/cache/bot_instance.go b/lib/cache/bot_instance.go index cd41d587b5bd0..386a97d03a731 100644 --- a/lib/cache/bot_instance.go +++ b/lib/cache/bot_instance.go @@ -160,24 +160,26 @@ func keyForActiveAtIndex(botInstance *machineidv1.BotInstance) string { return recordedAt.Format(time.RFC3339) + "/" + botInstance.GetMetadata().GetName() } +// keyForVersionIndex produces a zero-padded version string for sorting. Pre- +// releases are sorted naively - 1.0.0-rc is correctly less than 1.0.0, but +// 1.0.0-rc.2 is more than 1.0.0-rc.11 func keyForVersionIndex(botInstance *machineidv1.BotInstance) string { version := "000000.000000.000000" heartbeat := services.GetBotInstanceLatestHeartbeat(botInstance) if heartbeat == nil { - return version + "/" + botInstance.GetMetadata().GetName() + return version + "-~/" + botInstance.GetMetadata().GetName() } sv, err := semver.NewVersion(heartbeat.GetVersion()) if err != nil { - return version + "/" + botInstance.GetMetadata().GetName() + return version + "-~/" + botInstance.GetMetadata().GetName() } version = fmt.Sprintf("%06d.%06d.%06d", sv.Major, sv.Minor, sv.Patch) if sv.PreRelease != "" { version = version + "-" + string(sv.PreRelease) - } - if sv.Metadata != "" { - version = version + "+" + sv.Metadata + } else { + version = version + "-~" } return version + "/" + botInstance.GetMetadata().GetName() } diff --git a/lib/cache/bot_instance_test.go b/lib/cache/bot_instance_test.go index 3f15acf103314..ea82a3f165c50 100644 --- a/lib/cache/bot_instance_test.go +++ b/lib/cache/bot_instance_test.go @@ -242,8 +242,8 @@ func TestBotInstanceCacheSorting(t *testing.T) { version string hostname string }{ - {"bot-1", "instance-1", 2, "3.0.0", "hostname-2"}, - {"bot-1", "instance-3", 1, "2.0.0", "hostname-3"}, + {"bot-1", "instance-1", 2, "2.0.0", "hostname-2"}, + {"bot-1", "instance-3", 1, "2.0.0-rc1", "hostname-3"}, {"bot-2", "instance-2", 3, "1.0.0", "hostname-1"}, } @@ -441,7 +441,7 @@ func TestKeyForVersionIndex(t *testing.T) { { name: "zero heartbeats", mutatorFn: func(b *machineidv1.BotInstance) {}, - key: "000000.000000.000000/bot-instance-1", + key: "000000.000000.000000-~/bot-instance-1", }, { name: "invalid version", @@ -452,7 +452,7 @@ func TestKeyForVersionIndex(t *testing.T) { }, } }, - key: "000000.000000.000000/bot-instance-1", + key: "000000.000000.000000-~/bot-instance-1", }, { name: "initial heartbeat", @@ -463,7 +463,7 @@ func TestKeyForVersionIndex(t *testing.T) { }, } }, - key: "000001.000000.000000/bot-instance-1", + key: "000001.000000.000000-~/bot-instance-1", }, { name: "latest heartbeat", @@ -476,7 +476,7 @@ func TestKeyForVersionIndex(t *testing.T) { }, } }, - key: "000001.000000.000000/bot-instance-1", + key: "000001.000000.000000-~/bot-instance-1", }, { name: "with release", @@ -498,7 +498,7 @@ func TestKeyForVersionIndex(t *testing.T) { }, } }, - key: "000001.000000.000000+build1/bot-instance-1", + key: "000001.000000.000000-~/bot-instance-1", }, { name: "with release and build", @@ -509,7 +509,7 @@ func TestKeyForVersionIndex(t *testing.T) { }, } }, - key: "000001.000000.000000-dev+build1/bot-instance-1", + key: "000001.000000.000000-dev/bot-instance-1", }, } From 55d2cca1d653076c64b6e27331875a7fbb5059fe Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Mon, 22 Sep 2025 12:54:51 +0100 Subject: [PATCH 11/19] Rename bot instance cache utils --- lib/cache/bot_instance.go | 34 +++++++++++++++++----------------- lib/cache/bot_instance_test.go | 2 +- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/lib/cache/bot_instance.go b/lib/cache/bot_instance.go index 386a97d03a731..21b06f5c7d359 100644 --- a/lib/cache/bot_instance.go +++ b/lib/cache/bot_instance.go @@ -54,13 +54,13 @@ func newBotInstanceCollection(upstream services.BotInstance, w types.WatchKind) proto.CloneOf[*machineidv1.BotInstance], map[botInstanceIndex]func(*machineidv1.BotInstance) string{ // Index on a combination of bot name and instance name - botInstanceNameIndex: keyForNameIndex, + botInstanceNameIndex: keyForBotInstanceNameIndex, // Index on a combination of most recent heartbeat time and instance name - botInstanceActiveAtIndex: keyForActiveAtIndex, + botInstanceActiveAtIndex: keyForBotInstanceActiveAtIndex, // Index on a combination of most recent heartbeat version and instance name - botInstanceVersionIndex: keyForVersionIndex, + botInstanceVersionIndex: keyForBotInstanceVersionIndex, // Index on a combination of most recent heartbeat hostname and instance name - botInstanceHostnameIndex: keyForHostnameIndex, + botInstanceHostnameIndex: keyForBotInstanceHostnameIndex, }), fetcher: func(ctx context.Context, loadSecrets bool) ([]*machineidv1.BotInstance, error) { out, err := stream.Collect(clientutils.Resources(ctx, @@ -88,7 +88,7 @@ func (c *Cache) GetBotInstance(ctx context.Context, botName, instanceID string) }, } - out, err := getter.get(ctx, makeNameIndexKey(botName, instanceID)) + out, err := getter.get(ctx, makeBotInstanceNameIndexKey(botName, instanceID)) return out, trace.Wrap(err) } @@ -99,21 +99,21 @@ func (c *Cache) ListBotInstances(ctx context.Context, pageSize int, lastToken st defer span.End() index := botInstanceNameIndex - keyFn := keyForNameIndex + keyFn := keyForBotInstanceNameIndex isDesc := options.GetSortDesc() switch options.GetSortField() { case "bot_name": index = botInstanceNameIndex - keyFn = keyForNameIndex + keyFn = keyForBotInstanceNameIndex case "active_at_latest": index = botInstanceActiveAtIndex - keyFn = keyForActiveAtIndex + keyFn = keyForBotInstanceActiveAtIndex case "version_latest": index = botInstanceVersionIndex - keyFn = keyForVersionIndex + keyFn = keyForBotInstanceVersionIndex case "host_name_latest": index = botInstanceHostnameIndex - keyFn = keyForHostnameIndex + keyFn = keyForBotInstanceHostnameIndex case "": // default ordering as defined above default: @@ -143,27 +143,27 @@ func (c *Cache) ListBotInstances(ctx context.Context, pageSize int, lastToken st return out, next, trace.Wrap(err) } -func keyForNameIndex(botInstance *machineidv1.BotInstance) string { - return makeNameIndexKey( +func keyForBotInstanceNameIndex(botInstance *machineidv1.BotInstance) string { + return makeBotInstanceNameIndexKey( botInstance.GetSpec().GetBotName(), botInstance.GetMetadata().GetName(), ) } -func makeNameIndexKey(botName string, instanceID string) string { +func makeBotInstanceNameIndexKey(botName string, instanceID string) string { return botName + "/" + instanceID } -func keyForActiveAtIndex(botInstance *machineidv1.BotInstance) string { +func keyForBotInstanceActiveAtIndex(botInstance *machineidv1.BotInstance) string { heartbeat := services.GetBotInstanceLatestHeartbeat(botInstance) recordedAt := heartbeat.GetRecordedAt().AsTime() return recordedAt.Format(time.RFC3339) + "/" + botInstance.GetMetadata().GetName() } -// keyForVersionIndex produces a zero-padded version string for sorting. Pre- +// keyForBotInstanceVersionIndex produces a zero-padded version string for sorting. Pre- // releases are sorted naively - 1.0.0-rc is correctly less than 1.0.0, but // 1.0.0-rc.2 is more than 1.0.0-rc.11 -func keyForVersionIndex(botInstance *machineidv1.BotInstance) string { +func keyForBotInstanceVersionIndex(botInstance *machineidv1.BotInstance) string { version := "000000.000000.000000" heartbeat := services.GetBotInstanceLatestHeartbeat(botInstance) if heartbeat == nil { @@ -184,7 +184,7 @@ func keyForVersionIndex(botInstance *machineidv1.BotInstance) string { return version + "/" + botInstance.GetMetadata().GetName() } -func keyForHostnameIndex(botInstance *machineidv1.BotInstance) string { +func keyForBotInstanceHostnameIndex(botInstance *machineidv1.BotInstance) string { hostname := "~" heartbeat := services.GetBotInstanceLatestHeartbeat(botInstance) if heartbeat != nil { diff --git a/lib/cache/bot_instance_test.go b/lib/cache/bot_instance_test.go index ea82a3f165c50..a7a7ec5107a18 100644 --- a/lib/cache/bot_instance_test.go +++ b/lib/cache/bot_instance_test.go @@ -526,7 +526,7 @@ func TestKeyForVersionIndex(t *testing.T) { } tc.mutatorFn(instance) - versionKey := keyForVersionIndex(instance) + versionKey := keyForBotInstanceVersionIndex(instance) assert.Equal(t, tc.key, versionKey) }) From 044bef3da6a21f340089aa31e2e333a6fcdbb014 Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Mon, 22 Sep 2025 14:04:40 +0100 Subject: [PATCH 12/19] Fix lint deprecation warnings --- lib/web/machineid.go | 1 + tool/tctl/common/bots_command.go | 2 ++ tool/tctl/common/resource_command.go | 2 ++ 3 files changed, 5 insertions(+) diff --git a/lib/web/machineid.go b/lib/web/machineid.go index f21a5b5a8eafb..54eb6238d233f 100644 --- a/lib/web/machineid.go +++ b/lib/web/machineid.go @@ -413,6 +413,7 @@ func (h *Handler) listBotInstances(_ http.ResponseWriter, r *http.Request, _ htt sort = &s } + //nolint:staticcheck // SA1019. Kept for backward compatibility. instances, err := clt.BotInstanceServiceClient().ListBotInstances(r.Context(), &machineidv1.ListBotInstancesRequest{ FilterBotName: r.URL.Query().Get("bot_name"), PageSize: int32(pageSize), diff --git a/tool/tctl/common/bots_command.go b/tool/tctl/common/bots_command.go index 60bcd20a44dca..34880be7936f8 100644 --- a/tool/tctl/common/bots_command.go +++ b/tool/tctl/common/bots_command.go @@ -554,6 +554,8 @@ func (c *BotsCommand) ListBotInstances(ctx context.Context, client *authclient.C } for { + // TODO(nicholasmarais1158) Use ListBotInstancesV2 instead. + //nolint:staticcheck // SA1019 resp, err := client.BotInstanceServiceClient().ListBotInstances(ctx, req) if err != nil { return trace.Wrap(err) diff --git a/tool/tctl/common/resource_command.go b/tool/tctl/common/resource_command.go index 617c75f36c48f..db443c9b60b70 100644 --- a/tool/tctl/common/resource_command.go +++ b/tool/tctl/common/resource_command.go @@ -3498,6 +3498,8 @@ func (rc *ResourceCommand) getCollection(ctx context.Context, client *authclient } instances, err := stream.Collect(clientutils.Resources(ctx, func(ctx context.Context, limit int, pageToken string) ([]*machineidv1pb.BotInstance, string, error) { + // TODO(nicholasmarais1158) Use ListBotInstancesV2 instead. + //nolint:staticcheck // SA1019 resp, err := client.BotInstanceServiceClient().ListBotInstances(ctx, &machineidv1pb.ListBotInstancesRequest{ PageSize: int32(limit), PageToken: pageToken, From 04d30a4916e26eea1576cb64a9d6be56db6886f2 Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Wed, 24 Sep 2025 10:52:58 +0100 Subject: [PATCH 13/19] Extract filter fields to message --- .../machineid/v1/bot_instance_service.pb.go | 157 ++++++++++++------ .../machineid/v1/bot_instance_service.proto | 18 +- .../machineidv1/bot_instance_service.go | 22 +-- lib/web/machineid.go | 10 +- 4 files changed, 134 insertions(+), 73 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 150a69f7678bb..ab0dc4ee66b23 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 @@ -198,14 +198,10 @@ type ListBotInstancesV2Request struct { // The sort order to use for the results. If empty, the default sort order is // used. SortDesc bool `protobuf:"varint,4,opt,name=sort_desc,json=sortDesc,proto3" json:"sort_desc,omitempty"` - // The name of the Bot to list BotInstances for. If empty, all BotInstances - // will be listed. - FilterBotName string `protobuf:"bytes,5,opt,name=filter_bot_name,json=filterBotName,proto3" json:"filter_bot_name,omitempty"` - // A search term used to filter the results. If non-empty, it's used to match - // against supported fields. - FilterSearchTerm string `protobuf:"bytes,6,opt,name=filter_search_term,json=filterSearchTerm,proto3" json:"filter_search_term,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Fields used to filter the results + Filter *ListBotInstancesV2Request_Filters `protobuf:"bytes,5,opt,name=filter,proto3" json:"filter,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ListBotInstancesV2Request) Reset() { @@ -266,18 +262,11 @@ func (x *ListBotInstancesV2Request) GetSortDesc() bool { return false } -func (x *ListBotInstancesV2Request) GetFilterBotName() string { +func (x *ListBotInstancesV2Request) GetFilter() *ListBotInstancesV2Request_Filters { if x != nil { - return x.FilterBotName + return x.Filter } - return "" -} - -func (x *ListBotInstancesV2Request) GetFilterSearchTerm() string { - if x != nil { - return x.FilterSearchTerm - } - return "" + return nil } // Response for ListBotInstances. @@ -474,6 +463,63 @@ func (*SubmitHeartbeatResponse) Descriptor() ([]byte, []int) { return file_teleport_machineid_v1_bot_instance_service_proto_rawDescGZIP(), []int{6} } +// Filters contains fields to be used to filter the results. +type ListBotInstancesV2Request_Filters struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The name of the Bot to list BotInstances for. If non-empty, only + // BotInstances for that bot will be listed. + BotName string `protobuf:"bytes,1,opt,name=bot_name,json=botName,proto3" json:"bot_name,omitempty"` + // A search term used to filter the results. If non-empty, it's used to + // match against supported fields. + SearchTerm string `protobuf:"bytes,2,opt,name=search_term,json=searchTerm,proto3" json:"search_term,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListBotInstancesV2Request_Filters) Reset() { + *x = ListBotInstancesV2Request_Filters{} + mi := &file_teleport_machineid_v1_bot_instance_service_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListBotInstancesV2Request_Filters) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListBotInstancesV2Request_Filters) ProtoMessage() {} + +func (x *ListBotInstancesV2Request_Filters) ProtoReflect() protoreflect.Message { + mi := &file_teleport_machineid_v1_bot_instance_service_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListBotInstancesV2Request_Filters.ProtoReflect.Descriptor instead. +func (*ListBotInstancesV2Request_Filters) Descriptor() ([]byte, []int) { + return file_teleport_machineid_v1_bot_instance_service_proto_rawDescGZIP(), []int{2, 0} +} + +func (x *ListBotInstancesV2Request_Filters) GetBotName() string { + if x != nil { + return x.BotName + } + return "" +} + +func (x *ListBotInstancesV2Request_Filters) GetSearchTerm() string { + if x != nil { + return x.SearchTerm + } + return "" +} + var File_teleport_machineid_v1_bot_instance_service_proto protoreflect.FileDescriptor const file_teleport_machineid_v1_bot_instance_service_proto_rawDesc = "" + @@ -489,16 +535,19 @@ const file_teleport_machineid_v1_bot_instance_service_proto_rawDesc = "" + "\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\"\xe9\x01\n" + + "\x04sort\x18\x05 \x01(\v2\r.types.SortByR\x04sort\"\xac\x02\n" + "\x19ListBotInstancesV2Request\x12\x1b\n" + "\tpage_size\x18\x01 \x01(\x05R\bpageSize\x12\x1d\n" + "\n" + "page_token\x18\x02 \x01(\tR\tpageToken\x12\x1d\n" + "\n" + "sort_field\x18\x03 \x01(\tR\tsortField\x12\x1b\n" + - "\tsort_desc\x18\x04 \x01(\bR\bsortDesc\x12&\n" + - "\x0ffilter_bot_name\x18\x05 \x01(\tR\rfilterBotName\x12,\n" + - "\x12filter_search_term\x18\x06 \x01(\tR\x10filterSearchTerm\"\x8b\x01\n" + + "\tsort_desc\x18\x04 \x01(\bR\bsortDesc\x12P\n" + + "\x06filter\x18\x05 \x01(\v28.teleport.machineid.v1.ListBotInstancesV2Request.FiltersR\x06filter\x1aE\n" + + "\aFilters\x12\x19\n" + + "\bbot_name\x18\x01 \x01(\tR\abotName\x12\x1f\n" + + "\vsearch_term\x18\x02 \x01(\tR\n" + + "searchTerm\"\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" + @@ -528,39 +577,41 @@ func file_teleport_machineid_v1_bot_instance_service_proto_rawDescGZIP() []byte return file_teleport_machineid_v1_bot_instance_service_proto_rawDescData } -var file_teleport_machineid_v1_bot_instance_service_proto_msgTypes = make([]protoimpl.MessageInfo, 7) +var file_teleport_machineid_v1_bot_instance_service_proto_msgTypes = make([]protoimpl.MessageInfo, 8) var file_teleport_machineid_v1_bot_instance_service_proto_goTypes = []any{ - (*GetBotInstanceRequest)(nil), // 0: teleport.machineid.v1.GetBotInstanceRequest - (*ListBotInstancesRequest)(nil), // 1: teleport.machineid.v1.ListBotInstancesRequest - (*ListBotInstancesV2Request)(nil), // 2: teleport.machineid.v1.ListBotInstancesV2Request - (*ListBotInstancesResponse)(nil), // 3: teleport.machineid.v1.ListBotInstancesResponse - (*DeleteBotInstanceRequest)(nil), // 4: teleport.machineid.v1.DeleteBotInstanceRequest - (*SubmitHeartbeatRequest)(nil), // 5: teleport.machineid.v1.SubmitHeartbeatRequest - (*SubmitHeartbeatResponse)(nil), // 6: teleport.machineid.v1.SubmitHeartbeatResponse - (*types.SortBy)(nil), // 7: types.SortBy - (*BotInstance)(nil), // 8: teleport.machineid.v1.BotInstance - (*BotInstanceStatusHeartbeat)(nil), // 9: teleport.machineid.v1.BotInstanceStatusHeartbeat - (*emptypb.Empty)(nil), // 10: google.protobuf.Empty + (*GetBotInstanceRequest)(nil), // 0: teleport.machineid.v1.GetBotInstanceRequest + (*ListBotInstancesRequest)(nil), // 1: teleport.machineid.v1.ListBotInstancesRequest + (*ListBotInstancesV2Request)(nil), // 2: teleport.machineid.v1.ListBotInstancesV2Request + (*ListBotInstancesResponse)(nil), // 3: teleport.machineid.v1.ListBotInstancesResponse + (*DeleteBotInstanceRequest)(nil), // 4: teleport.machineid.v1.DeleteBotInstanceRequest + (*SubmitHeartbeatRequest)(nil), // 5: teleport.machineid.v1.SubmitHeartbeatRequest + (*SubmitHeartbeatResponse)(nil), // 6: teleport.machineid.v1.SubmitHeartbeatResponse + (*ListBotInstancesV2Request_Filters)(nil), // 7: teleport.machineid.v1.ListBotInstancesV2Request.Filters + (*types.SortBy)(nil), // 8: types.SortBy + (*BotInstance)(nil), // 9: teleport.machineid.v1.BotInstance + (*BotInstanceStatusHeartbeat)(nil), // 10: teleport.machineid.v1.BotInstanceStatusHeartbeat + (*emptypb.Empty)(nil), // 11: google.protobuf.Empty } var file_teleport_machineid_v1_bot_instance_service_proto_depIdxs = []int32{ - 7, // 0: teleport.machineid.v1.ListBotInstancesRequest.sort:type_name -> types.SortBy - 8, // 1: teleport.machineid.v1.ListBotInstancesResponse.bot_instances:type_name -> teleport.machineid.v1.BotInstance - 9, // 2: teleport.machineid.v1.SubmitHeartbeatRequest.heartbeat:type_name -> teleport.machineid.v1.BotInstanceStatusHeartbeat - 0, // 3: teleport.machineid.v1.BotInstanceService.GetBotInstance:input_type -> teleport.machineid.v1.GetBotInstanceRequest - 1, // 4: teleport.machineid.v1.BotInstanceService.ListBotInstances:input_type -> teleport.machineid.v1.ListBotInstancesRequest - 2, // 5: teleport.machineid.v1.BotInstanceService.ListBotInstancesV2:input_type -> teleport.machineid.v1.ListBotInstancesV2Request - 4, // 6: teleport.machineid.v1.BotInstanceService.DeleteBotInstance:input_type -> teleport.machineid.v1.DeleteBotInstanceRequest - 5, // 7: teleport.machineid.v1.BotInstanceService.SubmitHeartbeat:input_type -> teleport.machineid.v1.SubmitHeartbeatRequest - 8, // 8: teleport.machineid.v1.BotInstanceService.GetBotInstance:output_type -> teleport.machineid.v1.BotInstance - 3, // 9: teleport.machineid.v1.BotInstanceService.ListBotInstances:output_type -> teleport.machineid.v1.ListBotInstancesResponse - 3, // 10: teleport.machineid.v1.BotInstanceService.ListBotInstancesV2:output_type -> teleport.machineid.v1.ListBotInstancesResponse - 10, // 11: teleport.machineid.v1.BotInstanceService.DeleteBotInstance:output_type -> google.protobuf.Empty - 6, // 12: teleport.machineid.v1.BotInstanceService.SubmitHeartbeat:output_type -> teleport.machineid.v1.SubmitHeartbeatResponse - 8, // [8:13] is the sub-list for method output_type - 3, // [3:8] is the sub-list for method input_type - 3, // [3:3] is the sub-list for extension type_name - 3, // [3:3] is the sub-list for extension extendee - 0, // [0:3] is the sub-list for field type_name + 8, // 0: teleport.machineid.v1.ListBotInstancesRequest.sort:type_name -> types.SortBy + 7, // 1: teleport.machineid.v1.ListBotInstancesV2Request.filter:type_name -> teleport.machineid.v1.ListBotInstancesV2Request.Filters + 9, // 2: teleport.machineid.v1.ListBotInstancesResponse.bot_instances:type_name -> teleport.machineid.v1.BotInstance + 10, // 3: teleport.machineid.v1.SubmitHeartbeatRequest.heartbeat:type_name -> teleport.machineid.v1.BotInstanceStatusHeartbeat + 0, // 4: teleport.machineid.v1.BotInstanceService.GetBotInstance:input_type -> teleport.machineid.v1.GetBotInstanceRequest + 1, // 5: teleport.machineid.v1.BotInstanceService.ListBotInstances:input_type -> teleport.machineid.v1.ListBotInstancesRequest + 2, // 6: teleport.machineid.v1.BotInstanceService.ListBotInstancesV2:input_type -> teleport.machineid.v1.ListBotInstancesV2Request + 4, // 7: teleport.machineid.v1.BotInstanceService.DeleteBotInstance:input_type -> teleport.machineid.v1.DeleteBotInstanceRequest + 5, // 8: teleport.machineid.v1.BotInstanceService.SubmitHeartbeat:input_type -> teleport.machineid.v1.SubmitHeartbeatRequest + 9, // 9: teleport.machineid.v1.BotInstanceService.GetBotInstance:output_type -> teleport.machineid.v1.BotInstance + 3, // 10: teleport.machineid.v1.BotInstanceService.ListBotInstances:output_type -> teleport.machineid.v1.ListBotInstancesResponse + 3, // 11: teleport.machineid.v1.BotInstanceService.ListBotInstancesV2:output_type -> teleport.machineid.v1.ListBotInstancesResponse + 11, // 12: teleport.machineid.v1.BotInstanceService.DeleteBotInstance:output_type -> google.protobuf.Empty + 6, // 13: teleport.machineid.v1.BotInstanceService.SubmitHeartbeat:output_type -> teleport.machineid.v1.SubmitHeartbeatResponse + 9, // [9:14] is the sub-list for method output_type + 4, // [4:9] is the sub-list for method input_type + 4, // [4:4] is the sub-list for extension type_name + 4, // [4:4] is the sub-list for extension extendee + 0, // [0:4] is the sub-list for field type_name } func init() { file_teleport_machineid_v1_bot_instance_service_proto_init() } @@ -575,7 +626,7 @@ func file_teleport_machineid_v1_bot_instance_service_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_teleport_machineid_v1_bot_instance_service_proto_rawDesc), len(file_teleport_machineid_v1_bot_instance_service_proto_rawDesc)), NumEnums: 0, - NumMessages: 7, + NumMessages: 8, NumExtensions: 0, NumServices: 1, }, diff --git a/api/proto/teleport/machineid/v1/bot_instance_service.proto b/api/proto/teleport/machineid/v1/bot_instance_service.proto index 9fcabd83986d8..e60539ddf5f19 100644 --- a/api/proto/teleport/machineid/v1/bot_instance_service.proto +++ b/api/proto/teleport/machineid/v1/bot_instance_service.proto @@ -67,12 +67,18 @@ message ListBotInstancesV2Request { // The sort order to use for the results. If empty, the default sort order is // used. bool sort_desc = 4; - // The name of the Bot to list BotInstances for. If empty, all BotInstances - // will be listed. - string filter_bot_name = 5; - // A search term used to filter the results. If non-empty, it's used to match - // against supported fields. - string filter_search_term = 6; + // Fields used to filter the results + Filters filter = 5; + + // Filters contains fields to be used to filter the results. + message Filters { + // The name of the Bot to list BotInstances for. If non-empty, only + // BotInstances for that bot will be listed. + string bot_name = 1; + // A search term used to filter the results. If non-empty, it's used to + // match against supported fields. + string search_term = 2; + } } // Response for ListBotInstances. diff --git a/lib/auth/machineid/machineidv1/bot_instance_service.go b/lib/auth/machineid/machineidv1/bot_instance_service.go index f4d6bceedc229..0b7a474efe433 100644 --- a/lib/auth/machineid/machineidv1/bot_instance_service.go +++ b/lib/auth/machineid/machineidv1/bot_instance_service.go @@ -155,12 +155,14 @@ func (b *BotInstanceService) ListBotInstances(ctx context.Context, req *pb.ListB sortDesc = req.GetSort().IsDesc } return b.ListBotInstancesV2(ctx, &pb.ListBotInstancesV2Request{ - PageSize: req.GetPageSize(), - PageToken: req.GetPageToken(), - SortField: sortField, - SortDesc: sortDesc, - FilterBotName: req.GetFilterBotName(), - FilterSearchTerm: req.GetFilterSearchTerm(), + PageSize: req.GetPageSize(), + PageToken: req.GetPageToken(), + SortField: sortField, + SortDesc: sortDesc, + Filter: &pb.ListBotInstancesV2Request_Filters{ + BotName: req.GetFilterBotName(), + SearchTerm: req.GetFilterSearchTerm(), + }, }) } @@ -176,10 +178,10 @@ func (b *BotInstanceService) ListBotInstancesV2(ctx context.Context, req *pb.Lis } res, nextToken, err := b.cache.ListBotInstances(ctx, int(req.PageSize), req.PageToken, &services.ListBotInstancesRequestOptions{ - SortField: req.SortField, - SortDesc: req.SortDesc, - FilterBotName: req.FilterBotName, - FilterSearchTerm: req.FilterSearchTerm, + SortField: req.GetSortField(), + SortDesc: req.GetSortDesc(), + FilterBotName: req.GetFilter().GetBotName(), + FilterSearchTerm: req.GetFilter().GetSearchTerm(), }) if err != nil { return nil, trace.Wrap(err) diff --git a/lib/web/machineid.go b/lib/web/machineid.go index 54eb6238d233f..f3652cae760ec 100644 --- a/lib/web/machineid.go +++ b/lib/web/machineid.go @@ -458,10 +458,12 @@ func (h *Handler) listBotInstancesV2(_ http.ResponseWriter, r *http.Request, _ h } request := &machineidv1.ListBotInstancesV2Request{ - PageToken: r.URL.Query().Get("page_token"), - SortField: r.URL.Query().Get("sort_field"), - FilterBotName: r.URL.Query().Get("bot_name"), - FilterSearchTerm: r.URL.Query().Get("search"), + PageToken: r.URL.Query().Get("page_token"), + SortField: r.URL.Query().Get("sort_field"), + Filter: &machineidv1.ListBotInstancesV2Request_Filters{ + BotName: r.URL.Query().Get("bot_name"), + SearchTerm: r.URL.Query().Get("search"), + }, } if r.URL.Query().Has("page_size") { From 2fbd79742a8b6fe77606bebe4466ddbcb9a14280 Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Wed, 24 Sep 2025 10:53:57 +0100 Subject: [PATCH 14/19] Replace `fmt.Sprintf("%06d", ...)` --- lib/cache/bot_instance.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/lib/cache/bot_instance.go b/lib/cache/bot_instance.go index 51bc63af4e0a5..d088f16a952f4 100644 --- a/lib/cache/bot_instance.go +++ b/lib/cache/bot_instance.go @@ -19,7 +19,8 @@ package cache import ( "context" "encoding/base32" - "fmt" + "strconv" + "strings" "time" "github.com/coreos/go-semver/semver" @@ -175,7 +176,17 @@ func keyForBotInstanceVersionIndex(botInstance *machineidv1.BotInstance) string return version + "-~/" + botInstance.GetMetadata().GetName() } - version = fmt.Sprintf("%06d.%06d.%06d", sv.Major, sv.Minor, sv.Patch) + zeroPad := func(num int) string { + length := 6 + s := strconv.Itoa(num) + var b strings.Builder + b.Grow(length) + b.WriteString(strings.Repeat("0", length-len(s))) + b.WriteString(s) + return b.String() + } + + version = zeroPad(int(sv.Major)) + "." + zeroPad(int(sv.Minor)) + "." + zeroPad(int(sv.Patch)) if sv.PreRelease != "" { version = version + "-" + string(sv.PreRelease) } else { From 019340f02b60574439723becacb6dd61a4c40ece Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Wed, 24 Sep 2025 10:54:28 +0100 Subject: [PATCH 15/19] Update invalid sort field error --- lib/cache/bot_instance.go | 2 +- lib/cache/bot_instance_test.go | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/cache/bot_instance.go b/lib/cache/bot_instance.go index d088f16a952f4..9926ffd928397 100644 --- a/lib/cache/bot_instance.go +++ b/lib/cache/bot_instance.go @@ -118,7 +118,7 @@ func (c *Cache) ListBotInstances(ctx context.Context, pageSize int, lastToken st case "": // default ordering as defined above default: - return nil, "", trace.BadParameter("unsupported sort %q but expected bot_name or active_at_latest", options.GetSortField()) + return nil, "", trace.BadParameter("unsupported sort %q but expected bot_name, active_at_latest, version_latest or host_name_latest", options.GetSortField()) } lister := genericLister[*machineidv1.BotInstance, botInstanceIndex]{ diff --git a/lib/cache/bot_instance_test.go b/lib/cache/bot_instance_test.go index 59d61bd848aac..e0298a9aaead6 100644 --- a/lib/cache/bot_instance_test.go +++ b/lib/cache/bot_instance_test.go @@ -372,6 +372,14 @@ func TestBotInstanceCacheSorting(t *testing.T) { require.Equal(t, "instance-1", results[1].GetMetadata().GetName()) require.Equal(t, "instance-2", results[2].GetMetadata().GetName()) }) + + t.Run("sort invalid field", func(t *testing.T) { + _, _, err := p.cache.ListBotInstances(ctx, 0, "", &services.ListBotInstancesRequestOptions{ + SortField: "blah", + }) + require.Error(t, err) + assert.ErrorContains(t, err, `unsupported sort "blah" but expected bot_name, active_at_latest, version_latest or host_name_latest`) + }) } // TestBotInstanceCacheFallback tests that requests fallback to the upstream when the cache is unhealthy. From d1fb2f4c9115e18e6f95175c4e98de8cd9288884 Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Wed, 24 Sep 2025 11:21:09 +0100 Subject: [PATCH 16/19] Fallback to v1 endpoint if possible --- .../src/BotInstances/BotInstances.test.tsx | 9 ++++ .../src/BotInstances/BotInstances.tsx | 3 ++ web/packages/teleport/src/config.ts | 10 ++++- web/packages/teleport/src/services/bot/bot.ts | 42 +++++++++++++++---- 4 files changed, 54 insertions(+), 10 deletions(-) diff --git a/web/packages/teleport/src/BotInstances/BotInstances.test.tsx b/web/packages/teleport/src/BotInstances/BotInstances.test.tsx index dec991d52b9bd..bda06ee65d455 100644 --- a/web/packages/teleport/src/BotInstances/BotInstances.test.tsx +++ b/web/packages/teleport/src/BotInstances/BotInstances.test.tsx @@ -226,6 +226,7 @@ describe('BotInstances', () => { pageSize: 20, pageToken: '', searchTerm: '', + query: '', sortDir: 'DESC', sortField: 'active_at_latest', }, @@ -241,6 +242,7 @@ describe('BotInstances', () => { pageSize: 20, pageToken: '.next', searchTerm: '', + query: '', sortDir: 'DESC', sortField: 'active_at_latest', }, @@ -256,6 +258,7 @@ describe('BotInstances', () => { pageSize: 20, pageToken: '.next.next', searchTerm: '', + query: '', sortDir: 'DESC', sortField: 'active_at_latest', }, @@ -309,6 +312,7 @@ describe('BotInstances', () => { pageSize: 20, pageToken: '', searchTerm: '', + query: '', sortDir: 'DESC', sortField: 'active_at_latest', }, @@ -325,6 +329,7 @@ describe('BotInstances', () => { pageSize: 20, pageToken: '.next', searchTerm: '', + query: '', sortDir: 'DESC', sortField: 'active_at_latest', }, @@ -344,6 +349,7 @@ describe('BotInstances', () => { pageSize: 20, pageToken: '', // Search should reset to the first page searchTerm: 'test-search-term', + query: '', sortDir: 'DESC', sortField: 'active_at_latest', }, @@ -385,6 +391,7 @@ describe('BotInstances', () => { pageSize: 20, pageToken: '', searchTerm: '', + query: '', sortDir: 'DESC', sortField: 'active_at_latest', }, @@ -399,6 +406,7 @@ describe('BotInstances', () => { pageSize: 20, pageToken: '', searchTerm: '', + query: '', sortDir: 'ASC', sortField: 'active_at_latest', }, @@ -414,6 +422,7 @@ describe('BotInstances', () => { pageSize: 20, pageToken: '', searchTerm: '', + query: '', sortDir: 'DESC', sortField: 'bot_name', }, diff --git a/web/packages/teleport/src/BotInstances/BotInstances.tsx b/web/packages/teleport/src/BotInstances/BotInstances.tsx index 0256b2b48c835..df4cf75c2c112 100644 --- a/web/packages/teleport/src/BotInstances/BotInstances.tsx +++ b/web/packages/teleport/src/BotInstances/BotInstances.tsx @@ -51,6 +51,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 sortField = queryParams.get('sort_field') || 'active_at_latest'; const sortDir = queryParams.get('sort_dir') || 'DESC'; @@ -64,6 +65,7 @@ export function BotInstances() { 'bot_instances', 'list', searchTerm, + query, pageToken, sortField, sortDir, @@ -74,6 +76,7 @@ export function BotInstances() { pageSize: 20, pageToken, searchTerm, + query, sortField, sortDir, }, diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index b4c42b1ccb643..6a8be00bf93c9 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -500,7 +500,8 @@ const cfg = { botInstance: { read: '/v1/webapi/sites/:clusterId/machine-id/bot/:botName/bot-instance/:instanceId', - list: '/v2/webapi/sites/:clusterId/machine-id/bot-instance', + list: '/v1/webapi/sites/:clusterId/machine-id/bot-instance', + listV2: '/v2/webapi/sites/:clusterId/machine-id/bot-instance', }, workloadIdentity: { @@ -1697,6 +1698,9 @@ const cfg = { | { action: 'list'; } + | { + action: 'listV2'; + } | { action: 'read'; botName: string; @@ -1710,6 +1714,10 @@ const cfg = { return generatePath(cfg.api.botInstance.list, { clusterId, }); + case 'listV2': + return generatePath(cfg.api.botInstance.listV2, { + clusterId, + }); case 'read': return generatePath(cfg.api.botInstance.read, { clusterId, diff --git a/web/packages/teleport/src/services/bot/bot.ts b/web/packages/teleport/src/services/bot/bot.ts index 83a657a730c95..7360fed8dfd8e 100644 --- a/web/packages/teleport/src/services/bot/bot.ts +++ b/web/packages/teleport/src/services/bot/bot.ts @@ -179,13 +179,26 @@ export async function listBotInstances( sortField?: string; sortDir?: string; botName?: string; + query?: string; }, signal?: AbortSignal ) { - const { pageToken, pageSize, searchTerm, sortField, sortDir, botName } = - variables; + const { + pageToken, + pageSize, + searchTerm, + sortField, + sortDir, + botName, + query, + } = variables; - const path = cfg.getBotInstanceUrl({ action: 'list' }); + // TODO(nicholasmarais1158) DELETE IN v20.0.0 + const useV1Endpoint = !query; + + const path = cfg.getBotInstanceUrl({ + action: useV1Endpoint ? 'list' : 'listV2', + }); const qs = new URLSearchParams(); qs.set('page_size', pageSize.toFixed()); @@ -193,16 +206,27 @@ export async function listBotInstances( if (searchTerm) { qs.set('search', searchTerm); } - if (sortField) { - qs.set('sort_field', sortField); - } - if (sortDir) { - qs.set('sort_dir', sortDir); - } if (botName) { qs.set('bot_name', botName); } + if (useV1Endpoint) { + const sort = `${sortField || 'name'}:${sortDir || 'asc'}`; + if (sort) { + qs.set('sort', sort); + } + } else { + if (sortField) { + qs.set('sort_field', sortField); + } + if (sortDir) { + qs.set('sort_dir', sortDir); + } + if (query) { + qs.set('query', query); + } + } + const data = await api.get(`${path}?${qs.toString()}`, signal); if (!validateListBotInstancesResponse(data)) { From 129a4a57c9e80fd2afc5c9cbcb11daf1816fc3f9 Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Wed, 24 Sep 2025 11:36:32 +0100 Subject: [PATCH 17/19] Use `strcase` for case-insensitive compare --- lib/services/bot_instance.go | 4 ++-- lib/services/local/bot_instance_test.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/services/bot_instance.go b/lib/services/bot_instance.go index db00a3b667a73..2a857e70fd7d1 100644 --- a/lib/services/bot_instance.go +++ b/lib/services/bot_instance.go @@ -19,8 +19,8 @@ package services import ( "context" "slices" - "strings" + "github.com/charlievieth/strcase" "github.com/gravitational/trace" machineidv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1" @@ -106,7 +106,7 @@ func MatchBotInstance(b *machineidv1.BotInstance, botName string, search string) } return slices.ContainsFunc(values, func(val string) bool { - return strings.Contains(strings.ToLower(val), strings.ToLower(search)) + return strcase.Contains(val, search) }) } diff --git a/lib/services/local/bot_instance_test.go b/lib/services/local/bot_instance_test.go index 6ed785179be4d..080d14faacc8d 100644 --- a/lib/services/local/bot_instance_test.go +++ b/lib/services/local/bot_instance_test.go @@ -411,7 +411,7 @@ func TestBotInstanceListWithSearchFilter(t *testing.T) { }, { name: "match on instance id", - searchTerm: "cb2c352", + searchTerm: "CB2C352", instance: newBotInstance("test-bot", withBotInstanceId("cb2c3523-01f6-4258-966b-ace9f38f9862")), }, { From e153fa9469f016420962bca363da4cafa8a321c7 Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Wed, 24 Sep 2025 11:43:56 +0100 Subject: [PATCH 18/19] Backend results are filtered by bot name so no need to re-filter in `MatchBotInstance` --- lib/services/bot_instance.go | 2 +- lib/services/local/bot_instance.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/services/bot_instance.go b/lib/services/bot_instance.go index 2a857e70fd7d1..e7c5cfc6ecd2f 100644 --- a/lib/services/bot_instance.go +++ b/lib/services/bot_instance.go @@ -82,7 +82,7 @@ func UnmarshalBotInstance(data []byte, opts ...MarshalOption) (*machineidv1.BotI } func MatchBotInstance(b *machineidv1.BotInstance, botName string, search string) bool { - if botName != "" && b.Spec.BotName != botName { + if botName != "" && b.GetSpec().GetBotName() != botName { return false } diff --git a/lib/services/local/bot_instance.go b/lib/services/local/bot_instance.go index 5a144669c016b..67770fcd0d382 100644 --- a/lib/services/local/bot_instance.go +++ b/lib/services/local/bot_instance.go @@ -116,7 +116,7 @@ func (b *BotInstanceService) ListBotInstances(ctx context.Context, pageSize int, } r, nextToken, err := service.ListResourcesWithFilter(ctx, pageSize, lastKey, func(item *machineidv1.BotInstance) bool { - return services.MatchBotInstance(item, options.GetFilterBotName(), options.GetFilterSearchTerm()) + return services.MatchBotInstance(item, "", options.GetFilterSearchTerm()) }) return r, nextToken, trace.Wrap(err) From 30392ba764e93a599672662d039e7c779c4db8fa Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Thu, 25 Sep 2025 09:49:00 +0100 Subject: [PATCH 19/19] Revert "Replace `fmt.Sprintf("%06d", ...)`" This reverts commit 2fbd79742a8b6fe77606bebe4466ddbcb9a14280. --- lib/cache/bot_instance.go | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/lib/cache/bot_instance.go b/lib/cache/bot_instance.go index 9926ffd928397..b84f3bacf9771 100644 --- a/lib/cache/bot_instance.go +++ b/lib/cache/bot_instance.go @@ -19,8 +19,7 @@ package cache import ( "context" "encoding/base32" - "strconv" - "strings" + "fmt" "time" "github.com/coreos/go-semver/semver" @@ -176,17 +175,7 @@ func keyForBotInstanceVersionIndex(botInstance *machineidv1.BotInstance) string return version + "-~/" + botInstance.GetMetadata().GetName() } - zeroPad := func(num int) string { - length := 6 - s := strconv.Itoa(num) - var b strings.Builder - b.Grow(length) - b.WriteString(strings.Repeat("0", length-len(s))) - b.WriteString(s) - return b.String() - } - - version = zeroPad(int(sv.Major)) + "." + zeroPad(int(sv.Minor)) + "." + zeroPad(int(sv.Patch)) + version = fmt.Sprintf("%06d.%06d.%06d", sv.Major, sv.Minor, sv.Patch) if sv.PreRelease != "" { version = version + "-" + string(sv.PreRelease) } else {