diff --git a/api/client/client.go b/api/client/client.go
index ee51583caf2eb..636ca6d922908 100644
--- a/api/client/client.go
+++ b/api/client/client.go
@@ -83,6 +83,7 @@ import (
gitserverpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/gitserver/v1"
healthcheckconfigv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/healthcheckconfig/v1"
integrationpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/integration/v1"
+ inventoryv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/inventory/v1"
joinv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/join/v1"
kubeproto "github.com/gravitational/teleport/api/gen/proto/go/teleport/kube/v1"
kubewaitingcontainerpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/kubewaitingcontainer/v1"
@@ -926,6 +927,11 @@ func (c *Client) WorkloadIdentityServiceClient() machineidv1pb.WorkloadIdentityS
return machineidv1pb.NewWorkloadIdentityServiceClient(c.conn)
}
+// InventoryServiceClient returns an unadorned client for the inventory service.
+func (c *Client) InventoryServiceClient() inventoryv1.InventoryServiceClient {
+ return inventoryv1.NewInventoryServiceClient(c.conn)
+}
+
// NotificationServiceClient returns a notification service client that can be used to fetch notifications.
func (c *Client) NotificationServiceClient() notificationsv1pb.NotificationServiceClient {
return notificationsv1pb.NewNotificationServiceClient(c.conn)
diff --git a/api/client/inventory.go b/api/client/inventory.go
index 88bad22c1fdcf..c20720a017f14 100644
--- a/api/client/inventory.go
+++ b/api/client/inventory.go
@@ -26,6 +26,7 @@ import (
"github.com/gravitational/trace"
"github.com/gravitational/teleport/api/client/proto"
+ inventoryv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/inventory/v1"
"github.com/gravitational/teleport/api/internalutils/stream"
"github.com/gravitational/teleport/api/types"
)
@@ -247,6 +248,15 @@ func (c *Client) GetInstances(ctx context.Context, filter types.InstanceFilter)
}, cancel)
}
+// ListUnifiedInstances returns a paginated list of unified instances (teleport instances and bot instances).
+func (c *Client) ListUnifiedInstances(ctx context.Context, req *inventoryv1.ListUnifiedInstancesRequest) (*inventoryv1.ListUnifiedInstancesResponse, error) {
+ rsp, err := c.InventoryServiceClient().ListUnifiedInstances(ctx, req)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ return rsp, nil
+}
+
func newDownstreamInventoryControlStream(stream proto.AuthService_InventoryControlStreamClient, cancel context.CancelFunc) DownstreamInventoryControlStream {
ics := &downstreamICS{
sendC: make(chan upstreamSend),
diff --git a/api/gen/proto/go/teleport/inventory/v1/inventory_service.pb.go b/api/gen/proto/go/teleport/inventory/v1/inventory_service.pb.go
new file mode 100644
index 0000000000000..6d245bed41b7d
--- /dev/null
+++ b/api/gen/proto/go/teleport/inventory/v1/inventory_service.pb.go
@@ -0,0 +1,629 @@
+// 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 .
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// protoc-gen-go v1.36.8
+// protoc (unknown)
+// source: teleport/inventory/v1/inventory_service.proto
+
+package inventoryv1
+
+import (
+ v1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1"
+ types "github.com/gravitational/teleport/api/types"
+ protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+ protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+ reflect "reflect"
+ sync "sync"
+ unsafe "unsafe"
+)
+
+const (
+ // Verify that this generated code is sufficiently up-to-date.
+ _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+ // Verify that runtime/protoimpl is sufficiently up-to-date.
+ _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// InstanceType represents the type of instance.
+type InstanceType int32
+
+const (
+ InstanceType_INSTANCE_TYPE_UNSPECIFIED InstanceType = 0
+ // INSTANCE_TYPE_INSTANCE is a Teleport instance.
+ InstanceType_INSTANCE_TYPE_INSTANCE InstanceType = 1
+ // INSTANCE_TYPE_BOT_INSTANCE is a bot instance.
+ InstanceType_INSTANCE_TYPE_BOT_INSTANCE InstanceType = 2
+)
+
+// Enum value maps for InstanceType.
+var (
+ InstanceType_name = map[int32]string{
+ 0: "INSTANCE_TYPE_UNSPECIFIED",
+ 1: "INSTANCE_TYPE_INSTANCE",
+ 2: "INSTANCE_TYPE_BOT_INSTANCE",
+ }
+ InstanceType_value = map[string]int32{
+ "INSTANCE_TYPE_UNSPECIFIED": 0,
+ "INSTANCE_TYPE_INSTANCE": 1,
+ "INSTANCE_TYPE_BOT_INSTANCE": 2,
+ }
+)
+
+func (x InstanceType) Enum() *InstanceType {
+ p := new(InstanceType)
+ *p = x
+ return p
+}
+
+func (x InstanceType) String() string {
+ return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (InstanceType) Descriptor() protoreflect.EnumDescriptor {
+ return file_teleport_inventory_v1_inventory_service_proto_enumTypes[0].Descriptor()
+}
+
+func (InstanceType) Type() protoreflect.EnumType {
+ return &file_teleport_inventory_v1_inventory_service_proto_enumTypes[0]
+}
+
+func (x InstanceType) Number() protoreflect.EnumNumber {
+ return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use InstanceType.Descriptor instead.
+func (InstanceType) EnumDescriptor() ([]byte, []int) {
+ return file_teleport_inventory_v1_inventory_service_proto_rawDescGZIP(), []int{0}
+}
+
+// UnifiedInstanceSort specifies the sort mode for listing unified instances.
+// If a sorting criteria for multiple instances are equal (eg. 2 instances are the same version), the secondary
+// sorting will always be by name.
+type UnifiedInstanceSort int32
+
+const (
+ UnifiedInstanceSort_UNIFIED_INSTANCE_SORT_UNSPECIFIED UnifiedInstanceSort = 0
+ // UNIFIED_INSTANCE_SORT_NAME sorts by display name (hostname for instances, bot name for bot instances).
+ UnifiedInstanceSort_UNIFIED_INSTANCE_SORT_NAME UnifiedInstanceSort = 1
+ // UNIFIED_INSTANCE_SORT_TYPE sorts by instance type (instance vs bot_instance).
+ UnifiedInstanceSort_UNIFIED_INSTANCE_SORT_TYPE UnifiedInstanceSort = 2
+ // UNIFIED_INSTANCE_SORT_VERSION sorts by version.
+ UnifiedInstanceSort_UNIFIED_INSTANCE_SORT_VERSION UnifiedInstanceSort = 3
+)
+
+// Enum value maps for UnifiedInstanceSort.
+var (
+ UnifiedInstanceSort_name = map[int32]string{
+ 0: "UNIFIED_INSTANCE_SORT_UNSPECIFIED",
+ 1: "UNIFIED_INSTANCE_SORT_NAME",
+ 2: "UNIFIED_INSTANCE_SORT_TYPE",
+ 3: "UNIFIED_INSTANCE_SORT_VERSION",
+ }
+ UnifiedInstanceSort_value = map[string]int32{
+ "UNIFIED_INSTANCE_SORT_UNSPECIFIED": 0,
+ "UNIFIED_INSTANCE_SORT_NAME": 1,
+ "UNIFIED_INSTANCE_SORT_TYPE": 2,
+ "UNIFIED_INSTANCE_SORT_VERSION": 3,
+ }
+)
+
+func (x UnifiedInstanceSort) Enum() *UnifiedInstanceSort {
+ p := new(UnifiedInstanceSort)
+ *p = x
+ return p
+}
+
+func (x UnifiedInstanceSort) String() string {
+ return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (UnifiedInstanceSort) Descriptor() protoreflect.EnumDescriptor {
+ return file_teleport_inventory_v1_inventory_service_proto_enumTypes[1].Descriptor()
+}
+
+func (UnifiedInstanceSort) Type() protoreflect.EnumType {
+ return &file_teleport_inventory_v1_inventory_service_proto_enumTypes[1]
+}
+
+func (x UnifiedInstanceSort) Number() protoreflect.EnumNumber {
+ return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use UnifiedInstanceSort.Descriptor instead.
+func (UnifiedInstanceSort) EnumDescriptor() ([]byte, []int) {
+ return file_teleport_inventory_v1_inventory_service_proto_rawDescGZIP(), []int{1}
+}
+
+// SortOrder specifies the sort order for listing unified instances.
+type SortOrder int32
+
+const (
+ SortOrder_SORT_ORDER_UNSPECIFIED SortOrder = 0
+ // SORT_ORDER_ASCENDING sorts in ascending order.
+ SortOrder_SORT_ORDER_ASCENDING SortOrder = 1
+ // SORT_ORDER_DESCENDING sorts in descending order.
+ SortOrder_SORT_ORDER_DESCENDING SortOrder = 2
+)
+
+// Enum value maps for SortOrder.
+var (
+ SortOrder_name = map[int32]string{
+ 0: "SORT_ORDER_UNSPECIFIED",
+ 1: "SORT_ORDER_ASCENDING",
+ 2: "SORT_ORDER_DESCENDING",
+ }
+ SortOrder_value = map[string]int32{
+ "SORT_ORDER_UNSPECIFIED": 0,
+ "SORT_ORDER_ASCENDING": 1,
+ "SORT_ORDER_DESCENDING": 2,
+ }
+)
+
+func (x SortOrder) Enum() *SortOrder {
+ p := new(SortOrder)
+ *p = x
+ return p
+}
+
+func (x SortOrder) String() string {
+ return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (SortOrder) Descriptor() protoreflect.EnumDescriptor {
+ return file_teleport_inventory_v1_inventory_service_proto_enumTypes[2].Descriptor()
+}
+
+func (SortOrder) Type() protoreflect.EnumType {
+ return &file_teleport_inventory_v1_inventory_service_proto_enumTypes[2]
+}
+
+func (x SortOrder) Number() protoreflect.EnumNumber {
+ return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use SortOrder.Descriptor instead.
+func (SortOrder) EnumDescriptor() ([]byte, []int) {
+ return file_teleport_inventory_v1_inventory_service_proto_rawDescGZIP(), []int{2}
+}
+
+// ListUnifiedInstancesRequest is the request for listing instances.
+type ListUnifiedInstancesRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // page_size is the size of the page to return.
+ PageSize int32 `protobuf:"varint,1,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
+ // page_token is the next_page_token value returned from a previous ListUnifiedInstances request, if any.
+ PageToken string `protobuf:"bytes,2,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"`
+ // filter specifies optional search criteria to limit which instances should be returned.
+ Filter *ListUnifiedInstancesFilter `protobuf:"bytes,3,opt,name=filter,proto3" json:"filter,omitempty"`
+ // sort specifies the sort mode for the results. Defaults to UNIFIED_INSTANCE_SORT_NAME.
+ Sort UnifiedInstanceSort `protobuf:"varint,4,opt,name=sort,proto3,enum=teleport.inventory.v1.UnifiedInstanceSort" json:"sort,omitempty"`
+ // order specifies the sort order for the results. Defaults to SORT_ORDER_ASCENDING.
+ Order SortOrder `protobuf:"varint,5,opt,name=order,proto3,enum=teleport.inventory.v1.SortOrder" json:"order,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *ListUnifiedInstancesRequest) Reset() {
+ *x = ListUnifiedInstancesRequest{}
+ mi := &file_teleport_inventory_v1_inventory_service_proto_msgTypes[0]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *ListUnifiedInstancesRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ListUnifiedInstancesRequest) ProtoMessage() {}
+
+func (x *ListUnifiedInstancesRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_teleport_inventory_v1_inventory_service_proto_msgTypes[0]
+ 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 ListUnifiedInstancesRequest.ProtoReflect.Descriptor instead.
+func (*ListUnifiedInstancesRequest) Descriptor() ([]byte, []int) {
+ return file_teleport_inventory_v1_inventory_service_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *ListUnifiedInstancesRequest) GetPageSize() int32 {
+ if x != nil {
+ return x.PageSize
+ }
+ return 0
+}
+
+func (x *ListUnifiedInstancesRequest) GetPageToken() string {
+ if x != nil {
+ return x.PageToken
+ }
+ return ""
+}
+
+func (x *ListUnifiedInstancesRequest) GetFilter() *ListUnifiedInstancesFilter {
+ if x != nil {
+ return x.Filter
+ }
+ return nil
+}
+
+func (x *ListUnifiedInstancesRequest) GetSort() UnifiedInstanceSort {
+ if x != nil {
+ return x.Sort
+ }
+ return UnifiedInstanceSort_UNIFIED_INSTANCE_SORT_UNSPECIFIED
+}
+
+func (x *ListUnifiedInstancesRequest) GetOrder() SortOrder {
+ if x != nil {
+ return x.Order
+ }
+ return SortOrder_SORT_ORDER_UNSPECIFIED
+}
+
+// ListUnifiedInstancesResponse is the response from listing instances.
+type ListUnifiedInstancesResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // items is the list of instances (instances or bot instances) returned.
+ Items []*UnifiedInstanceItem `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"`
+ // next_page_token contains the next page token to use as the start key for the next page of instances.
+ NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *ListUnifiedInstancesResponse) Reset() {
+ *x = ListUnifiedInstancesResponse{}
+ mi := &file_teleport_inventory_v1_inventory_service_proto_msgTypes[1]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *ListUnifiedInstancesResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ListUnifiedInstancesResponse) ProtoMessage() {}
+
+func (x *ListUnifiedInstancesResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_teleport_inventory_v1_inventory_service_proto_msgTypes[1]
+ 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 ListUnifiedInstancesResponse.ProtoReflect.Descriptor instead.
+func (*ListUnifiedInstancesResponse) Descriptor() ([]byte, []int) {
+ return file_teleport_inventory_v1_inventory_service_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *ListUnifiedInstancesResponse) GetItems() []*UnifiedInstanceItem {
+ if x != nil {
+ return x.Items
+ }
+ return nil
+}
+
+func (x *ListUnifiedInstancesResponse) GetNextPageToken() string {
+ if x != nil {
+ return x.NextPageToken
+ }
+ return ""
+}
+
+// ListUnifiedInstancesFilter provides a mechanism to refine ListUnifiedInstances results.
+type ListUnifiedInstancesFilter struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // search is a basic string search query which will filter results by name (hostname for instances, bot name for bot instances).
+ Search string `protobuf:"bytes,1,opt,name=search,proto3" json:"search,omitempty"`
+ // predicate_expression is a predicate expression used to match against resource field values.
+ PredicateExpression string `protobuf:"bytes,2,opt,name=predicate_expression,json=predicateExpression,proto3" json:"predicate_expression,omitempty"`
+ // instance_types is the types of instances to return. If omitted, both instances and bot instances will be returned.
+ InstanceTypes []InstanceType `protobuf:"varint,3,rep,packed,name=instance_types,json=instanceTypes,proto3,enum=teleport.inventory.v1.InstanceType" json:"instance_types,omitempty"`
+ // services is the list of services (system roles) to filter instances by. An instance must have one or more of the services here to be returned.
+ // The services filter is ignored for bot instances.
+ Services []string `protobuf:"bytes,4,rep,name=services,proto3" json:"services,omitempty"`
+ // updater_groups is the list of updater groups to filter instances by.
+ UpdaterGroups []string `protobuf:"bytes,5,rep,name=updater_groups,json=updaterGroups,proto3" json:"updater_groups,omitempty"`
+ // upgraders is the list of upgraders to filter instances by.
+ Upgraders []string `protobuf:"bytes,6,rep,name=upgraders,proto3" json:"upgraders,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *ListUnifiedInstancesFilter) Reset() {
+ *x = ListUnifiedInstancesFilter{}
+ mi := &file_teleport_inventory_v1_inventory_service_proto_msgTypes[2]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *ListUnifiedInstancesFilter) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ListUnifiedInstancesFilter) ProtoMessage() {}
+
+func (x *ListUnifiedInstancesFilter) ProtoReflect() protoreflect.Message {
+ mi := &file_teleport_inventory_v1_inventory_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 ListUnifiedInstancesFilter.ProtoReflect.Descriptor instead.
+func (*ListUnifiedInstancesFilter) Descriptor() ([]byte, []int) {
+ return file_teleport_inventory_v1_inventory_service_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *ListUnifiedInstancesFilter) GetSearch() string {
+ if x != nil {
+ return x.Search
+ }
+ return ""
+}
+
+func (x *ListUnifiedInstancesFilter) GetPredicateExpression() string {
+ if x != nil {
+ return x.PredicateExpression
+ }
+ return ""
+}
+
+func (x *ListUnifiedInstancesFilter) GetInstanceTypes() []InstanceType {
+ if x != nil {
+ return x.InstanceTypes
+ }
+ return nil
+}
+
+func (x *ListUnifiedInstancesFilter) GetServices() []string {
+ if x != nil {
+ return x.Services
+ }
+ return nil
+}
+
+func (x *ListUnifiedInstancesFilter) GetUpdaterGroups() []string {
+ if x != nil {
+ return x.UpdaterGroups
+ }
+ return nil
+}
+
+func (x *ListUnifiedInstancesFilter) GetUpgraders() []string {
+ if x != nil {
+ return x.Upgraders
+ }
+ return nil
+}
+
+// UnifiedInstanceItem represents either a teleport or bot instance.
+type UnifiedInstanceItem struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // Types that are valid to be assigned to Item:
+ //
+ // *UnifiedInstanceItem_Instance
+ // *UnifiedInstanceItem_BotInstance
+ Item isUnifiedInstanceItem_Item `protobuf_oneof:"item"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *UnifiedInstanceItem) Reset() {
+ *x = UnifiedInstanceItem{}
+ mi := &file_teleport_inventory_v1_inventory_service_proto_msgTypes[3]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *UnifiedInstanceItem) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UnifiedInstanceItem) ProtoMessage() {}
+
+func (x *UnifiedInstanceItem) ProtoReflect() protoreflect.Message {
+ mi := &file_teleport_inventory_v1_inventory_service_proto_msgTypes[3]
+ 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 UnifiedInstanceItem.ProtoReflect.Descriptor instead.
+func (*UnifiedInstanceItem) Descriptor() ([]byte, []int) {
+ return file_teleport_inventory_v1_inventory_service_proto_rawDescGZIP(), []int{3}
+}
+
+func (x *UnifiedInstanceItem) GetItem() isUnifiedInstanceItem_Item {
+ if x != nil {
+ return x.Item
+ }
+ return nil
+}
+
+func (x *UnifiedInstanceItem) GetInstance() *types.InstanceV1 {
+ if x != nil {
+ if x, ok := x.Item.(*UnifiedInstanceItem_Instance); ok {
+ return x.Instance
+ }
+ }
+ return nil
+}
+
+func (x *UnifiedInstanceItem) GetBotInstance() *v1.BotInstance {
+ if x != nil {
+ if x, ok := x.Item.(*UnifiedInstanceItem_BotInstance); ok {
+ return x.BotInstance
+ }
+ }
+ return nil
+}
+
+type isUnifiedInstanceItem_Item interface {
+ isUnifiedInstanceItem_Item()
+}
+
+type UnifiedInstanceItem_Instance struct {
+ // instance is the canonical instance type from the Instance heartbeat system.
+ Instance *types.InstanceV1 `protobuf:"bytes,1,opt,name=instance,proto3,oneof"`
+}
+
+type UnifiedInstanceItem_BotInstance struct {
+ // bot_instance is the canonical bot instance type.
+ BotInstance *v1.BotInstance `protobuf:"bytes,2,opt,name=bot_instance,json=botInstance,proto3,oneof"`
+}
+
+func (*UnifiedInstanceItem_Instance) isUnifiedInstanceItem_Item() {}
+
+func (*UnifiedInstanceItem_BotInstance) isUnifiedInstanceItem_Item() {}
+
+var File_teleport_inventory_v1_inventory_service_proto protoreflect.FileDescriptor
+
+const file_teleport_inventory_v1_inventory_service_proto_rawDesc = "" +
+ "\n" +
+ "-teleport/inventory/v1/inventory_service.proto\x12\x15teleport.inventory.v1\x1a!teleport/legacy/types/types.proto\x1a(teleport/machineid/v1/bot_instance.proto\"\x9c\x02\n" +
+ "\x1bListUnifiedInstancesRequest\x12\x1b\n" +
+ "\tpage_size\x18\x01 \x01(\x05R\bpageSize\x12\x1d\n" +
+ "\n" +
+ "page_token\x18\x02 \x01(\tR\tpageToken\x12I\n" +
+ "\x06filter\x18\x03 \x01(\v21.teleport.inventory.v1.ListUnifiedInstancesFilterR\x06filter\x12>\n" +
+ "\x04sort\x18\x04 \x01(\x0e2*.teleport.inventory.v1.UnifiedInstanceSortR\x04sort\x126\n" +
+ "\x05order\x18\x05 \x01(\x0e2 .teleport.inventory.v1.SortOrderR\x05order\"\x88\x01\n" +
+ "\x1cListUnifiedInstancesResponse\x12@\n" +
+ "\x05items\x18\x01 \x03(\v2*.teleport.inventory.v1.UnifiedInstanceItemR\x05items\x12&\n" +
+ "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"\x94\x02\n" +
+ "\x1aListUnifiedInstancesFilter\x12\x16\n" +
+ "\x06search\x18\x01 \x01(\tR\x06search\x121\n" +
+ "\x14predicate_expression\x18\x02 \x01(\tR\x13predicateExpression\x12J\n" +
+ "\x0einstance_types\x18\x03 \x03(\x0e2#.teleport.inventory.v1.InstanceTypeR\rinstanceTypes\x12\x1a\n" +
+ "\bservices\x18\x04 \x03(\tR\bservices\x12%\n" +
+ "\x0eupdater_groups\x18\x05 \x03(\tR\rupdaterGroups\x12\x1c\n" +
+ "\tupgraders\x18\x06 \x03(\tR\tupgraders\"\x97\x01\n" +
+ "\x13UnifiedInstanceItem\x12/\n" +
+ "\binstance\x18\x01 \x01(\v2\x11.types.InstanceV1H\x00R\binstance\x12G\n" +
+ "\fbot_instance\x18\x02 \x01(\v2\".teleport.machineid.v1.BotInstanceH\x00R\vbotInstanceB\x06\n" +
+ "\x04item*i\n" +
+ "\fInstanceType\x12\x1d\n" +
+ "\x19INSTANCE_TYPE_UNSPECIFIED\x10\x00\x12\x1a\n" +
+ "\x16INSTANCE_TYPE_INSTANCE\x10\x01\x12\x1e\n" +
+ "\x1aINSTANCE_TYPE_BOT_INSTANCE\x10\x02*\x9f\x01\n" +
+ "\x13UnifiedInstanceSort\x12%\n" +
+ "!UNIFIED_INSTANCE_SORT_UNSPECIFIED\x10\x00\x12\x1e\n" +
+ "\x1aUNIFIED_INSTANCE_SORT_NAME\x10\x01\x12\x1e\n" +
+ "\x1aUNIFIED_INSTANCE_SORT_TYPE\x10\x02\x12!\n" +
+ "\x1dUNIFIED_INSTANCE_SORT_VERSION\x10\x03*\\\n" +
+ "\tSortOrder\x12\x1a\n" +
+ "\x16SORT_ORDER_UNSPECIFIED\x10\x00\x12\x18\n" +
+ "\x14SORT_ORDER_ASCENDING\x10\x01\x12\x19\n" +
+ "\x15SORT_ORDER_DESCENDING\x10\x022\x93\x01\n" +
+ "\x10InventoryService\x12\x7f\n" +
+ "\x14ListUnifiedInstances\x122.teleport.inventory.v1.ListUnifiedInstancesRequest\x1a3.teleport.inventory.v1.ListUnifiedInstancesResponseBVZTgithub.com/gravitational/teleport/api/gen/proto/go/teleport/inventory/v1;inventoryv1b\x06proto3"
+
+var (
+ file_teleport_inventory_v1_inventory_service_proto_rawDescOnce sync.Once
+ file_teleport_inventory_v1_inventory_service_proto_rawDescData []byte
+)
+
+func file_teleport_inventory_v1_inventory_service_proto_rawDescGZIP() []byte {
+ file_teleport_inventory_v1_inventory_service_proto_rawDescOnce.Do(func() {
+ file_teleport_inventory_v1_inventory_service_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_teleport_inventory_v1_inventory_service_proto_rawDesc), len(file_teleport_inventory_v1_inventory_service_proto_rawDesc)))
+ })
+ return file_teleport_inventory_v1_inventory_service_proto_rawDescData
+}
+
+var file_teleport_inventory_v1_inventory_service_proto_enumTypes = make([]protoimpl.EnumInfo, 3)
+var file_teleport_inventory_v1_inventory_service_proto_msgTypes = make([]protoimpl.MessageInfo, 4)
+var file_teleport_inventory_v1_inventory_service_proto_goTypes = []any{
+ (InstanceType)(0), // 0: teleport.inventory.v1.InstanceType
+ (UnifiedInstanceSort)(0), // 1: teleport.inventory.v1.UnifiedInstanceSort
+ (SortOrder)(0), // 2: teleport.inventory.v1.SortOrder
+ (*ListUnifiedInstancesRequest)(nil), // 3: teleport.inventory.v1.ListUnifiedInstancesRequest
+ (*ListUnifiedInstancesResponse)(nil), // 4: teleport.inventory.v1.ListUnifiedInstancesResponse
+ (*ListUnifiedInstancesFilter)(nil), // 5: teleport.inventory.v1.ListUnifiedInstancesFilter
+ (*UnifiedInstanceItem)(nil), // 6: teleport.inventory.v1.UnifiedInstanceItem
+ (*types.InstanceV1)(nil), // 7: types.InstanceV1
+ (*v1.BotInstance)(nil), // 8: teleport.machineid.v1.BotInstance
+}
+var file_teleport_inventory_v1_inventory_service_proto_depIdxs = []int32{
+ 5, // 0: teleport.inventory.v1.ListUnifiedInstancesRequest.filter:type_name -> teleport.inventory.v1.ListUnifiedInstancesFilter
+ 1, // 1: teleport.inventory.v1.ListUnifiedInstancesRequest.sort:type_name -> teleport.inventory.v1.UnifiedInstanceSort
+ 2, // 2: teleport.inventory.v1.ListUnifiedInstancesRequest.order:type_name -> teleport.inventory.v1.SortOrder
+ 6, // 3: teleport.inventory.v1.ListUnifiedInstancesResponse.items:type_name -> teleport.inventory.v1.UnifiedInstanceItem
+ 0, // 4: teleport.inventory.v1.ListUnifiedInstancesFilter.instance_types:type_name -> teleport.inventory.v1.InstanceType
+ 7, // 5: teleport.inventory.v1.UnifiedInstanceItem.instance:type_name -> types.InstanceV1
+ 8, // 6: teleport.inventory.v1.UnifiedInstanceItem.bot_instance:type_name -> teleport.machineid.v1.BotInstance
+ 3, // 7: teleport.inventory.v1.InventoryService.ListUnifiedInstances:input_type -> teleport.inventory.v1.ListUnifiedInstancesRequest
+ 4, // 8: teleport.inventory.v1.InventoryService.ListUnifiedInstances:output_type -> teleport.inventory.v1.ListUnifiedInstancesResponse
+ 8, // [8:9] is the sub-list for method output_type
+ 7, // [7:8] is the sub-list for method input_type
+ 7, // [7:7] is the sub-list for extension type_name
+ 7, // [7:7] is the sub-list for extension extendee
+ 0, // [0:7] is the sub-list for field type_name
+}
+
+func init() { file_teleport_inventory_v1_inventory_service_proto_init() }
+func file_teleport_inventory_v1_inventory_service_proto_init() {
+ if File_teleport_inventory_v1_inventory_service_proto != nil {
+ return
+ }
+ file_teleport_inventory_v1_inventory_service_proto_msgTypes[3].OneofWrappers = []any{
+ (*UnifiedInstanceItem_Instance)(nil),
+ (*UnifiedInstanceItem_BotInstance)(nil),
+ }
+ type x struct{}
+ out := protoimpl.TypeBuilder{
+ File: protoimpl.DescBuilder{
+ GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+ RawDescriptor: unsafe.Slice(unsafe.StringData(file_teleport_inventory_v1_inventory_service_proto_rawDesc), len(file_teleport_inventory_v1_inventory_service_proto_rawDesc)),
+ NumEnums: 3,
+ NumMessages: 4,
+ NumExtensions: 0,
+ NumServices: 1,
+ },
+ GoTypes: file_teleport_inventory_v1_inventory_service_proto_goTypes,
+ DependencyIndexes: file_teleport_inventory_v1_inventory_service_proto_depIdxs,
+ EnumInfos: file_teleport_inventory_v1_inventory_service_proto_enumTypes,
+ MessageInfos: file_teleport_inventory_v1_inventory_service_proto_msgTypes,
+ }.Build()
+ File_teleport_inventory_v1_inventory_service_proto = out.File
+ file_teleport_inventory_v1_inventory_service_proto_goTypes = nil
+ file_teleport_inventory_v1_inventory_service_proto_depIdxs = nil
+}
diff --git a/api/gen/proto/go/teleport/inventory/v1/inventory_service_grpc.pb.go b/api/gen/proto/go/teleport/inventory/v1/inventory_service_grpc.pb.go
new file mode 100644
index 0000000000000..9195022ac821f
--- /dev/null
+++ b/api/gen/proto/go/teleport/inventory/v1/inventory_service_grpc.pb.go
@@ -0,0 +1,143 @@
+// 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 .
+
+// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
+// versions:
+// - protoc-gen-go-grpc v1.5.1
+// - protoc (unknown)
+// source: teleport/inventory/v1/inventory_service.proto
+
+package inventoryv1
+
+import (
+ context "context"
+ grpc "google.golang.org/grpc"
+ codes "google.golang.org/grpc/codes"
+ status "google.golang.org/grpc/status"
+)
+
+// This is a compile-time assertion to ensure that this generated file
+// is compatible with the grpc package it is being compiled against.
+// Requires gRPC-Go v1.64.0 or later.
+const _ = grpc.SupportPackageIsVersion9
+
+const (
+ InventoryService_ListUnifiedInstances_FullMethodName = "/teleport.inventory.v1.InventoryService/ListUnifiedInstances"
+)
+
+// InventoryServiceClient is the client API for InventoryService service.
+//
+// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
+//
+// InventoryService provides methods to manage and query the inventory of Teleport instances and bot instances.
+type InventoryServiceClient interface {
+ // ListUnifiedInstances returns a page of teleport instances and bot instances.
+ ListUnifiedInstances(ctx context.Context, in *ListUnifiedInstancesRequest, opts ...grpc.CallOption) (*ListUnifiedInstancesResponse, error)
+}
+
+type inventoryServiceClient struct {
+ cc grpc.ClientConnInterface
+}
+
+func NewInventoryServiceClient(cc grpc.ClientConnInterface) InventoryServiceClient {
+ return &inventoryServiceClient{cc}
+}
+
+func (c *inventoryServiceClient) ListUnifiedInstances(ctx context.Context, in *ListUnifiedInstancesRequest, opts ...grpc.CallOption) (*ListUnifiedInstancesResponse, error) {
+ cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+ out := new(ListUnifiedInstancesResponse)
+ err := c.cc.Invoke(ctx, InventoryService_ListUnifiedInstances_FullMethodName, in, out, cOpts...)
+ if err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+// InventoryServiceServer is the server API for InventoryService service.
+// All implementations must embed UnimplementedInventoryServiceServer
+// for forward compatibility.
+//
+// InventoryService provides methods to manage and query the inventory of Teleport instances and bot instances.
+type InventoryServiceServer interface {
+ // ListUnifiedInstances returns a page of teleport instances and bot instances.
+ ListUnifiedInstances(context.Context, *ListUnifiedInstancesRequest) (*ListUnifiedInstancesResponse, error)
+ mustEmbedUnimplementedInventoryServiceServer()
+}
+
+// UnimplementedInventoryServiceServer must be embedded to have
+// forward compatible implementations.
+//
+// NOTE: this should be embedded by value instead of pointer to avoid a nil
+// pointer dereference when methods are called.
+type UnimplementedInventoryServiceServer struct{}
+
+func (UnimplementedInventoryServiceServer) ListUnifiedInstances(context.Context, *ListUnifiedInstancesRequest) (*ListUnifiedInstancesResponse, error) {
+ return nil, status.Errorf(codes.Unimplemented, "method ListUnifiedInstances not implemented")
+}
+func (UnimplementedInventoryServiceServer) mustEmbedUnimplementedInventoryServiceServer() {}
+func (UnimplementedInventoryServiceServer) testEmbeddedByValue() {}
+
+// UnsafeInventoryServiceServer may be embedded to opt out of forward compatibility for this service.
+// Use of this interface is not recommended, as added methods to InventoryServiceServer will
+// result in compilation errors.
+type UnsafeInventoryServiceServer interface {
+ mustEmbedUnimplementedInventoryServiceServer()
+}
+
+func RegisterInventoryServiceServer(s grpc.ServiceRegistrar, srv InventoryServiceServer) {
+ // If the following call pancis, it indicates UnimplementedInventoryServiceServer was
+ // embedded by pointer and is nil. This will cause panics if an
+ // unimplemented method is ever invoked, so we test this at initialization
+ // time to prevent it from happening at runtime later due to I/O.
+ if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
+ t.testEmbeddedByValue()
+ }
+ s.RegisterService(&InventoryService_ServiceDesc, srv)
+}
+
+func _InventoryService_ListUnifiedInstances_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+ in := new(ListUnifiedInstancesRequest)
+ if err := dec(in); err != nil {
+ return nil, err
+ }
+ if interceptor == nil {
+ return srv.(InventoryServiceServer).ListUnifiedInstances(ctx, in)
+ }
+ info := &grpc.UnaryServerInfo{
+ Server: srv,
+ FullMethod: InventoryService_ListUnifiedInstances_FullMethodName,
+ }
+ handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+ return srv.(InventoryServiceServer).ListUnifiedInstances(ctx, req.(*ListUnifiedInstancesRequest))
+ }
+ return interceptor(ctx, in, info, handler)
+}
+
+// InventoryService_ServiceDesc is the grpc.ServiceDesc for InventoryService service.
+// It's only intended for direct use with grpc.RegisterService,
+// and not to be introspected or modified (even as a copy)
+var InventoryService_ServiceDesc = grpc.ServiceDesc{
+ ServiceName: "teleport.inventory.v1.InventoryService",
+ HandlerType: (*InventoryServiceServer)(nil),
+ Methods: []grpc.MethodDesc{
+ {
+ MethodName: "ListUnifiedInstances",
+ Handler: _InventoryService_ListUnifiedInstances_Handler,
+ },
+ },
+ Streams: []grpc.StreamDesc{},
+ Metadata: "teleport/inventory/v1/inventory_service.proto",
+}
diff --git a/api/proto/teleport/inventory/v1/inventory_service.proto b/api/proto/teleport/inventory/v1/inventory_service.proto
new file mode 100644
index 0000000000000..9d456f22bf4dc
--- /dev/null
+++ b/api/proto/teleport/inventory/v1/inventory_service.proto
@@ -0,0 +1,110 @@
+// 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 .
+
+syntax = "proto3";
+
+package teleport.inventory.v1;
+
+import "teleport/legacy/types/types.proto";
+import "teleport/machineid/v1/bot_instance.proto";
+
+option go_package = "github.com/gravitational/teleport/api/gen/proto/go/teleport/inventory/v1;inventoryv1";
+
+// InventoryService provides methods to manage and query the inventory of Teleport instances and bot instances.
+service InventoryService {
+ // ListUnifiedInstances returns a page of teleport instances and bot instances.
+ rpc ListUnifiedInstances(ListUnifiedInstancesRequest) returns (ListUnifiedInstancesResponse);
+}
+
+// InstanceType represents the type of instance.
+enum InstanceType {
+ INSTANCE_TYPE_UNSPECIFIED = 0;
+ // INSTANCE_TYPE_INSTANCE is a Teleport instance.
+ INSTANCE_TYPE_INSTANCE = 1;
+ // INSTANCE_TYPE_BOT_INSTANCE is a bot instance.
+ INSTANCE_TYPE_BOT_INSTANCE = 2;
+}
+
+// UnifiedInstanceSort specifies the sort mode for listing unified instances.
+// If a sorting criteria for multiple instances are equal (eg. 2 instances are the same version), the secondary
+// sorting will always be by name.
+enum UnifiedInstanceSort {
+ UNIFIED_INSTANCE_SORT_UNSPECIFIED = 0;
+ // UNIFIED_INSTANCE_SORT_NAME sorts by display name (hostname for instances, bot name for bot instances).
+ UNIFIED_INSTANCE_SORT_NAME = 1;
+ // UNIFIED_INSTANCE_SORT_TYPE sorts by instance type (instance vs bot_instance).
+ UNIFIED_INSTANCE_SORT_TYPE = 2;
+ // UNIFIED_INSTANCE_SORT_VERSION sorts by version.
+ UNIFIED_INSTANCE_SORT_VERSION = 3;
+}
+
+// SortOrder specifies the sort order for listing unified instances.
+enum SortOrder {
+ SORT_ORDER_UNSPECIFIED = 0;
+ // SORT_ORDER_ASCENDING sorts in ascending order.
+ SORT_ORDER_ASCENDING = 1;
+ // SORT_ORDER_DESCENDING sorts in descending order.
+ SORT_ORDER_DESCENDING = 2;
+}
+
+// ListUnifiedInstancesRequest is the request for listing instances.
+message ListUnifiedInstancesRequest {
+ // page_size is the size of the page to return.
+ int32 page_size = 1;
+ // page_token is the next_page_token value returned from a previous ListUnifiedInstances request, if any.
+ string page_token = 2;
+ // filter specifies optional search criteria to limit which instances should be returned.
+ ListUnifiedInstancesFilter filter = 3;
+ // sort specifies the sort mode for the results. Defaults to UNIFIED_INSTANCE_SORT_NAME.
+ UnifiedInstanceSort sort = 4;
+ // order specifies the sort order for the results. Defaults to SORT_ORDER_ASCENDING.
+ SortOrder order = 5;
+}
+
+// ListUnifiedInstancesResponse is the response from listing instances.
+message ListUnifiedInstancesResponse {
+ // items is the list of instances (instances or bot instances) returned.
+ repeated UnifiedInstanceItem items = 1;
+ // next_page_token contains the next page token to use as the start key for the next page of instances.
+ string next_page_token = 2;
+}
+
+// ListUnifiedInstancesFilter provides a mechanism to refine ListUnifiedInstances results.
+message ListUnifiedInstancesFilter {
+ // search is a basic string search query which will filter results by name (hostname for instances, bot name for bot instances).
+ string search = 1;
+ // predicate_expression is a predicate expression used to match against resource field values.
+ string predicate_expression = 2;
+ // instance_types is the types of instances to return. If omitted, both instances and bot instances will be returned.
+ repeated InstanceType instance_types = 3;
+ // services is the list of services (system roles) to filter instances by. An instance must have one or more of the services here to be returned.
+ // The services filter is ignored for bot instances.
+ repeated string services = 4;
+ // updater_groups is the list of updater groups to filter instances by.
+ repeated string updater_groups = 5;
+ // upgraders is the list of upgraders to filter instances by.
+ repeated string upgraders = 6;
+}
+
+// UnifiedInstanceItem represents either a teleport or bot instance.
+message UnifiedInstanceItem {
+ oneof item {
+ // instance is the canonical instance type from the Instance heartbeat system.
+ types.InstanceV1 instance = 1;
+ // bot_instance is the canonical bot instance type.
+ teleport.machineid.v1.BotInstance bot_instance = 2;
+ }
+}
diff --git a/build.assets/tooling/go.sum b/build.assets/tooling/go.sum
index 96df0fd2ea251..6dec86aee6e47 100644
--- a/build.assets/tooling/go.sum
+++ b/build.assets/tooling/go.sum
@@ -1016,6 +1016,8 @@ oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc=
oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o=
pluginrpc.com/pluginrpc v0.5.0 h1:tOQj2D35hOmvHyPu8e7ohW2/QvAnEtKscy2IJYWQ2yo=
pluginrpc.com/pluginrpc v0.5.0/go.mod h1:UNWZ941hcVAoOZUn8YZsMmOZBzbUjQa3XMns8RQLp9o=
+rsc.io/ordered v1.1.1 h1:1kZM6RkTmceJgsFH/8DLQvkCVEYomVDJfBRLT595Uak=
+rsc.io/ordered v1.1.1/go.mod h1:evAi8739bWVBRG9aaufsjVc202+6okf8u2QeVL84BCM=
sigs.k8s.io/controller-runtime v0.20.4 h1:X3c+Odnxz+iPTRobG4tp092+CvBU9UK0t/bRf+n0DGU=
sigs.k8s.io/controller-runtime v0.20.4/go.mod h1:xg2XB0K5ShQzAgsoujxuKN4LNXR2LfwwHsPj7Iaw+XY=
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8=
diff --git a/go.mod b/go.mod
index 1b9afddf82ebb..e573023127143 100644
--- a/go.mod
+++ b/go.mod
@@ -282,6 +282,7 @@ require (
k8s.io/kubectl v0.33.3
k8s.io/metrics v0.33.3
k8s.io/utils v0.0.0-20241210054802-24370beab758
+ rsc.io/ordered v1.1.1
sigs.k8s.io/controller-runtime v0.20.4
sigs.k8s.io/controller-tools v0.17.1
sigs.k8s.io/yaml v1.6.0
diff --git a/go.sum b/go.sum
index 25f7102fb0aa7..f73eb2aacb343 100644
--- a/go.sum
+++ b/go.sum
@@ -3299,6 +3299,8 @@ mvdan.cc/sh/v3 v3.7.0/go.mod h1:K2gwkaesF/D7av7Kxl0HbF5kGOd2ArupNTX3X44+8l8=
oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc=
oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
+rsc.io/ordered v1.1.1 h1:1kZM6RkTmceJgsFH/8DLQvkCVEYomVDJfBRLT595Uak=
+rsc.io/ordered v1.1.1/go.mod h1:evAi8739bWVBRG9aaufsjVc202+6okf8u2QeVL84BCM=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
diff --git a/integrations/event-handler/go.mod b/integrations/event-handler/go.mod
index 3a3d11181f35d..f79dd34e64be1 100644
--- a/integrations/event-handler/go.mod
+++ b/integrations/event-handler/go.mod
@@ -384,6 +384,7 @@ require (
k8s.io/utils v0.0.0-20241210054802-24370beab758 // indirect
mvdan.cc/sh/v3 v3.7.0 // indirect
oras.land/oras-go/v2 v2.6.0 // indirect
+ rsc.io/ordered v1.1.1 // indirect
sigs.k8s.io/controller-runtime v0.20.4 // indirect
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect
sigs.k8s.io/kustomize/api v0.19.0 // indirect
diff --git a/integrations/event-handler/go.sum b/integrations/event-handler/go.sum
index f58a1141fcb25..c7393b4d9a6fb 100644
--- a/integrations/event-handler/go.sum
+++ b/integrations/event-handler/go.sum
@@ -1150,6 +1150,8 @@ mvdan.cc/sh/v3 v3.7.0 h1:lSTjdP/1xsddtaKfGg7Myu7DnlHItd3/M2tomOcNNBg=
mvdan.cc/sh/v3 v3.7.0/go.mod h1:K2gwkaesF/D7av7Kxl0HbF5kGOd2ArupNTX3X44+8l8=
oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc=
oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o=
+rsc.io/ordered v1.1.1 h1:1kZM6RkTmceJgsFH/8DLQvkCVEYomVDJfBRLT595Uak=
+rsc.io/ordered v1.1.1/go.mod h1:evAi8739bWVBRG9aaufsjVc202+6okf8u2QeVL84BCM=
sigs.k8s.io/controller-runtime v0.20.4 h1:X3c+Odnxz+iPTRobG4tp092+CvBU9UK0t/bRf+n0DGU=
sigs.k8s.io/controller-runtime v0.20.4/go.mod h1:xg2XB0K5ShQzAgsoujxuKN4LNXR2LfwwHsPj7Iaw+XY=
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8=
diff --git a/integrations/terraform-mwi/go.mod b/integrations/terraform-mwi/go.mod
index 614d2990a2d69..581cec4acc2a7 100644
--- a/integrations/terraform-mwi/go.mod
+++ b/integrations/terraform-mwi/go.mod
@@ -524,6 +524,7 @@ require (
k8s.io/utils v0.0.0-20241210054802-24370beab758 // indirect
mvdan.cc/sh/v3 v3.7.0 // indirect
oras.land/oras-go/v2 v2.6.0 // indirect
+ rsc.io/ordered v1.1.1 // indirect
sigs.k8s.io/controller-runtime v0.21.0 // indirect
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect
sigs.k8s.io/kustomize/api v0.19.0 // indirect
diff --git a/integrations/terraform-mwi/go.sum b/integrations/terraform-mwi/go.sum
index 0bcd29dc84a20..4cbe0119f0324 100644
--- a/integrations/terraform-mwi/go.sum
+++ b/integrations/terraform-mwi/go.sum
@@ -1775,6 +1775,8 @@ mvdan.cc/sh/v3 v3.7.0 h1:lSTjdP/1xsddtaKfGg7Myu7DnlHItd3/M2tomOcNNBg=
mvdan.cc/sh/v3 v3.7.0/go.mod h1:K2gwkaesF/D7av7Kxl0HbF5kGOd2ArupNTX3X44+8l8=
oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc=
oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o=
+rsc.io/ordered v1.1.1 h1:1kZM6RkTmceJgsFH/8DLQvkCVEYomVDJfBRLT595Uak=
+rsc.io/ordered v1.1.1/go.mod h1:evAi8739bWVBRG9aaufsjVc202+6okf8u2QeVL84BCM=
sigs.k8s.io/controller-runtime v0.21.0 h1:CYfjpEuicjUecRk+KAeyYh+ouUBn4llGyDYytIGcJS8=
sigs.k8s.io/controller-runtime v0.21.0/go.mod h1:OSg14+F65eWqIu4DceX7k/+QRAbTTvxeQSNSOQpukWM=
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8=
diff --git a/integrations/terraform/go.mod b/integrations/terraform/go.mod
index 3a5d377d028b3..e52cece5cf1c6 100644
--- a/integrations/terraform/go.mod
+++ b/integrations/terraform/go.mod
@@ -523,6 +523,7 @@ require (
k8s.io/utils v0.0.0-20241210054802-24370beab758 // indirect
mvdan.cc/sh/v3 v3.7.0 // indirect
oras.land/oras-go/v2 v2.6.0 // indirect
+ rsc.io/ordered v1.1.1 // indirect
sigs.k8s.io/controller-runtime v0.20.4 // indirect
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect
sigs.k8s.io/kustomize/api v0.19.0 // indirect
diff --git a/integrations/terraform/go.sum b/integrations/terraform/go.sum
index 4485725c3dd00..2bcedd2bae6ce 100644
--- a/integrations/terraform/go.sum
+++ b/integrations/terraform/go.sum
@@ -2128,6 +2128,8 @@ mvdan.cc/sh/v3 v3.7.0/go.mod h1:K2gwkaesF/D7av7Kxl0HbF5kGOd2ArupNTX3X44+8l8=
oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc=
oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
+rsc.io/ordered v1.1.1 h1:1kZM6RkTmceJgsFH/8DLQvkCVEYomVDJfBRLT595Uak=
+rsc.io/ordered v1.1.1/go.mod h1:evAi8739bWVBRG9aaufsjVc202+6okf8u2QeVL84BCM=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
sigs.k8s.io/controller-runtime v0.20.4 h1:X3c+Odnxz+iPTRobG4tp092+CvBU9UK0t/bRf+n0DGU=
diff --git a/lib/auth/auth.go b/lib/auth/auth.go
index 8ba586e518c97..aabb19843a674 100644
--- a/lib/auth/auth.go
+++ b/lib/auth/auth.go
@@ -106,6 +106,7 @@ import (
"github.com/gravitational/teleport/lib/backend"
"github.com/gravitational/teleport/lib/boundkeypair"
"github.com/gravitational/teleport/lib/cache"
+ inventorycache "github.com/gravitational/teleport/lib/cache/inventory"
"github.com/gravitational/teleport/lib/cloud/awsconfig"
"github.com/gravitational/teleport/lib/cryptosuites"
"github.com/gravitational/teleport/lib/decision"
@@ -1358,6 +1359,9 @@ type Server struct {
// GlobalNotificationCache is a cache of global notifications.
GlobalNotificationCache *services.GlobalNotificationCache
+ // inventoryCache is a cache of unified instances (teleport instances and bot instances).
+ inventoryCache *inventorycache.InventoryCache
+
// workloadIdentityX509CAOverrideGetter is a getter for CA overrides for
// SPIFFE X.509 certificate issuance. Optional, set in enterprise code.
workloadIdentityX509CAOverrideGetter services.WorkloadIdentityX509CAOverrideGetter
@@ -1684,6 +1688,20 @@ func (a *Server) SetGlobalNotificationCache(globalNotificationCache *services.Gl
a.GlobalNotificationCache = globalNotificationCache
}
+// SetInventoryCache sets the inventory cache.
+func (a *Server) SetInventoryCache(inventoryCache *inventorycache.InventoryCache) {
+ a.lock.Lock()
+ defer a.lock.Unlock()
+ a.inventoryCache = inventoryCache
+}
+
+// GetInventoryCache returns the inventory cache.
+func (a *Server) GetInventoryCache() *inventorycache.InventoryCache {
+ a.lock.RLock()
+ defer a.lock.RUnlock()
+ return a.inventoryCache
+}
+
func (a *Server) SetLockWatcher(lockWatcher *services.LockWatcher) {
a.lock.Lock()
defer a.lock.Unlock()
@@ -2460,6 +2478,12 @@ func (a *Server) Close() error {
errs = append(errs, err)
}
+ if inventoryCache := a.GetInventoryCache(); inventoryCache != nil {
+ if err := inventoryCache.Close(); err != nil {
+ errs = append(errs, err)
+ }
+ }
+
if a.Services.AuditLogSessionStreamer != nil {
if err := a.Services.AuditLogSessionStreamer.Close(); err != nil {
errs = append(errs, err)
diff --git a/lib/auth/authclient/clt.go b/lib/auth/authclient/clt.go
index cc3786221e054..b5765fb4b18fa 100644
--- a/lib/auth/authclient/clt.go
+++ b/lib/auth/authclient/clt.go
@@ -45,6 +45,7 @@ import (
dbobjectimportrulev1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/dbobjectimportrule/v1"
devicepb "github.com/gravitational/teleport/api/gen/proto/go/teleport/devicetrust/v1"
integrationv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/integration/v1"
+ inventoryv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/inventory/v1"
loginrulepb "github.com/gravitational/teleport/api/gen/proto/go/teleport/loginrule/v1"
machineidv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1"
notificationsv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/notifications/v1"
@@ -1517,6 +1518,9 @@ type ClientI interface {
services.ScopedAccessClientGetter
services.WorkloadClusterService
+ // ListUnifiedInstances returns a paginated list of unified instances (teleport instances and bot instances).
+ ListUnifiedInstances(ctx context.Context, req *inventoryv1.ListUnifiedInstancesRequest) (*inventoryv1.ListUnifiedInstancesResponse, error)
+
types.WebSessionsGetter
services.WebToken
diff --git a/lib/auth/authtest/authtest.go b/lib/auth/authtest/authtest.go
index 46eadf57c9e4d..eac1a59e17ba4 100644
--- a/lib/auth/authtest/authtest.go
+++ b/lib/auth/authtest/authtest.go
@@ -55,6 +55,7 @@ import (
"github.com/gravitational/teleport/lib/backend"
"github.com/gravitational/teleport/lib/backend/memory"
"github.com/gravitational/teleport/lib/cache"
+ inventorycache "github.com/gravitational/teleport/lib/cache/inventory"
"github.com/gravitational/teleport/lib/cryptosuites"
"github.com/gravitational/teleport/lib/defaults"
"github.com/gravitational/teleport/lib/events"
@@ -626,6 +627,19 @@ func InitAuthCache(p AuthCacheParams) error {
return trace.Wrap(err)
}
p.AuthServer.Cache = c
+
+ // Create and set the inventory cache
+ invCache, err := inventorycache.NewInventoryCache(inventorycache.InventoryCacheConfig{
+ PrimaryCache: c,
+ Events: p.AuthServer.Services,
+ Inventory: p.AuthServer.Services,
+ BotInstanceBackend: p.AuthServer.Services,
+ })
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ p.AuthServer.SetInventoryCache(invCache)
+
return nil
}
diff --git a/lib/auth/grpcserver.go b/lib/auth/grpcserver.go
index 4d11cd43c15ee..133b48920cab7 100644
--- a/lib/auth/grpcserver.go
+++ b/lib/auth/grpcserver.go
@@ -65,6 +65,7 @@ import (
gitserverv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/gitserver/v1"
healthcheckconfigv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/healthcheckconfig/v1"
integrationv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/integration/v1"
+ inventorypb "github.com/gravitational/teleport/api/gen/proto/go/teleport/inventory/v1"
kubewaitingcontainerv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/kubewaitingcontainer/v1"
loginrulev1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/loginrule/v1"
machineidv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1"
@@ -108,6 +109,7 @@ import (
"github.com/gravitational/teleport/lib/auth/gitserver/gitserverv1"
"github.com/gravitational/teleport/lib/auth/healthcheckconfig/healthcheckconfigv1"
"github.com/gravitational/teleport/lib/auth/integration/integrationv1"
+ "github.com/gravitational/teleport/lib/auth/inventory/inventoryv1"
"github.com/gravitational/teleport/lib/auth/kubewaitingcontainer/kubewaitingcontainerv1"
"github.com/gravitational/teleport/lib/auth/loginrule/loginrulev1"
"github.com/gravitational/teleport/lib/auth/machineid/machineidv1"
@@ -6048,6 +6050,16 @@ func NewGRPCServer(cfg GRPCServerConfig) (*GRPCServer, error) {
}
presencev1pb.RegisterPresenceServiceServer(server, presenceService)
+ inventoryService, err := inventoryv1.NewService(inventoryv1.ServiceConfig{
+ Authorizer: cfg.Authorizer,
+ InventoryCache: cfg.AuthServer.GetInventoryCache(),
+ Logger: cfg.AuthServer.logger.With(teleport.ComponentKey, "inventory.service"),
+ })
+ if err != nil {
+ return nil, trace.Wrap(err, "creating inventory service")
+ }
+ inventorypb.RegisterInventoryServiceServer(server, inventoryService)
+
botService, err := machineidv1.NewBotService(machineidv1.BotServiceConfig{
Authorizer: cfg.Authorizer,
Cache: cfg.AuthServer.Cache,
diff --git a/lib/auth/inventory/inventoryv1/service.go b/lib/auth/inventory/inventoryv1/service.go
new file mode 100644
index 0000000000000..dc6b0fe63f535
--- /dev/null
+++ b/lib/auth/inventory/inventoryv1/service.go
@@ -0,0 +1,111 @@
+/*
+ * 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 inventoryv1
+
+import (
+ "context"
+ "log/slog"
+
+ "github.com/gravitational/trace"
+
+ "github.com/gravitational/teleport"
+ inventorypb "github.com/gravitational/teleport/api/gen/proto/go/teleport/inventory/v1"
+ "github.com/gravitational/teleport/api/types"
+ "github.com/gravitational/teleport/lib/authz"
+)
+
+// InventoryCache is the subset of the inventory cache that the Service uses.
+type InventoryCache interface {
+ ListUnifiedInstances(ctx context.Context, req *inventorypb.ListUnifiedInstancesRequest) (*inventorypb.ListUnifiedInstancesResponse, error)
+}
+
+// ServiceConfig holds configuration options for the inventory gRPC service.
+type ServiceConfig struct {
+ Authorizer authz.Authorizer
+ InventoryCache InventoryCache
+ Logger *slog.Logger
+}
+
+// Service implements the teleport.inventory.v1.InventoryService RPC service.
+type Service struct {
+ inventorypb.UnimplementedInventoryServiceServer
+
+ authorizer authz.Authorizer
+ inventoryCache InventoryCache
+ logger *slog.Logger
+}
+
+// NewService returns a new inventory gRPC service.
+func NewService(cfg ServiceConfig) (*Service, error) {
+ switch {
+ case cfg.Authorizer == nil:
+ return nil, trace.BadParameter("authorizer is required")
+ case cfg.InventoryCache == nil:
+ return nil, trace.BadParameter("inventory cache is required")
+ }
+
+ if cfg.Logger == nil {
+ cfg.Logger = slog.With(teleport.ComponentKey, "inventory.service")
+ }
+
+ return &Service{
+ logger: cfg.Logger,
+ authorizer: cfg.Authorizer,
+ inventoryCache: cfg.InventoryCache,
+ }, nil
+}
+
+// ListUnifiedInstances returns a page of teleport instances and bot_instances.
+// This API will refuse any requests when the cache is unhealthy or not yet fully initialized.
+func (s *Service) ListUnifiedInstances(
+ ctx context.Context, req *inventorypb.ListUnifiedInstancesRequest,
+) (*inventorypb.ListUnifiedInstancesResponse, error) {
+ authCtx, err := s.authorizer.Authorize(ctx)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ // If no instance types are specified, default to all instance types
+ instanceTypes := req.GetFilter().GetInstanceTypes()
+ if len(instanceTypes) == 0 {
+ instanceTypes = []inventorypb.InstanceType{
+ inventorypb.InstanceType_INSTANCE_TYPE_INSTANCE,
+ inventorypb.InstanceType_INSTANCE_TYPE_BOT_INSTANCE,
+ }
+ }
+
+ // Ensure that the instance types requested align with the user's permissions
+ for _, instanceType := range instanceTypes {
+ switch instanceType {
+ case inventorypb.InstanceType_INSTANCE_TYPE_INSTANCE:
+ if err := authCtx.CheckAccessToKind(types.KindInstance, types.VerbList, types.VerbRead); err != nil {
+ return nil, trace.Wrap(err)
+ }
+ case inventorypb.InstanceType_INSTANCE_TYPE_BOT_INSTANCE:
+ if err := authCtx.CheckAccessToKind(types.KindBotInstance, types.VerbList, types.VerbRead); err != nil {
+ return nil, trace.Wrap(err)
+ }
+ default:
+ return nil, trace.NotImplemented("instance type %v is not supported", instanceType)
+ }
+ }
+
+ resp, err := s.inventoryCache.ListUnifiedInstances(ctx, req)
+ return resp, trace.Wrap(err)
+}
diff --git a/lib/auth/machineid/machineidv1/expression/expression.go b/lib/auth/machineid/machineidv1/expression/expression.go
index 1c6dae206950e..a34a34e343119 100644
--- a/lib/auth/machineid/machineidv1/expression/expression.go
+++ b/lib/auth/machineid/machineidv1/expression/expression.go
@@ -17,8 +17,7 @@
package expression
import (
- "github.com/coreos/go-semver/semver"
- "github.com/gravitational/trace"
+ "maps"
"github.com/gravitational/teleport/lib/expression"
"github.com/gravitational/teleport/lib/utils/typical"
@@ -27,7 +26,7 @@ import (
func NewBotInstanceExpressionParser() (*typical.Parser[*Environment, bool], error) {
spec := expression.DefaultParserSpec[*Environment]()
- spec.Variables = map[string]typical.Variable{
+ newVariables := map[string]typical.Variable{
"name": typical.DynamicVariable(func(env *Environment) (string, error) {
return env.GetMetadata().GetName(), nil
}),
@@ -60,75 +59,11 @@ func NewBotInstanceExpressionParser() (*typical.Parser[*Environment, bool], erro
}),
}
- // e.g. `newer_than(status.latest_heartbeat.version, "19.0.0")`
- spec.Functions["newer_than"] = typical.BinaryFunction[*Environment](semverGt)
- // e.g. `older_than(status.latest_heartbeat.version, "19.0.2")`
- spec.Functions["older_than"] = typical.BinaryFunction[*Environment](semverLt)
- // e.g. `between(status.latest_heartbeat.version, "19.0.0", "19.0.2")`
- spec.Functions["between"] = typical.TernaryFunction[*Environment](semverBetween)
-
- return typical.NewParser[*Environment, bool](spec)
-}
-
-func semverGt(a, b any) (bool, error) {
- va, err := toSemver(a)
- if va == nil || err != nil {
- return false, err
- }
- vb, err := toSemver(b)
- if vb == nil || err != nil {
- return false, err
+ if len(spec.Variables) < 1 {
+ spec.Variables = newVariables
+ } else {
+ maps.Copy(spec.Variables, newVariables)
}
- return va.Compare(*vb) > 0, nil
-}
-func semverLt(a, b any) (bool, error) {
- va, err := toSemver(a)
- if va == nil || err != nil {
- return false, err
- }
- vb, err := toSemver(b)
- if vb == nil || err != nil {
- return false, err
- }
- return va.Compare(*vb) < 0, nil
-}
-
-func semverEq(a, b any) (bool, error) {
- va, err := toSemver(a)
- if va == nil || err != nil {
- return false, err
- }
- vb, err := toSemver(b)
- if vb == nil || err != nil {
- return false, err
- }
- return va.Compare(*vb) == 0, nil
-}
-
-func semverBetween(c, a, b any) (bool, error) {
- gt, err := semverGt(c, a)
- if err != nil {
- return false, err
- }
- eq, err := semverEq(c, a)
- if err != nil {
- return false, err
- }
- lt, err := semverLt(c, b)
- if err != nil {
- return false, err
- }
- return (gt || eq) && lt, nil
-}
-
-func toSemver(anyV any) (*semver.Version, error) {
- switch v := anyV.(type) {
- case *semver.Version:
- return v, nil
- case string:
- return semver.NewVersion(v)
- default:
- return nil, trace.BadParameter("type %T cannot be parsed as semver.Version", v)
- }
+ return typical.NewParser[*Environment, bool](spec)
}
diff --git a/lib/cache/cache.go b/lib/cache/cache.go
index d7c3c5ea2d882..e8d53074c352e 100644
--- a/lib/cache/cache.go
+++ b/lib/cache/cache.go
@@ -521,6 +521,10 @@ type Cache struct {
// fails.
initErr error
+ // firstTimeInitC is closed on the first successful initialization of the cache
+ firstTimeInitC chan struct{}
+ firstTimeInitOnce sync.Once
+
// ctx is a cache exit context
ctx context.Context
// cancel triggers exit context closure
@@ -553,12 +557,20 @@ func (c *Cache) setInitError(err error) {
})
if err == nil {
+ c.firstTimeInitOnce.Do(func() {
+ close(c.firstTimeInitC)
+ })
cacheHealth.WithLabelValues(c.target).Set(1.0)
} else {
cacheHealth.WithLabelValues(c.target).Set(0.0)
}
}
+// FirstInit returns a channel that is closed when the cache successfully initializes for the first time.
+func (c *Cache) FirstInit() <-chan struct{} {
+ return c.firstTimeInitC
+}
+
// setReadStatus updates Cache.ok, which determines whether the
// cache is overall accessible for reads, and confirmedKinds
// which stores resource kinds accessible in current generation.
@@ -906,6 +918,7 @@ func New(config Config) (*Cache, error) {
cancel: cancel,
Config: config,
initC: make(chan struct{}),
+ firstTimeInitC: make(chan struct{}),
fnCache: fnCache,
eventsFanout: fanout,
collections: collections,
@@ -1823,3 +1836,35 @@ func buildListResourcesResponse[T types.ResourceWithLabels](resources iter.Seq[T
return &resp, nil
}
+
+// GetUnifiedResourcesAndBotsCount returns the combined total number of nodes, app servers, database servers, kube servers, desktops, and bot instances.
+func (c *Cache) GetUnifiedResourcesAndBotsCount() int {
+ c.rw.RLock()
+ defer c.rw.RUnlock()
+
+ if !c.ok {
+ return -1
+ }
+
+ count := 0
+ if c.collections.nodes != nil {
+ count += c.collections.nodes.store.len()
+ }
+ if c.collections.appServers != nil {
+ count += c.collections.appServers.store.len()
+ }
+ if c.collections.dbServers != nil {
+ count += c.collections.dbServers.store.len()
+ }
+ if c.collections.kubeServers != nil {
+ count += c.collections.kubeServers.store.len()
+ }
+ if c.collections.windowsDesktops != nil {
+ count += c.collections.windowsDesktops.store.len()
+ }
+ if c.collections.botInstances != nil {
+ count += c.collections.botInstances.store.len()
+ }
+
+ return count
+}
diff --git a/lib/cache/cache_test.go b/lib/cache/cache_test.go
index e36948aa5069c..e5e9696f03aaf 100644
--- a/lib/cache/cache_test.go
+++ b/lib/cache/cache_test.go
@@ -308,7 +308,7 @@ func newTestPack(t *testing.T, setupConfig SetupConfigFn, opts ...packOption) *t
return pack
}
-func newTestPackWithoutCache(t *testing.T) *testPack {
+func NewTestPackWithoutCache(t *testing.T) *testPack {
pack, err := newPackWithoutCache(t.TempDir())
require.NoError(t, err)
return pack
@@ -800,7 +800,7 @@ func TestCompletenessInit(t *testing.T) {
ctx := context.Background()
const caCount = 100
const inits = 20
- p := newTestPackWithoutCache(t)
+ p := NewTestPackWithoutCache(t)
t.Cleanup(p.Close)
// put lots of CAs in the backend
@@ -895,7 +895,7 @@ func TestCompletenessReset(t *testing.T) {
ctx := context.Background()
const caCount = 100
const resets = 20
- p := newTestPackWithoutCache(t)
+ p := NewTestPackWithoutCache(t)
t.Cleanup(p.Close)
// put lots of CAs in the backend
@@ -1164,7 +1164,7 @@ func TestListResources_NodesTTLVariant(t *testing.T) {
func initStrategy(t *testing.T) {
ctx := context.Background()
- p := newTestPackWithoutCache(t)
+ p := NewTestPackWithoutCache(t)
t.Cleanup(p.Close)
p.backend.SetReadError(trace.ConnectionProblem(nil, "backend is out"))
diff --git a/lib/cache/inventory/inventory_cache.go b/lib/cache/inventory/inventory_cache.go
new file mode 100644
index 0000000000000..973d5fa423c89
--- /dev/null
+++ b/lib/cache/inventory/inventory_cache.go
@@ -0,0 +1,1234 @@
+// 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 inventory
+
+import (
+ "context"
+ "encoding/base32"
+ "fmt"
+ "log/slog"
+ "maps"
+ "math"
+ "slices"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/charlievieth/strcase"
+ "github.com/coreos/go-semver/semver"
+ "github.com/gravitational/trace"
+ "github.com/prometheus/client_golang/prometheus"
+ "golang.org/x/time/rate"
+ "google.golang.org/protobuf/proto"
+ "rsc.io/ordered"
+
+ "github.com/gravitational/teleport/api/defaults"
+ inventoryv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/inventory/v1"
+ machineidv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1"
+ "github.com/gravitational/teleport/api/types"
+ apiutils "github.com/gravitational/teleport/api/utils"
+ "github.com/gravitational/teleport/lib/cache"
+ "github.com/gravitational/teleport/lib/expression"
+ "github.com/gravitational/teleport/lib/observability/metrics"
+ "github.com/gravitational/teleport/lib/services"
+ "github.com/gravitational/teleport/lib/utils"
+ "github.com/gravitational/teleport/lib/utils/sortcache"
+ "github.com/gravitational/teleport/lib/utils/typical"
+)
+
+const (
+ // instancePrefix is the backend prefix for teleport instances.
+ instancePrefix = "instances"
+
+ // botInstancePrefix is the backend prefix for bot instances.
+ botInstancePrefix = "bot_instance"
+
+ metricsSubsystem = "inventory_cache"
+ // metricsVersionLabel is the label name for version metrics.
+ metricsVersionLabel = "version"
+)
+
+type inventoryCacheMetrics struct {
+ // instancesTotal is the total number of teleport instances in the cache.
+ instancesTotal prometheus.Gauge
+
+ // botInstancesTotal is the total number of bot instances in the cache.
+ botInstancesTotal prometheus.Gauge
+
+ // instancesByVersion is the number of instances by teleport version.
+ instancesByVersion *prometheus.GaugeVec
+
+ // initDurationSeconds is how long it took to initialize and populate the cache.
+ initDurationSeconds prometheus.Gauge
+
+ // requests is the total number of requests made to the cache (times ListUnifiedInstances was called).
+ requests prometheus.Counter
+}
+
+// newInventoryCacheMetrics creates the inventory cache metrics.
+func newInventoryCacheMetrics(reg *metrics.Registry) *inventoryCacheMetrics {
+ var namespace, subsystem string
+ if reg != nil {
+ namespace = reg.Namespace()
+ subsystem = reg.Subsystem()
+ }
+
+ return &inventoryCacheMetrics{
+ instancesTotal: prometheus.NewGauge(prometheus.GaugeOpts{
+ Namespace: namespace,
+ Subsystem: subsystem,
+ Name: "instances_total",
+ Help: "Total number of teleport instances in the inventory cache.",
+ }),
+ botInstancesTotal: prometheus.NewGauge(prometheus.GaugeOpts{
+ Namespace: namespace,
+ Subsystem: subsystem,
+ Name: "bot_instances_total",
+ Help: "Total number of bot instances in the inventory cache.",
+ }),
+ instancesByVersion: prometheus.NewGaugeVec(prometheus.GaugeOpts{
+ Namespace: namespace,
+ Subsystem: subsystem,
+ Name: "instances_by_version",
+ Help: "Number of teleport instances by teleport version.",
+ }, []string{metricsVersionLabel}),
+ initDurationSeconds: prometheus.NewGauge(prometheus.GaugeOpts{
+ Namespace: namespace,
+ Subsystem: subsystem,
+ Name: "init_duration_seconds",
+ Help: "Time taken to initialize and populate the inventory cache.",
+ }),
+ requests: prometheus.NewCounter(prometheus.CounterOpts{
+ Namespace: namespace,
+ Subsystem: subsystem,
+ Name: "requests_total",
+ Help: "Total number of requests to the inventory cache.",
+ }),
+ }
+}
+
+// register registers the metrics with the provided registerer.
+func (m *inventoryCacheMetrics) register(reg prometheus.Registerer) error {
+ return trace.NewAggregate(
+ reg.Register(m.instancesTotal),
+ reg.Register(m.botInstancesTotal),
+ reg.Register(m.instancesByVersion),
+ reg.Register(m.initDurationSeconds),
+ reg.Register(m.requests),
+ )
+}
+
+var (
+ // unifiedExpressionParser is a cached unified expression parser
+ unifiedExpressionParser *typical.Parser[*unifiedFilterEnvironment, bool]
+ unifiedExpressionParserOnce sync.Once
+)
+
+// instanceTypeToKind converts an InstanceType enum to a resource kind string.
+func instanceTypeToKind(instanceType inventoryv1.InstanceType) (string, error) {
+ switch instanceType {
+ case inventoryv1.InstanceType_INSTANCE_TYPE_INSTANCE:
+ return types.KindInstance, nil
+ case inventoryv1.InstanceType_INSTANCE_TYPE_BOT_INSTANCE:
+ return types.KindBotInstance, nil
+ default:
+ return "", trace.BadParameter("unknown instance type: %v", instanceType)
+ }
+}
+
+// bytestring contains binary data (encoded keys) and is not intended to be printable.
+type bytestring = string
+
+type inventoryIndex string
+
+const (
+ // inventoryAlphabeticalIndex sorts instances by display name (bot name
+ // or instance hostname), unique ID (bot instance ID or instance host ID)
+ // and type ("bot" or "instance").
+ inventoryAlphabeticalIndex inventoryIndex = "alphabetical"
+
+ // inventoryTypeIndex sorts instances by type, display name
+ // and unique ID.
+ inventoryTypeIndex inventoryIndex = "type"
+
+ // inventoryVersionIndex sorts instances by version, display name
+ // and unique ID.
+ inventoryVersionIndex inventoryIndex = "version"
+
+ // inventoryIDIndex allows lookup by instance ID.
+ // Uses ordered.Encode(bot name, instance ID, kind) where the bot name is "" for regular instances
+ inventoryIDIndex inventoryIndex = "id"
+)
+
+// inventoryInstance is a wrapper for either a teleport instance or a bot instance.
+type inventoryInstance struct {
+ instance *types.InstanceV1
+ bot *machineidv1.BotInstance
+}
+
+// isInstance returns true if this wrapper contains a teleport instance (not a bot instance).
+func (u *inventoryInstance) isInstance() bool {
+ return u.instance != nil
+}
+
+// getInstanceID returns a unique ID for this instance.
+// For instances, this is the instance ID. For bot instances, this is the bot instance ID
+func (u *inventoryInstance) getInstanceID() string {
+ if u.isInstance() {
+ return u.instance.GetName()
+ }
+ return u.bot.GetSpec().GetInstanceId()
+}
+
+// getBotName returns the bot name for bot instances, or an empty string for regular instances.
+func (u *inventoryInstance) getBotName() string {
+ if u.isInstance() {
+ return ""
+ }
+ return u.bot.GetSpec().GetBotName()
+}
+
+// getKind returns the resource kind for this instance.
+func (u *inventoryInstance) getKind() string {
+ if u.isInstance() {
+ return types.KindInstance
+ }
+ return types.KindBotInstance
+}
+
+// getAlphabeticalKey returns the composite key for alphabetical sorting.
+func (u *inventoryInstance) getAlphabeticalKey() bytestring {
+ var name, id string
+ if u.isInstance() {
+ name = u.instance.GetHostname()
+ id = u.instance.GetName()
+ } else {
+ name = u.bot.GetSpec().GetBotName()
+ id = u.bot.GetSpec().GetInstanceId()
+ }
+
+ return bytestring(ordered.Encode(name, id, u.getKind()))
+}
+
+// getTypeKey returns the composite key for sorting by type.
+func (u *inventoryInstance) getTypeKey() bytestring {
+ var name, id string
+ if u.isInstance() {
+ name = u.instance.GetHostname()
+ id = u.instance.GetName()
+ } else {
+ name = u.bot.GetSpec().GetBotName()
+ id = u.bot.GetSpec().GetInstanceId()
+ }
+
+ return bytestring(ordered.Encode(u.getKind(), name, id))
+}
+
+// getVersionKey returns the composite key for sorting by version using semver ordering.
+func (u *inventoryInstance) getVersionKey() bytestring {
+ var versionStr, name, id string
+ if u.isInstance() {
+ versionStr = u.instance.Spec.Version
+ name = u.instance.GetHostname()
+ id = u.instance.GetName()
+ } else {
+ if u.bot.Status != nil && len(u.bot.Status.LatestHeartbeats) > 0 {
+ versionStr = u.bot.Status.LatestHeartbeats[0].Version
+ }
+ name = u.bot.GetSpec().GetBotName()
+ id = u.bot.GetSpec().GetInstanceId()
+ }
+
+ // If version is empty, treat it as 0.0.0
+ if versionStr == "" {
+ return bytestring(ordered.Encode(uint64(0), uint64(0), uint64(0), ordered.Inf, name, id))
+ }
+
+ // Strip "v" prefix if it's there, eg. v1.2.3 -> 1.2.3
+ versionStr = strings.TrimPrefix(versionStr, "v")
+
+ // Parse as semver
+ v, err := semver.NewVersion(versionStr)
+ if err != nil {
+ // Invalid semvers sort after all other versions
+ return bytestring(ordered.Encode(ordered.Inf, versionStr, name, id))
+ }
+
+ // For releases (ie. anything not a pre-release), we use ordered.Inf before the metadata to ensure it sorts after all prereleases
+ if v.PreRelease == "" {
+ return bytestring(ordered.Encode(v.Major, v.Minor, v.Patch, ordered.Inf, v.Metadata, name, id))
+ }
+
+ // For pre-releases
+
+ parts := strings.Split(string(v.PreRelease), ".")
+
+ // Pre-allocate the slice
+ // The 3 is for major, minor, patch
+ // The len(parts)*2 is for pre-release parts (each one needs 2 for tag+value)
+ // The 4 is for the sentinel value, metadata, name, id
+ encodeArgs := make([]any, 0, 3+len(parts)*2+4)
+ encodeArgs = append(encodeArgs, v.Major, v.Minor, v.Patch)
+
+ // We encode each pre-release part with a tag to ensure correct ordering:
+ // We use 0 for numeric parts (so that they always sort before tag 1)
+ // We use 1 for alphanumeric parts (so that they always sort after tag 0, but before ordered.Inf which we use for releases)
+ for _, part := range parts {
+ // Check if the part is numeric or alphanumeric
+ if num, err := strconv.ParseUint(part, 10, 64); err == nil {
+ encodeArgs = append(encodeArgs, uint64(0), num)
+ } else {
+ encodeArgs = append(encodeArgs, uint64(1), part)
+ }
+ }
+
+ // We use "" as sentinel value to mark the "end" of pre-releases
+ // This way we can make sure that for example, "alpha" < "alpha.1"
+ // "alpha" would be [..., 1, "alpha", ""] and "alpha.1" would be [..., 1, "alpha", 0, 1, ""]
+ // Since it'll compare "" < 0, "alpha" will come first
+ encodeArgs = append(encodeArgs, "")
+
+ // Metadata eg. "+build123" doesn't affect semver ordering, but we add it to ensure that
+ // two versions that differ only in metadata have a consistent order
+ encodeArgs = append(encodeArgs, v.Metadata)
+
+ encodeArgs = append(encodeArgs, name, id)
+
+ return bytestring(ordered.Encode(encodeArgs...))
+}
+
+// getIDKey returns the key for lookup by instance ID.
+// We use ordered encoding with (bot name, instance ID, kind) to ensure uniqueness and safe lexicographic ordering.
+// For instances this is ordered.Encode("", instance ID, "instance")
+// For bot instances this is ordered.Encode(bot name, instance ID, "bot_instance")
+func (u *inventoryInstance) getIDKey() bytestring {
+ return bytestring(ordered.Encode(u.getBotName(), u.getInstanceID(), u.getKind()))
+}
+
+// InventoryCacheConfig holds the configuration parameters for the InventoryCache.
+type InventoryCacheConfig struct {
+ // PrimaryCache is Teleport's primary cache.
+ PrimaryCache *cache.Cache
+
+ // Events is the events service for watching backend events.
+ Events types.Events
+
+ // Inventory is the inventory service.
+ Inventory services.Inventory
+
+ // BotInstanceBackend is the backend service for reading bot instances.
+ // This must be the backend and not a cache since the watcher is from the backend,
+ // so the OpInit event might refer to a "time" after the current "time" of a cache, which could cause
+ // us to miss items that are not yet in the cache but were already written in the backend.
+ BotInstanceBackend services.BotInstance
+
+ // TargetVersion is the target Teleport version for the cluster.
+ TargetVersion string
+
+ Logger *slog.Logger
+
+ // MetricsRegistry is the registry for prometheus metrics.
+ MetricsRegistry *metrics.Registry
+}
+
+func (c *InventoryCacheConfig) CheckAndSetDefaults() error {
+ if c.PrimaryCache == nil {
+ return trace.BadParameter("missing PrimaryCache")
+ }
+ if c.Events == nil {
+ return trace.BadParameter("missing Events")
+ }
+ if c.Inventory == nil {
+ return trace.BadParameter("missing Inventory")
+ }
+ if c.BotInstanceBackend == nil {
+ return trace.BadParameter("missing BotInstanceBackend")
+ }
+
+ if c.Logger == nil {
+ c.Logger = slog.Default()
+ }
+
+ return nil
+}
+
+// InventoryCache is the cache for teleport and bot instances.
+type InventoryCache struct {
+ // healthy is whether the cache is healthy and ready to serve requests.
+ healthy atomic.Bool
+ // done is a channel used to ensure clean shutdowns.
+ done chan struct{}
+
+ cfg InventoryCacheConfig
+
+ ctx context.Context
+ cancel context.CancelFunc
+
+ // cache is the unified sortcache that holds both teleport and bot instances.
+ cache *sortcache.SortCache[*inventoryInstance, inventoryIndex]
+
+ // metrics holds the prometheus metrics for the inventory cache.
+ metrics *inventoryCacheMetrics
+}
+
+func NewInventoryCache(cfg InventoryCacheConfig) (*InventoryCache, error) {
+ if err := cfg.CheckAndSetDefaults(); err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ var reg *metrics.Registry
+ if cfg.MetricsRegistry != nil {
+ reg = cfg.MetricsRegistry.Wrap(metricsSubsystem)
+ }
+
+ m := newInventoryCacheMetrics(reg)
+ if reg != nil {
+ if err := m.register(reg); err != nil {
+ cfg.Logger.ErrorContext(context.Background(), "Failed to register inventory cache metrics", "error", err)
+ }
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+
+ ic := &InventoryCache{
+ cfg: cfg,
+
+ // Create the sortcache
+ cache: sortcache.New(sortcache.Config[*inventoryInstance, inventoryIndex]{
+ Indexes: map[inventoryIndex]func(*inventoryInstance) string{
+ inventoryAlphabeticalIndex: (*inventoryInstance).getAlphabeticalKey,
+ inventoryTypeIndex: (*inventoryInstance).getTypeKey,
+ inventoryVersionIndex: (*inventoryInstance).getVersionKey,
+ inventoryIDIndex: (*inventoryInstance).getIDKey,
+ },
+ }),
+
+ // Create a channel that will close when the initialization is done.
+ done: make(chan struct{}),
+
+ ctx: ctx,
+ cancel: cancel,
+ metrics: m,
+ }
+
+ go func() {
+ defer close(ic.done)
+ ic.initializeAndWatchWithRetry(ctx)
+ }()
+
+ return ic, nil
+}
+
+// IsHealthy returns true if the cache is healthy and initialized.
+func (ic *InventoryCache) IsHealthy() bool {
+ return ic.healthy.Load()
+}
+
+func (ic *InventoryCache) Close() error {
+ ic.cancel()
+ // Wait for done channel to finish so we can close gracefully.
+ <-ic.done
+ return nil
+}
+
+// updateMetrics iterates through the cache and updates all prometheus metrics.
+func (ic *InventoryCache) updateMetrics() {
+ var instanceCount, botInstanceCount float64
+ versionCounts := make(map[string]float64)
+
+ for item := range ic.cache.Ascend(inventoryIDIndex, "", "") {
+ if item.isInstance() {
+ instanceCount++
+
+ // Count versions
+ version := item.instance.Spec.Version
+ if version != "" {
+ versionCounts[version]++
+ }
+ } else {
+ botInstanceCount++
+ }
+ }
+
+ ic.metrics.instancesTotal.Set(instanceCount)
+ ic.metrics.botInstancesTotal.Set(botInstanceCount)
+
+ ic.metrics.instancesByVersion.Reset()
+ for version, count := range versionCounts {
+ ic.metrics.instancesByVersion.WithLabelValues(version).Set(count)
+ }
+}
+
+// calculateReadsPerSecond calculates the rate limit to use for backend reads based on cluster size.
+// The curve is intentionalled capped to stay below the 90s watcher grace period even in extremely large clusters.
+// With this implementation, these are some of the expected rate limits and corresponding total times based on cluster size:
+//
+// Cluster size | Reads per second | Total time to finish all reads
+// -------------|------------------|-------------------------------
+// 500 | 283 | 1.77s
+// 1,000 | 298 | 3.36s
+// 2,000 | 322 | 6.21s
+// 4,000 | 363 | 11.02s
+// 8,000 | 433 | 18.47s
+// 32,000 | 789 | 40.56s
+// 64,000 | 1219 | 52.5s
+// 128,000 | 2035 | 1m03s
+// 256,000 | 3605 | 1m11s
+func calculateReadsPerSecond(clusterSize int) int {
+ // minimumComponent is the minimum value of reads per second we never want to drop below.
+ const minimumComponent = 256
+
+ // linearComponent ensures we stay under a worst-case upper bound init time of 90s.
+ linearComponent := clusterSize / 90
+
+ // subLinearComponent ensures that growth is sub-linear across most reasonable cluster sizes.
+ subLinearComponent := int(math.Sqrt(float64(clusterSize)))
+
+ return minimumComponent + linearComponent + subLinearComponent
+}
+
+// initializeAndWatchWithRetry runs initializeAndWatch with a retry every 10 seconds if it fails.
+func (ic *InventoryCache) initializeAndWatchWithRetry(ctx context.Context) {
+ const retryInterval = 10 * time.Second
+
+ for {
+ ic.cfg.Logger.DebugContext(ctx, "Attempting to initialize inventory cache")
+
+ // Attempt to initialize and watch
+ err := ic.initializeAndWatch(ctx)
+ if ctx.Err() != nil {
+ ic.cfg.Logger.DebugContext(ctx, "Exiting from inventory cache watch loop because context was canceled")
+ return
+ }
+
+ ic.cfg.Logger.WarnContext(ctx, "Failed to initialize inventory cache, retrying in 10 seconds",
+ "error", err)
+
+ // Wait before retrying
+ select {
+ case <-ctx.Done():
+ return
+ case <-time.After(retryInterval):
+ }
+ }
+}
+
+// initializeAndWatch initializes the inventory cache and begins watching for instance and bot_instance backend events.
+func (ic *InventoryCache) initializeAndWatch(ctx context.Context) error {
+ initStart := time.Now()
+
+ // Wait for primary cache to be ready.
+ if err := ic.waitForPrimaryCacheInit(ctx); err != nil {
+ return trace.Wrap(err, "Failed to wait for primary cache init")
+ }
+
+ // Setup the backend watcher.
+ watcher, err := ic.setupWatcher(ctx)
+ if err != nil {
+ return trace.Wrap(err, "Failed to set up backend watcher")
+ }
+ defer watcher.Close()
+
+ // Wait for the watcher to be ready.
+ if err := ic.waitForWatcherInit(ctx, watcher); err != nil {
+ return trace.Wrap(err, "Failed to wait for watcher init")
+ }
+
+ // Calculate the rate limit to use.
+ primaryCacheSize := ic.cfg.PrimaryCache.GetUnifiedResourcesAndBotsCount()
+ readsPerSecond := calculateReadsPerSecond(primaryCacheSize)
+
+ // Populate the cache with teleport instance and bot instances.
+ if err := ic.populateCache(ctx, readsPerSecond); err != nil {
+ return trace.Wrap(err, "failed to populate inventory cache")
+ }
+
+ // Record how long it took to initialize the cache
+ ic.metrics.initDurationSeconds.Set(time.Since(initStart).Seconds())
+
+ ic.updateMetrics()
+
+ // Mark cache as healthy.
+ ic.healthy.Store(true)
+ ic.cfg.Logger.InfoContext(ctx, "Inventory cache init succeeded")
+
+ // This runs infinitely until the context is canceled.
+ ic.processEvents(ctx, watcher)
+
+ return ctx.Err()
+}
+
+// waitForPrimaryCacheInit waits for the primary cache to be initialized.
+func (ic *InventoryCache) waitForPrimaryCacheInit(ctx context.Context) error {
+ select {
+ case <-ctx.Done():
+ return trace.Wrap(ctx.Err())
+ case <-ic.cfg.PrimaryCache.FirstInit():
+ return nil
+ }
+}
+
+// setupWatcher sets up a watcher for instance and bot_instance events.
+func (ic *InventoryCache) setupWatcher(ctx context.Context) (types.Watcher, error) {
+ watcher, err := ic.cfg.Events.NewWatcher(ctx, types.Watch{
+ Name: "inventory_cache",
+ Kinds: []types.WatchKind{
+ {Kind: types.KindInstance},
+ {Kind: types.KindBotInstance},
+ },
+ })
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ return watcher, nil
+}
+
+// waitForWatcherInit waits for the watcher to finish initializing.
+func (ic *InventoryCache) waitForWatcherInit(ctx context.Context, watcher types.Watcher) error {
+ select {
+ case <-ctx.Done():
+ // Context was canceled
+ return trace.Wrap(ctx.Err())
+
+ case event := <-watcher.Events():
+ if event.Type != types.OpInit {
+ return trace.BadParameter("expected OpInit event, got %v", event.Type)
+ }
+ return nil
+ }
+}
+
+// populateCache reads teleport and bot instances and populates the cache with rate limiting.
+func (ic *InventoryCache) populateCache(ctx context.Context, readsPerSecond int) error {
+ limiter := rate.NewLimiter(rate.Limit(readsPerSecond), readsPerSecond)
+
+ if err := ic.populateInstances(ctx, limiter); err != nil {
+ return trace.Wrap(err)
+ }
+
+ if err := ic.populateBotInstances(ctx, limiter); err != nil {
+ return trace.Wrap(err)
+ }
+
+ return nil
+}
+
+// populateInstances reads teleport instances from the inventory service with rate limiting.
+func (ic *InventoryCache) populateInstances(ctx context.Context, limiter *rate.Limiter) error {
+ instanceStream := ic.cfg.Inventory.GetInstances(ctx, types.InstanceFilter{})
+
+ for instanceStream.Next() {
+ if err := limiter.Wait(ctx); err != nil {
+ return trace.Wrap(err)
+ }
+
+ instance := instanceStream.Item()
+
+ instanceV1, ok := instance.(*types.InstanceV1)
+ if !ok {
+ ic.cfg.Logger.WarnContext(ctx, "Instance is not InstanceV1", "instance", instance.GetName())
+ continue
+ }
+
+ // Add it to the cache
+ ui := &inventoryInstance{instance: apiutils.CloneProtoMsg(instanceV1)}
+ ic.cache.Put(ui)
+ }
+
+ return trace.Wrap(instanceStream.Done())
+}
+
+// populateBotInstances reads bot instances from the bot instance service with rate limiting.
+func (ic *InventoryCache) populateBotInstances(ctx context.Context, limiter *rate.Limiter) error {
+ var pageToken string
+
+ for {
+ if err := limiter.Wait(ctx); err != nil {
+ return trace.Wrap(err)
+ }
+
+ botInstances, nextToken, err := ic.cfg.BotInstanceBackend.ListBotInstances(
+ ctx,
+ defaults.DefaultChunkSize,
+ pageToken,
+ nil,
+ )
+ if err != nil {
+ return trace.Wrap(err)
+ }
+
+ for _, botInstance := range botInstances {
+ // Add it to the cache
+ ui := &inventoryInstance{bot: proto.CloneOf(botInstance)}
+ ic.cache.Put(ui)
+ }
+
+ if nextToken == "" || len(botInstances) == 0 {
+ break
+ }
+
+ pageToken = nextToken
+ }
+
+ return nil
+}
+
+// processEvents processes events from the watcher.
+func (ic *InventoryCache) processEvents(ctx context.Context, watcher types.Watcher) {
+ metricsTicker := time.NewTicker(30 * time.Second)
+ defer metricsTicker.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+
+ case event := <-watcher.Events():
+ if err := ic.processEvent(event); err != nil {
+ ic.cfg.Logger.WarnContext(ctx, "Failed to process event", "error", err)
+ }
+
+ case <-metricsTicker.C:
+ ic.updateMetrics()
+ }
+ }
+}
+
+// processEvent processes an event from the watcher.
+func (ic *InventoryCache) processEvent(event types.Event) error {
+ switch event.Type {
+ case types.OpPut:
+ return ic.processPutEvent(event)
+ case types.OpDelete:
+ return ic.processDeleteEvent(event)
+ default:
+ // Unknown event type
+ return nil
+ }
+}
+
+// processPutEvent processes an OpPut event.
+func (ic *InventoryCache) processPutEvent(event types.Event) error {
+ switch resource := event.Resource.(type) {
+ case *types.InstanceV1:
+ // Add/update it in the cache
+ ui := &inventoryInstance{instance: apiutils.CloneProtoMsg(resource)}
+ ic.cache.Put(ui)
+ case types.Resource153UnwrapperT[*machineidv1.BotInstance]:
+ // Handle bot instances wrapped in Resource153ToLegacy adapter
+ botInstance := resource.UnwrapT()
+ ui := &inventoryInstance{bot: proto.CloneOf(botInstance)}
+ ic.cache.Put(ui)
+ }
+
+ return nil
+}
+
+// processDeleteEvent handles OpDelete events.
+func (ic *InventoryCache) processDeleteEvent(event types.Event) error {
+ // For delete events, the EventsService returns a ResourceHeader
+ switch resource := event.Resource.(type) {
+ case *types.InstanceV1:
+ // Find and remove the instance from the cache.
+ instanceID := resource.GetName()
+ encodedID := string(ordered.Encode("", instanceID, types.KindInstance))
+ ic.cache.Delete(inventoryIDIndex, encodedID)
+ case *types.ResourceHeader:
+ // For regular instances, use the instance ID directly
+ instanceID := resource.GetName()
+ encodedID := string(ordered.Encode("", instanceID, types.KindInstance))
+ ic.cache.Delete(inventoryIDIndex, encodedID)
+ case types.Resource153UnwrapperT[*machineidv1.BotInstance]:
+ botInstance := resource.UnwrapT()
+ botName := botInstance.GetSpec().GetBotName()
+ instanceID := botInstance.GetSpec().GetInstanceId()
+ encodedID := string(ordered.Encode(botName, instanceID, types.KindBotInstance))
+ ic.cache.Delete(inventoryIDIndex, encodedID)
+ }
+
+ return nil
+}
+
+// parsedFilter holds the expression filters.
+type parsedFilter struct {
+ filter *inventoryv1.ListUnifiedInstancesFilter
+ unifiedInstanceExpression typical.Expression[*unifiedFilterEnvironment, bool]
+}
+
+// parseFilter returns a parsedFilter given a ListUnifiedInstancesFilter.
+func (ic *InventoryCache) parseFilter(filter *inventoryv1.ListUnifiedInstancesFilter) (*parsedFilter, error) {
+ pf := &parsedFilter{
+ filter: filter,
+ }
+
+ if filter == nil || filter.PredicateExpression == "" {
+ return pf, nil
+ }
+
+ parser := getUnifiedExpressionParser()
+
+ expr, err := parser.Parse(filter.PredicateExpression)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ pf.unifiedInstanceExpression = expr
+
+ return pf, nil
+}
+
+// ListUnifiedInstances returns a page of instances and bot_instances. This API will refuse any requests when the cache is unhealthy or not yet
+// fully initialized.
+func (ic *InventoryCache) ListUnifiedInstances(ctx context.Context, req *inventoryv1.ListUnifiedInstancesRequest) (*inventoryv1.ListUnifiedInstancesResponse, error) {
+ if !ic.IsHealthy() {
+ // This returns HTTP error 503. Keep in sync with web/packages/teleport/src/Instances/Instances.tsx (isCacheInitializing)
+ return nil, trace.ConnectionProblem(nil, "inventory cache is not yet healthy, please try again in a few minutes")
+ }
+
+ ic.metrics.requests.Inc()
+
+ if req.PageSize <= 0 {
+ req.PageSize = defaults.DefaultChunkSize
+ }
+
+ // Decode the PageToken from base32hex
+ var startKey string
+ if req.PageToken != "" {
+ decoded, err := base32.HexEncoding.WithPadding(base32.NoPadding).DecodeString(req.PageToken)
+ if err != nil {
+ return nil, trace.BadParameter("invalid page token: %v", err)
+ }
+ startKey = string(decoded)
+ }
+
+ parsed, err := ic.parseFilter(req.GetFilter())
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ var items []*inventoryv1.UnifiedInstanceItem
+ var nextPageToken string
+
+ var index inventoryIndex
+
+ switch req.GetSort() {
+ case inventoryv1.UnifiedInstanceSort_UNIFIED_INSTANCE_SORT_TYPE:
+ index = inventoryTypeIndex
+ case inventoryv1.UnifiedInstanceSort_UNIFIED_INSTANCE_SORT_VERSION:
+ index = inventoryVersionIndex
+ case inventoryv1.UnifiedInstanceSort_UNIFIED_INSTANCE_SORT_NAME:
+ index = inventoryAlphabeticalIndex
+ default:
+ index = inventoryAlphabeticalIndex
+ }
+
+ var endKey string
+
+ // Determine sort order (ascending vs descending)
+ isDesc := req.GetOrder() == inventoryv1.SortOrder_SORT_ORDER_DESCENDING
+
+ // For type index with a single instance type filter, set bounds
+ if index == inventoryTypeIndex && req.GetFilter() != nil && len(req.GetFilter().GetInstanceTypes()) == 1 {
+ kind, err := instanceTypeToKind(req.GetFilter().GetInstanceTypes()[0])
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ start := string(ordered.Encode(kind))
+ end := string(ordered.Encode(kind, ordered.Inf))
+
+ // If we're going in descending order, the start key becomes the end key, and vice versa
+ if isDesc {
+ start, end = end, start
+ }
+
+ if req.PageToken == "" {
+ startKey = start
+ }
+ endKey = end
+ }
+
+ // Iterate over items in the cache
+ iterator := ic.cache.Ascend
+ if isDesc {
+ iterator = ic.cache.Descend
+ }
+
+ for sf := range iterator(index, startKey, endKey) {
+ if !ic.matchesFilter(sf, parsed) {
+ continue
+ }
+
+ if len(items) == int(req.PageSize) {
+ // Get the key for the current item based on the index
+ rawKey, err := ic.getKeyForIndex(sf, index)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ // Encode the next page token to base32hex
+ nextPageToken = base32.HexEncoding.WithPadding(base32.NoPadding).EncodeToString([]byte(rawKey))
+ break
+ }
+
+ item := ic.unifiedInstanceToProto(sf)
+ items = append(items, item)
+ }
+
+ return &inventoryv1.ListUnifiedInstancesResponse{
+ Items: items,
+ NextPageToken: nextPageToken,
+ }, nil
+}
+
+// matchesFilter checks if a unified instance matches the filter criteria.
+func (ic *InventoryCache) matchesFilter(ui *inventoryInstance, parsed *parsedFilter) bool {
+ if parsed == nil || parsed.filter == nil {
+ return true
+ }
+
+ filter := parsed.filter
+
+ // Filter by instance types
+ isInstance := ui.isInstance()
+ matchesType := false
+ for _, instanceType := range filter.InstanceTypes {
+ if instanceType == inventoryv1.InstanceType_INSTANCE_TYPE_INSTANCE && isInstance {
+ matchesType = true
+ break
+ }
+ if instanceType == inventoryv1.InstanceType_INSTANCE_TYPE_BOT_INSTANCE && !isInstance {
+ matchesType = true
+ break
+ }
+ }
+ if len(filter.InstanceTypes) > 0 && !matchesType {
+ return false
+ }
+
+ // Basic search
+ if filter.Search != "" {
+ var searchableText string
+ if ui.isInstance() {
+ // For instances, search by hostname or instance ID
+ searchableText = ui.instance.Spec.Hostname + " " + ui.instance.GetName()
+ } else {
+ // For bot instances, search by bot name or instance ID
+ searchableText = ui.bot.Spec.BotName + " " + ui.bot.GetMetadata().GetName()
+ }
+
+ searchTerms := strings.Fields(filter.Search)
+ matchedAll := true
+ for _, term := range searchTerms {
+ if !strcase.Contains(searchableText, term) {
+ matchedAll = false
+ break
+ }
+ }
+
+ if !matchedAll {
+ return false
+ }
+ }
+
+ // Filter by services (only applies to instances)
+ if len(filter.Services) > 0 {
+ // Bot instances don't have services, so exclude them when the services filter is active
+ if !ui.isInstance() {
+ return false
+ }
+ filterServices := utils.NewSet(filter.Services...)
+ hasService := false
+ for _, svc := range ui.instance.Spec.Services {
+ if filterServices.Contains(string(svc)) {
+ hasService = true
+ break
+ }
+ }
+ if !hasService {
+ return false
+ }
+ }
+
+ // Filter by updater groups
+ if len(filter.UpdaterGroups) > 0 {
+ var updateGroup string
+ if ui.isInstance() {
+ if ui.instance.Spec.UpdaterInfo != nil {
+ updateGroup = ui.instance.Spec.UpdaterInfo.UpdateGroup
+ }
+ } else {
+ if len(ui.bot.Status.LatestHeartbeats) > 0 && ui.bot.Status.LatestHeartbeats[0].UpdaterInfo != nil {
+ updateGroup = ui.bot.Status.LatestHeartbeats[0].UpdaterInfo.UpdateGroup
+ }
+ }
+ if !slices.Contains(filter.UpdaterGroups, updateGroup) {
+ return false
+ }
+ }
+
+ // Filter by upgraders
+ if len(filter.Upgraders) > 0 {
+ var upgrader string
+ if ui.isInstance() {
+ upgrader = ui.instance.Spec.ExternalUpgrader
+ } else {
+ if len(ui.bot.Status.LatestHeartbeats) > 0 {
+ upgrader = ui.bot.Status.LatestHeartbeats[0].ExternalUpdater
+ }
+ }
+ if !slices.Contains(filter.Upgraders, upgrader) {
+ return false
+ }
+ }
+
+ // Filter with predicate language query
+ if filter.PredicateExpression != "" {
+ match, err := ic.matchSearchKeywords(ui, parsed)
+ if err != nil {
+ ic.cfg.Logger.DebugContext(context.Background(), "Failed to filter instances using predicate expression", "error", err)
+ return false
+ }
+ if !match {
+ return false
+ }
+ }
+
+ return true
+}
+
+// matchSearchKeywords evaluates predicate language expressions against a unified instance.
+func (ic *InventoryCache) matchSearchKeywords(ui *inventoryInstance, parsed *parsedFilter) (bool, error) {
+ if parsed.unifiedInstanceExpression == nil {
+ return true, nil
+ }
+
+ env := &unifiedFilterEnvironment{
+ ui: ui,
+ }
+
+ match, err := parsed.unifiedInstanceExpression.Evaluate(env)
+ if err != nil {
+ return false, trace.Wrap(err)
+ }
+
+ return match, nil
+}
+
+// unifiedFilterEnvironment is the filter environment for evaluating expressions on both instances and bot instances.
+type unifiedFilterEnvironment struct {
+ ui *inventoryInstance
+}
+
+func (e *unifiedFilterEnvironment) GetVersion() string {
+ if e == nil || e.ui == nil {
+ return ""
+ }
+ if e.ui.isInstance() {
+ // Trim "v" prefix if it's there
+ return strings.TrimPrefix(e.ui.instance.Spec.Version, "v")
+ }
+ // For bot instances, get version from latest heartbeat
+ if e.ui.bot.Status != nil && len(e.ui.bot.Status.LatestHeartbeats) > 0 {
+ // Trim "v" prefix if it's there
+ return strings.TrimPrefix(e.ui.bot.Status.LatestHeartbeats[0].Version, "v")
+ }
+ return ""
+}
+
+func (e *unifiedFilterEnvironment) GetHostname() string {
+ if e == nil || e.ui == nil {
+ return ""
+ }
+ if e.ui.isInstance() {
+ return e.ui.instance.Spec.Hostname
+ }
+ // For bot instances, get hostname from latest heartbeat
+ if e.ui.bot.Status != nil && len(e.ui.bot.Status.LatestHeartbeats) > 0 {
+ return e.ui.bot.Status.LatestHeartbeats[0].Hostname
+ }
+ return ""
+}
+
+func (e *unifiedFilterEnvironment) GetName() string {
+ if e == nil || e.ui == nil {
+ return ""
+ }
+ if e.ui.isInstance() {
+ return e.ui.instance.GetMetadata().Name
+ }
+ return e.ui.bot.GetMetadata().GetName()
+}
+
+func (e *unifiedFilterEnvironment) GetBotName() string {
+ if e == nil || e.ui == nil || e.ui.isInstance() {
+ return ""
+ }
+ return e.ui.bot.Spec.BotName
+}
+
+func (e *unifiedFilterEnvironment) GetInstanceID() string {
+ if e == nil || e.ui == nil {
+ return ""
+ }
+ if e.ui.isInstance() {
+ return e.ui.instance.GetName()
+ }
+ return e.ui.bot.Spec.InstanceId
+}
+
+func (e *unifiedFilterEnvironment) GetServices() []string {
+ if e == nil || e.ui == nil || !e.ui.isInstance() {
+ return nil
+ }
+ services := e.ui.instance.Spec.Services
+ result := make([]string, len(services))
+ for i, svc := range services {
+ result[i] = string(svc)
+ }
+ return result
+}
+
+func (e *unifiedFilterEnvironment) GetUpdaterGroup() string {
+ if e == nil || e.ui == nil {
+ return ""
+ }
+ if e.ui.isInstance() {
+ if e.ui.instance.Spec.UpdaterInfo != nil {
+ return e.ui.instance.Spec.UpdaterInfo.UpdateGroup
+ }
+ return ""
+ }
+ // For bot instances, get from latest heartbeat
+ if e.ui.bot.Status != nil && len(e.ui.bot.Status.LatestHeartbeats) > 0 && e.ui.bot.Status.LatestHeartbeats[0].UpdaterInfo != nil {
+ return e.ui.bot.Status.LatestHeartbeats[0].UpdaterInfo.UpdateGroup
+ }
+ return ""
+}
+
+func (e *unifiedFilterEnvironment) GetExternalUpgrader() string {
+ if e == nil || e.ui == nil {
+ return ""
+ }
+ if e.ui.isInstance() {
+ return e.ui.instance.Spec.ExternalUpgrader
+ }
+ // For bot instances, get from latest heartbeat
+ if e.ui.bot.Status != nil && len(e.ui.bot.Status.LatestHeartbeats) > 0 {
+ return e.ui.bot.Status.LatestHeartbeats[0].ExternalUpdater
+ }
+ return ""
+}
+
+// getUnifiedExpressionParser returns a cached parser instance, initializing it once.
+// Panics if the parser cannot be created, similar to regexp.MustCompile.
+func getUnifiedExpressionParser() *typical.Parser[*unifiedFilterEnvironment, bool] {
+ unifiedExpressionParserOnce.Do(func() {
+ spec := expression.DefaultParserSpec[*unifiedFilterEnvironment]()
+
+ newVariables := map[string]typical.Variable{
+ "version": typical.DynamicVariable(func(env *unifiedFilterEnvironment) (string, error) {
+ return env.GetVersion(), nil
+ }),
+ "status.latest_heartbeat.version": typical.DynamicVariable(func(env *unifiedFilterEnvironment) (string, error) {
+ return env.GetVersion(), nil
+ }),
+ "hostname": typical.DynamicVariable(func(env *unifiedFilterEnvironment) (string, error) {
+ return env.GetHostname(), nil
+ }),
+ "spec.hostname": typical.DynamicVariable(func(env *unifiedFilterEnvironment) (string, error) {
+ return env.GetHostname(), nil
+ }),
+ "status.latest_heartbeat.hostname": typical.DynamicVariable(func(env *unifiedFilterEnvironment) (string, error) {
+ return env.GetHostname(), nil
+ }),
+ "name": typical.DynamicVariable(func(env *unifiedFilterEnvironment) (string, error) {
+ return env.GetName(), nil
+ }),
+ "metadata.name": typical.DynamicVariable(func(env *unifiedFilterEnvironment) (string, error) {
+ return env.GetName(), nil
+ }),
+ "spec.bot_name": typical.DynamicVariable(func(env *unifiedFilterEnvironment) (string, error) {
+ return env.GetBotName(), nil
+ }),
+ "spec.instance_id": typical.DynamicVariable(func(env *unifiedFilterEnvironment) (string, error) {
+ return env.GetInstanceID(), nil
+ }),
+ "spec.services": typical.DynamicVariable(func(env *unifiedFilterEnvironment) ([]string, error) {
+ return env.GetServices(), nil
+ }),
+ "spec.updater_group": typical.DynamicVariable(func(env *unifiedFilterEnvironment) (string, error) {
+ return env.GetUpdaterGroup(), nil
+ }),
+ "status.latest_heartbeat.updater_info.update_group": typical.DynamicVariable(func(env *unifiedFilterEnvironment) (string, error) {
+ return env.GetUpdaterGroup(), nil
+ }),
+ "spec.external_upgrader": typical.DynamicVariable(func(env *unifiedFilterEnvironment) (string, error) {
+ return env.GetExternalUpgrader(), nil
+ }),
+ "status.latest_heartbeat.external_updater": typical.DynamicVariable(func(env *unifiedFilterEnvironment) (string, error) {
+ return env.GetExternalUpgrader(), nil
+ }),
+ }
+
+ if len(spec.Variables) < 1 {
+ spec.Variables = newVariables
+ } else {
+ maps.Copy(spec.Variables, newVariables)
+ }
+
+ var err error
+ unifiedExpressionParser, err = typical.NewParser[*unifiedFilterEnvironment, bool](spec)
+ if err != nil {
+ panic(fmt.Sprintf("failed to initialize unified expression parser: %v", err))
+ }
+ })
+
+ return unifiedExpressionParser
+}
+
+// getKeyForIndex returns the key a the given instance based on the index
+func (ic *InventoryCache) getKeyForIndex(ui *inventoryInstance, index inventoryIndex) (string, error) {
+ switch index {
+ case inventoryAlphabeticalIndex:
+ return ui.getAlphabeticalKey(), nil
+ case inventoryTypeIndex:
+ return ui.getTypeKey(), nil
+ case inventoryVersionIndex:
+ return ui.getVersionKey(), nil
+ case inventoryIDIndex:
+ return ui.getIDKey(), nil
+ default:
+ return "", trace.BadParameter("unknown index: %v", index)
+ }
+}
+
+// unifiedInstanceToProto converts a unified instance to a proto UnifiedInstanceItem.
+func (ic *InventoryCache) unifiedInstanceToProto(ui *inventoryInstance) *inventoryv1.UnifiedInstanceItem {
+ if ui.isInstance() {
+ return &inventoryv1.UnifiedInstanceItem{
+ Item: &inventoryv1.UnifiedInstanceItem_Instance{
+ Instance: ui.instance,
+ },
+ }
+ }
+ return &inventoryv1.UnifiedInstanceItem{
+ Item: &inventoryv1.UnifiedInstanceItem_BotInstance{
+ BotInstance: ui.bot,
+ },
+ }
+}
diff --git a/lib/cache/inventory/inventory_cache_test.go b/lib/cache/inventory/inventory_cache_test.go
new file mode 100644
index 0000000000000..39b94ef6d4fd8
--- /dev/null
+++ b/lib/cache/inventory/inventory_cache_test.go
@@ -0,0 +1,1753 @@
+// 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 inventory
+
+import (
+ "cmp"
+ "context"
+ "encoding/base32"
+ "fmt"
+ "slices"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/coreos/go-semver/semver"
+ "github.com/gravitational/trace"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/protobuf/types/known/timestamppb"
+ "rsc.io/ordered"
+
+ headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1"
+ inventoryv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/inventory/v1"
+ machineidv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1"
+ "github.com/gravitational/teleport/api/internalutils/stream"
+ "github.com/gravitational/teleport/api/types"
+ "github.com/gravitational/teleport/lib/backend"
+ "github.com/gravitational/teleport/lib/backend/memory"
+ "github.com/gravitational/teleport/lib/cache"
+ "github.com/gravitational/teleport/lib/modules/modulestest"
+ "github.com/gravitational/teleport/lib/services"
+ "github.com/gravitational/teleport/lib/services/local"
+ "github.com/gravitational/teleport/lib/services/local/generic"
+ libslices "github.com/gravitational/teleport/lib/utils/slices"
+ "github.com/gravitational/teleport/lib/utils/testutils/synctest"
+)
+
+// testCache holds the resources needed for creating a test cache
+type testCache struct {
+ Backend *backend.Wrapper
+ Cache *cache.Cache
+ EventsC chan cache.Event
+}
+
+func (tc *testCache) Close() error {
+ var errors []error
+ if tc.Cache != nil {
+ errors = append(errors, tc.Cache.Close())
+ }
+ if tc.Backend != nil {
+ errors = append(errors, tc.Backend.Close())
+ }
+ return trace.NewAggregate(errors...)
+}
+
+func getItemName(item *inventoryv1.UnifiedInstanceItem) string {
+ if item == nil {
+ return ""
+ }
+ if instance := item.GetInstance(); instance != nil {
+ return instance.Spec.Hostname
+ } else if bot := item.GetBotInstance(); bot != nil {
+ return bot.Spec.BotName
+ }
+ return ""
+}
+
+func getItemVersion(item *inventoryv1.UnifiedInstanceItem) string {
+ if item == nil {
+ return ""
+ }
+ if instance := item.GetInstance(); instance != nil {
+ return instance.Spec.Version
+ } else if bot := item.GetBotInstance(); bot != nil {
+ if len(bot.Status.LatestHeartbeats) > 0 {
+ return bot.Status.LatestHeartbeats[0].Version
+ }
+ }
+ return ""
+}
+
+// setupTestCache creates a new test cache
+func setupTestCache(t *testing.T, setupConfig cache.SetupConfigFn) (*testCache, error) {
+ t.Helper()
+ ctx := t.Context()
+
+ bk, err := memory.New(memory.Config{
+ Context: ctx,
+ Mirror: true,
+ })
+ require.NoError(t, err)
+ bkWrapper := backend.NewWrapper(bk)
+
+ eventsC := make(chan cache.Event, 1024)
+
+ clusterConfig, err := local.NewClusterConfigurationService(bkWrapper)
+ require.NoError(t, err)
+
+ idService, err := local.NewTestIdentityService(bkWrapper)
+ require.NoError(t, err)
+
+ dynamicWindowsDesktopService, err := local.NewDynamicWindowsDesktopService(bkWrapper)
+ require.NoError(t, err)
+
+ trustS := local.NewCAService(bkWrapper)
+ provisionerS := local.NewProvisioningService(bkWrapper)
+ eventsS := local.NewEventsService(bkWrapper)
+ presenceS := local.NewPresenceService(bkWrapper)
+ accessS := local.NewAccessService(bkWrapper)
+ dynamicAccessS := local.NewDynamicAccessService(bkWrapper)
+ restrictions := local.NewRestrictionsService(bkWrapper)
+ apps := local.NewAppService(bkWrapper)
+ kubernetes := local.NewKubernetesService(bkWrapper)
+ databases := local.NewDatabasesService(bkWrapper)
+ databaseServices := local.NewDatabaseServicesService(bkWrapper)
+ windowsDesktops := local.NewWindowsDesktopService(bkWrapper)
+
+ samlIDPServiceProviders, err := local.NewSAMLIdPServiceProviderService(bkWrapper)
+ require.NoError(t, err)
+
+ userGroups, err := local.NewUserGroupService(bkWrapper)
+ require.NoError(t, err)
+
+ oktaSvc, err := local.NewOktaService(bkWrapper, bkWrapper.Clock())
+ require.NoError(t, err)
+
+ igSvc, err := local.NewIntegrationsService(bkWrapper, local.WithIntegrationsServiceCacheMode(true))
+ require.NoError(t, err)
+
+ userTasksSvc, err := local.NewUserTasksService(bkWrapper)
+ require.NoError(t, err)
+
+ dcSvc, err := local.NewDiscoveryConfigService(bkWrapper)
+ require.NoError(t, err)
+
+ ulsSvc, err := local.NewUserLoginStateService(bkWrapper)
+ require.NoError(t, err)
+
+ secReportsSvc, err := local.NewSecReportsService(bkWrapper, bkWrapper.Clock())
+ require.NoError(t, err)
+
+ accessListsSvc, err := local.NewAccessListServiceV2(local.AccessListServiceConfig{
+ Backend: bkWrapper,
+ Modules: modulestest.EnterpriseModules(),
+ })
+ require.NoError(t, err)
+
+ accessMonitoringRuleService, err := local.NewAccessMonitoringRulesService(bkWrapper)
+ require.NoError(t, err)
+
+ crownJewelsSvc, err := local.NewCrownJewelsService(bkWrapper)
+ require.NoError(t, err)
+
+ spiffeFederationsSvc, err := local.NewSPIFFEFederationService(bkWrapper)
+ require.NoError(t, err)
+
+ workloadIdentitySvc, err := local.NewWorkloadIdentityService(bkWrapper)
+ require.NoError(t, err)
+
+ databaseObjectsSvc, err := local.NewDatabaseObjectService(bkWrapper)
+ require.NoError(t, err)
+
+ kubeWaitingContSvc, err := local.NewKubeWaitingContainerService(bkWrapper)
+ require.NoError(t, err)
+
+ notificationsSvc, err := local.NewNotificationsService(bkWrapper, bkWrapper.Clock())
+ require.NoError(t, err)
+
+ staticHostUserService, err := local.NewStaticHostUserService(bkWrapper)
+ require.NoError(t, err)
+
+ autoUpdateService, err := local.NewAutoUpdateService(bkWrapper)
+ require.NoError(t, err)
+
+ provisioningStates, err := local.NewProvisioningStateService(bkWrapper)
+ require.NoError(t, err)
+
+ identityCenter, err := local.NewIdentityCenterService(local.IdentityCenterServiceConfig{
+ Backend: bkWrapper,
+ })
+ require.NoError(t, err)
+
+ pluginStaticCredentials, err := local.NewPluginStaticCredentialsService(bkWrapper)
+ require.NoError(t, err)
+
+ gitServers, err := local.NewGitServerService(bkWrapper)
+ require.NoError(t, err)
+
+ healthCheckConfig, err := local.NewHealthCheckConfigService(bkWrapper)
+ require.NoError(t, err)
+
+ botInstanceService, err := local.NewBotInstanceService(bkWrapper, bkWrapper.Clock())
+ require.NoError(t, err)
+
+ recordingEncryption, err := local.NewRecordingEncryptionService(bkWrapper)
+ require.NoError(t, err)
+
+ workloadClusters, err := local.NewWorkloadClusterService(bkWrapper)
+ require.NoError(t, err)
+
+ plugin := local.NewPluginsService(bkWrapper)
+
+ c, err := cache.New(setupConfig(cache.Config{
+ Context: ctx,
+ Events: eventsS,
+ ClusterConfig: clusterConfig,
+ Provisioner: provisionerS,
+ Trust: trustS,
+ Users: idService,
+ Access: accessS,
+ DynamicAccess: dynamicAccessS,
+ Presence: presenceS,
+ AppSession: idService,
+ WebSession: idService.WebSessions(),
+ WebToken: idService,
+ SnowflakeSession: idService,
+ Restrictions: restrictions,
+ Apps: apps,
+ Kubernetes: kubernetes,
+ DatabaseServices: databaseServices,
+ Databases: databases,
+ WindowsDesktops: windowsDesktops,
+ DynamicWindowsDesktops: dynamicWindowsDesktopService,
+ SAMLIdPServiceProviders: samlIDPServiceProviders,
+ UserGroups: userGroups,
+ Okta: oktaSvc,
+ Integrations: igSvc,
+ UserTasks: userTasksSvc,
+ DiscoveryConfigs: dcSvc,
+ UserLoginStates: ulsSvc,
+ SecReports: secReportsSvc,
+ AccessLists: accessListsSvc,
+ KubeWaitingContainers: kubeWaitingContSvc,
+ Notifications: notificationsSvc,
+ AccessMonitoringRules: accessMonitoringRuleService,
+ CrownJewels: crownJewelsSvc,
+ SPIFFEFederations: spiffeFederationsSvc,
+ DatabaseObjects: databaseObjectsSvc,
+ StaticHostUsers: staticHostUserService,
+ AutoUpdateService: autoUpdateService,
+ ProvisioningStates: provisioningStates,
+ IdentityCenter: identityCenter,
+ PluginStaticCredentials: pluginStaticCredentials,
+ GitServers: gitServers,
+ HealthCheckConfig: healthCheckConfig,
+ WorkloadIdentity: workloadIdentitySvc,
+ BotInstanceService: botInstanceService,
+ RecordingEncryption: recordingEncryption,
+ StaticScopedToken: clusterConfig,
+ Plugin: plugin,
+ MaxRetryPeriod: 200 * time.Millisecond,
+ EventsC: eventsC,
+ WorkloadClusterService: workloadClusters,
+ }))
+ require.NoError(t, err)
+
+ select {
+ case event := <-eventsC:
+ require.Equal(t, cache.WatcherStarted, event.Type)
+ case <-time.After(time.Second):
+ t.Fatal("timeout waiting for watcher to start")
+ }
+
+ return &testCache{
+ Backend: bkWrapper,
+ Cache: c,
+ EventsC: eventsC,
+ }, nil
+}
+
+// TestInventoryCache tests the initialization and use of the inventory cache.
+func TestInventoryCache(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ ctx := t.Context()
+
+ p, err := setupTestCache(t, cache.ForAuth)
+ require.NoError(t, err)
+ defer p.Close()
+
+ // Create mock instances
+ instances := []*types.InstanceV1{
+ {
+ ResourceHeader: types.ResourceHeader{
+ Metadata: types.Metadata{
+ Name: "agent1",
+ },
+ },
+ Spec: types.InstanceSpecV1{
+ Hostname: "agent1.example.com",
+ Version: "18.1.0",
+ Services: []types.SystemRole{types.RoleNode, types.RoleDatabase, types.RoleApp},
+ ExternalUpgrader: "unit",
+ UpdaterInfo: &types.UpdaterV2Info{
+ UpdateGroup: "group1",
+ },
+ },
+ },
+ {
+ ResourceHeader: types.ResourceHeader{
+ Metadata: types.Metadata{
+ Name: "agent2",
+ },
+ },
+ Spec: types.InstanceSpecV1{
+ Hostname: "kube1.example.com",
+ Version: "18.2.0",
+ Services: []types.SystemRole{types.RoleKube},
+ ExternalUpgrader: "kube",
+ UpdaterInfo: &types.UpdaterV2Info{
+ UpdateGroup: "group2",
+ },
+ },
+ },
+ {
+ ResourceHeader: types.ResourceHeader{
+ Metadata: types.Metadata{
+ Name: "agent3",
+ },
+ },
+ Spec: types.InstanceSpecV1{
+ Hostname: "node1.example.com",
+ Version: "18.2.0",
+ Services: []types.SystemRole{types.RoleNode, types.RoleDatabase},
+ ExternalUpgrader: "unit",
+ UpdaterInfo: &types.UpdaterV2Info{
+ UpdateGroup: "group1",
+ },
+ },
+ },
+ {
+ ResourceHeader: types.ResourceHeader{
+ Metadata: types.Metadata{
+ Name: "agent4",
+ },
+ },
+ Spec: types.InstanceSpecV1{
+ Hostname: "app1.example.com",
+ Version: "18.2.0",
+ Services: []types.SystemRole{types.RoleApp},
+ ExternalUpgrader: "unit",
+ UpdaterInfo: &types.UpdaterV2Info{
+ UpdateGroup: "group2",
+ },
+ },
+ },
+ {
+ ResourceHeader: types.ResourceHeader{
+ Metadata: types.Metadata{
+ Name: "agent5",
+ },
+ },
+ Spec: types.InstanceSpecV1{
+ Hostname: "desktop1.example.com",
+ Version: "18.1.0",
+ Services: []types.SystemRole{types.RoleWindowsDesktop},
+ ExternalUpgrader: "unit",
+ UpdaterInfo: &types.UpdaterV2Info{
+ UpdateGroup: "group1",
+ },
+ },
+ },
+ {
+ ResourceHeader: types.ResourceHeader{
+ Metadata: types.Metadata{
+ Name: "agent6",
+ },
+ },
+ Spec: types.InstanceSpecV1{
+ Hostname: "proxy1.example.com",
+ Version: "18.1.0",
+ Services: []types.SystemRole{types.RoleProxy},
+ ExternalUpgrader: "unit",
+ },
+ },
+ {
+ ResourceHeader: types.ResourceHeader{
+ Metadata: types.Metadata{
+ Name: "agent7",
+ },
+ },
+ Spec: types.InstanceSpecV1{
+ Hostname: "auth1.example.com",
+ Version: "18.1.0",
+ Services: []types.SystemRole{types.RoleAuth},
+ },
+ },
+ {
+ ResourceHeader: types.ResourceHeader{
+ Metadata: types.Metadata{
+ Name: "agent8",
+ },
+ },
+ Spec: types.InstanceSpecV1{
+ Hostname: "discovery1.example.com",
+ Version: "18.2.0",
+ Services: []types.SystemRole{types.RoleDiscovery},
+ },
+ },
+ }
+
+ // Create mock bot instances
+ bots := []*machineidv1.BotInstance{
+ {
+ Metadata: &headerv1.Metadata{
+ Name: "bot1",
+ },
+ Spec: &machineidv1.BotInstanceSpec{
+ BotName: "bot-1",
+ InstanceId: "bot1",
+ },
+ Status: &machineidv1.BotInstanceStatus{
+ LatestHeartbeats: []*machineidv1.BotInstanceStatusHeartbeat{
+ {
+ RecordedAt: timestamppb.Now(),
+ Version: "18.1.0",
+ },
+ },
+ },
+ },
+ {
+ Metadata: &headerv1.Metadata{
+ Name: "bot2",
+ },
+ Spec: &machineidv1.BotInstanceSpec{
+ BotName: "bot-2",
+ InstanceId: "bot2",
+ },
+ Status: &machineidv1.BotInstanceStatus{
+ LatestHeartbeats: []*machineidv1.BotInstanceStatusHeartbeat{
+ {
+ RecordedAt: timestamppb.Now(),
+ Version: "18.2.0",
+ },
+ },
+ },
+ },
+ {
+ Metadata: &headerv1.Metadata{
+ Name: "bot3",
+ },
+ Spec: &machineidv1.BotInstanceSpec{
+ BotName: "bot-3",
+ InstanceId: "bot3",
+ },
+ Status: &machineidv1.BotInstanceStatus{
+ LatestHeartbeats: []*machineidv1.BotInstanceStatusHeartbeat{
+ {
+ RecordedAt: timestamppb.Now(),
+ Version: "18.2.0",
+ },
+ },
+ },
+ },
+ {
+ Metadata: &headerv1.Metadata{
+ Name: "bot4",
+ },
+ Spec: &machineidv1.BotInstanceSpec{
+ BotName: "bot-4",
+ InstanceId: "bot4",
+ },
+ Status: &machineidv1.BotInstanceStatus{
+ LatestHeartbeats: []*machineidv1.BotInstanceStatusHeartbeat{
+ {
+ RecordedAt: timestamppb.Now(),
+ Version: "18.1.0",
+ },
+ },
+ },
+ },
+ }
+
+ mockInventory := &mockInventoryService{instances: instances}
+ mockBotCache := &mockBotInstanceCache{bots: bots}
+
+ // Create inventory cache
+ inventoryCache, err := NewInventoryCache(InventoryCacheConfig{
+ PrimaryCache: p.Cache,
+ Events: local.NewEventsService(p.Backend),
+ Inventory: mockInventory,
+ BotInstanceBackend: mockBotCache,
+ TargetVersion: "18.2.0",
+ })
+ require.NoError(t, err)
+ defer inventoryCache.Close()
+
+ // Wait for the inventory cache to initialize by blocking until all the initialization goroutines are durably blocked, which means
+ // the cache has been initialized.
+ synctest.Wait()
+ require.True(t, inventoryCache.IsHealthy())
+
+ // Verify all the instances were loaded into the cache
+ listResp, err := inventoryCache.ListUnifiedInstances(ctx, &inventoryv1.ListUnifiedInstancesRequest{
+ PageSize: 100,
+ })
+ require.NoError(t, err)
+ require.Len(t, listResp.Items, 12, "should have 8 instances + 4 bots")
+
+ // Verify results are correctly alphabetically sorted
+ expectedOrder := []string{
+ "agent1.example.com",
+ "app1.example.com",
+ "auth1.example.com",
+ "bot-1",
+ "bot-2",
+ "bot-3",
+ "bot-4",
+ "desktop1.example.com",
+ "discovery1.example.com",
+ "kube1.example.com",
+ "node1.example.com",
+ "proxy1.example.com",
+ }
+
+ require.Equal(t, expectedOrder, libslices.Map(listResp.Items, getItemName))
+
+ // Verify pagination works as intended
+ firstPageResp, err := inventoryCache.ListUnifiedInstances(ctx, &inventoryv1.ListUnifiedInstancesRequest{
+ PageSize: 5,
+ })
+ require.NoError(t, err)
+ require.Len(t, firstPageResp.Items, 5, "first page should have 5 items, got %d", len(firstPageResp.Items))
+
+ // The next page token should be the alphabetical key of the first item on the next page (6th item)
+ expectedNextPageToken := base32.HexEncoding.WithPadding(base32.NoPadding).EncodeToString([]byte(string(ordered.Encode("bot-3", "bot3", types.KindBotInstance))))
+ require.Equal(t, expectedNextPageToken, firstPageResp.NextPageToken)
+
+ // Fetch another page of 5, this time using the page token from before
+ secondPageResp, err := inventoryCache.ListUnifiedInstances(ctx, &inventoryv1.ListUnifiedInstancesRequest{
+ PageSize: 5,
+ PageToken: firstPageResp.NextPageToken,
+ })
+ require.NoError(t, err)
+ require.Len(t, secondPageResp.Items, 5, "second page should have 5 items, got %d", len(secondPageResp.Items))
+
+ // The returned next page token should be the alphabetical key of the first item on the third page (11th item)
+ expectedSecondPageToken := base32.HexEncoding.WithPadding(base32.NoPadding).EncodeToString([]byte(string(ordered.Encode("node1.example.com", "agent3", types.KindInstance))))
+ require.Equal(t, expectedSecondPageToken, secondPageResp.NextPageToken, "second page next token should match expected format")
+
+ // Fetch another page of 5, using the page token from before
+ thirdPageResp, err := inventoryCache.ListUnifiedInstances(ctx, &inventoryv1.ListUnifiedInstancesRequest{
+ PageSize: 5,
+ PageToken: secondPageResp.NextPageToken,
+ })
+ require.NoError(t, err)
+ // We should only get 2 items, and no next page token
+ require.Len(t, thirdPageResp.Items, 2, "third page should have 2 items, got %d", len(thirdPageResp.Items))
+ require.Empty(t, thirdPageResp.NextPageToken, "third page should not have a next page token")
+
+ // Verify filtering by kind works
+ instancesOnlyResp, err := inventoryCache.ListUnifiedInstances(ctx, &inventoryv1.ListUnifiedInstancesRequest{
+ PageSize: 100,
+ Filter: &inventoryv1.ListUnifiedInstancesFilter{
+ InstanceTypes: []inventoryv1.InstanceType{inventoryv1.InstanceType_INSTANCE_TYPE_INSTANCE},
+ },
+ })
+ require.NoError(t, err)
+ require.Len(t, instancesOnlyResp.Items, 8, "should have 8 results when filtering by KindInstance")
+
+ botsOnlyResp, err := inventoryCache.ListUnifiedInstances(ctx, &inventoryv1.ListUnifiedInstancesRequest{
+ PageSize: 100,
+ Filter: &inventoryv1.ListUnifiedInstancesFilter{
+ InstanceTypes: []inventoryv1.InstanceType{inventoryv1.InstanceType_INSTANCE_TYPE_BOT_INSTANCE},
+ },
+ })
+ require.NoError(t, err)
+ require.Len(t, botsOnlyResp.Items, 4, "should have 4 results when filtering by KindBotInstance")
+ })
+}
+
+// TestInventoryCacheWatcher tests the inventory cache watcher.
+func TestInventoryCacheWatcher(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ ctx := t.Context()
+
+ p, err := setupTestCache(t, cache.ForAuth)
+ require.NoError(t, err)
+ defer p.Close()
+
+ // Create 2 instances
+ instances := []*types.InstanceV1{
+ {
+ ResourceHeader: types.ResourceHeader{
+ Metadata: types.Metadata{
+ Name: "agent1",
+ },
+ },
+ Spec: types.InstanceSpecV1{
+ Hostname: "agent1.example.com",
+ Version: "18.1.0",
+ Services: []types.SystemRole{types.RoleNode, types.RoleDatabase, types.RoleApp},
+ ExternalUpgrader: "unit",
+ UpdaterInfo: &types.UpdaterV2Info{
+ UpdateGroup: "group1",
+ },
+ },
+ },
+ {
+ ResourceHeader: types.ResourceHeader{
+ Metadata: types.Metadata{
+ Name: "agent2",
+ },
+ },
+ Spec: types.InstanceSpecV1{
+ Hostname: "agent2.example.com",
+ Version: "18.2.0",
+ Services: []types.SystemRole{types.RoleNode},
+ ExternalUpgrader: "unit",
+ UpdaterInfo: &types.UpdaterV2Info{
+ UpdateGroup: "group1",
+ },
+ },
+ },
+ }
+
+ // Create 2 bot instances
+ bots := []*machineidv1.BotInstance{
+ {
+ Metadata: &headerv1.Metadata{
+ Name: "bot1",
+ },
+ Spec: &machineidv1.BotInstanceSpec{
+ BotName: "bot-1",
+ InstanceId: "bot1",
+ },
+ Status: &machineidv1.BotInstanceStatus{
+ LatestHeartbeats: []*machineidv1.BotInstanceStatusHeartbeat{
+ {
+ RecordedAt: timestamppb.Now(),
+ Version: "18.1.0",
+ },
+ },
+ },
+ },
+ {
+ Metadata: &headerv1.Metadata{
+ Name: "bot2",
+ },
+ Spec: &machineidv1.BotInstanceSpec{
+ BotName: "bot-2",
+ InstanceId: "bot2",
+ },
+ Status: &machineidv1.BotInstanceStatus{
+ LatestHeartbeats: []*machineidv1.BotInstanceStatusHeartbeat{
+ {
+ RecordedAt: timestamppb.Now(),
+ Version: "18.2.0",
+ },
+ },
+ },
+ },
+ }
+
+ mockInventoryService := &mockInventoryService{
+ instances: instances,
+ }
+
+ mockBotInstanceCache := &mockBotInstanceCache{
+ bots: bots,
+ }
+
+ inventoryCache, err := NewInventoryCache(InventoryCacheConfig{
+ Inventory: mockInventoryService,
+ BotInstanceBackend: mockBotInstanceCache,
+ Events: local.NewEventsService(p.Backend),
+ PrimaryCache: p.Cache,
+ TargetVersion: "18.2.0",
+ })
+ require.NoError(t, err)
+ defer inventoryCache.Close()
+
+ // Wait for the inventory cache to initialize by blocking until all the initialization goroutines are durably blocked, which means
+ // the cache has been initialized.
+ synctest.Wait()
+ require.True(t, inventoryCache.IsHealthy())
+
+ listResp, err := inventoryCache.ListUnifiedInstances(ctx, &inventoryv1.ListUnifiedInstancesRequest{
+ PageSize: 100,
+ })
+ require.NoError(t, err)
+ require.Len(t, listResp.Items, 4, "should start with 4 items (2 instances + 2 bots)")
+
+ // Add a new instance and a bot instance to the backend
+ newInstance := &types.InstanceV1{
+ ResourceHeader: types.ResourceHeader{
+ Metadata: types.Metadata{
+ Name: "agent3",
+ },
+ },
+ Spec: types.InstanceSpecV1{
+ Hostname: "newagent.example.com",
+ Version: "18.2.0",
+ Services: []types.SystemRole{types.RoleNode},
+ ExternalUpgrader: "unit",
+ UpdaterInfo: &types.UpdaterV2Info{
+ UpdateGroup: "group1",
+ },
+ },
+ }
+
+ newBot := &machineidv1.BotInstance{
+ Metadata: &headerv1.Metadata{
+ Name: "bot3",
+ },
+ Spec: &machineidv1.BotInstanceSpec{
+ BotName: "new-bot",
+ InstanceId: "bot3",
+ },
+ Status: &machineidv1.BotInstanceStatus{
+ LatestHeartbeats: []*machineidv1.BotInstanceStatusHeartbeat{
+ {
+ RecordedAt: timestamppb.Now(),
+ Version: "18.2.0",
+ },
+ },
+ },
+ }
+
+ newInstanceItem, err := generic.FastMarshal(backend.NewKey(instancePrefix, newInstance.GetName()), newInstance)
+ require.NoError(t, err)
+ _, err = p.Backend.Put(ctx, newInstanceItem)
+ require.NoError(t, err)
+
+ newBotBytes, err := services.MarshalBotInstance(newBot)
+ require.NoError(t, err)
+ _, err = p.Backend.Put(ctx, backend.Item{
+ Key: backend.NewKey(botInstancePrefix, newBot.Spec.BotName, newBot.Metadata.Name),
+ Value: newBotBytes,
+ })
+ require.NoError(t, err)
+
+ // Wait for the inventory cache watcher to process the put events
+ synctest.Wait()
+ listResp, err = inventoryCache.ListUnifiedInstances(ctx, &inventoryv1.ListUnifiedInstancesRequest{
+ PageSize: 100,
+ })
+ require.NoError(t, err)
+ require.Len(t, listResp.Items, 6, "should have 6 items after adding 2")
+
+ // Delete the newly added instances
+ err = p.Backend.Delete(ctx, backend.NewKey(instancePrefix, newInstance.GetName()))
+ require.NoError(t, err)
+
+ err = p.Backend.Delete(ctx, backend.NewKey(botInstancePrefix, newBot.Spec.BotName, newBot.Metadata.Name))
+ require.NoError(t, err)
+
+ // Wait for the watcher to process the delete events
+ synctest.Wait()
+ listResp, err = inventoryCache.ListUnifiedInstances(ctx, &inventoryv1.ListUnifiedInstancesRequest{
+ PageSize: 100,
+ })
+ require.NoError(t, err)
+ require.Len(t, listResp.Items, 4, "should have 4 items after deleting 2")
+ })
+}
+
+// TestInventoryCacheRateLimiting tests the rate limiting behavior of the inventory cache.
+func TestInventoryCacheRateLimiting(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ p, err := setupTestCache(t, cache.ForAuth)
+ require.NoError(t, err)
+ defer p.Close()
+
+ // Create 300 instances
+ const numInstances = 300
+ instances := make([]*types.InstanceV1, numInstances)
+ for i := range numInstances {
+ instances[i] = &types.InstanceV1{
+ ResourceHeader: types.ResourceHeader{
+ Metadata: types.Metadata{
+ Name: fmt.Sprintf("agent%d", i),
+ },
+ },
+ Spec: types.InstanceSpecV1{
+ Hostname: "agent.example.com",
+ Version: "18.2.0",
+ Services: []types.SystemRole{types.RoleNode},
+ },
+ }
+ }
+
+ mockInventory := &mockInventoryService{instances: instances}
+ mockBotCache := &mockBotInstanceCache{bots: nil}
+
+ inventoryCache, err := NewInventoryCache(InventoryCacheConfig{
+ PrimaryCache: p.Cache,
+ Events: local.NewEventsService(p.Backend),
+ Inventory: mockInventory,
+ BotInstanceBackend: mockBotCache,
+ TargetVersion: "18.2.0",
+ })
+ require.NoError(t, err)
+ defer inventoryCache.Close()
+
+ synctest.Wait()
+ time.Sleep(100 * time.Millisecond)
+ synctest.Wait()
+
+ // After only 100ms, not all instances should be loaded and the cache shouldn't be healthy yet.
+ initialCount := inventoryCache.cache.Len()
+ require.Greater(t, initialCount, 0, "expected some instances to be loaded")
+ require.Less(t, initialCount, numInstances,
+ "not all instances should be loaded immediately",
+ numInstances, initialCount)
+ require.False(t, inventoryCache.IsHealthy())
+
+ // After another 50s, more instances should be loaded, but not all.
+ time.Sleep(50 * time.Millisecond)
+ synctest.Wait()
+
+ require.Greater(t, inventoryCache.cache.Len(), initialCount,
+ "expected more instances to be loaded now than before, but not all", initialCount, inventoryCache.cache.Len())
+ require.Less(t, initialCount, numInstances,
+ "not all instances should be loaded yet",
+ numInstances, initialCount)
+ require.False(t, inventoryCache.IsHealthy())
+
+ time.Sleep(2 * time.Second)
+ synctest.Wait()
+
+ // After 2 seconds, all instances should be loaded and the cache should be healthy.
+ require.True(t, inventoryCache.IsHealthy())
+ finalCount := inventoryCache.cache.Len()
+ require.Equal(t, numInstances, finalCount,
+ "expected all %d instances to be loaded, got %d", numInstances, finalCount)
+ })
+}
+
+// TestInventoryCacheFiltering tests the filtering for the inventory cache.
+func TestInventoryCacheFiltering(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ ctx := t.Context()
+
+ bk, err := memory.New(memory.Config{
+ Context: ctx,
+ })
+ require.NoError(t, err)
+ defer bk.Close()
+
+ p, err := setupTestCache(t, cache.ForAuth)
+ require.NoError(t, err)
+ defer p.Close()
+
+ instances := []*types.InstanceV1{
+ {
+ ResourceHeader: types.ResourceHeader{
+ Metadata: types.Metadata{
+ Name: "node1",
+ },
+ },
+ Spec: types.InstanceSpecV1{
+ Hostname: "node1.example.com",
+ Version: "18.1.0",
+ Services: []types.SystemRole{types.RoleNode},
+ UpdaterInfo: &types.UpdaterV2Info{UpdateGroup: "group1"},
+ ExternalUpgrader: "kube",
+ },
+ },
+ {
+ ResourceHeader: types.ResourceHeader{
+ Metadata: types.Metadata{
+ Name: "node2",
+ },
+ },
+ Spec: types.InstanceSpecV1{
+ Hostname: "node2.example.com",
+ Version: "18.2.0",
+ Services: []types.SystemRole{types.RoleNode, types.RoleProxy},
+ UpdaterInfo: &types.UpdaterV2Info{UpdateGroup: "group1"},
+ ExternalUpgrader: "unit",
+ },
+ },
+ {
+ ResourceHeader: types.ResourceHeader{
+ Metadata: types.Metadata{
+ Name: "auth1",
+ },
+ },
+ Spec: types.InstanceSpecV1{
+ Hostname: "auth1.example.com",
+ Version: "19.0.0",
+ Services: []types.SystemRole{types.RoleAuth},
+ UpdaterInfo: &types.UpdaterV2Info{UpdateGroup: "group2"},
+ ExternalUpgrader: "kube",
+ },
+ },
+ {
+ ResourceHeader: types.ResourceHeader{
+ Metadata: types.Metadata{
+ Name: "node-no-upgrader",
+ },
+ },
+ Spec: types.InstanceSpecV1{
+ Hostname: "no-upgrader.example.com",
+ Version: "18.3.0",
+ Services: []types.SystemRole{types.RoleNode},
+ },
+ },
+ }
+
+ bots := []*machineidv1.BotInstance{
+ {
+ Kind: types.KindBotInstance,
+ Version: types.V1,
+ Metadata: &headerv1.Metadata{
+ Name: "bot1-instance1",
+ },
+ Spec: &machineidv1.BotInstanceSpec{
+ BotName: "bot1",
+ InstanceId: "instance1",
+ },
+ Status: &machineidv1.BotInstanceStatus{
+ LatestHeartbeats: []*machineidv1.BotInstanceStatusHeartbeat{
+ {
+ RecordedAt: timestamppb.Now(),
+ Version: "18.1.5",
+ Hostname: "bot-host1.example.com",
+ ExternalUpdater: "kube",
+ UpdaterInfo: &types.UpdaterV2Info{UpdateGroup: "bot-group1"},
+ },
+ },
+ },
+ },
+ {
+ Kind: types.KindBotInstance,
+ Version: types.V1,
+ Metadata: &headerv1.Metadata{
+ Name: "bot2-instance2",
+ },
+ Spec: &machineidv1.BotInstanceSpec{
+ BotName: "bot2",
+ InstanceId: "instance2",
+ },
+ Status: &machineidv1.BotInstanceStatus{
+ LatestHeartbeats: []*machineidv1.BotInstanceStatusHeartbeat{
+ {
+ RecordedAt: timestamppb.Now(),
+ Version: "19.0.1",
+ Hostname: "bot-host2.example.com",
+ ExternalUpdater: "unit",
+ UpdaterInfo: &types.UpdaterV2Info{UpdateGroup: "bot-group2"},
+ },
+ },
+ },
+ },
+ }
+
+ mockInventory := &mockInventoryService{instances: instances}
+ mockBotCache := &mockBotInstanceCache{bots: bots}
+
+ inventoryCache, err := NewInventoryCache(InventoryCacheConfig{
+ PrimaryCache: p.Cache,
+ Events: local.NewEventsService(bk),
+ Inventory: mockInventory,
+ BotInstanceBackend: mockBotCache,
+ TargetVersion: "19.0.0",
+ })
+ require.NoError(t, err)
+ defer inventoryCache.Close()
+
+ // Wait for the inventory cache to initialize by blocking until all the initialization goroutines are durably blocked, which means
+ // the cache has been initialized.
+ synctest.Wait()
+ require.True(t, inventoryCache.IsHealthy())
+
+ // Test searching by hostname
+ resp, err := inventoryCache.ListUnifiedInstances(ctx, &inventoryv1.ListUnifiedInstancesRequest{
+ PageSize: 100,
+ Filter: &inventoryv1.ListUnifiedInstancesFilter{
+ Search: "node1",
+ },
+ })
+ require.NoError(t, err)
+ require.Len(t, resp.Items, 1)
+ require.Equal(t, "node1.example.com", resp.Items[0].GetInstance().Spec.Hostname)
+
+ // Test searching by bot name
+ resp, err = inventoryCache.ListUnifiedInstances(ctx, &inventoryv1.ListUnifiedInstancesRequest{
+ PageSize: 100,
+ Filter: &inventoryv1.ListUnifiedInstancesFilter{
+ Search: "bot2",
+ InstanceTypes: []inventoryv1.InstanceType{inventoryv1.InstanceType_INSTANCE_TYPE_BOT_INSTANCE},
+ },
+ })
+ require.NoError(t, err)
+ require.Len(t, resp.Items, 1)
+ require.Equal(t, "bot2", resp.Items[0].GetBotInstance().Spec.BotName)
+
+ // Test filtering by services
+ resp, err = inventoryCache.ListUnifiedInstances(ctx, &inventoryv1.ListUnifiedInstancesRequest{
+ PageSize: 100,
+ Filter: &inventoryv1.ListUnifiedInstancesFilter{
+ Services: []string{string(types.RoleAuth), string(types.RoleProxy)},
+ InstanceTypes: []inventoryv1.InstanceType{inventoryv1.InstanceType_INSTANCE_TYPE_INSTANCE},
+ },
+ })
+ require.NoError(t, err)
+ require.Len(t, resp.Items, 2)
+ require.Equal(t, "auth1.example.com", resp.Items[0].GetInstance().Spec.Hostname)
+ require.Equal(t, "node2.example.com", resp.Items[1].GetInstance().Spec.Hostname)
+
+ // Test filtering by updater groups
+ resp, err = inventoryCache.ListUnifiedInstances(ctx, &inventoryv1.ListUnifiedInstancesRequest{
+ PageSize: 100,
+ Filter: &inventoryv1.ListUnifiedInstancesFilter{
+ UpdaterGroups: []string{"group1"},
+ InstanceTypes: []inventoryv1.InstanceType{inventoryv1.InstanceType_INSTANCE_TYPE_INSTANCE},
+ },
+ })
+ require.NoError(t, err)
+ require.Len(t, resp.Items, 2)
+
+ // Test filtering by external upgraders
+ resp, err = inventoryCache.ListUnifiedInstances(ctx, &inventoryv1.ListUnifiedInstancesRequest{
+ PageSize: 100,
+ Filter: &inventoryv1.ListUnifiedInstancesFilter{
+ Upgraders: []string{"kube"},
+ InstanceTypes: []inventoryv1.InstanceType{inventoryv1.InstanceType_INSTANCE_TYPE_INSTANCE},
+ },
+ })
+ require.NoError(t, err)
+ require.Len(t, resp.Items, 2)
+ for _, item := range resp.Items {
+ require.Equal(t, "kube", item.GetInstance().Spec.ExternalUpgrader)
+ }
+
+ // Test predicate query filtering by version (less than)
+ resp, err = inventoryCache.ListUnifiedInstances(ctx, &inventoryv1.ListUnifiedInstancesRequest{
+ PageSize: 100,
+ Filter: &inventoryv1.ListUnifiedInstancesFilter{
+ PredicateExpression: `older_than(status.latest_heartbeat.version, "18.2.0")`,
+ InstanceTypes: []inventoryv1.InstanceType{inventoryv1.InstanceType_INSTANCE_TYPE_INSTANCE},
+ },
+ })
+ require.NoError(t, err)
+ require.Len(t, resp.Items, 1)
+ require.Equal(t, "18.1.0", resp.Items[0].GetInstance().Spec.Version)
+
+ // Test predicate query filtering by version (greater than) for both instance types.
+ // This should return 3 instances and 1 bot instance.
+ resp, err = inventoryCache.ListUnifiedInstances(ctx, &inventoryv1.ListUnifiedInstancesRequest{
+ PageSize: 100,
+ Filter: &inventoryv1.ListUnifiedInstancesFilter{
+ PredicateExpression: `newer_than(status.latest_heartbeat.version, "18.1.6")`,
+ InstanceTypes: []inventoryv1.InstanceType{},
+ },
+ })
+ require.NoError(t, err)
+ require.Len(t, resp.Items, 4)
+
+ // Test predicate query filtering by version (between)
+ resp, err = inventoryCache.ListUnifiedInstances(ctx, &inventoryv1.ListUnifiedInstancesRequest{
+ PageSize: 100,
+ Filter: &inventoryv1.ListUnifiedInstancesFilter{
+ PredicateExpression: `between(status.latest_heartbeat.version, "18.0.0", "19.0.0")`,
+ InstanceTypes: []inventoryv1.InstanceType{inventoryv1.InstanceType_INSTANCE_TYPE_INSTANCE},
+ },
+ })
+ require.NoError(t, err)
+ require.Len(t, resp.Items, 3)
+
+ // Test predicate query filtering by hostname
+ resp, err = inventoryCache.ListUnifiedInstances(ctx, &inventoryv1.ListUnifiedInstancesRequest{
+ PageSize: 100,
+ Filter: &inventoryv1.ListUnifiedInstancesFilter{
+ PredicateExpression: `hostname == "node2.example.com"`,
+ InstanceTypes: []inventoryv1.InstanceType{inventoryv1.InstanceType_INSTANCE_TYPE_INSTANCE},
+ },
+ })
+ require.NoError(t, err)
+ require.Len(t, resp.Items, 1)
+ require.Equal(t, "node2.example.com", resp.Items[0].GetInstance().Spec.Hostname)
+
+ // Test filtering with multiple filters.
+ resp, err = inventoryCache.ListUnifiedInstances(ctx, &inventoryv1.ListUnifiedInstancesRequest{
+ PageSize: 100,
+ Filter: &inventoryv1.ListUnifiedInstancesFilter{
+ Services: []string{string(types.RoleNode)},
+ UpdaterGroups: []string{"group1"},
+ PredicateExpression: `older_than(status.latest_heartbeat.version, "18.2.0")`,
+ InstanceTypes: []inventoryv1.InstanceType{inventoryv1.InstanceType_INSTANCE_TYPE_INSTANCE},
+ },
+ })
+ require.NoError(t, err)
+ require.Len(t, resp.Items, 1)
+ require.Equal(t, "node1.example.com", resp.Items[0].GetInstance().Spec.Hostname)
+
+ // Test filtering bot instances by updater group
+ resp, err = inventoryCache.ListUnifiedInstances(ctx, &inventoryv1.ListUnifiedInstancesRequest{
+ PageSize: 100,
+ Filter: &inventoryv1.ListUnifiedInstancesFilter{
+ UpdaterGroups: []string{"bot-group1"},
+ InstanceTypes: []inventoryv1.InstanceType{inventoryv1.InstanceType_INSTANCE_TYPE_BOT_INSTANCE},
+ },
+ })
+ require.NoError(t, err)
+ require.Len(t, resp.Items, 1)
+ require.Equal(t, "bot1", resp.Items[0].GetBotInstance().Spec.BotName)
+
+ // Test filtering both instances and bot instances by upgrader
+ resp, err = inventoryCache.ListUnifiedInstances(ctx, &inventoryv1.ListUnifiedInstancesRequest{
+ PageSize: 100,
+ Filter: &inventoryv1.ListUnifiedInstancesFilter{
+ Upgraders: []string{"kube"},
+ },
+ })
+ require.NoError(t, err)
+ require.Len(t, resp.Items, 3)
+ upgraders := make(map[string]bool)
+ for _, item := range resp.Items {
+ if item.GetInstance() != nil {
+ upgraders[item.GetInstance().Spec.ExternalUpgrader] = true
+ } else if item.GetBotInstance() != nil && len(item.GetBotInstance().Status.LatestHeartbeats) > 0 {
+ upgraders[item.GetBotInstance().Status.LatestHeartbeats[0].ExternalUpdater] = true
+ }
+ }
+ require.True(t, upgraders["kube"])
+ require.Len(t, upgraders, 1)
+
+ // Test filtering for no upgrader works
+ resp, err = inventoryCache.ListUnifiedInstances(ctx, &inventoryv1.ListUnifiedInstancesRequest{
+ PageSize: 100,
+ Filter: &inventoryv1.ListUnifiedInstancesFilter{
+ Upgraders: []string{""},
+ InstanceTypes: []inventoryv1.InstanceType{inventoryv1.InstanceType_INSTANCE_TYPE_INSTANCE},
+ },
+ })
+ require.NoError(t, err)
+ require.Len(t, resp.Items, 1)
+ require.Equal(t, "no-upgrader.example.com", resp.Items[0].GetInstance().Spec.Hostname)
+ require.Empty(t, resp.Items[0].GetInstance().Spec.ExternalUpgrader)
+
+ // Test filtering for no upgrader or kube upgrader
+ resp, err = inventoryCache.ListUnifiedInstances(ctx, &inventoryv1.ListUnifiedInstancesRequest{
+ PageSize: 100,
+ Filter: &inventoryv1.ListUnifiedInstancesFilter{
+ Upgraders: []string{"", "kube"},
+ InstanceTypes: []inventoryv1.InstanceType{inventoryv1.InstanceType_INSTANCE_TYPE_INSTANCE},
+ },
+ })
+ require.NoError(t, err)
+ require.Len(t, resp.Items, 3) // 2 with kube upgrader + 1 with no upgrader
+ })
+}
+
+// TestInventoryCacheSorting tests the sorting functionality of the inventory cache
+func TestInventoryCacheSorting(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ ctx := t.Context()
+
+ bk, err := memory.New(memory.Config{
+ Context: ctx,
+ })
+ require.NoError(t, err)
+ defer bk.Close()
+
+ p, err := setupTestCache(t, cache.ForAuth)
+ require.NoError(t, err)
+ defer p.Close()
+
+ instances := []*types.InstanceV1{
+ {
+ ResourceHeader: types.ResourceHeader{
+ Metadata: types.Metadata{
+ Name: "instance1",
+ },
+ },
+ Spec: types.InstanceSpecV1{
+ Hostname: "zzzz",
+ Version: "19.0.0",
+ Services: []types.SystemRole{types.RoleNode},
+ },
+ },
+ {
+ ResourceHeader: types.ResourceHeader{
+ Metadata: types.Metadata{
+ Name: "instance2",
+ },
+ },
+ Spec: types.InstanceSpecV1{
+ Hostname: "aaaa",
+ Version: "18.1.0",
+ Services: []types.SystemRole{types.RoleNode},
+ },
+ },
+ {
+ ResourceHeader: types.ResourceHeader{
+ Metadata: types.Metadata{
+ Name: "instance3",
+ },
+ },
+ Spec: types.InstanceSpecV1{
+ Hostname: "mmmm",
+ Version: "18.2.5",
+ Services: []types.SystemRole{types.RoleProxy},
+ },
+ },
+ {
+ ResourceHeader: types.ResourceHeader{
+ Metadata: types.Metadata{
+ Name: "instance4",
+ },
+ },
+ Spec: types.InstanceSpecV1{
+ Hostname: "cccc",
+ Version: "19.1.0-beta.1", // Prerelease version
+ Services: []types.SystemRole{types.RoleAuth},
+ },
+ },
+ {
+ ResourceHeader: types.ResourceHeader{
+ Metadata: types.Metadata{
+ Name: "instance5",
+ },
+ },
+ Spec: types.InstanceSpecV1{
+ Hostname: "pppp",
+ Version: "19.1.0-alpha", // Prerelease version (should sort before beta)
+ Services: []types.SystemRole{types.RoleNode},
+ },
+ },
+ {
+ ResourceHeader: types.ResourceHeader{
+ Metadata: types.Metadata{
+ Name: "instance6",
+ },
+ },
+ Spec: types.InstanceSpecV1{
+ Hostname: "vvvv",
+ Version: "v18.0.0", // v-prefix (should be parsed correctly)
+ Services: []types.SystemRole{types.RoleNode},
+ },
+ },
+ {
+ ResourceHeader: types.ResourceHeader{
+ Metadata: types.Metadata{
+ Name: "instance7",
+ },
+ },
+ Spec: types.InstanceSpecV1{
+ Hostname: "iiii",
+ Version: "not-a-version", // Invalid version (should sort last)
+ Services: []types.SystemRole{types.RoleNode},
+ },
+ },
+ }
+
+ // Create test bot instances with different bot names and versions
+ bots := []*machineidv1.BotInstance{
+ {
+ Metadata: &headerv1.Metadata{
+ Name: "bot1",
+ },
+ Spec: &machineidv1.BotInstanceSpec{
+ BotName: "yyyy",
+ InstanceId: "bot1",
+ },
+ Status: &machineidv1.BotInstanceStatus{
+ LatestHeartbeats: []*machineidv1.BotInstanceStatusHeartbeat{
+ {
+ RecordedAt: timestamppb.Now(),
+ Version: "18.0.0",
+ },
+ },
+ },
+ },
+ {
+ Metadata: &headerv1.Metadata{
+ Name: "bot2",
+ },
+ Spec: &machineidv1.BotInstanceSpec{
+ BotName: "bbbb",
+ InstanceId: "bot2",
+ },
+ Status: &machineidv1.BotInstanceStatus{
+ LatestHeartbeats: []*machineidv1.BotInstanceStatusHeartbeat{
+ {
+ RecordedAt: timestamppb.Now(),
+ Version: "19.2.0",
+ },
+ },
+ },
+ },
+ {
+ Metadata: &headerv1.Metadata{
+ Name: "bot3",
+ },
+ Spec: &machineidv1.BotInstanceSpec{
+ BotName: "dddd",
+ InstanceId: "bot3",
+ },
+ Status: &machineidv1.BotInstanceStatus{
+ LatestHeartbeats: []*machineidv1.BotInstanceStatusHeartbeat{
+ {
+ RecordedAt: timestamppb.Now(),
+ Version: "18.1.5",
+ },
+ },
+ },
+ },
+ }
+
+ mockInventory := &mockInventoryService{instances: instances}
+ mockBotCache := &mockBotInstanceCache{bots: bots}
+
+ inventoryCache, err := NewInventoryCache(InventoryCacheConfig{
+ PrimaryCache: p.Cache,
+ Events: local.NewEventsService(bk),
+ Inventory: mockInventory,
+ BotInstanceBackend: mockBotCache,
+ TargetVersion: "19.0.0",
+ })
+ require.NoError(t, err)
+ defer inventoryCache.Close()
+
+ // Wait for the inventory cache to initialize by blocking until all the initialization goroutines are durably blocked, which means
+ // the cache has been initialized.
+ synctest.Wait()
+ require.True(t, inventoryCache.IsHealthy())
+
+ // Version predicate filter should work whether the instance's version has "v" prefix or not
+ resp, err := inventoryCache.ListUnifiedInstances(ctx, &inventoryv1.ListUnifiedInstancesRequest{
+ PageSize: 100,
+ Filter: &inventoryv1.ListUnifiedInstancesFilter{
+ PredicateExpression: `version == "18.0.0"`,
+ },
+ })
+ require.NoError(t, err)
+ require.Len(t, resp.Items, 2)
+ require.ElementsMatch(t, []string{"v18.0.0", "18.0.0"}, libslices.Map(resp.Items, getItemVersion))
+
+ // Test sort by name ascending
+ resp, err = inventoryCache.ListUnifiedInstances(ctx, &inventoryv1.ListUnifiedInstancesRequest{
+ PageSize: 100,
+ Sort: inventoryv1.UnifiedInstanceSort_UNIFIED_INSTANCE_SORT_NAME,
+ Order: inventoryv1.SortOrder_SORT_ORDER_ASCENDING,
+ })
+ require.NoError(t, err)
+ require.Len(t, resp.Items, 10)
+
+ expectedNames := []string{
+ "aaaa",
+ "bbbb",
+ "cccc",
+ "dddd",
+ "iiii",
+ "mmmm",
+ "pppp",
+ "vvvv",
+ "yyyy",
+ "zzzz",
+ }
+
+ require.Equal(t, expectedNames, libslices.Map(resp.Items, getItemName))
+
+ // Test sort by name descending
+ resp, err = inventoryCache.ListUnifiedInstances(ctx, &inventoryv1.ListUnifiedInstancesRequest{
+ PageSize: 100,
+ Sort: inventoryv1.UnifiedInstanceSort_UNIFIED_INSTANCE_SORT_NAME,
+ Order: inventoryv1.SortOrder_SORT_ORDER_DESCENDING,
+ })
+ require.NoError(t, err)
+ require.Len(t, resp.Items, 10)
+
+ expectedNames = []string{
+ "zzzz",
+ "yyyy",
+ "vvvv",
+ "pppp",
+ "mmmm",
+ "iiii",
+ "dddd",
+ "cccc",
+ "bbbb",
+ "aaaa",
+ }
+
+ require.Equal(t, expectedNames, libslices.Map(resp.Items, getItemName))
+
+ // Test sort by type ascending
+ resp, err = inventoryCache.ListUnifiedInstances(ctx, &inventoryv1.ListUnifiedInstancesRequest{
+ PageSize: 100,
+ Sort: inventoryv1.UnifiedInstanceSort_UNIFIED_INSTANCE_SORT_TYPE,
+ Order: inventoryv1.SortOrder_SORT_ORDER_ASCENDING,
+ })
+ require.NoError(t, err)
+ require.Len(t, resp.Items, 10)
+
+ // Expect all bot instances first (sorted by name), then all instances (sorted by name)
+ expectedNames = []string{
+ "bbbb",
+ "dddd",
+ "yyyy",
+ "aaaa",
+ "cccc",
+ "iiii",
+ "mmmm",
+ "pppp",
+ "vvvv",
+ "zzzz",
+ }
+
+ require.Equal(t, expectedNames, libslices.Map(resp.Items, getItemName))
+
+ // Test sort by type descending
+ resp, err = inventoryCache.ListUnifiedInstances(ctx, &inventoryv1.ListUnifiedInstancesRequest{
+ PageSize: 100,
+ Sort: inventoryv1.UnifiedInstanceSort_UNIFIED_INSTANCE_SORT_TYPE,
+ Order: inventoryv1.SortOrder_SORT_ORDER_DESCENDING,
+ })
+ require.NoError(t, err)
+ require.Len(t, resp.Items, 10)
+
+ // Expect all instances first (desc sorted by name), then all bot instances (desc sorted by name)
+ expectedNames = []string{
+ "zzzz",
+ "vvvv",
+ "pppp",
+ "mmmm",
+ "iiii",
+ "cccc",
+ "aaaa",
+ "yyyy",
+ "dddd",
+ "bbbb",
+ }
+
+ require.Equal(t, expectedNames, libslices.Map(resp.Items, getItemName))
+
+ // Test sorting by version ascending
+ resp, err = inventoryCache.ListUnifiedInstances(ctx, &inventoryv1.ListUnifiedInstancesRequest{
+ PageSize: 100,
+ Sort: inventoryv1.UnifiedInstanceSort_UNIFIED_INSTANCE_SORT_VERSION,
+ Order: inventoryv1.SortOrder_SORT_ORDER_ASCENDING,
+ })
+ require.NoError(t, err)
+ require.Len(t, resp.Items, 10)
+
+ expectedVersions := []string{
+ "v18.0.0", // v-prefix version (treated as 18.0.0)
+ "18.0.0",
+ "18.1.0",
+ "18.1.5",
+ "18.2.5",
+ "19.0.0",
+ "19.1.0-alpha",
+ "19.1.0-beta.1",
+ "19.2.0",
+ "not-a-version", // invalid version
+ }
+
+ require.Equal(t, expectedVersions, libslices.Map(resp.Items, getItemVersion))
+
+ // Test sort by version descending
+ resp, err = inventoryCache.ListUnifiedInstances(ctx, &inventoryv1.ListUnifiedInstancesRequest{
+ PageSize: 100,
+ Sort: inventoryv1.UnifiedInstanceSort_UNIFIED_INSTANCE_SORT_VERSION,
+ Order: inventoryv1.SortOrder_SORT_ORDER_DESCENDING,
+ })
+ require.NoError(t, err)
+ require.Len(t, resp.Items, 10)
+
+ expectedVersions = []string{
+ "not-a-version",
+ "19.2.0",
+ "19.1.0-beta.1",
+ "19.1.0-alpha",
+ "19.0.0",
+ "18.2.5",
+ "18.1.5",
+ "18.1.0",
+ "18.0.0",
+ "v18.0.0",
+ }
+
+ require.Equal(t, expectedVersions, libslices.Map(resp.Items, getItemVersion))
+
+ // Test sorting by version with pagination
+ firstPageResp, err := inventoryCache.ListUnifiedInstances(ctx, &inventoryv1.ListUnifiedInstancesRequest{
+ PageSize: 3,
+ Sort: inventoryv1.UnifiedInstanceSort_UNIFIED_INSTANCE_SORT_VERSION,
+ Order: inventoryv1.SortOrder_SORT_ORDER_ASCENDING,
+ })
+ require.NoError(t, err)
+ require.Len(t, firstPageResp.Items, 3)
+ require.NotEmpty(t, firstPageResp.NextPageToken)
+
+ // Verify first page has the correct versions
+ expectedFirstPageVersions := []string{"v18.0.0", "18.0.0", "18.1.0"}
+ require.Equal(t, expectedFirstPageVersions, libslices.Map(firstPageResp.Items, getItemVersion))
+
+ // Fetch second page
+ secondPageResp, err := inventoryCache.ListUnifiedInstances(ctx, &inventoryv1.ListUnifiedInstancesRequest{
+ PageSize: 3,
+ PageToken: firstPageResp.NextPageToken,
+ Sort: inventoryv1.UnifiedInstanceSort_UNIFIED_INSTANCE_SORT_VERSION,
+ Order: inventoryv1.SortOrder_SORT_ORDER_ASCENDING,
+ })
+ require.NoError(t, err)
+ require.Len(t, secondPageResp.Items, 3)
+ require.NotEmpty(t, secondPageResp.NextPageToken)
+
+ // Verify second page has the correct versions
+ expectedSecondPageVersions := []string{"18.1.5", "18.2.5", "19.0.0"}
+ require.Equal(t, expectedSecondPageVersions, libslices.Map(secondPageResp.Items, getItemVersion))
+
+ // Fetch third page
+ thirdPageResp, err := inventoryCache.ListUnifiedInstances(ctx, &inventoryv1.ListUnifiedInstancesRequest{
+ PageSize: 3,
+ PageToken: secondPageResp.NextPageToken,
+ Sort: inventoryv1.UnifiedInstanceSort_UNIFIED_INSTANCE_SORT_VERSION,
+ Order: inventoryv1.SortOrder_SORT_ORDER_ASCENDING,
+ })
+ require.NoError(t, err)
+ require.Len(t, thirdPageResp.Items, 3)
+ require.NotEmpty(t, thirdPageResp.NextPageToken)
+
+ // Verify third page has the correct versions
+ expectedThirdPageVersions := []string{"19.1.0-alpha", "19.1.0-beta.1", "19.2.0"}
+ require.Equal(t, expectedThirdPageVersions, libslices.Map(thirdPageResp.Items, getItemVersion))
+
+ // Fetch fourth page
+ fourthPageResp, err := inventoryCache.ListUnifiedInstances(ctx, &inventoryv1.ListUnifiedInstancesRequest{
+ PageSize: 3,
+ PageToken: thirdPageResp.NextPageToken,
+ Sort: inventoryv1.UnifiedInstanceSort_UNIFIED_INSTANCE_SORT_VERSION,
+ Order: inventoryv1.SortOrder_SORT_ORDER_ASCENDING,
+ })
+ require.NoError(t, err)
+ require.Len(t, fourthPageResp.Items, 1)
+ require.Empty(t, fourthPageResp.NextPageToken)
+
+ // Verify fourth page has the invalid version
+ var actualVersion string
+ if instance := fourthPageResp.Items[0].GetInstance(); instance != nil {
+ actualVersion = instance.Spec.Version
+ } else if bot := fourthPageResp.Items[0].GetBotInstance(); bot != nil {
+ actualVersion = bot.Status.LatestHeartbeats[0].Version
+ }
+ require.Equal(t, "not-a-version", actualVersion)
+
+ // Test sorting by version while filtering for only instances
+ resp, err = inventoryCache.ListUnifiedInstances(ctx, &inventoryv1.ListUnifiedInstancesRequest{
+ PageSize: 100,
+ Sort: inventoryv1.UnifiedInstanceSort_UNIFIED_INSTANCE_SORT_VERSION,
+ Order: inventoryv1.SortOrder_SORT_ORDER_ASCENDING,
+ Filter: &inventoryv1.ListUnifiedInstancesFilter{
+ InstanceTypes: []inventoryv1.InstanceType{inventoryv1.InstanceType_INSTANCE_TYPE_INSTANCE},
+ },
+ })
+ require.NoError(t, err)
+ require.Len(t, resp.Items, 7)
+
+ expectedVersions = []string{"v18.0.0", "18.1.0", "18.2.5", "19.0.0", "19.1.0-alpha", "19.1.0-beta.1", "not-a-version"}
+ require.Equal(t, expectedVersions, libslices.Map(resp.Items, func(item *inventoryv1.UnifiedInstanceItem) string {
+ return item.GetInstance().Spec.Version
+ }))
+
+ // Test sorting by version while filtering for only bot instances
+ resp, err = inventoryCache.ListUnifiedInstances(ctx, &inventoryv1.ListUnifiedInstancesRequest{
+ PageSize: 100,
+ Sort: inventoryv1.UnifiedInstanceSort_UNIFIED_INSTANCE_SORT_VERSION,
+ Order: inventoryv1.SortOrder_SORT_ORDER_ASCENDING,
+ Filter: &inventoryv1.ListUnifiedInstancesFilter{
+ InstanceTypes: []inventoryv1.InstanceType{inventoryv1.InstanceType_INSTANCE_TYPE_BOT_INSTANCE},
+ },
+ })
+ require.NoError(t, err)
+ require.Len(t, resp.Items, 3)
+
+ expectedBotVersions := []string{"18.0.0", "18.1.5", "19.2.0"}
+ require.Equal(t, expectedBotVersions, libslices.Map(resp.Items, func(item *inventoryv1.UnifiedInstanceItem) string {
+ return item.GetBotInstance().Status.LatestHeartbeats[0].Version
+ }))
+ })
+}
+
+// TestSemverSorting tests that semver ordering works properly
+func TestSemverSorting(t *testing.T) {
+ // Expected ordering
+ versions := []struct {
+ version string
+ desc string
+ }{
+ {"1.0.0-1", "prerelease numeric 1"},
+ {"1.0.0-2", "prerelease numeric 2"},
+ {"1.0.0-11", "prerelease numeric 11"},
+ {"1.0.0-alpha", "prerelease alpha"},
+ {"1.0.0-alpha.1", "prerelease alpha.1"},
+ {"1.0.0-beta", "prerelease beta"},
+ {"1.0.0", "release"},
+ {"1.0.1", "patch increment"},
+ {"1.1.0", "minor version increment"},
+ {"2.0.0", "major version increment"},
+ {"invalid", "invalid version"},
+ }
+
+ // Create the instances
+ instances := make([]*inventoryInstance, len(versions))
+ for i, v := range versions {
+ instances[i] = &inventoryInstance{
+ instance: &types.InstanceV1{
+ ResourceHeader: types.ResourceHeader{
+ Metadata: types.Metadata{
+ Name: fmt.Sprintf("instance-%d", i),
+ },
+ },
+ Spec: types.InstanceSpecV1{
+ Hostname: fmt.Sprintf("host-%d", i),
+ Version: v.version,
+ },
+ },
+ }
+ }
+
+ // Sort instances by version key
+ sorted := slices.SortedFunc(slices.Values(instances), func(a, b *inventoryInstance) int {
+ keyA := a.getVersionKey()
+ keyB := b.getVersionKey()
+ if keyA < keyB {
+ return -1
+ } else if keyA > keyB {
+ return 1
+ }
+ return 0
+ })
+
+ require.Equal(t, instances, sorted)
+}
+
+// TestGetVersionKeyOrdering verifies that the version keys returned by getVersionKey
+// are correct and match semver.Compare
+func TestGetVersionKeyOrdering(t *testing.T) {
+ versions := []string{
+ "1.0.0-alpha",
+ "2.1.0-rc.1",
+ "1.0.0-alpha.1",
+ "v2.1.0",
+ "1.1.0",
+ "1.0.0-beta",
+ "1.0.0",
+ "1.0.0-beta.2",
+ "1.0.0-alpha.beta",
+ "1.0.0-beta.11",
+ "1.0.0-rc.1",
+ "1.0.1",
+ "1.2.0-alpha",
+ "1.2.0",
+ "2.0.0",
+ "v3.0.0-beta",
+ }
+
+ // Sort with semver.Compare
+ semverSorted := slices.Clone(versions)
+ slices.SortFunc(semverSorted, func(a, b string) int {
+ semverA := semver.New(strings.TrimPrefix(a, "v"))
+ semverB := semver.New(strings.TrimPrefix(b, "v"))
+ return semverA.Compare(*semverB)
+ })
+
+ // Sort with getVersionKey
+ keySorted := slices.Clone(versions)
+ slices.SortFunc(keySorted, func(a, b string) int {
+ instA := &inventoryInstance{instance: &types.InstanceV1{Spec: types.InstanceSpecV1{Version: a}}}
+ instB := &inventoryInstance{instance: &types.InstanceV1{Spec: types.InstanceSpecV1{Version: b}}}
+ return cmp.Compare(instA.getVersionKey(), instB.getVersionKey())
+ })
+
+ require.Equal(t, semverSorted, keySorted)
+}
+
+// TestGetUnifiedExpressionParser tests that the parser can be initialized successfully
+func TestGetUnifiedExpressionParser(t *testing.T) {
+ parser := getUnifiedExpressionParser()
+ require.NotNil(t, parser)
+
+ expr, err := parser.Parse(`hostname == "test"`)
+ require.NoError(t, err)
+ require.NotNil(t, expr)
+
+ // Verify the between function is available
+ expr, err = parser.Parse(`between(version, "1.0.0", "2.0.0")`)
+ require.NoError(t, err)
+ require.NotNil(t, expr)
+
+ // Test an invalid predicate expression
+ expr, err = parser.Parse(`asdafafa("afafafa")`)
+ require.Error(t, err)
+ require.Nil(t, expr)
+}
+
+// mockInventoryService is a mock implementation of services.Inventory.
+type mockInventoryService struct {
+ instances []*types.InstanceV1
+}
+
+func (m *mockInventoryService) GetInstances(ctx context.Context, filter types.InstanceFilter) stream.Stream[types.Instance] {
+ items := make([]types.Instance, len(m.instances))
+ for i, inst := range m.instances {
+ items[i] = inst
+ }
+ return stream.Slice(items)
+}
+
+// mockBotInstanceCache is a mock implementation of services.BotInstance.
+type mockBotInstanceCache struct {
+ bots []*machineidv1.BotInstance
+}
+
+func (m *mockBotInstanceCache) ListBotInstances(ctx context.Context, pageSize int, pageToken string, opts *services.ListBotInstancesRequestOptions) ([]*machineidv1.BotInstance, string, error) {
+ return m.bots, "", nil
+}
+
+func (m *mockBotInstanceCache) GetBotInstance(ctx context.Context, botName, instanceID string) (*machineidv1.BotInstance, error) {
+ return nil, nil
+}
+
+func (m *mockBotInstanceCache) DeleteBotInstance(ctx context.Context, botName, instanceID string) error {
+ return nil
+}
+
+func (m *mockBotInstanceCache) PatchBotInstance(ctx context.Context, botName, instanceID string, update func(*machineidv1.BotInstance) (*machineidv1.BotInstance, error)) (*machineidv1.BotInstance, error) {
+ return nil, nil
+}
+
+func (m *mockBotInstanceCache) DeleteAllBotInstances(ctx context.Context) error {
+ return nil
+}
+
+func (m *mockBotInstanceCache) CreateBotInstance(ctx context.Context, instance *machineidv1.BotInstance) (*machineidv1.BotInstance, error) {
+ return instance, nil
+}
+
+func (m *mockBotInstanceCache) GetBotInstancesCount(ctx context.Context, botName string) (int, error) {
+ return len(m.bots), nil
+}
+
+func (m *mockBotInstanceCache) SubmitHeartbeat(ctx context.Context, heartbeat *machineidv1.SubmitHeartbeatRequest) (*machineidv1.SubmitHeartbeatResponse, error) {
+ return nil, nil
+}
diff --git a/lib/expression/parser.go b/lib/expression/parser.go
index 7a6505220c7b2..743816ad15997 100644
--- a/lib/expression/parser.go
+++ b/lib/expression/parser.go
@@ -22,6 +22,7 @@ import (
"strings"
"time"
+ "github.com/coreos/go-semver/semver"
"github.com/gravitational/trace"
"github.com/gravitational/teleport/lib/utils"
@@ -126,16 +127,24 @@ func DefaultParserSpec[evaluationEnv any]() typical.ParserSpec[evaluationEnv] {
func(t time.Time, other time.Time) (bool, error) {
return t.After(other), nil
}),
- "between": typical.BinaryVariadicFunction[evaluationEnv](
- func(t time.Time, interval ...time.Time) (bool, error) {
- if len(interval) != 2 {
- return false, trace.BadParameter("between expected 2 parameters: got %v", len(interval))
- }
- first, second := interval[0], interval[1]
- if first.After(second) {
- first, second = second, first
+ "between": typical.TernaryFunction[evaluationEnv](
+ func(value any, arg1 any, arg2 any) (bool, error) {
+ // If the value provided is a time, do a time comparison
+ if t, ok := value.(time.Time); ok {
+ firstTime, ok1 := arg1.(time.Time)
+ secondTime, ok2 := arg2.(time.Time)
+ if !ok1 || !ok2 {
+ return false, trace.BadParameter("the time parameters provided are invalid time values")
+ }
+
+ if firstTime.After(secondTime) {
+ firstTime, secondTime = secondTime, firstTime
+ }
+ return t.After(firstTime) && t.Before(secondTime), nil
}
- return t.After(first) && t.Before(second), nil
+
+ // If it's not a time, try semver comparison
+ return SemverBetween(value, arg1, arg2)
}),
"contains_any": typical.BinaryFunction[evaluationEnv](
func(s1, s2 Set) (bool, error) {
@@ -159,6 +168,9 @@ func DefaultParserSpec[evaluationEnv any]() typical.ParserSpec[evaluationEnv] {
func(s Set) (bool, error) {
return len(s.s) == 0, nil
}),
+ //TODO(rudream): add newer_than, older_than, and between functions to predicate docs
+ "newer_than": typical.BinaryFunction[evaluationEnv](SemverGt),
+ "older_than": typical.BinaryFunction[evaluationEnv](SemverLt),
},
Methods: map[string]typical.Function{
"add": typical.BinaryVariadicFunction[evaluationEnv](
@@ -300,3 +312,71 @@ type option struct {
condition bool
value any
}
+
+// SemverGt compares two semantic versions and returns true if a > b.
+func SemverGt(a, b any) (bool, error) {
+ va, err := ToSemver(a)
+ if va == nil || err != nil {
+ return false, err
+ }
+ vb, err := ToSemver(b)
+ if vb == nil || err != nil {
+ return false, err
+ }
+ return va.Compare(*vb) > 0, nil
+}
+
+// SemverLt compares two semantic versions and returns true if a < b.
+func SemverLt(a, b any) (bool, error) {
+ va, err := ToSemver(a)
+ if va == nil || err != nil {
+ return false, err
+ }
+ vb, err := ToSemver(b)
+ if vb == nil || err != nil {
+ return false, err
+ }
+ return va.Compare(*vb) < 0, nil
+}
+
+// SemverEq compares two semantic versions and returns true if a == b.
+func SemverEq(a, b any) (bool, error) {
+ va, err := ToSemver(a)
+ if va == nil || err != nil {
+ return false, err
+ }
+ vb, err := ToSemver(b)
+ if vb == nil || err != nil {
+ return false, err
+ }
+ return va.Compare(*vb) == 0, nil
+}
+
+// SemverBetween checks if c is between versions a and b (inclusive of a, exclusive of b).
+func SemverBetween(c, a, b any) (bool, error) {
+ gt, err := SemverGt(c, a)
+ if err != nil {
+ return false, err
+ }
+ eq, err := SemverEq(c, a)
+ if err != nil {
+ return false, err
+ }
+ lt, err := SemverLt(c, b)
+ if err != nil {
+ return false, err
+ }
+ return (gt || eq) && lt, nil
+}
+
+// ToSemver converts a value to a semantic version.
+func ToSemver(anyV any) (*semver.Version, error) {
+ switch v := anyV.(type) {
+ case *semver.Version:
+ return v, nil
+ case string:
+ return semver.NewVersion(v)
+ default:
+ return nil, trace.BadParameter("type %T cannot be parsed as semver.Version", v)
+ }
+}
diff --git a/lib/service/service.go b/lib/service/service.go
index 0a4bfc5fc2ee8..a2f8f60d647a3 100644
--- a/lib/service/service.go
+++ b/lib/service/service.go
@@ -112,6 +112,7 @@ import (
_ "github.com/gravitational/teleport/lib/backend/pgbk"
"github.com/gravitational/teleport/lib/bpf"
"github.com/gravitational/teleport/lib/cache"
+ inventorycache "github.com/gravitational/teleport/lib/cache/inventory"
myrepl "github.com/gravitational/teleport/lib/client/db/mysql/repl"
pgrepl "github.com/gravitational/teleport/lib/client/db/postgres/repl"
dbrepl "github.com/gravitational/teleport/lib/client/db/repl"
@@ -728,7 +729,7 @@ type TeleportProcess struct {
//
// Both the metricsRegistry and the default global registry are gathered by
// Teleport's metric service.
- metricsRegistry *prometheus.Registry
+ metricsRegistry *metrics.Registry
// We gather metrics both from the in-process registry (preferred metrics registration method)
// and the global registry (used by some Teleport services and many dependencies).
@@ -1122,7 +1123,11 @@ func NewTeleport(cfg *servicecfg.Config) (_ *TeleportProcess, err error) {
// We must create the registry in NewTeleport, as opposed to the config,
// because some tests are running multiple Teleport instances from the same
// config and reusing the same registry causes them to fail.
- metricsRegistry := prometheus.NewRegistry()
+ rootMetricRegistry := prometheus.NewRegistry()
+ metricsRegistry, err := metrics.NewRegistry(rootMetricRegistry, teleport.MetricNamespace, "")
+ if err != nil {
+ return nil, trace.Wrap(err, "creating metrics registry")
+ }
// If FIPS mode was requested make sure binary is build against BoringCrypto.
if cfg.FIPS {
@@ -1329,7 +1334,7 @@ func NewTeleport(cfg *servicecfg.Config) (_ *TeleportProcess, err error) {
TracingProvider: tracing.NoopProvider(),
metricsRegistry: metricsRegistry,
SyncGatherers: metrics.NewSyncGatherers(
- metricsRegistry,
+ rootMetricRegistry,
prometheus.DefaultGatherer,
),
}
@@ -2477,6 +2482,20 @@ func (process *TeleportProcess) initAuthService() error {
as.Cache = cache
recordingEncryptionManager.SetCache(cache)
+ // Create the inventory cache. This will wait for the primary cache to be ready before starting.
+ invCache, err := inventorycache.NewInventoryCache(inventorycache.InventoryCacheConfig{
+ PrimaryCache: cache,
+ Events: as.Services,
+ Inventory: as.Services,
+ BotInstanceBackend: as.Services,
+ Logger: process.logger.With(teleport.ComponentKey, "inventory.cache"),
+ MetricsRegistry: process.metricsRegistry.Wrap("inventory_cache"),
+ })
+ if err != nil {
+ return trace.Wrap(err, "creating inventory cache")
+ }
+ as.SetInventoryCache(invCache)
+
return nil
})
if err != nil {
diff --git a/lib/service/service_test.go b/lib/service/service_test.go
index abb50ff7cdf68..943b76521037c 100644
--- a/lib/service/service_test.go
+++ b/lib/service/service_test.go
@@ -1639,6 +1639,8 @@ func TestDebugService(t *testing.T) {
log := logtest.NewLogger()
localRegistry := prometheus.NewRegistry()
+ processRegistry, err := metrics.NewRegistry(localRegistry, teleport.MetricNamespace, "")
+ require.NoError(t, err)
additionalRegistry := prometheus.NewRegistry()
// In this test we don't want to spin a whole process and have to wait for
@@ -1650,7 +1652,7 @@ func TestDebugService(t *testing.T) {
Config: cfg,
Clock: fakeClock,
logger: log,
- metricsRegistry: localRegistry,
+ metricsRegistry: processRegistry,
SyncGatherers: metrics.NewSyncGatherers(localRegistry, prometheus.DefaultGatherer),
Supervisor: supervisor,
}
@@ -2134,6 +2136,8 @@ func TestDiagnosticsService(t *testing.T) {
log := logtest.NewLogger()
localRegistry := prometheus.NewRegistry()
+ processRegistry, err := metrics.NewRegistry(localRegistry, teleport.MetricNamespace, "")
+ require.NoError(t, err)
additionalRegistry := prometheus.NewRegistry()
// In this test we don't want to spin a whole process and have to wait for
@@ -2145,7 +2149,7 @@ func TestDiagnosticsService(t *testing.T) {
Config: cfg,
Clock: fakeClock,
logger: log,
- metricsRegistry: localRegistry,
+ metricsRegistry: processRegistry,
SyncGatherers: metrics.NewSyncGatherers(localRegistry, prometheus.DefaultGatherer),
Supervisor: supervisor,
}
diff --git a/lib/services/useracl.go b/lib/services/useracl.go
index 340305c0b786e..8b37569a99a6a 100644
--- a/lib/services/useracl.go
+++ b/lib/services/useracl.go
@@ -108,6 +108,8 @@ type UserACL struct {
Bots ResourceAccess `json:"bots"`
// BotInstances defines access to manage bot instances
BotInstances ResourceAccess `json:"botInstances"`
+ // Instances defines access to manage instances
+ Instances ResourceAccess `json:"instances"`
// AccessMonitoringRule defines access to manage access monitoring rule resources.
AccessMonitoringRule ResourceAccess `json:"accessMonitoringRule"`
// CrownJewel defines access to manage CrownJewel resources.
@@ -223,6 +225,7 @@ func NewUserACL(user types.User, userRoles RoleSet, features proto.Features, des
externalAuditStorage := newAccess(userRoles, ctx, types.KindExternalAuditStorage)
bots := newAccess(userRoles, ctx, types.KindBot)
botInstances := newAccess(userRoles, ctx, types.KindBotInstance)
+ instances := newAccess(userRoles, ctx, types.KindInstance)
crownJewelAccess := newAccess(userRoles, ctx, types.KindCrownJewel)
userTasksAccess := newAccess(userRoles, ctx, types.KindUserTask)
reviewRequests := userRoles.MaybeCanReviewRequests()
@@ -281,6 +284,7 @@ func NewUserACL(user types.User, userRoles RoleSet, features proto.Features, des
AccessGraph: accessGraphAccess,
Bots: bots,
BotInstances: botInstances,
+ Instances: instances,
AccessMonitoringRule: accessMonitoringRules,
CrownJewel: crownJewelAccess,
AccessGraphSettings: accessGraphSettings,
diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go
index e911db0abc657..c63a5481af117 100644
--- a/lib/web/apiserver.go
+++ b/lib/web/apiserver.go
@@ -861,6 +861,8 @@ func (h *Handler) bindDefaultEndpoints() {
h.GET("/webapi/sites/:site/nodes", h.WithClusterAuth(h.clusterNodesGet))
h.POST("/webapi/sites/:site/nodes", h.WithClusterAuth(h.handleNodeCreate))
+ h.GET("/webapi/sites/:site/instances", h.WithClusterAuth(h.clusterUnifiedInstancesGet))
+
// get login alerts
h.GET("/webapi/sites/:site/alerts", h.WithClusterAuth(h.clusterLoginAlertsGet))
diff --git a/lib/web/inventory.go b/lib/web/inventory.go
new file mode 100644
index 0000000000000..e2e5d142ea877
--- /dev/null
+++ b/lib/web/inventory.go
@@ -0,0 +1,147 @@
+/*
+ * 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 web
+
+import (
+ "maps"
+ "net/http"
+ "slices"
+ "strings"
+
+ "github.com/gravitational/trace"
+ "github.com/julienschmidt/httprouter"
+
+ inventoryv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/inventory/v1"
+ "github.com/gravitational/teleport/lib/defaults"
+ "github.com/gravitational/teleport/lib/reversetunnelclient"
+ "github.com/gravitational/teleport/lib/web/ui"
+)
+
+func splitQuery(value string) []string {
+ values := map[string]struct{}{}
+ for v := range strings.SplitSeq(value, ",") {
+ if trimmed := strings.TrimSpace(v); trimmed != "" {
+ values[trimmed] = struct{}{}
+ }
+ }
+ return slices.Collect(maps.Keys(values))
+}
+
+// listUnifiedInstancesResponse is the response for listing unified instances
+type listUnifiedInstancesResponse struct {
+ // Instances is the list of unified instances (both instances and bot instances)
+ Instances []ui.UnifiedInstance `json:"instances"`
+ // StartKey is the next page token
+ StartKey string `json:"startKey"`
+}
+
+// clusterUnifiedInstancesGet returns a paginated list of unified instances
+func (h *Handler) clusterUnifiedInstancesGet(w http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, cluster reversetunnelclient.Cluster) (any, error) {
+ clt, err := sctx.GetUserClient(r.Context(), cluster)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ values := r.URL.Query()
+
+ limit, err := QueryLimitAsInt32(values, "limit", defaults.MaxIterationLimit)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ startKey := values.Get("startKey")
+
+ // Default values
+ sort := inventoryv1.UnifiedInstanceSort_UNIFIED_INSTANCE_SORT_NAME
+ order := inventoryv1.SortOrder_SORT_ORDER_ASCENDING
+ if sortParam := values.Get("sort"); sortParam != "" {
+ parts := strings.SplitN(sortParam, ":", 2)
+ fieldName := strings.ToLower(parts[0])
+ switch fieldName {
+ case "name", "hostname":
+ sort = inventoryv1.UnifiedInstanceSort_UNIFIED_INSTANCE_SORT_NAME
+ case "type":
+ sort = inventoryv1.UnifiedInstanceSort_UNIFIED_INSTANCE_SORT_TYPE
+ case "version":
+ sort = inventoryv1.UnifiedInstanceSort_UNIFIED_INSTANCE_SORT_VERSION
+ }
+ if len(parts) == 2 {
+ direction := strings.ToLower(parts[1])
+ if direction == "desc" || direction == "descending" {
+ order = inventoryv1.SortOrder_SORT_ORDER_DESCENDING
+ }
+ }
+ }
+
+ // We don't use splitQuery for parsing upgraders since it should be possible to filter for upgrader "" (none),
+ // and splitQuery would remove it.
+ var upgraders []string
+ if upgradersParam := values.Get("upgraders"); upgradersParam != "" {
+ upgraders = strings.Split(upgradersParam, ",")
+ }
+
+ filter := &inventoryv1.ListUnifiedInstancesFilter{
+ Search: values.Get("search"),
+ PredicateExpression: values.Get("query"),
+ Services: splitQuery(values.Get("services")),
+ Upgraders: upgraders,
+ UpdaterGroups: splitQuery(values.Get("updaterGroups")),
+ }
+
+ var hasInstance, hasBotInstance bool
+ for t := range strings.SplitSeq(values.Get("types"), ",") {
+ switch strings.ToLower(strings.TrimSpace(t)) {
+ case "instance":
+ hasInstance = true
+ case "bot_instance":
+ hasBotInstance = true
+ }
+
+ if hasInstance && hasBotInstance {
+ break
+ }
+ }
+ if hasInstance {
+ filter.InstanceTypes = append(filter.InstanceTypes, inventoryv1.InstanceType_INSTANCE_TYPE_INSTANCE)
+ }
+ if hasBotInstance {
+ filter.InstanceTypes = append(filter.InstanceTypes, inventoryv1.InstanceType_INSTANCE_TYPE_BOT_INSTANCE)
+ }
+
+ resp, err := clt.ListUnifiedInstances(r.Context(), &inventoryv1.ListUnifiedInstancesRequest{
+ PageSize: limit,
+ PageToken: startKey,
+ Sort: sort,
+ Order: order,
+ Filter: filter,
+ })
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ uiInstances := make([]ui.UnifiedInstance, 0, len(resp.Items))
+ for _, item := range resp.Items {
+ uiInstances = append(uiInstances, ui.MakeUnifiedInstance(item))
+ }
+
+ return &listUnifiedInstancesResponse{
+ Instances: uiInstances,
+ StartKey: resp.NextPageToken,
+ }, nil
+}
diff --git a/lib/web/inventory_test.go b/lib/web/inventory_test.go
new file mode 100644
index 0000000000000..3283790281817
--- /dev/null
+++ b/lib/web/inventory_test.go
@@ -0,0 +1,244 @@
+/*
+ * 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 web
+
+import (
+ "encoding/json"
+ "net/url"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ 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"
+)
+
+func TestListUnifiedInstances(t *testing.T) {
+ t.Parallel()
+
+ ctx := t.Context()
+ env := newWebPack(t, 1, func(cfg *WebPackOptions) {
+ cfg.enableAuthCache = true
+ })
+ clusterName := env.server.ClusterName()
+
+ // Create mock instances
+ instances := []*types.InstanceV1{
+ {
+ ResourceHeader: types.ResourceHeader{
+ Metadata: types.Metadata{Name: "instance-1"},
+ },
+ Spec: types.InstanceSpecV1{
+ Hostname: "host-1",
+ Version: "18.1.0",
+ Services: []types.SystemRole{types.RoleNode},
+ },
+ },
+ {
+ ResourceHeader: types.ResourceHeader{
+ Metadata: types.Metadata{Name: "instance-2"},
+ },
+ Spec: types.InstanceSpecV1{
+ Hostname: "host-2",
+ Version: "18.2.0",
+ Services: []types.SystemRole{types.RoleProxy, types.RoleAuth},
+ },
+ },
+ {
+ ResourceHeader: types.ResourceHeader{
+ Metadata: types.Metadata{Name: "instance-3"},
+ },
+ Spec: types.InstanceSpecV1{
+ Hostname: "host-3",
+ Version: "18.3.0",
+ Services: []types.SystemRole{types.RoleDatabase},
+ },
+ },
+ {
+ ResourceHeader: types.ResourceHeader{
+ Metadata: types.Metadata{Name: "instance-4"},
+ },
+ Spec: types.InstanceSpecV1{
+ Hostname: "host-4",
+ Version: "18.4.0",
+ Services: []types.SystemRole{types.RoleNode},
+ },
+ },
+ {
+ ResourceHeader: types.ResourceHeader{
+ Metadata: types.Metadata{Name: "instance-5"},
+ },
+ Spec: types.InstanceSpecV1{
+ Hostname: "host-5",
+ Version: "18.5.0",
+ Services: []types.SystemRole{types.RoleProxy},
+ },
+ },
+ {
+ ResourceHeader: types.ResourceHeader{
+ Metadata: types.Metadata{Name: "instance-6"},
+ },
+ Spec: types.InstanceSpecV1{
+ Hostname: "host-6",
+ Version: "18.6.0",
+ Services: []types.SystemRole{types.RoleAuth},
+ },
+ },
+ }
+ for _, instance := range instances {
+ require.NoError(t, env.server.Auth().UpsertInstance(ctx, instance))
+ }
+
+ // Create mock bot instances
+ bots := []*machineidv1.BotInstance{
+ {
+ Metadata: &headerv1.Metadata{Name: "bot-1"},
+ Spec: &machineidv1.BotInstanceSpec{
+ BotName: "bot-1",
+ InstanceId: "bot-1",
+ },
+ Status: &machineidv1.BotInstanceStatus{
+ LatestHeartbeats: []*machineidv1.BotInstanceStatusHeartbeat{
+ {
+ RecordedAt: timestamppb.Now(),
+ Version: "18.7.0",
+ },
+ },
+ },
+ },
+ {
+ Metadata: &headerv1.Metadata{Name: "bot-2"},
+ Spec: &machineidv1.BotInstanceSpec{
+ BotName: "bot-2",
+ InstanceId: "bot-2",
+ },
+ Status: &machineidv1.BotInstanceStatus{
+ LatestHeartbeats: []*machineidv1.BotInstanceStatusHeartbeat{
+ {
+ RecordedAt: timestamppb.Now(),
+ Version: "18.8.0",
+ },
+ },
+ },
+ },
+ {
+ Metadata: &headerv1.Metadata{Name: "bot-3"},
+ Spec: &machineidv1.BotInstanceSpec{
+ BotName: "bot-3",
+ InstanceId: "bot-3",
+ },
+ Status: &machineidv1.BotInstanceStatus{
+ LatestHeartbeats: []*machineidv1.BotInstanceStatusHeartbeat{
+ {
+ RecordedAt: timestamppb.Now(),
+ Version: "18.9.0",
+ },
+ },
+ },
+ },
+ }
+ for _, bot := range bots {
+ _, err := env.server.Auth().BotInstance.CreateBotInstance(ctx, bot)
+ require.NoError(t, err)
+ }
+
+ // Create user with required permissions
+ username := "test-user"
+ role, err := types.NewRole(services.RoleNameForUser(username), types.RoleSpecV6{
+ Allow: types.RoleConditions{
+ Rules: []types.Rule{
+ types.NewRule(types.KindInstance, []string{types.VerbRead, types.VerbList}),
+ types.NewRule(types.KindBotInstance, []string{types.VerbRead, types.VerbList}),
+ },
+ },
+ })
+ require.NoError(t, err)
+ pack := env.proxies[0].authPack(t, username, []types.Role{role})
+
+ endpoint := pack.clt.Endpoint("webapi", "sites", clusterName, "instances")
+
+ // The initial requests are in the eventually to ensure that the inventory
+ // cache is healthy, has processed all put events, and that all the instances
+ // are present.
+ var listResp listUnifiedInstancesResponse
+ require.EventuallyWithT(t, func(t *assert.CollectT) {
+ // Test listing with no params
+ resp, err := pack.clt.Get(ctx, endpoint, url.Values{})
+ require.NoError(t, err)
+
+ require.NoError(t, json.Unmarshal(resp.Bytes(), &listResp))
+ require.Len(t, listResp.Instances, 9, "should have 9 items (6 instances + 3 bots)")
+ }, 10*time.Second, 100*time.Millisecond, "inventory cache failed to become healthy and populated")
+
+ // Test pagination
+ // Get page of 4
+ resp, err := pack.clt.Get(ctx, endpoint, url.Values{
+ "limit": []string{"4"},
+ })
+ require.NoError(t, err)
+ require.NoError(t, json.Unmarshal(resp.Bytes(), &listResp))
+ require.Len(t, listResp.Instances, 4, "should have 4 items")
+ require.NotEmpty(t, listResp.StartKey, "should have a startKey in the response")
+
+ // Get page of 5
+ resp, err = pack.clt.Get(ctx, endpoint, url.Values{
+ "limit": []string{"5"},
+ "startKey": []string{listResp.StartKey},
+ })
+ require.NoError(t, err)
+ require.NoError(t, json.Unmarshal(resp.Bytes(), &listResp))
+ require.Len(t, listResp.Instances, 5, "should have 5 items in second page")
+ // First item on the second page should be instance-2
+ require.Equal(t, "instance-2", listResp.Instances[0].ID)
+ require.Empty(t, listResp.StartKey, "should not have a startKey in the response")
+
+ // Test sorting by version descending
+ resp, err = pack.clt.Get(ctx, endpoint, url.Values{
+ "sort": []string{"version:desc"},
+ })
+ require.NoError(t, err)
+ require.NoError(t, json.Unmarshal(resp.Bytes(), &listResp))
+ require.Len(t, listResp.Instances, 9)
+ require.Equal(t, "bot-3", listResp.Instances[0].ID) // 18.9.0
+ require.Equal(t, "bot-2", listResp.Instances[1].ID) // 18.8.0
+ require.Equal(t, "instance-4", listResp.Instances[5].ID) // 18.4.0
+ require.Equal(t, "instance-1", listResp.Instances[8].ID) // 18.1.0
+
+ // Test searching
+ resp, err = pack.clt.Get(ctx, endpoint, url.Values{
+ "search": []string{"host-1"},
+ })
+ require.NoError(t, err)
+ require.NoError(t, json.Unmarshal(resp.Bytes(), &listResp))
+ require.Len(t, listResp.Instances, 1, "should find 1 item matching 'host-1' search")
+ require.Equal(t, "instance-1", listResp.Instances[0].ID)
+
+ // Test a search with no matches
+ resp, err = pack.clt.Get(ctx, endpoint, url.Values{
+ "search": []string{"nonexistentinstance"},
+ })
+ require.NoError(t, err)
+ require.NoError(t, json.Unmarshal(resp.Bytes(), &listResp))
+ require.Empty(t, listResp.Instances, "should find no items matching the search")
+}
diff --git a/lib/web/ui/instance.go b/lib/web/ui/instance.go
new file mode 100644
index 0000000000000..961def1819c28
--- /dev/null
+++ b/lib/web/ui/instance.go
@@ -0,0 +1,127 @@
+/*
+ * 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 ui
+
+import (
+ "strings"
+
+ inventoryv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/inventory/v1"
+ machineidv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1"
+ "github.com/gravitational/teleport/api/types"
+)
+
+// UnifiedInstance represents either a Teleport instance or a bot instance for the WebUI.
+type UnifiedInstance struct {
+ // ID is the unique identifier for this item
+ ID string `json:"id"`
+ // Type is the type of instance, either "instance" or "bot_instance"
+ Type string `json:"type"`
+ // Instance contains the instance data if type is "instance"
+ Instance *InstanceData `json:"instance,omitempty"`
+ // BotInstance contains the bot instance data if type is "bot_instance"
+ BotInstance *BotInstanceData `json:"botInstance,omitempty"`
+}
+
+// InstanceData represents a teleport instance item for the WebUI
+type InstanceData struct {
+ // Name is the hostname of the instance
+ Name string `json:"name"`
+ // Version is the version
+ Version string `json:"version"`
+ // Services is the list of services running on this instance
+ Services []string `json:"services"`
+ // Upgrader contains information about the external upgrader
+ Upgrader *UpgraderInfo `json:"upgrader,omitempty"`
+}
+
+// BotInstanceData represents a bot instance item for the WebUI
+type BotInstanceData struct {
+ // Name is the name of the bot
+ Name string `json:"name"`
+ // Version is the bot version
+ Version string `json:"version"`
+}
+
+// UpgraderInfo contains information about an external upgrader
+type UpgraderInfo struct {
+ // Type is the upgrader type
+ Type string `json:"type"`
+ // Version is the upgrader version
+ Version string `json:"version"`
+ // Group is the updater group
+ Group string `json:"group"`
+}
+
+// MakeUnifiedInstance creates a UnifiedInstance from a UnifiedInstanceItem proto
+func MakeUnifiedInstance(item *inventoryv1.UnifiedInstanceItem) UnifiedInstance {
+ if instance := item.GetInstance(); instance != nil {
+ return makeInstanceUnifiedItem(instance)
+ }
+ if botInstance := item.GetBotInstance(); botInstance != nil {
+ return makeBotInstanceUnifiedItem(botInstance)
+ }
+ return UnifiedInstance{}
+}
+
+func makeInstanceUnifiedItem(instance *types.InstanceV1) UnifiedInstance {
+ services := make([]string, 0, len(instance.Spec.Services))
+ for _, service := range instance.Spec.Services {
+ services = append(services, string(service))
+ }
+
+ instanceData := &InstanceData{
+ Name: instance.Spec.Hostname,
+ Version: strings.TrimPrefix(instance.Spec.Version, "v"),
+ Services: services,
+ }
+
+ if instance.Spec.ExternalUpgrader != "" || instance.Spec.UpdaterInfo != nil {
+ instanceData.Upgrader = &UpgraderInfo{
+ Type: instance.Spec.ExternalUpgrader,
+ }
+ if instance.Spec.UpdaterInfo != nil {
+ instanceData.Upgrader.Group = instance.Spec.UpdaterInfo.UpdateGroup
+ // UpdaterV2Info doesn't have a version field so we hardcode "v2"
+ instanceData.Upgrader.Version = "v2"
+ }
+ }
+
+ return UnifiedInstance{
+ ID: instance.Metadata.Name,
+ Type: "instance",
+ Instance: instanceData,
+ }
+}
+
+func makeBotInstanceUnifiedItem(botInstance *machineidv1.BotInstance) UnifiedInstance {
+ botData := &BotInstanceData{
+ Name: botInstance.Spec.BotName,
+ }
+
+ if botInstance.Status != nil && len(botInstance.Status.LatestHeartbeats) > 0 {
+ heartbeat := botInstance.Status.LatestHeartbeats[0]
+ botData.Version = strings.TrimPrefix(heartbeat.Version, "v")
+ }
+
+ return UnifiedInstance{
+ ID: botInstance.Metadata.Name,
+ Type: "bot_instance",
+ BotInstance: botData,
+ }
+}
diff --git a/web/packages/design/src/Icon/Icons.story.tsx b/web/packages/design/src/Icon/Icons.story.tsx
index fe4066fc3a7a0..e261d75a25d8c 100644
--- a/web/packages/design/src/Icon/Icons.story.tsx
+++ b/web/packages/design/src/Icon/Icons.story.tsx
@@ -176,6 +176,7 @@ export const Icons = () => (
+
@@ -215,6 +216,7 @@ export const Icons = () => (
+
diff --git a/web/packages/design/src/Icon/Icons/Network.tsx b/web/packages/design/src/Icon/Icons/Network.tsx
new file mode 100644
index 0000000000000..39e9189655957
--- /dev/null
+++ b/web/packages/design/src/Icon/Icons/Network.tsx
@@ -0,0 +1,65 @@
+/**
+ * Teleport
+ * Copyright (C) 2023 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 .
+ */
+
+/* MIT License
+
+Copyright (c) 2020 Phosphor Icons
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+*/
+
+import { forwardRef } from 'react';
+
+import { Icon, IconProps } from '../Icon';
+
+/*
+
+THIS FILE IS GENERATED. DO NOT EDIT.
+
+*/
+
+export const Network = forwardRef(
+ ({ size = 24, color, ...otherProps }, ref) => (
+
+
+
+ )
+);
diff --git a/web/packages/design/src/Icon/Icons/Stack.tsx b/web/packages/design/src/Icon/Icons/Stack.tsx
new file mode 100644
index 0000000000000..e061a00a2841a
--- /dev/null
+++ b/web/packages/design/src/Icon/Icons/Stack.tsx
@@ -0,0 +1,71 @@
+/**
+ * Teleport
+ * Copyright (C) 2023 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 .
+ */
+
+/* MIT License
+
+Copyright (c) 2020 Phosphor Icons
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+*/
+
+import { forwardRef } from 'react';
+
+import { Icon, IconProps } from '../Icon';
+
+/*
+
+THIS FILE IS GENERATED. DO NOT EDIT.
+
+*/
+
+export const Stack = forwardRef(
+ ({ size = 24, color, ...otherProps }, ref) => (
+
+
+
+
+
+ )
+);
diff --git a/web/packages/design/src/Icon/assets/Network.svg b/web/packages/design/src/Icon/assets/Network.svg
new file mode 100644
index 0000000000000..046b6b19e4c1e
--- /dev/null
+++ b/web/packages/design/src/Icon/assets/Network.svg
@@ -0,0 +1,3 @@
+
diff --git a/web/packages/design/src/Icon/assets/Stack.svg b/web/packages/design/src/Icon/assets/Stack.svg
new file mode 100644
index 0000000000000..04e416b7424d4
--- /dev/null
+++ b/web/packages/design/src/Icon/assets/Stack.svg
@@ -0,0 +1,5 @@
+
diff --git a/web/packages/design/src/Icon/index.ts b/web/packages/design/src/Icon/index.ts
index 58a7137e8d15d..f5fd2d1eb8496 100644
--- a/web/packages/design/src/Icon/index.ts
+++ b/web/packages/design/src/Icon/index.ts
@@ -165,6 +165,7 @@ export { Moon } from './Icons/Moon';
export { MoreHoriz } from './Icons/MoreHoriz';
export { MoreVert } from './Icons/MoreVert';
export { Mute } from './Icons/Mute';
+export { Network } from './Icons/Network';
export { NewTab } from './Icons/NewTab';
export { NoteAdded } from './Icons/NoteAdded';
export { Notification } from './Icons/Notification';
@@ -204,6 +205,7 @@ export { SortDescending } from './Icons/SortDescending';
export { Speed } from './Icons/Speed';
export { Spinner } from './Icons/Spinner';
export { SquaresFour } from './Icons/SquaresFour';
+export { Stack } from './Icons/Stack';
export { Stars } from './Icons/Stars';
export { Sun } from './Icons/Sun';
export { SyncAlt } from './Icons/SyncAlt';
diff --git a/web/packages/shared/components/CopyButton/CopyButton.tsx b/web/packages/shared/components/CopyButton/CopyButton.tsx
index 8362a6d8b9f2a..a18c74ff65942 100644
--- a/web/packages/shared/components/CopyButton/CopyButton.tsx
+++ b/web/packages/shared/components/CopyButton/CopyButton.tsx
@@ -31,13 +31,15 @@ export function CopyButton({
value,
mr,
ml,
+ tooltip,
}: {
value: string;
mr?: number;
ml?: number;
+ tooltip?: string;
}) {
const copySuccess = 'Copied!';
- const copyDefault = 'Click to copy';
+ const copyDefault = tooltip || 'Click to copy';
const timeout = useRef>(undefined);
const copyAnchorEl = useRef(null);
const [copiedText, setCopiedText] = useState(copyDefault);
diff --git a/web/packages/teleport/src/Instances/Instances.story.tsx b/web/packages/teleport/src/Instances/Instances.story.tsx
new file mode 100644
index 0000000000000..82950e105509d
--- /dev/null
+++ b/web/packages/teleport/src/Instances/Instances.story.tsx
@@ -0,0 +1,183 @@
+/**
+ * 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 .
+ */
+
+import { Meta, StoryObj } from '@storybook/react-vite';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { createMemoryHistory } from 'history';
+import { MemoryRouter, Route, Router } from 'react-router';
+
+import cfg from 'teleport/config';
+import { createTeleportContext } from 'teleport/mocks/contexts';
+import { TeleportProviderBasic } from 'teleport/mocks/providers';
+import { defaultAccess, makeAcl } from 'teleport/services/user/makeAcl';
+import {
+ listInstancesError,
+ listInstancesLoading,
+ listInstancesSuccess,
+ listOnlyBotInstances,
+ listOnlyRegularInstances,
+} from 'teleport/test/helpers/instances';
+
+import { Instances } from './Instances';
+
+const meta = {
+ title: 'Teleport/Instance Inventory',
+ component: Wrapper,
+ beforeEach: () => {
+ queryClient.clear();
+ },
+} satisfies Meta;
+
+type Story = StoryObj;
+
+export default meta;
+
+export const Loaded: Story = {
+ parameters: {
+ msw: {
+ handlers: [listInstancesSuccess],
+ },
+ },
+};
+
+export const CacheInitializing: Story = {
+ parameters: {
+ msw: {
+ handlers: [
+ listInstancesError(
+ 503,
+ 'inventory cache is not yet healthy, please try again in a few minutes'
+ ),
+ ],
+ },
+ },
+};
+
+export const Loading: Story = {
+ parameters: {
+ msw: {
+ handlers: [listInstancesLoading],
+ },
+ },
+};
+
+export const Error: Story = {
+ parameters: {
+ msw: {
+ handlers: [listInstancesError(500, 'some error')],
+ },
+ },
+};
+
+export const NoInstancePermissions: Story = {
+ args: {
+ hasInstanceListPermission: false,
+ hasInstanceReadPermission: false,
+ },
+ parameters: {
+ msw: {
+ handlers: [listOnlyBotInstances],
+ },
+ },
+};
+
+export const NoBotInstancePermissions: Story = {
+ args: {
+ hasBotInstanceListPermission: false,
+ hasBotInstanceReadPermission: false,
+ },
+ parameters: {
+ msw: {
+ handlers: [listOnlyRegularInstances],
+ },
+ },
+};
+
+export const NoPermissionsAtAll: Story = {
+ args: {
+ hasInstanceListPermission: false,
+ hasInstanceReadPermission: false,
+ hasBotInstanceListPermission: false,
+ hasBotInstanceReadPermission: false,
+ },
+ parameters: {
+ msw: {
+ handlers: [listInstancesError(403, 'access denied')],
+ },
+ },
+};
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ refetchOnWindowFocus: false,
+ retry: false,
+ },
+ },
+});
+
+function Wrapper(props?: {
+ hasInstanceListPermission?: boolean;
+ hasInstanceReadPermission?: boolean;
+ hasBotInstanceListPermission?: boolean;
+ hasBotInstanceReadPermission?: boolean;
+}) {
+ const {
+ hasInstanceListPermission = true,
+ hasInstanceReadPermission = true,
+ hasBotInstanceListPermission = true,
+ hasBotInstanceReadPermission = true,
+ } = props ?? {};
+
+ const history = createMemoryHistory({
+ initialEntries: [cfg.routes.instances],
+ });
+
+ const customAcl = makeAcl({
+ instances: {
+ ...defaultAccess,
+ read: hasInstanceReadPermission,
+ list: hasInstanceListPermission,
+ },
+ botInstances: {
+ ...defaultAccess,
+ read: hasBotInstanceReadPermission,
+ list: hasBotInstanceListPermission,
+ },
+ });
+
+ const ctx = createTeleportContext({
+ customAcl,
+ });
+
+ ctx.storeUser.state.cluster.authVersion = '18.2.4';
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/web/packages/teleport/src/Instances/Instances.test.tsx b/web/packages/teleport/src/Instances/Instances.test.tsx
new file mode 100644
index 0000000000000..60b63963537d4
--- /dev/null
+++ b/web/packages/teleport/src/Instances/Instances.test.tsx
@@ -0,0 +1,337 @@
+/**
+ * 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 .
+ */
+
+import { QueryClientProvider } from '@tanstack/react-query';
+import { createMemoryHistory } from 'history';
+import { http, HttpResponse } from 'msw';
+import { setupServer } from 'msw/node';
+import { PropsWithChildren } from 'react';
+import { MemoryRouter, Route, Router } from 'react-router';
+
+import darkTheme from 'design/theme/themes/darkTheme';
+import { ConfiguredThemeProvider } from 'design/ThemeProvider';
+import {
+ render,
+ screen,
+ testQueryClient,
+ userEvent,
+ waitFor,
+} from 'design/utils/testing';
+
+import cfg from 'teleport/config';
+import { ContextProvider } from 'teleport/index';
+import { createTeleportContext } from 'teleport/mocks/contexts';
+import { defaultAccess, makeAcl } from 'teleport/services/user/makeAcl';
+import {
+ listInstancesError,
+ listInstancesSuccess,
+ listOnlyBotInstances,
+ listOnlyRegularInstances,
+ mockInstances,
+} from 'teleport/test/helpers/instances';
+
+import { Instances } from './Instances';
+
+const server = setupServer();
+
+beforeAll(() => {
+ server.listen();
+
+ global.IntersectionObserver = class IntersectionObserver {
+ constructor() {}
+ disconnect() {}
+ observe() {}
+ takeRecords() {
+ return [];
+ }
+ unobserve() {}
+ } as any;
+});
+
+afterEach(async () => {
+ server.resetHandlers();
+ await testQueryClient.resetQueries();
+ jest.clearAllMocks();
+});
+
+afterAll(() => server.close());
+
+it('having no permissions should show correct error', async () => {
+ renderComponent({
+ customAcl: makeAcl({
+ instances: {
+ ...defaultAccess,
+ list: false,
+ read: false,
+ },
+ botInstances: {
+ ...defaultAccess,
+ list: false,
+ read: false,
+ },
+ }),
+ });
+
+ expect(
+ screen.getByText(
+ 'You do not have permission to view the instance inventory.',
+ { exact: false }
+ )
+ ).toBeInTheDocument();
+});
+
+it('having only bot instances permissions should show warning banner', async () => {
+ server.use(listOnlyBotInstances);
+ renderComponent({
+ customAcl: makeAcl({
+ instances: {
+ ...defaultAccess,
+ list: false,
+ read: false,
+ },
+ botInstances: {
+ ...defaultAccess,
+ list: true,
+ read: true,
+ },
+ }),
+ });
+
+ await waitFor(() => {
+ expect(
+ screen.getByText('You do not have permission to view instances.', {
+ exact: false,
+ })
+ ).toBeInTheDocument();
+ });
+});
+
+it('having only instances permissions should show warning banner', async () => {
+ server.use(listOnlyRegularInstances);
+ renderComponent({
+ customAcl: makeAcl({
+ instances: {
+ ...defaultAccess,
+ list: true,
+ read: true,
+ },
+ botInstances: {
+ ...defaultAccess,
+ list: false,
+ read: false,
+ },
+ }),
+ });
+
+ await waitFor(() => {
+ expect(
+ screen.getByText('You do not have permission to view bot instances.', {
+ exact: false,
+ })
+ ).toBeInTheDocument();
+ });
+});
+
+it('cache still initializing error should show correct error', async () => {
+ server.use(
+ listInstancesError(
+ 503,
+ 'inventory cache is not yet healthy, please try again in a few minutes'
+ )
+ );
+ renderComponent();
+
+ await waitFor(() => {
+ expect(
+ screen.getByText(
+ 'The instance inventory is not yet ready to be displayed',
+ { exact: false }
+ )
+ ).toBeInTheDocument();
+ });
+});
+
+it('listing successfully should show instances', async () => {
+ server.use(listInstancesSuccess);
+ renderComponent();
+
+ expect(
+ await screen.findByText('ip-10-1-1-100.ec2.internal')
+ ).toBeInTheDocument();
+ expect(screen.getByText('teleport-auth-01')).toBeInTheDocument();
+ expect(screen.getByText('app-server-prod')).toBeInTheDocument();
+ expect(screen.getByText('github-actions-bot')).toBeInTheDocument();
+ expect(screen.getByText('ci-cd-bot')).toBeInTheDocument();
+});
+
+it('no instances should show empty state', async () => {
+ server.use(
+ http.get('/v1/webapi/sites/:clusterId/instances', () => {
+ return HttpResponse.json({
+ instances: [],
+ startKey: '',
+ });
+ })
+ );
+ renderComponent();
+
+ await waitFor(() => {
+ expect(screen.getByText('No instances found')).toBeInTheDocument();
+ });
+});
+
+it('search query param in the URL should be populated in the search input', async () => {
+ server.use(listInstancesSuccess);
+
+ renderComponent({ initialUrl: cfg.routes.instances + '?query=test-server' });
+
+ const searchInput = screen.getByPlaceholderText(/search/i);
+ expect(searchInput).toHaveValue('test-server');
+});
+
+it('version filter query param URL should be populated in the version filter control', async () => {
+ server.use(listInstancesSuccess);
+
+ renderComponent({
+ initialUrl: cfg.routes.instances + '?version_filter=up-to-date',
+ });
+
+ const versionButton = screen.getByRole('button', {
+ name: /Version \(1\)/i,
+ });
+ expect(versionButton).toBeInTheDocument();
+});
+
+it('selecting a version filter should append the version predicate expression to an existing advanced query', async () => {
+ let lastRequestUrl: string;
+
+ server.use(
+ http.get('/v1/webapi/sites/:clusterId/instances', ({ request }) => {
+ lastRequestUrl = request.url;
+ return HttpResponse.json(mockInstances);
+ })
+ );
+
+ const { user } = renderComponent();
+
+ // Wait for initial load
+ await screen.findByText('ip-10-1-1-100.ec2.internal');
+
+ // Switch to advanced search mode
+ const advancedToggle = screen.getByRole('checkbox', {
+ name: /advanced/i,
+ });
+ await user.click(advancedToggle);
+
+ // Type in a predicate query
+ const searchInput = screen.getByPlaceholderText(/search/i);
+ await user.clear(searchInput);
+ await user.type(searchInput, 'name == "teleport-auth-01"{Enter}');
+
+ await waitFor(() => {
+ expect(lastRequestUrl).toContain('query=');
+ });
+ {
+ const url = new URL(lastRequestUrl);
+ expect(url.searchParams.get('query')).toBe('name == "teleport-auth-01"');
+ }
+
+ // Select a version filter
+ const versionButton = screen.getByRole('button', { name: /Version/i });
+ await user.click(versionButton);
+
+ const upToDateOption = screen.getByText('Up-to-date');
+ await user.click(upToDateOption);
+
+ const applyButton = screen.getByRole('button', { name: /Apply Filters/i });
+ await user.click(applyButton);
+
+ // Verify that the request made combines both predicates
+ await waitFor(() => {
+ expect(lastRequestUrl).toContain('version');
+ });
+ {
+ const url = new URL(lastRequestUrl);
+ expect(url.searchParams.get('query')).toBe(
+ '(name == "teleport-auth-01") && (version == "18.2.4")'
+ );
+ }
+}, 15000);
+
+function renderComponent(options?: {
+ customAcl?: ReturnType;
+ initialUrl?: string;
+}) {
+ const user = userEvent.setup();
+ const history = createMemoryHistory({
+ initialEntries: [options?.initialUrl || cfg.routes.instances],
+ });
+
+ return {
+ ...render(, {
+ wrapper: makeWrapper({
+ customAcl: options?.customAcl,
+ history,
+ }),
+ }),
+ user,
+ };
+}
+
+function makeWrapper(options: {
+ history: ReturnType;
+ customAcl?: ReturnType;
+}) {
+ const {
+ history,
+ customAcl = makeAcl({
+ instances: {
+ ...defaultAccess,
+ list: true,
+ read: true,
+ },
+ botInstances: {
+ ...defaultAccess,
+ list: true,
+ read: true,
+ },
+ }),
+ } = options;
+
+ return ({ children }: PropsWithChildren) => {
+ const ctx = createTeleportContext({
+ customAcl,
+ });
+
+ ctx.storeUser.state.cluster.authVersion = '18.2.4';
+
+ return (
+
+
+
+
+
+ {children}
+
+
+
+
+
+ );
+ };
+}
diff --git a/web/packages/teleport/src/Instances/Instances.tsx b/web/packages/teleport/src/Instances/Instances.tsx
new file mode 100644
index 0000000000000..c33437fe0b3ca
--- /dev/null
+++ b/web/packages/teleport/src/Instances/Instances.tsx
@@ -0,0 +1,514 @@
+/**
+ * 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 .
+ */
+
+import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query';
+import { useCallback, useMemo } from 'react';
+import { useHistory, useLocation } from 'react-router';
+import styled from 'styled-components';
+
+import { Alert } from 'design/Alert';
+import Box from 'design/Box';
+import Flex from 'design/Flex/Flex';
+import { MultiselectMenu } from 'shared/components/Controls/MultiselectMenu';
+import { SortMenu } from 'shared/components/Controls/SortMenuV2';
+import { SearchPanel } from 'shared/components/Search';
+
+import {
+ FeatureBox,
+ FeatureHeader,
+ FeatureHeaderTitle,
+} from 'teleport/components/Layout/Layout';
+import cfg from 'teleport/config';
+import type { SortType } from 'teleport/services/agents';
+import api from 'teleport/services/api';
+import { ApiError } from 'teleport/services/api/parseError';
+import type { UnifiedInstancesResponse } from 'teleport/services/instances/types';
+import useTeleport from 'teleport/useTeleport';
+
+import { InstancesList } from './InstancesList';
+import {
+ buildVersionPredicate,
+ CustomOperator,
+ FilterOption,
+ VersionsFilterPanel,
+} from './VersionsFilterPanel';
+
+async function fetchInstances(
+ variables: {
+ clusterId: string;
+ limit: number;
+ startKey?: string;
+ query?: string;
+ search?: string;
+ sort?: SortType;
+ types?: string;
+ services?: string;
+ upgraders?: string;
+ },
+ signal?: AbortSignal
+): Promise {
+ const { clusterId, ...params } = variables;
+
+ const response = await api.get(
+ cfg.getInstancesUrl(clusterId, params),
+ signal
+ );
+
+ return {
+ instances: response?.instances || [],
+ startKey: response?.startKey,
+ };
+}
+
+export function Instances() {
+ const history = useHistory();
+ const location = useLocation();
+ const queryParams = new URLSearchParams(location.search);
+ const query = queryParams.get('query') ?? '';
+ const isAdvancedQuery = Boolean(queryParams.get('is_advanced'));
+ const sortField = queryParams.get('sort') || 'name';
+ const sortDir = queryParams.get('sort_dir') || 'ASC';
+
+ const typesParam = queryParams.get('types');
+ const selectedTypes = (
+ typesParam ? typesParam.split(',') : []
+ ) as InstanceType[];
+
+ const servicesParam = queryParams.get('services');
+ const selectedServices = (
+ servicesParam ? servicesParam.split(',') : []
+ ) as ServiceType[];
+
+ const upgradersParam = queryParams.get('upgraders');
+ const selectedUpgraders = (
+ upgradersParam !== null ? upgradersParam.split(',') : []
+ ) as UpgraderType[];
+
+ const versionFilter = queryParams.get('version_filter') || '';
+ const versionOperator = queryParams.get('version_operator') || '';
+ const versionValue1 = queryParams.get('version_value1') || '';
+ const versionValue2 = queryParams.get('version_value2') || '';
+
+ const ctx = useTeleport();
+ const clusterId = ctx.storeUser.getClusterId();
+ const authVersion = ctx.storeUser.state.cluster.authVersion;
+ const flags = ctx.getFeatureFlags();
+
+ const hasInstancePermissions = flags.listInstances && flags.readInstances;
+ const hasBotInstancePermissions =
+ flags.listBotInstances && flags.readBotInstances;
+ const hasAnyPermissions = hasInstancePermissions || hasBotInstancePermissions;
+
+ // versionPredicateQuery is the predicate query for the selected version filter, if any.
+ // Under the hood, the version filter works by appending a predicate query to the request which
+ // applies the selected version filters
+ const versionPredicateQuery = useMemo(
+ () =>
+ buildVersionPredicate(
+ versionFilter,
+ versionOperator,
+ versionValue1,
+ versionValue2,
+ authVersion
+ ),
+ [versionFilter, versionOperator, versionValue1, versionValue2, authVersion]
+ );
+
+ // If there is also an existing predicate query (ie. the user made an advanced search), we append the version predicate query to it in the request
+ const combinedQuery = useMemo(() => {
+ if (versionPredicateQuery && query && isAdvancedQuery) {
+ return `(${query}) && (${versionPredicateQuery})`;
+ }
+
+ if (versionPredicateQuery) {
+ return versionPredicateQuery;
+ }
+
+ if (isAdvancedQuery && query) {
+ return query;
+ }
+
+ return '';
+ }, [query, isAdvancedQuery, versionPredicateQuery]);
+
+ const onlyBotInstancesSelected =
+ selectedTypes.length === 1 && selectedTypes[0] === 'bot_instance';
+
+ const {
+ isSuccess,
+ data,
+ isFetching,
+ isFetchingNextPage,
+ error,
+ hasNextPage,
+ fetchNextPage,
+ } = useInfiniteQuery({
+ enabled: hasAnyPermissions,
+ queryKey: [
+ 'instances',
+ 'list',
+ clusterId,
+ sortField,
+ sortDir,
+ query,
+ isAdvancedQuery,
+ selectedTypes.join(','),
+ selectedServices.join(','),
+ selectedUpgraders.join(','),
+ versionPredicateQuery,
+ ],
+ queryFn: ({ pageParam, signal }) =>
+ fetchInstances(
+ {
+ clusterId,
+ limit: 32,
+ startKey: pageParam,
+ query: combinedQuery || undefined,
+ search: !isAdvancedQuery ? query : undefined,
+ sort: { fieldName: sortField, dir: sortDir as 'ASC' | 'DESC' },
+ types: selectedTypes.length > 0 ? selectedTypes.join(',') : undefined,
+ services:
+ selectedServices.length > 0
+ ? selectedServices.join(',')
+ : undefined,
+ upgraders:
+ selectedUpgraders.length > 0
+ ? selectedUpgraders.join(',')
+ : undefined,
+ },
+ signal
+ ),
+ initialPageParam: '',
+ getNextPageParam: data => data?.startKey || undefined,
+ placeholderData: keepPreviousData,
+ staleTime: 30_000,
+ });
+
+ // Check if the error is due to cache initialization (HTTP 503)
+ const isCacheInitializing =
+ error instanceof ApiError && error.response.status === 503;
+
+ const updateSearch = useCallback(
+ (updateFn: (search: URLSearchParams) => void) => {
+ const search = new URLSearchParams(location.search);
+ updateFn(search);
+ history.push({
+ pathname: location.pathname,
+ search: search.toString(),
+ });
+ },
+ [history, location.pathname, location.search]
+ );
+
+ const handleQueryChange = useCallback(
+ (query: string, isAdvanced: boolean) =>
+ updateSearch(search => {
+ if (query) {
+ search.set('query', query);
+ } else {
+ search.delete('query');
+ }
+ if (isAdvanced) {
+ search.set('is_advanced', '1');
+ } else {
+ search.delete('is_advanced');
+ }
+ }),
+ [updateSearch]
+ );
+
+ const handleSortChange = useCallback(
+ (sortField: string, sortDir: string) => {
+ const search = new URLSearchParams(location.search);
+ search.set('sort', sortField);
+ search.set('sort_dir', sortDir);
+
+ history.replace({
+ pathname: location.pathname,
+ search: search.toString(),
+ });
+ },
+ [history, location.pathname, location.search]
+ );
+
+ const handleTypesChange = useCallback(
+ (types: InstanceType[]) =>
+ updateSearch(search => {
+ if (types.length > 0) {
+ search.set('types', types.join(','));
+ } else {
+ search.delete('types');
+ }
+ }),
+ [updateSearch]
+ );
+
+ const handleServicesChange = useCallback(
+ (services: ServiceType[]) =>
+ updateSearch(search => {
+ if (services.length > 0) {
+ search.set('services', services.join(','));
+ } else {
+ search.delete('services');
+ }
+ }),
+ [updateSearch]
+ );
+
+ const handleUpgradersChange = useCallback(
+ (upgraders: UpgraderType[]) =>
+ updateSearch(search => {
+ if (upgraders.length > 0) {
+ search.set('upgraders', upgraders.join(','));
+ } else {
+ search.delete('upgraders');
+ }
+ }),
+ [updateSearch]
+ );
+
+ const handleVersionFilterChange = useCallback(
+ (filter: {
+ selectedOption: string;
+ operator: string;
+ value1: string;
+ value2: string;
+ }) =>
+ updateSearch(search => {
+ if (filter.selectedOption) {
+ // If it's one of the preset filters, set it as the version_filter param in the route
+ search.set('version_filter', filter.selectedOption);
+
+ // For a custom condition version filter, we also set the custom version values
+ if (filter.selectedOption === 'custom') {
+ search.set('version_operator', filter.operator);
+ if (filter.value1) {
+ search.set('version_value1', filter.value1);
+ } else {
+ search.delete('version_value1');
+ }
+
+ if (filter.value2) {
+ search.set('version_value2', filter.value2);
+ } else {
+ search.delete('version_value2');
+ }
+ } else {
+ search.delete('version_operator');
+ search.delete('version_value1');
+ search.delete('version_value2');
+ }
+ } else {
+ search.delete('version_filter');
+ search.delete('version_operator');
+ search.delete('version_value1');
+ search.delete('version_value2');
+ }
+ }),
+ [updateSearch]
+ );
+
+ const flatData = useMemo(
+ () => (isSuccess ? data.pages.flatMap(page => page.instances) : []),
+ [data?.pages, isSuccess]
+ );
+
+ // If they have neither instances nor bot instances permissions, just render a message informing them
+ if (!hasAnyPermissions) {
+ return (
+
+
+ Instance Inventory
+
+
+ You do not have permission to view the instance inventory. Missing
+ permissions: instance.list or instance.read,
+ and bot_instance.list or bot_instance.read.
+
+
+ );
+ }
+
+ if (isCacheInitializing) {
+ return (
+
+
+ Instance Inventory
+
+
+ The instance inventory is not yet ready to be displayed, please check
+ back in a few minutes.
+
+
+ );
+ }
+
+ return (
+
+
+ Instance Inventory
+
+
+
+ {!hasInstancePermissions && hasBotInstancePermissions && (
+
+ You do not have permission to view instances. This list will only
+ show bot instances.
+
+ Listing instances requires permissions
+ instance.list
+ {' '}
+ and instance.read.
+
+ )}
+ {hasInstancePermissions && !hasBotInstancePermissions && (
+
+ You do not have permission to view bot instances. This list will
+ only show instances.
+
+ Listing bot instances requires permissions{' '}
+ bot_instance.list and bot_instance.read.
+
+ )}
+ handleQueryChange(query, false)}
+ updateQuery={query => handleQueryChange(query, true)}
+ mb={3}
+ />
+
+
+
+
+
+
+
+ {
+ handleSortChange(key, order);
+ }}
+ />
+
+
+
+
+ );
+}
+
+const FiltersRow = styled(Flex)`
+ flex-wrap: wrap;
+`;
+
+type InstanceType = 'instance' | 'bot_instance';
+
+type ServiceType =
+ | 'App'
+ | 'Db'
+ | 'WindowsDesktop'
+ | 'Kube'
+ | 'Node'
+ | 'Auth'
+ | 'Proxy';
+
+type UpgraderType =
+ | ''
+ | 'kube-updater'
+ | 'unit-updater'
+ | 'systemd-unit-updater';
+
+const typeOptions: { value: InstanceType; label: string }[] = [
+ { value: 'instance', label: 'Instances' },
+ { value: 'bot_instance', label: 'Bot Instances' },
+];
+
+const serviceOptions: { value: ServiceType; label: string }[] = [
+ { value: 'App', label: 'Applications' },
+ { value: 'Db', label: 'Databases' },
+ { value: 'WindowsDesktop', label: 'Desktops' },
+ { value: 'Kube', label: 'Kubernetes Clusters' },
+ { value: 'Node', label: 'SSH Servers' },
+ { value: 'Auth', label: 'Auth' },
+ { value: 'Proxy', label: 'Proxy' },
+];
+
+const upgraderOptions = [
+ { value: '', label: 'None' },
+ { value: 'unit-updater', label: 'Unit Updater (legacy)' },
+ {
+ value: 'systemd-unit-updater',
+ label: 'Systemd Unit Updater',
+ },
+ { value: 'kube-updater', label: 'Kubernetes' },
+];
+
+const sortFields = [
+ { key: 'name', label: 'Name' },
+ { key: 'version', label: 'Version' },
+ { key: 'type', label: 'Type' },
+];
diff --git a/web/packages/teleport/src/Instances/InstancesList.tsx b/web/packages/teleport/src/Instances/InstancesList.tsx
new file mode 100644
index 0000000000000..6a6053ceed1a6
--- /dev/null
+++ b/web/packages/teleport/src/Instances/InstancesList.tsx
@@ -0,0 +1,354 @@
+/**
+ * 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 .
+ */
+
+import { Link } from 'react-router-dom';
+import styled from 'styled-components';
+
+import { Box, Flex, Text } from 'design';
+import { Danger } from 'design/Alert';
+import { ButtonBorder } from 'design/Button';
+import Table, { Cell } from 'design/DataTable';
+import * as Icons from 'design/Icon';
+import { Indicator } from 'design/Indicator';
+import { HoverTooltip } from 'design/Tooltip';
+import { CopyButton } from 'shared/components/CopyButton/CopyButton';
+import { useInfiniteScroll } from 'shared/hooks';
+
+import cfg from 'teleport/config';
+import { UnifiedInstance } from 'teleport/services/instances/types';
+
+type TableInstance = {
+ name: string;
+ version: string;
+ type: 'instance' | 'bot_instance';
+ original: UnifiedInstance;
+};
+
+export function InstancesList(props: {
+ data: UnifiedInstance[];
+ isLoading: boolean;
+ isFetchingNextPage: boolean;
+ error: Error | null;
+ hasNextPage: boolean;
+ sortField: string;
+ sortDir: string;
+ onSortChanged: (sortField: string, sortDir: string) => void;
+ onLoadNextPage: () => void;
+}) {
+ const {
+ data,
+ isLoading,
+ isFetchingNextPage,
+ error,
+ hasNextPage,
+ sortField,
+ sortDir,
+ onSortChanged,
+ onLoadNextPage,
+ } = props;
+
+ // Normalize the data
+ const tableData: TableInstance[] = data.map(instance => ({
+ name:
+ instance.type === 'instance'
+ ? instance.instance?.name || instance.id
+ : instance.botInstance?.name || '',
+ version:
+ instance.type === 'instance'
+ ? instance.instance?.version || ''
+ : instance.botInstance?.version || '',
+ type: instance.type,
+ original: instance,
+ }));
+
+ const { setTrigger } = useInfiniteScroll({
+ fetch: async () => {
+ if (hasNextPage && !isFetchingNextPage) {
+ onLoadNextPage();
+ }
+ },
+ });
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ Failed to fetch instances
+
+ );
+ }
+
+ if (!data || data.length === 0) {
+ return (
+
+
+ No instances found
+
+
+ );
+ }
+
+ return (
+
+ (
+
+ ),
+ },
+ {
+ key: 'version',
+ headerText: 'Version',
+ isSortable: true,
+ render: (row: TableInstance) => {row.version} | ,
+ },
+ {
+ key: 'type',
+ headerText: 'Type',
+ isSortable: true,
+ render: (row: TableInstance) => (
+
+ {row.type === 'instance' ? 'Instance' : 'Bot Instance'}
+ |
+ ),
+ },
+ {
+ altKey: 'services',
+ headerText: 'Services',
+ render: (row: TableInstance) => (
+
+ ),
+ },
+ {
+ altKey: 'upgrader',
+ headerText: 'Upgrader',
+ render: (row: TableInstance) => {
+ const upgraderType =
+ row.type === 'instance'
+ ? row.original.instance?.upgrader?.type
+ : undefined;
+ return ;
+ },
+ },
+ {
+ altKey: 'upgrader-group',
+ headerText: 'Upgrader Group',
+ render: (row: TableInstance) => {
+ const group =
+ row.type === 'instance'
+ ? row.original.instance?.upgrader?.group
+ : undefined;
+ return {group || ''} | ;
+ },
+ },
+ ]}
+ emptyText="No instances found"
+ customSort={{
+ fieldName: sortField,
+ dir: sortDir === 'DESC' ? 'DESC' : 'ASC',
+ onSort: sort => {
+ onSortChanged(sort.fieldName, sort.dir);
+ },
+ }}
+ />
+
+ {isFetchingNextPage && (
+
+
+
+ )}
+
+ );
+}
+
+const StyledTable = styled(Table)`
+ thead > tr > th {
+ color: ${props => props.theme.colors.text.slightlyMuted};
+ }
+` as typeof Table;
+
+function NameCell({ instance }: { instance: UnifiedInstance }) {
+ const name =
+ instance.type === 'instance'
+ ? instance.instance?.name || instance.id // Use the id as the name in case it doesn't have a friendly name
+ : instance.botInstance?.name;
+
+ return (
+
+ {name && {name}}
+
+
+ {instance.id.substring(0, 7)}
+
+
+
+
+
+ |
+ );
+}
+
+/**
+ * UpgraderCell displays the upgrader in a more readable way with styling
+ */
+function UpgraderCell({ upgrader }: { upgrader: string | undefined }) {
+ if (!upgrader || upgrader === '') {
+ return (
+
+ None
+ |
+ );
+ }
+
+ if (upgrader === 'unit-updater') {
+ return (
+
+ Unit Updater (legacy)
+ |
+ );
+ }
+
+ if (upgrader === 'systemd-unit-updater') {
+ return (
+
+ Systemd Unit Updater
+ |
+ );
+ }
+
+ if (upgrader === 'kube-updater') {
+ return (
+
+ Kubernetes
+ |
+ );
+ }
+
+ // This normally shouldn't happen, but in case it's none of the expected values, and it's also not empty, just display whatever it is as is
+ return (
+
+ {upgrader}
+ |
+ );
+}
+
+function ServicesCell({ instance }: { instance: UnifiedInstance }) {
+ // For bot instances, we don't list services in this table. Instead, we deeplink to the bot instance dashboard page with this
+ // particular bot instance filtered for and selected
+ if (instance.type === 'bot_instance') {
+ const query = `spec.instance_id == "${instance.id}"`;
+ const botName = instance.botInstance.name;
+ const url = cfg.getBotInstancesRoute({
+ query,
+ isAdvancedQuery: true,
+ selectedItemId: `${botName}/${instance.id}`,
+ });
+
+ return (
+
+
+
+ Services
+
+
+ |
+ );
+ }
+
+ const services = instance.instance?.services || [];
+
+ return (
+
+
+ {services.map(service => {
+ const IconComponent = getServiceIcon(service);
+ const displayName = getServiceDisplayName(service);
+ return (
+
+
+
+
+
+ );
+ })}
+
+ |
+ );
+}
+
+function getServiceIcon(service: string): React.ComponentType {
+ const serviceMap: Record> = {
+ node: Icons.Server,
+ kube: Icons.Kubernetes,
+ app: Icons.Application,
+ db: Icons.Database,
+ windowsdesktop: Icons.Desktop,
+ proxy: Icons.Network,
+ auth: Icons.Keypair,
+ };
+
+ return serviceMap[service.toLowerCase()] || Icons.Server;
+}
+
+function getServiceDisplayName(service: string): string {
+ const displayNames: Record = {
+ node: 'SSH Server',
+ kube: 'Kubernetes',
+ app: 'Application',
+ db: 'Database',
+ windowsdesktop: 'Windows Desktop',
+ proxy: 'Proxy',
+ auth: 'Auth',
+ };
+
+ return displayNames[service.toLowerCase()] || service;
+}
+
+const IdContainer = styled(Box)`
+ display: inline-flex;
+ align-items: center;
+ gap: ${props => props.theme.space[1]}px;
+`;
+
+const IdText = styled(Text)`
+ color: ${props => props.theme.colors.text.muted};
+ font-size: ${props => props.theme.fontSizes[1]}px;
+ font-family: ${props => props.theme.fonts.mono};
+`;
+
+const CopyButtonWrapper = styled(Box)`
+ display: inline-flex;
+ align-items: center;
+ opacity: 0;
+
+ tr:hover & {
+ opacity: 1;
+ }
+`;
diff --git a/web/packages/teleport/src/Instances/VersionsFilterPanel.tsx b/web/packages/teleport/src/Instances/VersionsFilterPanel.tsx
new file mode 100644
index 0000000000000..b87f3aec5fa24
--- /dev/null
+++ b/web/packages/teleport/src/Instances/VersionsFilterPanel.tsx
@@ -0,0 +1,530 @@
+/**
+ * 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 .
+ */
+
+import React, { useState } from 'react';
+import styled, { useTheme } from 'styled-components';
+
+import {
+ Box,
+ ButtonPrimary,
+ ButtonSecondary,
+ Flex,
+ Input,
+ Menu,
+ MenuItem,
+ Text,
+} from 'design';
+import { Check, ChevronDown } from 'design/Icon';
+import * as Icons from 'design/Icon';
+import { HoverTooltip } from 'design/Tooltip';
+import { FiltersExistIndicator } from 'shared/components/Controls/MultiselectMenu';
+import Select from 'shared/components/Select';
+import { major, parse } from 'shared/utils/semVer';
+
+export type FilterOption =
+ | 'up-to-date'
+ | 'patch'
+ | 'upgrade'
+ | 'incompatible'
+ | 'custom';
+
+export type CustomOperator =
+ | 'equals'
+ | 'less-than'
+ | 'greater-than'
+ | 'between';
+
+interface VersionsFilterPanelProps {
+ currentVersion: string;
+ onApply: (filter: {
+ selectedOption: FilterOption;
+ operator: CustomOperator;
+ value1: string;
+ value2: string;
+ }) => void;
+ tooltip?: string;
+ disabled?: boolean;
+ filter?: FilterOption;
+ operator?: CustomOperator;
+ value1?: string;
+ value2?: string;
+}
+
+export function VersionsFilterPanel({
+ currentVersion,
+ onApply,
+ tooltip = 'Filter by version',
+ disabled = false,
+ filter,
+ operator = 'equals',
+ value1 = '',
+ value2 = '',
+}: VersionsFilterPanelProps) {
+ const theme = useTheme();
+ const [anchorEl, setAnchorEl] = useState(null);
+ const [selectedOption, setSelectedOption] = useState(
+ null
+ );
+ const [customOperator, setCustomOperator] =
+ useState('equals');
+ const [customValue1, setCustomValue1] = useState('');
+ const [customValue2, setCustomValue2] = useState('');
+
+ const handleOpen = (event: React.MouseEvent) => {
+ setSelectedOption(filter || null);
+ setCustomOperator(operator || 'equals');
+ setCustomValue1(value1);
+ setCustomValue2(value2);
+ setAnchorEl(event.currentTarget);
+ };
+
+ const handleClose = () => {
+ setAnchorEl(null);
+ };
+
+ const handleApply = () => {
+ onApply({
+ selectedOption: selectedOption,
+ operator: customOperator,
+ value1: customValue1,
+ value2: customValue2,
+ });
+ handleClose();
+ };
+
+ const handleOptionSelect = (option: FilterOption) => {
+ // Clicking on the already selected option unselects it
+ if (selectedOption === option) {
+ setSelectedOption(null);
+ setCustomValue1('');
+ setCustomValue2('');
+ } else {
+ setSelectedOption(option);
+ if (option !== 'custom') {
+ setCustomValue1('');
+ setCustomValue2('');
+ }
+ }
+ };
+
+ const handleClearCustom = () => {
+ setCustomValue1('');
+ setCustomValue2('');
+ setSelectedOption(null);
+ };
+
+ const operatorOptions: { value: CustomOperator; label: string }[] = [
+ { value: 'equals', label: 'Equals' },
+ { value: 'less-than', label: 'Older than' },
+ { value: 'greater-than', label: 'Newer than' },
+ { value: 'between', label: 'Between' },
+ ];
+
+ const minorVersion = getMinorVersion(currentVersion);
+
+ const presetOptions: Array<{
+ value: FilterOption;
+ label: string;
+ disabled?: boolean;
+ }> = [
+ { value: 'up-to-date', label: 'Up-to-date' },
+ {
+ value: 'patch',
+ label: 'Patch available',
+ // Disable if the minor version is the same as the current version, since that makes this option redundant.
+ // This can happen on the first major release version, (eg. 19.0.0).
+ disabled: minorVersion === currentVersion,
+ },
+ { value: 'upgrade', label: 'Upgrade available' },
+ { value: 'incompatible', label: 'Incompatible' },
+ ];
+
+ return (
+
+
+
+ Version
+ {filter && ' (1)'}
+
+ {filter && }
+
+
+
+
+ );
+}
+
+// stripVersionPrefix removes the 'v' prefix from a version string if present
+function stripVersionPrefix(version: string): string {
+ return version.startsWith('v') ? version.slice(1) : version;
+}
+
+export function getMajorVersion(version: string): string {
+ const parsed = parse(stripVersionPrefix(version));
+ return `${parsed.major}.0.0`;
+}
+
+export function getMinorVersion(version: string): string {
+ const parsed = parse(stripVersionPrefix(version));
+ return `${parsed.major}.${parsed.minor}.0`;
+}
+
+export function getPreviousMajorVersion(version: string): string {
+ const majorNum = major(stripVersionPrefix(version));
+ return `${majorNum - 1}.0.0`;
+}
+
+export function getNextMajorVersion(version: string): string {
+ const majorNum = major(stripVersionPrefix(version));
+ return `${majorNum + 1}.0.0`;
+}
+
+// buildVersionPredicate returns the predicate query corresponding to a given version filter selection.
+export function buildVersionPredicate(
+ filter: string,
+ operator: string,
+ value1: string,
+ value2: string,
+ currentVersion: string
+): string {
+ if (!filter) return '';
+
+ // Strip 'v' prefix from versions if present
+ const strippedCurrentVersion = stripVersionPrefix(currentVersion);
+ const strippedValue1 = stripVersionPrefix(value1);
+ const strippedValue2 = stripVersionPrefix(value2);
+
+ const minorVersion = getMinorVersion(currentVersion);
+ const prevMajor = getPreviousMajorVersion(currentVersion);
+ const nextMajor = getNextMajorVersion(currentVersion);
+
+ switch (filter) {
+ case 'up-to-date':
+ return `version == "${strippedCurrentVersion}"`;
+ case 'patch':
+ return `between(version, "${minorVersion}", "${strippedCurrentVersion}")`;
+ case 'upgrade':
+ return `between(version, "${prevMajor}", "${minorVersion}")`;
+ case 'incompatible':
+ return `older_than(version, "${prevMajor}") || newer_than(version, "${nextMajor}")`;
+ case 'custom':
+ switch (operator) {
+ case 'equals':
+ return strippedValue1 ? `version == "${strippedValue1}"` : '';
+ case 'less-than':
+ return strippedValue1
+ ? `older_than(version, "${strippedValue1}")`
+ : '';
+ case 'greater-than':
+ return strippedValue1
+ ? `newer_than(version, "${strippedValue1}")`
+ : '';
+ case 'between':
+ return strippedValue1 && strippedValue2
+ ? `between(version, "${strippedValue1}", "${strippedValue2}")`
+ : '';
+ default:
+ return '';
+ }
+ default:
+ return '';
+ }
+}
+
+/**
+ * getFilterDescription returns the text on the right of the preset version filter options indicating what each option entails
+ */
+const getFilterDescription = (
+ option: FilterOption,
+ currentVersion: string
+): string => {
+ const minorVersion = getMinorVersion(currentVersion);
+ const prevMajor = getPreviousMajorVersion(currentVersion);
+ const nextMajor = getNextMajorVersion(currentVersion);
+
+ switch (option) {
+ case 'up-to-date':
+ return currentVersion;
+ case 'patch':
+ return `between ${minorVersion} & ${currentVersion}`;
+ case 'upgrade':
+ return `between ${prevMajor} & ${minorVersion}`;
+ case 'incompatible':
+ return `<${prevMajor} or >${nextMajor}`;
+ default:
+ return '';
+ }
+};
+
+const FilterMenuItem = styled(MenuItem)<{ disabled?: boolean }>`
+ &:hover:not(:disabled) {
+ background-color: ${p => p.theme.colors.interactive.tonal.neutral[0]};
+ }
+
+ ${p =>
+ p.disabled &&
+ `
+ cursor: not-allowed;
+ opacity: 0.5;
+ pointer-events: none;
+ `}
+`;
+
+const Divider = styled.div`
+ height: 1px;
+ background-color: ${p => p.theme.colors.interactive.tonal.neutral[1]};
+`;
+
+const CheckIconWrapper = styled.div`
+ width: 20px;
+ height: 20px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ color: ${p => p.theme.colors.text.main};
+`;
+
+const ClearButton = styled.button`
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ padding: 4px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: ${p => p.theme.colors.text.muted};
+ border-radius: 4px;
+ height: 32px;
+ width: 32px;
+ margin-left: ${p => p.theme.space[1]}px;
+
+ &:hover:not(:disabled) {
+ background-color: ${p => p.theme.colors.interactive.tonal.neutral[0]};
+ color: ${p => p.theme.colors.text.main};
+ }
+
+ &:disabled {
+ opacity: 0.3;
+ }
+`;
+
+const ActionButtonsContainer = styled(Flex)`
+ position: sticky;
+ bottom: 0;
+ background-color: ${p => p.theme.colors.levels.elevated};
+ z-index: 1;
+`;
diff --git a/web/packages/teleport/src/Instances/index.ts b/web/packages/teleport/src/Instances/index.ts
new file mode 100644
index 0000000000000..56d22cc69c808
--- /dev/null
+++ b/web/packages/teleport/src/Instances/index.ts
@@ -0,0 +1,19 @@
+/**
+ * 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 .
+ */
+
+export { Instances } from './Instances';
diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts
index 0c73b8ff31741..6e9d602a6d9af 100644
--- a/web/packages/teleport/src/config.ts
+++ b/web/packages/teleport/src/config.ts
@@ -194,6 +194,7 @@ const cfg = {
bots: '/web/bots',
bot: '/web/bot/:botName',
botInstances: '/web/bots/instances',
+ instances: '/web/instances',
botsNew: '/web/bots/new/:type?',
workloadIdentities: '/web/workloadidentities',
console: '/web/cluster/:clusterId/console',
@@ -298,6 +299,8 @@ const cfg = {
list: `/v1/webapi/sites/:clusterId/databaseservers?searchAsRoles=:searchAsRoles?&limit=:limit?&startKey=:startKey?&query=:query?`,
},
+ instancesPath: `/v1/webapi/sites/:clusterId/instances?limit=:limit?&startKey=:startKey?&query=:query?&search=:search?&sort=:sort?&types=:types?&services=:services?&upgraders=:upgraders?`,
+
desktopsPath: `/v1/webapi/sites/:clusterId/desktops?searchAsRoles=:searchAsRoles?&limit=:limit?&startKey=:startKey?&query=:query?&search=:search?&sort=:sort?`,
desktopPath: `/v1/webapi/sites/:clusterId/desktops/:desktopName`,
desktopWsAddr:
@@ -903,6 +906,10 @@ const cfg = {
return generatePath(`${cfg.routes.botInstances}?${search.toString()}`);
},
+ getInstancesRoute() {
+ return generatePath(cfg.routes.instances);
+ },
+
getWorkloadIdentitiesRoute() {
return generatePath(cfg.routes.workloadIdentities);
},
@@ -1179,6 +1186,13 @@ const cfg = {
});
},
+ getInstancesUrl(clusterId: string, params?: UrlResourcesParams) {
+ return generateResourcePath(cfg.api.instancesPath, {
+ clusterId,
+ ...params,
+ });
+ },
+
getYamlParseUrl(kind: YamlSupportedResourceKind) {
return generatePath(cfg.api.yaml.parse, { kind });
},
diff --git a/web/packages/teleport/src/features.tsx b/web/packages/teleport/src/features.tsx
index 9854101766245..aa6a1c525abae 100644
--- a/web/packages/teleport/src/features.tsx
+++ b/web/packages/teleport/src/features.tsx
@@ -34,6 +34,7 @@ import {
Question,
Server,
SlidersVertical,
+ Stack,
Terminal,
UserCircleGear,
User as UserIcon,
@@ -60,6 +61,7 @@ import { BotDetails } from './Bots/Details/BotDetails';
import { Clusters } from './Clusters';
import { DeviceTrustLocked } from './DeviceTrust';
import { Discover } from './Discover';
+import { Instances } from './Instances/Instances';
import { Integrations } from './Integrations';
import { JoinTokens } from './JoinTokens/JoinTokens';
import { Locks } from './LocksV2/Locks';
@@ -311,6 +313,40 @@ export class FeatureBotInstanceDetails implements TeleportFeature {
}
}
+export class FeatureInstances implements TeleportFeature {
+ category = NavigationCategory.ZeroTrustAccess;
+
+ route = {
+ title: 'Instance Inventory',
+ path: cfg.routes.instances,
+ exact: true,
+ component: Instances,
+ };
+
+ hasAccess(flags: FeatureFlags) {
+ // if feature hiding is enabled, only show
+ // if the user has access
+ if (shouldHideFromNavigation(cfg)) {
+ return flags.listInstances || flags.listBotInstances;
+ }
+ return true;
+ }
+
+ navigationItem = {
+ title: NavTitle.InstanceInventory,
+ icon: Stack,
+ exact: true,
+ getLink() {
+ return cfg.getInstancesRoute();
+ },
+ searchableTags: ['instances', 'instance', 'agents', 'inventory'],
+ };
+
+ getRoute() {
+ return this.route;
+ }
+}
+
export class FeatureBotDetails implements TeleportFeature {
parent = FeatureBots;
@@ -874,6 +910,7 @@ export function getOSSFeatures(): TeleportFeature[] {
new FeatureBots(),
new FeatureBotDetails(),
new FeatureBotInstances(),
+ new FeatureInstances(),
new FeatureAddBotsShortcut(),
new FeatureJoinTokens(),
new FeatureRoles(),
diff --git a/web/packages/teleport/src/generateResourcePath.ts b/web/packages/teleport/src/generateResourcePath.ts
index e2659d5251180..61768aaaef1fa 100644
--- a/web/packages/teleport/src/generateResourcePath.ts
+++ b/web/packages/teleport/src/generateResourcePath.ts
@@ -72,8 +72,11 @@ export default function generateResourcePath(
.replace(':resourceType?', params.resourceType || '')
.replace(':search?', processedParams.search || '')
.replace(':searchAsRoles?', processedParams.searchAsRoles || '')
+ .replace(':services?', processedParams.services || '')
.replace(':sort?', processedParams.sort || '')
.replace(':startKey?', params.startKey || '')
+ .replace(':types?', processedParams.types || '')
+ .replace(':upgraders?', processedParams.upgraders || '')
.replace(':regions?', processedParams.regions || '')
.replace(':owners?', processedParams.owners || '')
.replace(':origin?', processedParams.origin || '')
diff --git a/web/packages/teleport/src/mocks/contexts.ts b/web/packages/teleport/src/mocks/contexts.ts
index 1f65a0fbab07e..2bfdca330e00f 100644
--- a/web/packages/teleport/src/mocks/contexts.ts
+++ b/web/packages/teleport/src/mocks/contexts.ts
@@ -78,6 +78,7 @@ export const allAccessAcl: Acl = {
contacts: fullAccess,
gitServers: fullAccess,
accessGraphSettings: fullAccess,
+ instances: fullAccess,
botInstances: fullAccess,
workloadIdentity: fullAccess,
clientIpRestriction: fullAccess,
diff --git a/web/packages/teleport/src/services/instances/index.ts b/web/packages/teleport/src/services/instances/index.ts
new file mode 100644
index 0000000000000..0a494d6c54aa2
--- /dev/null
+++ b/web/packages/teleport/src/services/instances/index.ts
@@ -0,0 +1,25 @@
+/**
+ * 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 .
+ */
+
+export type {
+ UnifiedInstance,
+ Instance,
+ BotInstance,
+ UpgraderInfo,
+ UnifiedInstancesResponse,
+} from './types';
diff --git a/web/packages/teleport/src/services/instances/types.ts b/web/packages/teleport/src/services/instances/types.ts
new file mode 100644
index 0000000000000..606c145f0c842
--- /dev/null
+++ b/web/packages/teleport/src/services/instances/types.ts
@@ -0,0 +1,47 @@
+/**
+ * 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 .
+ */
+
+export type UnifiedInstance = {
+ id: string;
+ type: 'instance' | 'bot_instance';
+ instance?: Instance;
+ botInstance?: BotInstance;
+};
+
+export type Instance = {
+ name: string;
+ version: string;
+ services: string[];
+ upgrader?: UpgraderInfo;
+};
+
+export type BotInstance = {
+ name: string;
+ version: string;
+};
+
+export type UpgraderInfo = {
+ type: string;
+ version: string;
+ group: string;
+};
+
+export type UnifiedInstancesResponse = {
+ instances: UnifiedInstance[];
+ startKey?: string;
+};
diff --git a/web/packages/teleport/src/services/user/makeAcl.ts b/web/packages/teleport/src/services/user/makeAcl.ts
index 501d1ae617dfc..e50fb73670498 100644
--- a/web/packages/teleport/src/services/user/makeAcl.ts
+++ b/web/packages/teleport/src/services/user/makeAcl.ts
@@ -86,6 +86,8 @@ export function makeAcl(json): Acl {
const botInstances = json.botInstances || defaultAccess;
+ const instances = json.instances || defaultAccess;
+
const workloadIdentity = json.workloadIdentity || defaultAccess;
const clientIpRestriction = json.clientIpRestriction || defaultAccess;
@@ -132,6 +134,7 @@ export function makeAcl(json): Acl {
gitServers,
accessGraphSettings,
botInstances,
+ instances,
workloadIdentity,
clientIpRestriction,
};
diff --git a/web/packages/teleport/src/services/user/types.ts b/web/packages/teleport/src/services/user/types.ts
index 12ef46be89256..7c76fee17dfdd 100644
--- a/web/packages/teleport/src/services/user/types.ts
+++ b/web/packages/teleport/src/services/user/types.ts
@@ -113,6 +113,7 @@ export interface Acl {
gitServers: Access;
accessGraphSettings: Access;
botInstances: Access;
+ instances: Access;
workloadIdentity: Access;
clientIpRestriction: Access;
}
diff --git a/web/packages/teleport/src/services/user/user.test.ts b/web/packages/teleport/src/services/user/user.test.ts
index 6ec02a67402d7..ab1e97fc5d258 100644
--- a/web/packages/teleport/src/services/user/user.test.ts
+++ b/web/packages/teleport/src/services/user/user.test.ts
@@ -303,6 +303,13 @@ test('undefined values in context response gives proper default values', async (
create: false,
remove: false,
},
+ instances: {
+ list: false,
+ read: false,
+ edit: false,
+ create: false,
+ remove: false,
+ },
botInstances: {
list: false,
read: false,
diff --git a/web/packages/teleport/src/stores/storeUserContext.ts b/web/packages/teleport/src/stores/storeUserContext.ts
index f29f02fe7f768..2f202d2dc3487 100644
--- a/web/packages/teleport/src/stores/storeUserContext.ts
+++ b/web/packages/teleport/src/stores/storeUserContext.ts
@@ -263,6 +263,10 @@ export default class StoreUserContext extends Store {
return this.state.acl.botInstances;
}
+ getInstancesAccess() {
+ return this.state.acl.instances;
+ }
+
getContactsAccess() {
return this.state.acl.contacts;
}
diff --git a/web/packages/teleport/src/teleportContext.tsx b/web/packages/teleport/src/teleportContext.tsx
index 341193f93f241..87eea14db3927 100644
--- a/web/packages/teleport/src/teleportContext.tsx
+++ b/web/packages/teleport/src/teleportContext.tsx
@@ -225,6 +225,8 @@ class TeleportContext implements types.Context {
userContext.getGitServersAccess().read,
readBotInstances: userContext.getBotInstancesAccess().read,
listBotInstances: userContext.getBotInstancesAccess().list,
+ readInstances: userContext.getInstancesAccess().read,
+ listInstances: userContext.getInstancesAccess().list,
listWorkloadIdentities: userContext.getWorkloadIdentityAccess().list,
};
}
@@ -271,6 +273,8 @@ export const disabledFeatureFlags: types.FeatureFlags = {
gitServers: false,
readBotInstances: false,
listBotInstances: false,
+ readInstances: false,
+ listInstances: false,
listWorkloadIdentities: false,
};
diff --git a/web/packages/teleport/src/test/helpers/instances.ts b/web/packages/teleport/src/test/helpers/instances.ts
new file mode 100644
index 0000000000000..37b9a8b5fb0ee
--- /dev/null
+++ b/web/packages/teleport/src/test/helpers/instances.ts
@@ -0,0 +1,129 @@
+/**
+ * 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 .
+ */
+
+import { delay, http, HttpResponse } from 'msw';
+
+import { UnifiedInstancesResponse } from 'teleport/services/instances/types';
+
+export const regularInstances = [
+ {
+ id: crypto.randomUUID(),
+ type: 'instance' as const,
+ instance: {
+ name: 'ip-10-1-1-100.ec2.internal',
+ version: '18.2.4',
+ services: ['node', 'proxy'],
+ upgrader: {
+ type: 'systemd-unit-updater',
+ version: '18.2.4',
+ group: 'production',
+ },
+ },
+ },
+ {
+ id: crypto.randomUUID(),
+ type: 'instance' as const,
+ instance: {
+ name: 'teleport-auth-01',
+ version: '18.2.3',
+ services: ['auth'],
+ upgrader: {
+ type: 'kube-updater',
+ version: '18.2.3',
+ group: 'staging',
+ },
+ },
+ },
+ {
+ id: crypto.randomUUID(),
+ type: 'instance' as const,
+ instance: {
+ name: 'app-server-prod',
+ version: '18.1.0',
+ services: ['app', 'db'],
+ },
+ },
+];
+
+export const botInstances = [
+ {
+ id: crypto.randomUUID(),
+ type: 'bot_instance' as const,
+ botInstance: {
+ name: 'github-actions-bot',
+ version: '18.2.4',
+ },
+ },
+ {
+ id: crypto.randomUUID(),
+ type: 'bot_instance' as const,
+ botInstance: {
+ name: 'ci-cd-bot',
+ version: '18.2.2',
+ },
+ },
+];
+
+export const mockInstances: UnifiedInstancesResponse = {
+ instances: [...regularInstances, ...botInstances],
+ startKey: '',
+};
+
+export const mockOnlyRegularInstances: UnifiedInstancesResponse = {
+ instances: regularInstances,
+ startKey: '',
+};
+
+export const mockOnlyBotInstances: UnifiedInstancesResponse = {
+ instances: botInstances,
+ startKey: '',
+};
+
+export const listInstancesSuccess = http.get(
+ '/v1/webapi/sites/:clusterId/instances',
+ () => {
+ return HttpResponse.json(mockInstances);
+ }
+);
+
+export const listOnlyRegularInstances = http.get(
+ '/v1/webapi/sites/:clusterId/instances',
+ () => {
+ return HttpResponse.json(mockOnlyRegularInstances);
+ }
+);
+
+export const listOnlyBotInstances = http.get(
+ '/v1/webapi/sites/:clusterId/instances',
+ () => {
+ return HttpResponse.json(mockOnlyBotInstances);
+ }
+);
+
+export const listInstancesError = (status: number, message: string) =>
+ http.get('/v1/webapi/sites/:clusterId/instances', () => {
+ return HttpResponse.json({ error: { message } }, { status });
+ });
+
+export const listInstancesLoading = http.get(
+ '/v1/webapi/sites/:clusterId/instances',
+ async () => {
+ await delay('infinite');
+ return HttpResponse.json(mockInstances);
+ }
+);
diff --git a/web/packages/teleport/src/types.ts b/web/packages/teleport/src/types.ts
index 914a2d9430505..b2e9d53924ec7 100644
--- a/web/packages/teleport/src/types.ts
+++ b/web/packages/teleport/src/types.ts
@@ -59,6 +59,7 @@ export enum NavTitle {
Users = 'Users',
Bots = 'Bots',
BotInstances = 'Bot Instances',
+ InstanceInventory = 'Instance Inventory',
Roles = 'Roles',
JoinTokens = 'Join Tokens',
AuthConnectors = 'Auth Connectors',
@@ -214,6 +215,8 @@ export interface FeatureFlags {
readBots: boolean;
readBotInstances: boolean;
listBotInstances: boolean;
+ readInstances: boolean;
+ listInstances: boolean;
addBots: boolean;
editBots: boolean;
removeBots: boolean;