diff --git a/api/constants/constants.go b/api/constants/constants.go index 48de0e9178bea..22267566a4480 100644 --- a/api/constants/constants.go +++ b/api/constants/constants.go @@ -583,3 +583,7 @@ const MaxPIVPINCacheTTL = time.Hour // routine running in every auth server. Any report older than this period should // be considered stale. const AutoUpdateAgentReportPeriod = time.Minute + +// AutoUpdateBotInstanceReportPeriod is the period of the autoupdate bot instance +// reporting routine. +const AutoUpdateBotInstanceReportPeriod = time.Minute diff --git a/api/gen/proto/go/teleport/machineid/v1/bot_instance_service.pb.go b/api/gen/proto/go/teleport/machineid/v1/bot_instance_service.pb.go index 45bad52dfd303..d9d09bb87e4f1 100644 --- a/api/gen/proto/go/teleport/machineid/v1/bot_instance_service.pb.go +++ b/api/gen/proto/go/teleport/machineid/v1/bot_instance_service.pb.go @@ -180,6 +180,95 @@ func (x *ListBotInstancesRequest) GetSort() *types.SortBy { return nil } +// Request for ListBotInstancesV2. +// +// Follows the pagination semantics of +// https://cloud.google.com/apis/design/standard_methods#list +type ListBotInstancesV2Request struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The maximum number of items to return. + // The server may impose a different page size at its discretion. + PageSize int32 `protobuf:"varint,1,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"` + // The page_token value returned from a previous ListBotInstancesV2 request, + // if any. + PageToken string `protobuf:"bytes,2,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"` + // The sort field to use for the results. If empty, the default sort field is + // used. + SortField string `protobuf:"bytes,3,opt,name=sort_field,json=sortField,proto3" json:"sort_field,omitempty"` + // The sort order to use for the results. If empty, the default sort order is + // used. + SortDesc bool `protobuf:"varint,4,opt,name=sort_desc,json=sortDesc,proto3" json:"sort_desc,omitempty"` + // Fields used to filter the results + Filter *ListBotInstancesV2Request_Filters `protobuf:"bytes,5,opt,name=filter,proto3" json:"filter,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListBotInstancesV2Request) Reset() { + *x = ListBotInstancesV2Request{} + mi := &file_teleport_machineid_v1_bot_instance_service_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListBotInstancesV2Request) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListBotInstancesV2Request) ProtoMessage() {} + +func (x *ListBotInstancesV2Request) ProtoReflect() protoreflect.Message { + mi := &file_teleport_machineid_v1_bot_instance_service_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListBotInstancesV2Request.ProtoReflect.Descriptor instead. +func (*ListBotInstancesV2Request) Descriptor() ([]byte, []int) { + return file_teleport_machineid_v1_bot_instance_service_proto_rawDescGZIP(), []int{2} +} + +func (x *ListBotInstancesV2Request) GetPageSize() int32 { + if x != nil { + return x.PageSize + } + return 0 +} + +func (x *ListBotInstancesV2Request) GetPageToken() string { + if x != nil { + return x.PageToken + } + return "" +} + +func (x *ListBotInstancesV2Request) GetSortField() string { + if x != nil { + return x.SortField + } + return "" +} + +func (x *ListBotInstancesV2Request) GetSortDesc() bool { + if x != nil { + return x.SortDesc + } + return false +} + +func (x *ListBotInstancesV2Request) GetFilter() *ListBotInstancesV2Request_Filters { + if x != nil { + return x.Filter + } + return nil +} + // Response for ListBotInstances. type ListBotInstancesResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -194,7 +283,7 @@ type ListBotInstancesResponse struct { func (x *ListBotInstancesResponse) Reset() { *x = ListBotInstancesResponse{} - mi := &file_teleport_machineid_v1_bot_instance_service_proto_msgTypes[2] + mi := &file_teleport_machineid_v1_bot_instance_service_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -206,7 +295,7 @@ func (x *ListBotInstancesResponse) String() string { func (*ListBotInstancesResponse) ProtoMessage() {} func (x *ListBotInstancesResponse) ProtoReflect() protoreflect.Message { - mi := &file_teleport_machineid_v1_bot_instance_service_proto_msgTypes[2] + mi := &file_teleport_machineid_v1_bot_instance_service_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -219,7 +308,7 @@ func (x *ListBotInstancesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListBotInstancesResponse.ProtoReflect.Descriptor instead. func (*ListBotInstancesResponse) Descriptor() ([]byte, []int) { - return file_teleport_machineid_v1_bot_instance_service_proto_rawDescGZIP(), []int{2} + return file_teleport_machineid_v1_bot_instance_service_proto_rawDescGZIP(), []int{3} } func (x *ListBotInstancesResponse) GetBotInstances() []*BotInstance { @@ -249,7 +338,7 @@ type DeleteBotInstanceRequest struct { func (x *DeleteBotInstanceRequest) Reset() { *x = DeleteBotInstanceRequest{} - mi := &file_teleport_machineid_v1_bot_instance_service_proto_msgTypes[3] + mi := &file_teleport_machineid_v1_bot_instance_service_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -261,7 +350,7 @@ func (x *DeleteBotInstanceRequest) String() string { func (*DeleteBotInstanceRequest) ProtoMessage() {} func (x *DeleteBotInstanceRequest) ProtoReflect() protoreflect.Message { - mi := &file_teleport_machineid_v1_bot_instance_service_proto_msgTypes[3] + mi := &file_teleport_machineid_v1_bot_instance_service_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -274,7 +363,7 @@ func (x *DeleteBotInstanceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteBotInstanceRequest.ProtoReflect.Descriptor instead. func (*DeleteBotInstanceRequest) Descriptor() ([]byte, []int) { - return file_teleport_machineid_v1_bot_instance_service_proto_rawDescGZIP(), []int{3} + return file_teleport_machineid_v1_bot_instance_service_proto_rawDescGZIP(), []int{4} } func (x *DeleteBotInstanceRequest) GetBotName() string { @@ -304,7 +393,7 @@ type SubmitHeartbeatRequest struct { func (x *SubmitHeartbeatRequest) Reset() { *x = SubmitHeartbeatRequest{} - mi := &file_teleport_machineid_v1_bot_instance_service_proto_msgTypes[4] + mi := &file_teleport_machineid_v1_bot_instance_service_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -316,7 +405,7 @@ func (x *SubmitHeartbeatRequest) String() string { func (*SubmitHeartbeatRequest) ProtoMessage() {} func (x *SubmitHeartbeatRequest) ProtoReflect() protoreflect.Message { - mi := &file_teleport_machineid_v1_bot_instance_service_proto_msgTypes[4] + mi := &file_teleport_machineid_v1_bot_instance_service_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -329,7 +418,7 @@ func (x *SubmitHeartbeatRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SubmitHeartbeatRequest.ProtoReflect.Descriptor instead. func (*SubmitHeartbeatRequest) Descriptor() ([]byte, []int) { - return file_teleport_machineid_v1_bot_instance_service_proto_rawDescGZIP(), []int{4} + return file_teleport_machineid_v1_bot_instance_service_proto_rawDescGZIP(), []int{5} } func (x *SubmitHeartbeatRequest) GetHeartbeat() *BotInstanceStatusHeartbeat { @@ -355,7 +444,7 @@ type SubmitHeartbeatResponse struct { func (x *SubmitHeartbeatResponse) Reset() { *x = SubmitHeartbeatResponse{} - mi := &file_teleport_machineid_v1_bot_instance_service_proto_msgTypes[5] + mi := &file_teleport_machineid_v1_bot_instance_service_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -367,7 +456,7 @@ func (x *SubmitHeartbeatResponse) String() string { func (*SubmitHeartbeatResponse) ProtoMessage() {} func (x *SubmitHeartbeatResponse) ProtoReflect() protoreflect.Message { - mi := &file_teleport_machineid_v1_bot_instance_service_proto_msgTypes[5] + mi := &file_teleport_machineid_v1_bot_instance_service_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -380,7 +469,73 @@ func (x *SubmitHeartbeatResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SubmitHeartbeatResponse.ProtoReflect.Descriptor instead. func (*SubmitHeartbeatResponse) Descriptor() ([]byte, []int) { - return file_teleport_machineid_v1_bot_instance_service_proto_rawDescGZIP(), []int{5} + return file_teleport_machineid_v1_bot_instance_service_proto_rawDescGZIP(), []int{6} +} + +// Filters contains fields to be used to filter the results. +type ListBotInstancesV2Request_Filters struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The name of the Bot to list BotInstances for. If non-empty, only + // BotInstances for that bot will be listed. + BotName string `protobuf:"bytes,1,opt,name=bot_name,json=botName,proto3" json:"bot_name,omitempty"` + // A search term used to filter the results. If non-empty, it's used to + // match against supported fields. + SearchTerm string `protobuf:"bytes,2,opt,name=search_term,json=searchTerm,proto3" json:"search_term,omitempty"` + // A Teleport predicate language query used to filter the results. + Query string `protobuf:"bytes,3,opt,name=query,proto3" json:"query,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListBotInstancesV2Request_Filters) Reset() { + *x = ListBotInstancesV2Request_Filters{} + mi := &file_teleport_machineid_v1_bot_instance_service_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListBotInstancesV2Request_Filters) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListBotInstancesV2Request_Filters) ProtoMessage() {} + +func (x *ListBotInstancesV2Request_Filters) ProtoReflect() protoreflect.Message { + mi := &file_teleport_machineid_v1_bot_instance_service_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListBotInstancesV2Request_Filters.ProtoReflect.Descriptor instead. +func (*ListBotInstancesV2Request_Filters) Descriptor() ([]byte, []int) { + return file_teleport_machineid_v1_bot_instance_service_proto_rawDescGZIP(), []int{2, 0} +} + +func (x *ListBotInstancesV2Request_Filters) GetBotName() string { + if x != nil { + return x.BotName + } + return "" +} + +func (x *ListBotInstancesV2Request_Filters) GetSearchTerm() string { + if x != nil { + return x.SearchTerm + } + return "" +} + +func (x *ListBotInstancesV2Request_Filters) GetQuery() string { + if x != nil { + return x.Query + } + return "" } var File_teleport_machineid_v1_bot_instance_service_proto protoreflect.FileDescriptor @@ -398,7 +553,20 @@ const file_teleport_machineid_v1_bot_instance_service_proto_rawDesc = "" + "\n" + "page_token\x18\x03 \x01(\tR\tpageToken\x12,\n" + "\x12filter_search_term\x18\x04 \x01(\tR\x10filterSearchTerm\x12!\n" + - "\x04sort\x18\x05 \x01(\v2\r.types.SortByR\x04sort\"\x8b\x01\n" + + "\x04sort\x18\x05 \x01(\v2\r.types.SortByR\x04sort\"\xc2\x02\n" + + "\x19ListBotInstancesV2Request\x12\x1b\n" + + "\tpage_size\x18\x01 \x01(\x05R\bpageSize\x12\x1d\n" + + "\n" + + "page_token\x18\x02 \x01(\tR\tpageToken\x12\x1d\n" + + "\n" + + "sort_field\x18\x03 \x01(\tR\tsortField\x12\x1b\n" + + "\tsort_desc\x18\x04 \x01(\bR\bsortDesc\x12P\n" + + "\x06filter\x18\x05 \x01(\v28.teleport.machineid.v1.ListBotInstancesV2Request.FiltersR\x06filter\x1a[\n" + + "\aFilters\x12\x19\n" + + "\bbot_name\x18\x01 \x01(\tR\abotName\x12\x1f\n" + + "\vsearch_term\x18\x02 \x01(\tR\n" + + "searchTerm\x12\x14\n" + + "\x05query\x18\x03 \x01(\tR\x05query\"\x8b\x01\n" + "\x18ListBotInstancesResponse\x12G\n" + "\rbot_instances\x18\x01 \x03(\v2\".teleport.machineid.v1.BotInstanceR\fbotInstances\x12&\n" + "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"V\n" + @@ -409,10 +577,11 @@ const file_teleport_machineid_v1_bot_instance_service_proto_rawDesc = "" + "\x16SubmitHeartbeatRequest\x12O\n" + "\theartbeat\x18\x01 \x01(\v21.teleport.machineid.v1.BotInstanceStatusHeartbeatR\theartbeat\x12V\n" + "\x0eservice_health\x18\x02 \x03(\v2/.teleport.machineid.v1.BotInstanceServiceHealthR\rserviceHealth\"\x19\n" + - "\x17SubmitHeartbeatResponse2\xbd\x03\n" + + "\x17SubmitHeartbeatResponse2\xbb\x04\n" + "\x12BotInstanceService\x12b\n" + - "\x0eGetBotInstance\x12,.teleport.machineid.v1.GetBotInstanceRequest\x1a\".teleport.machineid.v1.BotInstance\x12s\n" + - "\x10ListBotInstances\x12..teleport.machineid.v1.ListBotInstancesRequest\x1a/.teleport.machineid.v1.ListBotInstancesResponse\x12\\\n" + + "\x0eGetBotInstance\x12,.teleport.machineid.v1.GetBotInstanceRequest\x1a\".teleport.machineid.v1.BotInstance\x12x\n" + + "\x10ListBotInstances\x12..teleport.machineid.v1.ListBotInstancesRequest\x1a/.teleport.machineid.v1.ListBotInstancesResponse\"\x03\x88\x02\x01\x12w\n" + + "\x12ListBotInstancesV2\x120.teleport.machineid.v1.ListBotInstancesV2Request\x1a/.teleport.machineid.v1.ListBotInstancesResponse\x12\\\n" + "\x11DeleteBotInstance\x12/.teleport.machineid.v1.DeleteBotInstanceRequest\x1a\x16.google.protobuf.Empty\x12p\n" + "\x0fSubmitHeartbeat\x12-.teleport.machineid.v1.SubmitHeartbeatRequest\x1a..teleport.machineid.v1.SubmitHeartbeatResponseBVZTgithub.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1;machineidv1b\x06proto3" @@ -428,38 +597,43 @@ func file_teleport_machineid_v1_bot_instance_service_proto_rawDescGZIP() []byte return file_teleport_machineid_v1_bot_instance_service_proto_rawDescData } -var file_teleport_machineid_v1_bot_instance_service_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_teleport_machineid_v1_bot_instance_service_proto_msgTypes = make([]protoimpl.MessageInfo, 8) var file_teleport_machineid_v1_bot_instance_service_proto_goTypes = []any{ - (*GetBotInstanceRequest)(nil), // 0: teleport.machineid.v1.GetBotInstanceRequest - (*ListBotInstancesRequest)(nil), // 1: teleport.machineid.v1.ListBotInstancesRequest - (*ListBotInstancesResponse)(nil), // 2: teleport.machineid.v1.ListBotInstancesResponse - (*DeleteBotInstanceRequest)(nil), // 3: teleport.machineid.v1.DeleteBotInstanceRequest - (*SubmitHeartbeatRequest)(nil), // 4: teleport.machineid.v1.SubmitHeartbeatRequest - (*SubmitHeartbeatResponse)(nil), // 5: teleport.machineid.v1.SubmitHeartbeatResponse - (*types.SortBy)(nil), // 6: types.SortBy - (*BotInstance)(nil), // 7: teleport.machineid.v1.BotInstance - (*BotInstanceStatusHeartbeat)(nil), // 8: teleport.machineid.v1.BotInstanceStatusHeartbeat - (*BotInstanceServiceHealth)(nil), // 9: teleport.machineid.v1.BotInstanceServiceHealth - (*emptypb.Empty)(nil), // 10: google.protobuf.Empty + (*GetBotInstanceRequest)(nil), // 0: teleport.machineid.v1.GetBotInstanceRequest + (*ListBotInstancesRequest)(nil), // 1: teleport.machineid.v1.ListBotInstancesRequest + (*ListBotInstancesV2Request)(nil), // 2: teleport.machineid.v1.ListBotInstancesV2Request + (*ListBotInstancesResponse)(nil), // 3: teleport.machineid.v1.ListBotInstancesResponse + (*DeleteBotInstanceRequest)(nil), // 4: teleport.machineid.v1.DeleteBotInstanceRequest + (*SubmitHeartbeatRequest)(nil), // 5: teleport.machineid.v1.SubmitHeartbeatRequest + (*SubmitHeartbeatResponse)(nil), // 6: teleport.machineid.v1.SubmitHeartbeatResponse + (*ListBotInstancesV2Request_Filters)(nil), // 7: teleport.machineid.v1.ListBotInstancesV2Request.Filters + (*types.SortBy)(nil), // 8: types.SortBy + (*BotInstance)(nil), // 9: teleport.machineid.v1.BotInstance + (*BotInstanceStatusHeartbeat)(nil), // 10: teleport.machineid.v1.BotInstanceStatusHeartbeat + (*BotInstanceServiceHealth)(nil), // 11: teleport.machineid.v1.BotInstanceServiceHealth + (*emptypb.Empty)(nil), // 12: google.protobuf.Empty } var file_teleport_machineid_v1_bot_instance_service_proto_depIdxs = []int32{ - 6, // 0: teleport.machineid.v1.ListBotInstancesRequest.sort:type_name -> types.SortBy - 7, // 1: teleport.machineid.v1.ListBotInstancesResponse.bot_instances:type_name -> teleport.machineid.v1.BotInstance - 8, // 2: teleport.machineid.v1.SubmitHeartbeatRequest.heartbeat:type_name -> teleport.machineid.v1.BotInstanceStatusHeartbeat - 9, // 3: teleport.machineid.v1.SubmitHeartbeatRequest.service_health:type_name -> teleport.machineid.v1.BotInstanceServiceHealth - 0, // 4: teleport.machineid.v1.BotInstanceService.GetBotInstance:input_type -> teleport.machineid.v1.GetBotInstanceRequest - 1, // 5: teleport.machineid.v1.BotInstanceService.ListBotInstances:input_type -> teleport.machineid.v1.ListBotInstancesRequest - 3, // 6: teleport.machineid.v1.BotInstanceService.DeleteBotInstance:input_type -> teleport.machineid.v1.DeleteBotInstanceRequest - 4, // 7: teleport.machineid.v1.BotInstanceService.SubmitHeartbeat:input_type -> teleport.machineid.v1.SubmitHeartbeatRequest - 7, // 8: teleport.machineid.v1.BotInstanceService.GetBotInstance:output_type -> teleport.machineid.v1.BotInstance - 2, // 9: teleport.machineid.v1.BotInstanceService.ListBotInstances:output_type -> teleport.machineid.v1.ListBotInstancesResponse - 10, // 10: teleport.machineid.v1.BotInstanceService.DeleteBotInstance:output_type -> google.protobuf.Empty - 5, // 11: teleport.machineid.v1.BotInstanceService.SubmitHeartbeat:output_type -> teleport.machineid.v1.SubmitHeartbeatResponse - 8, // [8:12] is the sub-list for method output_type - 4, // [4:8] is the sub-list for method input_type - 4, // [4:4] is the sub-list for extension type_name - 4, // [4:4] is the sub-list for extension extendee - 0, // [0:4] is the sub-list for field type_name + 8, // 0: teleport.machineid.v1.ListBotInstancesRequest.sort:type_name -> types.SortBy + 7, // 1: teleport.machineid.v1.ListBotInstancesV2Request.filter:type_name -> teleport.machineid.v1.ListBotInstancesV2Request.Filters + 9, // 2: teleport.machineid.v1.ListBotInstancesResponse.bot_instances:type_name -> teleport.machineid.v1.BotInstance + 10, // 3: teleport.machineid.v1.SubmitHeartbeatRequest.heartbeat:type_name -> teleport.machineid.v1.BotInstanceStatusHeartbeat + 11, // 4: teleport.machineid.v1.SubmitHeartbeatRequest.service_health:type_name -> teleport.machineid.v1.BotInstanceServiceHealth + 0, // 5: teleport.machineid.v1.BotInstanceService.GetBotInstance:input_type -> teleport.machineid.v1.GetBotInstanceRequest + 1, // 6: teleport.machineid.v1.BotInstanceService.ListBotInstances:input_type -> teleport.machineid.v1.ListBotInstancesRequest + 2, // 7: teleport.machineid.v1.BotInstanceService.ListBotInstancesV2:input_type -> teleport.machineid.v1.ListBotInstancesV2Request + 4, // 8: teleport.machineid.v1.BotInstanceService.DeleteBotInstance:input_type -> teleport.machineid.v1.DeleteBotInstanceRequest + 5, // 9: teleport.machineid.v1.BotInstanceService.SubmitHeartbeat:input_type -> teleport.machineid.v1.SubmitHeartbeatRequest + 9, // 10: teleport.machineid.v1.BotInstanceService.GetBotInstance:output_type -> teleport.machineid.v1.BotInstance + 3, // 11: teleport.machineid.v1.BotInstanceService.ListBotInstances:output_type -> teleport.machineid.v1.ListBotInstancesResponse + 3, // 12: teleport.machineid.v1.BotInstanceService.ListBotInstancesV2:output_type -> teleport.machineid.v1.ListBotInstancesResponse + 12, // 13: teleport.machineid.v1.BotInstanceService.DeleteBotInstance:output_type -> google.protobuf.Empty + 6, // 14: teleport.machineid.v1.BotInstanceService.SubmitHeartbeat:output_type -> teleport.machineid.v1.SubmitHeartbeatResponse + 10, // [10:15] is the sub-list for method output_type + 5, // [5:10] is the sub-list for method input_type + 5, // [5:5] is the sub-list for extension type_name + 5, // [5:5] is the sub-list for extension extendee + 0, // [0:5] is the sub-list for field type_name } func init() { file_teleport_machineid_v1_bot_instance_service_proto_init() } @@ -474,7 +648,7 @@ func file_teleport_machineid_v1_bot_instance_service_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_teleport_machineid_v1_bot_instance_service_proto_rawDesc), len(file_teleport_machineid_v1_bot_instance_service_proto_rawDesc)), NumEnums: 0, - NumMessages: 6, + NumMessages: 8, NumExtensions: 0, NumServices: 1, }, diff --git a/api/gen/proto/go/teleport/machineid/v1/bot_instance_service_grpc.pb.go b/api/gen/proto/go/teleport/machineid/v1/bot_instance_service_grpc.pb.go index 766dda1c5d9a5..3637522d47a33 100644 --- a/api/gen/proto/go/teleport/machineid/v1/bot_instance_service_grpc.pb.go +++ b/api/gen/proto/go/teleport/machineid/v1/bot_instance_service_grpc.pb.go @@ -34,10 +34,11 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - BotInstanceService_GetBotInstance_FullMethodName = "/teleport.machineid.v1.BotInstanceService/GetBotInstance" - BotInstanceService_ListBotInstances_FullMethodName = "/teleport.machineid.v1.BotInstanceService/ListBotInstances" - BotInstanceService_DeleteBotInstance_FullMethodName = "/teleport.machineid.v1.BotInstanceService/DeleteBotInstance" - BotInstanceService_SubmitHeartbeat_FullMethodName = "/teleport.machineid.v1.BotInstanceService/SubmitHeartbeat" + BotInstanceService_GetBotInstance_FullMethodName = "/teleport.machineid.v1.BotInstanceService/GetBotInstance" + BotInstanceService_ListBotInstances_FullMethodName = "/teleport.machineid.v1.BotInstanceService/ListBotInstances" + BotInstanceService_ListBotInstancesV2_FullMethodName = "/teleport.machineid.v1.BotInstanceService/ListBotInstancesV2" + BotInstanceService_DeleteBotInstance_FullMethodName = "/teleport.machineid.v1.BotInstanceService/DeleteBotInstance" + BotInstanceService_SubmitHeartbeat_FullMethodName = "/teleport.machineid.v1.BotInstanceService/SubmitHeartbeat" ) // BotInstanceServiceClient is the client API for BotInstanceService service. @@ -48,8 +49,12 @@ const ( type BotInstanceServiceClient interface { // GetBotInstance returns the specified BotInstance resource. GetBotInstance(ctx context.Context, in *GetBotInstanceRequest, opts ...grpc.CallOption) (*BotInstance, error) + // Deprecated: Do not use. // ListBotInstances returns a page of BotInstance resources. + // Deprecated: Use ListBotInstancesV2 instead ListBotInstances(ctx context.Context, in *ListBotInstancesRequest, opts ...grpc.CallOption) (*ListBotInstancesResponse, error) + // ListBotInstancesV2 returns a page of BotInstance resources. + ListBotInstancesV2(ctx context.Context, in *ListBotInstancesV2Request, opts ...grpc.CallOption) (*ListBotInstancesResponse, error) // DeleteBotInstance hard deletes the specified BotInstance resource. DeleteBotInstance(ctx context.Context, in *DeleteBotInstanceRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) // SubmitHeartbeat submits a heartbeat for a BotInstance. @@ -74,6 +79,7 @@ func (c *botInstanceServiceClient) GetBotInstance(ctx context.Context, in *GetBo return out, nil } +// Deprecated: Do not use. func (c *botInstanceServiceClient) ListBotInstances(ctx context.Context, in *ListBotInstancesRequest, opts ...grpc.CallOption) (*ListBotInstancesResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(ListBotInstancesResponse) @@ -84,6 +90,16 @@ func (c *botInstanceServiceClient) ListBotInstances(ctx context.Context, in *Lis return out, nil } +func (c *botInstanceServiceClient) ListBotInstancesV2(ctx context.Context, in *ListBotInstancesV2Request, opts ...grpc.CallOption) (*ListBotInstancesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListBotInstancesResponse) + err := c.cc.Invoke(ctx, BotInstanceService_ListBotInstancesV2_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *botInstanceServiceClient) DeleteBotInstance(ctx context.Context, in *DeleteBotInstanceRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(emptypb.Empty) @@ -112,8 +128,12 @@ func (c *botInstanceServiceClient) SubmitHeartbeat(ctx context.Context, in *Subm type BotInstanceServiceServer interface { // GetBotInstance returns the specified BotInstance resource. GetBotInstance(context.Context, *GetBotInstanceRequest) (*BotInstance, error) + // Deprecated: Do not use. // ListBotInstances returns a page of BotInstance resources. + // Deprecated: Use ListBotInstancesV2 instead ListBotInstances(context.Context, *ListBotInstancesRequest) (*ListBotInstancesResponse, error) + // ListBotInstancesV2 returns a page of BotInstance resources. + ListBotInstancesV2(context.Context, *ListBotInstancesV2Request) (*ListBotInstancesResponse, error) // DeleteBotInstance hard deletes the specified BotInstance resource. DeleteBotInstance(context.Context, *DeleteBotInstanceRequest) (*emptypb.Empty, error) // SubmitHeartbeat submits a heartbeat for a BotInstance. @@ -134,6 +154,9 @@ func (UnimplementedBotInstanceServiceServer) GetBotInstance(context.Context, *Ge func (UnimplementedBotInstanceServiceServer) ListBotInstances(context.Context, *ListBotInstancesRequest) (*ListBotInstancesResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method ListBotInstances not implemented") } +func (UnimplementedBotInstanceServiceServer) ListBotInstancesV2(context.Context, *ListBotInstancesV2Request) (*ListBotInstancesResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListBotInstancesV2 not implemented") +} func (UnimplementedBotInstanceServiceServer) DeleteBotInstance(context.Context, *DeleteBotInstanceRequest) (*emptypb.Empty, error) { return nil, status.Errorf(codes.Unimplemented, "method DeleteBotInstance not implemented") } @@ -197,6 +220,24 @@ func _BotInstanceService_ListBotInstances_Handler(srv interface{}, ctx context.C return interceptor(ctx, in, info, handler) } +func _BotInstanceService_ListBotInstancesV2_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListBotInstancesV2Request) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(BotInstanceServiceServer).ListBotInstancesV2(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: BotInstanceService_ListBotInstancesV2_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(BotInstanceServiceServer).ListBotInstancesV2(ctx, req.(*ListBotInstancesV2Request)) + } + return interceptor(ctx, in, info, handler) +} + func _BotInstanceService_DeleteBotInstance_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(DeleteBotInstanceRequest) if err := dec(in); err != nil { @@ -248,6 +289,10 @@ var BotInstanceService_ServiceDesc = grpc.ServiceDesc{ MethodName: "ListBotInstances", Handler: _BotInstanceService_ListBotInstances_Handler, }, + { + MethodName: "ListBotInstancesV2", + Handler: _BotInstanceService_ListBotInstancesV2_Handler, + }, { MethodName: "DeleteBotInstance", Handler: _BotInstanceService_DeleteBotInstance_Handler, diff --git a/api/proto/teleport/machineid/v1/bot_instance_service.proto b/api/proto/teleport/machineid/v1/bot_instance_service.proto index 9e68a1697d6b7..bc3469f895ab6 100644 --- a/api/proto/teleport/machineid/v1/bot_instance_service.proto +++ b/api/proto/teleport/machineid/v1/bot_instance_service.proto @@ -50,6 +50,39 @@ message ListBotInstancesRequest { types.SortBy sort = 5; } +// Request for ListBotInstancesV2. +// +// Follows the pagination semantics of +// https://cloud.google.com/apis/design/standard_methods#list +message ListBotInstancesV2Request { + // The maximum number of items to return. + // The server may impose a different page size at its discretion. + int32 page_size = 1; + // The page_token value returned from a previous ListBotInstancesV2 request, + // if any. + string page_token = 2; + // The sort field to use for the results. If empty, the default sort field is + // used. + string sort_field = 3; + // The sort order to use for the results. If empty, the default sort order is + // used. + bool sort_desc = 4; + // Fields used to filter the results + Filters filter = 5; + + // Filters contains fields to be used to filter the results. + message Filters { + // The name of the Bot to list BotInstances for. If non-empty, only + // BotInstances for that bot will be listed. + string bot_name = 1; + // A search term used to filter the results. If non-empty, it's used to + // match against supported fields. + string search_term = 2; + // A Teleport predicate language query used to filter the results. + string query = 3; + } +} + // Response for ListBotInstances. message ListBotInstancesResponse { // BotInstance that matched the search. @@ -86,7 +119,12 @@ service BotInstanceService { // GetBotInstance returns the specified BotInstance resource. rpc GetBotInstance(GetBotInstanceRequest) returns (BotInstance); // ListBotInstances returns a page of BotInstance resources. - rpc ListBotInstances(ListBotInstancesRequest) returns (ListBotInstancesResponse); + // Deprecated: Use ListBotInstancesV2 instead + rpc ListBotInstances(ListBotInstancesRequest) returns (ListBotInstancesResponse) { + option deprecated = true; + } + // ListBotInstancesV2 returns a page of BotInstance resources. + rpc ListBotInstancesV2(ListBotInstancesV2Request) returns (ListBotInstancesResponse); // DeleteBotInstance hard deletes the specified BotInstance resource. rpc DeleteBotInstance(DeleteBotInstanceRequest) returns (google.protobuf.Empty); // SubmitHeartbeat submits a heartbeat for a BotInstance. diff --git a/docs/pages/reference/cli/tctl.mdx b/docs/pages/reference/cli/tctl.mdx index f0e3cfe8eab3b..88bba8111b6c7 100644 --- a/docs/pages/reference/cli/tctl.mdx +++ b/docs/pages/reference/cli/tctl.mdx @@ -595,6 +595,16 @@ $ tctl bots instances list [] - `[]` - an optional bot name. If provided, filters the result to show only instances for the named bot. Otherwise, shows all instances for all bots. +### Flags + +| Name | Default Value(s) | Allowed Value(s) | Description | +| - | - | - | - | +| `--search` | none | A search term | Optional. Filters the returned bot instances using a fuzzy search based on the term provided. | +| `--query` | none | Teleport predicate language query | Optional. Filters the returned bot instances based on the Teleport predicate language query provided. | +| `--sort-index` | `bot_name` | `bot_name`, `active_at_latest`, `version_latest`, `host_name_latest` | Optional. Sorts the returned bot instances using the given field. | +| `--sort-order` | `ascending` | `ascending`, `descending` | Optional. Sorts the returned bot instances in the given order. | +| `--format` | `text` | `text`, `json` | If set to `json`, returns results as a machine-readable JSON string. | + ### Examples This shows all known instances for the bot named "example": @@ -603,6 +613,25 @@ This shows all known instances for the bot named "example": $ tctl bots instance list example ``` +This shows all known instances which contain the term "github"; + +```code +$ tctl bots instance list --search github +``` + +Searchable fields include; bot name, instance id, hostname, join method, version + +This shows all known instances with a version older than "18.0.0"; + +```code +$ tctl bots instance list --query `older_than(status.latest_heartbeat.version, "18.0.0")` +``` +Version-specific functions include; + +- `newer_than(version, comparison)` +- `older_than(version, comparison)` +- `between(version, lower (inclusive), upper (exclusive))` + ### Global flags These flags are available for all commands: `--debug, --config`. Run diff --git a/gen/preset-roles.json b/gen/preset-roles.json index f63743dd82e3e..1e461edebb379 100755 --- a/gen/preset-roles.json +++ b/gen/preset-roles.json @@ -1051,6 +1051,15 @@ "read" ] }, + { + "resources": [ + "autoupdate_bot_instance_report" + ], + "verbs": [ + "list", + "read" + ] + }, { "resources": [ "git_server" diff --git a/lib/auth/auth.go b/lib/auth/auth.go index 9e3a36d801042..619060a631881 100644 --- a/lib/auth/auth.go +++ b/lib/auth/auth.go @@ -1740,7 +1740,7 @@ func (a *Server) runPeriodicOperations() { }) ticker.Push(interval.SubInterval[periodicIntervalKey]{ Key: autoUpdateBotInstanceReportKey, - Duration: constants.AutoUpdateAgentReportPeriod, + Duration: constants.AutoUpdateBotInstanceReportPeriod, FirstDuration: retryutils.HalfJitter(10 * time.Second), Jitter: retryutils.SeventhJitter, }) diff --git a/lib/auth/authclient/api.go b/lib/auth/authclient/api.go index ff3d4d2ae1e79..56646247250e2 100644 --- a/lib/auth/authclient/api.go +++ b/lib/auth/authclient/api.go @@ -1372,7 +1372,7 @@ type Cache interface { GetBotInstance(ctx context.Context, botName, instanceID string) (*machineidv1.BotInstance, error) // ListBotInstances returns a page of BotInstance resources. - ListBotInstances(ctx context.Context, botName string, pageSize int, lastToken string, search string, sort *types.SortBy) ([]*machineidv1.BotInstance, string, error) + ListBotInstances(ctx context.Context, pageSize int, lastToken string, options *services.ListBotInstancesRequestOptions) ([]*machineidv1.BotInstance, string, error) // ListProvisionTokens returns a paginated list of provision tokens. ListProvisionTokens(ctx context.Context, pageSize int, pageToken string, anyRoles types.SystemRoles, botName string) ([]types.ProvisionToken, string, error) diff --git a/lib/auth/autoupdate/autoupdatev1/service.go b/lib/auth/autoupdate/autoupdatev1/service.go index edbbf1724c645..f11eb88291c1a 100644 --- a/lib/auth/autoupdate/autoupdatev1/service.go +++ b/lib/auth/autoupdate/autoupdatev1/service.go @@ -22,6 +22,7 @@ import ( "context" "log/slog" "maps" + "slices" "github.com/gravitational/trace" "github.com/jonboulle/clockwork" @@ -1055,8 +1056,15 @@ func (s *Service) GetAutoUpdateBotInstanceReport(ctx context.Context, _ *autoupd return nil, trace.Wrap(err) } - if err := authCtx.CheckAccessToKind(types.KindAutoUpdateBotInstanceReport, types.VerbRead); err != nil { - return nil, trace.Wrap(err) + // Because this report also powers the bot instance dashboard in the Web UI + // we allow users with `bot_instance:list` as well as `autoupdate_bot_instance_report:read` + // and return the first error if both checks fail. + authErrors := []error{ + authCtx.CheckAccessToKind(types.KindAutoUpdateBotInstanceReport, types.VerbRead), + authCtx.CheckAccessToKind(types.KindBotInstance, types.VerbList), + } + if !slices.Contains(authErrors, nil) { + return nil, trace.NewAggregate(authErrors...) } report, err := s.backend.GetAutoUpdateBotInstanceReport(ctx) diff --git a/lib/auth/autoupdate/autoupdatev1/service_test.go b/lib/auth/autoupdate/autoupdatev1/service_test.go index 435659e4262e8..4234e33e99f5e 100644 --- a/lib/auth/autoupdate/autoupdatev1/service_test.go +++ b/lib/auth/autoupdate/autoupdatev1/service_test.go @@ -362,6 +362,17 @@ func TestServiceAccess(t *testing.T) { kind: types.KindAutoUpdateBotInstanceReport, allowedVerbs: []string{types.VerbRead}, }, + { + name: "GetAutoUpdateBotInstanceReport", + allowedStates: []authz.AdminActionAuthState{ + authz.AdminActionAuthUnauthorized, + authz.AdminActionAuthNotRequired, + authz.AdminActionAuthMFAVerified, + authz.AdminActionAuthMFAVerifiedWithReuse, + }, + kind: types.KindBotInstance, + allowedVerbs: []string{types.VerbList}, + }, { name: "DeleteAutoUpdateBotInstanceReport", allowedStates: []authz.AdminActionAuthState{ diff --git a/lib/auth/machineid/machineidv1/auto_update_version_reporter.go b/lib/auth/machineid/machineidv1/auto_update_version_reporter.go index b3029270f2a82..8c1e728fb5482 100644 --- a/lib/auth/machineid/machineidv1/auto_update_version_reporter.go +++ b/lib/auth/machineid/machineidv1/auto_update_version_reporter.go @@ -242,10 +242,8 @@ func (r *AutoUpdateVersionReporter) generateReport(ctx context.Context) error { ) instances, nextToken, err = r.cache.ListBotInstances( ctx, - "", defaults.DefaultChunkSize, nextToken, - "", nil, ) if err != nil { diff --git a/lib/auth/machineid/machineidv1/bot_instance_service.go b/lib/auth/machineid/machineidv1/bot_instance_service.go index 6d0f60e98f8b4..3f0270545a4ff 100644 --- a/lib/auth/machineid/machineidv1/bot_instance_service.go +++ b/lib/auth/machineid/machineidv1/bot_instance_service.go @@ -62,7 +62,7 @@ type BotInstancesCache interface { GetBotInstance(ctx context.Context, botName, instanceID string) (*pb.BotInstance, error) // ListBotInstances returns a page of BotInstance resources. - ListBotInstances(ctx context.Context, botName string, pageSize int, lastToken string, search string, sort *types.SortBy) ([]*pb.BotInstance, string, error) + ListBotInstances(ctx context.Context, pageSize int, lastToken string, options *services.ListBotInstancesRequestOptions) ([]*pb.BotInstance, string, error) } // BotInstanceServiceConfig holds configuration options for the BotInstance gRPC @@ -156,6 +156,26 @@ func (b *BotInstanceService) GetBotInstance(ctx context.Context, req *pb.GetBotI // ListBotInstances returns a list of bot instances matching the criteria in the request func (b *BotInstanceService) ListBotInstances(ctx context.Context, req *pb.ListBotInstancesRequest) (*pb.ListBotInstancesResponse, error) { + var sortField string + var sortDesc bool + if req.GetSort() != nil { + sortField = req.GetSort().Field + sortDesc = req.GetSort().IsDesc + } + return b.ListBotInstancesV2(ctx, &pb.ListBotInstancesV2Request{ + PageSize: req.GetPageSize(), + PageToken: req.GetPageToken(), + SortField: sortField, + SortDesc: sortDesc, + Filter: &pb.ListBotInstancesV2Request_Filters{ + BotName: req.GetFilterBotName(), + SearchTerm: req.GetFilterSearchTerm(), + }, + }) +} + +// ListBotInstancesV2 returns a list of bot instances matching the criteria in the request +func (b *BotInstanceService) ListBotInstancesV2(ctx context.Context, req *pb.ListBotInstancesV2Request) (*pb.ListBotInstancesResponse, error) { authCtx, err := b.authorizer.Authorize(ctx) if err != nil { return nil, trace.Wrap(err) @@ -165,7 +185,13 @@ func (b *BotInstanceService) ListBotInstances(ctx context.Context, req *pb.ListB return nil, trace.Wrap(err) } - res, nextToken, err := b.cache.ListBotInstances(ctx, req.FilterBotName, int(req.PageSize), req.PageToken, req.FilterSearchTerm, req.Sort) + res, nextToken, err := b.cache.ListBotInstances(ctx, int(req.PageSize), req.PageToken, &services.ListBotInstancesRequestOptions{ + SortField: req.GetSortField(), + SortDesc: req.GetSortDesc(), + FilterBotName: req.GetFilter().GetBotName(), + FilterSearchTerm: req.GetFilter().GetSearchTerm(), + FilterQuery: req.GetFilter().GetQuery(), + }) if err != nil { return nil, trace.Wrap(err) } diff --git a/lib/auth/machineid/machineidv1/bot_instance_service_test.go b/lib/auth/machineid/machineidv1/bot_instance_service_test.go index 9fa7a2dc6f86f..5a2662740798b 100644 --- a/lib/auth/machineid/machineidv1/bot_instance_service_test.go +++ b/lib/auth/machineid/machineidv1/bot_instance_service_test.go @@ -70,6 +70,14 @@ func TestBotInstanceServiceAccess(t *testing.T) { }, allowedVerbs: []string{types.VerbRead, types.VerbList}, }, + { + name: "ListBotInstancesV2", + allowedStates: []authz.AdminActionAuthState{ + authz.AdminActionAuthUnauthorized, authz.AdminActionAuthNotRequired, + authz.AdminActionAuthMFAVerified, authz.AdminActionAuthMFAVerifiedWithReuse, + }, + allowedVerbs: []string{types.VerbRead, types.VerbList}, + }, { name: "DeleteBotInstance", allowedStates: []authz.AdminActionAuthState{ diff --git a/lib/auth/machineid/machineidv1/expression/environment.go b/lib/auth/machineid/machineidv1/expression/environment.go new file mode 100644 index 0000000000000..f6a1baed0055b --- /dev/null +++ b/lib/auth/machineid/machineidv1/expression/environment.go @@ -0,0 +1,58 @@ +// 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 expression + +import ( + headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" + machineidv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1" +) + +// Environment in which expressions will be evaluated. +type Environment struct { + Metadata *headerv1.Metadata + Spec *machineidv1.BotInstanceSpec + LatestHeartbeat *machineidv1.BotInstanceStatusHeartbeat + LatestAuthentication *machineidv1.BotInstanceStatusAuthentication +} + +func (e *Environment) GetMetadata() *headerv1.Metadata { + if e == nil { + return nil + } + return e.Metadata +} + +func (e *Environment) GetSpec() *machineidv1.BotInstanceSpec { + if e == nil { + return nil + } + return e.Spec +} + +func (e *Environment) GetLatestHeartbeat() *machineidv1.BotInstanceStatusHeartbeat { + if e == nil { + return nil + } + return e.LatestHeartbeat +} + +func (e *Environment) GetLatestAuthentication() *machineidv1.BotInstanceStatusAuthentication { + if e == nil { + return nil + } + return e.LatestAuthentication +} diff --git a/lib/auth/machineid/machineidv1/expression/expression.go b/lib/auth/machineid/machineidv1/expression/expression.go new file mode 100644 index 0000000000000..1c6dae206950e --- /dev/null +++ b/lib/auth/machineid/machineidv1/expression/expression.go @@ -0,0 +1,134 @@ +// 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 expression + +import ( + "github.com/coreos/go-semver/semver" + "github.com/gravitational/trace" + + "github.com/gravitational/teleport/lib/expression" + "github.com/gravitational/teleport/lib/utils/typical" +) + +func NewBotInstanceExpressionParser() (*typical.Parser[*Environment, bool], error) { + spec := expression.DefaultParserSpec[*Environment]() + + spec.Variables = map[string]typical.Variable{ + "name": typical.DynamicVariable(func(env *Environment) (string, error) { + return env.GetMetadata().GetName(), nil + }), + "metadata.name": typical.DynamicVariable(func(env *Environment) (string, error) { + return env.GetMetadata().GetName(), nil + }), + "spec.bot_name": typical.DynamicVariable(func(env *Environment) (string, error) { + return env.GetSpec().GetBotName(), nil + }), + "spec.instance_id": typical.DynamicVariable(func(env *Environment) (string, error) { + return env.GetSpec().GetInstanceId(), nil + }), + "status.latest_heartbeat.architecture": typical.DynamicVariable(func(env *Environment) (string, error) { + return env.GetLatestHeartbeat().GetArchitecture(), nil + }), + "status.latest_heartbeat.os": typical.DynamicVariable(func(env *Environment) (string, error) { + return env.GetLatestHeartbeat().GetOs(), nil + }), + "status.latest_heartbeat.hostname": typical.DynamicVariable(func(env *Environment) (string, error) { + return env.GetLatestHeartbeat().GetHostname(), nil + }), + "status.latest_heartbeat.one_shot": typical.DynamicVariable(func(env *Environment) (bool, error) { + return env.GetLatestHeartbeat().GetOneShot(), nil + }), + "status.latest_heartbeat.version": typical.DynamicVariable(func(env *Environment) (string, error) { + return env.GetLatestHeartbeat().GetVersion(), nil + }), + "status.latest_authentication.join_method": typical.DynamicVariable(func(env *Environment) (string, error) { + return env.GetLatestAuthentication().GetJoinMethod(), nil + }), + } + + // 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 + } + 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) + } +} diff --git a/lib/auth/machineid/machineidv1/expression/expression_test.go b/lib/auth/machineid/machineidv1/expression/expression_test.go new file mode 100644 index 0000000000000..44a64da40750e --- /dev/null +++ b/lib/auth/machineid/machineidv1/expression/expression_test.go @@ -0,0 +1,162 @@ +// 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 expression + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" + machineidv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1" +) + +func TestBotInstanceExpressionParser(t *testing.T) { + parser, err := NewBotInstanceExpressionParser() + require.NoError(t, err) + + makeBaseEnv := func() Environment { + return Environment{ + Metadata: &headerv1.Metadata{ + Name: "test-bot-1/76efb07a-3077-471c-988a-54d0fa49fc71", + }, + Spec: &machineidv1.BotInstanceSpec{ + BotName: "test-bot-1", + InstanceId: "76efb07a-3077-471c-988a-54d0fa49fc71", + }, + LatestAuthentication: &machineidv1.BotInstanceStatusAuthentication{ + JoinMethod: "kubernetes", + }, + LatestHeartbeat: &machineidv1.BotInstanceStatusHeartbeat{ + IsStartup: false, + Version: "19.0.1", + OneShot: false, + Architecture: "arm64", + Os: "linux", + Hostname: "test-hostname-1", + }, + } + } + + tcs := []struct { + name string + expTrue string + expFalse string + envFns []func(*Environment) + }{ + { + name: "equals name", + expTrue: `metadata.name == "test-bot-1/76efb07a-3077-471c-988a-54d0fa49fc71"`, + expFalse: `metadata.name == "test-bot-2/bf8ad485-9a8f-483d-8bcb-9d2f8f8c48d0"`, + }, + { + name: "equals name (short)", + expTrue: `name == "test-bot-1/76efb07a-3077-471c-988a-54d0fa49fc71"`, + expFalse: `name == "test-bot-2/bf8ad485-9a8f-483d-8bcb-9d2f8f8c48d0"`, + }, + { + name: "equals bot name", + expTrue: `spec.bot_name == "test-bot-1"`, + expFalse: `spec.bot_name == "test-bot-2"`, + }, + { + name: "equals instance id", + expTrue: `spec.instance_id == "76efb07a-3077-471c-988a-54d0fa49fc71"`, + expFalse: `spec.instance_id == "80eefb93-e79c-47f1-8170-a025013da490"`, + }, + { + name: "equals architecture", + expTrue: `status.latest_heartbeat.architecture == "arm64"`, + expFalse: `status.latest_heartbeat.architecture == "amd64"`, + }, + { + name: "equals os", + expTrue: `status.latest_heartbeat.os == "linux"`, + expFalse: `status.latest_heartbeat.os == "windows"`, + }, + { + name: "equals hostname", + expTrue: `status.latest_heartbeat.hostname == "test-hostname-1"`, + expFalse: `status.latest_heartbeat.hostname == "test-hostname-2"`, + }, + { + name: "equals one shot", + expTrue: `status.latest_heartbeat.one_shot`, + expFalse: `!status.latest_heartbeat.one_shot`, + envFns: []func(*Environment){ + func(e *Environment) { + e.LatestHeartbeat = &machineidv1.BotInstanceStatusHeartbeat{ + OneShot: true, + } + }, + }, + }, + { + name: "exact version", + expTrue: `status.latest_heartbeat.version == "19.0.1"`, + expFalse: `status.latest_heartbeat.version == "19.0.2-rc.1+56001"`, + }, + { + name: "between versions - lower", + expTrue: `between(status.latest_heartbeat.version, "19.0.1", "19.0.2")`, + expFalse: `between(status.latest_heartbeat.version, "19.0.2", "19.0.3")`, + }, + { + name: "between versions - upper", + expTrue: `between(status.latest_heartbeat.version, "19.0.0", "19.0.2")`, + expFalse: `between(status.latest_heartbeat.version, "19.0.0", "19.0.1")`, + }, + { + name: "newer than version", + expTrue: `newer_than(status.latest_heartbeat.version, "19.0.0")`, + expFalse: `newer_than(status.latest_heartbeat.version, "19.0.1")`, + }, + { + name: "older than version", + expTrue: `older_than(status.latest_heartbeat.version, "19.0.2")`, + expFalse: `older_than(status.latest_heartbeat.version, "19.0.1")`, + }, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + env := makeBaseEnv() + if tc.envFns != nil { + for _, fn := range tc.envFns { + fn(&env) + } + } + + if tc.expTrue != "" { + exp, err := parser.Parse(tc.expTrue) + require.NoError(t, err) + result, err := exp.Evaluate(&env) + require.NoError(t, err) + assert.True(t, result) + } + + if tc.expFalse != "" { + exp, err := parser.Parse(tc.expFalse) + require.NoError(t, err) + result, err := exp.Evaluate(&env) + require.NoError(t, err) + assert.False(t, result) + } + }) + } +} diff --git a/lib/cache/bot_instance.go b/lib/cache/bot_instance.go index 3f864b8a4c8d0..daca25763d73e 100644 --- a/lib/cache/bot_instance.go +++ b/lib/cache/bot_instance.go @@ -18,10 +18,11 @@ package cache import ( "context" - "slices" - "strings" + "encoding/base32" + "fmt" "time" + "github.com/coreos/go-semver/semver" "github.com/gravitational/trace" "google.golang.org/protobuf/proto" @@ -29,7 +30,10 @@ import ( machineidv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1" "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/api/utils/clientutils" + "github.com/gravitational/teleport/lib/auth/machineid/machineidv1/expression" + "github.com/gravitational/teleport/lib/itertools/stream" "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/lib/utils/typical" ) type botInstanceIndex string @@ -37,6 +41,8 @@ type botInstanceIndex string const ( botInstanceNameIndex botInstanceIndex = "name" botInstanceActiveAtIndex botInstanceIndex = "active_at_latest" + botInstanceVersionIndex botInstanceIndex = "version_latest" + botInstanceHostnameIndex botInstanceIndex = "host_name_latest" ) func newBotInstanceCollection(upstream services.BotInstance, w types.WatchKind) (*collection[*machineidv1.BotInstance, botInstanceIndex], error) { @@ -53,19 +59,18 @@ func newBotInstanceCollection(upstream services.BotInstance, w types.WatchKind) botInstanceNameIndex: keyForBotInstanceNameIndex, // Index on a combination of most recent heartbeat time and instance name botInstanceActiveAtIndex: keyForBotInstanceActiveAtIndex, + // Index on a combination of most recent heartbeat version and instance name + botInstanceVersionIndex: keyForBotInstanceVersionIndex, + // Index on a combination of most recent heartbeat hostname and instance name + botInstanceHostnameIndex: keyForBotInstanceHostnameIndex, }), fetcher: func(ctx context.Context, loadSecrets bool) ([]*machineidv1.BotInstance, error) { - var out []*machineidv1.BotInstance - clientutils.IterateResources(ctx, + out, err := stream.Collect(clientutils.Resources(ctx, func(ctx context.Context, limit int, start string) ([]*machineidv1.BotInstance, string, error) { - return upstream.ListBotInstances(ctx, "", limit, start, "", nil) + return upstream.ListBotInstances(ctx, limit, start, nil) }, - func(hcc *machineidv1.BotInstance) error { - out = append(out, hcc) - return nil - }, - ) - return out, nil + )) + return out, trace.Wrap(err) }, watch: w, }, nil @@ -90,25 +95,42 @@ func (c *Cache) GetBotInstance(ctx context.Context, botName, instanceID string) } // ListBotInstances returns a page of BotInstance resources. -func (c *Cache) ListBotInstances(ctx context.Context, botName string, pageSize int, lastToken string, search string, sort *types.SortBy) ([]*machineidv1.BotInstance, string, error) { +// request *services.ListBotInstancesRequestOptions +func (c *Cache) ListBotInstances(ctx context.Context, pageSize int, lastToken string, options *services.ListBotInstancesRequestOptions) ([]*machineidv1.BotInstance, string, error) { ctx, span := c.Tracer.Start(ctx, "cache/ListBotInstances") defer span.End() index := botInstanceNameIndex keyFn := keyForBotInstanceNameIndex - var isDesc bool - if sort != nil { - isDesc = sort.IsDesc - - switch sort.Field { - case "bot_name": - index = botInstanceNameIndex - keyFn = keyForBotInstanceNameIndex - case "active_at_latest": - index = botInstanceActiveAtIndex - keyFn = keyForBotInstanceActiveAtIndex - default: - return nil, "", trace.BadParameter("unsupported sort %q but expected bot_name or active_at_latest", sort.Field) + isDesc := options.GetSortDesc() + switch options.GetSortField() { + case "bot_name": + index = botInstanceNameIndex + keyFn = keyForBotInstanceNameIndex + case "active_at_latest": + index = botInstanceActiveAtIndex + keyFn = keyForBotInstanceActiveAtIndex + case "version_latest": + index = botInstanceVersionIndex + keyFn = keyForBotInstanceVersionIndex + case "host_name_latest": + index = botInstanceHostnameIndex + keyFn = keyForBotInstanceHostnameIndex + case "": + // default ordering as defined above + default: + return nil, "", trace.BadParameter("unsupported sort %q but expected bot_name, active_at_latest, version_latest or host_name_latest", options.GetSortField()) + } + + var exp typical.Expression[*expression.Environment, bool] + if options.GetFilterQuery() != "" { + parser, err := expression.NewBotInstanceExpressionParser() + if err != nil { + return nil, "", trace.Wrap(err) + } + exp, err = parser.Parse(options.GetFilterQuery()) + if err != nil { + return nil, "", trace.Wrap(err) } } @@ -119,10 +141,10 @@ func (c *Cache) ListBotInstances(ctx context.Context, botName string, pageSize i isDesc: isDesc, defaultPageSize: defaults.DefaultChunkSize, upstreamList: func(ctx context.Context, limit int, start string) ([]*machineidv1.BotInstance, string, error) { - return c.Config.BotInstanceService.ListBotInstances(ctx, botName, limit, start, search, sort) + return c.Config.BotInstanceService.ListBotInstances(ctx, limit, start, options) }, filter: func(b *machineidv1.BotInstance) bool { - return matchBotInstance(b, botName, search) + return services.MatchBotInstance(b, options.GetFilterBotName(), options.GetFilterSearchTerm(), exp) }, nextToken: func(b *machineidv1.BotInstance) string { return keyFn(b) @@ -135,37 +157,6 @@ func (c *Cache) ListBotInstances(ctx context.Context, botName string, pageSize i return out, next, trace.Wrap(err) } -func matchBotInstance(b *machineidv1.BotInstance, botName string, search string) bool { - // If updating this, ensure it's consistent with the upstream search logic in `lib/services/local/bot_instance.go`. - - if botName != "" && b.Spec.BotName != botName { - return false - } - - if search == "" { - return true - } - - latestHeartbeats := b.GetStatus().GetLatestHeartbeats() - heartbeat := b.Status.InitialHeartbeat // Use initial heartbeat as a fallback - if len(latestHeartbeats) > 0 { - heartbeat = latestHeartbeats[len(latestHeartbeats)-1] - } - - values := []string{ - b.Spec.BotName, - b.Spec.InstanceId, - } - - if heartbeat != nil { - values = append(values, heartbeat.Hostname, heartbeat.JoinMethod, heartbeat.Version, "v"+heartbeat.Version) - } - - return slices.ContainsFunc(values, func(val string) bool { - return strings.Contains(strings.ToLower(val), strings.ToLower(search)) - }) -} - func keyForBotInstanceNameIndex(botInstance *machineidv1.BotInstance) string { return makeBotInstanceNameIndexKey( botInstance.GetSpec().GetBotName(), @@ -178,17 +169,41 @@ func makeBotInstanceNameIndexKey(botName string, instanceID string) string { } func keyForBotInstanceActiveAtIndex(botInstance *machineidv1.BotInstance) string { - var recordedAt time.Time + heartbeat := services.GetBotInstanceLatestHeartbeat(botInstance) + recordedAt := heartbeat.GetRecordedAt().AsTime() + return recordedAt.Format(time.RFC3339) + "/" + botInstance.GetMetadata().GetName() +} - initialHeartbeatTime := botInstance.GetStatus().GetInitialHeartbeat().GetRecordedAt() - if initialHeartbeatTime != nil { - recordedAt = initialHeartbeatTime.AsTime() +// keyForBotInstanceVersionIndex produces a zero-padded version string for sorting. Pre- +// releases are sorted naively - 1.0.0-rc is correctly less than 1.0.0, but +// 1.0.0-rc.2 is more than 1.0.0-rc.11 +func keyForBotInstanceVersionIndex(botInstance *machineidv1.BotInstance) string { + version := "000000.000000.000000" + heartbeat := services.GetBotInstanceLatestHeartbeat(botInstance) + if heartbeat == nil { + return version + "-~/" + botInstance.GetMetadata().GetName() } - latestHeartbeats := botInstance.GetStatus().GetLatestHeartbeats() - if len(latestHeartbeats) > 0 { - recordedAt = latestHeartbeats[len(latestHeartbeats)-1].GetRecordedAt().AsTime() + sv, err := semver.NewVersion(heartbeat.GetVersion()) + if err != nil { + return version + "-~/" + botInstance.GetMetadata().GetName() } - return recordedAt.Format(time.RFC3339) + "/" + botInstance.GetMetadata().GetName() + version = fmt.Sprintf("%06d.%06d.%06d", sv.Major, sv.Minor, sv.Patch) + if sv.PreRelease != "" { + version = version + "-" + string(sv.PreRelease) + } else { + version = version + "-~" + } + return version + "/" + botInstance.GetMetadata().GetName() +} + +func keyForBotInstanceHostnameIndex(botInstance *machineidv1.BotInstance) string { + hostname := "~" + heartbeat := services.GetBotInstanceLatestHeartbeat(botInstance) + if heartbeat != nil { + hostname = heartbeat.GetHostname() + } + hostname = hostname + "/" + botInstance.GetMetadata().GetName() + return base32.HexEncoding.WithPadding(base32.NoPadding).EncodeToString([]byte(hostname)) } diff --git a/lib/cache/bot_instance_test.go b/lib/cache/bot_instance_test.go index d9e21b59dda94..bca74ccc0521d 100644 --- a/lib/cache/bot_instance_test.go +++ b/lib/cache/bot_instance_test.go @@ -22,6 +22,7 @@ import ( "testing" "time" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/timestamppb" @@ -29,6 +30,7 @@ import ( headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" machineidv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1" "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/services" ) // TestBotInstanceCache tests that CRUD operations on bot instances resources are @@ -56,14 +58,14 @@ func TestBotInstanceCache(t *testing.T) { return p.cache.GetBotInstance(ctx, "bot-1", key) }, cacheList: func(ctx context.Context, pageSize int, pageToken string) ([]*machineidv1.BotInstance, string, error) { - return p.cache.ListBotInstances(ctx, "", pageSize, pageToken, "", nil) + return p.cache.ListBotInstances(ctx, pageSize, pageToken, nil) }, create: func(ctx context.Context, resource *machineidv1.BotInstance) error { _, err := p.botInstanceService.CreateBotInstance(ctx, resource) return err }, list: func(ctx context.Context, pageSize int, pageToken string) ([]*machineidv1.BotInstance, string, error) { - return p.botInstanceService.ListBotInstances(ctx, "", pageSize, pageToken, "", nil) + return p.botInstanceService.ListBotInstances(ctx, pageSize, pageToken, nil) }, update: func(ctx context.Context, bi *machineidv1.BotInstance) error { _, err := p.botInstanceService.PatchBotInstance(ctx, "bot-1", bi.Metadata.GetName(), func(_ *machineidv1.BotInstance) (*machineidv1.BotInstance, error) { @@ -84,7 +86,7 @@ func TestBotInstanceCache(t *testing.T) { func TestBotInstanceCachePaging(t *testing.T) { t.Parallel() - ctx := context.Background() + ctx := t.Context() p := newTestPack(t, ForAuth) t.Cleanup(p.Close) @@ -105,13 +107,13 @@ func TestBotInstanceCachePaging(t *testing.T) { // Let the cache catch up require.EventuallyWithT(t, func(t *assert.CollectT) { - results, _, err := p.cache.ListBotInstances(ctx, "", 0, "", "", nil) + results, _, err := p.cache.ListBotInstances(ctx, 0, "", nil) require.NoError(t, err) require.Len(t, results, 5) }, 10*time.Second, 100*time.Millisecond) // page size equal to total items - results, nextPageToken, err := p.cache.ListBotInstances(ctx, "", 0, "", "", nil) + results, nextPageToken, err := p.cache.ListBotInstances(ctx, 0, "", nil) require.NoError(t, err) require.Empty(t, nextPageToken) require.Len(t, results, 5) @@ -122,7 +124,7 @@ func TestBotInstanceCachePaging(t *testing.T) { require.Equal(t, "instance-5", results[4].GetMetadata().GetName()) // page size smaller than total items - results, nextPageToken, err = p.cache.ListBotInstances(ctx, "", 3, "", "", nil) + results, nextPageToken, err = p.cache.ListBotInstances(ctx, 3, "", nil) require.NoError(t, err) require.Equal(t, "bot-1/instance-4", nextPageToken) require.Len(t, results, 3) @@ -131,7 +133,7 @@ func TestBotInstanceCachePaging(t *testing.T) { require.Equal(t, "instance-3", results[2].GetMetadata().GetName()) // next page - results, nextPageToken, err = p.cache.ListBotInstances(ctx, "", 3, nextPageToken, "", nil) + results, nextPageToken, err = p.cache.ListBotInstances(ctx, 3, nextPageToken, nil) require.NoError(t, err) require.Empty(t, nextPageToken) require.Len(t, results, 2) @@ -143,7 +145,7 @@ func TestBotInstanceCachePaging(t *testing.T) { func TestBotInstanceCacheBotFilter(t *testing.T) { t.Parallel() - ctx := context.Background() + ctx := t.Context() p := newTestPack(t, ForAuth) t.Cleanup(p.Close) @@ -164,12 +166,14 @@ func TestBotInstanceCacheBotFilter(t *testing.T) { // Let the cache catch up require.EventuallyWithT(t, func(t *assert.CollectT) { - results, _, err := p.cache.ListBotInstances(ctx, "", 0, "", "", nil) + results, _, err := p.cache.ListBotInstances(ctx, 0, "", nil) require.NoError(t, err) require.Len(t, results, 10) }, 10*time.Second, 100*time.Millisecond) - results, _, err := p.cache.ListBotInstances(ctx, "bot-1", 0, "", "", nil) + results, _, err := p.cache.ListBotInstances(ctx, 0, "", &services.ListBotInstancesRequestOptions{ + FilterBotName: "bot-1", + }) require.NoError(t, err) require.Len(t, results, 5) @@ -178,11 +182,12 @@ func TestBotInstanceCacheBotFilter(t *testing.T) { } } -// TestBotInstanceCacheSearchFilter tests that cache items are filtered by search query. +// TestBotInstanceCacheSearchFilter tests that cache items are filtered by +// search term. func TestBotInstanceCacheSearchFilter(t *testing.T) { t.Parallel() - ctx := context.Background() + ctx := t.Context() p := newTestPack(t, ForAuth) t.Cleanup(p.Close) @@ -211,21 +216,87 @@ func TestBotInstanceCacheSearchFilter(t *testing.T) { // Let the cache catch up require.EventuallyWithT(t, func(t *assert.CollectT) { - results, _, err := p.cache.ListBotInstances(ctx, "", 0, "", "", nil) + results, _, err := p.cache.ListBotInstances(ctx, 0, "", nil) require.NoError(t, err) require.Len(t, results, 10) }, 10*time.Second, 100*time.Millisecond) - results, _, err := p.cache.ListBotInstances(ctx, "", 0, "", "host-1", nil) + results, _, err := p.cache.ListBotInstances(ctx, 0, "", &services.ListBotInstancesRequestOptions{ + FilterSearchTerm: "host-1", + }) require.NoError(t, err) require.Len(t, results, 5) } +// TestBotInstanceCacheQueryFilter tests that cache items are filtered by query. +func TestBotInstanceCacheQueryFilter(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + p := newTestPack(t, ForAuth) + t.Cleanup(p.Close) + + { + _, err := p.botInstanceService.CreateBotInstance(ctx, &machineidv1.BotInstance{ + Kind: types.KindBotInstance, + Version: types.V1, + Metadata: &headerv1.Metadata{}, + Spec: &machineidv1.BotInstanceSpec{ + BotName: "bot-1", + InstanceId: "00000000-0000-0000-0000-000000000000", + }, + Status: &machineidv1.BotInstanceStatus{ + LatestHeartbeats: []*machineidv1.BotInstanceStatusHeartbeat{ + { + Hostname: "host-1", + }, + }, + }, + }) + require.NoError(t, err) + } + + { + _, err := p.botInstanceService.CreateBotInstance(ctx, &machineidv1.BotInstance{ + Kind: types.KindBotInstance, + Version: types.V1, + Metadata: &headerv1.Metadata{}, + Spec: &machineidv1.BotInstanceSpec{ + BotName: "bot-1", + InstanceId: uuid.New().String(), + }, + Status: &machineidv1.BotInstanceStatus{ + LatestHeartbeats: []*machineidv1.BotInstanceStatusHeartbeat{ + { + Hostname: "host-2", + }, + }, + }, + }) + require.NoError(t, err) + } + + // Let the cache catch up + require.EventuallyWithT(t, func(t *assert.CollectT) { + results, _, err := p.cache.ListBotInstances(ctx, 0, "", nil) + require.NoError(t, err) + require.Len(t, results, 2) + }, 10*time.Second, 100*time.Millisecond) + + results, _, err := p.cache.ListBotInstances(ctx, 0, "", &services.ListBotInstancesRequestOptions{ + FilterQuery: `status.latest_heartbeat.hostname == "host-1"`, + }) + require.NoError(t, err) + require.Len(t, results, 1) + require.Equal(t, "00000000-0000-0000-0000-000000000000", results[0].Spec.InstanceId) +} + // TestBotInstanceCacheSorting tests that cache items are sorted. func TestBotInstanceCacheSorting(t *testing.T) { t.Parallel() - ctx := context.Background() + ctx := t.Context() p := newTestPack(t, ForAuth) t.Cleanup(p.Close) @@ -234,10 +305,12 @@ func TestBotInstanceCacheSorting(t *testing.T) { botName string instanceId string recordedAtSeconds int64 + version string + hostname string }{ - {"bot-1", "instance-1", 2}, - {"bot-1", "instance-3", 1}, - {"bot-2", "instance-2", 3}, + {"bot-1", "instance-1", 2, "2.0.0", "hostname-2"}, + {"bot-1", "instance-3", 1, "2.0.0-rc1", "hostname-3"}, + {"bot-2", "instance-2", 3, "1.0.0", "hostname-1"}, } for _, b := range items { @@ -255,6 +328,8 @@ func TestBotInstanceCacheSorting(t *testing.T) { RecordedAt: ×tamppb.Timestamp{ Seconds: b.recordedAtSeconds, }, + Version: b.version, + Hostname: b.hostname, }, }, }, @@ -266,51 +341,111 @@ func TestBotInstanceCacheSorting(t *testing.T) { // Let the cache catch up require.EventuallyWithT(t, func(t *assert.CollectT) { - results, _, err := p.cache.ListBotInstances(ctx, "", 0, "", "", nil) + results, _, err := p.cache.ListBotInstances(ctx, 0, "", nil) require.NoError(t, err) require.Len(t, results, 3) }, 10*time.Second, 100*time.Millisecond) - // sort ascending by active_at_latest - results, _, err := p.cache.ListBotInstances(ctx, "", 0, "", "", &types.SortBy{ - Field: "active_at_latest", - IsDesc: false, + t.Run("sort ascending by active_at_latest", func(t *testing.T) { + results, _, err := p.cache.ListBotInstances(ctx, 0, "", &services.ListBotInstancesRequestOptions{ + SortField: "active_at_latest", + SortDesc: false, + }) + require.NoError(t, err) + require.Len(t, results, 3) + assert.Equal(t, "instance-3", results[0].GetMetadata().GetName()) + assert.Equal(t, "instance-1", results[1].GetMetadata().GetName()) + assert.Equal(t, "instance-2", results[2].GetMetadata().GetName()) }) - require.NoError(t, err) - require.Len(t, results, 3) - require.Equal(t, "instance-3", results[0].GetMetadata().GetName()) - require.Equal(t, "instance-1", results[1].GetMetadata().GetName()) - require.Equal(t, "instance-2", results[2].GetMetadata().GetName()) - - // sort descending by active_at_latest - results, _, err = p.cache.ListBotInstances(ctx, "", 0, "", "", &types.SortBy{ - Field: "active_at_latest", - IsDesc: true, + + t.Run("sort descending by active_at_latest", func(t *testing.T) { + results, _, err := p.cache.ListBotInstances(ctx, 0, "", &services.ListBotInstancesRequestOptions{ + SortField: "active_at_latest", + SortDesc: true, + }) + require.NoError(t, err) + require.Len(t, results, 3) + assert.Equal(t, "instance-2", results[0].GetMetadata().GetName()) + assert.Equal(t, "instance-1", results[1].GetMetadata().GetName()) + assert.Equal(t, "instance-3", results[2].GetMetadata().GetName()) }) - require.NoError(t, err) - require.Len(t, results, 3) - require.Equal(t, "instance-2", results[0].GetMetadata().GetName()) - require.Equal(t, "instance-1", results[1].GetMetadata().GetName()) - require.Equal(t, "instance-3", results[2].GetMetadata().GetName()) - // sort ascending by bot_name - results, _, err = p.cache.ListBotInstances(ctx, "", 0, "", "", nil) // empty sort should default to `bot_name:asc` - require.NoError(t, err) - require.Len(t, results, 3) - require.Equal(t, "instance-1", results[0].GetMetadata().GetName()) - require.Equal(t, "instance-3", results[1].GetMetadata().GetName()) - require.Equal(t, "instance-2", results[2].GetMetadata().GetName()) + t.Run("sort ascending by bot_name", func(t *testing.T) { + results, _, err := p.cache.ListBotInstances(ctx, 0, "", nil) // empty sort should default to `bot_name:asc` + require.NoError(t, err) + require.Len(t, results, 3) + assert.Equal(t, "instance-1", results[0].GetMetadata().GetName()) + assert.Equal(t, "instance-3", results[1].GetMetadata().GetName()) + assert.Equal(t, "instance-2", results[2].GetMetadata().GetName()) + }) - // sort descending by bot_name - results, _, err = p.cache.ListBotInstances(ctx, "", 0, "", "", &types.SortBy{ - Field: "bot_name", - IsDesc: true, + t.Run("sort descending by bot_name", func(t *testing.T) { + results, _, err := p.cache.ListBotInstances(ctx, 0, "", &services.ListBotInstancesRequestOptions{ + SortField: "bot_name", + SortDesc: true, + }) + require.NoError(t, err) + require.Len(t, results, 3) + assert.Equal(t, "instance-2", results[0].GetMetadata().GetName()) + assert.Equal(t, "instance-3", results[1].GetMetadata().GetName()) + assert.Equal(t, "instance-1", results[2].GetMetadata().GetName()) + }) + + t.Run("sort ascending by version", func(t *testing.T) { + results, _, err := p.cache.ListBotInstances(ctx, 0, "", &services.ListBotInstancesRequestOptions{ + SortField: "version_latest", + SortDesc: false, + }) + require.NoError(t, err) + require.Len(t, results, 3) + assert.Equal(t, "instance-2", results[0].GetMetadata().GetName()) + assert.Equal(t, "instance-3", results[1].GetMetadata().GetName()) + assert.Equal(t, "instance-1", results[2].GetMetadata().GetName()) + }) + + t.Run("sort descending by version", func(t *testing.T) { + results, _, err := p.cache.ListBotInstances(ctx, 0, "", &services.ListBotInstancesRequestOptions{ + SortField: "version_latest", + SortDesc: true, + }) + require.NoError(t, err) + require.Len(t, results, 3) + assert.Equal(t, "instance-1", results[0].GetMetadata().GetName()) + assert.Equal(t, "instance-3", results[1].GetMetadata().GetName()) + assert.Equal(t, "instance-2", results[2].GetMetadata().GetName()) + }) + + t.Run("sort ascending by hostname", func(t *testing.T) { + results, _, err := p.cache.ListBotInstances(ctx, 0, "", &services.ListBotInstancesRequestOptions{ + SortField: "host_name_latest", + SortDesc: false, + }) + require.NoError(t, err) + require.Len(t, results, 3) + require.Equal(t, "instance-2", results[0].GetMetadata().GetName()) + require.Equal(t, "instance-1", results[1].GetMetadata().GetName()) + require.Equal(t, "instance-3", results[2].GetMetadata().GetName()) + }) + + t.Run("sort descending by hostname", func(t *testing.T) { + results, _, err := p.cache.ListBotInstances(ctx, 0, "", &services.ListBotInstancesRequestOptions{ + SortField: "host_name_latest", + SortDesc: true, + }) + require.NoError(t, err) + require.Len(t, results, 3) + require.Equal(t, "instance-3", results[0].GetMetadata().GetName()) + require.Equal(t, "instance-1", results[1].GetMetadata().GetName()) + require.Equal(t, "instance-2", results[2].GetMetadata().GetName()) + }) + + t.Run("sort invalid field", func(t *testing.T) { + _, _, err := p.cache.ListBotInstances(ctx, 0, "", &services.ListBotInstancesRequestOptions{ + SortField: "blah", + }) + require.Error(t, err) + assert.ErrorContains(t, err, `unsupported sort "blah" but expected bot_name, active_at_latest, version_latest or host_name_latest`) }) - require.NoError(t, err) - require.Len(t, results, 3) - require.Equal(t, "instance-2", results[0].GetMetadata().GetName()) - require.Equal(t, "instance-3", results[1].GetMetadata().GetName()) - require.Equal(t, "instance-1", results[2].GetMetadata().GetName()) } // TestBotInstanceCacheFallback tests that requests fallback to the upstream when the cache is unhealthy. @@ -339,33 +474,135 @@ func TestBotInstanceCacheFallback(t *testing.T) { // Let the cache catch up require.EventuallyWithT(t, func(t *assert.CollectT) { - results, _, err := p.cache.ListBotInstances(ctx, "", 0, "", "", nil) + results, _, err := p.cache.ListBotInstances(ctx, 0, "", nil) require.NoError(t, err) require.Len(t, results, 1) }, 10*time.Second, 100*time.Millisecond) // sort ascending by bot_name - results, _, err := p.cache.ListBotInstances(ctx, "", 0, "", "", &types.SortBy{ - Field: "bot_name", - IsDesc: false, + results, _, err := p.cache.ListBotInstances(ctx, 0, "", &services.ListBotInstancesRequestOptions{ + SortField: "bot_name", + SortDesc: false, }) require.NoError(t, err) // asc by bot_name is the only sort supported by the upstream require.Len(t, results, 1) // sort descending by bot_name - _, _, err = p.cache.ListBotInstances(ctx, "", 0, "", "", &types.SortBy{ - Field: "bot_name", - IsDesc: true, + _, _, err = p.cache.ListBotInstances(ctx, 0, "", &services.ListBotInstancesRequestOptions{ + SortField: "bot_name", + SortDesc: true, }) require.Error(t, err) - require.Equal(t, "unsupported sort, only bot_name:asc is supported, but got \"bot_name\" (desc = true)", err.Error()) + assert.ErrorContains(t, err, "unsupported sort, only ascending order is supported") // sort ascending by active_at_latest - _, _, err = p.cache.ListBotInstances(ctx, "", 0, "", "", &types.SortBy{ - Field: "active_at_latest", - IsDesc: false, + _, _, err = p.cache.ListBotInstances(ctx, 0, "", &services.ListBotInstancesRequestOptions{ + SortField: "active_at_latest", + SortDesc: false, }) require.Error(t, err) - require.Equal(t, "unsupported sort, only bot_name:asc is supported, but got \"active_at_latest\" (desc = false)", err.Error()) + assert.ErrorContains(t, err, "unsupported sort, only bot_name field is supported, but got \"active_at_latest\"") +} + +func TestKeyForVersionIndex(t *testing.T) { + t.Parallel() + + tcs := []struct { + name string + mutatorFn func(*machineidv1.BotInstance) + key string + }{ + { + name: "zero heartbeats", + mutatorFn: func(b *machineidv1.BotInstance) {}, + key: "000000.000000.000000-~/bot-instance-1", + }, + { + name: "invalid version", + mutatorFn: func(b *machineidv1.BotInstance) { + b.Status = &machineidv1.BotInstanceStatus{ + InitialHeartbeat: &machineidv1.BotInstanceStatusHeartbeat{ + Version: "a.b.c", + }, + } + }, + key: "000000.000000.000000-~/bot-instance-1", + }, + { + name: "initial heartbeat", + mutatorFn: func(b *machineidv1.BotInstance) { + b.Status = &machineidv1.BotInstanceStatus{ + InitialHeartbeat: &machineidv1.BotInstanceStatusHeartbeat{ + Version: "1.0.0", + }, + } + }, + key: "000001.000000.000000-~/bot-instance-1", + }, + { + name: "latest heartbeat", + mutatorFn: func(b *machineidv1.BotInstance) { + b.Status = &machineidv1.BotInstanceStatus{ + LatestHeartbeats: []*machineidv1.BotInstanceStatusHeartbeat{ + { + Version: "1.0.0", + }, + }, + } + }, + key: "000001.000000.000000-~/bot-instance-1", + }, + { + name: "with release", + mutatorFn: func(b *machineidv1.BotInstance) { + b.Status = &machineidv1.BotInstanceStatus{ + InitialHeartbeat: &machineidv1.BotInstanceStatusHeartbeat{ + Version: "1.0.0-dev", + }, + } + }, + key: "000001.000000.000000-dev/bot-instance-1", + }, + { + name: "with build", + mutatorFn: func(b *machineidv1.BotInstance) { + b.Status = &machineidv1.BotInstanceStatus{ + InitialHeartbeat: &machineidv1.BotInstanceStatusHeartbeat{ + Version: "1.0.0+build1", + }, + } + }, + key: "000001.000000.000000-~/bot-instance-1", + }, + { + name: "with release and build", + mutatorFn: func(b *machineidv1.BotInstance) { + b.Status = &machineidv1.BotInstanceStatus{ + InitialHeartbeat: &machineidv1.BotInstanceStatusHeartbeat{ + Version: "1.0.0-dev+build1", + }, + } + }, + key: "000001.000000.000000-dev/bot-instance-1", + }, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + instance := &machineidv1.BotInstance{ + Kind: types.KindBotInstance, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "bot-instance-1", + }, + Spec: &machineidv1.BotInstanceSpec{}, + Status: &machineidv1.BotInstanceStatus{}, + } + tc.mutatorFn(instance) + + versionKey := keyForBotInstanceVersionIndex(instance) + assert.Equal(t, tc.key, versionKey) + }) + } } diff --git a/lib/cache/workload_identity.go b/lib/cache/workload_identity.go index 61ddee9a7b291..daa6fe9066c8d 100644 --- a/lib/cache/workload_identity.go +++ b/lib/cache/workload_identity.go @@ -141,9 +141,7 @@ func keyForWorkloadIdentitySpiffeIDIndex(r *workloadidentityv1pb.WorkloadIdentit spiffeID := cases.Fold().String(r.GetSpec().GetSpiffe().GetId()) // Encode the id avoid; "a/b" + "/" + "c" vs. "a" + "/" + "b/c". Base32 hex // maintains original ordering. - spiffeID = unpaddedBase32hex.EncodeToString([]byte(spiffeID)) + spiffeID = base32.HexEncoding.WithPadding(base32.NoPadding).EncodeToString([]byte(spiffeID)) // SPIFFE IDs may not be unique, so append the resource name return spiffeID + "/" + name } - -var unpaddedBase32hex = base32.HexEncoding.WithPadding(base32.NoPadding) diff --git a/lib/services/bot_instance.go b/lib/services/bot_instance.go index 50beac8a76472..df9fcd3bacb1e 100644 --- a/lib/services/bot_instance.go +++ b/lib/services/bot_instance.go @@ -18,11 +18,14 @@ package services import ( "context" + "slices" + "github.com/charlievieth/strcase" "github.com/gravitational/trace" machineidv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1" - "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/auth/machineid/machineidv1/expression" + "github.com/gravitational/teleport/lib/utils/typical" ) // BotInstance is an interface for the BotInstance service. @@ -34,7 +37,7 @@ type BotInstance interface { GetBotInstance(ctx context.Context, botName, instanceID string) (*machineidv1.BotInstance, error) // ListBotInstances - ListBotInstances(ctx context.Context, botName string, pageSize int, lastToken string, search string, sort *types.SortBy) ([]*machineidv1.BotInstance, string, error) + ListBotInstances(ctx context.Context, pageSize int, lastToken string, options *ListBotInstancesRequestOptions) ([]*machineidv1.BotInstance, string, error) // DeleteBotInstance DeleteBotInstance(ctx context.Context, botName, instanceID string) error @@ -79,3 +82,114 @@ func MarshalBotInstance(object *machineidv1.BotInstance, opts ...MarshalOption) func UnmarshalBotInstance(data []byte, opts ...MarshalOption) (*machineidv1.BotInstance, error) { return UnmarshalProtoResource[*machineidv1.BotInstance](data, opts...) } + +func MatchBotInstance(b *machineidv1.BotInstance, botName string, search string, exp typical.Expression[*expression.Environment, bool]) bool { + if botName != "" && b.GetSpec().GetBotName() != botName { + return false + } + + heartbeat := GetBotInstanceLatestHeartbeat(b) + authentication := GetBotInstanceLatestAuthentication(b) + + if exp != nil { + if match, err := exp.Evaluate(&expression.Environment{ + Metadata: b.GetMetadata(), + Spec: b.GetSpec(), + LatestHeartbeat: heartbeat, + LatestAuthentication: authentication, + }); err != nil || !match { + return false + } + } + + if search == "" { + return true + } + + values := []string{ + b.Spec.BotName, + b.Spec.InstanceId, + } + + if heartbeat != nil { + values = append(values, heartbeat.Hostname, heartbeat.JoinMethod, heartbeat.Version, "v"+heartbeat.Version) + } + + return slices.ContainsFunc(values, func(val string) bool { + return strcase.Contains(val, search) + }) +} + +// GetBotInstanceLatestHeartbeat returns the most recent heartbeat for the +// given bot instance. +func GetBotInstanceLatestHeartbeat(botInstance *machineidv1.BotInstance) *machineidv1.BotInstanceStatusHeartbeat { + heartbeat := botInstance.GetStatus().GetInitialHeartbeat() + latestHeartbeats := botInstance.GetStatus().GetLatestHeartbeats() + if len(latestHeartbeats) > 0 { + heartbeat = latestHeartbeats[len(latestHeartbeats)-1] + } + return heartbeat +} + +// GetBotInstanceLatestAuthentication returns the most recent authentication for +// the given bot instance. +func GetBotInstanceLatestAuthentication(botInstance *machineidv1.BotInstance) *machineidv1.BotInstanceStatusAuthentication { + authentication := botInstance.GetStatus().GetInitialAuthentication() + latestAuthentications := botInstance.GetStatus().GetLatestAuthentications() + if len(latestAuthentications) > 0 { + authentication = latestAuthentications[len(latestAuthentications)-1] + } + return authentication +} + +type ListBotInstancesRequestOptions struct { + // The sort field to use for the results. If empty, the default sort field + // is used. + SortField string + // The sort order to use for the results. If empty, the default sort order + // is used. + SortDesc bool + // The name of the Bot to list BotInstances for. If empty, all BotInstances + // will be listed. + FilterBotName string + // A search term used to filter the results. If non-empty, it's used to + // match against supported fields. + FilterSearchTerm string + // A Teleport predicate language query used to filter the results. + FilterQuery string +} + +func (o *ListBotInstancesRequestOptions) GetSortField() string { + if o == nil { + return "" + } + return o.SortField +} + +func (o *ListBotInstancesRequestOptions) GetSortDesc() bool { + if o == nil { + return false + } + return o.SortDesc +} + +func (o *ListBotInstancesRequestOptions) GetFilterBotName() string { + if o == nil { + return "" + } + return o.FilterBotName +} + +func (o *ListBotInstancesRequestOptions) GetFilterSearchTerm() string { + if o == nil { + return "" + } + return o.FilterSearchTerm +} + +func (o *ListBotInstancesRequestOptions) GetFilterQuery() string { + if o == nil { + return "" + } + return o.FilterQuery +} diff --git a/lib/services/local/bot_instance.go b/lib/services/local/bot_instance.go index 82090ea0f8b53..2256d46d0caa5 100644 --- a/lib/services/local/bot_instance.go +++ b/lib/services/local/bot_instance.go @@ -18,8 +18,6 @@ package local import ( "context" - "slices" - "strings" "github.com/gravitational/trace" "github.com/jonboulle/clockwork" @@ -28,9 +26,11 @@ import ( machineidv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1" "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/api/utils" + "github.com/gravitational/teleport/lib/auth/machineid/machineidv1/expression" "github.com/gravitational/teleport/lib/backend" "github.com/gravitational/teleport/lib/services" "github.com/gravitational/teleport/lib/services/local/generic" + "github.com/gravitational/teleport/lib/utils/typical" ) const ( @@ -96,62 +96,46 @@ func (b *BotInstanceService) GetBotInstance(ctx context.Context, botName, instan // If an non-empty search term is provided, only instances with a value containing the term in supported fields are fetched. // Supported search fields include; bot name, instance id, hostname (latest), tbot version (latest), join method (latest). // Sorting by bot name in ascending order is supported - an error is returned for any other sort type. -func (b *BotInstanceService) ListBotInstances(ctx context.Context, botName string, pageSize int, lastKey string, search string, sort *types.SortBy) ([]*machineidv1.BotInstance, string, error) { - if sort != nil && (sort.Field != "bot_name" || sort.IsDesc != false) { - return nil, "", trace.BadParameter("unsupported sort, only bot_name:asc is supported, but got %q (desc = %t)", sort.Field, sort.IsDesc) +func (b *BotInstanceService) ListBotInstances(ctx context.Context, pageSize int, lastKey string, options *services.ListBotInstancesRequestOptions) ([]*machineidv1.BotInstance, string, error) { + if options.GetSortField() != "" && options.GetSortField() != "bot_name" { + return nil, "", trace.CompareFailed("unsupported sort, only bot_name field is supported, but got %q", options.GetSortField()) + } + if options.GetSortDesc() { + return nil, "", trace.CompareFailed("unsupported sort, only ascending order is supported") } var service *generic.ServiceWrapper[*machineidv1.BotInstance] - if botName == "" { + if options.GetFilterBotName() == "" { // If botName is empty, return instances for all bots by not using a service prefix service = b.service } else { - service = b.service.WithPrefix(botName) + service = b.service.WithPrefix(options.GetFilterBotName()) } - if search == "" { + var exp typical.Expression[*expression.Environment, bool] + if options.GetFilterQuery() != "" { + parser, err := expression.NewBotInstanceExpressionParser() + if err != nil { + return nil, "", trace.Wrap(err) + } + exp, err = parser.Parse(options.GetFilterQuery()) + if err != nil { + return nil, "", trace.Wrap(err) + } + } + + if options.GetFilterSearchTerm() == "" && exp == nil { r, nextToken, err := service.ListResources(ctx, pageSize, lastKey) return r, nextToken, trace.Wrap(err) } r, nextToken, err := service.ListResourcesWithFilter(ctx, pageSize, lastKey, func(item *machineidv1.BotInstance) bool { - return matchBotInstance(item, botName, search) + return services.MatchBotInstance(item, "", options.GetFilterSearchTerm(), exp) }) return r, nextToken, trace.Wrap(err) } -func matchBotInstance(b *machineidv1.BotInstance, botName string, search string) bool { - // If updating this, ensure it's consistent with the cache search logic in `lib/cache/bot_instance.go`. - - if botName != "" && b.Spec.BotName != botName { - return false - } - - if search == "" { - return true - } - - latestHeartbeats := b.GetStatus().GetLatestHeartbeats() - heartbeat := b.Status.InitialHeartbeat // Use initial heartbeat as a fallback - if len(latestHeartbeats) > 0 { - heartbeat = latestHeartbeats[len(latestHeartbeats)-1] - } - - values := []string{ - b.Spec.BotName, - b.Spec.InstanceId, - } - - if heartbeat != nil { - values = append(values, heartbeat.Hostname, heartbeat.JoinMethod, heartbeat.Version, "v"+heartbeat.Version) - } - - return slices.ContainsFunc(values, func(val string) bool { - return strings.Contains(strings.ToLower(val), strings.ToLower(search)) - }) -} - // DeleteBotInstance deletes a specific bot instance matching the given bot name // and instance ID. func (b *BotInstanceService) DeleteBotInstance(ctx context.Context, botName, instanceID string) error { diff --git a/lib/services/local/bot_instance_test.go b/lib/services/local/bot_instance_test.go index 7d0b4e49f8f26..fe35c8ce5ba20 100644 --- a/lib/services/local/bot_instance_test.go +++ b/lib/services/local/bot_instance_test.go @@ -34,6 +34,7 @@ import ( "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/lib/backend" "github.com/gravitational/teleport/lib/backend/memory" + "github.com/gravitational/teleport/lib/services" ) // newBotInstance creates (but does not insert) a bot instance that is ready for @@ -159,7 +160,7 @@ func createInstances(t *testing.T, ctx context.Context, service *BotInstanceServ } // listInstances fetches all instances from the BotInstanceService matching the botName filter -func listInstances(t *testing.T, ctx context.Context, service *BotInstanceService, botName string, searchTerm string, sort *types.SortBy) []*machineidv1.BotInstance { +func listInstances(t *testing.T, ctx context.Context, service *BotInstanceService, options *services.ListBotInstancesRequestOptions) []*machineidv1.BotInstance { t.Helper() var resources []*machineidv1.BotInstance @@ -168,7 +169,7 @@ func listInstances(t *testing.T, ctx context.Context, service *BotInstanceServic var err error for { - bis, nextKey, err = service.ListBotInstances(ctx, botName, 0, nextKey, searchTerm, sort) + bis, nextKey, err = service.ListBotInstances(ctx, 0, nextKey, options) require.NoError(t, err) resources = append(resources, bis...) @@ -235,7 +236,7 @@ func TestBotInstanceCreateMetadata(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - ctx := context.Background() + ctx := t.Context() mem, err := memory.New(memory.Config{ Context: ctx, @@ -258,7 +259,7 @@ func TestBotInstanceCreateMetadata(t *testing.T) { func TestBotInstanceInvalidGetters(t *testing.T) { t.Parallel() - ctx := context.Background() + ctx := t.Context() clock := clockwork.NewFakeClock() mem, err := memory.New(memory.Config{ @@ -282,7 +283,7 @@ func TestBotInstanceInvalidGetters(t *testing.T) { func TestBotInstanceCRUD(t *testing.T) { t.Parallel() - ctx := context.Background() + ctx := t.Context() clock := clockwork.NewFakeClock() mem, err := memory.New(memory.Config{ @@ -307,7 +308,9 @@ func TestBotInstanceCRUD(t *testing.T) { require.EqualExportedValues(t, patched, bi2) require.Equal(t, bi.Metadata.Name, bi2.Metadata.Name) - resources := listInstances(t, ctx, service, "example", "", nil) + resources := listInstances(t, ctx, service, &services.ListBotInstancesRequestOptions{ + FilterBotName: "example", + }) require.Len(t, resources, 1, "must list only 1 bot instance") require.EqualExportedValues(t, patched, resources[0]) @@ -338,7 +341,7 @@ func TestBotInstanceCRUD(t *testing.T) { func TestBotInstanceList(t *testing.T) { t.Parallel() - ctx := context.Background() + ctx := t.Context() clock := clockwork.NewFakeClock() mem, err := memory.New(memory.Config{ @@ -354,14 +357,18 @@ func TestBotInstanceList(t *testing.T) { bIds := createInstances(t, ctx, service, "b", 4) // listing "a" should only return known "a" instances - aInstances := listInstances(t, ctx, service, "a", "", nil) + aInstances := listInstances(t, ctx, service, &services.ListBotInstancesRequestOptions{ + FilterBotName: "a", + }) require.Len(t, aInstances, 3) for _, ins := range aInstances { require.Contains(t, aIds, ins.Spec.InstanceId) } // listing "b" should only return known "b" instances - bInstances := listInstances(t, ctx, service, "b", "", nil) + bInstances := listInstances(t, ctx, service, &services.ListBotInstancesRequestOptions{ + FilterBotName: "b", + }) require.Len(t, bInstances, 4) for _, ins := range bInstances { require.Contains(t, bIds, ins.Spec.InstanceId) @@ -376,14 +383,16 @@ func TestBotInstanceList(t *testing.T) { } // Listing an empty bot name ("") should return all instances. - allInstances := listInstances(t, ctx, service, "", "", nil) + allInstances := listInstances(t, ctx, service, &services.ListBotInstancesRequestOptions{ + FilterBotName: "", + }) require.Len(t, allInstances, 7) for _, ins := range allInstances { require.Contains(t, allIds, ins.Spec.InstanceId) } } -// TestBotInstanceListWithSearchFilter verifies list and filtering wit39db3c10-870c-4544-aeec-9fc2e961eca3h search +// TestBotInstanceListWithSearchFilter verifies list and filtering with search // term functionality for bot instances. func TestBotInstanceListWithSearchFilter(t *testing.T) { t.Parallel() @@ -402,7 +411,7 @@ func TestBotInstanceListWithSearchFilter(t *testing.T) { }, { name: "match on instance id", - searchTerm: "cb2c352", + searchTerm: "CB2C352", instance: newBotInstance("test-bot", withBotInstanceId("cb2c3523-01f6-4258-966b-ace9f38f9862")), }, { @@ -429,7 +438,7 @@ func TestBotInstanceListWithSearchFilter(t *testing.T) { for _, tc := range tcs { t.Run(tc.name, func(t *testing.T) { - ctx := context.Background() + ctx := t.Context() mem, err := memory.New(memory.Config{ Context: ctx, @@ -445,7 +454,9 @@ func TestBotInstanceListWithSearchFilter(t *testing.T) { _, err = service.CreateBotInstance(ctx, newBotInstance("bot-not-matched")) require.NoError(t, err) - instances := listInstances(t, ctx, service, "", tc.searchTerm, nil) + instances := listInstances(t, ctx, service, &services.ListBotInstancesRequestOptions{ + FilterSearchTerm: tc.searchTerm, + }) require.Len(t, instances, 1) require.Equal(t, tc.instance.Spec.InstanceId, instances[0].Spec.InstanceId) @@ -453,13 +464,42 @@ func TestBotInstanceListWithSearchFilter(t *testing.T) { } } +// TestBotInstanceListWithQuery verifies list and filtering with query +// functionality for bot instances. +func TestBotInstanceListWithQuery(t *testing.T) { + t.Parallel() + + clock := clockwork.NewFakeClock() + ctx := t.Context() + mem, err := memory.New(memory.Config{ + Context: ctx, + Clock: clock, + }) + require.NoError(t, err) + service, err := NewBotInstanceService(backend.NewSanitizer(mem), clock) + require.NoError(t, err) + + instance := newBotInstance("test-bot", withBotInstanceHeartbeatHostname("svr-eu-tel-123-a")) + _, err = service.CreateBotInstance(ctx, instance) + require.NoError(t, err) + _, err = service.CreateBotInstance(ctx, newBotInstance("bot-not-matched")) + require.NoError(t, err) + + instances := listInstances(t, ctx, service, &services.ListBotInstancesRequestOptions{ + FilterQuery: `status.latest_heartbeat.hostname == "svr-eu-tel-123-a"`, + }) + + require.Len(t, instances, 1) + require.Equal(t, instance.Spec.InstanceId, instances[0].Spec.InstanceId) +} + // TestBotInstanceListWithSort verifies sorting returns a not-implemented error. func TestBotInstanceListWithSort(t *testing.T) { t.Parallel() clock := clockwork.NewFakeClock() - ctx := context.Background() + ctx := t.Context() mem, err := memory.New(memory.Config{ Context: ctx, @@ -470,13 +510,20 @@ func TestBotInstanceListWithSort(t *testing.T) { service, err := NewBotInstanceService(backend.NewSanitizer(mem), clock) require.NoError(t, err) - _, _, err = service.ListBotInstances(ctx, "", 0, "", "", &types.SortBy{ - Field: "test_field", - IsDesc: true, + _, _, err = service.ListBotInstances(ctx, 0, "", &services.ListBotInstancesRequestOptions{ + SortField: "test_field", + SortDesc: false, + }) + require.Error(t, err) + require.ErrorContains(t, err, "unsupported sort, only bot_name field is supported, but got \"test_field\"") + + _, _, err = service.ListBotInstances(ctx, 0, "", &services.ListBotInstancesRequestOptions{ + SortField: "bot_name", + SortDesc: true, }) require.Error(t, err) - require.Equal(t, "unsupported sort, only bot_name:asc is supported, but got \"test_field\" (desc = true)", err.Error()) + require.ErrorContains(t, err, "unsupported sort, only ascending order is supported") - _, _, err = service.ListBotInstances(ctx, "", 0, "", "", nil) + _, _, err = service.ListBotInstances(ctx, 0, "", nil) require.NoError(t, err) } diff --git a/lib/services/presets.go b/lib/services/presets.go index 101a6b8b3a395..ee758795a2493 100644 --- a/lib/services/presets.go +++ b/lib/services/presets.go @@ -212,6 +212,7 @@ func NewPresetEditorRole() types.Role { types.NewRule(types.KindAutoUpdateVersion, RW()), types.NewRule(types.KindAutoUpdateConfig, RW()), types.NewRule(types.KindAutoUpdateAgentRollout, RO()), + types.NewRule(types.KindAutoUpdateBotInstanceReport, RO()), types.NewRule(types.KindGitServer, RW()), types.NewRule(types.KindWorkloadIdentityX509Revocation, RW()), types.NewRule(types.KindHealthCheckConfig, RW()), diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index c351631d8834d..3f4ccd3052ac9 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -1171,7 +1171,13 @@ func (h *Handler) bindDefaultEndpoints() { // GET Machine ID instance for a bot by id h.GET("/webapi/sites/:site/machine-id/bot/:name/bot-instance/:id", h.WithClusterAuth(h.getBotInstance)) // GET Machine ID bot instances (paged) + // TODO(nicholasmarais1158) DELETE IN v20.0.0 + // Replaced by `GET /v2/webapi/sites/:site/machine-id/bot-instance`. h.GET("/webapi/sites/:site/machine-id/bot-instance", h.WithClusterAuth(h.listBotInstances)) + // GET Machine ID bot instances (paged) + h.GET("/v2/webapi/sites/:site/machine-id/bot-instance", h.WithClusterAuth(h.listBotInstancesV2)) + // GET Machine ID bot instance metrics. + h.GET("/webapi/sites/:site/machine-id/bot-instance/metrics", h.WithClusterAuth(h.botInstanceMetrics)) // List workload identities h.GET("/webapi/sites/:site/workload-identity", h.WithClusterAuth(h.listWorkloadIdentities)) diff --git a/lib/web/machineid.go b/lib/web/machineid.go index c2ad81288a5cf..633e6ebfc5791 100644 --- a/lib/web/machineid.go +++ b/lib/web/machineid.go @@ -17,22 +17,28 @@ package web import ( + "cmp" "context" + "fmt" "net/http" "strconv" + "strings" "time" + "github.com/coreos/go-semver/semver" yaml "github.com/ghodss/yaml" "github.com/gravitational/trace" "github.com/julienschmidt/httprouter" "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/fieldmaskpb" + "github.com/gravitational/teleport/api/constants" 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/httplib" "github.com/gravitational/teleport/lib/reversetunnelclient" + "github.com/gravitational/teleport/lib/services" tslices "github.com/gravitational/teleport/lib/utils/slices" ) @@ -418,6 +424,7 @@ func (h *Handler) listBotInstances(_ http.ResponseWriter, r *http.Request, _ htt sort = &s } + //nolint:staticcheck // SA1019. Kept for backward compatibility. instances, err := clt.BotInstanceServiceClient().ListBotInstances(r.Context(), &machineidv1.ListBotInstancesRequest{ FilterBotName: r.URL.Query().Get("bot_name"), PageSize: int32(pageSize), @@ -430,11 +437,7 @@ func (h *Handler) listBotInstances(_ http.ResponseWriter, r *http.Request, _ htt } uiInstances := tslices.Map(instances.BotInstances, func(instance *machineidv1.BotInstance) BotInstance { - latestHeartbeats := instance.GetStatus().GetLatestHeartbeats() - heartbeat := instance.Status.InitialHeartbeat // Use initial heartbeat as a fallback - if len(latestHeartbeats) > 0 { - heartbeat = latestHeartbeats[len(latestHeartbeats)-1] - } + heartbeat := services.GetBotInstanceLatestHeartbeat(instance) uiInstance := BotInstance{ InstanceId: instance.Spec.InstanceId, @@ -458,6 +461,73 @@ func (h *Handler) listBotInstances(_ http.ResponseWriter, r *http.Request, _ htt }, nil } +// listBotInstancesV2 returns a list of bot instances for a given cluster site. +func (h *Handler) listBotInstancesV2(_ http.ResponseWriter, r *http.Request, _ httprouter.Params, sctx *SessionContext, cluster reversetunnelclient.Cluster) (any, error) { + clt, err := sctx.GetUserClient(r.Context(), cluster) + if err != nil { + return nil, trace.Wrap(err) + } + + request := &machineidv1.ListBotInstancesV2Request{ + PageToken: r.URL.Query().Get("page_token"), + SortField: r.URL.Query().Get("sort_field"), + Filter: &machineidv1.ListBotInstancesV2Request_Filters{ + BotName: r.URL.Query().Get("bot_name"), + SearchTerm: r.URL.Query().Get("search"), + Query: r.URL.Query().Get("query"), + }, + } + + if r.URL.Query().Has("page_size") { + pageSize, err := strconv.ParseInt(r.URL.Query().Get("page_size"), 10, 32) + if err != nil { + return nil, trace.BadParameter("invalid page size") + } + request.PageSize = int32(pageSize) + } + + if r.URL.Query().Has("sort_dir") { + sortDir := r.URL.Query().Get("sort_dir") + request.SortDesc = strings.ToLower(sortDir) == "desc" + } + + instances, err := clt.BotInstanceServiceClient().ListBotInstancesV2(r.Context(), request) + if err != nil { + return nil, trace.Wrap(err) + } + + uiInstances := tslices.Map(instances.BotInstances, func(instance *machineidv1.BotInstance) BotInstance { + heartbeat := services.GetBotInstanceLatestHeartbeat(instance) + authentication := services.GetBotInstanceLatestAuthentication(instance) + + uiInstance := BotInstance{ + InstanceId: instance.GetSpec().GetInstanceId(), + BotName: instance.GetSpec().GetBotName(), + } + + if authentication != nil { + uiInstance.JoinMethodLatest = cmp.Or( + authentication.GetJoinAttrs().GetMeta().GetJoinMethod(), + authentication.GetJoinMethod(), + ) + } + + if heartbeat != nil { + uiInstance.HostNameLatest = heartbeat.GetHostname() + uiInstance.VersionLatest = heartbeat.GetVersion() + uiInstance.ActiveAtLatest = heartbeat.GetRecordedAt().AsTime().Format(time.RFC3339) + uiInstance.OSLatest = heartbeat.GetOs() + } + + return uiInstance + }) + + return ListBotInstancesResponse{ + BotInstances: uiInstances, + NextPageToken: instances.NextPageToken, + }, nil +} + type ListBotInstancesResponse struct { BotInstances []BotInstance `json:"bot_instances"` NextPageToken string `json:"next_page_token,omitempty"` @@ -472,3 +542,161 @@ type BotInstance struct { ActiveAtLatest string `json:"active_at_latest,omitempty"` OSLatest string `json:"os_latest,omitempty"` } + +func (h *Handler) botInstanceMetrics(_ http.ResponseWriter, r *http.Request, _ httprouter.Params, sctx *SessionContext, cluster reversetunnelclient.Cluster) (any, error) { + ctx := r.Context() + + clt, err := sctx.GetUserClient(ctx, cluster) + if err != nil { + return nil, trace.Wrap(err) + } + + rsp := BotInstanceMetricsResponse{ + RefreshAfterSeconds: int(constants.AutoUpdateBotInstanceReportPeriod.Seconds()), + } + + // If no report is available yet, `UpgradeStatuses` will be nil. + report, err := clt.GetAutoUpdateBotInstanceReport(ctx) + switch { + case trace.IsNotFound(err): + return rsp, nil + case err != nil: + return nil, trace.Wrap(err) + } + + // Our target version is the operator's selected auto-update tools version, + // or if there isn't one configured: the proxy version. + autoUpdateVersion, err := h.cfg.AccessPoint.GetAutoUpdateVersion(ctx) + if err != nil && !trace.IsNotFound(err) { + return nil, trace.Wrap(err) + } + targetVersion, err := getToolsVersion(autoUpdateVersion) + if err != nil { + return nil, trace.Wrap(err) + } + + // Returns the earliest possible version in a major release. It's based on: + // lib/utils.VersionBeforeAlpha. + lowerBound := func(major int64) semver.Version { + return semver.Version{Major: major, PreRelease: "aa"} + } + + const versionField = "status.latest_heartbeat.version" + + rsp.UpgradeStatuses = &BotInstanceUpgradeStatuses{ + UpdatedAt: report.GetSpec().GetTimestamp().AsTime(), + UpToDate: BotInstanceUpgradeStatus{ + Filter: fmt.Sprintf("%[1]s == %[2]q", versionField, targetVersion), + }, + Unsupported: BotInstanceUpgradeStatus{ + Filter: fmt.Sprintf( + "older_than(%[1]s, %[2]q) || %[1]s == %[3]q || newer_than(%[1]s, %[3]q)", + versionField, + lowerBound(targetVersion.Major-1), + lowerBound(targetVersion.Major+1), + ), + }, + PatchAvailable: BotInstanceUpgradeStatus{ + Filter: fmt.Sprintf( + "between(%[1]s, %[2]q, %[3]q)", + versionField, + lowerBound(targetVersion.Major), + targetVersion, + ), + }, + RequiresUpgrade: BotInstanceUpgradeStatus{ + Filter: fmt.Sprintf( + "between(%[1]s, %[2]q, %[3]q)", + versionField, + lowerBound(targetVersion.Major-1), + lowerBound(targetVersion.Major), + ), + }, + } + + for _, groupMetrics := range report.GetSpec().GetGroups() { + for versionString, versionMetrics := range groupMetrics.GetVersions() { + version, err := semver.NewVersion(versionString) + if err != nil { + h.logger.ErrorContext(ctx, + "Failed to parse bot instance version string", + "version_string", versionString, + "error", err, + ) + continue + } + + switch { + case targetVersion.Equal(*version): + // Bot is up to date. + rsp.UpgradeStatuses.UpToDate.Count += int(versionMetrics.Count) + + case targetVersion.LessThan(*version): + // Bot is running a newer version, we don't support this. + rsp.UpgradeStatuses.Unsupported.Count += int(versionMetrics.Count) + + case targetVersion.Major == version.Major: + // Bot is running the right major version, but there's a minor + // or patch update available + rsp.UpgradeStatuses.PatchAvailable.Count += int(versionMetrics.Count) + + case version.Major == targetVersion.Major-1: + // Bot is running the previous major version and should upgrade. + rsp.UpgradeStatuses.RequiresUpgrade.Count += int(versionMetrics.Count) + + case version.Major < targetVersion.Major-1: + // Bot is running a version that is too old. In this case, the + // connection would be terminated so we shouldn't really see it. + rsp.UpgradeStatuses.Unsupported.Count += int(versionMetrics.Count) + + default: + // The branches of this switch should be exhaustive, but just in case! + h.logger.DebugContext(ctx, + "Bot instance version comparison is missing a branch", + "bot_instance_version", version, + "target_version", targetVersion, + ) + } + } + } + return rsp, nil +} + +type BotInstanceMetricsResponse struct { + // RefreshAfterSeconds is the amount of time (in seconds) after receiving + // this response the client should poll for new metrics. + RefreshAfterSeconds int `json:"refresh_after_seconds"` + + // UpgradeStatuses contains instance counts by "upgrade status". + UpgradeStatuses *BotInstanceUpgradeStatuses `json:"upgrade_statuses"` +} + +type BotInstanceUpgradeStatuses struct { + // UpdatedAt is when these metrics were last updated. + UpdatedAt time.Time `json:"updated_at"` + + // UpToDate means the instance matches the desired version. + UpToDate BotInstanceUpgradeStatus `json:"up_to_date"` + + // Unsupported means the instance is running a release that is too old or + // too new for us to support. + Unsupported BotInstanceUpgradeStatus `json:"unsupported"` + + // RequiresUpgrade means the instance is running a release from the previous + // major series. We can support it for now, but the next major upgrade will + // break compatibility. + RequiresUpgrade BotInstanceUpgradeStatus `json:"requires_upgrade"` + + // PatchAvailable means the instance is running a release from the desired + // major series, but they're behind on a minor or patch release. + PatchAvailable BotInstanceUpgradeStatus `json:"patch_available"` +} + +type BotInstanceUpgradeStatus struct { + // Count is the number of bot instances. + Count int `json:"count"` + + // Filter is a predicate language filter that can be applied to the bot + // instance list to find matching instances. + Filter string `json:"filter"` +} diff --git a/lib/web/machineid_test.go b/lib/web/machineid_test.go index 28f6134467f7f..0644cd0449e82 100644 --- a/lib/web/machineid_test.go +++ b/lib/web/machineid_test.go @@ -17,7 +17,6 @@ package web import ( - "context" "encoding/json" "fmt" "math" @@ -36,14 +35,17 @@ import ( "google.golang.org/protobuf/testing/protocmp" "google.golang.org/protobuf/types/known/timestamppb" + "github.com/gravitational/teleport/api/gen/proto/go/teleport/autoupdate/v1" + 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" + update "github.com/gravitational/teleport/api/types/autoupdate" "github.com/gravitational/teleport/lib/services" "github.com/gravitational/teleport/lib/web/ui" ) func TestListBots(t *testing.T) { - ctx := context.Background() + ctx := t.Context() env := newWebPack(t, 1) proxy := env.proxies[0] pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) @@ -82,7 +84,7 @@ func TestListBots(t *testing.T) { } func TestListBots_UnauthenticatedError(t *testing.T) { - ctx := context.Background() + ctx := t.Context() s := newWebSuite(t) env := newWebPack(t, 1) proxy := env.proxies[0] @@ -121,7 +123,7 @@ func TestCreateBot(t *testing.T) { "bot", ) - ctx := context.Background() + ctx := t.Context() resp, err := pack.clt.PostJSON(ctx, endpoint, CreateBotRequest{ BotName: "test-bot", @@ -179,7 +181,7 @@ func TestCreateBot(t *testing.T) { } func TestCreateBotJoinToken(t *testing.T) { - ctx := context.Background() + ctx := t.Context() env := newWebPack(t, 1) proxy := env.proxies[0] pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) @@ -231,7 +233,7 @@ func TestCreateBotJoinToken(t *testing.T) { } func TestDeleteBot_UnauthenticatedError(t *testing.T) { - ctx := context.Background() + ctx := t.Context() s := newWebSuite(t) env := newWebPack(t, 1) proxy := env.proxies[0] @@ -255,7 +257,7 @@ func TestDeleteBot_UnauthenticatedError(t *testing.T) { func TestDeleteBot(t *testing.T) { botName := "bot-bravo" - ctx := context.Background() + ctx := t.Context() env := newWebPack(t, 1) proxy := env.proxies[0] pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) @@ -290,7 +292,7 @@ func TestDeleteBot(t *testing.T) { } func TestGetBotByName(t *testing.T) { - ctx := context.Background() + ctx := t.Context() env := newWebPack(t, 1) proxy := env.proxies[0] pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) @@ -325,7 +327,7 @@ func TestGetBotByName(t *testing.T) { } func TestEditBot(t *testing.T) { - ctx := context.Background() + ctx := t.Context() env := newWebPack(t, 1) proxy := env.proxies[0] pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) @@ -359,7 +361,7 @@ func TestEditBot(t *testing.T) { } func TestEditBotRoles(t *testing.T) { - ctx := context.Background() + ctx := t.Context() env := newWebPack(t, 1) proxy := env.proxies[0] pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) @@ -415,7 +417,7 @@ func TestEditBotRoles(t *testing.T) { } func TestEditBotTraits(t *testing.T) { - ctx := context.Background() + ctx := t.Context() env := newWebPack(t, 1) proxy := env.proxies[0] pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) @@ -484,7 +486,7 @@ func TestEditBotTraits(t *testing.T) { } func TestEditBotMaxSessionTTL(t *testing.T) { - ctx := context.Background() + ctx := t.Context() env := newWebPack(t, 1) proxy := env.proxies[0] pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) @@ -614,19 +616,28 @@ func TestEditBotDescription(t *testing.T) { func TestListBotInstances(t *testing.T) { t.Parallel() - ctx := context.Background() + ctx := t.Context() env := newWebPack(t, 1) proxy := env.proxies[0] pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) clusterName := env.server.ClusterName() - endpoint := pack.clt.Endpoint( - "webapi", - "sites", - clusterName, - "machine-id", - "bot-instance", - ) - + endpoints := []string{ + pack.clt.Endpoint( + "webapi", + "sites", + clusterName, + "machine-id", + "bot-instance", + ), + pack.clt.Endpoint( + "v2", + "webapi", + "sites", + clusterName, + "machine-id", + "bot-instance", + ), + } instanceID := uuid.New().String() _, err := env.server.Auth().CreateBotInstance(ctx, &machineidv1.BotInstance{ @@ -661,48 +672,83 @@ func TestListBotInstances(t *testing.T) { Os: "linux", }, }, + LatestAuthentications: []*machineidv1.BotInstanceStatusAuthentication{ + { + AuthenticatedAt: ×tamppb.Timestamp{ + Seconds: 2, + Nanos: 0, + }, + }, + { + AuthenticatedAt: ×tamppb.Timestamp{ + Seconds: 1, + Nanos: 0, + }, + }, + { + AuthenticatedAt: ×tamppb.Timestamp{ + Seconds: 2, + Nanos: 0, + }, + JoinMethod: "test-join-method", + }, + }, }, }) require.NoError(t, err) - response, err := pack.clt.Get(ctx, endpoint, url.Values{}) - require.NoError(t, err) - assert.Equal(t, http.StatusOK, response.Code(), "unexpected status code") + for _, endpoint := range endpoints { + t.Run(endpoint, func(t *testing.T) { + response, err := pack.clt.Get(ctx, endpoint, url.Values{}) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, response.Code(), "unexpected status code") - var instances ListBotInstancesResponse - require.NoError(t, json.Unmarshal(response.Bytes(), &instances), "invalid response received") + var instances ListBotInstancesResponse + require.NoError(t, json.Unmarshal(response.Bytes(), &instances), "invalid response received") - assert.Len(t, instances.BotInstances, 1) - require.Empty(t, cmp.Diff(instances, ListBotInstancesResponse{ - BotInstances: []BotInstance{ - { - InstanceId: instanceID, - BotName: "test-bot", - JoinMethodLatest: "test-join-method", - HostNameLatest: "test-hostname", - VersionLatest: "1.0.0", - ActiveAtLatest: "1970-01-01T00:00:03Z", - OSLatest: "linux", - }, - }, - })) + assert.Len(t, instances.BotInstances, 1) + require.Empty(t, cmp.Diff(instances, ListBotInstancesResponse{ + BotInstances: []BotInstance{ + { + InstanceId: instanceID, + BotName: "test-bot", + JoinMethodLatest: "test-join-method", + HostNameLatest: "test-hostname", + VersionLatest: "1.0.0", + ActiveAtLatest: "1970-01-01T00:00:03Z", + OSLatest: "linux", + }, + }, + })) + }) + } } func TestListBotInstancesWithInitialHeartbeat(t *testing.T) { t.Parallel() - ctx := context.Background() + ctx := t.Context() env := newWebPack(t, 1) proxy := env.proxies[0] pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) clusterName := env.server.ClusterName() - endpoint := pack.clt.Endpoint( - "webapi", - "sites", - clusterName, - "machine-id", - "bot-instance", - ) + endpoints := []string{ + pack.clt.Endpoint( + "webapi", + "sites", + clusterName, + "machine-id", + "bot-instance", + ), + pack.clt.Endpoint( + "v2", + "webapi", + "sites", + clusterName, + "machine-id", + "bot-instance", + ), + } instanceID := uuid.New().String() @@ -724,35 +770,66 @@ func TestListBotInstancesWithInitialHeartbeat(t *testing.T) { JoinMethod: "test-join-method", }, LatestHeartbeats: []*machineidv1.BotInstanceStatusHeartbeat{}, + InitialAuthentication: &machineidv1.BotInstanceStatusAuthentication{ + JoinMethod: "test-join-method", + }, }, }) require.NoError(t, err) - response, err := pack.clt.Get(ctx, endpoint, url.Values{}) - require.NoError(t, err) - assert.Equal(t, http.StatusOK, response.Code(), "unexpected status code") + for _, endpoint := range endpoints { + t.Run(endpoint, func(t *testing.T) { + response, err := pack.clt.Get(ctx, endpoint, url.Values{}) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, response.Code(), "unexpected status code") - var instances ListBotInstancesResponse - require.NoError(t, json.Unmarshal(response.Bytes(), &instances), "invalid response received") + var instances ListBotInstancesResponse + require.NoError(t, json.Unmarshal(response.Bytes(), &instances), "invalid response received") - assert.Len(t, instances.BotInstances, 1) - require.Empty(t, cmp.Diff(instances, ListBotInstancesResponse{ - BotInstances: []BotInstance{ - { - InstanceId: instanceID, - BotName: "test-bot", - JoinMethodLatest: "test-join-method", - HostNameLatest: "test-hostname", - VersionLatest: "1.0.0", - ActiveAtLatest: "1970-01-01T00:00:03Z", - }, - }, - })) + assert.Len(t, instances.BotInstances, 1) + require.Empty(t, cmp.Diff(instances, ListBotInstancesResponse{ + BotInstances: []BotInstance{ + { + InstanceId: instanceID, + BotName: "test-bot", + JoinMethodLatest: "test-join-method", + HostNameLatest: "test-hostname", + VersionLatest: "1.0.0", + ActiveAtLatest: "1970-01-01T00:00:03Z", + }, + }, + })) + }) + } } func TestListBotInstancesPaging(t *testing.T) { t.Parallel() + ctx := t.Context() + env := newWebPack(t, 1) + proxy := env.proxies[0] + pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) + clusterName := env.server.ClusterName() + + endpoints := []string{ + pack.clt.Endpoint( + "webapi", + "sites", + clusterName, + "machine-id", + "bot-instance", + ), + pack.clt.Endpoint( + "v2", + "webapi", + "sites", + clusterName, + "machine-id", + "bot-instance", + ), + } + tcs := []struct { name string numInstances int @@ -775,47 +852,42 @@ func TestListBotInstancesPaging(t *testing.T) { }, } - for _, tc := range tcs { - t.Run(tc.name, func(t *testing.T) { - ctx := context.Background() - env := newWebPack(t, 1) - proxy := env.proxies[0] - pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) - clusterName := env.server.ClusterName() - endpoint := pack.clt.Endpoint( - "webapi", - "sites", - clusterName, - "machine-id", - "bot-instance", - ) - - n := 0 - for n < tc.numInstances { - n += 1 - _, err := env.server.Auth().CreateBotInstance(ctx, &machineidv1.BotInstance{ - Kind: types.KindBotInstance, - Version: types.V1, - Spec: &machineidv1.BotInstanceSpec{ - BotName: "bot-1", - InstanceId: uuid.New().String(), - }, - Status: &machineidv1.BotInstanceStatus{}, + for _, endpoint := range endpoints { + t.Run(endpoint, func(t *testing.T) { + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + for n := range tc.numInstances { + _, err := env.server.Auth().CreateBotInstance(ctx, &machineidv1.BotInstance{ + Kind: types.KindBotInstance, + Version: types.V1, + Spec: &machineidv1.BotInstanceSpec{ + BotName: "bot-1", + InstanceId: "instance-" + strconv.Itoa(n), + }, + Status: &machineidv1.BotInstanceStatus{}, + }) + require.NoError(t, err) + } + + response, err := pack.clt.Get(ctx, endpoint, url.Values{ + "page_token": []string{""}, // default to the start + "page_size": []string{strconv.Itoa(tc.pageSize)}, + }) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, response.Code(), "unexpected status code") + + var resp ListBotInstancesResponse + require.NoError(t, json.Unmarshal(response.Bytes(), &resp), "invalid response received") + + assert.Len(t, resp.BotInstances, int(math.Min(float64(tc.numInstances), float64(tc.pageSize)))) + + // remove instances before next test + for n := range tc.numInstances { + err = env.server.Auth().DeleteBotInstance(ctx, "bot-1", "instance-"+strconv.Itoa(n)) + require.NoError(t, err) + } }) - require.NoError(t, err) } - - response, err := pack.clt.Get(ctx, endpoint, url.Values{ - "page_token": []string{""}, // default to the start - "page_size": []string{strconv.Itoa(tc.pageSize)}, - }) - require.NoError(t, err) - assert.Equal(t, http.StatusOK, response.Code(), "unexpected status code") - - var resp ListBotInstancesResponse - require.NoError(t, json.Unmarshal(response.Bytes(), &resp), "invalid response received") - - assert.Len(t, resp.BotInstances, int(math.Min(float64(tc.numInstances), float64(tc.pageSize)))) }) } } @@ -877,18 +949,28 @@ func TestListBotInstancesSorting(t *testing.T) { func TestListBotInstancesWithBotFilter(t *testing.T) { t.Parallel() - ctx := context.Background() + ctx := t.Context() env := newWebPack(t, 1) proxy := env.proxies[0] pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) clusterName := env.server.ClusterName() - endpoint := pack.clt.Endpoint( - "webapi", - "sites", - clusterName, - "machine-id", - "bot-instance", - ) + endpoints := []string{ + pack.clt.Endpoint( + "webapi", + "sites", + clusterName, + "machine-id", + "bot-instance", + ), + pack.clt.Endpoint( + "v2", + "webapi", + "sites", + clusterName, + "machine-id", + "bot-instance", + ), + } n := 0 for n < 5 { @@ -906,21 +988,48 @@ func TestListBotInstancesWithBotFilter(t *testing.T) { require.NoError(t, err) } - response, err := pack.clt.Get(ctx, endpoint, url.Values{ - "bot_name": []string{"bot-1"}, - }) - require.NoError(t, err) - assert.Equal(t, http.StatusOK, response.Code(), "unexpected status code") + for _, endpoint := range endpoints { + t.Run(endpoint, func(t *testing.T) { + response, err := pack.clt.Get(ctx, endpoint, url.Values{ + "bot_name": []string{"bot-1"}, + }) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, response.Code(), "unexpected status code") - var instances ListBotInstancesResponse - require.NoError(t, json.Unmarshal(response.Bytes(), &instances), "invalid response received") + var instances ListBotInstancesResponse + require.NoError(t, json.Unmarshal(response.Bytes(), &instances), "invalid response received") - assert.Len(t, instances.BotInstances, 3) + assert.Len(t, instances.BotInstances, 3) + }) + } } func TestListBotInstancesWithSearchTermFilter(t *testing.T) { t.Parallel() + ctx := t.Context() + env := newWebPack(t, 1) + proxy := env.proxies[0] + pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) + clusterName := env.server.ClusterName() + endpoints := []string{ + pack.clt.Endpoint( + "webapi", + "sites", + clusterName, + "machine-id", + "bot-instance", + ), + pack.clt.Endpoint( + "v2", + "webapi", + "sites", + clusterName, + "machine-id", + "bot-instance", + ), + } + tcs := []struct { name string searchTerm string @@ -969,73 +1078,128 @@ func TestListBotInstancesWithSearchTermFilter(t *testing.T) { }, } - for _, tc := range tcs { - t.Run(tc.name, func(t *testing.T) { - ctx := context.Background() - env := newWebPack(t, 1) - proxy := env.proxies[0] - pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) - clusterName := env.server.ClusterName() - endpoint := pack.clt.Endpoint( - "webapi", - "sites", - clusterName, - "machine-id", - "bot-instance", - ) - - spec := tc.spec - if spec == nil { - spec = &machineidv1.BotInstanceSpec{ - BotName: "test-bot", - InstanceId: "00000000-0000-0000-0000-000000000000", - } + for _, endpoint := range endpoints { + t.Run(endpoint, func(t *testing.T) { + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + spec := tc.spec + if spec == nil { + spec = &machineidv1.BotInstanceSpec{ + BotName: "test-bot", + InstanceId: "00000000-0000-0000-0000-000000000000", + } + } + + _, err := env.server.Auth().CreateBotInstance(ctx, &machineidv1.BotInstance{ + Kind: types.KindBotInstance, + Version: types.V1, + Spec: spec, + Status: &machineidv1.BotInstanceStatus{ + InitialHeartbeat: tc.heartbeat, + }, + }) + require.NoError(t, err) + + _, err = env.server.Auth().CreateBotInstance(ctx, &machineidv1.BotInstance{ + Kind: types.KindBotInstance, + Version: types.V1, + Spec: &machineidv1.BotInstanceSpec{ + BotName: "bot-gone", + InstanceId: uuid.New().String(), + }, + Status: &machineidv1.BotInstanceStatus{ + InitialHeartbeat: &machineidv1.BotInstanceStatusHeartbeat{ + Version: "1.1.1-prod", + Hostname: "test-hostname", + JoinMethod: "test-join-method", + }, + }, + }) + require.NoError(t, err) + + response, err := pack.clt.Get(ctx, endpoint, url.Values{ + "search": []string{tc.searchTerm}, + }) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, response.Code(), "unexpected status code") + + var instances ListBotInstancesResponse + require.NoError(t, json.Unmarshal(response.Bytes(), &instances), "invalid response received") + + assert.Len(t, instances.BotInstances, 1) + assert.Equal(t, "00000000-0000-0000-0000-000000000000", instances.BotInstances[0].InstanceId) + + // remove before next test + err = env.server.Auth().DeleteBotInstance(ctx, spec.BotName, spec.InstanceId) + require.NoError(t, err) + }) } + }) + } +} - _, err := env.server.Auth().CreateBotInstance(ctx, &machineidv1.BotInstance{ - Kind: types.KindBotInstance, - Version: types.V1, - Spec: spec, - Status: &machineidv1.BotInstanceStatus{ - InitialHeartbeat: tc.heartbeat, - }, - }) - require.NoError(t, err) +func TestListBotInstancesWithQueryFilter(t *testing.T) { + t.Parallel() - _, err = env.server.Auth().CreateBotInstance(ctx, &machineidv1.BotInstance{ - Kind: types.KindBotInstance, - Version: types.V1, - Spec: &machineidv1.BotInstanceSpec{ - BotName: "bot-gone", - InstanceId: uuid.New().String(), - }, - Status: &machineidv1.BotInstanceStatus{ - InitialHeartbeat: &machineidv1.BotInstanceStatusHeartbeat{ - Version: "1.1.1-prod", - Hostname: "test-hostname", - JoinMethod: "test-join-method", - }, - }, - }) - require.NoError(t, err) + ctx := t.Context() + env := newWebPack(t, 1) + proxy := env.proxies[0] + pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) + clusterName := env.server.ClusterName() + endpoint := pack.clt.Endpoint( + "v2", + "webapi", + "sites", + clusterName, + "machine-id", + "bot-instance", + ) - response, err := pack.clt.Get(ctx, endpoint, url.Values{ - "search": []string{tc.searchTerm}, - }) - require.NoError(t, err) - assert.Equal(t, http.StatusOK, response.Code(), "unexpected status code") + _, err := env.server.Auth().CreateBotInstance(ctx, &machineidv1.BotInstance{ + Kind: types.KindBotInstance, + Version: types.V1, + Spec: &machineidv1.BotInstanceSpec{ + BotName: "test-bot-1", + InstanceId: "00000000-0000-0000-0000-000000000000", + }, + Status: &machineidv1.BotInstanceStatus{ + InitialHeartbeat: &machineidv1.BotInstanceStatusHeartbeat{ + Hostname: "svr-eu-tel-123-a", + }, + }, + }) + require.NoError(t, err) - var instances ListBotInstancesResponse - require.NoError(t, json.Unmarshal(response.Bytes(), &instances), "invalid response received") + _, err = env.server.Auth().CreateBotInstance(ctx, &machineidv1.BotInstance{ + Kind: types.KindBotInstance, + Version: types.V1, + Spec: &machineidv1.BotInstanceSpec{ + BotName: "test-bot-2", + InstanceId: uuid.New().String(), + }, + Status: &machineidv1.BotInstanceStatus{ + InitialHeartbeat: &machineidv1.BotInstanceStatusHeartbeat{ + Hostname: "test-hostname", + }, + }, + }) + require.NoError(t, err) - assert.Len(t, instances.BotInstances, 1) - assert.Equal(t, "00000000-0000-0000-0000-000000000000", instances.BotInstances[0].InstanceId) - }) - } + response, err := pack.clt.Get(ctx, endpoint, url.Values{ + "query": []string{`status.latest_heartbeat.hostname == "svr-eu-tel-123-a"`}, + }) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, response.Code(), "unexpected status code") + + var instances ListBotInstancesResponse + require.NoError(t, json.Unmarshal(response.Bytes(), &instances), "invalid response received") + + assert.Len(t, instances.BotInstances, 1) + assert.Equal(t, "00000000-0000-0000-0000-000000000000", instances.BotInstances[0].InstanceId) } func TestGetBotInstance(t *testing.T) { - ctx := context.Background() + ctx := t.Context() env := newWebPack(t, 1) proxy := env.proxies[0] pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) @@ -1097,3 +1261,110 @@ func TestGetBotInstance(t *testing.T) { }, protocmp.Transform(), protocmp.IgnoreFields(&machineidv1.BotInstance{}, "metadata"))) assert.YAMLEq(t, fmt.Sprintf("kind: bot_instance\nmetadata:\n name: %[1]s\n revision: %[2]s\nspec:\n bot_name: test-bot\n instance_id: %[1]s\nstatus:\n initial_heartbeat:\n recorded_at: \"1970-01-01T00:00:01Z\"\nversion: v1\n", instanceID, resp.BotInstance.Metadata.Revision), resp.YAML) } + +func TestBotInstanceMetrics_NotFound(t *testing.T) { + ctx := t.Context() + env := newWebPack(t, 1) + pack := env.proxies[0].authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) + clusterName := env.server.ClusterName() + + // No report yet should return an empty `UpgradeStatuses`. + endpoint := pack.clt.Endpoint( + "webapi", "sites", clusterName, "machine-id", "bot-instance", "metrics", + ) + rsp, err := pack.clt.Get(ctx, endpoint, url.Values{}) + require.NoError(t, err) + + var body BotInstanceMetricsResponse + require.NoError(t, json.Unmarshal(rsp.Bytes(), &body)) + require.Nil(t, body.UpgradeStatuses) +} + +func TestBotInstanceMetrics_Success(t *testing.T) { + ctx := t.Context() + env := newWebPack(t, 1) + pack := env.proxies[0].authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) + clusterName := env.server.ClusterName() + + const targetVersion = "19.1.1" + + _, err := env.server.Auth(). + CreateAutoUpdateVersion(ctx, &autoupdate.AutoUpdateVersion{ + Kind: types.KindAutoUpdateVersion, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: types.MetaNameAutoUpdateVersion, + }, + Spec: &autoupdate.AutoUpdateVersionSpec{ + Tools: &autoupdate.AutoUpdateVersionSpecTools{ + TargetVersion: targetVersion, + }, + }, + }) + require.NoError(t, err) + + report, err := update.NewAutoUpdateBotInstanceReport(&autoupdate.AutoUpdateBotInstanceReportSpec{ + Timestamp: timestamppb.New(env.clock.Now()), + Groups: map[string]*autoupdate.AutoUpdateBotInstanceReportSpecGroup{ + "prod": { + Versions: map[string]*autoupdate.AutoUpdateBotInstanceReportSpecGroupVersion{ + "19.1.1": {Count: 1}, // Up to date + "19.2.0": {Count: 10}, // Unsupported (too new) + "19.1.0": {Count: 100}, // Patch available + "19.0.0-rc1": {Count: 1000}, // Patch available + "18.0.0": {Count: 10000}, // Requires upgrade + "17.0.0": {Count: 100000}, // Unsupported (too old) + }, + }, + }, + }) + require.NoError(t, err) + + _, err = env.server.Auth().UpsertAutoUpdateBotInstanceReport(ctx, report) + require.NoError(t, err) + + endpoint := pack.clt.Endpoint( + "webapi", "sites", clusterName, "machine-id", "bot-instance", "metrics", + ) + rsp, err := pack.clt.Get(ctx, endpoint, url.Values{}) + require.NoError(t, err) + + var body BotInstanceMetricsResponse + require.NoError(t, json.Unmarshal(rsp.Bytes(), &body)) + + // Up to date + require.Equal(t, + BotInstanceUpgradeStatus{ + Count: 1, + Filter: `status.latest_heartbeat.version == "19.1.1"`, + }, + body.UpgradeStatuses.UpToDate, + ) + + // Unsupported + require.Equal(t, + BotInstanceUpgradeStatus{ + Count: 100010, + Filter: `older_than(status.latest_heartbeat.version, "18.0.0-aa") || status.latest_heartbeat.version == "20.0.0-aa" || newer_than(status.latest_heartbeat.version, "20.0.0-aa")`, + }, + body.UpgradeStatuses.Unsupported, + ) + + // Patch available + require.Equal(t, + BotInstanceUpgradeStatus{ + Count: 1100, + Filter: `between(status.latest_heartbeat.version, "19.0.0-aa", "19.1.1")`, + }, + body.UpgradeStatuses.PatchAvailable, + ) + + // Requires upgrade + require.Equal(t, + BotInstanceUpgradeStatus{ + Count: 10000, + Filter: `between(status.latest_heartbeat.version, "18.0.0-aa", "19.0.0-aa")`, + }, + body.UpgradeStatuses.RequiresUpgrade, + ) +} diff --git a/rfd/0222-bot-instances-at-scale.md b/rfd/0222-bot-instances-at-scale.md new file mode 100644 index 0000000000000..e43107f03b069 --- /dev/null +++ b/rfd/0222-bot-instances-at-scale.md @@ -0,0 +1,425 @@ +--- +authors: Nick Marais (nicholas.marais@goteleport.com), Dan Upton (dan.upton@goteleport.com) +state: draft +--- + +# RFD 0222 - Bot Instances at Scale + +## Required Approvers + +- Engineering: @strideynet +- Product: @thedevelopnik + +# RFD 0222 - Bot Instances at Scale + +# What + +Machine ID bots allow non-human users to access resources in a Teleport cluster. They are useful for automating tasks, such as CI/CD runners, monitoring systems, and running scripts. The bot resource encapsulates a bot's access rights and roles. The `tbot` binary is used to start an instance of a bot and enrol it with the cluster (using a join token). When `tbot` starts and receives its credentials, a bot instance record is created in the cluster. Bot instance records expire when their credentials expire. Bot instances can be long-running processes which periodically renew their credentials, or short-lived, ephemeral processes which are created and destroyed on demand. Many instances of the same bot can exist at once, and clusters can have many bot instances. `tbot` periodically sends heartbeats which contain info such as uptime, hostname, tbot version and join method. Heartbeats are stored as part of the bot instance resource, and are limited to the 10 most recent. + +Today, the highest number of active bot instances is around 550 across 40 bots with most being long-running workloads. In general, 95% of Teleport clusters have fewer than 50 bot instances. + +This proposal seeks to address the pain points of deploying and maintaining a large fleet of Machine ID bots. It will focus solely on the Day 1 experience, and users are expected to be familiar with Teleport in general as well as the config and setup of their respective clusters. Day 0 tutorialization of setup steps and guided beginner scenarios are left to other initiatives. + +# Why + +As adoption of Machine & Workload ID increases, in part due to the drive to increase efficiency through automation as well as trends like Agentive AI, customers expect managing large fleets of bots to be simple and easy. + +The current bot instance list in the Web UI is suitable for the today's usage and future expansion and is scalable in terms of performance, but does not cater for the UX challenge of scaling. Currently, bot instances are paginated, sorted by either recency (default) or bot name, and can be filtered by basic text search term. + +It’s the responsibility of the **infrastructure security team** to own and manage the Teleport cluster and enrol protected resources. For teams which make extensive use of Machine ID, it has become cumbersome to manage many bots and bot instances. Where Dev/Dev Ops teams deploy bot instances themselves, it can be doubly difficult to coordinate upgrades and security initiatives. + +# Details + +## UX + +### User Stories + +**As a cluster owner (Infrastructure Security team), I want to know which Bot Instances, across all Bots, are blocking a safe cluster upgrade (major) due to their version.** + +The upgrade process can vary depending on the flavour of Teleport in use (cloud, oss, etc), and how it’s deployed. A common step is to query for agents running a version which would become incompatible should an upgrade be done - using `tctl inventory ls --older-than=v18.0.0`. This command does not include bot instances, and `tctl bots instances ls` doesn’t return versions numbers for instances. + +As such, it is a difficult task to identify bot instances that may be running an old version of `tbot`. This is especially difficult at scale. The current bot instance list available in the web UI allows filtering by version, although it’s a text search and it is not aware of semantic versioning - finding versions older than a given version is not possible. + +A breakdown of active instance versions will make the process of monitoring the version status easy at a glance, as well as provide convenient links to filter the instance list for versions already one or more major versions behind the control plane (thereby preventing a safe upgrade). To facilitate this in the web UI, the filter will allow queries such as `older_than(version, "18.1.0")`. The instance list will also indicate the status of an instance’s most recent version (as up-to-date, upgrade available, patch available, or incompatible). For the CLI, the `query` flag can be used to filter instances (e.g. `tctl bots instances ls --query=older_than(version, "18.1.0")`). + +**As a cluster owner (Infrastructure Security team), I want to know which Bot Instances, across all Bots, are running vulnerable versions.** + +Currently in the web UI the instances list can be filtered by version, but this is a text search and it is not aware of semantic versioning. It’s possible to find a specific version number, but it’s not easy to isolate a range of versions, such as “>18 & <18.2.1”, which is likely required to find instances between a vulnerable and patched version. + +To support this use-case, the filter for bot instances will support the predicate language and allow queries such as `version.between("18.0.0", "18.1.0")`. This works though the web UI and the CLI (`tctl`). + +**As a Bot Instance owner (Dev/Dev Ops team), I'd like help in understanding why my Bot Instance is not working properly.** + +For somebody diagnosing an issue with `tbot`, they’re likely to have access to the `tbot` log output. Such as; + +``` +INFO [TBOT:IDENTITY] Fetched new bot identity identity:mwi-demo-aws-manager, id=5c6af2e6-13a4-48c1-855f-74d8b8e01d86 | valid: after=2025-08-21T12:10:15Z, before=2025-08-21T12:31:13Z, duration=20m58s | kind=tls, renewable=false, disallow-reissue=false, roles=[bot-mwi-demo-aws-manager], principals=[-teleport-internal-join], generation=1 tbot/service_bot_identity.go:224 +``` + +This log entry contains the bot name (as `identity`) and the instance’s ID. The instance ID can be used to filter the instances list in the web UI, and should make finding the relevant instance easy. In the CLI (`tctl`), both the bot name and instance ID are required to perform the look-up; `tctl bots instances show [bot_name]/[instance_id]`. + +Once found, in the web UI or CLI, the instance's details can be seen. Here a health status can be found for each `tbot` service (outputs, tunnels, etc), which includes failure info for those services which are unhealthy. + +### Instances dashboard + +This mock-up shows the bot instance page as it would be when first arriving at the page. No filters are set, so all instances are available in the list. Results are paginated, and the default sort order is by recency - instances with the most recent heartbeat are displayed first. Sorting can be toggled between ascending and descending, and the following sort fields are available; bot, recency, version, hostname. Filtering can be performed using a basic text search over supported fields, or an advanced search using the Teleport predicate language. + +The right-hand half of the page displays the dashboard, which is a summary over all instances. The visualizations use aggregate data prepared and updated by the auth server. An indication of when the data current is provided, as well as a refresh button which retrieves the most recently available data. A selection of timeframes is also available. + +The Upgrade Status visualization show a summary of all instances grouped by upgrade status; up-to-date, upgrade available, patch available, or incompatible. Status labels are selectable, and will populate the advanced search with the appropriate filter. For example, if the auth server is running v18 the filter will be populated with `older_than(version, "16.0.0")` when a user selects "not supported". + +![](assets/0222-dashboard.png) + +### Instance details +These mock-ups shows the state of the page once an item had been selected from the instances list by clicking it. The dashboard section is replaced by the selected instance's details. + +The overview tab is displayed by default when an instance is selected. It shows further information about the instance, the join token that was used to enrol, and a summary of service health. + +![](assets/0222-details-overview.png) + +The services tab shows a list of all configured services (or outputs). Each includes it's name, type and health status. If there is extra health info available (such as and error message), this is also displayed. + +![](assets/0222-details-services.png) + +The YAML tab show the raw resource as readonly YAML. + +![](assets/0222-details-yaml.png) + +### tctl bots instances ls --search [term] --query [tql] + +The list bot instances command will include extra information about each instance; version and health status. A search term (`--search` flag) or advanced query (`--query` flag) can be used to filter the results. All instances are displayed, by fetching all available pages. + +```diff +- ID Join Method Hostname Joined Last Seen Generation +- ------------------------------------------ ----------- ------------- -------------------- -------------------- ---------- +- bot-1/d83b381d-b46c-4b92-a899-755991a6d0f5 iam ip-10-0-15-34 2025-08-29T06:09:26Z 2025-09-01T12:49:26Z 237 +- ++ ID Join Method Version Hostname Status Last Seen ++ ------------------------------------------ ----------- ------- ------------- --------- -------------------- ++ bot-1/d83b381d-b46c-4b92-a899-755991a6d0f5 iam v18.2.1 ip-10-0-15-34 UNHEALTHY 2025-09-01T12:49:26Z ++ +To view more information on a particular instance, run: + +> /Users/example/.tsh/bin/tctl bots instances show [id] +``` + +### tctl bots instances show [id] + +The show bot instance command gives an overall health summary as well as a listing of services and their respective health status. + +```diff +Bot: w2w-demo-app-bot +ID: d83b381d-b46c-4b92-a899-755991a6d0f5 ++ Status: UNHEALTHY + +Initial Authentication: + Authenticated At: 2025-08-29T06:09:26Z + Join Method: iam + Join Token: w2w-demo-web-bot + Join Metadata: meta:{join_token_name:"w2w-demo-web-bot" join_method:"iam"} iam:{account:"668558765449" arn:"arn:aws:sts::668558765449:assumed-role/MWIw2wDemoInstance/i-0b7667843950debfd"} + Generation: 1 + Public Key: <178 bytes> + +Latest Authentication: + Authenticated At: 2025-09-01T12:49:26Z + Join Method: iam + Join Token: w2w-demo-web-bot + Join Metadata: meta:{join_token_name:"w2w-demo-web-bot" join_method:"iam"} iam:{account:"668558765449" arn:"arn:aws:sts::668558765449:assumed-role/MWIw2wDemoInstance/i-0b7667843950debfd"} + Generation: 237 + Public Key: <178 bytes> + +Latest Heartbeat: + Recorded At: 2025-09-01T12:39:26Z + Is Startup: false + Version: 18.1.5 + Hostname: ip-10-0-15-34 + Uptime: 78h30m0.539099441s + Join Method: iam + One Shot: false + Architecture: arm64 + OS: linux ++ ++ Service status: ++ Status Name Type Reason Updated At ++ --------- ----------- ------------------- -------------- -------------------- ++ UNHEALTHY prod-aws-01 X509-output-service out of bananas 2025-09-01T12:49:26Z + +To view a full, machine-readable record including past heartbeats and authentication records, run: +> /Users/example/.tsh/bin/tctl get bot_instance/w2w-demo-app-bot/d83b381d-b46c-4b92-a899-755991a6d0f5 + +To onboard a new instance for this bot, run: +> /Users/example/.tsh/bin/tctl bots instances add w2w-demo-app-bot +``` + +### Predicate language for instance filters + +The predicate language will be used to provide advanced filtering for instances. The filter query will be applied in the same way the existing filters work, and no changes to indexes are required. As items are read out of the backend storage, they are filtered one by one until the page size is reached or the end of the list. For a narrow filter, many or even all records will be scanned - this inefficiency is mitigated by the in-memory caching layer's performance. + +Instance-specific functions will be supported by implementing a custom `typical.ParserSpec`; + +| Function | Purpose | Example | +| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | +| **older_than** | Find instances running a version less than a given version - based on the most recent heartbeat | `older_than(version, "18.1.0")` | +| **newer_than** | Find instances running a version more than a given version - based on the most recent heartbeat | `newer_than(version, "18.1.0")` | +| **between** | Find instances running versions between a vulnerable version and a fix version - based on the most recent heartbeat. Inclusive of from and exclusive of to. | `between(version, "18.0.0", "18.1.0")` | + +## Service/Output health + +Service health records for each configured `tbot` service/output will be sent with each heartbeat. These records are stored within the bot instance resource alongside heartbeats and authentications. + +Each service record includes the name of the configured service or output, a timestamp, it's health status (such as healthy, unhealthy, initializing or unknown), a timestamp, and an optional reason which may include an error message or stack trace. + +Any values included which are user-provided or dynamically generated based on errors or external state (namely the configured name and reason fields) will be limited in length to prevent uncapped data submissions. + +## Version Metrics + +We will periodically pre-calculate the number of bot instances per version, to power the Upgrade Status section of the dashboard. + +To aid future compatibility with Managed Updates, version counts will be written to the backend as an `AutoUpgradeBotInstanceReport` resource, with the following schema: + +```proto +// AutoUpdateBotInstanceReport is a report generated by an elected instance of +// the Teleport Auth service. The report tracks per update group and per version +// how many instances of tbot are running. +message AutoUpdateBotInstanceReport { + // The kind of resource represented. This is always `autoupdate_bot_instance_report`. + string kind = 1; + + // Differentiates variations of the same kind. All resources should contain + // one, even if it is never populated. + string sub_kind = 2; + + // The version of the resource being represented. + string version = 3; + + // Common metadata that all resources share. + teleport.header.v1.Metadata metadata = 4; + + // Contents of the report. + AutoUpdateBotInstanceReportSpec spec = 5; +} + +// AutoUpdateBotInstanceReportSpec holds the contents of an AutoUpdateBotInstanceReport. +message AutoUpdateBotInstanceReportSpec { + // Timestamp is when the report was generated. + google.protobuf.Timestamp timestamp = 1; + + // Bot counts aggregated by update group. + map groups = 2; +} + +// AutoUpdateBotInstanceReportSpecGroup holds an update group's bot instance +// counts. +message AutoUpdateBotInstanceReportSpecGroup { + // Bot counts aggregated by version. + map versions = 1; +} + +// AutoUpdateBotInstanceReportSpecGroupVersion holds a version's bot instance count. +message AutoUpdateBotInstanceReportSpecGroupVersion { + // Count of bots running this version. + int32 count = 1; +} +``` + +As `tbot` is not yet fully supported by `teleport-update`, we will add the necessary fields (e.g. `types.UpdaterV2Info`) to the bot heartbeat message, but leave them unpopulated. In reports, bot instances will be grouped together in the "unmanaged" update group (identified as an empty string) until `tbot` gains support and starts heartbeating a proper update group. + +Whereas the existing `AutoUpgradeAgentReport` resource is generated by each auth server instance by counting its connected agents, the `AutoUpgradeBotInstanceReport` will be generated from cluster state by a **single** instance, elected using a semaphore object. + +We will also expose these metrics to Teleport Cloud using a Prometheus gauge called `teleport_bot_instances`, labeled by version. Each auth server instance will report the same number, so operators will need to aggregate them using `max` rather than `sum` like the current `teleport_registered_servers` gauge. + +## Data aggregation + +To power the Upgrade Status section of the bot instance dashboard, we will pre-aggregate the following metrics; number of instances per version. Metrics will be stored in memory. + +As these metrics are intended to provide a rough overview of fleet health, and do not need to be strictly up-to-date, we will recalculate them on a timer (e.g. every 10 minutes). This is simpler, and in many cases likely more efficient than updating them incrementally by consuming the event stream. + +To avoid the need to "elect" a leader to calculate these metrics, each auth server instance will calculate them independently. Users therefore may see the numbers change if they refresh the dashboard, but this is an acceptable trade-off. + +## Data fields and expected quantities + +| Field | Description | Example | Quantity | Limitations | +| --------------------- | -------------------------------------------------------------------------------------------- | --------------------------------------------- | -------------------------------- | --------------------------------------------------------------------------------------------- | +| Bot | A collection of roles and access assumed by `tbot` using a join token | | 0-40+ per cluster | | +| Bot instance | A unique joined instance of `tbot` in either a long-running or ephemeral environment | | 1-15+ per bot | | +| Authentication record | Created for each join or renewal | | 0-10 per instance (max enforced) | | +| Instance heartbeat | Self-reported by each bot instance | | 0-10 per instance (max enforced) | Data is **not** validated by the auth server, and cannot be used for making access decisions. | +| Service health | An independent, internal part of `tbot`. Generally maps 1:1 with configured outputs/tunnels. | `application-tunnel`, `workload-identity-api` | 1-30 per instance (max enforced) | | +| OS | Operating system from `runtime.GOOS` | linux, windows or darwin | Once per heartbeat | | +| Version | Version of `tbot` | 18.1.0 | Once per heartbeat | | +| Hostname | | | Once per heartbeat | | +| Uptime | How long `tbot` has been running | | Once per heartbeat | | +| Join token name | | | Once per auth | | +| Join method | | github, iam, kubernetes | Once per auth | | +| Join attributes | Metadata specific to a join method | GitHub repository name | Once per auth | | +| Health status | | INITIALIZING, HEALTHY, UNHEALTHY, UNKNOWN | Once per service health | | + +## Resource storage (backend) + +This proposal adds service health to bot instances. Service health scales with the number of services/outputs `tbot` is configured with. It doesn't make sense to limit the number of service records, as this would no longer provide a complete picture of the instance. As such, if there are more configured services than a maximum, then `tbot` will send none and raise a warning the the logs. Service health records for an instance will be cleared when the instance starts-up (denoted by the heartbeat field `is_startup`). Service health records will be stored as part of an instance's state, alongside heartbeats and authentications. Service health records may be used for filtering the list of bot instance in the web UI and CLI in the future, and so will remain local to the instance itself. + +To mitigate the risk of service degradation related to storing and serving additional data as part of `tbot` heartbeats (service health), the auth server will respect an environment variable which will disable the ingestion of this additional data. + +``` +TELEPORT_DISABLE_TBOT_HEARTBEAT_EXTRAS=true|false teleport start +``` + +While `tbot` will continue to send the extra heartbeat data, and it will continue to be relayed by proxies, the auth server will discard it. The UI and CLI will not be aware of this configuration and will simply receive no service health data when requesting bot instance/s and display an empty state. + +## Proto changes + +### RPC: `SubmitHeartbeat()` + +```protobuf +// BotInstanceService provides functions to record and manage bot instances. +service BotInstanceService { + // SubmitHeartbeat submits a heartbeat for a BotInstance. + rpc SubmitHeartbeat(SubmitHeartbeatRequest) returns (SubmitHeartbeatResponse); +} + +// The request for SubmitHeartbeat. +message SubmitHeartbeatRequest { + // The heartbeat data to submit. + BotInstanceStatusHeartbeat heartbeat = 1; + + // The health of the services/output `tbot` is running. + repeated BotInstanceServiceHealth service_health = 2; +} +``` + +### Bot instance + +```protobuf +// BotInstanceStatus holds the status of a BotInstance. +message BotInstanceStatus { + // ...[snip]... + + // The health of the services/output `tbot` is running. + repeated BotInstanceServiceHealth service_health = 6; +} +``` + +### Bot instance heartbeat + +```protobuf +message BotInstanceStatusHeartbeat { + // ...[snip]... + + // Kind identifies whether the bot is running in the tbot binary or embedded + // in another component. + BotKind kind = 12; +} + +// BotKind identifies whether the bot is the tbot binary or embedded in another +// component. +enum BotKind { + // The enum zero-value, it means no kind was included. + BOT_KIND_UNSET = 0; + + // Means the bot is running the tbot binary. + BOT_KIND_TBOT_BINARY = 1; + + // Means the bot is running inside the Teleport Terraform provider. + BOT_KIND_TERRAFORM_PROVIDER = 2; + + // Means the bot is running inside the Teleport Kubernetes operator. + BOT_KIND_KUBERNETES_OPERATOR = 3; +} +``` + +### Bot instance service health + +```protobuf +// BotInstanceServiceHealth is a snapshot of a `tbot` service's health. +message BotInstanceServiceHealth { + // Service identifies the service. + BotInstanceServiceIdentifier service = 1; + + // Status describes the service's healthiness. + BotInstanceHealthStatus status = 2; + + // Reason is a human-readable explanation for the service's status. It might + // include an error message. + optional string reason = 3; + + // UpdatedAt is the time at which the service's health last changed. + google.protobuf.Timestamp updated_at = 4; +} + +// BotInstanceServiceIdentifier uniquely identifies a `tbot` service. +message BotInstanceServiceIdentifier { + // Type of service (e.g. database-tunnel, ssh-multiplexer). + string type = 1; + + // Name of the service, either given by the user or auto-generated. + string name = 2; +} + +// BotInstanceHealthStatus describes the healthiness of a `tbot` service. +enum BotInstanceHealthStatus { + // The enum zero-value, it means no status was included. + BOT_INSTANCE_HEALTH_STATUS_UNSPECIFIED = 0; + + // Means the service is still "starting up" and hasn't reported its status. + BOT_INSTANCE_HEALTH_STATUS_INITIALIZING = 1; + + // Means the service is healthy and ready to serve traffic, or it has + // recently succeeded in generating an output. + BOT_INSTANCE_HEALTH_STATUS_HEALTHY = 2; + + // Means the service is failing to serve traffic or generate output. + BOT_INSTANCE_HEALTH_STATUS_UNHEALTHY = 3; +} +``` + +## Web API + +| Endpoint | Description | +| ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **GET /v2/webapi/sites/:site/machine-id/bot-instance** | A new version of an existing endpoint with a `query` parameter added to accept a string query in the Teleport predicate language (e.g. `older_than(version, 18.1)`) which is used to filter returned instances. | +| **GET /v2/webapi/sites/:site/machine-id/bot/:name/bot-instance/:id** | A new version of an existing endpoint which includes the new fields on bot instance (such as service health). | +| **GET /webapi/:site/machine-id/bot-instance/dashboard** | A new endpoint to return summary data for bot instances. The result will contain multiple named datasets (one for each supported visualization). A “last updated at” timestamp will be included to give users a sense of recency. | + +## Privacy and Security + +The proposed changes are mainly capturing extra data and presenting it in the web UI and CLI. As such, there are not security and privacy concerns that need to be highlighted. + +## Backward Compatibility + +None of the proposed changes are backwards incompatible, nor are any migrations required. Older CLI clients will continue to function as they did, with any extra data being ignored. Older `tbot` versions will continue to be supported, but will not submit service health - the absence of this data will be supported by the web UI and CLI clients as well as the APIs they interact with. + +Where V2 versions of a webapi are added, the following backwards compatibility behaviour applies; In the situation where a new web client sends a request to an old proxy (in a load balanced setup), the old proxy will not host the new endpoint and will return a 404 and the proxy’s version. A helpful message is then displayed to the user advising that the proxy needs to be upgraded to support the operation. + +## Test Plan + +Addition after a Machine ID bot is created and an instance enrolled: "In the UI, a single Bot Instance record shows, with a heartbeat and an authentication record." and "In the CLI, using `tctl bot instance show [bot name]/[instance id]`, a single Bot Instance record shows, with a heartbeat and an authentication record.". + +# Delivery phases + +## Phase 1 + +**tl;dr**: new instance UI, and version filters + +**Backports**: v17 and v18 + +In this phase we’ll focus on the requirements to manage the versions of bot instances at scale. This includes filtering for instances running a version that prevents an upgrade, as well as instances running a compromised version. + +The instances list from the bot details will be reused with minimal changes (except the addition of bot name), and the details view will simply house the full instance yaml (including auth records and heartbeats). This lays the UI foundation for the following phases. + +The search field will get an ‘advanced’ mode where the Teleport predicate language can be used to filter items using semver-aware functions (such as `older_than` and `newer_than_or_equal`). + +An additional sort, by version, will also be included. + +## Phase 2 + +**tl;dr**: upgrades dashboard, and service-level health + +**Backports**: v17 and v18 + +This phase adds a dashboard which will provide a high-level overview of the MWI estate, and is invaluable when dealing with a large fleet. This phase will provide the groundwork for data aggregation and reporting, and will pave the way for future visualizations. + +A visual breakdown of instance upgrade status will be added to allow cluster maintainers to easily judge the number of instances with upgrades required/available, and to identify those instances. + +Additionally, this phase focuses on the requirements of the dev/dev-ops teams, who deploy instances of `tbot`. It includes features that enable easy confirmation of `tbot` running correctly, as well as tools to help troubleshoot issues. + +Giving users access to fine-grained health statuses (for each service/output) will help pinpoint areas where problems lie. diff --git a/rfd/assets/0222-dashboard.png b/rfd/assets/0222-dashboard.png new file mode 100644 index 0000000000000..7aa1c65d5e57f Binary files /dev/null and b/rfd/assets/0222-dashboard.png differ diff --git a/rfd/assets/0222-details-overview.png b/rfd/assets/0222-details-overview.png new file mode 100644 index 0000000000000..9ff9e2c3d704a Binary files /dev/null and b/rfd/assets/0222-details-overview.png differ diff --git a/rfd/assets/0222-details-services.png b/rfd/assets/0222-details-services.png new file mode 100644 index 0000000000000..ed102279fcafe Binary files /dev/null and b/rfd/assets/0222-details-services.png differ diff --git a/rfd/assets/0222-details-yaml.png b/rfd/assets/0222-details-yaml.png new file mode 100644 index 0000000000000..adaca4a2d0152 Binary files /dev/null and b/rfd/assets/0222-details-yaml.png differ diff --git a/rfd/cspell.json b/rfd/cspell.json index 46cf7f5fe37c1..8c1196c021f86 100644 --- a/rfd/cspell.json +++ b/rfd/cspell.json @@ -382,6 +382,7 @@ "managedclusters", "mant", "Mapred", + "Marais", "marcoandredinis", "marcodbtest", "Marek", @@ -701,6 +702,7 @@ "stretchr", "strideynet", "struct", + "structpb", "structs", "stscreds", "subcases", @@ -783,6 +785,7 @@ "tshd", "ttyplayback", "tunables", + "tutorialization", "udner", "uintptr", "unbeknowst", diff --git a/tool/tctl/common/bots_command.go b/tool/tctl/common/bots_command.go index 517aa47a284da..f2fd47054697d 100644 --- a/tool/tctl/common/bots_command.go +++ b/tool/tctl/common/bots_command.go @@ -29,26 +29,30 @@ import ( "maps" "os" "path/filepath" + "slices" "strings" "text/template" "time" "github.com/alecthomas/kingpin/v2" + "github.com/fatih/color" "github.com/google/uuid" "github.com/gravitational/trace" "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/fieldmaskpb" "github.com/gravitational/teleport" + "github.com/gravitational/teleport/api/client/proto" "github.com/gravitational/teleport/api/constants" headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" machineidv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1" "github.com/gravitational/teleport/api/mfa" "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/api/utils/clientutils" "github.com/gravitational/teleport/lib/asciitable" - "github.com/gravitational/teleport/lib/auth/authclient" "github.com/gravitational/teleport/lib/auth/machineid/machineidv1" "github.com/gravitational/teleport/lib/defaults" + "github.com/gravitational/teleport/lib/itertools/stream" "github.com/gravitational/teleport/lib/service/servicecfg" "github.com/gravitational/teleport/lib/utils" commonclient "github.com/gravitational/teleport/tool/tctl/common/client" @@ -73,6 +77,12 @@ type BotsCommand struct { addLogins string setLogins string + search string + query string + + sortIndex string + sortOrder string + botsList *kingpin.CmdClause botsAdd *kingpin.CmdClause botsRemove *kingpin.CmdClause @@ -126,6 +136,11 @@ func (c *BotsCommand) Initialize(app *kingpin.Application, _ *tctlcfg.GlobalCLIF c.botsInstancesList = c.botsInstances.Command("list", "List bot instances.").Alias("ls") c.botsInstancesList.Arg("name", "The name of the bot from which to list instances. If unset, lists instances from all bots.").StringVar(&c.botName) + c.botsInstancesList.Flag("format", "Output format, 'text' or 'json'").Default(teleport.Text).EnumVar(&c.format, teleport.Text, teleport.JSON) + c.botsInstancesList.Flag("search", "Fuzzy search query used to filter bot instances").StringVar(&c.search) + c.botsInstancesList.Flag("query", "An expression in the Teleport predicate language used to filter bot instances").StringVar(&c.query) + c.botsInstancesList.Flag("sort-index", "Request sort index, 'bot_name', 'active_at_latest', 'version_latest' or 'host_name_latest'").Default("bot_name").StringVar(&c.sortIndex) + c.botsInstancesList.Flag("sort-order", "Request sort order, 'ascending' or 'descending'").Default("ascending").StringVar(&c.sortOrder) c.botsInstancesAdd = c.botsInstances.Command("add", "Join a new instance onto an existing bot.").Alias("join") c.botsInstancesAdd.Arg("name", "The name of the existing bot for which to add a new instance.").Required().StringVar(&c.botName) @@ -138,8 +153,8 @@ func (c *BotsCommand) Initialize(app *kingpin.Application, _ *tctlcfg.GlobalCLIF } // TryRun attempts to run subcommands. -func (c *BotsCommand) TryRun(ctx context.Context, cmd string, clientFunc commonclient.InitFunc) (match bool, err error) { - var commandFunc func(ctx context.Context, client *authclient.Client) error +func (c *BotsCommand) TryRun(ctx context.Context, cmd string, clientFunc commonclient.InitFunc) (bool, error) { + var commandFunc func(ctx context.Context, client botsCommandClient) error switch cmd { case c.botsList.FullCommand(): commandFunc = c.ListBots @@ -170,9 +185,22 @@ func (c *BotsCommand) TryRun(ctx context.Context, cmd string, clientFunc commonc return true, trace.Wrap(err) } +type botsCommandClient interface { + BotServiceClient() machineidv1pb.BotServiceClient + BotInstanceServiceClient() machineidv1pb.BotInstanceServiceClient + + GetToken(ctx context.Context, name string) (types.ProvisionToken, error) + UpsertToken(ctx context.Context, token types.ProvisionToken) error + GetUser(ctx context.Context, name string, withSecrets bool) (types.User, error) + GetRole(context.Context, string) (types.Role, error) + UpsertLock(ctx context.Context, lock types.Lock) error + GetProxies() ([]types.Server, error) + PerformMFACeremony(ctx context.Context, in *proto.CreateAuthenticateChallengeRequest, promptOpts ...mfa.PromptOpt) (*proto.MFAAuthenticateResponse, error) +} + // ListBots writes a listing of the cluster's certificate renewal bots // to standard out. -func (c *BotsCommand) ListBots(ctx context.Context, client *authclient.Client) error { +func (c *BotsCommand) ListBots(ctx context.Context, client botsCommandClient) error { var bots []*machineidv1pb.Bot req := &machineidv1pb.ListBotsRequest{} for { @@ -255,7 +283,7 @@ Please note: `)) // AddBot adds a new certificate renewal bot to the cluster. -func (c *BotsCommand) AddBot(ctx context.Context, client *authclient.Client) error { +func (c *BotsCommand) AddBot(ctx context.Context, client botsCommandClient) error { // Prompt for admin action MFA if required, allowing reuse for UpsertToken and CreateBot. mfaResponse, err := mfa.PerformAdminActionMFACeremony(ctx, client.PerformMFACeremony, true /*allowReuse*/) if err == nil { @@ -344,7 +372,7 @@ func (c *BotsCommand) AddBot(ctx context.Context, client *authclient.Client) err return trace.Wrap(outputToken(c.stdout, c.format, client, bot, token)) } -func (c *BotsCommand) RemoveBot(ctx context.Context, client *authclient.Client) error { +func (c *BotsCommand) RemoveBot(ctx context.Context, client botsCommandClient) error { _, err := client.BotServiceClient().DeleteBot(ctx, &machineidv1pb.DeleteBotRequest{ BotName: c.botName, }) @@ -357,7 +385,7 @@ func (c *BotsCommand) RemoveBot(ctx context.Context, client *authclient.Client) return nil } -func (c *BotsCommand) LockBot(ctx context.Context, client *authclient.Client) error { +func (c *BotsCommand) LockBot(ctx context.Context, client botsCommandClient) error { lockExpiry, err := computeLockExpiry(c.lockExpires, c.lockTTL) if err != nil { return trace.Wrap(err) @@ -459,14 +487,9 @@ func (c *BotsCommand) updateBotLogins(ctx context.Context, bot *machineidv1pb.Bo return trace.Wrap(mask.Append(&machineidv1pb.Bot{}, "spec.traits")) } -// clientRoleGetter is a minimal mockable interface for the client API -type clientRoleGetter interface { - GetRole(context.Context, string) (types.Role, error) -} - // updateBotRoles applies updates from CLI arguments to a bot's roles, updating // the field mask as necessary if any updates were made. -func (c *BotsCommand) updateBotRoles(ctx context.Context, client clientRoleGetter, bot *machineidv1pb.Bot, mask *fieldmaskpb.FieldMask) error { +func (c *BotsCommand) updateBotRoles(ctx context.Context, client botsCommandClient, bot *machineidv1pb.Bot, mask *fieldmaskpb.FieldMask) error { currentRoles := make(map[string]struct{}) for _, role := range bot.Spec.Roles { currentRoles[role] = struct{}{} @@ -510,7 +533,7 @@ func (c *BotsCommand) updateBotRoles(ctx context.Context, client clientRoleGette } // UpdateBot performs various updates to existing bot users and roles. -func (c *BotsCommand) UpdateBot(ctx context.Context, client *authclient.Client) error { +func (c *BotsCommand) UpdateBot(ctx context.Context, client botsCommandClient) error { bot, err := client.BotServiceClient().GetBot(ctx, &machineidv1pb.GetBotRequest{ BotName: c.botName, }) @@ -561,25 +584,47 @@ func (c *BotsCommand) UpdateBot(ctx context.Context, client *authclient.Client) } // ListBotInstances lists bot instances, possibly filtering for a specific bot -func (c *BotsCommand) ListBotInstances(ctx context.Context, client *authclient.Client) error { - var instances []*machineidv1pb.BotInstance - req := &machineidv1pb.ListBotInstancesRequest{} - - if c.botName != "" { - req.FilterBotName = c.botName +func (c *BotsCommand) ListBotInstances(ctx context.Context, client botsCommandClient) error { + pageFunc := func(ctx context.Context, pageSize int, pageToken string) ([]*machineidv1pb.BotInstance, string, error) { + resp, err := client.BotInstanceServiceClient().ListBotInstancesV2(ctx, &machineidv1pb.ListBotInstancesV2Request{ + PageSize: int32(pageSize), + PageToken: pageToken, + SortField: c.sortIndex, + SortDesc: c.sortOrder == "descending", + Filter: &machineidv1pb.ListBotInstancesV2Request_Filters{ + BotName: c.botName, + SearchTerm: c.search, + Query: c.query, + }, + }) + return resp.GetBotInstances(), resp.GetNextPageToken(), trace.Wrap(err) } - for { - resp, err := client.BotInstanceServiceClient().ListBotInstances(ctx, req) - if err != nil { - return trace.Wrap(err) + fallbackFunc := func(ctx context.Context) ([]*machineidv1pb.BotInstance, error) { + if c.query != "" { + return nil, trace.NotImplemented("fallback not supported for requests with a query") } - - instances = append(instances, resp.BotInstances...) - if resp.NextPageToken == "" { - break + fallbackPageFunc := func(ctx context.Context, pageSize int, pageToken string) ([]*machineidv1pb.BotInstance, string, error) { + // Needed for backwards compatibility + //nolint:staticcheck // SA1019 + resp, err := client.BotInstanceServiceClient().ListBotInstances(ctx, &machineidv1pb.ListBotInstancesRequest{ + FilterBotName: c.botName, + PageSize: int32(pageSize), + PageToken: pageToken, + FilterSearchTerm: c.search, + Sort: &types.SortBy{ + Field: c.sortIndex, + IsDesc: c.sortOrder == "descending", + }, + }) + return resp.GetBotInstances(), resp.GetNextPageToken(), trace.Wrap(err) } - req.PageToken = resp.NextPageToken + return stream.Collect(clientutils.Resources(ctx, fallbackPageFunc)) + } + + instances, err := clientutils.CollectWithFallback(ctx, pageFunc, fallbackFunc) + if err != nil { + return trace.Wrap(err) } if c.format == teleport.JSON { @@ -608,15 +653,14 @@ func (c *BotsCommand) ListBotInstances(ctx context.Context, client *authclient.C return nil } - t := asciitable.MakeTable([]string{"ID", "Join Method", "Hostname", "Joined", "Last Seen", "Generation"}) + t := asciitable.MakeTable([]string{"ID", "Join Method", "Version", "Hostname", "Status", "Last Seen"}) for _, i := range instances { var ( joinMethod string hostname string - generation string + version string ) - joined := i.Status.InitialAuthentication.AuthenticatedAt.AsTime().Format(time.RFC3339) initialJoinMethod := cmp.Or( i.Status.InitialAuthentication.GetJoinAttrs().GetMeta().GetJoinMethod(), i.Status.InitialAuthentication.JoinMethod, @@ -624,13 +668,9 @@ func (c *BotsCommand) ListBotInstances(ctx context.Context, client *authclient.C lastSeen := i.Status.InitialAuthentication.AuthenticatedAt.AsTime() - if len(i.Status.LatestAuthentications) == 0 { - generation = "n/a" - } else { + if len(i.Status.LatestAuthentications) > 0 { auth := i.Status.LatestAuthentications[len(i.Status.LatestAuthentications)-1] - generation = fmt.Sprint(auth.Generation) - authJM := cmp.Or( auth.GetJoinAttrs().GetMeta().GetJoinMethod(), auth.JoinMethod, @@ -648,20 +688,27 @@ func (c *BotsCommand) ListBotInstances(ctx context.Context, client *authclient.C } if len(i.Status.LatestHeartbeats) == 0 { - hostname = "n/a" + hostname = "-" + version = "-" } else { hb := i.Status.LatestHeartbeats[len(i.Status.LatestHeartbeats)-1] hostname = hb.Hostname + version = hb.Version if hb.RecordedAt.AsTime().After(lastSeen) { lastSeen = hb.RecordedAt.AsTime() } } + healthStatus := "-" + if hasStatus, status := aggregateServiceHealth(i.GetStatus().GetServiceHealth()); hasStatus { + healthStatus = formatStatus(status, false) // Disable color, it messes with the table layout + } + t.AddRow([]string{ fmt.Sprintf("%s/%s", i.Spec.BotName, i.Spec.InstanceId), joinMethod, - hostname, joined, lastSeen.Format(time.RFC3339), generation, + version, hostname, healthStatus, lastSeen.Format(time.RFC3339), }) } fmt.Fprintln(c.stdout, t.AsBuffer().String()) @@ -677,7 +724,7 @@ func (c *BotsCommand) ListBotInstances(ctx context.Context, client *authclient.C } // AddBotInstance begins onboarding a new instance of an existing bot. -func (c *BotsCommand) AddBotInstance(ctx context.Context, client *authclient.Client) error { +func (c *BotsCommand) AddBotInstance(ctx context.Context, client botsCommandClient) error { // A bit of a misnomer but makes the terminology a bit more consistent. This // doesn't directly create a bot instance, but creates token that allows a // bot to join, which creates a new instance. @@ -741,8 +788,9 @@ func (c *BotsCommand) AddBotInstance(ctx context.Context, client *authclient.Cli var showMessageTemplate = template.Must(template.New("show").Funcs(template.FuncMap{ "bold": bold, -}).Parse(`Bot: {{.instance.Spec.BotName}} -ID: {{.instance.Spec.InstanceId}} +}).Parse(`Bot: {{.instance.Spec.BotName}} +ID: {{.instance.Spec.InstanceId}} +Status: {{.health_status}} Initial Authentication: {{.initial_authentication_table}} @@ -750,6 +798,9 @@ Latest Authentication: {{.latest_authentication_table}} Latest Heartbeat: {{.heartbeat_table}} +Services: +{{.services_table}} + To view a full, machine-readable record including past heartbeats and authentication records, run: @@ -760,7 +811,7 @@ To onboard a new instance for this bot, run: > {{.executable}} bots instances add {{.instance.Spec.BotName}} `)) -func (c *BotsCommand) ShowBotInstance(ctx context.Context, client *authclient.Client) error { +func (c *BotsCommand) ShowBotInstance(ctx context.Context, client botsCommandClient) error { botName, instanceID, err := parseInstanceID(c.instanceID) if err != nil { return trace.Wrap(err) @@ -792,12 +843,24 @@ func (c *BotsCommand) ShowBotInstance(ctx context.Context, client *authclient.Cl heartbeatTable = "No heartbeat records." } + healthStatus := "-" + if hasStatus, status := aggregateServiceHealth(instance.GetStatus().GetServiceHealth()); hasStatus { + healthStatus = formatStatus(status, true) + } + + servicesTable := " No reported services." + if instance.GetStatus().GetServiceHealth() != nil { + servicesTable = formatServices(instance.GetStatus().GetServiceHealth()) + } + templateData := map[string]interface{}{ "executable": os.Args[0], "instance": instance, "initial_authentication_table": initialAuthenticationTable, "latest_authentication_table": latestAuthenticationTable, "heartbeat_table": heartbeatTable, + "health_status": healthStatus, + "services_table": servicesTable, } return trace.Wrap(showMessageTemplate.Execute(os.Stdout, templateData)) @@ -813,7 +876,7 @@ type botJSONResponse struct { } // outputToken writes token information to stdout, depending on the token format. -func outputToken(wr io.Writer, format string, client *authclient.Client, bot *machineidv1pb.Bot, token types.ProvisionToken) error { +func outputToken(wr io.Writer, format string, client botsCommandClient, bot *machineidv1pb.Bot, token types.ProvisionToken) error { if format == teleport.JSON { tokenTTL := time.Duration(0) if exp := token.Expiry(); !exp.IsZero() { @@ -911,6 +974,61 @@ func formatBotInstanceHeartbeat(record *machineidv1pb.BotInstanceStatusHeartbeat return "\n" + indentString(table.AsBuffer().String(), " ") } +// formatServices returns a string containing a tabular representation of a +// bot's services. +func formatServices(services []*machineidv1pb.BotInstanceServiceHealth) string { + all := strings.Builder{} + + sortedServices := slices.SortedFunc(slices.Values(services), func(a, b *machineidv1pb.BotInstanceServiceHealth) int { + return cmp.Compare(a.GetService().GetName(), b.GetService().GetName()) + }) + for _, service := range sortedServices { + all.WriteString("Name: " + service.GetService().GetName()) + all.WriteString("\n") + all.WriteString("Type: " + service.GetService().GetType()) + all.WriteString("\n") + all.WriteString("Status: " + formatStatus(service.GetStatus(), true)) + all.WriteString("\n") + + if service.GetReason() != "" { + all.WriteString("Reason: " + service.GetReason()) + all.WriteString("\n") + } + + all.WriteString("Reported at: " + service.GetUpdatedAt().AsTime().Format(time.RFC3339)) + all.WriteString("\n\n") + } + + return indentString(all.String(), " ") +} + +// formatStatus returns an human-readable representation of a service status. +// Optionally, it can include a colored dot. +func formatStatus(status machineidv1pb.BotInstanceHealthStatus, useColor bool) string { + switch status { + case machineidv1pb.BotInstanceHealthStatus_BOT_INSTANCE_HEALTH_STATUS_HEALTHY: + if useColor { + return color.GreenString("\u25CF") + " Healthy" + } + return "Healthy" + case machineidv1pb.BotInstanceHealthStatus_BOT_INSTANCE_HEALTH_STATUS_UNHEALTHY: + if useColor { + return color.RedString("\u25CF") + " Unhealthy" + } + return "Unhealthy" + case machineidv1pb.BotInstanceHealthStatus_BOT_INSTANCE_HEALTH_STATUS_INITIALIZING: + if useColor { + return color.WhiteString("\u25CF") + " Initializing" + } + return "Initializing" + default: + if useColor { + return color.YellowString("\u25CF") + " Unknown" + } + return "Unknown" + } +} + // parseInstanceID converts an instance ID string in the form of // '[bot name]/[uuid]' to separate bot name and UUID strings. func parseInstanceID(s string) (name string, uuid string, err error) { @@ -937,3 +1055,34 @@ func indentString(s string, indent string) string { return buf.String() } + +// aggregateServiceHealth returns the least healthy status from the list of +// services provided. Priority; unhealthy, unspecified, initializing, healthy +func aggregateServiceHealth(services []*machineidv1pb.BotInstanceServiceHealth) (bool, machineidv1pb.BotInstanceHealthStatus) { + if len(services) == 0 { + return false, 0 + } + + hasUnhealthy := slices.ContainsFunc(services, func(service *machineidv1pb.BotInstanceServiceHealth) bool { + return service.GetStatus() == machineidv1pb.BotInstanceHealthStatus_BOT_INSTANCE_HEALTH_STATUS_UNHEALTHY + }) + if hasUnhealthy { + return true, machineidv1pb.BotInstanceHealthStatus_BOT_INSTANCE_HEALTH_STATUS_UNHEALTHY + } + + hasUnknown := slices.ContainsFunc(services, func(service *machineidv1pb.BotInstanceServiceHealth) bool { + return service.GetStatus() == machineidv1pb.BotInstanceHealthStatus_BOT_INSTANCE_HEALTH_STATUS_UNSPECIFIED + }) + if hasUnknown { + return true, machineidv1pb.BotInstanceHealthStatus_BOT_INSTANCE_HEALTH_STATUS_UNSPECIFIED + } + + hasInitializing := slices.ContainsFunc(services, func(service *machineidv1pb.BotInstanceServiceHealth) bool { + return service.GetStatus() == machineidv1pb.BotInstanceHealthStatus_BOT_INSTANCE_HEALTH_STATUS_INITIALIZING + }) + if hasInitializing { + return true, machineidv1pb.BotInstanceHealthStatus_BOT_INSTANCE_HEALTH_STATUS_INITIALIZING + } + + return true, machineidv1pb.BotInstanceHealthStatus_BOT_INSTANCE_HEALTH_STATUS_HEALTHY +} diff --git a/tool/tctl/common/bots_command_test.go b/tool/tctl/common/bots_command_test.go index bfcc1bba3b96f..c003223026054 100644 --- a/tool/tctl/common/bots_command_test.go +++ b/tool/tctl/common/bots_command_test.go @@ -24,10 +24,16 @@ import ( "slices" "strings" "testing" + "time" + "github.com/google/uuid" "github.com/gravitational/trace" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/fieldmaskpb" + "google.golang.org/protobuf/types/known/timestamppb" "github.com/gravitational/teleport" "github.com/gravitational/teleport/api/constants" @@ -36,8 +42,11 @@ import ( "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/api/utils/clientutils" "github.com/gravitational/teleport/integration/helpers" + "github.com/gravitational/teleport/lib/auth/authclient" "github.com/gravitational/teleport/lib/config" "github.com/gravitational/teleport/lib/itertools/stream" + "github.com/gravitational/teleport/lib/service" + "github.com/gravitational/teleport/lib/services" "github.com/gravitational/teleport/tool/teleport/testenv" ) @@ -135,6 +144,7 @@ func TestUpdateBotLogins(t *testing.T) { // mockAPIClient is a minimal API client used for testing type mockRoleGetterClient struct { + *authclient.Client roles []string } @@ -233,7 +243,7 @@ func TestUpdateBotRoles(t *testing.T) { botRoles: tt.set, } - err = cmd.updateBotRoles(context.TODO(), &mockClient, bot, fieldMask) + err = cmd.updateBotRoles(t.Context(), &mockClient, bot, fieldMask) tt.assert(t, bot, fieldMask, err) }) } @@ -256,6 +266,7 @@ func TestAddAndListBotInstancesJSON(t *testing.T) { ctx := context.Background() client, err := testenv.NewDefaultAuthClient(process) require.NoError(t, err) + t.Cleanup(func() { _ = client.Close() }) tokens, err := stream.Collect(clientutils.Resources(ctx, func(ctx context.Context, pageSize int, pageKey string) ([]types.ProvisionToken, string, error) { @@ -306,3 +317,388 @@ func TestAddAndListBotInstancesJSON(t *testing.T) { buf.Reset() } + +func TestAggregateServiceHealth(t *testing.T) { + t.Parallel() + + healthy := machineidv1pb.BotInstanceServiceHealth{ + Status: machineidv1pb.BotInstanceHealthStatus_BOT_INSTANCE_HEALTH_STATUS_HEALTHY, + } + unhealthy := machineidv1pb.BotInstanceServiceHealth{ + Status: machineidv1pb.BotInstanceHealthStatus_BOT_INSTANCE_HEALTH_STATUS_UNHEALTHY, + } + initializing := machineidv1pb.BotInstanceServiceHealth{ + Status: machineidv1pb.BotInstanceHealthStatus_BOT_INSTANCE_HEALTH_STATUS_INITIALIZING, + } + unknown := machineidv1pb.BotInstanceServiceHealth{ + Status: machineidv1pb.BotInstanceHealthStatus_BOT_INSTANCE_HEALTH_STATUS_UNSPECIFIED, + } + + tcs := []struct { + name string + services []*machineidv1pb.BotInstanceServiceHealth + hasStatus bool + status machineidv1pb.BotInstanceHealthStatus + }{ + { + name: "nil", + services: nil, + hasStatus: false, + status: 0, + }, + { + name: "empty", + services: []*machineidv1pb.BotInstanceServiceHealth{}, + hasStatus: false, + status: 0, + }, + { + name: "one item - healthy", + services: []*machineidv1pb.BotInstanceServiceHealth{ + &healthy, + }, + hasStatus: true, + status: machineidv1pb.BotInstanceHealthStatus_BOT_INSTANCE_HEALTH_STATUS_HEALTHY, + }, + { + name: "one item - unhealthy", + services: []*machineidv1pb.BotInstanceServiceHealth{ + &unhealthy, + }, + hasStatus: true, + status: machineidv1pb.BotInstanceHealthStatus_BOT_INSTANCE_HEALTH_STATUS_UNHEALTHY, + }, + { + name: "one item - initializing", + services: []*machineidv1pb.BotInstanceServiceHealth{ + &initializing, + }, + hasStatus: true, + status: machineidv1pb.BotInstanceHealthStatus_BOT_INSTANCE_HEALTH_STATUS_INITIALIZING, + }, + { + name: "one item - unknown", + services: []*machineidv1pb.BotInstanceServiceHealth{ + &unknown, + }, + hasStatus: true, + status: machineidv1pb.BotInstanceHealthStatus_BOT_INSTANCE_HEALTH_STATUS_UNSPECIFIED, + }, + { + name: "multiple items - healthy", + services: []*machineidv1pb.BotInstanceServiceHealth{ + &healthy, + &healthy, + }, + hasStatus: true, + status: machineidv1pb.BotInstanceHealthStatus_BOT_INSTANCE_HEALTH_STATUS_HEALTHY, + }, + { + name: "multiple items - unhealthy", + services: []*machineidv1pb.BotInstanceServiceHealth{ + &unhealthy, + &healthy, + &initializing, + &unknown, + }, + hasStatus: true, + status: machineidv1pb.BotInstanceHealthStatus_BOT_INSTANCE_HEALTH_STATUS_UNHEALTHY, + }, + { + name: "multiple items - unknown", + services: []*machineidv1pb.BotInstanceServiceHealth{ + &healthy, + &initializing, + &unknown, + }, + hasStatus: true, + status: machineidv1pb.BotInstanceHealthStatus_BOT_INSTANCE_HEALTH_STATUS_UNSPECIFIED, + }, + { + name: "multiple items - initializing", + services: []*machineidv1pb.BotInstanceServiceHealth{ + &healthy, + &initializing, + }, + hasStatus: true, + status: machineidv1pb.BotInstanceHealthStatus_BOT_INSTANCE_HEALTH_STATUS_INITIALIZING, + }, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + has, status := aggregateServiceHealth(tc.services) + assert.Equal(t, tc.hasStatus, has) + assert.Equal(t, tc.status, status) + }) + } +} +func TestListBotInstances(t *testing.T) { + t.Parallel() + + dynAddr := helpers.NewDynamicServiceAddr(t) + fileConfig := &config.FileConfig{ + Global: config.Global{ + DataDir: t.TempDir(), + }, + Auth: config.Auth{ + Service: config.Service{ + EnabledFlag: "true", + ListenAddress: dynAddr.AuthAddr, + }, + }, + } + process := makeAndRunTestAuthServer(t, withFileConfig(fileConfig), withFileDescriptors(dynAddr.Descriptors), withEnableCache(true)) + ctx := t.Context() + client, err := testenv.NewDefaultAuthClient(process) + require.NoError(t, err) + + t.Cleanup(func() { _ = client.Close() }) + + instance0 := createBotInstance(t, ctx, process) + instance1 := createBotInstance(t, ctx, process, func(instance *machineidv1pb.BotInstance) { + instance.Status.InitialHeartbeat.Hostname = "test-hostname-3" + instance.Status.InitialHeartbeat.Version = "19.0.1" + }) + instance2 := createBotInstance(t, ctx, process, func(instance *machineidv1pb.BotInstance) { + instance.Spec.BotName = "test-bot-2" + instance.Status.InitialHeartbeat.Hostname = "test-hostname-2" + instance.Status.InitialHeartbeat.Version = "18.1.0" + }) + + // Give the auth cache a chance to catch-up + require.EventuallyWithT(t, func(t *assert.CollectT) { + res, _, err := process.GetAuthServer().ListBotInstances(ctx, 0, "", nil) + require.NoError(t, err) + require.Len(t, res, 3) + }, time.Second*10, time.Millisecond*50) + + t.Run("defaults", func(t *testing.T) { + buf := strings.Builder{} + cmd := BotsCommand{ + stdout: &buf, + format: teleport.JSON, + } + + require.NoError(t, cmd.ListBotInstances(ctx, client)) + + res, err := services.UnmarshalProtoResourceArray[*machineidv1pb.BotInstance]([]byte(buf.String())) + require.NoError(t, err) + + require.Len(t, res, 3) + }) + + t.Run("filter by bot name", func(t *testing.T) { + buf := strings.Builder{} + cmd := BotsCommand{ + stdout: &buf, + format: teleport.JSON, + botName: "test-bot-1", + } + + require.NoError(t, cmd.ListBotInstances(ctx, client)) + + res, err := services.UnmarshalProtoResourceArray[*machineidv1pb.BotInstance]([]byte(buf.String())) + require.NoError(t, err) + + require.Len(t, res, 2) + assertContainsInstance(t, res, instance0.GetSpec().GetInstanceId()) + assertContainsInstance(t, res, instance1.GetSpec().GetInstanceId()) + }) + + t.Run("filter with search", func(t *testing.T) { + buf := strings.Builder{} + cmd := BotsCommand{ + stdout: &buf, + format: teleport.JSON, + search: "test-hostname-2", + } + + require.NoError(t, cmd.ListBotInstances(ctx, client)) + + res, err := services.UnmarshalProtoResourceArray[*machineidv1pb.BotInstance]([]byte(buf.String())) + require.NoError(t, err) + + require.Len(t, res, 1) + assertContainsInstance(t, res, instance2.GetSpec().GetInstanceId()) + }) + + t.Run("filter with query", func(t *testing.T) { + buf := strings.Builder{} + cmd := BotsCommand{ + stdout: &buf, + format: teleport.JSON, + query: `status.latest_heartbeat.hostname == "test-hostname-2"`, + } + + require.NoError(t, cmd.ListBotInstances(ctx, client)) + + res, err := services.UnmarshalProtoResourceArray[*machineidv1pb.BotInstance]([]byte(buf.String())) + require.NoError(t, err) + + require.Len(t, res, 1) + assertContainsInstance(t, res, instance2.GetSpec().GetInstanceId()) + }) + + t.Run("sort by field", func(t *testing.T) { + buf := strings.Builder{} + cmd := BotsCommand{ + stdout: &buf, + format: teleport.JSON, + sortIndex: "version_latest", + } + + require.NoError(t, cmd.ListBotInstances(ctx, client)) + + res, err := services.UnmarshalProtoResourceArray[*machineidv1pb.BotInstance]([]byte(buf.String())) + require.NoError(t, err) + + require.Len(t, res, 3) + assert.Equal(t, "18.1.0", res[0].GetStatus().GetInitialHeartbeat().GetVersion()) + assert.Equal(t, "19.0.0", res[1].GetStatus().GetInitialHeartbeat().GetVersion()) + assert.Equal(t, "19.0.1", res[2].GetStatus().GetInitialHeartbeat().GetVersion()) + }) + + t.Run("sort order", func(t *testing.T) { + buf := strings.Builder{} + cmd := BotsCommand{ + stdout: &buf, + format: teleport.JSON, + sortIndex: "version_latest", + sortOrder: "descending", + } + + require.NoError(t, cmd.ListBotInstances(ctx, client)) + + res, err := services.UnmarshalProtoResourceArray[*machineidv1pb.BotInstance]([]byte(buf.String())) + require.NoError(t, err) + + require.Len(t, res, 3) + assert.Equal(t, "19.0.1", res[0].GetStatus().GetInitialHeartbeat().GetVersion()) + assert.Equal(t, "19.0.0", res[1].GetStatus().GetInitialHeartbeat().GetVersion()) + assert.Equal(t, "18.1.0", res[2].GetStatus().GetInitialHeartbeat().GetVersion()) + }) +} + +func assertContainsInstance(t *testing.T, res []*machineidv1pb.BotInstance, instanceId string) { + assert.True(t, slices.ContainsFunc(res, func(in *machineidv1pb.BotInstance) bool { + return in.GetSpec().GetInstanceId() == instanceId + })) +} + +func createBotInstance(t *testing.T, ctx context.Context, process *service.TeleportProcess, options ...func(instance *machineidv1pb.BotInstance)) (result *machineidv1pb.BotInstance) { + heartbeat := &machineidv1pb.BotInstanceStatusHeartbeat{ + RecordedAt: timestamppb.New(time.Now()), + IsStartup: true, + Version: "19.0.0", + Hostname: "test-hostname-1", + Uptime: durationpb.New(1 * time.Hour), + Os: "linux", + } + + base := &machineidv1pb.BotInstance{ + Spec: &machineidv1pb.BotInstanceSpec{ + BotName: "test-bot-1", + InstanceId: uuid.New().String(), + }, + Status: &machineidv1pb.BotInstanceStatus{ + InitialHeartbeat: heartbeat, + LatestHeartbeats: []*machineidv1pb.BotInstanceStatusHeartbeat{ + heartbeat, + }, + }, + } + + for _, fn := range options { + fn(base) + } + + result, err := process.GetAuthServer().CreateBotInstance(ctx, base) + require.NoError(t, err) + + return +} + +func TestListBotInstancesFallback(t *testing.T) { + t.Parallel() + + dynAddr := helpers.NewDynamicServiceAddr(t) + fileConfig := &config.FileConfig{ + Global: config.Global{ + DataDir: t.TempDir(), + }, + Auth: config.Auth{ + Service: config.Service{ + EnabledFlag: "true", + ListenAddress: dynAddr.AuthAddr, + }, + }, + } + process := makeAndRunTestAuthServer(t, withFileConfig(fileConfig), withFileDescriptors(dynAddr.Descriptors), withEnableCache(true)) + ctx := t.Context() + client, err := testenv.NewDefaultAuthClient(process) + require.NoError(t, err) + + authClient := &mockBotInstanceListerClient{ + Client: client, + } + + t.Run("fallback allowed", func(t *testing.T) { + cmd := BotsCommand{ + stdout: ptr(strings.Builder{}), + format: teleport.JSON, + } + + require.NoError(t, cmd.ListBotInstances(ctx, authClient)) + }) + + t.Run("fallback not allowed", func(t *testing.T) { + cmd := BotsCommand{ + stdout: ptr(strings.Builder{}), + format: teleport.JSON, + query: "foo()", // query is only available in ListBotInstancesV2 + } + + err := cmd.ListBotInstances(ctx, authClient) + require.Error(t, err) + require.ErrorContains(t, err, "fallback not supported for requests with a query") + }) +} + +// mockBotInstanceListerClient is a client which returns NotImplemented for +// ListBotInstancesV2 to simulate a service running an older version. +type mockBotInstanceListerClient struct { + *authclient.Client +} + +func (c *mockBotInstanceListerClient) BotInstanceServiceClient() machineidv1pb.BotInstanceServiceClient { + return &mockBotInstanceListV2ErrorClient{ + BotInstanceServiceClient: c.Client.BotInstanceServiceClient(), + errV1: nil, + errV2: trace.NotImplemented("not implemeted in mock"), + } +} + +type mockBotInstanceListV2ErrorClient struct { + machineidv1pb.BotInstanceServiceClient + errV1 error + errV2 error +} + +func (c *mockBotInstanceListV2ErrorClient) ListBotInstances(ctx context.Context, in *machineidv1pb.ListBotInstancesRequest, opts ...grpc.CallOption) (*machineidv1pb.ListBotInstancesResponse, error) { + if c.errV1 == nil { + // Needed for backwards compatibility + //nolint:staticcheck // SA1019 + return c.BotInstanceServiceClient.ListBotInstances(ctx, in, opts...) + } + return nil, c.errV2 +} + +func (c *mockBotInstanceListV2ErrorClient) ListBotInstancesV2(ctx context.Context, in *machineidv1pb.ListBotInstancesV2Request, opts ...grpc.CallOption) (*machineidv1pb.ListBotInstancesResponse, error) { + if c.errV2 == nil { + return c.BotInstanceServiceClient.ListBotInstancesV2(ctx, in, opts...) + } + return nil, c.errV2 +} + +func ptr[T any](v T) *T { return &v } diff --git a/tool/tctl/common/helpers_test.go b/tool/tctl/common/helpers_test.go index 16370b1de0eac..eea30ae63ad1f 100644 --- a/tool/tctl/common/helpers_test.go +++ b/tool/tctl/common/helpers_test.go @@ -250,6 +250,7 @@ type testServerOptions struct { fileConfig *config.FileConfig fileDescriptors []*servicecfg.FileDescriptor fakeClock *clockwork.FakeClock + enableCache bool } type testServerOptionFunc func(options *testServerOptions) @@ -272,6 +273,12 @@ func withFakeClock(fakeClock *clockwork.FakeClock) testServerOptionFunc { } } +func withEnableCache(enableCache bool) testServerOptionFunc { + return func(options *testServerOptions) { + options.enableCache = enableCache + } +} + func makeAndRunTestAuthServer(t *testing.T, opts ...testServerOptionFunc) (auth *service.TeleportProcess) { var options testServerOptions for _, opt := range opts { @@ -287,7 +294,7 @@ func makeAndRunTestAuthServer(t *testing.T, opts ...testServerOptionFunc) (auth require.NoError(t, err) } - cfg.CachePolicy.Enabled = false + cfg.CachePolicy.Enabled = options.enableCache cfg.Proxy.DisableWebInterface = true cfg.InstanceMetadataClient = imds.NewDisabledIMDSClient() if options.fakeClock != nil { diff --git a/tool/tctl/common/resource_command.go b/tool/tctl/common/resource_command.go index 5fe804d9b3b9a..8d8e243e1ab50 100644 --- a/tool/tctl/common/resource_command.go +++ b/tool/tctl/common/resource_command.go @@ -3497,6 +3497,8 @@ func (rc *ResourceCommand) getCollection(ctx context.Context, client *authclient } instances, err := stream.Collect(clientutils.Resources(ctx, func(ctx context.Context, limit int, pageToken string) ([]*machineidv1pb.BotInstance, string, error) { + // TODO(nicholasmarais1158) Use ListBotInstancesV2 instead. + //nolint:staticcheck // SA1019 resp, err := client.BotInstanceServiceClient().ListBotInstances(ctx, &machineidv1pb.ListBotInstancesRequest{ PageSize: int32(limit), PageToken: pageToken, diff --git a/web/packages/design/src/DataTable/InputSearch/InputSearch.test.tsx b/web/packages/design/src/DataTable/InputSearch/InputSearch.test.tsx new file mode 100644 index 0000000000000..cadbe626dcb5c --- /dev/null +++ b/web/packages/design/src/DataTable/InputSearch/InputSearch.test.tsx @@ -0,0 +1,67 @@ +/** + * 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 { ComponentProps, PropsWithChildren } from 'react'; + +import { Providers, render, screen, userEvent } from 'design/utils/testing'; + +import InputSearch from './InputSearch'; + +describe('InputSearch', () => { + test('renders', async () => { + renderComponent({ + searchValue: '', + setSearchValue: jest.fn(), + }); + + expect(screen.getByPlaceholderText('Search...')).toBeInTheDocument(); + }); + + test('submits a search', async () => { + const setSearchValue = jest.fn(); + + const { user } = renderComponent({ + searchValue: '', + setSearchValue, + }); + + const input = screen.getByPlaceholderText('Search...'); + await user.click(input); + await user.paste('Lorem ipsum delor sit amet.'); + await user.type(input, '{enter}'); + + expect(setSearchValue).toHaveBeenCalledTimes(1); + expect(setSearchValue).toHaveBeenLastCalledWith( + 'Lorem ipsum delor sit amet.' + ); + }); +}); + +function renderComponent(props: ComponentProps) { + const user = userEvent.setup(); + return { + ...render(, { wrapper: makeWrapper() }), + user, + }; +} + +function makeWrapper() { + return (props: PropsWithChildren) => { + return {props.children}; + }; +} diff --git a/web/packages/design/src/DataTable/InputSearch/InputSearch.tsx b/web/packages/design/src/DataTable/InputSearch/InputSearch.tsx index 4708f19cc9969..bd69ad0673945 100644 --- a/web/packages/design/src/DataTable/InputSearch/InputSearch.tsx +++ b/web/packages/design/src/DataTable/InputSearch/InputSearch.tsx @@ -61,6 +61,8 @@ export default function InputSearch({ {children} + {/* Required to submit a form with multiple inputs using keyboard [ENTER] */} + ); diff --git a/web/packages/shared/components/AdvancedSearchToggle/AdvancedSearchToggle.tsx b/web/packages/shared/components/AdvancedSearchToggle/AdvancedSearchToggle.tsx index 70a2ff22a6899..f20d09ab9d8f9 100644 --- a/web/packages/shared/components/AdvancedSearchToggle/AdvancedSearchToggle.tsx +++ b/web/packages/shared/components/AdvancedSearchToggle/AdvancedSearchToggle.tsx @@ -38,8 +38,11 @@ export function AdvancedSearchToggle(props: { px={props.px} className={props.className} > - - Advanced + + + Advanced + + diff --git a/web/packages/shared/components/Search/SearchPanel.test.tsx b/web/packages/shared/components/Search/SearchPanel.test.tsx new file mode 100644 index 0000000000000..40e9e11e3e936 --- /dev/null +++ b/web/packages/shared/components/Search/SearchPanel.test.tsx @@ -0,0 +1,105 @@ +/** + * 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 { ComponentProps, PropsWithChildren } from 'react'; + +import { Providers, render, screen, userEvent } from 'design/utils/testing'; + +import { SearchPanel } from './SearchPanel'; + +describe('SearchPanel', () => { + test('renders', async () => { + renderComponent({ + filter: { + search: '', + }, + updateSearch: jest.fn(), + updateQuery: jest.fn(), + disableSearch: false, + }); + + expect(screen.getByPlaceholderText('Search...')).toBeInTheDocument(); + }); + + test('submits a search', async () => { + const updateSearch = jest.fn(); + const updateQuery = jest.fn(); + + const { user } = renderComponent({ + filter: { + search: '', + }, + updateSearch, + updateQuery, + hideAdvancedSearch: true, + disableSearch: false, + }); + + const input = screen.getByPlaceholderText('Search...'); + await user.click(input); + await user.paste('Lorem ipsum delor sit amet.'); + await user.type(input, '{enter}'); + + expect(updateSearch).toHaveBeenCalledTimes(1); + expect(updateSearch).toHaveBeenLastCalledWith( + 'Lorem ipsum delor sit amet.' + ); + expect(updateQuery).not.toHaveBeenCalled(); + }); + + test('submits a query (advanced)', async () => { + const updateSearch = jest.fn(); + const updateQuery = jest.fn(); + + const { user } = renderComponent({ + filter: { + query: '', + }, + updateSearch, + updateQuery, + hideAdvancedSearch: false, + disableSearch: false, + }); + + // Toggle advanced mode on + await userEvent.click(screen.getByLabelText('Advanced')); + + const input = screen.getByPlaceholderText('Search...'); + await user.click(input); + await user.paste('Lorem ipsum delor sit amet.'); + await user.type(input, '{enter}'); + + expect(updateQuery).toHaveBeenCalledTimes(1); + expect(updateQuery).toHaveBeenLastCalledWith('Lorem ipsum delor sit amet.'); + expect(updateSearch).not.toHaveBeenCalled(); + }); +}); + +function renderComponent(props: ComponentProps) { + const user = userEvent.setup(); + return { + ...render(, { wrapper: makeWrapper() }), + user, + }; +} + +function makeWrapper() { + return (props: PropsWithChildren) => { + return {props.children}; + }; +} diff --git a/web/packages/shared/components/Search/SearchPanel.tsx b/web/packages/shared/components/Search/SearchPanel.tsx index 32f581b5cf326..c69930a20f120 100644 --- a/web/packages/shared/components/Search/SearchPanel.tsx +++ b/web/packages/shared/components/Search/SearchPanel.tsx @@ -22,6 +22,7 @@ import styled from 'styled-components'; import { Flex } from 'design'; import InputSearch from 'design/DataTable/InputSearch'; import { PageIndicatorText } from 'design/DataTable/Pager/PageIndicatorText'; +import { FlexProps } from 'design/Flex/Flex'; import { AdvancedSearchToggle } from 'shared/components/AdvancedSearchToggle'; // eslint-disable-next-line no-restricted-imports -- FIXME @@ -35,14 +36,16 @@ export function SearchPanel({ disableSearch, hideAdvancedSearch, extraChildren, + mb = 3, }: { updateQuery?: (s: string) => void; updateSearch: (s: string) => void; pageIndicators?: { from: number; to: number; total: number }; filter: ResourceFilter; - disableSearch: boolean; + disableSearch?: boolean; hideAdvancedSearch?: boolean; extraChildren?: JSX.Element; + mb?: FlexProps['mb']; }) { const [query, setQuery] = useState(filter.search || filter.query || ''); const [isAdvancedSearch, setIsAdvancedSearch] = useState(!!filter.query); @@ -72,7 +75,7 @@ export function SearchPanel({ justifyContent="space-between" alignItems="center" width="100%" - mb={3} + mb={mb} > . + */ + +jest.mock('./TextEditor', () => { + return { + __esModule: true, + default: MockTextEditor, + }; +}); + +/** + * How to use this? + * + * Import "shared/components/TextEditor/TextEditor.mock" in your test file and + * the mock will be setup for you. It can be used to test the content only, no + * other features are available in the mock. + */ + +function MockTextEditor(props: { data?: [{ content: string }] }) { + return ( +
+ {props.data?.map(d =>
{d.content}
)} +
+ ); +} diff --git a/web/packages/teleport/src/BotInstances/BotInstances.story.tsx b/web/packages/teleport/src/BotInstances/BotInstances.story.tsx new file mode 100644 index 0000000000000..0d0afec88d159 --- /dev/null +++ b/web/packages/teleport/src/BotInstances/BotInstances.story.tsx @@ -0,0 +1,217 @@ +/** + * 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, Router } from 'react-router'; + +import Box from 'design/Box/Box'; + +import { Route } from 'teleport/components/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 { + getBotInstanceError, + getBotInstanceMetricsSuccess, + getBotInstanceSuccess, + listBotInstancesError, + listBotInstancesForever, + listBotInstancesSuccess, +} from 'teleport/test/helpers/botInstances'; + +import { BotInstances } from './BotInstances'; + +const meta = { + title: 'Teleport/BotInstances', + component: Wrapper, + beforeEach: () => { + queryClient.clear(); // Prevent cached data sharing between stories + }, +} satisfies Meta; + +type Story = StoryObj; + +export default meta; + +const listBotInstances = { + bot_instances: [ + { + bot_name: 'ansible-worker', + instance_id: crypto.randomUUID(), + active_at_latest: '2025-07-22T10:54:00Z', + host_name_latest: 'my-svc.my-namespace.svc.cluster-domain.example', + join_method_latest: 'github', + os_latest: 'linux', + version_latest: '4.4.0', + }, + { + bot_name: 'ansible-worker', + instance_id: crypto.randomUUID(), + active_at_latest: '2025-07-22T10:54:00Z', + host_name_latest: 'win-123a', + join_method_latest: 'tpm', + os_latest: 'windows', + version_latest: '4.3.18+ab12hd', + }, + { + bot_name: 'ansible-worker', + instance_id: crypto.randomUUID(), + active_at_latest: '2025-07-22T10:54:00Z', + host_name_latest: 'mac-007', + join_method_latest: 'kubernetes', + os_latest: 'darwin', + version_latest: '3.9.99', + }, + { + bot_name: 'ansible-worker', + instance_id: crypto.randomUUID(), + active_at_latest: '2025-07-22T10:54:00Z', + host_name_latest: 'aws:g49dh27dhjm3', + join_method_latest: 'ec2', + os_latest: 'linux', + version_latest: '1.3.2', + }, + { + bot_name: 'ansible-worker', + instance_id: crypto.randomUUID(), + }, + ], + next_page_token: '', +}; + +export const Happy: Story = { + parameters: { + msw: { + handlers: [ + listBotInstancesSuccess(listBotInstances, 'v1'), + listBotInstancesSuccess(listBotInstances, 'v2'), + getBotInstanceSuccess(), + getBotInstanceMetricsSuccess(), + ], + }, + }, +}; + +export const ErrorLoadingList: Story = { + parameters: { + msw: { + handlers: [ + listBotInstancesError(500, 'something went wrong'), + getBotInstanceMetricsSuccess(), + ], + }, + }, +}; + +export const StillLoadingList: Story = { + parameters: { + msw: { + handlers: [listBotInstancesForever(), getBotInstanceMetricsSuccess()], + }, + }, +}; + +export const NoListPermission: Story = { + args: { + hasBotInstanceListPermission: false, + }, + parameters: { + msw: { + handlers: [ + listBotInstancesError( + 500, + 'this call should never be made without permissions' + ), + getBotInstanceMetricsSuccess(), + ], + }, + }, +}; + +export const NoReadPermission: Story = { + args: { + hasBotInstanceReadPermission: false, + }, + parameters: { + msw: { + handlers: [ + listBotInstancesSuccess(listBotInstances, 'v1'), + listBotInstancesSuccess(listBotInstances, 'v2'), + getBotInstanceError( + 500, + 'this call should never be made without permissions' + ), + getBotInstanceMetricsSuccess(), + ], + }, + }, +}; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: false, + }, + }, +}); + +function Wrapper(props?: { + hasBotInstanceListPermission?: boolean; + hasBotInstanceReadPermission?: boolean; +}) { + const { + hasBotInstanceListPermission = true, + hasBotInstanceReadPermission = true, + } = props ?? {}; + + const history = createMemoryHistory({ + initialEntries: ['/web/bots/instances'], + }); + + const customAcl = makeAcl({ + botInstances: { + ...defaultAccess, + read: hasBotInstanceReadPermission, + list: hasBotInstanceListPermission, + }, + }); + + const ctx = createTeleportContext({ + customAcl, + }); + + return ( + + + + + + + + + + + + + + ); +} diff --git a/web/packages/teleport/src/BotInstances/BotInstances.test.tsx b/web/packages/teleport/src/BotInstances/BotInstances.test.tsx index 587822ee5fba6..618a1ff538cac 100644 --- a/web/packages/teleport/src/BotInstances/BotInstances.test.tsx +++ b/web/packages/teleport/src/BotInstances/BotInstances.test.tsx @@ -17,31 +17,37 @@ */ import { QueryClientProvider } from '@tanstack/react-query'; +import { createMemoryHistory } from 'history'; import { setupServer } from 'msw/node'; import { PropsWithChildren } from 'react'; -import { MemoryRouter } from 'react-router'; +import { MemoryRouter, Route, Router } from 'react-router'; import { darkTheme } from 'design/theme'; import { ConfiguredThemeProvider } from 'design/ThemeProvider'; import { - fireEvent, render, screen, testQueryClient, userEvent, waitFor, waitForElementToBeRemoved, + within, } from 'design/utils/testing'; import { InfoGuidePanelProvider } from 'shared/components/SlidingSidePanel/InfoGuide'; +import cfg from 'teleport/config'; import { createTeleportContext } from 'teleport/mocks/contexts'; import { listBotInstances } from 'teleport/services/bot/bot'; -import { makeAcl } from 'teleport/services/user/makeAcl'; +import { defaultAccess, makeAcl } from 'teleport/services/user/makeAcl'; import { + getBotInstanceMetricsSuccess, + getBotInstanceSuccess, listBotInstancesError, listBotInstancesSuccess, } from 'teleport/test/helpers/botInstances'; +import 'shared/components/TextEditor/TextEditor.mock'; + import { ContextProvider } from '..'; import { BotInstances } from './BotInstances'; @@ -51,6 +57,12 @@ jest.mock('teleport/services/bot/bot', () => { listBotInstances: jest.fn((...all) => { return actual.listBotInstances(...all); }), + getBotInstance: jest.fn((...all) => { + return actual.getBotInstance(...all); + }), + getBotInstanceMetrics: jest.fn((...all) => { + return actual.getBotInstanceMetrics(...all); + }), }; }); @@ -60,10 +72,6 @@ beforeAll(() => { server.listen(); }); -beforeEach(() => { - jest.useFakeTimers().setSystemTime(new Date('2025-05-19T08:00:00Z')); -}); - afterEach(async () => { server.resetHandlers(); await testQueryClient.resetQueries(); @@ -77,17 +85,21 @@ afterAll(() => server.close()); describe('BotInstances', () => { it('Shows an empty state', async () => { server.use( - listBotInstancesSuccess({ - bot_instances: [], - next_page_token: '', - }) + listBotInstancesSuccess( + { + bot_instances: [], + next_page_token: '', + }, + 'v1' + ) ); + server.use(getBotInstanceMetricsSuccess()); - render(, { wrapper: makeWrapper() }); + renderComponent(); await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); - expect(screen.getByText('No active instances found')).toBeInTheDocument(); + expect(screen.getByText('No active instances')).toBeInTheDocument(); expect( screen.getByText( 'Bot instances are ephemeral, and disappear once all issued credentials have expired.' @@ -96,57 +108,52 @@ describe('BotInstances', () => { }); it('Shows an error state', async () => { - server.use(listBotInstancesError(500, 'server error')); + server.use(listBotInstancesError(500, 'something went wrong')); + server.use(getBotInstanceMetricsSuccess()); - render(, { wrapper: makeWrapper() }); + renderComponent(); await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); - expect(screen.getByText('Error: server error')).toBeInTheDocument(); + expect(screen.getByText('something went wrong')).toBeInTheDocument(); }); it('Shows an unsupported sort error state', async () => { const testErrorMessage = 'unsupported sort, only bot_name:asc is supported, but got "blah" (desc = true)'; server.use(listBotInstancesError(400, testErrorMessage)); + server.use(getBotInstanceMetricsSuccess()); - render(, { wrapper: makeWrapper() }); + const { user } = renderComponent(); await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); - expect(screen.getByText(`Error: ${testErrorMessage}`)).toBeInTheDocument(); + expect(screen.getByText(testErrorMessage)).toBeInTheDocument(); server.use( - listBotInstancesSuccess({ - bot_instances: [], - next_page_token: '', - }) + listBotInstancesSuccess( + { + bot_instances: [], + next_page_token: '', + }, + 'v1' + ) ); - const resetButton = screen.getByText('Reset sort'); - expect(resetButton).toBeInTheDocument(); - fireEvent.click(resetButton); + const resetButton = screen.getByRole('button', { name: 'Reset sort' }); + await user.click(resetButton); - await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); - - expect( - screen.queryByText(`Error: ${testErrorMessage}`) - ).not.toBeInTheDocument(); + expect(screen.queryByText(testErrorMessage)).not.toBeInTheDocument(); }); it('Shows an unauthorised error state', async () => { - render(, { - wrapper: makeWrapper( - makeAcl({ - botInstances: { - list: false, - create: true, - edit: true, - remove: true, - read: true, - }, - }) - ), + renderComponent({ + customAcl: makeAcl({ + botInstances: { + ...defaultAccess, + list: false, + }, + }), }); expect( @@ -160,39 +167,104 @@ describe('BotInstances', () => { }); it('Shows a list', async () => { + jest.useFakeTimers().setSystemTime(new Date('2025-05-19T08:00:00Z')); + server.use( - listBotInstancesSuccess({ - bot_instances: [ - { - bot_name: 'test-bot-1', - instance_id: '5e885c66-1af3-4a36-987d-a604d8ee49d2', - active_at_latest: '2025-05-19T07:32:00Z', - host_name_latest: 'test-hostname', - join_method_latest: 'test-join-method', - version_latest: '1.0.0-dev-a12b3c', - }, - { - bot_name: 'test-bot-2', - instance_id: '3c3aae3e-de25-4824-a8e9-5a531862f19a', - }, - ], - next_page_token: '', - }) + listBotInstancesSuccess( + { + bot_instances: [ + { + bot_name: 'test-bot-1', + instance_id: '5e885c66-1af3-4a36-987d-a604d8ee49d2', + active_at_latest: '2025-05-19T07:32:00Z', + host_name_latest: 'test-hostname', + join_method_latest: 'github', + version_latest: '1.0.0-dev-a12b3c', + }, + { + bot_name: 'test-bot-2', + instance_id: '3c3aae3e-de25-4824-a8e9-5a531862f19a', + }, + ], + next_page_token: '', + }, + 'v1' + ) ); + server.use(getBotInstanceMetricsSuccess()); - render(, { wrapper: makeWrapper() }); + renderComponent(); await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); - expect(screen.getByText('test-bot-1')).toBeInTheDocument(); - expect(screen.getByText('5e885c6')).toBeInTheDocument(); + expect(screen.getByText('test-bot-1/5e885c6')).toBeInTheDocument(); expect(screen.getByText('28 minutes ago')).toBeInTheDocument(); expect(screen.getByText('test-hostname')).toBeInTheDocument(); - expect(screen.getByText('test-join-method')).toBeInTheDocument(); + expect(screen.getByTestId('res-icon-github')).toBeInTheDocument(); expect(screen.getByText('v1.0.0-dev-a12b3c')).toBeInTheDocument(); }); + it('Selects an item', async () => { + server.use( + listBotInstancesSuccess( + { + bot_instances: [ + { + bot_name: 'test-bot-1', + instance_id: '5e885c66-1af3-4a36-987d-a604d8ee49d2', + active_at_latest: '2025-05-19T07:32:00Z', + host_name_latest: 'test-hostname', + join_method_latest: 'github', + version_latest: '1.0.0-dev-a12b3c', + }, + { + bot_name: 'test-bot-2', + instance_id: '3c3aae3e-de25-4824-a8e9-5a531862f19a', + }, + ], + next_page_token: '', + }, + 'v1' + ) + ); + server.use(getBotInstanceMetricsSuccess()); + + server.use(getBotInstanceSuccess()); + + const { user } = renderComponent(); + + await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); + + expect( + screen.queryByRole('heading', { + name: 'test-bot-2/3c3aae3e-de25-4824-a8e9-5a531862f19a', + }) + ).not.toBeInTheDocument(); + + const item = screen.getByRole('listitem', { + name: 'test-bot-2/3c3aae3e-de25-4824-a8e9-5a531862f19a', + }); + await user.click(item); + + expect( + screen.getByRole('heading', { + name: 'test-bot-2/3c3aae3e-de25-4824-a8e9-5a531862f19a', + }) + ).toBeInTheDocument(); + + const summarySection = screen + .getByRole('heading', { + name: 'Summary', + }) + .closest('section'); + expect( + within(summarySection!).getByText('test-bot-name') + ).toBeInTheDocument(); + }); + it('Allows paging', async () => { + server.use(getBotInstanceMetricsSuccess()); + jest.mocked(listBotInstances).mockImplementation( ({ pageToken }) => new Promise(resolve => { @@ -200,7 +272,7 @@ describe('BotInstances', () => { bot_instances: [ { bot_name: `test-bot`, - instance_id: `00000000-0000-4000-0000-000000000000`, + instance_id: crypto.randomUUID(), active_at_latest: `2025-05-19T07:32:00Z`, host_name_latest: 'test-hostname', join_method_latest: 'test-join-method', @@ -214,58 +286,141 @@ describe('BotInstances', () => { expect(listBotInstances).toHaveBeenCalledTimes(0); - render(, { wrapper: makeWrapper() }); + const { user } = renderComponent(); await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); - const [nextButton] = screen.getAllByTitle('Next page'); + const moreAction = screen.getByRole('button', { name: 'Load More' }); expect(listBotInstances).toHaveBeenCalledTimes(1); - expect(listBotInstances).toHaveBeenLastCalledWith({ - pageSize: 20, - pageToken: '', - searchTerm: '', - sort: 'active_at_latest:desc', - }); + expect(listBotInstances).toHaveBeenLastCalledWith( + { + pageSize: 32, + pageToken: '', + searchTerm: '', + query: undefined, + sortDir: 'DESC', + sortField: 'active_at_latest', + }, + expect.anything() + ); - await waitFor(() => expect(nextButton).toBeEnabled()); - fireEvent.click(nextButton); + await waitFor(() => expect(moreAction).toBeEnabled()); + await user.click(moreAction); expect(listBotInstances).toHaveBeenCalledTimes(2); - expect(listBotInstances).toHaveBeenLastCalledWith({ - pageSize: 20, - pageToken: '.next', - searchTerm: '', - sort: 'active_at_latest:desc', - }); + expect(listBotInstances).toHaveBeenLastCalledWith( + { + pageSize: 32, + pageToken: '.next', + searchTerm: '', + query: undefined, + sortDir: 'DESC', + sortField: 'active_at_latest', + }, + expect.anything() + ); - await waitFor(() => expect(nextButton).toBeEnabled()); - fireEvent.click(nextButton); + await waitFor(() => expect(moreAction).toBeEnabled()); + await user.click(moreAction); expect(listBotInstances).toHaveBeenCalledTimes(3); - expect(listBotInstances).toHaveBeenLastCalledWith({ - pageSize: 20, - pageToken: '.next.next', - searchTerm: '', - sort: 'active_at_latest:desc', - }); + expect(listBotInstances).toHaveBeenLastCalledWith( + { + pageSize: 32, + pageToken: '.next.next', + searchTerm: '', + query: undefined, + sortDir: 'DESC', + sortField: 'active_at_latest', + }, + expect.anything() + ); + }); + + it('Allows filtering (search)', async () => { + server.use(getBotInstanceMetricsSuccess()); + + jest.mocked(listBotInstances).mockImplementation( + ({ pageToken }) => + new Promise(resolve => { + resolve({ + bot_instances: [ + { + bot_name: `test-bot`, + instance_id: crypto.randomUUID(), + active_at_latest: `2025-05-19T07:32:00Z`, + host_name_latest: 'test-hostname', + join_method_latest: 'test-join-method', + version_latest: `1.0.0-dev-a12b3c`, + }, + ], + next_page_token: pageToken + '.next', + }); + }) + ); - const [prevButton] = screen.getAllByTitle('Previous page'); + expect(listBotInstances).toHaveBeenCalledTimes(0); + const { user, history } = renderComponent(); + jest.spyOn(history, 'push'); - await waitFor(() => expect(prevButton).toBeEnabled()); - fireEvent.click(prevButton); + await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); - // This page's data will have been cached - expect(listBotInstances).toHaveBeenCalledTimes(3); + expect(listBotInstances).toHaveBeenCalledTimes(1); + expect(listBotInstances).toHaveBeenLastCalledWith( + { + pageSize: 32, + pageToken: '', + searchTerm: '', + query: undefined, + sortDir: 'DESC', + sortField: 'active_at_latest', + }, + expect.anything() + ); - await waitFor(() => expect(prevButton).toBeEnabled()); - fireEvent.click(prevButton); + const moreAction = screen.getByRole('button', { name: 'Load More' }); + await waitFor(() => expect(moreAction).toBeEnabled()); + await user.click(moreAction); - // This page's data will have been cached + expect(listBotInstances).toHaveBeenCalledTimes(2); + expect(listBotInstances).toHaveBeenLastCalledWith( + { + pageSize: 32, + pageToken: '.next', + searchTerm: '', + query: undefined, + sortDir: 'DESC', + sortField: 'active_at_latest', + }, + expect.anything() + ); + + const search = screen.getByPlaceholderText('Search...'); + await userEvent.type(search, 'test-search-term'); + await userEvent.type(search, '{enter}'); + + expect(history.push).toHaveBeenLastCalledWith({ + pathname: '/web/bots/instances', + search: 'query=test-search-term', + }); expect(listBotInstances).toHaveBeenCalledTimes(3); + expect(listBotInstances).toHaveBeenLastCalledWith( + { + pageSize: 32, + pageToken: '', // Should reset to the first page + searchTerm: 'test-search-term', + query: undefined, + sortDir: 'DESC', + sortField: 'active_at_latest', + }, + expect.anything() + ); }); - it('Allows filtering (search)', async () => { + it('Allows filtering (query)', async () => { + server.use(getBotInstanceMetricsSuccess()); + jest.mocked(listBotInstances).mockImplementation( ({ pageToken }) => new Promise(resolve => { @@ -273,7 +428,7 @@ describe('BotInstances', () => { bot_instances: [ { bot_name: `test-bot`, - instance_id: `00000000-0000-4000-0000-000000000000`, + instance_id: crypto.randomUUID(), active_at_latest: `2025-05-19T07:32:00Z`, host_name_latest: 'test-hostname', join_method_latest: 'test-join-method', @@ -286,48 +441,125 @@ describe('BotInstances', () => { ); expect(listBotInstances).toHaveBeenCalledTimes(0); - - render(, { wrapper: makeWrapper() }); + const { user, history } = renderComponent(); + jest.spyOn(history, 'push'); await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); expect(listBotInstances).toHaveBeenCalledTimes(1); - expect(listBotInstances).toHaveBeenLastCalledWith({ - pageSize: 20, - pageToken: '', - searchTerm: '', - sort: 'active_at_latest:desc', - }); + expect(listBotInstances).toHaveBeenLastCalledWith( + { + pageSize: 32, + pageToken: '', + searchTerm: '', + query: undefined, + sortDir: 'DESC', + sortField: 'active_at_latest', + }, + expect.anything() + ); - const [nextButton] = screen.getAllByTitle('Next page'); - await waitFor(() => expect(nextButton).toBeEnabled()); - fireEvent.click(nextButton); + const moreAction = screen.getByRole('button', { name: 'Load More' }); + await waitFor(() => expect(moreAction).toBeEnabled()); + await user.click(moreAction); expect(listBotInstances).toHaveBeenCalledTimes(2); - expect(listBotInstances).toHaveBeenLastCalledWith({ - pageSize: 20, - pageToken: '.next', - searchTerm: '', - sort: 'active_at_latest:desc', - }); + expect(listBotInstances).toHaveBeenLastCalledWith( + { + pageSize: 32, + pageToken: '.next', + searchTerm: '', + query: undefined, + sortDir: 'DESC', + sortField: 'active_at_latest', + }, + expect.anything() + ); - jest.useRealTimers(); // Required as userEvent.type() uses setTimeout internally + const advancedToggle = screen.getByLabelText('Advanced'); + expect(advancedToggle).not.toBeChecked(); + await userEvent.click(advancedToggle); + expect(advancedToggle).toBeChecked(); const search = screen.getByPlaceholderText('Search...'); - await waitFor(() => expect(search).toBeEnabled()); - await userEvent.type(search, 'test-search-term'); + await userEvent.type(search, 'test-query'); await userEvent.type(search, '{enter}'); + expect(history.push).toHaveBeenLastCalledWith({ + pathname: '/web/bots/instances', + search: 'query=test-query&is_advanced=1', + }); expect(listBotInstances).toHaveBeenCalledTimes(3); - expect(listBotInstances).toHaveBeenLastCalledWith({ - pageSize: 20, - pageToken: '', // Search should reset to the first page - searchTerm: 'test-search-term', - sort: 'active_at_latest:desc', + expect(listBotInstances).toHaveBeenLastCalledWith( + { + pageSize: 32, + pageToken: '', // Should reset to the first page + searchTerm: undefined, + query: 'test-query', + sortDir: 'DESC', + sortField: 'active_at_latest', + }, + expect.anything() + ); + }); + + it('Allows a filter to be applied from the dashboard', async () => { + server.use(getBotInstanceMetricsSuccess()); + + jest.mocked(listBotInstances).mockImplementation( + ({ pageToken }) => + new Promise(resolve => { + resolve({ + bot_instances: [], + next_page_token: pageToken + '.next', + }); + }) + ); + + const { user, history } = renderComponent(); + jest.spyOn(history, 'push'); + + await waitForElementToBeRemoved(() => + screen.queryByTestId('loading-dashboard') + ); + + expect(listBotInstances).toHaveBeenCalledTimes(1); + expect(listBotInstances).toHaveBeenLastCalledWith( + { + pageSize: 32, + pageToken: '', + searchTerm: '', + query: undefined, + sortDir: 'DESC', + sortField: 'active_at_latest', + }, + expect.anything() + ); + + const item = screen.getByLabelText('Up to date'); + await user.click(item); + + expect(history.push).toHaveBeenLastCalledWith({ + pathname: '/web/bots/instances', + search: 'query=up+to+date+filter+goes+here&is_advanced=1', }); + expect(listBotInstances).toHaveBeenCalledTimes(2); + expect(listBotInstances).toHaveBeenLastCalledWith( + { + pageSize: 32, + pageToken: '', // Should reset to the first page + searchTerm: undefined, + query: 'up to date filter goes here', + sortDir: 'DESC', + sortField: 'active_at_latest', + }, + expect.anything() + ); }); it('Allows sorting', async () => { + server.use(getBotInstanceMetricsSuccess()); + jest.mocked(listBotInstances).mockImplementation( ({ pageToken }) => new Promise(resolve => { @@ -349,54 +581,89 @@ describe('BotInstances', () => { expect(listBotInstances).toHaveBeenCalledTimes(0); - render(, { wrapper: makeWrapper() }); + const { user } = renderComponent(); await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); - const lastHeartbeatHeader = screen.getByText('Last heartbeat'); - expect(listBotInstances).toHaveBeenCalledTimes(1); - expect(listBotInstances).toHaveBeenLastCalledWith({ - pageSize: 20, - pageToken: '', - searchTerm: '', - sort: 'active_at_latest:desc', - }); + expect(listBotInstances).toHaveBeenLastCalledWith( + { + pageSize: 32, + pageToken: '', + searchTerm: '', + query: undefined, + sortDir: 'DESC', + sortField: 'active_at_latest', + }, + expect.anything() + ); - fireEvent.click(lastHeartbeatHeader); + const dirAction = screen.getByRole('button', { name: 'Sort direction' }); + await user.click(dirAction); expect(listBotInstances).toHaveBeenCalledTimes(2); - expect(listBotInstances).toHaveBeenLastCalledWith({ - pageSize: 20, - pageToken: '', - searchTerm: '', - sort: 'active_at_latest:asc', - }); + expect(listBotInstances).toHaveBeenLastCalledWith( + { + pageSize: 32, + pageToken: '', + searchTerm: '', + query: undefined, + sortDir: 'ASC', + sortField: 'active_at_latest', + }, + expect.anything() + ); - const botHeader = screen.getByText('Bot'); - fireEvent.click(botHeader); + const sortFieldAction = screen.getByRole('button', { name: 'Sort by' }); + await user.click(sortFieldAction); + const option = screen.getByRole('menuitem', { name: 'Bot name' }); + await user.click(option); expect(listBotInstances).toHaveBeenCalledTimes(3); - expect(listBotInstances).toHaveBeenLastCalledWith({ - pageSize: 20, - pageToken: '', - searchTerm: '', - sort: 'bot_name:desc', - }); + expect(listBotInstances).toHaveBeenLastCalledWith( + { + pageSize: 32, + pageToken: '', + searchTerm: '', + query: undefined, + sortDir: 'ASC', + sortField: 'bot_name', + }, + expect.anything() + ); }); }); -function makeWrapper( - customAcl: ReturnType = makeAcl({ - botInstances: { - list: true, - create: true, - edit: true, - remove: true, - read: true, - }, - }) -) { +function renderComponent(options?: { customAcl?: ReturnType }) { + const { + customAcl = makeAcl({ + botInstances: { + ...defaultAccess, + read: true, + list: true, + }, + }), + } = options ?? {}; + + const user = userEvent.setup(); + const history = createMemoryHistory({ + initialEntries: ['/web/bots/instances'], + }); + return { + ...render(, { + wrapper: makeWrapper({ customAcl, history }), + }), + user, + history, + }; +} + +function makeWrapper(options: { + customAcl: ReturnType; + history: ReturnType; +}) { + const { customAcl, history } = options ?? {}; + return ({ children }: PropsWithChildren) => { const ctx = createTeleportContext({ customAcl, @@ -406,7 +673,11 @@ function makeWrapper( - {children} + + + {children} + + diff --git a/web/packages/teleport/src/BotInstances/BotInstances.tsx b/web/packages/teleport/src/BotInstances/BotInstances.tsx index f8b1e64dcf293..73a340a1e5428 100644 --- a/web/packages/teleport/src/BotInstances/BotInstances.tsx +++ b/web/packages/teleport/src/BotInstances/BotInstances.tsx @@ -16,22 +16,16 @@ * along with this program. If not, see . */ -import { keepPreviousData, useQuery } from '@tanstack/react-query'; -import { useCallback, useMemo } from 'react'; +import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query'; +import { useCallback, useMemo, useRef } from 'react'; import { useHistory, useLocation } from 'react-router'; +import styled, { css } from 'styled-components'; import { Alert } from 'design/Alert/Alert'; -import Box from 'design/Box/Box'; -import { formatSortType, parseSortType } from 'design/DataTable/sort'; -import { SortType } from 'design/DataTable/types'; -import { Indicator } from 'design/Indicator/Indicator'; -import { Mark } from 'design/Mark/Mark'; -import { - InfoExternalTextLink, - InfoGuideButton, - InfoParagraph, - ReferenceLinks, -} from 'shared/components/SlidingSidePanel/InfoGuide/InfoGuide'; +import { CardTile } from 'design/CardTile/CardTile'; +import Flex from 'design/Flex/Flex'; +import { SearchPanel } from 'shared/components/Search'; +import { InfoGuideButton } from 'shared/components/SlidingSidePanel/InfoGuide/InfoGuide'; import { EmptyState } from 'teleport/Bots/List/EmptyState/EmptyState'; import { @@ -39,120 +33,136 @@ import { FeatureHeader, FeatureHeaderTitle, } from 'teleport/components/Layout/Layout'; -import cfg from 'teleport/config'; import { listBotInstances } from 'teleport/services/bot/bot'; import { BotInstanceSummary } from 'teleport/services/bot/types'; import useTeleport from 'teleport/useTeleport'; -import { BotInstancesList } from './List/BotInstancesList'; +import { BotInstancesDashboard } from './Dashboard/BotInstanceDashboard'; +import { BotInstanceDetails } from './Details/BotInstanceDetails'; +import { InfoGuide } from './InfoGuide'; +import { + BotInstancesList, + BotInstancesListControls, +} from './List/BotInstancesList'; export function BotInstances() { const history = useHistory(); const location = useLocation<{ prevPageTokens?: readonly string[] }>(); const queryParams = new URLSearchParams(location.search); - const pageToken = queryParams.get('page') ?? ''; - const searchTerm = queryParams.get('search') ?? ''; - const sort = queryParams.get('sort') || 'active_at_latest:desc'; + const query = queryParams.get('query') ?? ''; + const isAdvancedQuery = queryParams.get('is_advanced') ?? ''; + const sortField = queryParams.get('sort_field') || 'active_at_latest'; + const sortDir = queryParams.get('sort_dir') || 'DESC'; + const selectedItemId = queryParams.get('selected'); + const activeTab = queryParams.get('tab'); + + const listRef = useRef(null); const ctx = useTeleport(); const flags = ctx.getFeatureFlags(); - const canListInstances = flags.listBotInstances; + const hasListPermission = flags.listBotInstances; - const { isPending, isFetching, isSuccess, isError, error, data } = useQuery({ - enabled: canListInstances, - queryKey: ['bot_instances', 'list', searchTerm, pageToken, sort], - queryFn: () => - listBotInstances({ - pageSize: 20, - pageToken, - searchTerm, - sort, - }), + const { + isSuccess, + data, + isLoading, + isFetchingNextPage, + error, + hasNextPage, + fetchNextPage, + } = useInfiniteQuery({ + enabled: hasListPermission, + queryKey: [ + 'bot_instances', + 'list', + sortField, + sortDir, + query, + isAdvancedQuery, + ], + queryFn: ({ pageParam, signal }) => + listBotInstances( + { + pageSize: 32, + pageToken: pageParam, + sortField, + sortDir, + searchTerm: isAdvancedQuery ? undefined : query, + query: isAdvancedQuery ? query : undefined, + }, + signal + ), + initialPageParam: '', + getNextPageParam: data => data?.next_page_token, placeholderData: keepPreviousData, staleTime: 30_000, // Cached pages are valid for 30 seconds }); - const { prevPageTokens = [] } = location.state ?? {}; - const hasNextPage = !!data?.next_page_token; - const hasPrevPage = !!pageToken; - - const handleFetchNext = useCallback(() => { - const search = new URLSearchParams(location.search); - search.set('page', data?.next_page_token ?? ''); - - history.replace( - { - pathname: location.pathname, - search: search.toString(), - }, - { - prevPageTokens: [...prevPageTokens, pageToken], + const handleQueryChange = useCallback( + (query: string, isAdvanced: boolean) => { + const search = new URLSearchParams(location.search); + if (query) { + search.set('query', query); + } else { + search.delete('query'); + } + if (isAdvanced) { + search.set('is_advanced', '1'); + } else { + search.delete('is_advanced'); } - ); - }, [ - data?.next_page_token, - history, - location.pathname, - location.search, - pageToken, - prevPageTokens, - ]); - - const handleFetchPrev = useCallback(() => { - const prevTokens = [...prevPageTokens]; - const nextToken = prevTokens.pop(); - - const search = new URLSearchParams(location.search); - search.set('page', nextToken ?? ''); - history.replace( - { - pathname: location.pathname, + history.push({ + pathname: `${location.pathname}`, search: search.toString(), - }, - { - prevPageTokens: prevTokens, - } - ); - }, [history, location.pathname, location.search, prevPageTokens]); + }); + + listRef.current?.scrollToTop(); + }, + [history, location.pathname, location.search] + ); - const handleSearchChange = useCallback( - (term: string) => { + const handleSortChanged = useCallback( + (sortField: string, sortDir: string) => { const search = new URLSearchParams(location.search); - search.set('search', term); - search.set('page', ''); + search.set('sort_field', sortField); + search.set('sort_dir', sortDir); history.replace({ - pathname: `${location.pathname}`, + pathname: location.pathname, search: search.toString(), }); + + listRef.current?.scrollToTop(); }, [history, location.pathname, location.search] ); - const onItemSelected = useCallback( - (item: BotInstanceSummary) => { - history.push( - cfg.getBotInstanceDetailsRoute({ - botName: item.bot_name, - instanceId: item.instance_id, - }) - ); + const handleItemSelected = useCallback( + (item: BotInstanceSummary | null) => { + const search = new URLSearchParams(location.search); + if (item) { + search.set('selected', `${item.bot_name}/${item.instance_id}`); + } else { + search.delete('selected'); + search.delete('tab'); + } + + history.push({ + pathname: location.pathname, + search: search.toString(), + }); }, - [history] + [history, location.pathname, location.search] ); - const sortType = useMemo(() => parseSortType(sort), [sort]); - - const handleSortChanged = useCallback( - (sortType: SortType) => { - const formattedSortType = formatSortType(sortType); - + const handleDetailsTabSelected = useCallback( + (tab: string) => { const search = new URLSearchParams(location.search); - search.set('sort', formattedSortType); - search.set('page', ''); - history.replace({ + search.set('tab', tab); + + history.push({ pathname: location.pathname, search: search.toString(), }); @@ -160,9 +170,22 @@ export function BotInstances() { [history, location.pathname, location.search] ); - const hasUnsupportedSortError = isUnsupportedSortError(error); + const [selectedBotName, selectedInstanceId] = + selectedItemId?.split('/') ?? []; + + const flatData = useMemo( + () => (isSuccess ? data.pages.flatMap(page => page.bot_instances) : null), + [data?.pages, isSuccess] + ); + + const handleFilterSelected = useCallback( + (filter: string) => { + handleQueryChange(filter, true); + }, + [handleQueryChange] + ); - if (!canListInstances) { + if (!hasListPermission) { return ( @@ -175,107 +198,84 @@ export function BotInstances() { } return ( - - - Bot instances + + + Bot Instances }} /> - {isPending ? ( - - - - ) : undefined} - - {isError && hasUnsupportedSortError ? ( - { - handleSortChanged({ fieldName: 'bot_name', dir: 'ASC' }); - }, + + - {`Error: ${error.message}`} - - ) : undefined} - - {isError && !hasUnsupportedSortError ? ( - {`Error: ${error.message}`} - ) : undefined} - - {isSuccess ? ( - handleQueryChange(query, false)} + updateQuery={query => handleQueryChange(query, true)} + mb={2} /> - ) : undefined} + + + + {selectedItemId ? ( + handleItemSelected(null)} + activeTab={activeTab} + onTabSelected={tab => handleDetailsTabSelected(tab)} + /> + ) : undefined} + + {!selectedItemId ? ( + + ) : undefined} + + ); } -const InfoGuide = () => ( - - - A{' '} - - Bot Instance - {' '} - identifies a single lineage of{' '} - - bot - {' '} - identities, even through certificate renewals and rejoins. When the{' '} - tbot client first authenticates to a cluster, a Bot Instance - is generated and its UUID is embedded in the returned client identity. - - - Bot Instances track a variety of information about tbot{' '} - instances, including regular heartbeats which include basic information - about the tbot host, like its architecture and OS version. - - - {' '} - Bot Instances have a relatively short lifespan and are set to expire after - the most recent identity issued for that instance will expire. If the{' '} - tbot client associated with a particular Bot Instance renews - or rejoins, the expiration of the bot instance is reset. This is designed - to allow users to list Bot Instances for an accurate view of the number of - active tbot clients interacting with their Teleport cluster. - - - -); +const Container = styled(Flex)` + flex-direction: column; + flex: 1; + overflow: auto; + padding-bottom: ${props => props.theme.space[3]}px; +`; + +const ContentContainer = styled(Flex)` + flex: 1; + overflow: auto; + gap: ${props => props.theme.space[2]}px; +`; -const InfoGuideReferenceLinks = { - BotInstances: { - title: 'What are Bot instances', - href: 'https://goteleport.com/docs/enroll-resources/machine-id/introduction/#bot-instances', - }, - Bots: { - title: 'What are Bots', - href: 'https://goteleport.com/docs/enroll-resources/machine-id/introduction/#bots', - }, - Tctl: { - title: 'Use tctl to manage bot instances', - href: 'https://goteleport.com/docs/reference/cli/tctl/#tctl-bots-instances-add', - }, -}; +const ListAndDetailsContainer = styled(CardTile)<{ $listOnlyMode: boolean }>` + flex-direction: row; + overflow: auto; + padding: 0; + gap: 0; + margin: ${props => props.theme.space[1]}px; -const isUnsupportedSortError = (error: Error) => { - return error?.message && error.message.includes('unsupported sort'); -}; + ${p => + p.$listOnlyMode + ? css` + min-width: 300px; + max-width: 400px; + ` + : ''} +`; diff --git a/web/packages/teleport/src/BotInstances/Dashboard/BotInstanceDashboard.story.tsx b/web/packages/teleport/src/BotInstances/Dashboard/BotInstanceDashboard.story.tsx new file mode 100644 index 0000000000000..fa327d5028d7e --- /dev/null +++ b/web/packages/teleport/src/BotInstances/Dashboard/BotInstanceDashboard.story.tsx @@ -0,0 +1,118 @@ +/** + * 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 . + */ + +/** + * 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 { createTeleportContext } from 'teleport/mocks/contexts'; +import { TeleportProviderBasic } from 'teleport/mocks/providers'; +import { + getBotInstanceMetricsError, + getBotInstanceMetricsForever, + getBotInstanceMetricsSuccess, +} from 'teleport/test/helpers/botInstances'; + +import { BotInstancesDashboard } from './BotInstanceDashboard'; + +const meta = { + title: 'Teleport/BotInstances/Dashboard', + component: Wrapper, + beforeEach: () => { + queryClient.clear(); // Prevent cached data sharing between stories + }, +} satisfies Meta; + +type Story = StoryObj; + +export default meta; + +export const Happy: Story = { + parameters: { + msw: { + handlers: [getBotInstanceMetricsSuccess()], + }, + }, +}; + +export const NoData: Story = { + parameters: { + msw: { + handlers: [ + getBotInstanceMetricsSuccess({ + upgrade_statuses: null, + refresh_after_seconds: 60_000, + }), + ], + }, + }, +}; + +export const Loading: Story = { + parameters: { + msw: { + handlers: [getBotInstanceMetricsForever()], + }, + }, +}; + +export const Error: Story = { + parameters: { + msw: { + handlers: [getBotInstanceMetricsError(500, 'something went wrong')], + }, + }, +}; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: false, + }, + }, +}); + +function Wrapper() { + const ctx = createTeleportContext(); + + return ( + + + {}} /> + + + ); +} diff --git a/web/packages/teleport/src/BotInstances/Dashboard/BotInstanceDashboard.test.tsx b/web/packages/teleport/src/BotInstances/Dashboard/BotInstanceDashboard.test.tsx new file mode 100644 index 0000000000000..12348d43f05b3 --- /dev/null +++ b/web/packages/teleport/src/BotInstances/Dashboard/BotInstanceDashboard.test.tsx @@ -0,0 +1,264 @@ +/** + * 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 { setupServer } from 'msw/node'; +import { ComponentProps, PropsWithChildren } from 'react'; + +import { darkTheme } from 'design/theme'; +import { ConfiguredThemeProvider } from 'design/ThemeProvider'; +import { + render, + screen, + testQueryClient, + userEvent, + waitForElementToBeRemoved, + within, +} from 'design/utils/testing'; + +import { + getBotInstanceMetricsError, + getBotInstanceMetricsSuccess, +} from 'teleport/test/helpers/botInstances'; + +import { BotInstancesDashboard } from './BotInstanceDashboard'; + +const server = setupServer(); + +beforeAll(() => { + server.listen(); +}); + +afterEach(async () => { + server.resetHandlers(); + await testQueryClient.resetQueries(); + + jest.clearAllMocks(); +}); + +afterAll(() => server.close()); + +describe('BotInstanceDashboard', () => { + it('renders', async () => { + withSuccessResponse(); + + renderComponent(); + + await waitForLoading(); + + expect(screen.getByText('Insights')).toBeInTheDocument(); + expect(screen.getByText('Version Compatibility')).toBeInTheDocument(); + + const upToDate = screen.getByLabelText('Up to date'); + expect(within(upToDate).getByText('100 (57%)')).toBeInTheDocument(); + + const patch = screen.getByLabelText('Patch available'); + expect(within(patch).getByText('50 (29%)')).toBeInTheDocument(); + + const upgrade = screen.getByLabelText('Upgrade required'); + expect(within(upgrade).getByText('25 (14%)')).toBeInTheDocument(); + + const unsupported = screen.getByLabelText('Unsupported'); + expect(within(unsupported).getByText('0 (0%)')).toBeInTheDocument(); + + expect( + screen.getByText('Select a category above to filter bot instances.') + ).toBeInTheDocument(); + }); + + it('shows no data message', async () => { + withSuccessResponse({ + upgrade_statuses: null, + refresh_after_seconds: 60_000, + }); + + renderComponent(); + + await waitForLoading(); + + expect(screen.getByText('No data available')).toBeInTheDocument(); + expect( + screen.queryByText('Select a status above to view instances.') + ).not.toBeInTheDocument(); + }); + + it('shows an error', async () => { + withErrorResponse(500, 'something went wrong'); + + renderComponent(); + + await waitForLoading(); + + expect(screen.getByText('something went wrong')).toBeInTheDocument(); + expect( + screen.queryByText('Select a status above to view instances.') + ).not.toBeInTheDocument(); + }); + + it('items are selectable', async () => { + const onFilterSelected = jest.fn(); + + withSuccessResponse(); + + const { user } = renderComponent({ props: { onFilterSelected } }); + + await waitForLoading(); + + { + const item = screen.getByLabelText('Up to date'); + await user.click(item); + expect(onFilterSelected).toHaveBeenCalledTimes(1); + expect(onFilterSelected).toHaveBeenLastCalledWith( + 'mock up-to-date filter' + ); + } + + { + const item = screen.getByLabelText('Patch available'); + await user.click(item); + expect(onFilterSelected).toHaveBeenCalledTimes(2); + expect(onFilterSelected).toHaveBeenLastCalledWith('mock patch filter'); + } + + { + const item = screen.getByLabelText('Upgrade required'); + await user.click(item); + expect(onFilterSelected).toHaveBeenCalledTimes(3); + expect(onFilterSelected).toHaveBeenLastCalledWith('mock upgrade filter'); + } + + { + const item = screen.getByLabelText('Unsupported'); + await user.click(item); + expect(onFilterSelected).toHaveBeenCalledTimes(4); + expect(onFilterSelected).toHaveBeenLastCalledWith( + 'mock unsupported filter' + ); + } + }); + + it('refreshes', async () => { + const onFilterSelected = jest.fn(); + + withSuccessResponse(); + + const { user } = renderComponent({ props: { onFilterSelected } }); + + await waitForLoading(); + + { + const upToDate = screen.getByLabelText('Up to date'); + expect(within(upToDate).getByText('100 (57%)')).toBeInTheDocument(); + } + + withSuccessResponse({ + upgrade_statuses: { + up_to_date: { + count: 99, + }, + patch_available: { + count: 0, + }, + requires_upgrade: { + count: 0, + }, + unsupported: { + count: 0, + }, + updated_at: '1970-01-01T00:00:00Z', + }, + refresh_after_seconds: 60_000, + }); + + const refreshButton = screen.getByLabelText('refresh'); + await user.click(refreshButton); + + { + const upToDate = screen.getByLabelText('Up to date'); + expect(within(upToDate).getByText('99 (100%)')).toBeInTheDocument(); + } + }); +}); + +function renderComponent(options?: { + props?: ComponentProps; +}) { + const { props } = options ?? {}; + const { onFilterSelected = jest.fn() } = props ?? {}; + + const user = userEvent.setup(); + + return { + ...render(, { + wrapper: makeWrapper(), + }), + user, + history, + }; +} + +function makeWrapper() { + return ({ children }: PropsWithChildren) => { + return ( + + + {children} + + + ); + }; +} + +async function waitForLoading() { + await waitForElementToBeRemoved(() => + screen.queryByTestId('loading-dashboard') + ); +} + +function withSuccessResponse( + mock: Parameters[0] = { + upgrade_statuses: { + up_to_date: { + count: 100, + filter: 'mock up-to-date filter', + }, + patch_available: { + count: 50, + filter: 'mock patch filter', + }, + requires_upgrade: { + count: 25, + filter: 'mock upgrade filter', + }, + unsupported: { + count: 0, + filter: 'mock unsupported filter', + }, + updated_at: new Date().toISOString(), + }, + refresh_after_seconds: 60_000, + } +) { + server.use(getBotInstanceMetricsSuccess(mock)); +} + +function withErrorResponse( + ...params: Parameters +) { + server.use(getBotInstanceMetricsError(...params)); +} diff --git a/web/packages/teleport/src/BotInstances/Dashboard/BotInstanceDashboard.tsx b/web/packages/teleport/src/BotInstances/Dashboard/BotInstanceDashboard.tsx new file mode 100644 index 0000000000000..c6148dfd1bd11 --- /dev/null +++ b/web/packages/teleport/src/BotInstances/Dashboard/BotInstanceDashboard.tsx @@ -0,0 +1,380 @@ +/** + * 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 { useQuery } from '@tanstack/react-query'; +import { format, formatDistanceToNowStrict, parseISO } from 'date-fns'; +import { useEffect, useState } from 'react'; +import styled, { useTheme } from 'styled-components'; + +import { Alert } from 'design/Alert/Alert'; +import Box from 'design/Box/Box'; +import ButtonIcon from 'design/ButtonIcon/ButtonIcon'; +import { CardTile } from 'design/CardTile/CardTile'; +import Flex from 'design/Flex'; +import { Refresh } from 'design/Icon'; +import { Indicator } from 'design/Indicator/Indicator'; +import Text, { H2, H3 } from 'design/Text'; +import { IconTooltip } from 'design/Tooltip'; +import { HoverTooltip } from 'design/Tooltip/HoverTooltip'; + +import { getBotInstanceMetrics } from 'teleport/services/bot/bot'; +import { GetBotInstanceMetricsResponse } from 'teleport/services/bot/types'; + +export function BotInstancesDashboard(props: { + /** + * Callback used when a dashbaord item is selected (e.g. "unsupported" + * instance versions). The given filter is used as an advanced query (in the + * Teleport predicate language) to filter the items in the instances list. + * + * @param filter query (verbatum) used to filter the bot instance list. + */ + onFilterSelected: (filter: string) => void; +}) { + const { onFilterSelected } = props; + + const { data, error, isLoading, isPending, refetch } = useQuery({ + queryKey: ['bot_instance', 'metrics'], + queryFn: ({ signal }) => getBotInstanceMetrics(null, signal), + // The metrics endpoint (used by this query) returns a + // `refresh_after_seconds` value to indicate how frequently the client + // should poll for updated metrics, which may take jitter into account. This + // allows the polling rate to most closely match the backend data refresh, + // and allows the rate to be controlled server-side. + // + // The `refetchInterval` is set to this value from the lasty successful + // response, otherwise 1 min as a fallback. + refetchInterval: ({ state }) => + (state.data?.refresh_after_seconds ?? 60) * 1_000, + }); + + // Used to keep "Last updated x minutes ago" label current + useTick(30_000); + + return ( + + +

Insights

+ + refetch()} + aria-label="refresh" + disabled={isLoading} + > + + + +
+ + + {error ? ( + + {error.message} + + ) : undefined} + + {isLoading ? ( + + + + ) : undefined} + + {isPending ? undefined : ( + <> + + + + + {data?.upgrade_statuses ? ( + + Select a category above to filter bot instances. + + ) : undefined} + + )} +
+ ); +} + +const Container = styled(CardTile)` + flex-direction: column; + flex-basis: 100%; + margin: ${props => props.theme.space[1]}px; + padding: 0; + gap: 0; +`; + +const TitleContainer = styled(Flex)` + align-items: center; + justify-content: space-between; + min-height: ${p => p.theme.space[8]}px; + padding-left: ${p => p.theme.space[3]}px; + padding-right: ${p => p.theme.space[3]}px; + gap: ${p => p.theme.space[2]}px; +`; + +const Divider = styled.div` + height: 1px; + flex-shrink: 0; + background-color: ${p => p.theme.colors.interactive.tonal.neutral[0]}; +`; + +const InnerContainer = styled(Flex)` + overflow: auto; + flex-direction: column; + padding: ${p => p.theme.space[3]}px; +`; + +function UpgradeStatusChart(props: { + data: GetBotInstanceMetricsResponse['upgrade_statuses']; + onFilterSelected: (status: string) => void; +}) { + const { data, onFilterSelected } = props; + + const theme = useTheme(); + + const max = Math.max( + 1, // Never zero + data?.up_to_date?.count ?? 0, + data?.patch_available?.count ?? 0, + data?.requires_upgrade?.count ?? 0, + data?.unsupported?.count ?? 0 + ); + + const total = Math.max( + 1, // Never zero + (data?.up_to_date?.count ?? 0) + + (data?.patch_available?.count ?? 0) + + (data?.requires_upgrade?.count ?? 0) + + (data?.unsupported?.count ?? 0) + ); + + const series = data + ? [ + { + name: 'Up to date', + percent: (data.up_to_date?.count ?? 0) / max, + count: data.up_to_date?.count ?? 0, + label: `${data.up_to_date?.count ?? 0}\xa0(${formatPercent((data.up_to_date?.count ?? 0) / total)})`, + color: theme.colors.interactive.solid.success.default, + onClick: () => + data.up_to_date?.filter + ? onFilterSelected(data.up_to_date?.filter) + : undefined, + tooltip: + 'Up-to-date instances are running the same version as the Teleport cluster.', + }, + { + name: 'Patch available', + percent: (data.patch_available?.count ?? 0) / max, + count: data.patch_available?.count ?? 0, + label: `${data.patch_available?.count ?? 0}\xa0(${formatPercent((data.patch_available?.count ?? 0) / total)})`, + color: theme.colors.interactive.solid.accent.default, + onClick: () => + data.patch_available?.filter + ? onFilterSelected(data.patch_available?.filter) + : undefined, + tooltip: + 'Instances with a patch available are running the same major version as the Teleport cluster.', + }, + { + name: 'Upgrade required', + percent: (data.requires_upgrade?.count ?? 0) / max, + count: data.requires_upgrade?.count ?? 0, + label: `${data.requires_upgrade?.count ?? 0}\xa0(${formatPercent((data.requires_upgrade?.count ?? 0) / total)})`, + color: theme.colors.interactive.solid.alert.default, + onClick: () => + data.requires_upgrade?.filter + ? onFilterSelected(data.requires_upgrade?.filter) + : undefined, + tooltip: + 'Instances requiring an upgrade are running the one major version behind the Teleport cluster.', + }, + { + name: 'Unsupported', + percent: (data.unsupported?.count ?? 0) / max, + count: data.unsupported?.count ?? 0, + label: `${data.unsupported?.count ?? 0}\xa0(${formatPercent((data.unsupported?.count ?? 0) / total)})`, + color: theme.colors.interactive.solid.danger.default, + onClick: () => + data.unsupported?.filter + ? onFilterSelected(data.unsupported?.filter) + : undefined, + tooltip: + 'Unsupported instances are running two or more major versions behind the Teleport cluster, or are running a newer version.', + }, + ] + : null; + + return ( + + +

Version Compatibility

+ {data?.updated_at ? ( + + + Last updated{' '} + {formatDistanceToNowStrict(parseISO(data.updated_at))} ago + + + ) : undefined} +
+ + {series ? ( + series.map(s => ( + { + if (event.key === 'Enter') { + s.onClick(); + } + }} + role="button" + tabIndex={0} + aria-label={`${s.name}`} + > + + {s.name} + + {s.tooltip} + + + + + )) + ) : ( + No data available + )} + +
+ ); +} + +const UpgradeStatusContainer = styled(Flex)` + flex-direction: column; + padding: ${({ theme }) => theme.space[3]}px; + border-radius: ${({ theme }) => theme.space[2]}px; + gap: ${({ theme }) => theme.space[3]}px; + border: 1px solid ${p => p.theme.colors.interactive.tonal.neutral[0]}; +`; + +const BarsContainer = styled(Flex)` + flex-direction: column; +`; + +const SeriesContainer = styled.div` + padding: ${({ theme }) => theme.space[2]}px ${({ theme }) => theme.space[3]}px; + border-radius: ${({ theme }) => theme.space[2]}px; + + cursor: pointer; + + &:hover { + background-color: ${({ theme }) => theme.colors.levels.sunken}; + } + &:focus, + &:active { + outline: none; + + background-color: ${({ theme }) => theme.colors.levels.deep}; + } + + transition: background-color 200ms linear; +`; + +const ChartLabelContainer = styled(Flex)` + align-items: center; + gap: ${({ theme }) => theme.space[2]}px; +`; + +const ChartLabelText = styled(Text)` + white-space: nowrap; + font-size: ${({ theme }) => theme.fontSizes[1]}px; +`; + +const ChartNoDataContainer = styled(Flex)` + align-items: center; + justify-content: center; + padding: ${({ theme }) => theme.space[4]}px; + color: ${({ theme }) => theme.colors.text.muted}; +`; + +const ChartUpdatedAtText = styled(Text)` + font-size: ${({ theme }) => theme.fontSizes[1]}px; + font-weight: ${({ theme }) => theme.fontWeights.medium}; + text-align: right; +`; + +function Bar(props: { percent: number; label: string; color: string }) { + const { percent, label, color } = props; + + return ( + + + {label} + + ); +} + +const BarContainer = styled(Flex)` + align-items: center; + gap: ${({ theme }) => theme.space[2]}px; +`; + +const BarAmount = styled.div<{ $percent: number; $color: string }>` + flex-grow: ${({ $percent }) => $percent}; + background-color: ${({ $color }) => $color}; + height: ${({ theme }) => theme.space[3]}px; + border-radius: ${({ theme }) => theme.space[1]}px; + min-width: ${({ theme }) => theme.space[1]}px; + + transition: flex-grow 1000ms ease-in-out; +`; + +const BarLabel = styled.div<{ $percent: number }>` + flex-grow: ${({ $percent }) => 1 - $percent}; + + transition: flex-grow 1000ms ease-in-out; +`; + +function formatPercent(percent: number) { + return `${(percent * 100).toFixed(0)}%`; +} + +/** + * A hook which ticks at the given interval and will cause a re-render of + * components which use it. Useful for updating messaging such as "updated 10 + * seconds ago". + * @param interval how often to tick (in milliseconds) + * @returns A date instance representing the last tick + */ +function useTick(interval: number) { + const [tick, setTick] = useState(new Date()); + + useEffect(() => { + const id = setInterval(() => setTick(new Date()), interval); + return () => clearInterval(id); + }); + + return tick; +} diff --git a/web/packages/teleport/src/BotInstances/Details/BotInstanceDetails.story.tsx b/web/packages/teleport/src/BotInstances/Details/BotInstanceDetails.story.tsx new file mode 100644 index 0000000000000..6b2a7224192fd --- /dev/null +++ b/web/packages/teleport/src/BotInstances/Details/BotInstanceDetails.story.tsx @@ -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 . + */ + +import { Meta, StoryObj } from '@storybook/react-vite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useState } from 'react'; + +import { CardTile } from 'design/CardTile'; + +import { createTeleportContext } from 'teleport/mocks/contexts'; +import { TeleportProviderBasic } from 'teleport/mocks/providers'; +import { defaultAccess, makeAcl } from 'teleport/services/user/makeAcl'; +import { + getBotInstanceError, + getBotInstanceForever, + getBotInstanceSuccess, + mockGetBotInstanceResponse, +} from 'teleport/test/helpers/botInstances'; + +import { BotInstanceDetails } from './BotInstanceDetails'; + +const meta = { + title: 'Teleport/BotInstances/Details', + component: Wrapper, + beforeEach: () => { + queryClient.clear(); // Prevent cached data sharing between stories + }, +} satisfies Meta; + +type Story = StoryObj; + +export default meta; + +export const Happy: Story = { + parameters: { + msw: { + handlers: [getBotInstanceSuccess()], + }, + }, +}; + +export const ZeroServices: Story = { + parameters: { + msw: { + handlers: [ + getBotInstanceSuccess({ + ...mockGetBotInstanceResponse, + bot_instance: { + ...mockGetBotInstanceResponse.bot_instance, + status: { + ...mockGetBotInstanceResponse.bot_instance.status, + service_health: [], + }, + }, + }), + ], + }, + }, +}; + +export const ErrorLoadingList: Story = { + parameters: { + msw: { + handlers: [getBotInstanceError(500, 'something went wrong')], + }, + }, +}; + +export const StillLoadingList: Story = { + parameters: { + msw: { + handlers: [getBotInstanceForever()], + }, + }, +}; + +export const NoReadPermission: Story = { + args: { + hasBotInstanceReadPermission: false, + }, + parameters: { + msw: { + handlers: [getBotInstanceError(500, 'this call should never be made')], + }, + }, +}; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: false, + }, + }, +}); + +function Wrapper(props?: { hasBotInstanceReadPermission?: boolean }) { + const { hasBotInstanceReadPermission = true } = props ?? {}; + + const [activeTab, setActiveTab] = useState('info'); + + const customAcl = makeAcl({ + botInstances: { + ...defaultAccess, + read: hasBotInstanceReadPermission, + }, + }); + + const ctx = createTeleportContext({ + customAcl, + }); + + return ( + + + + {}} + activeTab={activeTab} + onTabSelected={tab => setActiveTab(tab)} + /> + + + + ); +} diff --git a/web/packages/teleport/src/BotInstances/Details/BotInstanceDetails.test.tsx b/web/packages/teleport/src/BotInstances/Details/BotInstanceDetails.test.tsx index 9ab039f29fc8f..efe8f93f14dfe 100644 --- a/web/packages/teleport/src/BotInstances/Details/BotInstanceDetails.test.tsx +++ b/web/packages/teleport/src/BotInstances/Details/BotInstanceDetails.test.tsx @@ -17,24 +17,25 @@ */ import { QueryClientProvider } from '@tanstack/react-query'; -import { createMemoryHistory } from 'history'; import { setupServer } from 'msw/node'; -import { PropsWithChildren } from 'react'; -import { MemoryRouter, Router } from 'react-router'; +import { ComponentProps, PropsWithChildren } from 'react'; import darkTheme from 'design/theme/themes/darkTheme'; import { ConfiguredThemeProvider } from 'design/ThemeProvider'; -import { copyToClipboard } from 'design/utils/copyToClipboard'; import { - fireEvent, render, screen, testQueryClient, + userEvent, waitForElementToBeRemoved, + within, } from 'design/utils/testing'; -import { Route } from 'teleport/components/Router'; -import cfg from 'teleport/config'; +import 'shared/components/TextEditor/TextEditor.mock'; + +import { createTeleportContext } from 'teleport/mocks/contexts'; +import { TeleportProviderBasic } from 'teleport/mocks/providers'; +import { defaultAccess, makeAcl } from 'teleport/services/user/makeAcl'; import { getBotInstanceError, getBotInstanceSuccess, @@ -42,20 +43,6 @@ import { import { BotInstanceDetails } from './BotInstanceDetails'; -jest.mock('shared/components/TextEditor/TextEditor', () => { - return { - __esModule: true, - default: MockTextEditor, - }; -}); - -jest.mock('design/utils/copyToClipboard', () => { - return { - __esModule: true, - copyToClipboard: jest.fn(), - }; -}); - const server = setupServer(); beforeAll(() => { @@ -71,91 +58,78 @@ afterEach(async () => { afterAll(() => server.close()); -const withSuccessResponse = () => { - server.use( - getBotInstanceSuccess({ - bot_instance: { - spec: { - instance_id: '4fa10e68-f2e0-4cf9-ad5b-1458febcd827', - }, - }, - yaml: 'kind: bot_instance\nversion: v1\n', - }) - ); -}; - -const withErrorResponse = () => { - server.use(getBotInstanceError(500)); -}; - describe('BotIntanceDetails', () => { - it('Allows back navigation', async () => { - const history = createMemoryHistory({ - initialEntries: [ - '/web/bot/test-bot-name/instance/4fa10e68-f2e0-4cf9-ad5b-1458febcd827', - ], - }); - history.goBack = jest.fn(); - + it('Allows close action', async () => { + const onClose = jest.fn(); withSuccessResponse(); - renderComponent({ history }); + const { user } = renderComponent({ props: { onClose } }); await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); - const backButton = screen.getByLabelText('back'); - fireEvent.click(backButton); + const closeButton = screen.getByLabelText('close'); + await user.click(closeButton); - expect(history.goBack).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); }); - it('Shows the short instance id', async () => { + it('Allows switching tab', async () => { + const onTabSelected = jest.fn(); + withSuccessResponse(); - renderComponent(); + const { user } = renderComponent({ props: { onTabSelected } }); await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); - expect(screen.getByText('4fa10e6')).toBeInTheDocument(); + const overviewTab = screen.getByRole('tab', { name: 'Overview' }); + await user.click(overviewTab); + expect(onTabSelected).toHaveBeenCalledTimes(1); + expect(onTabSelected).toHaveBeenLastCalledWith('info'); + + const servicesTab = screen.getByRole('tab', { name: 'Services' }); + await user.click(servicesTab); + expect(onTabSelected).toHaveBeenCalledTimes(2); + expect(onTabSelected).toHaveBeenLastCalledWith('health'); + + const yamlTab = screen.getByRole('tab', { name: 'YAML' }); + await user.click(yamlTab); + expect(onTabSelected).toHaveBeenCalledTimes(3); + expect(onTabSelected).toHaveBeenLastCalledWith('yaml'); }); - it('Allows the full instance id to be copied', async () => { + it('Shows instance info', async () => { withSuccessResponse(); - renderComponent(); + renderComponent({ props: { activeTab: 'info' } }); await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); - const copyButton = screen.getByLabelText('copy'); - fireEvent.click(copyButton); - - expect(copyToClipboard).toHaveBeenCalledTimes(1); - expect(copyToClipboard).toHaveBeenLastCalledWith( - '4fa10e68-f2e0-4cf9-ad5b-1458febcd827' - ); + const summarySection = screen + .getByRole('heading', { + name: 'Summary', + }) + .closest('section'); + expect( + within(summarySection!).getByText('test-bot-name') + ).toBeInTheDocument(); }); - it('Shows a docs link', async () => { - const onClick = jest.fn(e => { - e.preventDefault(); - }); - + it('Shows instance services', async () => { withSuccessResponse(); - renderComponent({ onDocsLinkClicked: onClick }); + renderComponent({ props: { activeTab: 'health' } }); await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); - const docsButton = screen.getByText('View Documentation'); - fireEvent.click(docsButton); - - expect(onClick).toHaveBeenCalledTimes(1); + const item = screen.getByTestId('application-tunnel-1'); + expect(within(item!).getByText('application-tunnel-1')).toBeInTheDocument(); }); it('Shows full yaml', async () => { withSuccessResponse(); - renderComponent(); + renderComponent({ props: { activeTab: 'yaml' } }); await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); @@ -171,55 +145,88 @@ describe('BotIntanceDetails', () => { await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); + expect(screen.getByText('something went wrong')).toBeInTheDocument(); + }); + + it('Shows a permisison warning', async () => { + withErrorResponse(); + + renderComponent({ + hasBotInstanceReadPermission: false, + }); + expect( - screen.getByText('Error: 500', { exact: false }) + screen.getByText('You do not have permission to read Bot instances', { + exact: false, + }) ).toBeInTheDocument(); + + expect(screen.getByText('bot_instance.read')).toBeInTheDocument(); }); }); -const renderComponent = async (options?: { - history?: ReturnType; - onDocsLinkClicked?: (e: unknown) => void; +const renderComponent = (options?: { + props?: Partial>; + hasBotInstanceReadPermission?: boolean; }) => { - const { onDocsLinkClicked } = options ?? {}; - render( - , - { - wrapper: makeWrapper(options), - } - ); + const { props, ...rest } = options ?? {}; + const { + botName = 'test-bot-name', + instanceId = '4fa10e68-f2e0-4cf9-ad5b-1458febcd827', + onClose = jest.fn(), + activeTab = 'info', + onTabSelected = jest.fn(), + } = props ?? {}; + + const user = userEvent.setup(); + + return { + ...render( + , + { + wrapper: makeWrapper(rest), + } + ), + user, + }; }; -function makeWrapper(options?: { - history?: ReturnType; -}) { - const { - history = createMemoryHistory({ - initialEntries: [ - '/web/bot/test-bot-name/instance/4fa10e68-f2e0-4cf9-ad5b-1458febcd827', - ], - }), - } = options ?? {}; +function makeWrapper(options?: { hasBotInstanceReadPermission?: boolean }) { + const { hasBotInstanceReadPermission = true } = options ?? {}; + const customAcl = makeAcl({ + botInstances: { + ...defaultAccess, + read: hasBotInstanceReadPermission, + }, + }); + + const ctx = createTeleportContext({ + customAcl, + }); return (props: PropsWithChildren) => { return ( - - + + - - {props.children} - + {props.children} - - + + ); }; } -function MockTextEditor(props: { data?: [{ content: string }] }) { - return ( -
- {props.data?.map(d =>
{d.content}
)} -
- ); -} +const withSuccessResponse = () => { + server.use(getBotInstanceSuccess()); +}; + +const withErrorResponse = () => { + server.use(getBotInstanceError(500, 'something went wrong')); +}; diff --git a/web/packages/teleport/src/BotInstances/Details/BotInstanceDetails.tsx b/web/packages/teleport/src/BotInstances/Details/BotInstanceDetails.tsx index 25a0720a5b36e..83bf5689f16ec 100644 --- a/web/packages/teleport/src/BotInstances/Details/BotInstanceDetails.tsx +++ b/web/packages/teleport/src/BotInstances/Details/BotInstanceDetails.tsx @@ -16,138 +16,212 @@ * along with this program. If not, see . */ -import { MouseEventHandler, useCallback } from 'react'; -import { useHistory, useLocation, useParams } from 'react-router'; import styled from 'styled-components'; import { Alert } from 'design/Alert/Alert'; import Box from 'design/Box/Box'; -import { ButtonBorder } from 'design/Button/Button'; import ButtonIcon from 'design/ButtonIcon/ButtonIcon'; import Flex from 'design/Flex/Flex'; -import { ArrowLeft } from 'design/Icon/Icons/ArrowLeft'; +import { Cross } from 'design/Icon/Icons/Cross'; import { Indicator } from 'design/Indicator/Indicator'; -import Text from 'design/Text'; +import { TabBorder, TabContainer, TabsContainer } from 'design/Tabs/Tabs'; +import { useSlidingBottomBorderTabs } from 'design/Tabs/useSlidingBottomBorderTabs'; +import Text from 'design/Text/Text'; import { HoverTooltip } from 'design/Tooltip/HoverTooltip'; -import { CopyButton } from 'shared/components/CopyButton/CopyButton'; import TextEditor from 'shared/components/TextEditor/TextEditor'; -import { - FeatureBox, - FeatureHeader, - FeatureHeaderTitle, -} from 'teleport/components/Layout/Layout'; -import cfg from 'teleport/config'; +import useTeleport from 'teleport/useTeleport'; import { useGetBotInstance } from '../hooks'; - -const docsUrl = - 'https://goteleport.com/docs/enroll-resources/machine-id/introduction/#bot-instances'; +import { HealthTab } from './HealthTab'; +import { InfoTab } from './InfoTab'; export function BotInstanceDetails(props: { - onDocsLinkClickedForTesting?: MouseEventHandler; + botName: string; + instanceId: string; + onClose: () => void; + activeTab?: string | null; + onTabSelected: (tab: string) => void; }) { - const history = useHistory(); - const location = useLocation(); - const params = useParams<{ - botName: string; - instanceId: string; - }>(); + const { botName, instanceId, onClose, activeTab, onTabSelected } = props; + + const ctx = useTeleport(); + const flags = ctx.getFeatureFlags(); + const hasReadPermission = flags.readBotInstances; const { data, error, isSuccess, isError, isLoading } = useGetBotInstance( - params, { + botName, + instanceId, + }, + { + enabled: hasReadPermission, staleTime: 30_000, // Keep data in the cache for 30 seconds } ); - const handleBackPress = useCallback(() => { - // If location.key is unset, or 'default', this is the first history entry in-app in the session. - if (!location.key || location.key === 'default') { - history.push(cfg.getBotInstancesRoute()); - } else { - history.goBack(); - } - }, [history, location.key]); + const tab = tabs.find(t => t.id === activeTab)?.id ?? 'info'; return ( - - - - - - - - - Bot instance - {isSuccess && data.bot_instance?.spec?.instance_id ? ( - - - - {data.bot_instance.spec.instance_id.substring(0, 7)} - - - - - ) : undefined} - - - - View Documentation - - - - {isLoading ? ( - - - - ) : undefined} - - {isError ? ( - Error: {error.message} - ) : undefined} - - {isSuccess && data.yaml ? ( - - - - ) : undefined} - + + + + {botName}/{instanceId} + + + onClose()} aria-label="close"> + + + + + + + {isLoading ? ( + + + + ) : undefined} + + {isError ? ( + + {error.message} + + ) : undefined} + + {!hasReadPermission ? ( + + You do not have permission to read Bot instances. Missing role + permissions: bot_instance.read + + ) : undefined} + + {isSuccess ? ( + <> + + + {tab === 'info' ? ( + + onTabSelected('health')} + /> + + ) : undefined} + + {tab === 'health' ? : undefined} + + {tab === 'yaml' ? ( + + ) : undefined} + + ) : undefined} + + ); } -const MonoText = styled(Text)` - font-family: ${({ theme }) => theme.fonts.mono}; +const Container = styled.section` + display: flex; + flex-direction: column; + flex: 1; + border-left-color: ${p => p.theme.colors.interactive.tonal.neutral[0]}; + border-left-width: 1px; + border-left-style: solid; + overflow: hidden; + min-width: 300px; `; -const InstanceId = styled.div` - display: flex; +const TitleContainer = styled(Flex)` align-items: center; - padding-left: ${props => props.theme.space[2]}px; - padding-right: ${props => props.theme.space[2]}px; - height: ${props => props.theme.space[5]}px; - border-radius: ${props => props.theme.space[3]}px; - background-color: ${({ theme }) => theme.colors.interactive.tonal.neutral[0]}; + justify-content: space-between; + min-height: ${p => p.theme.space[8]}px; + padding-left: ${p => p.theme.space[3]}px; + padding-right: ${p => p.theme.space[3]}px; + gap: ${p => p.theme.space[2]}px; + overflow: hidden; +`; + +export const TitleText = styled(Text).attrs({ + as: 'h2', + typography: 'h2', +})` + flex: 1; + white-space: nowrap; +`; + +const Divider = styled.div` + height: 1px; + flex-shrink: 0; + background-color: ${p => p.theme.colors.interactive.tonal.neutral[0]}; +`; + +const ContentContainer = styled.div` + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; `; -const YamlContaner = styled(Flex)` +const TabContentContainer = styled(Flex)` + overflow: auto; flex: 1; - border-radius: ${props => props.theme.space[2]}px; - background-color: ${({ theme }) => theme.colors.levels.elevated}; + background-color: ${({ theme }) => theme.colors.levels.surface}; +`; + +const tabs = [ + { + id: 'info', + label: 'Overview', + }, + { + id: 'health', + label: 'Services', + }, + { id: 'yaml', label: 'YAML' }, +] as const; + +type TabId = (typeof tabs)[number]['id']; + +function Tabs(props: { + activeTab: TabId; + onTabSelected: (tab: TabId) => void; +}) { + const { activeTab, onTabSelected } = props; + const { borderRef, parentRef } = useSlidingBottomBorderTabs({ activeTab }); + + return ( + + {tabs.map(t => ( + onTabSelected(t.id)} + role="tab" + > + {t.label} + + ))} + + + ); +} + +const StyledTabsContainer = styled(TabsContainer)` + gap: 0; +`; + +const StyledTabContainer = styled(TabContainer)` + padding: ${p => p.theme.space[2]}px ${p => p.theme.space[3]}px; + font-weight: ${p => p.theme.fontWeights.medium}; + font-size: ${p => p.theme.fontSizes[2]}px; `; diff --git a/web/packages/teleport/src/BotInstances/Details/HealthTab.test.tsx b/web/packages/teleport/src/BotInstances/Details/HealthTab.test.tsx new file mode 100644 index 0000000000000..8e752fb08bd03 --- /dev/null +++ b/web/packages/teleport/src/BotInstances/Details/HealthTab.test.tsx @@ -0,0 +1,125 @@ +/** + * 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 userEvent from '@testing-library/user-event'; +import { ComponentProps, PropsWithChildren } from 'react'; + +import darkTheme from 'design/theme/themes/darkTheme'; +import { ConfiguredThemeProvider } from 'design/ThemeProvider'; +import { render, screen, within } from 'design/utils/testing'; + +import { mockGetBotInstanceResponse } from 'teleport/test/helpers/botInstances'; + +import { HealthTab } from './HealthTab'; + +beforeAll(() => { + jest.useFakeTimers({ + now: new Date('2025-10-10T11:00:00Z'), + }); +}); + +afterAll(() => { + jest.useRealTimers(); +}); + +describe('HealthTab', () => { + // eslint-disable-next-line jest/expect-expect + it('renders', async () => { + renderComponent(); + + expectItem({ + name: 'application-tunnel-1', + type: 'application-tunnel', + updatedAt: 'Reported 15 minutes ago', + status: 'Healthy', + }); + + expectItem({ + name: 'db-eu-lon-1', + type: 'database-tunnel', + updatedAt: 'Reported 14 minutes ago', + status: 'Unhealthy', + reason: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + }); + + expectItem({ + name: 'workload-identity-aws-roles-anywhere-1', + type: 'workload-identity-aws-roles-anywhere', + updatedAt: 'Reported 13 minutes ago', + status: 'Initializing', + }); + + expectItem({ + name: 'application-tunnel-2', + type: 'application-tunnel', + updatedAt: 'Reported 12 minutes ago', + status: 'Unknown', + }); + }); + + it('show an empty state', async () => { + renderComponent({ + data: { + bot_instance: { + status: { + service_health: [], + }, + }, + }, + }); + + expect(screen.getByText('No reported services')).toBeInTheDocument(); + }); +}); + +function expectItem(match: { + name: string; + type: string; + updatedAt: string; + status: string; + reason?: string; +}) { + const item = screen.getByTestId(match.name); + expect(within(item).getByText(match.name)).toBeInTheDocument(); + expect(within(item).getByText(`Type: ${match.type}`)).toBeInTheDocument(); + expect(within(item).getByText(match.updatedAt)).toBeInTheDocument(); + expect(within(item).getByText(match.status)).toBeInTheDocument(); + if (match.reason) { + expect(within(item).getByText(match.reason)).toBeInTheDocument(); + } +} + +function renderComponent(props?: Partial>) { + const { data = mockGetBotInstanceResponse } = props ?? {}; + const user = userEvent.setup(); + + return { + ...render(, { wrapper: makeWrapper() }), + user, + history, + }; +} + +function makeWrapper() { + return (props: PropsWithChildren) => ( + + {props.children} + + ); +} diff --git a/web/packages/teleport/src/BotInstances/Details/HealthTab.tsx b/web/packages/teleport/src/BotInstances/Details/HealthTab.tsx new file mode 100644 index 0000000000000..7fe320a7552ff --- /dev/null +++ b/web/packages/teleport/src/BotInstances/Details/HealthTab.tsx @@ -0,0 +1,201 @@ +/** + * 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 format from 'date-fns/format'; +import formatDistanceToNowStrict from 'date-fns/formatDistanceToNowStrict'; +import styled from 'styled-components'; + +import Flex from 'design/Flex'; +import { SecondaryOutlined } from 'design/Label/Label'; +import Text from 'design/Text'; +import { HoverTooltip } from 'design/Tooltip/HoverTooltip'; + +import { + BotInstanceServiceHealthStatus, + GetBotInstanceResponse, +} from 'teleport/services/bot/types'; + +export function HealthTab(props: { data: GetBotInstanceResponse }) { + const { data } = props; + const { bot_instance } = data ?? {}; + const { status } = bot_instance ?? {}; + const { service_health } = status ?? {}; + + return ( + + {service_health?.length ? ( + service_health + ?.toSorted((a, b) => + (a.service?.name ?? '').localeCompare(b.service?.name ?? '') + ) + .map(h => + h.service?.name ? ( + + + + {h.service.name} + {h.service.type ? ( + Type: {h.service.type} + ) : undefined} + + + + {h.updated_at?.seconds ? ( + + {`Reported ${formatDistanceToNowStrict(new Date(h.updated_at.seconds * 1000))} ago`} + + ) : undefined} + + + + {makeHealthLabel(h.status)} + + + + + + + + {h.reason ? ( + + {h.reason} + + ) : undefined} + + ) : undefined + ) + ) : ( + No reported services + )} + + ); +} + +const Container = styled(Flex)` + flex-direction: column; + flex: 1; + min-width: 0; + padding: ${({ theme }) => theme.space[3]}px; + gap: ${({ theme }) => theme.space[3]}px; + overflow: auto; +`; + +const ItemContainer = styled(Flex)` + flex-direction: column; + border: 1px solid ${p => p.theme.colors.interactive.tonal.neutral[0]}; + border-radius: ${({ theme }) => theme.space[1]}px; + padding: ${({ theme }) => theme.space[3]}px; + gap: ${({ theme }) => theme.space[3]}px; +`; + +export const HealthStatusDot = styled.div<{ + $status: BotInstanceServiceHealthStatus | undefined; +}>` + width: ${({ theme }) => theme.space[3] - theme.space[1]}px; + height: ${({ theme }) => theme.space[3] - theme.space[1]}px; + border-radius: 999px; + background-color: ${({ theme, $status }) => + $status === + BotInstanceServiceHealthStatus.BOT_INSTANCE_HEALTH_STATUS_HEALTHY + ? theme.colors.interactive.solid.success.default + : $status === + BotInstanceServiceHealthStatus.BOT_INSTANCE_HEALTH_STATUS_UNHEALTHY + ? theme.colors.interactive.solid.danger.default + : $status === + BotInstanceServiceHealthStatus.BOT_INSTANCE_HEALTH_STATUS_INITIALIZING + ? theme.colors.interactive.tonal.neutral[1] + : theme.colors.interactive.solid.alert.default}; +`; + +const ReasonContainer = styled.div<{ + $status: BotInstanceServiceHealthStatus | undefined; +}>` + border-width: 0; + border-left-width: ${({ theme }) => theme.space[1]}px; + border-style: solid; + border-color: ${({ theme, $status }) => + $status === + BotInstanceServiceHealthStatus.BOT_INSTANCE_HEALTH_STATUS_HEALTHY + ? theme.colors.interactive.solid.success.default + : $status === + BotInstanceServiceHealthStatus.BOT_INSTANCE_HEALTH_STATUS_UNHEALTHY + ? theme.colors.interactive.solid.danger.default + : $status === + BotInstanceServiceHealthStatus.BOT_INSTANCE_HEALTH_STATUS_INITIALIZING + ? theme.colors.interactive.tonal.neutral[1] + : theme.colors.interactive.solid.alert.default}; + padding: 0 ${({ theme }) => theme.space[2]}px; +`; + +const TitleText = styled(Text).attrs({ + typography: 'body2', +})` + white-space: nowrap; + font-weight: ${({ theme }) => theme.fontWeights.medium}; +`; + +const EmptyText = styled(Text)` + color: ${p => p.theme.colors.text.muted}; +`; + +const TimeText = styled(Text).attrs({ + typography: 'body4', +})` + white-space: nowrap; +`; + +function makeHealthLabel(status: BotInstanceServiceHealthStatus | undefined) { + if ( + status === + BotInstanceServiceHealthStatus.BOT_INSTANCE_HEALTH_STATUS_INITIALIZING + ) { + return 'Initializing'; + } + if ( + status === BotInstanceServiceHealthStatus.BOT_INSTANCE_HEALTH_STATUS_HEALTHY + ) { + return 'Healthy'; + } + if ( + status === + BotInstanceServiceHealthStatus.BOT_INSTANCE_HEALTH_STATUS_UNHEALTHY + ) { + return 'Unhealthy'; + } + return 'Unknown'; +} diff --git a/web/packages/teleport/src/BotInstances/Details/InfoTab.test.tsx b/web/packages/teleport/src/BotInstances/Details/InfoTab.test.tsx new file mode 100644 index 0000000000000..56437fc718508 --- /dev/null +++ b/web/packages/teleport/src/BotInstances/Details/InfoTab.test.tsx @@ -0,0 +1,185 @@ +/** + * 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 . + */ + +// Required to allow using `.closest()` to find sections +/* eslint-disable testing-library/no-node-access */ + +import userEvent from '@testing-library/user-event'; +import { createMemoryHistory } from 'history'; +import { ComponentProps, PropsWithChildren } from 'react'; +import { Router } from 'react-router'; + +import { darkTheme } from 'design/theme'; +import { ConfiguredThemeProvider } from 'design/ThemeProvider'; +import { render, screen, within } from 'design/utils/testing'; + +import cfg from 'teleport/config'; +import { mockGetBotInstanceResponse } from 'teleport/test/helpers/botInstances'; + +import { InfoTab } from './InfoTab'; + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe('InfoTab', () => { + it('renders summary section', async () => { + renderComponent(); + + const section = screen + .getByRole('heading', { name: 'Summary' }) + .closest('section'); + expect(section).toBeInTheDocument(); + + expectFieldAndValue('Bot name', 'test-bot-name', section); + expectFieldAndValue('Up time', '12h 1m', section); + expectFieldAndValue('Kind', 'tctl', section); + expectFieldAndValue('Version', 'v18.4.0', section); + expectFieldAndValue('OS', 'linux', section); + expectFieldAndValue('Hostname', 'test-hostname', section); + }); + + it('renders health section', async () => { + renderComponent(); + + const section = screen + .getByRole('heading', { name: 'Health Status' }) + .closest('section'); + + expect( + within(section!).getByText( + (_, element) => element?.textContent === '1 of 4 services are healthy' + ) + ).toBeInTheDocument(); + + expect( + within(section!).getByText('application-tunnel-1', {}) + ).toBeInTheDocument(); + expect(within(section!).getByText('db-eu-lon-1', {})).toBeInTheDocument(); + expect( + within(section!).getByText('workload-identity-aws-roles-anywhere-1', {}) + ).toBeInTheDocument(); + expect( + within(section!).getByText('application-tunnel-2', {}) + ).toBeInTheDocument(); + }); + + it('renders join token section', async () => { + renderComponent(); + + const section = screen + .getByRole('heading', { name: 'Join Token' }) + .closest('section'); + expect(section).toBeInTheDocument(); + + expectFieldAndValue('Name', 'test-token-name', section); + expectFieldAndValue('Method', 'github', section); + expectFieldAndValue('Repository', 'gravitational/teleport', section); + expectFieldAndValue('Subject', 'test-github-sub', section); + }); + + it('navigate on bot name link click', async () => { + const { history, user } = renderComponent(); + const pushMock = jest.spyOn(history, 'push'); + + const section = screen + .getByRole('heading', { name: 'Summary' }) + .closest('section'); + expect(section).toBeInTheDocument(); + + const link = within(section!).getByText('test-bot-name'); + await user.click(link); + + expect(pushMock).toHaveBeenCalledTimes(1); + expect(pushMock).toHaveBeenLastCalledWith('/web/bot/test-bot-name'); + }); + + it('navigate on join token name link click', async () => { + const { history, user } = renderComponent(); + const pushMock = jest.spyOn(history, 'push'); + + const section = screen + .getByRole('heading', { name: 'Join Token' }) + .closest('section'); + expect(section).toBeInTheDocument(); + + const link = within(section!).getByText('test-token-name'); + await user.click(link); + + expect(pushMock).toHaveBeenCalledTimes(1); + expect(pushMock).toHaveBeenLastCalledWith('/web/tokens'); + }); + + it('callback on "view services" click', async () => { + const callback = jest.fn(); + const { user } = renderComponent({ onGoToServicesClick: callback }); + + const section = screen + .getByRole('heading', { name: 'Health Status' }) + .closest('section'); + + const button = within(section!).getByText('View Services'); + await user.click(button); + + expect(callback).toHaveBeenCalledTimes(1); + }); +}); + +function expectFieldAndValue( + field: string, + value: string, + container?: HTMLElement | null +) { + if (container) { + expect(within(container).getByText(field)).toBeInTheDocument(); + expect(within(container).getByText(value)).toBeInTheDocument(); + } else { + expect(screen.getByText(field)).toBeInTheDocument(); + expect(screen.getByText(value)).toBeInTheDocument(); + } +} + +function renderComponent(props?: Partial>) { + const { data = mockGetBotInstanceResponse, onGoToServicesClick = jest.fn() } = + props ?? {}; + const user = userEvent.setup(); + + const history = createMemoryHistory({ + initialEntries: [cfg.getBotInstancesRoute()], + }); + + return { + ...render( + , + { wrapper: makeWrapper({ history }) } + ), + user, + history, + }; +} + +function makeWrapper(options: { + history: ReturnType; +}) { + return (props: PropsWithChildren) => ( + + {/* A Router with history is required to render */} + {props.children} + + ); +} diff --git a/web/packages/teleport/src/BotInstances/Details/InfoTab.tsx b/web/packages/teleport/src/BotInstances/Details/InfoTab.tsx new file mode 100644 index 0000000000000..393bab7e678ae --- /dev/null +++ b/web/packages/teleport/src/BotInstances/Details/InfoTab.tsx @@ -0,0 +1,401 @@ +/** + * 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 from 'react'; +import { Link } from 'react-router-dom'; +import styled from 'styled-components'; + +import Box from 'design/Box/Box'; +import Flex from 'design/Flex/Flex'; +import { SecondaryOutlined } from 'design/Label/Label'; +import Text from 'design/Text'; +import { HoverTooltip } from 'design/Tooltip/HoverTooltip'; +import { IconTooltip } from 'design/Tooltip/IconTooltip'; +import { CopyButton } from 'shared/components/CopyButton/CopyButton'; + +import { Panel } from 'teleport/Bots/Details/Panel'; +import { formatDuration } from 'teleport/Bots/formatDuration'; +import cfg from 'teleport/config'; +import { + BotInstanceKind, + BotInstanceServiceHealthStatus, + GetBotInstanceResponse, + GetBotInstanceResponseJoinAttrs, +} from 'teleport/services/bot/types'; + +import { HealthStatusDot } from './HealthTab'; + +export function InfoTab(props: { + data: GetBotInstanceResponse; + onGoToServicesClick: () => void; +}) { + const { + data: { bot_instance }, + onGoToServicesClick, + } = props; + + const { spec, status } = bot_instance ?? {}; + const { bot_name } = spec ?? {}; + const { latest_heartbeats, latest_authentications, service_health } = + status ?? {}; + const latestHeartbeat = latest_heartbeats?.at(-1); + const { kind, uptime, version, os, hostname } = latestHeartbeat ?? {}; + const latestAuthentication = latest_authentications?.at(-1); + const { join_attrs } = latestAuthentication ?? {}; + const { meta } = join_attrs ?? {}; + const { join_method, join_token_name } = meta ?? {}; + + const joinExtras = makeJoinExtras(join_attrs); + + const { kindLabel, kindTooltip } = makeKindInfo(kind) ?? {}; + + const healthyCount = + service_health?.filter( + h => + h.status === + BotInstanceServiceHealthStatus.BOT_INSTANCE_HEALTH_STATUS_HEALTHY + ).length ?? 0; + const totalCount = service_health?.length ?? 0; + + return ( + + + + + Bot name + {bot_name ? ( + + + + {bot_name} + + + + + ) : ( + '-' + )} + Up time + {uptime?.seconds + ? formatDuration( + { seconds: uptime.seconds }, + { + separator: ' ', + } + ) + : '-'} + Kind + {kindLabel ? ( + + {kindLabel} + + {kindTooltip} + + + ) : ( + '-' + )} + Version + {version ? `v${version}` : '-'} + OS + {os || '-'} + Hostname + {hostname || '-'} + + + + + + + + +
+ {healthyCount} of{' '} + {totalCount} services are healthy +
+ + + {service_health + ?.toSorted((a, b) => + (a.service?.name ?? '').localeCompare(b.service?.name ?? '') + ) + .map(h => + h.service?.name ? ( + + + + + {h.service.name} + + + + ) : undefined + )} + +
+
+ + + + + + + Name + {join_token_name ? ( + + + + {join_token_name} + + + + + ) : ( + '-' + )} + Method + {join_method || '-'} + + {joinExtras + ? joinExtras.map(([label, value]) => ( + + {label} + {value || '-'} + + )) + : undefined} + + + +
+ ); +} + +const Container = styled.div` + flex: 1; + min-width: 0; +`; + +const PanelContentContainer = styled(Flex)` + flex-direction: column; + padding: ${props => props.theme.space[3]}px; + padding-top: 0; + overflow: hidden; +`; + +const Grid = styled(Box)` + align-self: flex-start; + display: grid; + grid-template-columns: repeat(2, auto); + gap: ${({ theme }) => theme.space[2]}px; + overflow: hidden; +`; + +const GridLabel = styled(Text)` + color: ${({ theme }) => theme.colors.text.muted}; + font-weight: ${({ theme }) => theme.fontWeights.regular}; + padding-right: ${({ theme }) => theme.space[2]}px; +`; + +const GridValue = styled(Text)` + white-space: nowrap; +`; + +const PaddedDivider = styled.div` + height: 1px; + background-color: ${p => p.theme.colors.interactive.tonal.neutral[0]}; + flex-shrink: 0; + margin-left: ${props => props.theme.space[3]}px; + margin-right: ${props => props.theme.space[3]}px; +`; + +const HealthLabelsContainer = styled(Flex)` + flex-wrap: wrap; + overflow: hidden; + gap: ${props => props.theme.space[1]}px; +`; + +const HealthLabelText = styled(Text).attrs({ + typography: 'body3', +})` + white-space: nowrap; +`; + +const AccentCountText = styled(Text)` + font-size: ${({ theme }) => theme.fontSizes[8]}px; + font-weight: ${({ theme }) => theme.fontWeights.light}px; +`; + +const StyledLink = styled(Link)` + color: ${({ theme }) => theme.colors.interactive.solid.accent.default}; + background: none; + text-decoration: underline; + text-transform: none; + + &:hover { + color: ${({ theme }) => theme.colors.interactive.solid.accent.hover}; + } + + &:active { + color: ${({ theme }) => theme.colors.interactive.solid.accent.active}; + } +`; + +function makeKindInfo(kind: BotInstanceKind | undefined) { + if (kind === BotInstanceKind.BOT_KIND_TBOT) { + return { + kindLabel: 'tbot', + kindTooltip: 'This instance is running using the tbot CLI.', + }; + } + if (kind === BotInstanceKind.BOT_KIND_TERRAFORM_PROVIDER) { + return { + kindLabel: 'Terraform', + kindTooltip: + 'This instance is running using the Teleport Terraform Provider.', + }; + } + if (kind === BotInstanceKind.BOT_KIND_KUBERNETES_OPERATOR) { + return { + kindLabel: 'Kubernetes', + kindTooltip: + 'This instance is running using the Teleport Kubernetes Operator.', + }; + } + if (kind === BotInstanceKind.BOT_KIND_TCTL) { + return { + kindLabel: 'tctl', + kindTooltip: 'This instance is running inside tctl.', + }; + } + + return undefined; +} + +function makeHealthTooltip(status: BotInstanceServiceHealthStatus | undefined) { + if ( + status === + BotInstanceServiceHealthStatus.BOT_INSTANCE_HEALTH_STATUS_INITIALIZING + ) { + return 'Status: Initializing'; + } + if ( + status === BotInstanceServiceHealthStatus.BOT_INSTANCE_HEALTH_STATUS_HEALTHY + ) { + return 'Status: Healthy'; + } + if ( + status === + BotInstanceServiceHealthStatus.BOT_INSTANCE_HEALTH_STATUS_UNHEALTHY + ) { + return 'Status: Unhealthy'; + } + return 'Status: Unspecified'; +} + +function makeJoinExtras( + joinAttrs?: GetBotInstanceResponseJoinAttrs | null +): [string, string | undefined][] { + if (joinAttrs?.azure) { + return [ + ['Resource group', joinAttrs.azure.resource_group], + ['Subscription', joinAttrs.azure.subscription], + ]; + } + if (joinAttrs?.azure_devops) { + return [ + ['Repository ID', joinAttrs.azure_devops.pipeline?.repository_id], + ['Subject', joinAttrs.azure_devops.pipeline?.sub], + ]; + } + if (joinAttrs?.bitbucket) { + return [ + ['Repository UUID', joinAttrs.bitbucket.repository_uuid], + ['Subject', joinAttrs.bitbucket.sub], + ]; + } + if (joinAttrs?.circleci) { + return [ + ['Project ID', joinAttrs.circleci.project_id], + ['Subject', joinAttrs.circleci.sub], + ]; + } + if (joinAttrs?.gcp) { + return [['Service account', joinAttrs.gcp.service_account]]; + } + if (joinAttrs?.github) { + return [ + ['Repository', joinAttrs.github.repository], + ['Subject', joinAttrs.github.sub], + ]; + } + if (joinAttrs?.gitlab) { + return [ + ['Project path', joinAttrs.gitlab.project_path], + ['Subject', joinAttrs.gitlab.sub], + ]; + } + if (joinAttrs?.iam) { + return [ + ['Account', joinAttrs.iam.account], + ['ARN', joinAttrs.iam.arn], + ]; + } + if (joinAttrs?.kubernetes) { + return [['Subject', joinAttrs.kubernetes.subject]]; + } + if (joinAttrs?.oracle) { + return [ + ['Tenancy ID', joinAttrs.oracle.tenancy_id], + ['Compartment ID', joinAttrs.oracle.compartment_id], + ]; + } + if (joinAttrs?.spacelift) { + return [ + ['Space ID', joinAttrs.spacelift.space_id], + ['Subject', joinAttrs.spacelift.sub], + ]; + } + if (joinAttrs?.terraform_cloud) { + return [ + ['Workspace', joinAttrs.terraform_cloud.full_workspace], + ['Subject', joinAttrs.terraform_cloud.sub], + ]; + } + if (joinAttrs?.tpm) { + return [['Public key', joinAttrs.tpm.ek_pub_hash]]; + } + return []; +} diff --git a/web/packages/teleport/src/BotInstances/InfoGuide.tsx b/web/packages/teleport/src/BotInstances/InfoGuide.tsx new file mode 100644 index 0000000000000..7df54624989e2 --- /dev/null +++ b/web/packages/teleport/src/BotInstances/InfoGuide.tsx @@ -0,0 +1,82 @@ +/** + * 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 Box from 'design/Box/Box'; +import { Mark } from 'design/Mark/Mark'; +import { + InfoExternalTextLink, + InfoParagraph, + ReferenceLinks, +} from 'shared/components/SlidingSidePanel/InfoGuide/InfoGuide'; + +export function InfoGuide() { + return ( + + + A{' '} + + Bot Instance + {' '} + identifies a single lineage of{' '} + + bot + {' '} + identities, even through certificate renewals and rejoins. When the{' '} + tbot client first authenticates to a cluster, a Bot + Instance is generated and its UUID is embedded in the returned client + identity. + + + Bot Instances track a variety of information about tbot{' '} + instances, including regular heartbeats which include basic information + about the tbot host, like its architecture and OS version. + + + Bot Instances have a relatively short lifespan and are set to expire + after the most recent identity issued for that instance expires. If the{' '} + tbot client associated with a particular Bot Instance + renews or rejoins, the expiration of the bot instance is reset. This is + designed to allow users to list Bot Instances for an accurate view of + the number of active tbot clients interacting with their + Teleport cluster. + + + + ); +} + +const InfoGuideReferenceLinks = { + BotInstances: { + title: 'What are Bot instances', + href: 'https://goteleport.com/docs/enroll-resources/machine-id/introduction/#bot-instances', + }, + Bots: { + title: 'What are Bots', + href: 'https://goteleport.com/docs/enroll-resources/machine-id/introduction/#bots', + }, + Tctl: { + title: 'Use tctl to manage bot instances', + href: 'https://goteleport.com/docs/reference/cli/tctl/#tctl-bots-instances-add', + }, +}; diff --git a/web/packages/teleport/src/BotInstances/List/BotInstancesList.story.tsx b/web/packages/teleport/src/BotInstances/List/BotInstancesList.story.tsx new file mode 100644 index 0000000000000..f6606d633f8bc --- /dev/null +++ b/web/packages/teleport/src/BotInstances/List/BotInstancesList.story.tsx @@ -0,0 +1,199 @@ +/** + * 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 } from '@tanstack/react-query'; +import { ComponentProps, useRef, useState } from 'react'; + +import { CardTile } from 'design/CardTile/CardTile'; + +import { createTeleportContext } from 'teleport/mocks/contexts'; +import { TeleportProviderBasic } from 'teleport/mocks/providers'; +import { BotInstanceSummary } from 'teleport/services/bot/types'; + +import { BotInstancesList, BotInstancesListControls } from './BotInstancesList'; + +const meta = { + title: 'Teleport/BotInstances/List', + component: Wrapper, + beforeEach: () => { + queryClient.clear(); // Prevent cached data sharing between stories + }, +} satisfies Meta; + +type Story = StoryObj; + +export default meta; + +export const Happy: Story = {}; + +export const Empty: Story = { + args: { + data: [], + }, +}; + +export const EmptyWithFilter: Story = { + args: { + data: [], + isFiltering: true, + }, +}; + +export const ErrorLoadingList: Story = { + args: { + error: new Error('something went wrong'), + }, +}; + +export const NoMoreToLoad: Story = { + args: { + hasNextPage: false, + }, +}; + +export const LoadingMore: Story = { + args: { + isFetchingNextPage: true, + }, +}; + +export const UnsupportedSort: Story = { + args: { + error: new Error('unsupported sort: something went wrong'), + }, +}; + +export const StillLoadingList: Story = { + args: { + isLoading: true, + }, +}; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: false, + }, + }, +}); + +function Wrapper( + props?: Partial< + Pick< + ComponentProps, + | 'error' + | 'isLoading' + | 'hasNextPage' + | 'isFetchingNextPage' + | 'data' + | 'isFiltering' + > + > +) { + const { + data = [ + { + bot_name: 'ansible-worker', + instance_id: `966c0850-9bb5-4ed7-af2d-4b1f202a936a`, + active_at_latest: '2025-07-22T10:54:00Z', + host_name_latest: 'my-svc.my-namespace.svc.cluster-domain.example', + join_method_latest: 'github', + os_latest: 'linux', + version_latest: '2.4.0', + }, + { + bot_name: 'ansible-worker', + instance_id: 'ac7135ce-fde6-4a91-bd77-ba7419e1c175', + active_at_latest: '2025-07-22T10:54:00Z', + host_name_latest: 'win-123a', + join_method_latest: 'tpm', + os_latest: 'windows', + version_latest: '4.3.18+ab12hd', + }, + { + bot_name: 'ansible-worker', + instance_id: '5283f4a9-c49b-4876-be48-b5f83000e612', + active_at_latest: '2025-07-22T10:54:00Z', + host_name_latest: 'mac-007', + join_method_latest: 'kubernetes', + os_latest: 'darwin', + version_latest: '3.9.99', + }, + ], + error = null, + hasNextPage = true, + isFetchingNextPage = false, + isLoading = false, + isFiltering = false, + } = props ?? {}; + + const [allData, setAllData] = useState(data); + const [selected, setSelected] = useState(null); + const [sortField, setSortField] = useState('active_at_latest'); + const [sortDir, setSortDir] = useState<'ASC' | 'DESC'>('ASC'); + + const listRef = useRef(null); + + const ctx = createTeleportContext(); + + return ( + + + { + setSortField(sortField); + setSortDir(sortDir); + listRef.current?.scrollToTop(); + }} + onLoadNextPage={() => + setAllData(existing => { + const newData = (data ?? []).map(i => { + return { + ...i, + instance_id: crypto.randomUUID(), + }; + }); + return [...(existing ?? []), ...newData]; + }) + } + onItemSelected={function (item: BotInstanceSummary | null): void { + setSelected(item ? `${item.bot_name}/${item.instance_id}` : null); + }} + isFiltering={isFiltering} + /> + + + ); +} diff --git a/web/packages/teleport/src/BotInstances/List/BotInstancesList.test.tsx b/web/packages/teleport/src/BotInstances/List/BotInstancesList.test.tsx new file mode 100644 index 0000000000000..4f2004d21aac2 --- /dev/null +++ b/web/packages/teleport/src/BotInstances/List/BotInstancesList.test.tsx @@ -0,0 +1,344 @@ +/** + * 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 { ComponentProps, PropsWithChildren } from 'react'; + +import darkTheme from 'design/theme/themes/darkTheme'; +import { ConfiguredThemeProvider } from 'design/ThemeProvider'; +import { + render, + screen, + testQueryClient, + userEvent, +} from 'design/utils/testing'; + +import { createTeleportContext } from 'teleport/mocks/contexts'; +import { TeleportProviderBasic } from 'teleport/mocks/providers'; + +import { BotInstancesList } from './BotInstancesList'; + +jest.mock('design/utils/copyToClipboard', () => { + return { + __esModule: true, + copyToClipboard: jest.fn(), + }; +}); + +afterEach(async () => { + jest.clearAllMocks(); +}); + +describe('BotIntancesList', () => { + it('renders items', async () => { + renderComponent(); + + expect(screen.getByText('ansible-worker/966c085')).toBeInTheDocument(); + expect(screen.getByText('ansible-worker/ac7135c')).toBeInTheDocument(); + expect(screen.getByText('ansible-worker/5283f4a')).toBeInTheDocument(); + }); + + it('select an item', async () => { + const onItemSelected = jest.fn(); + + const { user } = renderComponent({ + props: { + onItemSelected, + }, + }); + + const item2 = screen.getByRole('listitem', { + name: 'ansible-worker/ac7135ce-fde6-4a91-bd77-ba7419e1c175', + }); + await user.click(item2); + + expect(onItemSelected).toHaveBeenCalledTimes(1); + expect(onItemSelected).toHaveBeenLastCalledWith({ + active_at_latest: '2025-07-22T10:54:00Z', + bot_name: 'ansible-worker', + host_name_latest: 'win-123a', + instance_id: 'ac7135ce-fde6-4a91-bd77-ba7419e1c175', + join_method_latest: 'tpm', + os_latest: 'windows', + version_latest: '4.3.18+ab12hd', + }); + }); + + it('Shows a loading state', async () => { + renderComponent({ + props: { + isLoading: true, + }, + }); + + expect(screen.getByTestId('loading')).toBeInTheDocument(); + }); + + it('Shows an empty state', async () => { + renderComponent({ + props: { + data: [], + }, + }); + + expect(screen.getByText('No active instances')).toBeInTheDocument(); + expect( + screen.getByText( + 'Bot instances are ephemeral, and disappear once all issued credentials have expired.' + ) + ).toBeInTheDocument(); + }); + + it('Shows an error', async () => { + renderComponent({ + props: { + error: new Error('something went wrong'), + }, + }); + + expect(screen.getByText('something went wrong')).toBeInTheDocument(); + }); + + it('Allows fetch more action', async () => { + const onLoadNextPage = jest.fn(); + + const { user } = renderComponent({ + props: { + hasNextPage: true, + onLoadNextPage, + }, + }); + + const action = screen.getByText('Load More'); + await user.click(action); + + expect(onLoadNextPage).toHaveBeenCalledTimes(1); + }); + + it('Prevents next page action when no page', async () => { + const onLoadNextPage = jest.fn(); + + const { user } = renderComponent({ + props: { + hasNextPage: false, + onLoadNextPage, + }, + }); + + const action = screen.getByText('Load More'); + await user.click(action); + + expect(action).toBeDisabled(); + expect(onLoadNextPage).not.toHaveBeenCalled(); + }); + + it('Prevents next page action when loading next page', async () => { + const onLoadNextPage = jest.fn(); + + const { user } = renderComponent({ + props: { + hasNextPage: true, + isFetchingNextPage: true, + onLoadNextPage, + }, + }); + + const action = screen.getByText('Load More'); + await user.click(action); + + expect(action).toBeDisabled(); + expect(onLoadNextPage).not.toHaveBeenCalled(); + }); + + it('Allows sort change', async () => { + const onSortChanged = jest.fn(); + + const { user } = renderComponent({ + props: { + onSortChanged, + sortField: 'active_at_latest', + sortDir: 'DESC', + }, + }); + + const fieldAction = screen.getByRole('button', { name: 'Sort by' }); + await user.click(fieldAction); + + expect( + screen.getByRole('menuitem', { name: 'Bot name' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('menuitem', { name: 'Recent' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('menuitem', { name: 'Hostname' }) + ).toBeInTheDocument(); + const versionOption = screen.getByRole('menuitem', { name: 'Version' }); + await user.click(versionOption); + + expect(onSortChanged).toHaveBeenLastCalledWith('version_latest', 'DESC'); + + const dirAction = screen.getByRole('button', { name: 'Sort direction' }); + await user.click(dirAction); + + // The component under test does not keep sort state so the sort field will + // be 'active_at_latest' on the next change. + expect(onSortChanged).toHaveBeenLastCalledWith('active_at_latest', 'ASC'); + }); + + it('Shows an unsupported sort error', async () => { + const onSortChanged = jest.fn(); + + const { user } = renderComponent({ + props: { + onSortChanged, + sortField: 'active_at_latest', + sortDir: 'DESC', + error: new Error('unsupported sort: foo'), + }, + }); + + expect(screen.getByText('unsupported sort: foo')).toBeInTheDocument(); + + const resetAction = screen.getByRole('button', { name: 'Reset sort' }); + await user.click(resetAction); + + expect(onSortChanged).toHaveBeenLastCalledWith('bot_name', 'ASC'); + }); + + describe('When filtering', () => { + it('Shows an alterate title', async () => { + renderComponent({ + props: { + isFiltering: true, + }, + }); + + expect( + screen.getByRole('heading', { name: 'Filtered Instances' }) + ).toBeInTheDocument(); + }); + + it('Shows an empty state', async () => { + renderComponent({ + props: { + data: [], + isFiltering: true, + }, + }); + + expect( + screen.getByText('No instances matching filter') + ).toBeInTheDocument(); + expect( + screen.queryByText( + 'Bot instances are ephemeral, and disappear once all issued credentials have expired.' + ) + ).not.toBeInTheDocument(); + }); + }); +}); + +const renderComponent = (options?: { + props: Partial>; +}) => { + const { props } = options ?? {}; + const { + data = mockData, + isLoading = false, + isFetchingNextPage = false, + error = null, + hasNextPage = true, + sortField = 'bot_name', + sortDir = 'ASC', + selectedItem = null, + onSortChanged = jest.fn(), + onLoadNextPage = jest.fn(), + onItemSelected = jest.fn(), + isFiltering = false, + } = props ?? {}; + + const user = userEvent.setup(); + return { + ...render( + , + { + wrapper: makeWrapper(), + } + ), + user, + }; +}; + +function makeWrapper() { + const ctx = createTeleportContext(); + return (props: PropsWithChildren) => { + return ( + + + + {props.children} + + + + ); + }; +} + +const mockData = [ + { + bot_name: 'ansible-worker', + instance_id: `966c0850-9bb5-4ed7-af2d-4b1f202a936a`, + active_at_latest: '2025-07-22T10:54:00Z', + host_name_latest: 'my-svc.my-namespace.svc.cluster-domain.example', + join_method_latest: 'github', + os_latest: 'linux', + version_latest: '2.4.0', + }, + { + bot_name: 'ansible-worker', + instance_id: 'ac7135ce-fde6-4a91-bd77-ba7419e1c175', + active_at_latest: '2025-07-22T10:54:00Z', + host_name_latest: 'win-123a', + join_method_latest: 'tpm', + os_latest: 'windows', + version_latest: '4.3.18+ab12hd', + }, + { + bot_name: 'ansible-worker', + instance_id: '5283f4a9-c49b-4876-be48-b5f83000e612', + active_at_latest: '2025-07-22T10:54:00Z', + host_name_latest: 'mac-007', + join_method_latest: 'kubernetes', + os_latest: 'darwin', + version_latest: '3.9.99', + }, +]; diff --git a/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx b/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx index 505e131ffba06..86e83710342fa 100644 --- a/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx +++ b/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx @@ -16,143 +16,241 @@ * along with this program. If not, see . */ -import format from 'date-fns/format'; -import formatDistanceToNowStrict from 'date-fns/formatDistanceToNowStrict'; -import parseISO from 'date-fns/parseISO'; +import React, { forwardRef, useImperativeHandle } from 'react'; import styled from 'styled-components'; -import { Info } from 'design/Alert/Alert'; -import { Cell, LabelCell } from 'design/DataTable/Cells'; -import Table from 'design/DataTable/Table'; -import { FetchingConfig, SortType } from 'design/DataTable/types'; -import Flex from 'design/Flex'; +import { Alert, Info } from 'design/Alert/Alert'; +import Box from 'design/Box/Box'; +import { ButtonSecondary } from 'design/Button/Button'; +import Flex from 'design/Flex/Flex'; +import { Indicator } from 'design/Indicator/Indicator'; import Text from 'design/Text'; -import { HoverTooltip } from 'design/Tooltip/HoverTooltip'; -import { CopyButton } from 'shared/components/CopyButton/CopyButton'; -import { SearchPanel } from 'shared/components/Search'; +import { SortMenu } from 'shared/components/Controls/SortMenu'; +import { Instance } from 'teleport/Bots/Details/Instance'; import { BotInstanceSummary } from 'teleport/services/bot/types'; -const MonoText = styled(Text)` - font-family: ${({ theme }) => theme.fonts.mono}; -`; +export const BotInstancesList = forwardRef(InternalBotInstancesList); + +export type BotInstancesListControls = { + scrollToTop: () => void; +}; + +function InternalBotInstancesList( + props: { + data: BotInstanceSummary[] | null | undefined; + isLoading: boolean; + isFetchingNextPage: boolean; + error: Error | null | undefined; + hasNextPage: boolean; + sortField: string; + sortDir: 'ASC' | 'DESC'; + selectedItem: string | null; + onSortChanged: (sortField: string, sortDir: 'ASC' | 'DESC') => void; + onLoadNextPage: () => void; + onItemSelected: (item: BotInstanceSummary) => void; + isFiltering: boolean; + }, + ref: React.RefObject +) { + const { + data, + isLoading, + isFetchingNextPage, + error, + hasNextPage, + sortField, + sortDir, + selectedItem, + onSortChanged, + onLoadNextPage, + onItemSelected, + isFiltering, + } = props; + + const contentRef = React.useRef(null); + useImperativeHandle(ref, () => { + return { + scrollToTop() { + contentRef.current?.scrollTo({ top: 0, behavior: 'instant' }); + }, + }; + }, [contentRef]); -export function BotInstancesList({ - data, - fetchStatus, - onFetchNext, - onFetchPrev, - searchTerm, - onSearchChange, - onItemSelected, - sortType, - onSortChanged, -}: { - data: BotInstanceSummary[]; - searchTerm: string; - onSearchChange: (term: string) => void; - onItemSelected: (item: BotInstanceSummary) => void; - sortType: SortType; - onSortChanged: (sortType: SortType) => void; -} & Omit) { - const tableData = data.map(x => ({ - ...x, - hostnameDisplay: x.host_name_latest ?? '-', - instanceIdDisplay: x.instance_id.substring(0, 7), - versionDisplay: x.version_latest ? `v${x.version_latest}` : '-', - active_at_latest: x.active_at_latest - ? `${formatDistanceToNowStrict(parseISO(x.active_at_latest))} ago` - : '-', - activeAtLocal: x.active_at_latest - ? format(parseISO(x.active_at_latest), 'PP, p z') - : '-', - })); + const hasError = !!error; + const hasData = !hasError && !isLoading; + const hasUnsupportedSortError = isUnsupportedSortError(error); + + const makeOnSelectedCallback = (instance: BotInstanceSummary) => () => { + onItemSelected(instance); + }; return ( - - data={tableData} - fetching={{ - fetchStatus, - onFetchNext, - onFetchPrev, - disableLoadingIndicator: true, - }} - serversideProps={{ - sort: sortType, - setSort: onSortChanged, - serversideSearchPanel: ( - - ), - }} - row={{ - onClick: onItemSelected, - getStyle: () => ({ cursor: 'pointer' }), - }} - columns={[ - { - key: 'bot_name', - headerText: 'Bot', - isSortable: true, - }, - { - key: 'instanceIdDisplay', - headerText: 'ID', - isSortable: false, - render: ({ instance_id, instanceIdDisplay }) => ( - - - {instanceIdDisplay} - - - - ), - }, - { - key: 'join_method_latest', - headerText: 'Method', - isSortable: false, - render: ({ join_method_latest }) => - join_method_latest ? ( - - ) : ( - {'-'} - ), - }, - { - key: 'hostnameDisplay', - headerText: 'Hostname', - isSortable: false, - }, - { - key: 'versionDisplay', - headerText: 'Version (tbot)', - isSortable: false, - }, - { - key: 'active_at_latest', - headerText: 'Last heartbeat', - isSortable: true, - render: ({ active_at_latest, activeAtLocal }) => ( - - - {active_at_latest} - - - ), - }, - ]} - emptyText="No active instances found" - emptyButton={ - - Bot instances are ephemeral, and disappear once all issued credentials - have expired. - - } - /> + + + + {isFiltering ? 'Filtered Instances' : 'Active Instances'} + + { + onSortChanged(value.fieldName, value.dir); + }} + /> + + + + + {isLoading ? ( + + + + ) : undefined} + + {hasError && hasUnsupportedSortError ? ( + { + onSortChanged('bot_name', 'ASC'); + }, + }} + > + {error.message} + + ) : undefined} + + {hasError && !hasUnsupportedSortError ? ( + + Failed to fetch instances + + ) : undefined} + + {hasData ? ( + <> + {data && data.length > 0 ? ( + + {data.map((instance, i) => ( + + {i === 0 ? undefined : } + + + ))} + + + + + onLoadNextPage()} + disabled={!hasNextPage || isFetchingNextPage} + > + Load More + + + + ) : ( + + + {isFiltering + ? 'No instances matching filter' + : 'No active instances'} + + {!isFiltering ? ( + + Bot instances are ephemeral, and disappear once all issued + credentials have expired. + + ) : undefined} + + )} + + ) : undefined} + ); } + +const Container = styled.section` + display: flex; + flex-direction: column; + flex: 1; + min-width: 300px; + max-width: 400px; +`; + +const TitleContainer = styled(Flex)` + align-items: center; + justify-content: space-between; + gap: ${p => p.theme.space[2]}px; + padding-left: ${p => p.theme.space[3]}px; + padding-right: ${p => p.theme.space[3]}px; + min-height: ${p => p.theme.space[8]}px; +`; + +export const TitleText = styled(Text).attrs({ + as: 'h2', + typography: 'h2', +})``; + +const ContentContainer = styled.div` + overflow: auto; +`; + +const LoadMoreContainer = styled(Flex)` + justify-content: center; + padding: ${props => props.theme.space[3]}px; +`; + +const Divider = styled.div` + height: 1px; + flex-shrink: 0; + background-color: ${p => p.theme.colors.interactive.tonal.neutral[0]}; +`; + +const EmptyText = styled(Text)` + color: ${p => p.theme.colors.text.muted}; +`; + +const isUnsupportedSortError = (error: Error | null | undefined) => { + return !!error && error.message.includes('unsupported sort'); +}; + +const sortFields = [ + { + value: 'bot_name' as const, + label: 'Bot name', + }, + { + value: 'active_at_latest' as const, + label: 'Recent', + }, + { + value: 'version_latest' as const, + label: 'Version', + }, + { + value: 'host_name_latest' as const, + label: 'Hostname', + }, +]; diff --git a/web/packages/teleport/src/Bots/Delete/DeleteDialog.test.tsx b/web/packages/teleport/src/Bots/Delete/DeleteDialog.test.tsx index 970e38122520d..a280971870ff5 100644 --- a/web/packages/teleport/src/Bots/Delete/DeleteDialog.test.tsx +++ b/web/packages/teleport/src/Bots/Delete/DeleteDialog.test.tsx @@ -56,7 +56,7 @@ describe('DeleteDialog', () => { expect(screen.getByText('Delete test-bot-name?')).toBeInTheDocument(); expect( screen.getByText( - 'Alternatively, you can lock a bot to stop all of its activity.' + 'Alternatively, you can lock a bot to stop all of its activity immediately.' ) ).toBeInTheDocument(); expect(screen.getByText('Delete Bot')).toBeEnabled(); @@ -99,7 +99,7 @@ describe('DeleteDialog', () => { }); expect( screen.queryByText( - 'Alternatively, you can lock a bot to stop all of its activity.' + 'Alternatively, you can lock a bot to stop all of its activity immediately.' ) ).not.toBeInTheDocument(); expect(screen.queryByText('Lock Bot')).not.toBeInTheDocument(); diff --git a/web/packages/teleport/src/Bots/Delete/DeleteDialog.tsx b/web/packages/teleport/src/Bots/Delete/DeleteDialog.tsx index 82f13c4b05007..309d9771e684e 100644 --- a/web/packages/teleport/src/Bots/Delete/DeleteDialog.tsx +++ b/web/packages/teleport/src/Bots/Delete/DeleteDialog.tsx @@ -92,15 +92,19 @@ export function DeleteDialog(props: { Delete {botName}? - +

- Deleting a bot is permanent and cannot be undone. All bot instances - will terminate immediately. + Deleting a bot is permanent and cannot be undone. Bot instances + remain active until their issued credentials expire. + {showLockAlternative + ? '' + : ' To terminate active instances immediately, lock the bot before deleting it.'}

{showLockAlternative ? (

- Alternatively, you can lock a bot to stop all of its activity. + Alternatively, you can lock a bot to stop all of its activity + immediately.

) : undefined}
diff --git a/web/packages/teleport/src/Bots/Details/BotDetails.story.tsx b/web/packages/teleport/src/Bots/Details/BotDetails.story.tsx index 00c1bcbde3149..0f40259ddc879 100644 --- a/web/packages/teleport/src/Bots/Details/BotDetails.story.tsx +++ b/web/packages/teleport/src/Bots/Details/BotDetails.story.tsx @@ -19,6 +19,7 @@ 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 { action } from 'storybook/internal/actions'; import Box from 'design/Box'; @@ -133,10 +134,13 @@ export const HappyWithEmpty: Story = { tokens: [], }), mfaAuthnChallengeSuccess(), - listBotInstancesSuccess({ - bot_instances: [], - next_page_token: '', - }), + listBotInstancesSuccess( + { + bot_instances: [], + next_page_token: '', + }, + 'v1' + ), successGetRoles({ startKey: '', items: Array.from({ length: 10 }, (_, k) => k).map(r => ({ @@ -171,21 +175,24 @@ export const HappyWithTypical: Story = { tokens: ['kubernetes'], }), mfaAuthnChallengeSuccess(), - listBotInstancesSuccess({ - bot_instances: [ - { - bot_name: 'bot-1', - instance_id: '6570dbf1-3530-4e13-a8c7-497bb9927994', - active_at_latest: new Date().toISOString(), - host_name_latest: - 'my-svc.my-namespace.svc.cluster-domain.example', - join_method_latest: 'kubernetes', - os_latest: 'linux', - version_latest: '18.1.0', - }, - ], - next_page_token: '', - }), + listBotInstancesSuccess( + { + bot_instances: [ + { + bot_name: 'bot-1', + instance_id: '6570dbf1-3530-4e13-a8c7-497bb9927994', + active_at_latest: new Date().toISOString(), + host_name_latest: + 'my-svc.my-namespace.svc.cluster-domain.example', + join_method_latest: 'kubernetes', + os_latest: 'linux', + version_latest: '18.1.0', + }, + ], + next_page_token: '', + }, + 'v1' + ), successGetRoles({ startKey: '', items: Array.from({ length: 10 }, (_, k) => k).map(r => ({ @@ -237,22 +244,25 @@ export const HappyWithLongValues: Story = { ], }), mfaAuthnChallengeSuccess(), - listBotInstancesSuccess({ - bot_instances: [ - { - bot_name: '', - instance_id: - '04241a2a66b904241a2a66b904241a2a66b904241a2a66b904241a2a66b9', - host_name_latest: - 'hotnamehotnamehotnamehotnamehotnamehotnamehotnamehotnamehotname', - active_at_latest: '2025-01-01T00:00:00Z', - join_method_latest: 'github', - os_latest: 'linux', - version_latest: '17.2.6-04241a2', - }, - ], - next_page_token: '', - }), + listBotInstancesSuccess( + { + bot_instances: [ + { + bot_name: '', + instance_id: + '04241a2a66b904241a2a66b904241a2a66b904241a2a66b904241a2a66b9', + host_name_latest: + 'hotnamehotnamehotnamehotnamehotnamehotnamehotnamehotnamehotname', + active_at_latest: '2025-01-01T00:00:00Z', + join_method_latest: 'github', + os_latest: 'linux', + version_latest: '17.2.6-04241a2', + }, + ], + next_page_token: '', + }, + 'v1' + ), successGetRoles({ startKey: '', items: ['access', 'editor', 'terraform-provider'].map(r => ({ @@ -581,6 +591,7 @@ function Wrapper(props?: { const history = createMemoryHistory({ initialEntries: ['/web/bot/ansible-worker'], }); + history.push = action('history.push'); const customAcl = makeAcl({ bots: { diff --git a/web/packages/teleport/src/Bots/Details/BotDetails.test.tsx b/web/packages/teleport/src/Bots/Details/BotDetails.test.tsx index dfe63fef0f215..31beed9f519bd 100644 --- a/web/packages/teleport/src/Bots/Details/BotDetails.test.tsx +++ b/web/packages/teleport/src/Bots/Details/BotDetails.test.tsx @@ -102,16 +102,12 @@ describe('BotDetails', () => { }); it('should allow back navigation', async () => { - const history = createMemoryHistory({ - initialEntries: ['/web/bot/test-bot-name'], - }); - history.goBack = jest.fn(); - withFetchSuccess(); withFetchJoinTokensSuccess(); withFetchInstancesSuccess(); withListLocksSuccess(); - const { user } = renderComponent({ history }); + const { user, history } = renderComponent(); + jest.spyOn(history, 'goBack'); await waitForLoadingBot(); const backButton = screen.getByLabelText('back'); @@ -251,6 +247,37 @@ describe('BotDetails', () => { ).toBeInTheDocument(); }); + it('should allow an instance to be selected', async () => { + withFetchSuccess(); + withFetchJoinTokensSuccess(); + withFetchInstancesSuccess(); + withListLocksSuccess(); + const { user, history } = renderComponent(); + jest.spyOn(history, 'push'); + await waitForLoadingBot(); + await waitForLoadingTokens(); + + const instanceSection = screen + .getByRole('heading', { name: 'Active Instances' }) + .closest('section'); + + const firstItem = within(instanceSection!).getByText( + 'c11250e0-00c2-4f52-bcdf-b367f80b9461' + ); + + await user.click(firstItem); + + expect(history.push).toHaveBeenCalledTimes(1); + const search = new URLSearchParams(history.location.search); + expect(search.get('query')).toBe('spec.bot_name == "ansible-worker"'); + expect(search.get('is_advanced')).toBe('1'); + expect(search.get('sort_field')).toBe('active_at_latest'); + expect(search.get('sort_dir')).toBe('DESC'); + expect(search.get('selected')).toBe( + 'ansible-worker/c11250e0-00c2-4f52-bcdf-b367f80b9461' + ); + }); + describe('should show bot join tokens empty message', () => { it('when an empty list is returned', async () => { withFetchSuccess(); @@ -578,11 +605,6 @@ describe('BotDetails', () => { describe('Delete', () => { it('should show an overflow option to delete the bot', async () => { - const history = createMemoryHistory({ - initialEntries: ['/web/bot/test-bot-name'], - }); - history.replace = jest.fn(); - withFetchSuccess(); withFetchJoinTokensSuccess(); withFetchInstancesSuccess(); @@ -590,7 +612,8 @@ describe('BotDetails', () => { locks: [], }); withDeleteBotSuccess(); - const { user } = renderComponent({ history }); + const { user, history } = renderComponent(); + jest.spyOn(history, 'replace'); await waitForLoadingBot(); const overflowButton = screen.getByTestId('overflow-btn-open'); @@ -730,15 +753,21 @@ async function inputMaxSessionDuration(user: UserEvent, duration: string) { } const renderComponent = (options?: { - history?: ReturnType; customAcl?: ReturnType; }) => { const user = userEvent.setup(); + const history = createMemoryHistory({ + initialEntries: ['/web/bot/test-bot-name'], + }); return { ...render(, { - wrapper: makeWrapper(options), + wrapper: makeWrapper({ + customAcl: options?.customAcl, + history, + }), }), user, + history, }; }; @@ -791,20 +820,23 @@ const withFetchJoinTokensOutdatedProxy = () => { function withFetchInstancesSuccess() { server.use( - listBotInstancesSuccess({ - bot_instances: [ - { - bot_name: 'ansible-worker', - instance_id: 'c11250e0-00c2-4f52-bcdf-b367f80b9461', - active_at_latest: '2025-07-22T10:54:00Z', - host_name_latest: 'svr-lon-01-ab23cd', - join_method_latest: 'github', - os_latest: 'linux', - version_latest: '4.4.16', - }, - ], - next_page_token: '', - }) + listBotInstancesSuccess( + { + bot_instances: [ + { + bot_name: 'ansible-worker', + instance_id: 'c11250e0-00c2-4f52-bcdf-b367f80b9461', + active_at_latest: '2025-07-22T10:54:00Z', + host_name_latest: 'svr-lon-01-ab23cd', + join_method_latest: 'github', + os_latest: 'linux', + version_latest: '4.4.16', + }, + ], + next_page_token: '', + }, + 'v1' + ) ); } @@ -857,14 +889,12 @@ function withDeleteBotSuccess() { server.use(deleteBotSuccess()); } -function makeWrapper(options?: { - history?: ReturnType; +function makeWrapper(options: { + history: ReturnType; customAcl?: ReturnType; }) { const { - history = createMemoryHistory({ - initialEntries: ['/web/bot/test-bot-name'], - }), + history, customAcl = makeAcl({ bots: { ...defaultAccess, @@ -892,7 +922,7 @@ function makeWrapper(options?: { edit: true, }, }), - } = options ?? {}; + } = options; return ({ children }: PropsWithChildren) => { const ctx = createTeleportContext({ customAcl, diff --git a/web/packages/teleport/src/Bots/Details/BotDetails.tsx b/web/packages/teleport/src/Bots/Details/BotDetails.tsx index f9a6885a52eb1..8129fbff7c15e 100644 --- a/web/packages/teleport/src/Bots/Details/BotDetails.tsx +++ b/web/packages/teleport/src/Bots/Details/BotDetails.tsx @@ -56,6 +56,7 @@ import { ResourceLockIndicator } from 'teleport/lib/locks/ResourceLockIndicator' import { ResourceUnlockDialog } from 'teleport/lib/locks/ResourceUnlockDialog'; import { useResourceLock } from 'teleport/lib/locks/useResourceLock'; import { isAdminActionRequiresMfaError } from 'teleport/services/api/api'; +import { BotInstanceSummary } from 'teleport/services/bot/types'; import useTeleport from 'teleport/useTeleport'; import { DeleteDialog } from '../Delete/DeleteDialog'; @@ -140,6 +141,17 @@ export function BotDetails() { history.replace(cfg.getBotsRoute()); }; + const handleInstanceSelected = (instance: BotInstanceSummary) => { + const path = cfg.getBotInstancesRoute({ + selectedItemId: `${instance.bot_name}/${instance.instance_id}`, + isAdvancedQuery: true, + query: `spec.bot_name == "${instance.bot_name}"`, + sortField: 'active_at_latest', + sortDir: 'DESC', + }); + history.push(path); + }; + return ( @@ -314,7 +326,10 @@ export function BotDetails() { /> - + {isEditing ? ( diff --git a/web/packages/teleport/src/Bots/Details/Instance.story.tsx b/web/packages/teleport/src/Bots/Details/Instance.story.tsx index ed99f2cbe257d..5b1d42e0b2489 100644 --- a/web/packages/teleport/src/Bots/Details/Instance.story.tsx +++ b/web/packages/teleport/src/Bots/Details/Instance.story.tsx @@ -57,11 +57,14 @@ export default meta; export const Item: Story = { args: { id: '686750f5-0f21-4a6f-b151-fa11a603701d', + botName: '', activeAt: new Date('2025-07-18T14:54:32Z').getTime(), hostname: 'my-svc.my-namespace.svc.cluster-domain.example', method: 'kubernetes', version: '4.4.0', os: 'linux', + isSelectable: true, + isSelected: false, }, }; @@ -82,26 +85,43 @@ export const ItemWithLongValues: Story = { }, }; +export const ItemWithLongValuesAndBotName: Story = { + args: { + id: 'fa11a603701dfa11a603701dfa11a603701dfa11a603701dfa11a603701dfa113701d', + botName: 'ansible-worker-ansible-worker-ansible-worker-ansible-worker', + activeAt: new Date('2025-07-18T14:54:32Z').getTime(), + hostname: 'hostnamehostnamehostnamehostnamehostnamehostnamehostnamehostnam', + method: 'kubernetes', + version: '4.4.0-fa11a60', + os: 'linux', + }, +}; + type Props = { - id: Parameters[0]['id']; - version?: Parameters[0]['version']; - hostname?: Parameters[0]['hostname']; + id: Parameters[0]['data']['id']; + botName?: Parameters[0]['data']['botName']; + version?: Parameters[0]['data']['version']; + hostname?: Parameters[0]['data']['hostname']; activeAt?: number; - method?: Parameters[0]['method']; - os?: Parameters[0]['os']; + method?: Parameters[0]['data']['method']; + os?: Parameters[0]['data']['os']; + isSelectable?: Parameters[0]['isSelectable']; + isSelected?: Parameters[0]['isSelected']; }; function Wrapper(props: Props) { + const { isSelectable, isSelected, activeAt, ...data } = props; return ( diff --git a/web/packages/teleport/src/Bots/Details/Instance.tsx b/web/packages/teleport/src/Bots/Details/Instance.tsx index aaac1f31452d1..a37128c7e6172 100644 --- a/web/packages/teleport/src/Bots/Details/Instance.tsx +++ b/web/packages/teleport/src/Bots/Details/Instance.tsx @@ -20,7 +20,7 @@ import format from 'date-fns/format'; import formatDistanceToNowStrict from 'date-fns/formatDistanceToNowStrict'; import parseISO from 'date-fns/parseISO'; import { ReactElement } from 'react'; -import styled from 'styled-components'; +import styled, { css } from 'styled-components'; import Flex from 'design/Flex/Flex'; import { ArrowFatLinesUp } from 'design/Icon/Icons/ArrowFatLinesUp'; @@ -32,26 +32,63 @@ import { import { ResourceIcon } from 'design/ResourceIcon'; import Text from 'design/Text/Text'; import { HoverTooltip } from 'design/Tooltip/HoverTooltip'; +import { CopyButton } from 'shared/components/CopyButton/CopyButton'; import { useClusterVersion } from '../../useClusterVersion'; import { JoinMethodIcon } from './JoinMethodIcon'; export function Instance(props: { - id: string; - version?: string; - hostname?: string; - activeAt?: string; - method?: string; - os?: string; + data: { + id: string; + botName?: string; + version?: string; + hostname?: string; + activeAt?: string; + method?: string; + os?: string; + }; + isSelectable?: boolean; + isSelected?: boolean; + onSelected?: () => void; }) { - const { id, version, hostname, activeAt, method, os } = props; + const { + data: { id, botName, version, hostname, activeAt, method, os }, + isSelectable, + isSelected, + onSelected, + } = props; const hasHeartbeatData = !!version || !!hostname || !!method || !!os; return ( - + { + if (event.key === 'Enter') { + onSelected(); + } + } + : undefined + } + role="listitem" + tabIndex={0} + aria-label={`${botName}/${id}`} + > - {id} + {botName ? ( + + + {botName}/{shortenId(id)} + + + + ) : ( + {id} + )} {activeAt ? ( ` flex-direction: column; padding: ${props => props.theme.space[3]}px; padding-top: ${p => p.theme.space[2]}px; padding-bottom: ${p => p.theme.space[2]}px; background-color: ${p => p.theme.colors.levels.surface}; gap: ${p => p.theme.space[1]}px; + + ${p => + p.$isSelected + ? css` + border-left: ${p.theme.space[1]}px solid + ${p.theme.colors.interactive.solid.primary.default}; + padding-left: ${props => props.theme.space[3] - p.theme.space[1]}px; + background-color: ${p.theme.colors.levels.sunken}; + ` + : ''} + + ${p => + p.$isSelectable + ? css` + cursor: pointer; + + &:hover { + background-color: ${p.theme.colors.levels.sunken}; + } + &:active, + &:focus { + outline: none; + background-color: ${p.theme.colors.levels.deep}; + } + ` + : ''} + + transition: background-color 200ms linear; `; const TopRow = styled(Flex)` @@ -142,6 +210,15 @@ const IdText = styled(Text)` white-space: nowrap; `; +const BotNameText = styled(Text)` + white-space: nowrap; +`; + +const BotNameContainer = styled(Flex)` + flex: 1; + overflow: hidden; +`; + const HostnameText = styled(Text).attrs({ typography: 'body3', })` @@ -179,8 +256,7 @@ function Version(props: { version: string | undefined }) { break; case 'too-new': Wrapper = DangerOutlined; - tooltip = - 'Version is one or more major versions ahead, and is not compatible.'; + tooltip = 'Version is ahead, and is not compatible.'; break; } } @@ -202,3 +278,7 @@ function Version(props: { version: string | undefined }) { const VersionContainer = styled.div` flex-shrink: 0; `; + +function shortenId(id: string) { + return id.substring(0, 7); +} diff --git a/web/packages/teleport/src/Bots/Details/InstancesPanel.story.tsx b/web/packages/teleport/src/Bots/Details/InstancesPanel.story.tsx index 5b2c3f97d124a..2df7a10403191 100644 --- a/web/packages/teleport/src/Bots/Details/InstancesPanel.story.tsx +++ b/web/packages/teleport/src/Bots/Details/InstancesPanel.story.tsx @@ -18,6 +18,7 @@ import { Meta, StoryObj } from '@storybook/react-vite'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { action } from 'storybook/internal/actions'; import styled from 'styled-components'; import { CardTile } from 'design/CardTile/CardTile'; @@ -47,51 +48,54 @@ type Story = StoryObj; export default meta; -export const listBotInstancesSuccessHandler = listBotInstancesSuccess({ - bot_instances: [ - { - bot_name: 'ansible-worker', - instance_id: crypto.randomUUID(), - active_at_latest: '2025-07-22T10:54:00Z', - host_name_latest: 'my-svc.my-namespace.svc.cluster-domain.example', - join_method_latest: 'github', - os_latest: 'linux', - version_latest: '4.4.0', - }, - { - bot_name: 'ansible-worker', - instance_id: crypto.randomUUID(), - active_at_latest: '2025-07-22T10:54:00Z', - host_name_latest: 'win-123a', - join_method_latest: 'tpm', - os_latest: 'windows', - version_latest: '4.3.18+ab12hd', - }, - { - bot_name: 'ansible-worker', - instance_id: crypto.randomUUID(), - active_at_latest: '2025-07-22T10:54:00Z', - host_name_latest: 'mac-007', - join_method_latest: 'kubernetes', - os_latest: 'darwin', - version_latest: '3.9.99', - }, - { - bot_name: 'ansible-worker', - instance_id: crypto.randomUUID(), - active_at_latest: '2025-07-22T10:54:00Z', - host_name_latest: 'aws:g49dh27dhjm3', - join_method_latest: 'ec2', - os_latest: 'linux', - version_latest: '1.3.2', - }, - { - bot_name: 'ansible-worker', - instance_id: crypto.randomUUID(), - }, - ], - next_page_token: '', -}); +export const listBotInstancesSuccessHandler = listBotInstancesSuccess( + { + bot_instances: [ + { + bot_name: 'ansible-worker', + instance_id: crypto.randomUUID(), + active_at_latest: '2025-07-22T10:54:00Z', + host_name_latest: 'my-svc.my-namespace.svc.cluster-domain.example', + join_method_latest: 'github', + os_latest: 'linux', + version_latest: '4.4.0', + }, + { + bot_name: 'ansible-worker', + instance_id: crypto.randomUUID(), + active_at_latest: '2025-07-22T10:54:00Z', + host_name_latest: 'win-123a', + join_method_latest: 'tpm', + os_latest: 'windows', + version_latest: '4.3.18+ab12hd', + }, + { + bot_name: 'ansible-worker', + instance_id: crypto.randomUUID(), + active_at_latest: '2025-07-22T10:54:00Z', + host_name_latest: 'mac-007', + join_method_latest: 'kubernetes', + os_latest: 'darwin', + version_latest: '3.9.99', + }, + { + bot_name: 'ansible-worker', + instance_id: crypto.randomUUID(), + active_at_latest: '2025-07-22T10:54:00Z', + host_name_latest: 'aws:g49dh27dhjm3', + join_method_latest: 'ec2', + os_latest: 'linux', + version_latest: '1.3.2', + }, + { + bot_name: 'ansible-worker', + instance_id: crypto.randomUUID(), + }, + ], + next_page_token: '', + }, + 'v1' +); export const Happy: Story = { parameters: { @@ -142,7 +146,10 @@ function Wrapper(props: { hasBotInstanceListPermission?: boolean }) { - + diff --git a/web/packages/teleport/src/Bots/Details/InstancesPanel.test.tsx b/web/packages/teleport/src/Bots/Details/InstancesPanel.test.tsx index c231a6813e263..0d19e6cfb998f 100644 --- a/web/packages/teleport/src/Bots/Details/InstancesPanel.test.tsx +++ b/web/packages/teleport/src/Bots/Details/InstancesPanel.test.tsx @@ -57,7 +57,7 @@ afterAll(() => server.close()); describe('InstancesPanel', () => { it('should show a fetch error state', async () => { withFetchError(); - render(, { + render(, { wrapper: makeWrapper(), }); await waitForLoading(); @@ -67,7 +67,7 @@ describe('InstancesPanel', () => { it('should show a no permissions state', async () => { withFetchError(); - render(, { + render(, { wrapper: makeWrapper({ customAcl: makeAcl({ botInstances: { @@ -88,7 +88,7 @@ describe('InstancesPanel', () => { it('renders instance items', async () => { withFetchSuccess(); - render(, { + render(, { wrapper: makeWrapper(), }); await waitForLoading(); @@ -107,20 +107,23 @@ const waitForLoading = async () => { function withFetchSuccess() { server.use( - listBotInstancesSuccess({ - bot_instances: [ - { - bot_name: 'ansible-worker', - instance_id: 'c11250e0-00c2-4f52-bcdf-b367f80b9461', - active_at_latest: '2025-07-22T10:54:00Z', - host_name_latest: 'svr-lon-01-ab23cd', - join_method_latest: 'github', - os_latest: 'linux', - version_latest: '4.4.16', - }, - ], - next_page_token: '', - }) + listBotInstancesSuccess( + { + bot_instances: [ + { + bot_name: 'ansible-worker', + instance_id: 'c11250e0-00c2-4f52-bcdf-b367f80b9461', + active_at_latest: '2025-07-22T10:54:00Z', + host_name_latest: 'svr-lon-01-ab23cd', + join_method_latest: 'github', + os_latest: 'linux', + version_latest: '4.4.16', + }, + ], + next_page_token: '', + }, + 'v1' + ) ); } diff --git a/web/packages/teleport/src/Bots/Details/InstancesPanel.tsx b/web/packages/teleport/src/Bots/Details/InstancesPanel.tsx index 30e5414829eaf..a3422c6351a6d 100644 --- a/web/packages/teleport/src/Bots/Details/InstancesPanel.tsx +++ b/web/packages/teleport/src/Bots/Details/InstancesPanel.tsx @@ -29,17 +29,20 @@ import { Indicator } from 'design/Indicator/Indicator'; import Text from 'design/Text'; import { listBotInstances } from 'teleport/services/bot/bot'; +import { BotInstanceSummary } from 'teleport/services/bot/types'; import useTeleport from 'teleport/useTeleport'; import { Instance } from './Instance'; import { PanelTitleText } from './Panel'; -export function InstancesPanel(props: { botName: string }) { - const { botName } = props; +export function InstancesPanel(props: { + botName: string; + onItemSelected: (instance: BotInstanceSummary) => void; +}) { + const { botName, onItemSelected } = props; - const [sort, setSort] = useState< - 'active_at_latest:asc' | 'active_at_latest:desc' - >('active_at_latest:desc'); + const [sortField] = useState('active_at_latest'); + const [sortDir, setSortDir] = useState<'ASC' | 'DESC'>('DESC'); const contentRef = React.useRef(null); @@ -58,14 +61,18 @@ export function InstancesPanel(props: { botName: string }) { fetchNextPage, } = useInfiniteQuery({ enabled: hasListPermission, - queryKey: ['bot_instances', 'list', sort, botName], - queryFn: ({ pageParam }) => - listBotInstances({ - pageSize: 32, - pageToken: pageParam, - sort, - botName, - }), + queryKey: ['bot_instances', 'list', sortField, sortDir, botName], + queryFn: ({ pageParam, signal }) => + listBotInstances( + { + pageSize: 32, + pageToken: pageParam, + sortField, + sortDir, + botName, + }, + signal + ), initialPageParam: '', getNextPageParam: data => data?.next_page_token, placeholderData: keepPreviousData, @@ -73,17 +80,17 @@ export function InstancesPanel(props: { botName: string }) { }); const handleToggleSort = () => { - setSort(sort => - sort === 'active_at_latest:desc' - ? 'active_at_latest:asc' - : 'active_at_latest:desc' - ); + setSortDir(dir => (dir === 'DESC' ? 'ASC' : 'DESC')); }; // Scrolls to the top when the selected sort changes useEffect(() => { contentRef.current?.scrollTo({ top: 0, behavior: 'instant' }); - }, [sort]); + }, [sortField, sortDir]); + + const makeOnSelectedCallback = (instance: BotInstanceSummary) => () => { + onItemSelected(instance); + }; return ( @@ -92,7 +99,7 @@ export function InstancesPanel(props: { botName: string }) { {isSuccess ? ( Recent - {sort === 'active_at_latest:desc' ? ( + {sortDir === 'DESC' ? ( ) : ( @@ -131,12 +138,16 @@ export function InstancesPanel(props: { botName: string }) { {i === 0 && j === 0 ? undefined : } )) diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index 4f4270413492e..646eb526dd6f3 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -188,7 +188,6 @@ const cfg = { bots: '/web/bots', bot: '/web/bot/:botName', botInstances: '/web/bots/instances', - botInstance: '/web/bot/:botName/instance/:instanceId', botsNew: '/web/bots/new/:type?', workloadIdentities: '/web/workloadidentities', console: '/web/cluster/:clusterId/console', @@ -505,6 +504,8 @@ const cfg = { botInstance: { read: '/v1/webapi/sites/:clusterId/machine-id/bot/:botName/bot-instance/:instanceId', list: '/v1/webapi/sites/:clusterId/machine-id/bot-instance', + listV2: '/v2/webapi/sites/:clusterId/machine-id/bot-instance', + metrics: '/v1/webapi/sites/:clusterId/machine-id/bot-instance/metrics', }, workloadIdentity: { @@ -845,18 +846,42 @@ const cfg = { return generatePath(cfg.routes.bot, { botName }); }, - getBotInstancesRoute() { - return generatePath(cfg.routes.botInstances); + getBotInstancesRoute( + options?: Partial<{ + query: string; + isAdvancedQuery: boolean; + sortField: string; + sortDir: 'ASC' | 'DESC'; + selectedItemId: string; + activeTab: 'info' | 'health' | 'yaml'; + }> + ) { + const search = new URLSearchParams(location.search); + if (options?.query) { + search.set('query', options.query); + } + if (options?.isAdvancedQuery) { + search.set('is_advanced', '1'); + } + if (options?.sortField) { + search.set('sort_field', options.sortField); + } + if (options?.sortDir) { + search.set('sort_dir', options.sortDir); + } + if (options?.selectedItemId) { + search.set('selected', options.selectedItemId); + } + if (options?.activeTab) { + search.set('tab', options.activeTab); + } + return generatePath(`${cfg.routes.botInstances}?${search.toString()}`); }, getWorkloadIdentitiesRoute() { return generatePath(cfg.routes.workloadIdentities); }, - getBotInstanceDetailsRoute(params: { botName: string; instanceId: string }) { - return generatePath(cfg.routes.botInstance, params); - }, - getBotsNewRoute(type?: string) { return generatePath(cfg.routes.botsNew, { type }); }, @@ -1716,11 +1741,17 @@ const cfg = { | { action: 'list'; } + | { + action: 'listV2'; + } | { action: 'read'; botName: string; instanceId: string; } + | { + action: 'metrics'; + } ) & { clusterId?: string } ) { const { clusterId = cfg.proxyCluster } = req; @@ -1729,12 +1760,20 @@ const cfg = { return generatePath(cfg.api.botInstance.list, { clusterId, }); + case 'listV2': + return generatePath(cfg.api.botInstance.listV2, { + clusterId, + }); case 'read': return generatePath(cfg.api.botInstance.read, { clusterId, botName: req.botName, instanceId: req.instanceId, }); + case 'metrics': + return generatePath(cfg.api.botInstance.metrics, { + clusterId, + }); default: req satisfies never; return ''; diff --git a/web/packages/teleport/src/features.tsx b/web/packages/teleport/src/features.tsx index ddcea06e5a181..6fe3589688cd4 100644 --- a/web/packages/teleport/src/features.tsx +++ b/web/packages/teleport/src/features.tsx @@ -53,7 +53,6 @@ import { AccountPage } from './Account'; import { AuditContainer as Audit } from './Audit'; import { AuthConnectorsContainer as AuthConnectors } from './AuthConnectors'; import { BotInstances } from './BotInstances/BotInstances'; -import { BotInstanceDetails } from './BotInstances/Details/BotInstanceDetails'; import { Bots } from './Bots'; import { AddBots } from './Bots/Add'; import { BotDetails } from './Bots/Details/BotDetails'; @@ -303,18 +302,11 @@ export class FeatureBotInstances implements TeleportFeature { } } +// TODO(nicholasmarais1158) Remove this feature stub when teleport.e no longer +// uses it. export class FeatureBotInstanceDetails implements TeleportFeature { - parent = FeatureBotInstances; - - route = { - title: 'Bot instance details', - path: cfg.routes.botInstance, - exact: true, - component: BotInstanceDetails, - }; - hasAccess() { - return true; + return false; } } @@ -867,7 +859,6 @@ export function getOSSFeatures(): TeleportFeature[] { new FeatureBots(), new FeatureBotDetails(), new FeatureBotInstances(), - new FeatureBotInstanceDetails(), new FeatureAddBotsShortcut(), new FeatureJoinTokens(), new FeatureRoles(), diff --git a/web/packages/teleport/src/services/bot/bot.ts b/web/packages/teleport/src/services/bot/bot.ts index e3df087b5ec95..4551b4a18a843 100644 --- a/web/packages/teleport/src/services/bot/bot.ts +++ b/web/packages/teleport/src/services/bot/bot.ts @@ -25,6 +25,7 @@ import { canUseV2Edit, makeBot, toApiGitHubTokenSpec, + validateGetBotInstanceMetricsResponse, validateGetBotInstanceResponse, validateListBotInstancesResponse, } from 'teleport/services/bot/consts'; @@ -187,14 +188,29 @@ export async function listBotInstances( pageToken: string; pageSize: number; searchTerm?: string; - sort?: string; + sortField?: string; + sortDir?: string; botName?: string; + query?: string; }, signal?: AbortSignal ) { - const { pageToken, pageSize, searchTerm, sort, botName } = variables; + const { + pageToken, + pageSize, + searchTerm, + sortField, + sortDir, + botName, + query, + } = variables; - const path = cfg.getBotInstanceUrl({ action: 'list' }); + // TODO(nicholasmarais1158) DELETE IN v20.0.0 + const useV1Endpoint = !query; + + const path = cfg.getBotInstanceUrl({ + action: useV1Endpoint ? 'list' : 'listV2', + }); const qs = new URLSearchParams(); qs.set('page_size', pageSize.toFixed()); @@ -202,13 +218,27 @@ export async function listBotInstances( if (searchTerm) { qs.set('search', searchTerm); } - if (sort) { - qs.set('sort', sort); - } if (botName) { qs.set('bot_name', botName); } + if (useV1Endpoint) { + const sort = `${sortField || 'name'}:${sortDir || 'asc'}`; + if (sort) { + qs.set('sort', sort); + } + } else { + if (sortField) { + qs.set('sort_field', sortField); + } + if (sortDir) { + qs.set('sort_dir', sortDir); + } + if (query) { + qs.set('query', query); + } + } + const data = await api.get(`${path}?${qs.toString()}`, signal); if (!validateListBotInstancesResponse(data)) { @@ -235,3 +265,23 @@ export async function getBotInstance( return data; } + +export async function getBotInstanceMetrics( + variables: null, + signal?: AbortSignal +) { + const path = cfg.getBotInstanceUrl({ action: 'metrics' }); + + try { + const data = await api.get(path, signal); + + if (!validateGetBotInstanceMetricsResponse(data)) { + throw new Error('failed to validate get bot instance metrics response'); + } + + return data; + } catch (err: unknown) { + // TODO(nicholasmarais1158) DELETE IN v20.0.0 + withGenericUnsupportedError(err, '18.4.0'); + } +} diff --git a/web/packages/teleport/src/services/bot/consts.ts b/web/packages/teleport/src/services/bot/consts.ts index 4bb7b0a99f321..f6f3dcb7cbbe5 100644 --- a/web/packages/teleport/src/services/bot/consts.ts +++ b/web/packages/teleport/src/services/bot/consts.ts @@ -22,6 +22,7 @@ import { BotUiFlow, EditBotRequest, FlatBot, + GetBotInstanceMetricsResponse, GetBotInstanceResponse, GitHubRepoRule, ListBotInstancesResponse, @@ -137,6 +138,24 @@ export function validateGetBotInstanceResponse( return true; } +export function validateGetBotInstanceMetricsResponse( + data: unknown +): data is GetBotInstanceMetricsResponse { + if (typeof data !== 'object' || data === null) { + return false; + } + + if (!('upgrade_statuses' in data)) { + return false; + } + + if (typeof data.upgrade_statuses !== 'object') { + return false; + } + + return true; +} + export function getBotType(labels: Map): BotType { if (!labels) { return null; diff --git a/web/packages/teleport/src/services/bot/types.ts b/web/packages/teleport/src/services/bot/types.ts index f42d7612b281c..6dea8400a811f 100644 --- a/web/packages/teleport/src/services/bot/types.ts +++ b/web/packages/teleport/src/services/bot/types.ts @@ -77,11 +77,130 @@ export type GetBotInstanceResponse = { bot_instance?: { spec?: { instance_id?: string; + bot_name?: string; } | null; + status?: { + latest_heartbeats?: + | { + uptime?: { + seconds?: number; + } | null; + version?: string; + os?: string; + hostname?: string; + kind?: BotInstanceKind; + }[] + | null; + latest_authentications?: + | { + join_attrs?: GetBotInstanceResponseJoinAttrs | null; + }[] + | null; + service_health?: + | { + service?: { + type?: string; + name?: string; + } | null; + status?: BotInstanceServiceHealthStatus; + reason?: string; + updated_at?: { seconds: number } | null; + }[] + | null; + }; } | null; yaml?: string; }; +export enum BotInstanceServiceHealthStatus { + BOT_INSTANCE_HEALTH_STATUS_UNSPECIFIED = 0, + BOT_INSTANCE_HEALTH_STATUS_INITIALIZING = 1, + BOT_INSTANCE_HEALTH_STATUS_HEALTHY = 2, + BOT_INSTANCE_HEALTH_STATUS_UNHEALTHY = 3, +} + +export enum BotInstanceKind { + BOT_KIND_UNSPECIFIED = 0, + BOT_KIND_TBOT = 1, + BOT_KIND_TERRAFORM_PROVIDER = 2, + BOT_KIND_KUBERNETES_OPERATOR = 3, + BOT_KIND_TCTL = 4, +} + +export type GetBotInstanceResponseJoinAttrs = { + meta?: { + join_token_name?: string; + join_method?: string; + } | null; + gitlab?: { + sub?: string; + project_path?: string; + } | null; + github?: { + sub?: string; + repository?: string; + } | null; + iam?: { + account?: string; + arn?: string; + } | null; + tpm?: { + ek_pub_hash?: string; + } | null; + azure?: { + subscription?: string; + resource_group?: string; + } | null; + circleci?: { + sub?: string; + project_id?: string; + } | null; + bitbucket?: { + sub?: string; + repository_uuid?: string; + } | null; + terraform_cloud?: { + sub?: string; + full_workspace?: string; + } | null; + spacelift?: { + sub?: string; + space_id?: string; + } | null; + gcp?: { + service_account?: string; + } | null; + kubernetes?: { + subject?: string; + } | null; + oracle?: { + tenancy_id?: string; + compartment_id?: string; + } | null; + azure_devops?: { + pipeline?: { + sub?: string; + repository_id?: string; + } | null; + } | null; +}; + +export type GetBotInstanceMetricsResponse = { + upgrade_statuses?: { + unsupported?: BotInstanceMetric | null; + patch_available?: BotInstanceMetric | null; + requires_upgrade?: BotInstanceMetric | null; + up_to_date?: BotInstanceMetric | null; + updated_at?: string; + } | null; + refresh_after_seconds: number; +}; + +type BotInstanceMetric = { + count?: number; + filter?: string; +}; + export type BotList = { bots: FlatBot[]; }; diff --git a/web/packages/teleport/src/teleportContext.tsx b/web/packages/teleport/src/teleportContext.tsx index b265b5314d35d..341193f93f241 100644 --- a/web/packages/teleport/src/teleportContext.tsx +++ b/web/packages/teleport/src/teleportContext.tsx @@ -223,6 +223,7 @@ class TeleportContext implements types.Context { gitServers: userContext.getGitServersAccess().list && userContext.getGitServersAccess().read, + readBotInstances: userContext.getBotInstancesAccess().read, listBotInstances: userContext.getBotInstancesAccess().list, listWorkloadIdentities: userContext.getWorkloadIdentityAccess().list, }; @@ -268,6 +269,7 @@ export const disabledFeatureFlags: types.FeatureFlags = { editBots: false, removeBots: false, gitServers: false, + readBotInstances: false, listBotInstances: false, listWorkloadIdentities: false, }; diff --git a/web/packages/teleport/src/test/helpers/botInstances.ts b/web/packages/teleport/src/test/helpers/botInstances.ts index 475aa0ab02088..faa0b85f50e38 100644 --- a/web/packages/teleport/src/test/helpers/botInstances.ts +++ b/web/packages/teleport/src/test/helpers/botInstances.ts @@ -20,18 +20,29 @@ import { http, HttpResponse } from 'msw'; import cfg from 'teleport/config'; import { + BotInstanceKind, + BotInstanceServiceHealthStatus, + GetBotInstanceMetricsResponse, GetBotInstanceResponse, ListBotInstancesResponse, } from 'teleport/services/bot/types'; -export const listBotInstancesSuccess = (mock: ListBotInstancesResponse) => - http.get(cfg.api.botInstance.list, () => { - return HttpResponse.json(mock); - }); +export const listBotInstancesSuccess = ( + mock: ListBotInstancesResponse, + version: ListBotInstancesApiVersion = 'v2' +) => + http.get( + version == 'v1' ? cfg.api.botInstance.list : cfg.api.botInstance.listV2, + () => { + return HttpResponse.json(mock); + } + ); -export const listBotInstancesForever = () => +export const listBotInstancesForever = ( + version: ListBotInstancesApiVersion = 'v1' +) => http.get( - cfg.api.botInstance.list, + version == 'v1' ? cfg.api.botInstance.list : cfg.api.botInstance.listV2, () => new Promise(() => { /* never resolved */ @@ -39,19 +50,173 @@ export const listBotInstancesForever = () => ); export const listBotInstancesError = ( + status: number, + error: string | null = null, + version: ListBotInstancesApiVersion = 'v1' +) => + http.get( + version == 'v1' ? cfg.api.botInstance.list : cfg.api.botInstance.listV2, + () => { + return HttpResponse.json({ error: { message: error } }, { status }); + } + ); + +export const getBotInstanceSuccess = (mock?: GetBotInstanceResponse) => + http.get(cfg.api.botInstance.read, () => { + return HttpResponse.json(mock ?? mockGetBotInstanceResponse); + }); + +export const getBotInstanceError = ( status: number, error: string | null = null ) => - http.get(cfg.api.botInstance.list, () => { + http.get(cfg.api.botInstance.read, () => { return HttpResponse.json({ error: { message: error } }, { status }); }); -export const getBotInstanceSuccess = (mock: GetBotInstanceResponse) => - http.get(cfg.api.botInstance.read, () => { - return HttpResponse.json(mock); +export const getBotInstanceForever = () => + http.get( + cfg.api.botInstance.read, + () => + new Promise(() => { + /* never resolved */ + }) + ); + +export const mockGetBotInstanceResponse = { + bot_instance: { + spec: { + instance_id: 'a55259e8-9b17-466f-9d37-ab390ca4024e', + bot_name: 'test-bot-name', + }, + status: { + latest_heartbeats: [ + { + uptime: { + seconds: 43200 + 60, + }, + version: '18.4.0', + hostname: 'test-hostname', + os: 'linux', + kind: BotInstanceKind.BOT_KIND_TCTL, + }, + ], + latest_authentications: [ + { + join_attrs: { + meta: { + join_method: 'github', + join_token_name: 'test-token-name', + }, + github: { + sub: 'test-github-sub', + repository: 'gravitational/teleport', + }, + }, + }, + ], + service_health: [ + { + service: { + name: 'application-tunnel-1', + type: 'application-tunnel', + }, + status: + BotInstanceServiceHealthStatus.BOT_INSTANCE_HEALTH_STATUS_HEALTHY, + updated_at: { + seconds: new Date('2025-10-10T10:45:00Z').getTime() / 1_000, + }, + }, + { + service: { + name: 'db-eu-lon-1', + type: 'database-tunnel', + }, + status: + BotInstanceServiceHealthStatus.BOT_INSTANCE_HEALTH_STATUS_UNHEALTHY, + updated_at: { + seconds: new Date('2025-10-10T10:46:00Z').getTime() / 1_000, + }, + reason: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + }, + { + service: { + name: 'workload-identity-aws-roles-anywhere-1', + type: 'workload-identity-aws-roles-anywhere', + }, + status: + BotInstanceServiceHealthStatus.BOT_INSTANCE_HEALTH_STATUS_INITIALIZING, + updated_at: { + seconds: new Date('2025-10-10T10:47:00Z').getTime() / 1_000, + }, + }, + { + service: { + name: 'application-tunnel-2', + type: 'application-tunnel', + }, + status: + BotInstanceServiceHealthStatus.BOT_INSTANCE_HEALTH_STATUS_UNSPECIFIED, + updated_at: { + seconds: new Date('2025-10-10T10:48:00Z').getTime() / 1_000, + }, + }, + ], + }, + }, + yaml: 'kind: bot_instance\nversion: v1\n', +}; + +export const getBotInstanceMetricsSuccess = ( + mock?: GetBotInstanceMetricsResponse +) => + http.get(cfg.api.botInstance.metrics, () => { + return HttpResponse.json( + mock ?? { + upgrade_statuses: { + updated_at: new Date().toISOString(), + up_to_date: { + count: randBetween(0, 2000), + filter: 'up to date filter goes here', + }, + patch_available: { + count: randBetween(0, 2000), + filter: 'patch filter goes here', + }, + requires_upgrade: { + count: randBetween(0, 2000), + filter: 'upgrade filter goes here', + }, + unsupported: { + count: randBetween(0, 2000), + filter: 'unsupported filter goes here', + }, + }, + } + ); }); -export const getBotInstanceError = (status: number) => - http.get(cfg.api.botInstance.read, () => { - return new HttpResponse(null, { status }); +export const getBotInstanceMetricsForever = () => + http.get( + cfg.api.botInstance.metrics, + () => + new Promise(() => { + /* never resolved */ + }) + ); + +export const getBotInstanceMetricsError = ( + status: number, + error: string | null = null +) => + http.get(cfg.api.botInstance.metrics, () => { + return HttpResponse.json({ error: { message: error } }, { status }); }); + +function randBetween(low: number, high: number) { + if (low > high) [low, high] = [high, low]; + return Math.floor(Math.random() * (high - low + 1)) + low; +} + +export type ListBotInstancesApiVersion = 'v1' | 'v2'; diff --git a/web/packages/teleport/src/types.ts b/web/packages/teleport/src/types.ts index e377294ce2af6..2612edb8cc746 100644 --- a/web/packages/teleport/src/types.ts +++ b/web/packages/teleport/src/types.ts @@ -207,6 +207,7 @@ export interface FeatureFlags { externalAuditStorage: boolean; listBots: boolean; readBots: boolean; + readBotInstances: boolean; listBotInstances: boolean; addBots: boolean; editBots: boolean; diff --git a/web/packages/teleport/src/useClusterVersion.test.tsx b/web/packages/teleport/src/useClusterVersion.test.tsx index 98b43c01c7cb6..b26a0740d902b 100644 --- a/web/packages/teleport/src/useClusterVersion.test.tsx +++ b/web/packages/teleport/src/useClusterVersion.test.tsx @@ -31,15 +31,15 @@ describe('useClusterVersion', () => { }); it.each` - clientVersion | compatibility - ${'4.4.0'} | ${{ isCompatible: true, reason: 'match' }} - ${'4.4.1'} | ${{ isCompatible: true, reason: 'match' }} - ${'4.3.999'} | ${{ isCompatible: true, reason: 'upgrade-minor' }} - ${'4.3.0'} | ${{ isCompatible: true, reason: 'upgrade-minor' }} - ${'5.0.0'} | ${{ isCompatible: true, reason: 'match' }} - ${'3.0.0'} | ${{ isCompatible: true, reason: 'upgrade-major' }} - ${'6.0.0'} | ${{ isCompatible: false, reason: 'too-new' }} - ${'2.0.0'} | ${{ isCompatible: false, reason: 'too-old' }} + clientVersion | compatibility + ${'4.4.0-dev'} | ${{ isCompatible: true, reason: 'match' }} + ${'4.4.0'} | ${{ isCompatible: false, reason: 'too-new' }} + ${'4.4.1'} | ${{ isCompatible: false, reason: 'too-new' }} + ${'4.3.999'} | ${{ isCompatible: true, reason: 'upgrade-minor' }} + ${'4.3.0'} | ${{ isCompatible: true, reason: 'upgrade-minor' }} + ${'5.0.0'} | ${{ isCompatible: false, reason: 'too-new' }} + ${'3.0.0'} | ${{ isCompatible: true, reason: 'upgrade-major' }} + ${'2.0.0'} | ${{ isCompatible: false, reason: 'too-old' }} `( 'diff("$clientVersion") should be "$compatibility"', ({ clientVersion, compatibility }) => { diff --git a/web/packages/teleport/src/useClusterVersion.ts b/web/packages/teleport/src/useClusterVersion.ts index 5434982f57b88..e05f6acd6e1d7 100644 --- a/web/packages/teleport/src/useClusterVersion.ts +++ b/web/packages/teleport/src/useClusterVersion.ts @@ -73,20 +73,32 @@ export function checkClientCompatibility( const client = parse(clientVersion); const cluster = parse(clusterVersion); if (!client || !cluster) return null; + if (client.compare(cluster) === 0) { + return { + isCompatible: true, + reason: 'match', + }; + } + if (client.compare(cluster) === 1) { + return { + isCompatible: false, + reason: 'too-new', + }; + } if (client.major === cluster.major) { return { isCompatible: true, - reason: client.compare(cluster) === -1 ? 'upgrade-minor' : 'match', + reason: 'upgrade-minor', }; } - if (Math.abs(client.major - cluster.major) == 1) { + if (client.major === cluster.major - 1) { return { isCompatible: true, - reason: client.major > cluster.major ? 'match' : 'upgrade-major', + reason: 'upgrade-major', }; } return { isCompatible: false, - reason: client.major > cluster.major ? 'too-new' : 'too-old', + reason: 'too-old', }; }