diff --git a/api/gen/proto/go/teleport/machineid/v1/bot_instance.pb.go b/api/gen/proto/go/teleport/machineid/v1/bot_instance.pb.go index fe5b9ca1689f7..1cc8a9b9b58be 100644 --- a/api/gen/proto/go/teleport/machineid/v1/bot_instance.pb.go +++ b/api/gen/proto/go/teleport/machineid/v1/bot_instance.pb.go @@ -103,6 +103,64 @@ func (BotKind) EnumDescriptor() ([]byte, []int) { return file_teleport_machineid_v1_bot_instance_proto_rawDescGZIP(), []int{0} } +// BotInstanceHealthStatus describes the healthiness of a `tbot` service. +type BotInstanceHealthStatus int32 + +const ( + // The enum zero-value, it means no status was included. + BotInstanceHealthStatus_BOT_INSTANCE_HEALTH_STATUS_UNSPECIFIED BotInstanceHealthStatus = 0 + // Means the service is still "starting up" and hasn't reported its status. + BotInstanceHealthStatus_BOT_INSTANCE_HEALTH_STATUS_INITIALIZING BotInstanceHealthStatus = 1 + // Means the service is healthy and ready to serve traffic, or it has + // recently succeeded in generating an output. + BotInstanceHealthStatus_BOT_INSTANCE_HEALTH_STATUS_HEALTHY BotInstanceHealthStatus = 2 + // Means the service is failing to serve traffic or generate output. + BotInstanceHealthStatus_BOT_INSTANCE_HEALTH_STATUS_UNHEALTHY BotInstanceHealthStatus = 3 +) + +// Enum value maps for BotInstanceHealthStatus. +var ( + BotInstanceHealthStatus_name = map[int32]string{ + 0: "BOT_INSTANCE_HEALTH_STATUS_UNSPECIFIED", + 1: "BOT_INSTANCE_HEALTH_STATUS_INITIALIZING", + 2: "BOT_INSTANCE_HEALTH_STATUS_HEALTHY", + 3: "BOT_INSTANCE_HEALTH_STATUS_UNHEALTHY", + } + BotInstanceHealthStatus_value = map[string]int32{ + "BOT_INSTANCE_HEALTH_STATUS_UNSPECIFIED": 0, + "BOT_INSTANCE_HEALTH_STATUS_INITIALIZING": 1, + "BOT_INSTANCE_HEALTH_STATUS_HEALTHY": 2, + "BOT_INSTANCE_HEALTH_STATUS_UNHEALTHY": 3, + } +) + +func (x BotInstanceHealthStatus) Enum() *BotInstanceHealthStatus { + p := new(BotInstanceHealthStatus) + *p = x + return p +} + +func (x BotInstanceHealthStatus) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (BotInstanceHealthStatus) Descriptor() protoreflect.EnumDescriptor { + return file_teleport_machineid_v1_bot_instance_proto_enumTypes[1].Descriptor() +} + +func (BotInstanceHealthStatus) Type() protoreflect.EnumType { + return &file_teleport_machineid_v1_bot_instance_proto_enumTypes[1] +} + +func (x BotInstanceHealthStatus) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use BotInstanceHealthStatus.Descriptor instead. +func (BotInstanceHealthStatus) EnumDescriptor() ([]byte, []int) { + return file_teleport_machineid_v1_bot_instance_proto_rawDescGZIP(), []int{1} +} + // A BotInstance type BotInstance struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -548,8 +606,10 @@ type BotInstanceStatus struct { InitialHeartbeat *BotInstanceStatusHeartbeat `protobuf:"bytes,3,opt,name=initial_heartbeat,json=initialHeartbeat,proto3" json:"initial_heartbeat,omitempty"` // The N most recent heartbeats for this bot instance. LatestHeartbeats []*BotInstanceStatusHeartbeat `protobuf:"bytes,4,rep,name=latest_heartbeats,json=latestHeartbeats,proto3" json:"latest_heartbeats,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // The health of the services/output `tbot` is running. + ServiceHealth []*BotInstanceServiceHealth `protobuf:"bytes,5,rep,name=service_health,json=serviceHealth,proto3" json:"service_health,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *BotInstanceStatus) Reset() { @@ -610,6 +670,142 @@ func (x *BotInstanceStatus) GetLatestHeartbeats() []*BotInstanceStatusHeartbeat return nil } +func (x *BotInstanceStatus) GetServiceHealth() []*BotInstanceServiceHealth { + if x != nil { + return x.ServiceHealth + } + return nil +} + +// BotInstanceServiceHealth is a snapshot of a `tbot` service's health. +type BotInstanceServiceHealth struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Service identifies the service. + Service *BotInstanceServiceIdentifier `protobuf:"bytes,1,opt,name=service,proto3" json:"service,omitempty"` + // Status describes the service's healthiness. + Status BotInstanceHealthStatus `protobuf:"varint,2,opt,name=status,proto3,enum=teleport.machineid.v1.BotInstanceHealthStatus" json:"status,omitempty"` + // Reason is a human-readable explanation for the service's status. It might + // include an error message. + Reason *string `protobuf:"bytes,3,opt,name=reason,proto3,oneof" json:"reason,omitempty"` + // UpdatedAt is the time at which the service's health last changed. + UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BotInstanceServiceHealth) Reset() { + *x = BotInstanceServiceHealth{} + mi := &file_teleport_machineid_v1_bot_instance_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BotInstanceServiceHealth) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BotInstanceServiceHealth) ProtoMessage() {} + +func (x *BotInstanceServiceHealth) ProtoReflect() protoreflect.Message { + mi := &file_teleport_machineid_v1_bot_instance_proto_msgTypes[5] + 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 BotInstanceServiceHealth.ProtoReflect.Descriptor instead. +func (*BotInstanceServiceHealth) Descriptor() ([]byte, []int) { + return file_teleport_machineid_v1_bot_instance_proto_rawDescGZIP(), []int{5} +} + +func (x *BotInstanceServiceHealth) GetService() *BotInstanceServiceIdentifier { + if x != nil { + return x.Service + } + return nil +} + +func (x *BotInstanceServiceHealth) GetStatus() BotInstanceHealthStatus { + if x != nil { + return x.Status + } + return BotInstanceHealthStatus_BOT_INSTANCE_HEALTH_STATUS_UNSPECIFIED +} + +func (x *BotInstanceServiceHealth) GetReason() string { + if x != nil && x.Reason != nil { + return *x.Reason + } + return "" +} + +func (x *BotInstanceServiceHealth) GetUpdatedAt() *timestamppb.Timestamp { + if x != nil { + return x.UpdatedAt + } + return nil +} + +// BotInstanceServiceIdentifier uniquely identifies a `tbot` service. +type BotInstanceServiceIdentifier struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Type of service (e.g. database-tunnel, ssh-multiplexer). + Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` + // Name of the service, either given by the user or auto-generated. + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BotInstanceServiceIdentifier) Reset() { + *x = BotInstanceServiceIdentifier{} + mi := &file_teleport_machineid_v1_bot_instance_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BotInstanceServiceIdentifier) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BotInstanceServiceIdentifier) ProtoMessage() {} + +func (x *BotInstanceServiceIdentifier) ProtoReflect() protoreflect.Message { + mi := &file_teleport_machineid_v1_bot_instance_proto_msgTypes[6] + 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 BotInstanceServiceIdentifier.ProtoReflect.Descriptor instead. +func (*BotInstanceServiceIdentifier) Descriptor() ([]byte, []int) { + return file_teleport_machineid_v1_bot_instance_proto_rawDescGZIP(), []int{6} +} + +func (x *BotInstanceServiceIdentifier) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *BotInstanceServiceIdentifier) GetName() string { + if x != nil { + return x.Name + } + return "" +} + var File_teleport_machineid_v1_bot_instance_proto protoreflect.FileDescriptor const file_teleport_machineid_v1_bot_instance_proto_rawDesc = "" + @@ -658,18 +854,34 @@ const file_teleport_machineid_v1_bot_instance_proto_rawDesc = "" + "\n" + "public_key\x18\x06 \x01(\fR\tpublicKey\x12F\n" + "\n" + - "join_attrs\x18\b \x01(\v2'.teleport.workloadidentity.v1.JoinAttrsR\tjoinAttrsJ\x04\b\a\x10\bR\vfingerprint\"\xb1\x03\n" + + "join_attrs\x18\b \x01(\v2'.teleport.workloadidentity.v1.JoinAttrsR\tjoinAttrsJ\x04\b\a\x10\bR\vfingerprint\"\x89\x04\n" + "\x11BotInstanceStatus\x12m\n" + "\x16initial_authentication\x18\x01 \x01(\v26.teleport.machineid.v1.BotInstanceStatusAuthenticationR\x15initialAuthentication\x12m\n" + "\x16latest_authentications\x18\x02 \x03(\v26.teleport.machineid.v1.BotInstanceStatusAuthenticationR\x15latestAuthentications\x12^\n" + "\x11initial_heartbeat\x18\x03 \x01(\v21.teleport.machineid.v1.BotInstanceStatusHeartbeatR\x10initialHeartbeat\x12^\n" + - "\x11latest_heartbeats\x18\x04 \x03(\v21.teleport.machineid.v1.BotInstanceStatusHeartbeatR\x10latestHeartbeats*\x8c\x01\n" + + "\x11latest_heartbeats\x18\x04 \x03(\v21.teleport.machineid.v1.BotInstanceStatusHeartbeatR\x10latestHeartbeats\x12V\n" + + "\x0eservice_health\x18\x05 \x03(\v2/.teleport.machineid.v1.BotInstanceServiceHealthR\rserviceHealth\"\x94\x02\n" + + "\x18BotInstanceServiceHealth\x12M\n" + + "\aservice\x18\x01 \x01(\v23.teleport.machineid.v1.BotInstanceServiceIdentifierR\aservice\x12F\n" + + "\x06status\x18\x02 \x01(\x0e2..teleport.machineid.v1.BotInstanceHealthStatusR\x06status\x12\x1b\n" + + "\x06reason\x18\x03 \x01(\tH\x00R\x06reason\x88\x01\x01\x129\n" + + "\n" + + "updated_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAtB\t\n" + + "\a_reason\"F\n" + + "\x1cBotInstanceServiceIdentifier\x12\x12\n" + + "\x04type\x18\x01 \x01(\tR\x04type\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name*\x8c\x01\n" + "\aBotKind\x12\x18\n" + "\x14BOT_KIND_UNSPECIFIED\x10\x00\x12\x11\n" + "\rBOT_KIND_TBOT\x10\x01\x12\x1f\n" + "\x1bBOT_KIND_TERRAFORM_PROVIDER\x10\x02\x12 \n" + "\x1cBOT_KIND_KUBERNETES_OPERATOR\x10\x03\x12\x11\n" + - "\rBOT_KIND_TCTL\x10\x04BVZTgithub.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1;machineidv1b\x06proto3" + "\rBOT_KIND_TCTL\x10\x04*\xc4\x01\n" + + "\x17BotInstanceHealthStatus\x12*\n" + + "&BOT_INSTANCE_HEALTH_STATUS_UNSPECIFIED\x10\x00\x12+\n" + + "'BOT_INSTANCE_HEALTH_STATUS_INITIALIZING\x10\x01\x12&\n" + + "\"BOT_INSTANCE_HEALTH_STATUS_HEALTHY\x10\x02\x12(\n" + + "$BOT_INSTANCE_HEALTH_STATUS_UNHEALTHY\x10\x03BVZTgithub.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1;machineidv1b\x06proto3" var ( file_teleport_machineid_v1_bot_instance_proto_rawDescOnce sync.Once @@ -683,42 +895,49 @@ func file_teleport_machineid_v1_bot_instance_proto_rawDescGZIP() []byte { return file_teleport_machineid_v1_bot_instance_proto_rawDescData } -var file_teleport_machineid_v1_bot_instance_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_teleport_machineid_v1_bot_instance_proto_msgTypes = make([]protoimpl.MessageInfo, 5) +var file_teleport_machineid_v1_bot_instance_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_teleport_machineid_v1_bot_instance_proto_msgTypes = make([]protoimpl.MessageInfo, 7) var file_teleport_machineid_v1_bot_instance_proto_goTypes = []any{ (BotKind)(0), // 0: teleport.machineid.v1.BotKind - (*BotInstance)(nil), // 1: teleport.machineid.v1.BotInstance - (*BotInstanceSpec)(nil), // 2: teleport.machineid.v1.BotInstanceSpec - (*BotInstanceStatusHeartbeat)(nil), // 3: teleport.machineid.v1.BotInstanceStatusHeartbeat - (*BotInstanceStatusAuthentication)(nil), // 4: teleport.machineid.v1.BotInstanceStatusAuthentication - (*BotInstanceStatus)(nil), // 5: teleport.machineid.v1.BotInstanceStatus - (*v1.Metadata)(nil), // 6: teleport.header.v1.Metadata - (*timestamppb.Timestamp)(nil), // 7: google.protobuf.Timestamp - (*durationpb.Duration)(nil), // 8: google.protobuf.Duration - (*types.UpdaterV2Info)(nil), // 9: types.UpdaterV2Info - (*structpb.Struct)(nil), // 10: google.protobuf.Struct - (*v11.JoinAttrs)(nil), // 11: teleport.workloadidentity.v1.JoinAttrs + (BotInstanceHealthStatus)(0), // 1: teleport.machineid.v1.BotInstanceHealthStatus + (*BotInstance)(nil), // 2: teleport.machineid.v1.BotInstance + (*BotInstanceSpec)(nil), // 3: teleport.machineid.v1.BotInstanceSpec + (*BotInstanceStatusHeartbeat)(nil), // 4: teleport.machineid.v1.BotInstanceStatusHeartbeat + (*BotInstanceStatusAuthentication)(nil), // 5: teleport.machineid.v1.BotInstanceStatusAuthentication + (*BotInstanceStatus)(nil), // 6: teleport.machineid.v1.BotInstanceStatus + (*BotInstanceServiceHealth)(nil), // 7: teleport.machineid.v1.BotInstanceServiceHealth + (*BotInstanceServiceIdentifier)(nil), // 8: teleport.machineid.v1.BotInstanceServiceIdentifier + (*v1.Metadata)(nil), // 9: teleport.header.v1.Metadata + (*timestamppb.Timestamp)(nil), // 10: google.protobuf.Timestamp + (*durationpb.Duration)(nil), // 11: google.protobuf.Duration + (*types.UpdaterV2Info)(nil), // 12: types.UpdaterV2Info + (*structpb.Struct)(nil), // 13: google.protobuf.Struct + (*v11.JoinAttrs)(nil), // 14: teleport.workloadidentity.v1.JoinAttrs } var file_teleport_machineid_v1_bot_instance_proto_depIdxs = []int32{ - 6, // 0: teleport.machineid.v1.BotInstance.metadata:type_name -> teleport.header.v1.Metadata - 2, // 1: teleport.machineid.v1.BotInstance.spec:type_name -> teleport.machineid.v1.BotInstanceSpec - 5, // 2: teleport.machineid.v1.BotInstance.status:type_name -> teleport.machineid.v1.BotInstanceStatus - 7, // 3: teleport.machineid.v1.BotInstanceStatusHeartbeat.recorded_at:type_name -> google.protobuf.Timestamp - 8, // 4: teleport.machineid.v1.BotInstanceStatusHeartbeat.uptime:type_name -> google.protobuf.Duration - 9, // 5: teleport.machineid.v1.BotInstanceStatusHeartbeat.updater_info:type_name -> types.UpdaterV2Info + 9, // 0: teleport.machineid.v1.BotInstance.metadata:type_name -> teleport.header.v1.Metadata + 3, // 1: teleport.machineid.v1.BotInstance.spec:type_name -> teleport.machineid.v1.BotInstanceSpec + 6, // 2: teleport.machineid.v1.BotInstance.status:type_name -> teleport.machineid.v1.BotInstanceStatus + 10, // 3: teleport.machineid.v1.BotInstanceStatusHeartbeat.recorded_at:type_name -> google.protobuf.Timestamp + 11, // 4: teleport.machineid.v1.BotInstanceStatusHeartbeat.uptime:type_name -> google.protobuf.Duration + 12, // 5: teleport.machineid.v1.BotInstanceStatusHeartbeat.updater_info:type_name -> types.UpdaterV2Info 0, // 6: teleport.machineid.v1.BotInstanceStatusHeartbeat.kind:type_name -> teleport.machineid.v1.BotKind - 7, // 7: teleport.machineid.v1.BotInstanceStatusAuthentication.authenticated_at:type_name -> google.protobuf.Timestamp - 10, // 8: teleport.machineid.v1.BotInstanceStatusAuthentication.metadata:type_name -> google.protobuf.Struct - 11, // 9: teleport.machineid.v1.BotInstanceStatusAuthentication.join_attrs:type_name -> teleport.workloadidentity.v1.JoinAttrs - 4, // 10: teleport.machineid.v1.BotInstanceStatus.initial_authentication:type_name -> teleport.machineid.v1.BotInstanceStatusAuthentication - 4, // 11: teleport.machineid.v1.BotInstanceStatus.latest_authentications:type_name -> teleport.machineid.v1.BotInstanceStatusAuthentication - 3, // 12: teleport.machineid.v1.BotInstanceStatus.initial_heartbeat:type_name -> teleport.machineid.v1.BotInstanceStatusHeartbeat - 3, // 13: teleport.machineid.v1.BotInstanceStatus.latest_heartbeats:type_name -> teleport.machineid.v1.BotInstanceStatusHeartbeat - 14, // [14:14] is the sub-list for method output_type - 14, // [14:14] is the sub-list for method input_type - 14, // [14:14] is the sub-list for extension type_name - 14, // [14:14] is the sub-list for extension extendee - 0, // [0:14] is the sub-list for field type_name + 10, // 7: teleport.machineid.v1.BotInstanceStatusAuthentication.authenticated_at:type_name -> google.protobuf.Timestamp + 13, // 8: teleport.machineid.v1.BotInstanceStatusAuthentication.metadata:type_name -> google.protobuf.Struct + 14, // 9: teleport.machineid.v1.BotInstanceStatusAuthentication.join_attrs:type_name -> teleport.workloadidentity.v1.JoinAttrs + 5, // 10: teleport.machineid.v1.BotInstanceStatus.initial_authentication:type_name -> teleport.machineid.v1.BotInstanceStatusAuthentication + 5, // 11: teleport.machineid.v1.BotInstanceStatus.latest_authentications:type_name -> teleport.machineid.v1.BotInstanceStatusAuthentication + 4, // 12: teleport.machineid.v1.BotInstanceStatus.initial_heartbeat:type_name -> teleport.machineid.v1.BotInstanceStatusHeartbeat + 4, // 13: teleport.machineid.v1.BotInstanceStatus.latest_heartbeats:type_name -> teleport.machineid.v1.BotInstanceStatusHeartbeat + 7, // 14: teleport.machineid.v1.BotInstanceStatus.service_health:type_name -> teleport.machineid.v1.BotInstanceServiceHealth + 8, // 15: teleport.machineid.v1.BotInstanceServiceHealth.service:type_name -> teleport.machineid.v1.BotInstanceServiceIdentifier + 1, // 16: teleport.machineid.v1.BotInstanceServiceHealth.status:type_name -> teleport.machineid.v1.BotInstanceHealthStatus + 10, // 17: teleport.machineid.v1.BotInstanceServiceHealth.updated_at:type_name -> google.protobuf.Timestamp + 18, // [18:18] is the sub-list for method output_type + 18, // [18:18] is the sub-list for method input_type + 18, // [18:18] is the sub-list for extension type_name + 18, // [18:18] is the sub-list for extension extendee + 0, // [0:18] is the sub-list for field type_name } func init() { file_teleport_machineid_v1_bot_instance_proto_init() } @@ -726,13 +945,14 @@ func file_teleport_machineid_v1_bot_instance_proto_init() { if File_teleport_machineid_v1_bot_instance_proto != nil { return } + file_teleport_machineid_v1_bot_instance_proto_msgTypes[5].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_teleport_machineid_v1_bot_instance_proto_rawDesc), len(file_teleport_machineid_v1_bot_instance_proto_rawDesc)), - NumEnums: 1, - NumMessages: 5, + NumEnums: 2, + NumMessages: 7, NumExtensions: 0, NumServices: 0, }, diff --git a/api/proto/teleport/machineid/v1/bot_instance.proto b/api/proto/teleport/machineid/v1/bot_instance.proto index 1010412b1a6a8..d31b9b4947262 100644 --- a/api/proto/teleport/machineid/v1/bot_instance.proto +++ b/api/proto/teleport/machineid/v1/bot_instance.proto @@ -167,4 +167,47 @@ message BotInstanceStatus { BotInstanceStatusHeartbeat initial_heartbeat = 3; // The N most recent heartbeats for this bot instance. repeated BotInstanceStatusHeartbeat latest_heartbeats = 4; + // The health of the services/output `tbot` is running. + repeated BotInstanceServiceHealth service_health = 5; +} + +// 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; } diff --git a/tool/tctl/common/bots_command.go b/tool/tctl/common/bots_command.go index 96a65c46df646..f2fd47054697d 100644 --- a/tool/tctl/common/bots_command.go +++ b/tool/tctl/common/bots_command.go @@ -29,11 +29,13 @@ 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" @@ -651,7 +653,7 @@ func (c *BotsCommand) ListBotInstances(ctx context.Context, client botsCommandCl return nil } - t := asciitable.MakeTable([]string{"ID", "Join Method", "Version", "Hostname", "Last Seen"}) + t := asciitable.MakeTable([]string{"ID", "Join Method", "Version", "Hostname", "Status", "Last Seen"}) for _, i := range instances { var ( joinMethod string @@ -699,9 +701,14 @@ func (c *BotsCommand) ListBotInstances(ctx context.Context, client botsCommandCl } } + 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, - version, hostname, lastSeen.Format(time.RFC3339), + version, hostname, healthStatus, lastSeen.Format(time.RFC3339), }) } fmt.Fprintln(c.stdout, t.AsBuffer().String()) @@ -781,8 +788,9 @@ func (c *BotsCommand) AddBotInstance(ctx context.Context, client botsCommandClie 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}} @@ -790,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: @@ -832,12 +843,24 @@ func (c *BotsCommand) ShowBotInstance(ctx context.Context, client botsCommandCli 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)) @@ -951,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) { @@ -977,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 89d2a1ab261ad..c003223026054 100644 --- a/tool/tctl/common/bots_command_test.go +++ b/tool/tctl/common/bots_command_test.go @@ -318,6 +318,121 @@ 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()