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