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 && } + + + `margin-top: 36px; width: 360px;`} + transformOrigin={{ + vertical: 'top', + horizontal: 'left', + }} + anchorOrigin={{ + vertical: 'bottom', + horizontal: 'left', + }} + anchorEl={anchorEl} + open={Boolean(anchorEl)} + onClose={handleClose} + > + {presetOptions.map(opt => ( + !opt.disabled && handleOptionSelect(opt.value)} + width="100%" + disabled={opt.disabled} + > + + + {selectedOption === opt.value && } + + + + {opt.label} + + + {getFilterDescription(opt.value, currentVersion)} + + + + + ))} + + handleOptionSelect('custom')} + > + + + {selectedOption === 'custom' && } + + + + Custom condition + + { + if (selectedOption === 'custom') { + e.stopPropagation(); + } + }} + > + setCustomValue1(e.target.value)} + disabled={selectedOption !== 'custom'} + onFocus={() => { + if (selectedOption !== 'custom') { + handleOptionSelect('custom'); + } + }} + width="70px" + /> + + & + + setCustomValue2(e.target.value)} + disabled={selectedOption !== 'custom'} + onFocus={() => { + if (selectedOption !== 'custom') { + handleOptionSelect('custom'); + } + }} + width="70px" + /> + + ) : ( + setCustomValue1(e.target.value)} + disabled={selectedOption !== 'custom'} + onFocus={() => { + if (selectedOption !== 'custom') { + handleOptionSelect('custom'); + } + }} + /> + )} + + + + + + + + + + + + + Apply Filters + + + Cancel + + + + + ); +} + +// 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;