diff --git a/lib/auth/accesspoint/accesspoint.go b/lib/auth/accesspoint/accesspoint.go
index 36aace8da5cd9..a6161217bd81a 100644
--- a/lib/auth/accesspoint/accesspoint.go
+++ b/lib/auth/accesspoint/accesspoint.go
@@ -74,6 +74,7 @@ type Config struct {
AccessMonitoringRules services.AccessMonitoringRules
AppSession services.AppSession
Apps services.Apps
+ BotInstance services.BotInstance
ClusterConfig services.ClusterConfiguration
CrownJewels services.CrownJewels
DatabaseObjects services.DatabaseObjects
@@ -194,6 +195,7 @@ func NewCache(cfg Config) (*cache.Cache, error) {
PluginStaticCredentials: cfg.PluginStaticCredentials,
GitServers: cfg.GitServers,
HealthCheckConfig: cfg.HealthCheckConfig,
+ BotInstanceService: cfg.BotInstance,
}
return cache.New(cfg.Setup(cacheCfg))
diff --git a/lib/auth/authclient/api.go b/lib/auth/authclient/api.go
index ab837bd47562e..3631f66751fb4 100644
--- a/lib/auth/authclient/api.go
+++ b/lib/auth/authclient/api.go
@@ -1226,6 +1226,12 @@ type Cache interface {
// HealthCheckConfigReader defines methods for fetching health checkc config
// resources.
services.HealthCheckConfigReader
+
+ // GetBotInstance returns the specified BotInstance resource.
+ 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) ([]*machineidv1.BotInstance, string, error)
}
type NodeWrapper struct {
diff --git a/lib/auth/grpcserver.go b/lib/auth/grpcserver.go
index b416592dd7f72..d28790bf266be 100644
--- a/lib/auth/grpcserver.go
+++ b/lib/auth/grpcserver.go
@@ -5368,6 +5368,7 @@ func NewGRPCServer(cfg GRPCServerConfig) (*GRPCServer, error) {
botInstanceService, err := machineidv1.NewBotInstanceService(machineidv1.BotInstanceServiceConfig{
Authorizer: cfg.Authorizer,
+ Cache: cfg.AuthServer.Cache,
Backend: cfg.AuthServer.Services.BotInstance,
Clock: cfg.AuthServer.GetClock(),
})
diff --git a/lib/auth/helpers.go b/lib/auth/helpers.go
index fac7a02d222c0..0bb3d972678f2 100644
--- a/lib/auth/helpers.go
+++ b/lib/auth/helpers.go
@@ -552,6 +552,7 @@ func InitTestAuthCache(p TestAuthCacheParams) error {
PluginStaticCredentials: p.AuthServer.Services.PluginStaticCredentials,
GitServers: p.AuthServer.Services.GitServers,
HealthCheckConfig: p.AuthServer.Services.HealthCheckConfig,
+ BotInstance: p.AuthServer.Services.BotInstance,
})
if err != nil {
return trace.Wrap(err)
diff --git a/lib/auth/machineid/machineidv1/bot_instance_service.go b/lib/auth/machineid/machineidv1/bot_instance_service.go
index db89348713956..ff1ccf7454e46 100644
--- a/lib/auth/machineid/machineidv1/bot_instance_service.go
+++ b/lib/auth/machineid/machineidv1/bot_instance_service.go
@@ -48,10 +48,20 @@ const (
ExpiryMargin = time.Minute * 5
)
+// BotInstancesCache is the subset of the cached resources that the Service queries.
+type BotInstancesCache interface {
+ // GetBotInstance returns the specified BotInstance resource.
+ 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) ([]*pb.BotInstance, string, error)
+}
+
// BotInstanceServiceConfig holds configuration options for the BotInstance gRPC
// service.
type BotInstanceServiceConfig struct {
Authorizer authz.Authorizer
+ Cache BotInstancesCache
Backend services.BotInstance
Logger *slog.Logger
Clock clockwork.Clock
@@ -64,6 +74,8 @@ func NewBotInstanceService(cfg BotInstanceServiceConfig) (*BotInstanceService, e
return nil, trace.BadParameter("backend service is required")
case cfg.Authorizer == nil:
return nil, trace.BadParameter("authorizer is required")
+ case cfg.Cache == nil:
+ return nil, trace.BadParameter("cache service is required")
}
if cfg.Logger == nil {
@@ -76,6 +88,7 @@ func NewBotInstanceService(cfg BotInstanceServiceConfig) (*BotInstanceService, e
return &BotInstanceService{
logger: cfg.Logger,
authorizer: cfg.Authorizer,
+ cache: cfg.Cache,
backend: cfg.Backend,
clock: cfg.Clock,
}, nil
@@ -87,6 +100,7 @@ type BotInstanceService struct {
backend services.BotInstance
authorizer authz.Authorizer
+ cache BotInstancesCache
logger *slog.Logger
clock clockwork.Clock
}
@@ -124,7 +138,7 @@ func (b *BotInstanceService) GetBotInstance(ctx context.Context, req *pb.GetBotI
return nil, trace.Wrap(err)
}
- res, err := b.backend.GetBotInstance(ctx, req.BotName, req.InstanceId)
+ res, err := b.cache.GetBotInstance(ctx, req.BotName, req.InstanceId)
if err != nil {
return nil, trace.Wrap(err)
}
@@ -143,7 +157,7 @@ func (b *BotInstanceService) ListBotInstances(ctx context.Context, req *pb.ListB
return nil, trace.Wrap(err)
}
- res, nextToken, err := b.backend.ListBotInstances(ctx, req.FilterBotName, int(req.PageSize), req.PageToken, req.FilterSearchTerm)
+ res, nextToken, err := b.cache.ListBotInstances(ctx, req.FilterBotName, int(req.PageSize), req.PageToken, 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 5b79a70cd0e9c..5de41a878a7e4 100644
--- a/lib/auth/machineid/machineidv1/bot_instance_service_test.go
+++ b/lib/auth/machineid/machineidv1/bot_instance_service_test.go
@@ -334,6 +334,7 @@ func TestBotInstanceServiceSubmitHeartbeat(t *testing.T) {
backend := newBotInstanceBackend(t)
service, err := NewBotInstanceService(BotInstanceServiceConfig{
Backend: backend,
+ Cache: backend,
Authorizer: authz.AuthorizerFunc(func(ctx context.Context) (*authz.Context, error) {
return &authz.Context{
Identity: identityGetterFn(func() tlsca.Identity {
@@ -391,6 +392,7 @@ func TestBotInstanceServiceSubmitHeartbeat_HeartbeatLimit(t *testing.T) {
backend := newBotInstanceBackend(t)
service, err := NewBotInstanceService(BotInstanceServiceConfig{
Backend: backend,
+ Cache: backend,
Authorizer: authz.AuthorizerFunc(func(ctx context.Context) (*authz.Context, error) {
return &authz.Context{
Identity: identityGetterFn(func() tlsca.Identity {
@@ -583,6 +585,7 @@ func newBotInstanceService(
service, err := NewBotInstanceService(BotInstanceServiceConfig{
Authorizer: authorizer,
Backend: backendService,
+ Cache: backendService,
})
require.NoError(t, err)
diff --git a/lib/cache/bot_instance.go b/lib/cache/bot_instance.go
new file mode 100644
index 0000000000000..06ba0a69b8d01
--- /dev/null
+++ b/lib/cache/bot_instance.go
@@ -0,0 +1,155 @@
+// Teleport
+// Copyright (C) 2025 Gravitational, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package cache
+
+import (
+ "context"
+ "slices"
+ "strings"
+
+ "github.com/gravitational/trace"
+ "google.golang.org/protobuf/proto"
+
+ "github.com/gravitational/teleport/api/defaults"
+ machineidv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1"
+ "github.com/gravitational/teleport/api/types"
+ "github.com/gravitational/teleport/api/utils/clientutils"
+ "github.com/gravitational/teleport/lib/services"
+)
+
+type botInstanceIndex string
+
+const (
+ botInstanceNameIndex botInstanceIndex = "name"
+)
+
+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 newBotInstanceCollection(upstream services.BotInstance, w types.WatchKind) (*collection[*machineidv1.BotInstance, botInstanceIndex], error) {
+ if upstream == nil {
+ return nil, trace.BadParameter("missing parameter upstream (BotInstance)")
+ }
+
+ return &collection[*machineidv1.BotInstance, botInstanceIndex]{
+ store: newStore(
+ types.KindBotInstance,
+ proto.CloneOf[*machineidv1.BotInstance],
+ map[botInstanceIndex]func(*machineidv1.BotInstance) string{
+ // Index on a combination of bot name and instance name
+ botInstanceNameIndex: keyForNameIndex,
+ }),
+ fetcher: func(ctx context.Context, loadSecrets bool) ([]*machineidv1.BotInstance, error) {
+ var out []*machineidv1.BotInstance
+ clientutils.IterateResources(ctx,
+ func(ctx context.Context, limit int, start string) ([]*machineidv1.BotInstance, string, error) {
+ return upstream.ListBotInstances(ctx, "", limit, start, "")
+ },
+ func(hcc *machineidv1.BotInstance) error {
+ out = append(out, hcc)
+ return nil
+ },
+ )
+ return out, nil
+ },
+ watch: w,
+ }, nil
+}
+
+// GetBotInstance returns the specified BotInstance resource.
+func (c *Cache) GetBotInstance(ctx context.Context, botName, instanceID string) (*machineidv1.BotInstance, error) {
+ ctx, span := c.Tracer.Start(ctx, "cache/GetBotInstance")
+ defer span.End()
+
+ getter := genericGetter[*machineidv1.BotInstance, botInstanceIndex]{
+ cache: c,
+ collection: c.collections.botInstances,
+ index: botInstanceNameIndex,
+ upstreamGet: func(ctx context.Context, _ string) (*machineidv1.BotInstance, error) {
+ return c.Config.BotInstanceService.GetBotInstance(ctx, botName, instanceID)
+ },
+ }
+
+ out, err := getter.get(ctx, makeNameIndexKey(botName, instanceID))
+ return out, trace.Wrap(err)
+}
+
+// ListBotInstances returns a page of BotInstance resources.
+func (c *Cache) ListBotInstances(ctx context.Context, botName string, pageSize int, lastToken string, search string) ([]*machineidv1.BotInstance, string, error) {
+ ctx, span := c.Tracer.Start(ctx, "cache/ListBotInstances")
+ defer span.End()
+
+ lister := genericLister[*machineidv1.BotInstance, botInstanceIndex]{
+ cache: c,
+ collection: c.collections.botInstances,
+ index: botInstanceNameIndex,
+ 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)
+ },
+ filter: func(b *machineidv1.BotInstance) bool {
+ return matchBotInstance(b, botName, search)
+ },
+ nextToken: func(b *machineidv1.BotInstance) string {
+ return keyForNameIndex(b)
+ },
+ }
+ out, next, err := lister.list(ctx,
+ pageSize,
+ lastToken,
+ )
+ return out, next, trace.Wrap(err)
+}
+
+func matchBotInstance(b *machineidv1.BotInstance, botName string, search string) bool {
+ // If updating this, ensure it's consistent with the upstream search logic in `lib/services/local/bot_instance.go`.
+
+ if botName != "" && b.Spec.BotName != botName {
+ return false
+ }
+
+ if search == "" {
+ return true
+ }
+
+ latestHeartbeats := b.GetStatus().GetLatestHeartbeats()
+ heartbeat := b.Status.InitialHeartbeat // Use initial heartbeat as a fallback
+ if len(latestHeartbeats) > 0 {
+ heartbeat = latestHeartbeats[len(latestHeartbeats)-1]
+ }
+
+ values := []string{
+ b.Spec.BotName,
+ b.Spec.InstanceId,
+ }
+
+ if heartbeat != nil {
+ values = append(values, heartbeat.Hostname, heartbeat.JoinMethod, heartbeat.Version, "v"+heartbeat.Version)
+ }
+
+ return slices.ContainsFunc(values, func(val string) bool {
+ return strings.Contains(strings.ToLower(val), strings.ToLower(search))
+ })
+}
diff --git a/lib/cache/bot_instance_test.go b/lib/cache/bot_instance_test.go
new file mode 100644
index 0000000000000..6395eed93f104
--- /dev/null
+++ b/lib/cache/bot_instance_test.go
@@ -0,0 +1,222 @@
+// Teleport
+// Copyright (C) 2025 Gravitational, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package cache
+
+import (
+ "context"
+ "strconv"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ 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"
+)
+
+// TestBotInstanceCache tests that CRUD operations on bot instances resources are
+// replicated from the backend to the cache.
+func TestBotInstanceCache(t *testing.T) {
+ t.Parallel()
+
+ p := newTestPack(t, ForAuth)
+ t.Cleanup(p.Close)
+
+ testResources153(t, p, testFuncs153[*machineidv1.BotInstance]{
+ newResource: func(key string) (*machineidv1.BotInstance, error) {
+ return &machineidv1.BotInstance{
+ Kind: types.KindBotInstance,
+ Version: types.V1,
+ Metadata: &headerv1.Metadata{},
+ Spec: &machineidv1.BotInstanceSpec{
+ BotName: "bot-1",
+ InstanceId: key,
+ },
+ Status: &machineidv1.BotInstanceStatus{},
+ }, nil
+ },
+ cacheGet: func(ctx context.Context, key string) (*machineidv1.BotInstance, error) {
+ return p.cache.GetBotInstance(ctx, "bot-1", key)
+ },
+ cacheList: func(ctx context.Context) ([]*machineidv1.BotInstance, error) {
+ results, _, err := p.cache.ListBotInstances(ctx, "", 0, "", "")
+ return results, err
+ },
+ create: func(ctx context.Context, resource *machineidv1.BotInstance) error {
+ _, err := p.botInstanceService.CreateBotInstance(ctx, resource)
+ return err
+ },
+ list: func(ctx context.Context) ([]*machineidv1.BotInstance, error) {
+ results, _, err := p.botInstanceService.ListBotInstances(ctx, "", 0, "", "")
+ return results, err
+ },
+ 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) {
+ return bi, nil
+ })
+ return err
+ },
+ delete: func(ctx context.Context, key string) error {
+ return p.botInstanceService.DeleteBotInstance(ctx, "bot-1", key)
+ },
+ deleteAll: func(ctx context.Context) error {
+ return p.botInstanceService.DeleteAllBotInstances(ctx)
+ },
+ })
+}
+
+// TestBotInstanceCachePaging tests that items from the cache are paginated.
+func TestBotInstanceCachePaging(t *testing.T) {
+ t.Parallel()
+
+ ctx := context.Background()
+
+ p := newTestPack(t, ForAuth)
+ t.Cleanup(p.Close)
+
+ for _, n := range []int{5, 1, 3, 4, 2} {
+ _, err := p.botInstanceService.CreateBotInstance(ctx, &machineidv1.BotInstance{
+ Kind: types.KindBotInstance,
+ Version: types.V1,
+ Metadata: &headerv1.Metadata{},
+ Spec: &machineidv1.BotInstanceSpec{
+ BotName: "bot-1",
+ InstanceId: "instance-" + strconv.Itoa(n),
+ },
+ Status: &machineidv1.BotInstanceStatus{},
+ })
+ require.NoError(t, err)
+ }
+
+ // Let the cache catch up
+ require.EventuallyWithT(t, func(t *assert.CollectT) {
+ _, err := p.cache.GetBotInstance(ctx, "bot-1", "instance-2")
+ require.NoError(t, err)
+ }, 2*time.Second, 10*time.Millisecond)
+
+ // page size equal to total items
+ results, nextPageToken, err := p.cache.ListBotInstances(ctx, "", 0, "", "")
+ require.NoError(t, err)
+ require.Empty(t, nextPageToken)
+ require.Len(t, results, 5)
+ require.Equal(t, "instance-1", results[0].GetMetadata().GetName())
+ require.Equal(t, "instance-2", results[1].GetMetadata().GetName())
+ require.Equal(t, "instance-3", results[2].GetMetadata().GetName())
+ require.Equal(t, "instance-4", results[3].GetMetadata().GetName())
+ require.Equal(t, "instance-5", results[4].GetMetadata().GetName())
+
+ // page size smaller than total items
+ results, nextPageToken, err = p.cache.ListBotInstances(ctx, "", 3, "", "")
+ require.NoError(t, err)
+ require.Equal(t, "bot-1/instance-4", nextPageToken)
+ require.Len(t, results, 3)
+ require.Equal(t, "instance-1", results[0].GetMetadata().GetName())
+ require.Equal(t, "instance-2", results[1].GetMetadata().GetName())
+ require.Equal(t, "instance-3", results[2].GetMetadata().GetName())
+
+ // next page
+ results, nextPageToken, err = p.cache.ListBotInstances(ctx, "", 3, nextPageToken, "")
+ require.NoError(t, err)
+ require.Empty(t, nextPageToken)
+ require.Len(t, results, 2)
+ require.Equal(t, "instance-4", results[0].GetMetadata().GetName())
+ require.Equal(t, "instance-5", results[1].GetMetadata().GetName())
+}
+
+// TestBotInstanceCacheBotFilter tests that cache items are filtered by bot name.
+func TestBotInstanceCacheBotFilter(t *testing.T) {
+ t.Parallel()
+
+ ctx := context.Background()
+
+ p := newTestPack(t, ForAuth)
+ t.Cleanup(p.Close)
+
+ for n := range 2 {
+ for m := range 5 {
+ _, err := p.botInstanceService.CreateBotInstance(ctx, &machineidv1.BotInstance{
+ Kind: types.KindBotInstance,
+ Version: types.V1,
+ Metadata: &headerv1.Metadata{},
+ Spec: &machineidv1.BotInstanceSpec{
+ BotName: "bot-" + strconv.Itoa(n+1),
+ InstanceId: "instance-" + strconv.Itoa((n+1)*(m+1)),
+ },
+ Status: &machineidv1.BotInstanceStatus{},
+ })
+ require.NoError(t, err)
+ }
+ }
+
+ // Let the cache catch up
+ require.EventuallyWithT(t, func(t *assert.CollectT) {
+ _, err := p.cache.GetBotInstance(ctx, "bot-2", "instance-10")
+ require.NoError(t, err)
+ }, 2*time.Second, 10*time.Millisecond)
+
+ results, _, err := p.cache.ListBotInstances(ctx, "bot-2", 0, "", "")
+ require.NoError(t, err)
+ require.Len(t, results, 5)
+
+ for _, b := range results {
+ require.Equal(t, "bot-2", b.GetSpec().GetBotName())
+ }
+}
+
+// TestBotInstanceCacheSearchFilter tests that cache items are filtered by search query.
+func TestBotInstanceCacheSearchFilter(t *testing.T) {
+ t.Parallel()
+
+ ctx := context.Background()
+
+ p := newTestPack(t, ForAuth)
+ t.Cleanup(p.Close)
+
+ for n := range 10 {
+ instance := &machineidv1.BotInstance{
+ Kind: types.KindBotInstance,
+ Version: types.V1,
+ Metadata: &headerv1.Metadata{},
+ Spec: &machineidv1.BotInstanceSpec{
+ BotName: "bot-1",
+ InstanceId: "instance-" + strconv.Itoa(n+1),
+ },
+ Status: &machineidv1.BotInstanceStatus{
+ LatestHeartbeats: []*machineidv1.BotInstanceStatusHeartbeat{
+ {
+ Hostname: "host-" + strconv.Itoa(n%2),
+ },
+ },
+ },
+ }
+
+ _, err := p.botInstanceService.CreateBotInstance(ctx, instance)
+ require.NoError(t, err)
+ }
+
+ // Let the cache catch up
+ require.EventuallyWithT(t, func(t *assert.CollectT) {
+ _, err := p.cache.GetBotInstance(ctx, "bot-1", "instance-10")
+ require.NoError(t, err)
+ }, 2*time.Second, 10*time.Millisecond)
+
+ results, _, err := p.cache.ListBotInstances(ctx, "", 0, "", "host-1")
+ require.NoError(t, err)
+ require.Len(t, results, 5)
+}
diff --git a/lib/cache/cache.go b/lib/cache/cache.go
index 66b5a65f84fcd..ac7a9c7bd022e 100644
--- a/lib/cache/cache.go
+++ b/lib/cache/cache.go
@@ -206,6 +206,7 @@ func ForAuth(cfg Config) Config {
{Kind: types.KindGitServer},
{Kind: types.KindWorkloadIdentity},
{Kind: types.KindHealthCheckConfig},
+ {Kind: types.KindBotInstance},
}
cfg.QueueSize = defaults.AuthQueueSize
// We don't want to enable partial health for auth cache because auth uses an event stream
@@ -737,6 +738,8 @@ type Config struct {
GitServers services.GitServerGetter
// HealthCheckConfig is a health check config service.
HealthCheckConfig services.HealthCheckConfigReader
+ // BotInstanceService is the upstream service that we're caching
+ BotInstanceService services.BotInstance
}
// CheckAndSetDefaults checks parameters and sets default values
diff --git a/lib/cache/cache_test.go b/lib/cache/cache_test.go
index d9e0f732bbf55..44faeea612c44 100644
--- a/lib/cache/cache_test.go
+++ b/lib/cache/cache_test.go
@@ -144,6 +144,7 @@ type testPack struct {
gitServers *local.GitServerService
workloadIdentity *local.WorkloadIdentityService
healthCheckConfig *local.HealthCheckConfigService
+ botInstanceService *local.BotInstanceService
}
// testFuncs are functions to support testing an object in a cache.
@@ -413,6 +414,11 @@ func newPackWithoutCache(dir string, opts ...packOption) (*testPack, error) {
return nil, trace.Wrap(err)
}
+ p.botInstanceService, err = local.NewBotInstanceService(p.backend, p.backend.Clock())
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
return p, nil
}
@@ -468,6 +474,7 @@ func newPack(dir string, setupConfig func(c Config) Config, opts ...packOption)
GitServers: p.gitServers,
HealthCheckConfig: p.healthCheckConfig,
WorkloadIdentity: p.workloadIdentity,
+ BotInstanceService: p.botInstanceService,
MaxRetryPeriod: 200 * time.Millisecond,
EventsC: p.eventsC,
}))
@@ -734,6 +741,7 @@ func TestCompletenessInit(t *testing.T) {
EventsC: p.eventsC,
GitServers: p.gitServers,
HealthCheckConfig: p.healthCheckConfig,
+ BotInstanceService: p.botInstanceService,
}))
require.NoError(t, err)
@@ -818,6 +826,7 @@ func TestCompletenessReset(t *testing.T) {
EventsC: p.eventsC,
GitServers: p.gitServers,
HealthCheckConfig: p.healthCheckConfig,
+ BotInstanceService: p.botInstanceService,
}))
require.NoError(t, err)
@@ -975,6 +984,7 @@ func TestListResources_NodesTTLVariant(t *testing.T) {
neverOK: true, // ensure reads are never healthy
GitServers: p.gitServers,
HealthCheckConfig: p.healthCheckConfig,
+ BotInstanceService: p.botInstanceService,
}))
require.NoError(t, err)
@@ -1071,6 +1081,7 @@ func initStrategy(t *testing.T) {
EventsC: p.eventsC,
GitServers: p.gitServers,
HealthCheckConfig: p.healthCheckConfig,
+ BotInstanceService: p.botInstanceService,
}))
require.NoError(t, err)
@@ -1828,6 +1839,7 @@ func TestCacheWatchKindExistsInEvents(t *testing.T) {
types.KindGitServer: &types.ServerV2{},
types.KindWorkloadIdentity: types.Resource153ToLegacy(newWorkloadIdentity("some_identifier")),
types.KindHealthCheckConfig: types.Resource153ToLegacy(newHealthCheckConfig(t, "some-name")),
+ types.KindBotInstance: types.ProtoResource153ToLegacy(new(machineidv1.BotInstance)),
}
for name, cfg := range cases {
@@ -1887,6 +1899,8 @@ func TestCacheWatchKindExistsInEvents(t *testing.T) {
require.Empty(t, cmp.Diff(resource.(types.Resource153UnwrapperT[*kubewaitingcontainerpb.KubernetesWaitingContainer]).UnwrapT(), uw.UnwrapT(), protocmp.Transform()))
case types.Resource153UnwrapperT[*healthcheckconfigv1.HealthCheckConfig]:
require.Empty(t, cmp.Diff(resource.(types.Resource153UnwrapperT[*healthcheckconfigv1.HealthCheckConfig]).UnwrapT(), uw.UnwrapT(), protocmp.Transform()))
+ case types.Resource153UnwrapperT[*machineidv1.BotInstance]:
+ require.Empty(t, cmp.Diff(resource.(types.Resource153UnwrapperT[*machineidv1.BotInstance]).UnwrapT(), uw.UnwrapT(), protocmp.Transform()))
default:
require.Empty(t, cmp.Diff(resource, event.Resource))
}
diff --git a/lib/cache/collections.go b/lib/cache/collections.go
index 41ff3c46731da..2c4a2b179fead 100644
--- a/lib/cache/collections.go
+++ b/lib/cache/collections.go
@@ -136,6 +136,7 @@ type collections struct {
auditQueries *collection[*secreports.AuditQuery, auditQueryIndex]
secReports *collection[*secreports.Report, securityReportIndex]
secReportsStates *collection[*secreports.ReportState, securityReportStateIndex]
+ botInstances *collection[*machineidv1.BotInstance, botInstanceIndex]
}
// isKnownUncollectedKind is true if a resource kind is not stored in
@@ -721,6 +722,14 @@ func setupCollections(c Config) (*collections, error) {
out.secReportsStates = collect
out.byKind[resourceKind] = out.secReportsStates
+ case types.KindBotInstance:
+ collect, err := newBotInstanceCollection(c.BotInstanceService, watch)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ out.botInstances = collect
+ out.byKind[resourceKind] = out.botInstances
default:
if _, ok := out.byKind[resourceKind]; !ok {
return nil, trace.BadParameter("resource %q is not supported", watch.Kind)
diff --git a/lib/service/service.go b/lib/service/service.go
index 287dfa9afcb32..db5d0173b1007 100644
--- a/lib/service/service.go
+++ b/lib/service/service.go
@@ -2704,6 +2704,7 @@ func (process *TeleportProcess) newAccessCacheForServices(cfg accesspoint.Config
cfg.PluginStaticCredentials = services.PluginStaticCredentials
cfg.GitServers = services.GitServers
cfg.HealthCheckConfig = services.HealthCheckConfig
+ cfg.BotInstance = services.BotInstance
return accesspoint.NewCache(cfg)
}
diff --git a/lib/services/local/bot_instance.go b/lib/services/local/bot_instance.go
index c1a35d2816dd0..bc568abb37ded 100644
--- a/lib/services/local/bot_instance.go
+++ b/lib/services/local/bot_instance.go
@@ -110,27 +110,41 @@ func (b *BotInstanceService) ListBotInstances(ctx context.Context, botName strin
}
r, nextToken, err := service.ListResourcesWithFilter(ctx, pageSize, lastKey, func(item *machineidv1.BotInstance) bool {
- latestHeartbeats := item.GetStatus().GetLatestHeartbeats()
- heartbeat := item.Status.InitialHeartbeat // Use initial heartbeat as a fallback
- if len(latestHeartbeats) > 0 {
- heartbeat = latestHeartbeats[len(latestHeartbeats)-1]
- }
+ return matchBotInstance(item, botName, search)
+ })
- values := []string{
- item.Spec.BotName,
- item.Spec.InstanceId,
- }
+ return r, nextToken, trace.Wrap(err)
+}
- if heartbeat != nil {
- values = append(values, heartbeat.Hostname, heartbeat.JoinMethod, heartbeat.Version, "v"+heartbeat.Version)
- }
+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`.
- return slices.ContainsFunc(values, func(val string) bool {
- return strings.Contains(strings.ToLower(val), strings.ToLower(search))
- })
- })
+ if botName != "" && b.Spec.BotName != botName {
+ return false
+ }
- return r, nextToken, trace.Wrap(err)
+ 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
@@ -140,6 +154,11 @@ func (b *BotInstanceService) DeleteBotInstance(ctx context.Context, botName, ins
return trace.Wrap(serviceWithPrefix.DeleteResource(ctx, instanceID))
}
+// DeleteAllBotInstances deletes all bot instances for all bots
+func (b *BotInstanceService) DeleteAllBotInstances(ctx context.Context) error {
+ return trace.Wrap(b.service.DeleteAllResources(ctx))
+}
+
// PatchBotInstance uses the supplied function to patch the bot instance
// matching the given (botName, instanceID) key and persists the patched
// resource. It will make multiple attempts if a `CompareFailed` error is