From cb0e8bcec3a1e971ef935226f0349ee89f2cbb49 Mon Sep 17 00:00:00 2001 From: Cedric Kienzler Date: Fri, 6 Jun 2025 19:06:58 +0200 Subject: [PATCH 1/3] feat(bladectl): add more bladectl commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR introduces a comprehensive set of new subcommands to bladectl, expanding its capabilities for querying and managing compute blade state. It also includes an internal refactor to simplify interface management across the gRPC API. * `get` * `fan`: Returns current fan speed. * `identify`: Indicates whether the identify mode is active. * `stealth`: Shows if stealth mode is currently enabled. * `status`: Prints a full blade status report. * `temperature`: Retrieves current SoC temperature. * `critical`: Shows whether critical mode is active. * `power`: Reports the current power source (e.g., PoE+ or USB). * `set` * `stealth`: Enables stealth mode. * `remove` * `stealth`: Disables stealth mode. * `describe` * `fan`: Outputs the current fan curve configuration. * `monitor`: plot some charts about the state of the compute-blade-agent * **gRPC API refactor**: The gRPC service definitions previously located in `internal/api` have been folded into `internal/agent`. This eliminates redundant interface declarations and ensures that all ComputeBladeAgent implementations are directly compatible with the gRPC API. This reduces duplication and improves long-term maintainability and clarity of the interface contract. ```bash bladectl set fan --percent 90 --blade 1 --blade 2 bladectl unset identify --blade 1 --blade 2 --blade 3 --blade 4 bladectl set stealth --blade 1 --blade 2 --blade 3 --blade 4 bladectl get status --blade 1 --blade 2 --blade 3 --blade 4 ┌───────┬─────────────┬────────────────────┬───────────────┬──────────────┬──────────┬───────────────┬──────────────┐ │ BLADE │ TEMPERATURE │ FAN SPEED OVERRIDE │ FAN SPEED │ STEALTH MODE │ IDENTIFY │ CRITICAL MODE │ POWER STATUS │ ├───────┼─────────────┼────────────────────┼───────────────┼──────────────┼──────────┼───────────────┼──────────────┤ │ 1 │ 50°C │ 90% │ 5825 RPM(90%) │ Active │ Off │ Off │ poe+ │ │ 2 │ 48°C │ 90% │ 5825 RPM(90%) │ Active │ Off │ Off │ poe+ │ │ 3 │ 49°C │ Not set │ 4643 RPM(56%) │ Active │ Off │ Off │ poe+ │ │ 4 │ 49°C │ Not set │ 4774 RPM(58%) │ Active │ Off │ Off │ poe+ │ └───────┴─────────────┴────────────────────┴───────────────┴──────────────┴──────────┴───────────────┴──────────────┘ bladectl rm stealth --blade 1 --blade 2 --blade 3 --blade 4 bladectl rm fan --blade 1 --blade 2 --blade 3 --blade 4 bladectl get status --blade 1 --blade 2 --blade 3 --blade 4 ┌───────┬─────────────┬────────────────────┬───────────────┬──────────────┬──────────┬───────────────┬──────────────┐ │ BLADE │ TEMPERATURE │ FAN SPEED OVERRIDE │ FAN SPEED │ STEALTH MODE │ IDENTIFY │ CRITICAL MODE │ POWER STATUS │ ├───────┼─────────────┼────────────────────┼───────────────┼──────────────┼──────────┼───────────────┼──────────────┤ │ 1 │ 51°C │ Not set │ 5177 RPM(66%) │ Off │ Off │ Off │ poe+ │ │ 2 │ 49°C │ Not set │ 5177 RPM(58%) │ Off │ Off │ Off │ poe+ │ │ 3 │ 50°C │ Not set │ 4659 RPM(60%) │ Off │ Off │ Off │ poe+ │ │ 4 │ 48°C │ Not set │ 4659 RPM(54%) │ Off │ Off │ Off │ poe+ │ └───────┴─────────────┴────────────────────┴───────────────┴──────────────┴──────────┴───────────────┴──────────────┘ ``` when having multiple compute-blades in your bladeconfig: ```yaml blades: - name: 1 blade: server: blade-pi1:8081 cert: certificate-authority-data: client-certificate-data: client-key-data: - name: 2 blade: server: blade-pi2:8081 cert: certificate-authority-data: client-certificate-data: client-key-data: - name: 3 blade: server: blade-pi3:8081 cert: certificate-authority-data: client-certificate-data: client-key-data: - name: 4 blade: server: blade-pi4:8081 cert: certificate-authority-data: client-certificate-data: client-key-data: - name: 4 blade: server: blade-pi4:8081 cert: certificate-authority-data: client-certificate-data: client-key-data: current-blade: 1 ``` Fixes #4, #9 (partially), should help with #5 --- api/bladeapi/v1alpha1/blade.pb.go | 253 +++++++++--- api/bladeapi/v1alpha1/blade.proto | 19 +- api/bladeapi/v1alpha1/blade_grpc.pb.go | 49 +++ cmd/agent/main.go | 28 +- cmd/bladectl/cmd_fan.go | 191 ++++++++- cmd/bladectl/cmd_get_misc.go | 105 +++++ cmd/bladectl/cmd_identify.go | 143 ++++--- cmd/bladectl/cmd_monitor.go | 169 ++++++++ cmd/bladectl/cmd_root.go | 123 +++--- cmd/bladectl/cmd_status.go | 78 ++++ cmd/bladectl/cmd_stealth.go | 88 +++++ cmd/bladectl/cmd_verbs.go | 8 + cmd/bladectl/main.go | 15 +- cmd/bladectl/util.go | 74 ++++ go.mod | 22 +- go.sum | 47 +++ internal/agent/agent.go | 407 ++++++++------------ internal/agent/api.go | 149 +++++++ internal/{api => agent}/api_certificates.go | 2 +- internal/agent/handler.go | 104 +++++ internal/agent/options.go | 30 ++ internal/agent/utils.go | 25 ++ internal/api/api.go | 191 --------- internal/api/config.go | 8 - internal/api/options.go | 46 --- pkg/agent/agent.go | 14 +- {internal => pkg}/agent/config.go | 10 +- pkg/fancontroller/fancontroller.go | 20 +- pkg/fancontroller/fancontroller_test.go | 4 +- pkg/hal/hal.go | 9 +- pkg/hal/hal_bcm2711.go | 10 +- pkg/hal/hal_bcm2711_simulated.go | 13 +- pkg/hal/hal_bcm2711_standardfanunit.go | 6 +- pkg/hal/hal_mock.go | 7 +- pkg/ledengine/ledengine.go | 9 +- pkg/ledengine/ledengine_test.go | 10 +- pkg/ledengine/options.go | 2 +- pkg/util/kv_print.go | 16 + 38 files changed, 1778 insertions(+), 726 deletions(-) create mode 100644 cmd/bladectl/cmd_get_misc.go create mode 100644 cmd/bladectl/cmd_monitor.go create mode 100644 cmd/bladectl/cmd_status.go create mode 100644 cmd/bladectl/cmd_stealth.go create mode 100644 cmd/bladectl/util.go create mode 100644 internal/agent/api.go rename internal/{api => agent}/api_certificates.go (99%) create mode 100644 internal/agent/handler.go create mode 100644 internal/agent/options.go create mode 100644 internal/agent/utils.go delete mode 100644 internal/api/api.go delete mode 100644 internal/api/config.go delete mode 100644 internal/api/options.go rename {internal => pkg}/agent/config.go (86%) create mode 100644 pkg/util/kv_print.go diff --git a/api/bladeapi/v1alpha1/blade.pb.go b/api/bladeapi/v1alpha1/blade.pb.go index 4728228..bad2074 100644 --- a/api/bladeapi/v1alpha1/blade.pb.go +++ b/api/bladeapi/v1alpha1/blade.pb.go @@ -309,23 +309,82 @@ func (x *EmitEventRequest) GetEvent() Event { return Event_IDENTIFY } +type FanCurveStep struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Temperature int64 `protobuf:"varint,1,opt,name=temperature,proto3" json:"temperature,omitempty"` + Percent uint32 `protobuf:"varint,2,opt,name=percent,proto3" json:"percent,omitempty"` +} + +func (x *FanCurveStep) Reset() { + *x = FanCurveStep{} + if protoimpl.UnsafeEnabled { + mi := &file_api_bladeapi_v1alpha1_blade_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *FanCurveStep) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FanCurveStep) ProtoMessage() {} + +func (x *FanCurveStep) ProtoReflect() protoreflect.Message { + mi := &file_api_bladeapi_v1alpha1_blade_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FanCurveStep.ProtoReflect.Descriptor instead. +func (*FanCurveStep) Descriptor() ([]byte, []int) { + return file_api_bladeapi_v1alpha1_blade_proto_rawDescGZIP(), []int{3} +} + +func (x *FanCurveStep) GetTemperature() int64 { + if x != nil { + return x.Temperature + } + return 0 +} + +func (x *FanCurveStep) GetPercent() uint32 { + if x != nil { + return x.Percent + } + return 0 +} + type StatusResponse struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - StealthMode bool `protobuf:"varint,1,opt,name=stealth_mode,json=stealthMode,proto3" json:"stealth_mode,omitempty"` - IdentifyActive bool `protobuf:"varint,2,opt,name=identify_active,json=identifyActive,proto3" json:"identify_active,omitempty"` - CriticalActive bool `protobuf:"varint,3,opt,name=critical_active,json=criticalActive,proto3" json:"critical_active,omitempty"` - Temperature int64 `protobuf:"varint,4,opt,name=temperature,proto3" json:"temperature,omitempty"` - FanRpm int64 `protobuf:"varint,5,opt,name=fan_rpm,json=fanRpm,proto3" json:"fan_rpm,omitempty"` - PowerStatus PowerStatus `protobuf:"varint,6,opt,name=power_status,json=powerStatus,proto3,enum=api.bladeapi.v1alpha1.PowerStatus" json:"power_status,omitempty"` + StealthMode bool `protobuf:"varint,1,opt,name=stealth_mode,json=stealthMode,proto3" json:"stealth_mode,omitempty"` + IdentifyActive bool `protobuf:"varint,2,opt,name=identify_active,json=identifyActive,proto3" json:"identify_active,omitempty"` + CriticalActive bool `protobuf:"varint,3,opt,name=critical_active,json=criticalActive,proto3" json:"critical_active,omitempty"` + Temperature int64 `protobuf:"varint,4,opt,name=temperature,proto3" json:"temperature,omitempty"` + FanRpm int64 `protobuf:"varint,5,opt,name=fan_rpm,json=fanRpm,proto3" json:"fan_rpm,omitempty"` + PowerStatus PowerStatus `protobuf:"varint,6,opt,name=power_status,json=powerStatus,proto3,enum=api.bladeapi.v1alpha1.PowerStatus" json:"power_status,omitempty"` + FanPercent uint32 `protobuf:"varint,7,opt,name=fan_percent,json=fanPercent,proto3" json:"fan_percent,omitempty"` + FanSpeedAutomatic bool `protobuf:"varint,8,opt,name=fan_speed_automatic,json=fanSpeedAutomatic,proto3" json:"fan_speed_automatic,omitempty"` + CriticalTemperatureThreshold int64 `protobuf:"varint,9,opt,name=critical_temperature_threshold,json=criticalTemperatureThreshold,proto3" json:"critical_temperature_threshold,omitempty"` + FanCurveSteps []*FanCurveStep `protobuf:"bytes,10,rep,name=fan_curve_steps,json=fanCurveSteps,proto3" json:"fan_curve_steps,omitempty"` } func (x *StatusResponse) Reset() { *x = StatusResponse{} if protoimpl.UnsafeEnabled { - mi := &file_api_bladeapi_v1alpha1_blade_proto_msgTypes[3] + mi := &file_api_bladeapi_v1alpha1_blade_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -338,7 +397,7 @@ func (x *StatusResponse) String() string { func (*StatusResponse) ProtoMessage() {} func (x *StatusResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_bladeapi_v1alpha1_blade_proto_msgTypes[3] + mi := &file_api_bladeapi_v1alpha1_blade_proto_msgTypes[4] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -351,7 +410,7 @@ func (x *StatusResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StatusResponse.ProtoReflect.Descriptor instead. func (*StatusResponse) Descriptor() ([]byte, []int) { - return file_api_bladeapi_v1alpha1_blade_proto_rawDescGZIP(), []int{3} + return file_api_bladeapi_v1alpha1_blade_proto_rawDescGZIP(), []int{4} } func (x *StatusResponse) GetStealthMode() bool { @@ -396,6 +455,34 @@ func (x *StatusResponse) GetPowerStatus() PowerStatus { return PowerStatus_POE_OR_USBC } +func (x *StatusResponse) GetFanPercent() uint32 { + if x != nil { + return x.FanPercent + } + return 0 +} + +func (x *StatusResponse) GetFanSpeedAutomatic() bool { + if x != nil { + return x.FanSpeedAutomatic + } + return false +} + +func (x *StatusResponse) GetCriticalTemperatureThreshold() int64 { + if x != nil { + return x.CriticalTemperatureThreshold + } + return 0 +} + +func (x *StatusResponse) GetFanCurveSteps() []*FanCurveStep { + if x != nil { + return x.FanCurveSteps + } + return nil +} + var File_api_bladeapi_v1alpha1_blade_proto protoreflect.FileDescriptor var file_api_bladeapi_v1alpha1_blade_proto_rawDesc = []byte{ @@ -414,24 +501,43 @@ var file_api_bladeapi_v1alpha1_blade_proto_rawDesc = []byte{ 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x32, 0x0a, 0x05, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x62, 0x6c, 0x61, 0x64, 0x65, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, - 0x2e, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x52, 0x05, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x22, 0x87, 0x02, - 0x0a, 0x0e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x74, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x5f, 0x6d, 0x6f, 0x64, 0x65, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x73, 0x74, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x4d, - 0x6f, 0x64, 0x65, 0x12, 0x27, 0x0a, 0x0f, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x79, 0x5f, - 0x61, 0x63, 0x74, 0x69, 0x76, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x69, 0x64, - 0x65, 0x6e, 0x74, 0x69, 0x66, 0x79, 0x41, 0x63, 0x74, 0x69, 0x76, 0x65, 0x12, 0x27, 0x0a, 0x0f, - 0x63, 0x72, 0x69, 0x74, 0x69, 0x63, 0x61, 0x6c, 0x5f, 0x61, 0x63, 0x74, 0x69, 0x76, 0x65, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x63, 0x72, 0x69, 0x74, 0x69, 0x63, 0x61, 0x6c, 0x41, - 0x63, 0x74, 0x69, 0x76, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x74, 0x65, 0x6d, 0x70, 0x65, 0x72, 0x61, - 0x74, 0x75, 0x72, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x74, 0x65, 0x6d, 0x70, - 0x65, 0x72, 0x61, 0x74, 0x75, 0x72, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x66, 0x61, 0x6e, 0x5f, 0x72, - 0x70, 0x6d, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x66, 0x61, 0x6e, 0x52, 0x70, 0x6d, - 0x12, 0x45, 0x0a, 0x0c, 0x70, 0x6f, 0x77, 0x65, 0x72, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, - 0x18, 0x06, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x22, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x62, 0x6c, 0x61, - 0x64, 0x65, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x50, - 0x6f, 0x77, 0x65, 0x72, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x0b, 0x70, 0x6f, 0x77, 0x65, - 0x72, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x2a, 0x4d, 0x0a, 0x05, 0x45, 0x76, 0x65, 0x6e, 0x74, + 0x2e, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x52, 0x05, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x22, 0x4a, 0x0a, + 0x0c, 0x46, 0x61, 0x6e, 0x43, 0x75, 0x72, 0x76, 0x65, 0x53, 0x74, 0x65, 0x70, 0x12, 0x20, 0x0a, + 0x0b, 0x74, 0x65, 0x6d, 0x70, 0x65, 0x72, 0x61, 0x74, 0x75, 0x72, 0x65, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x03, 0x52, 0x0b, 0x74, 0x65, 0x6d, 0x70, 0x65, 0x72, 0x61, 0x74, 0x75, 0x72, 0x65, 0x12, + 0x18, 0x0a, 0x07, 0x70, 0x65, 0x72, 0x63, 0x65, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, + 0x52, 0x07, 0x70, 0x65, 0x72, 0x63, 0x65, 0x6e, 0x74, 0x22, 0xeb, 0x03, 0x0a, 0x0e, 0x53, 0x74, + 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x21, 0x0a, 0x0c, + 0x73, 0x74, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x5f, 0x6d, 0x6f, 0x64, 0x65, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x0b, 0x73, 0x74, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x4d, 0x6f, 0x64, 0x65, 0x12, + 0x27, 0x0a, 0x0f, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x79, 0x5f, 0x61, 0x63, 0x74, 0x69, + 0x76, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, + 0x66, 0x79, 0x41, 0x63, 0x74, 0x69, 0x76, 0x65, 0x12, 0x27, 0x0a, 0x0f, 0x63, 0x72, 0x69, 0x74, + 0x69, 0x63, 0x61, 0x6c, 0x5f, 0x61, 0x63, 0x74, 0x69, 0x76, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x08, 0x52, 0x0e, 0x63, 0x72, 0x69, 0x74, 0x69, 0x63, 0x61, 0x6c, 0x41, 0x63, 0x74, 0x69, 0x76, + 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x74, 0x65, 0x6d, 0x70, 0x65, 0x72, 0x61, 0x74, 0x75, 0x72, 0x65, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x74, 0x65, 0x6d, 0x70, 0x65, 0x72, 0x61, 0x74, + 0x75, 0x72, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x66, 0x61, 0x6e, 0x5f, 0x72, 0x70, 0x6d, 0x18, 0x05, + 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x66, 0x61, 0x6e, 0x52, 0x70, 0x6d, 0x12, 0x45, 0x0a, 0x0c, + 0x70, 0x6f, 0x77, 0x65, 0x72, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x06, 0x20, 0x01, + 0x28, 0x0e, 0x32, 0x22, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x62, 0x6c, 0x61, 0x64, 0x65, 0x61, 0x70, + 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x50, 0x6f, 0x77, 0x65, 0x72, + 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x0b, 0x70, 0x6f, 0x77, 0x65, 0x72, 0x53, 0x74, 0x61, + 0x74, 0x75, 0x73, 0x12, 0x1f, 0x0a, 0x0b, 0x66, 0x61, 0x6e, 0x5f, 0x70, 0x65, 0x72, 0x63, 0x65, + 0x6e, 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0a, 0x66, 0x61, 0x6e, 0x50, 0x65, 0x72, + 0x63, 0x65, 0x6e, 0x74, 0x12, 0x2e, 0x0a, 0x13, 0x66, 0x61, 0x6e, 0x5f, 0x73, 0x70, 0x65, 0x65, + 0x64, 0x5f, 0x61, 0x75, 0x74, 0x6f, 0x6d, 0x61, 0x74, 0x69, 0x63, 0x18, 0x08, 0x20, 0x01, 0x28, + 0x08, 0x52, 0x11, 0x66, 0x61, 0x6e, 0x53, 0x70, 0x65, 0x65, 0x64, 0x41, 0x75, 0x74, 0x6f, 0x6d, + 0x61, 0x74, 0x69, 0x63, 0x12, 0x44, 0x0a, 0x1e, 0x63, 0x72, 0x69, 0x74, 0x69, 0x63, 0x61, 0x6c, + 0x5f, 0x74, 0x65, 0x6d, 0x70, 0x65, 0x72, 0x61, 0x74, 0x75, 0x72, 0x65, 0x5f, 0x74, 0x68, 0x72, + 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x18, 0x09, 0x20, 0x01, 0x28, 0x03, 0x52, 0x1c, 0x63, 0x72, + 0x69, 0x74, 0x69, 0x63, 0x61, 0x6c, 0x54, 0x65, 0x6d, 0x70, 0x65, 0x72, 0x61, 0x74, 0x75, 0x72, + 0x65, 0x54, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x12, 0x4b, 0x0a, 0x0f, 0x66, 0x61, + 0x6e, 0x5f, 0x63, 0x75, 0x72, 0x76, 0x65, 0x5f, 0x73, 0x74, 0x65, 0x70, 0x73, 0x18, 0x0a, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x62, 0x6c, 0x61, 0x64, 0x65, 0x61, + 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x46, 0x61, 0x6e, 0x43, + 0x75, 0x72, 0x76, 0x65, 0x53, 0x74, 0x65, 0x70, 0x52, 0x0d, 0x66, 0x61, 0x6e, 0x43, 0x75, 0x72, + 0x76, 0x65, 0x53, 0x74, 0x65, 0x70, 0x73, 0x2a, 0x4d, 0x0a, 0x05, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x0c, 0x0a, 0x08, 0x49, 0x44, 0x45, 0x4e, 0x54, 0x49, 0x46, 0x59, 0x10, 0x00, 0x12, 0x14, 0x0a, 0x10, 0x49, 0x44, 0x45, 0x4e, 0x54, 0x49, 0x46, 0x59, 0x5f, 0x43, 0x4f, 0x4e, 0x46, 0x49, 0x52, 0x4d, 0x10, 0x01, 0x12, 0x0c, 0x0a, 0x08, 0x43, 0x52, 0x49, 0x54, 0x49, 0x43, 0x41, 0x4c, @@ -441,7 +547,7 @@ var file_api_bladeapi_v1alpha1_blade_proto_rawDesc = []byte{ 0x0a, 0x05, 0x53, 0x4d, 0x41, 0x52, 0x54, 0x10, 0x01, 0x2a, 0x2e, 0x0a, 0x0b, 0x50, 0x6f, 0x77, 0x65, 0x72, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x0f, 0x0a, 0x0b, 0x50, 0x4f, 0x45, 0x5f, 0x4f, 0x52, 0x5f, 0x55, 0x53, 0x42, 0x43, 0x10, 0x00, 0x12, 0x0e, 0x0a, 0x0a, 0x50, 0x4f, 0x45, - 0x5f, 0x38, 0x30, 0x32, 0x5f, 0x41, 0x54, 0x10, 0x01, 0x32, 0xa8, 0x03, 0x0a, 0x11, 0x42, 0x6c, + 0x5f, 0x38, 0x30, 0x32, 0x5f, 0x41, 0x54, 0x10, 0x01, 0x32, 0xed, 0x03, 0x0a, 0x11, 0x42, 0x6c, 0x61, 0x64, 0x65, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x4e, 0x0a, 0x09, 0x45, 0x6d, 0x69, 0x74, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x27, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x62, 0x6c, 0x61, 0x64, 0x65, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, @@ -458,22 +564,27 @@ var file_api_bladeapi_v1alpha1_blade_proto_rawDesc = []byte{ 0x61, 0x31, 0x2e, 0x53, 0x65, 0x74, 0x46, 0x61, 0x6e, 0x53, 0x70, 0x65, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, - 0x55, 0x0a, 0x0e, 0x53, 0x65, 0x74, 0x53, 0x74, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x4d, 0x6f, 0x64, - 0x65, 0x12, 0x29, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x62, 0x6c, 0x61, 0x64, 0x65, 0x61, 0x70, 0x69, - 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x53, 0x74, 0x65, 0x61, 0x6c, 0x74, - 0x68, 0x4d, 0x6f, 0x64, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, - 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, - 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x4c, 0x0a, 0x09, 0x47, 0x65, 0x74, 0x53, 0x74, 0x61, - 0x74, 0x75, 0x73, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x25, 0x2e, 0x61, 0x70, - 0x69, 0x2e, 0x62, 0x6c, 0x61, 0x64, 0x65, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, - 0x68, 0x61, 0x31, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x22, 0x00, 0x42, 0x48, 0x5a, 0x46, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, - 0x6f, 0x6d, 0x2f, 0x78, 0x76, 0x7a, 0x66, 0x2f, 0x63, 0x6f, 0x6d, 0x70, 0x75, 0x74, 0x65, 0x62, - 0x6c, 0x61, 0x64, 0x65, 0x2d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x62, - 0x6c, 0x61, 0x64, 0x65, 0x2f, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x3b, 0x62, 0x6c, - 0x61, 0x64, 0x65, 0x61, 0x70, 0x69, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x62, 0x06, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x43, 0x0a, 0x0f, 0x53, 0x65, 0x74, 0x46, 0x61, 0x6e, 0x53, 0x70, 0x65, 0x65, 0x64, 0x41, 0x75, + 0x74, 0x6f, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, + 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, + 0x74, 0x79, 0x22, 0x00, 0x12, 0x55, 0x0a, 0x0e, 0x53, 0x65, 0x74, 0x53, 0x74, 0x65, 0x61, 0x6c, + 0x74, 0x68, 0x4d, 0x6f, 0x64, 0x65, 0x12, 0x29, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x62, 0x6c, 0x61, + 0x64, 0x65, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x53, + 0x74, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x4d, 0x6f, 0x64, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x4c, 0x0a, 0x09, 0x47, + 0x65, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, + 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, + 0x1a, 0x25, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x62, 0x6c, 0x61, 0x64, 0x65, 0x61, 0x70, 0x69, 0x2e, + 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42, 0x57, 0x5a, 0x55, 0x67, 0x69, 0x74, + 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x75, 0x70, 0x74, 0x69, 0x6d, 0x65, 0x2d, 0x69, + 0x6e, 0x64, 0x75, 0x65, 0x73, 0x74, 0x72, 0x69, 0x65, 0x73, 0x2f, 0x63, 0x6f, 0x6d, 0x70, 0x75, + 0x74, 0x65, 0x2d, 0x62, 0x6c, 0x61, 0x64, 0x65, 0x2d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2f, 0x61, + 0x70, 0x69, 0x2f, 0x62, 0x6c, 0x61, 0x64, 0x65, 0x2f, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, + 0x31, 0x3b, 0x62, 0x6c, 0x61, 0x64, 0x65, 0x61, 0x70, 0x69, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, + 0x61, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -489,7 +600,7 @@ func file_api_bladeapi_v1alpha1_blade_proto_rawDescGZIP() []byte { } var file_api_bladeapi_v1alpha1_blade_proto_enumTypes = make([]protoimpl.EnumInfo, 3) -var file_api_bladeapi_v1alpha1_blade_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_api_bladeapi_v1alpha1_blade_proto_msgTypes = make([]protoimpl.MessageInfo, 5) var file_api_bladeapi_v1alpha1_blade_proto_goTypes = []interface{}{ (Event)(0), // 0: api.bladeapi.v1alpha1.Event (FanUnit)(0), // 1: api.bladeapi.v1alpha1.FanUnit @@ -497,27 +608,31 @@ var file_api_bladeapi_v1alpha1_blade_proto_goTypes = []interface{}{ (*StealthModeRequest)(nil), // 3: api.bladeapi.v1alpha1.StealthModeRequest (*SetFanSpeedRequest)(nil), // 4: api.bladeapi.v1alpha1.SetFanSpeedRequest (*EmitEventRequest)(nil), // 5: api.bladeapi.v1alpha1.EmitEventRequest - (*StatusResponse)(nil), // 6: api.bladeapi.v1alpha1.StatusResponse - (*emptypb.Empty)(nil), // 7: google.protobuf.Empty + (*FanCurveStep)(nil), // 6: api.bladeapi.v1alpha1.FanCurveStep + (*StatusResponse)(nil), // 7: api.bladeapi.v1alpha1.StatusResponse + (*emptypb.Empty)(nil), // 8: google.protobuf.Empty } var file_api_bladeapi_v1alpha1_blade_proto_depIdxs = []int32{ 0, // 0: api.bladeapi.v1alpha1.EmitEventRequest.event:type_name -> api.bladeapi.v1alpha1.Event 2, // 1: api.bladeapi.v1alpha1.StatusResponse.power_status:type_name -> api.bladeapi.v1alpha1.PowerStatus - 5, // 2: api.bladeapi.v1alpha1.BladeAgentService.EmitEvent:input_type -> api.bladeapi.v1alpha1.EmitEventRequest - 7, // 3: api.bladeapi.v1alpha1.BladeAgentService.WaitForIdentifyConfirm:input_type -> google.protobuf.Empty - 4, // 4: api.bladeapi.v1alpha1.BladeAgentService.SetFanSpeed:input_type -> api.bladeapi.v1alpha1.SetFanSpeedRequest - 3, // 5: api.bladeapi.v1alpha1.BladeAgentService.SetStealthMode:input_type -> api.bladeapi.v1alpha1.StealthModeRequest - 7, // 6: api.bladeapi.v1alpha1.BladeAgentService.GetStatus:input_type -> google.protobuf.Empty - 7, // 7: api.bladeapi.v1alpha1.BladeAgentService.EmitEvent:output_type -> google.protobuf.Empty - 7, // 8: api.bladeapi.v1alpha1.BladeAgentService.WaitForIdentifyConfirm:output_type -> google.protobuf.Empty - 7, // 9: api.bladeapi.v1alpha1.BladeAgentService.SetFanSpeed:output_type -> google.protobuf.Empty - 7, // 10: api.bladeapi.v1alpha1.BladeAgentService.SetStealthMode:output_type -> google.protobuf.Empty - 6, // 11: api.bladeapi.v1alpha1.BladeAgentService.GetStatus:output_type -> api.bladeapi.v1alpha1.StatusResponse - 7, // [7:12] is the sub-list for method output_type - 2, // [2:7] is the sub-list for method input_type - 2, // [2:2] is the sub-list for extension type_name - 2, // [2:2] is the sub-list for extension extendee - 0, // [0:2] is the sub-list for field type_name + 6, // 2: api.bladeapi.v1alpha1.StatusResponse.fan_curve_steps:type_name -> api.bladeapi.v1alpha1.FanCurveStep + 5, // 3: api.bladeapi.v1alpha1.BladeAgentService.EmitEvent:input_type -> api.bladeapi.v1alpha1.EmitEventRequest + 8, // 4: api.bladeapi.v1alpha1.BladeAgentService.WaitForIdentifyConfirm:input_type -> google.protobuf.Empty + 4, // 5: api.bladeapi.v1alpha1.BladeAgentService.SetFanSpeed:input_type -> api.bladeapi.v1alpha1.SetFanSpeedRequest + 8, // 6: api.bladeapi.v1alpha1.BladeAgentService.SetFanSpeedAuto:input_type -> google.protobuf.Empty + 3, // 7: api.bladeapi.v1alpha1.BladeAgentService.SetStealthMode:input_type -> api.bladeapi.v1alpha1.StealthModeRequest + 8, // 8: api.bladeapi.v1alpha1.BladeAgentService.GetStatus:input_type -> google.protobuf.Empty + 8, // 9: api.bladeapi.v1alpha1.BladeAgentService.EmitEvent:output_type -> google.protobuf.Empty + 8, // 10: api.bladeapi.v1alpha1.BladeAgentService.WaitForIdentifyConfirm:output_type -> google.protobuf.Empty + 8, // 11: api.bladeapi.v1alpha1.BladeAgentService.SetFanSpeed:output_type -> google.protobuf.Empty + 8, // 12: api.bladeapi.v1alpha1.BladeAgentService.SetFanSpeedAuto:output_type -> google.protobuf.Empty + 8, // 13: api.bladeapi.v1alpha1.BladeAgentService.SetStealthMode:output_type -> google.protobuf.Empty + 7, // 14: api.bladeapi.v1alpha1.BladeAgentService.GetStatus:output_type -> api.bladeapi.v1alpha1.StatusResponse + 9, // [9:15] is the sub-list for method output_type + 3, // [3:9] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name } func init() { file_api_bladeapi_v1alpha1_blade_proto_init() } @@ -563,6 +678,18 @@ func file_api_bladeapi_v1alpha1_blade_proto_init() { } } file_api_bladeapi_v1alpha1_blade_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*FanCurveStep); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_api_bladeapi_v1alpha1_blade_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*StatusResponse); i { case 0: return &v.state @@ -581,7 +708,7 @@ func file_api_bladeapi_v1alpha1_blade_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_api_bladeapi_v1alpha1_blade_proto_rawDesc, NumEnums: 3, - NumMessages: 4, + NumMessages: 5, NumExtensions: 0, NumServices: 1, }, diff --git a/api/bladeapi/v1alpha1/blade.proto b/api/bladeapi/v1alpha1/blade.proto index 64e46fc..6958ce2 100644 --- a/api/bladeapi/v1alpha1/blade.proto +++ b/api/bladeapi/v1alpha1/blade.proto @@ -1,4 +1,4 @@ -syntax = "proto4"; +syntax = "proto3"; import "google/protobuf/empty.proto"; package api.bladeapi.v1alpha1; @@ -37,6 +37,11 @@ message EmitEventRequest { Event event = 1; } +message FanCurveStep { + int64 temperature = 1; + uint32 percent = 2; +} + message StatusResponse { bool stealth_mode = 1; bool identify_active = 2; @@ -44,6 +49,10 @@ message StatusResponse { int64 temperature = 4; int64 fan_rpm = 5; PowerStatus power_status = 6; + uint32 fan_percent = 7; + bool fan_speed_automatic = 8; + int64 critical_temperature_threshold = 9; + repeated FanCurveStep fan_curve_steps = 10; } service BladeAgentService { @@ -53,9 +62,17 @@ service BladeAgentService { // WaitForIdentifyConfirm blocks until the blades button is pressed rpc WaitForIdentifyConfirm(google.protobuf.Empty) returns (google.protobuf.Empty) {} + // Sets the fan speed to a specific value. rpc SetFanSpeed(SetFanSpeedRequest) returns (google.protobuf.Empty) {} + // Sets the fan speed to automatic mode. + // + // Internally, this is equivalent to calling SetFanSpeed with a nil/empty value. + rpc SetFanSpeedAuto(google.protobuf.Empty) returns (google.protobuf.Empty) {} + + // Sets the blade to stealth mode (disables all LEDs) rpc SetStealthMode(StealthModeRequest) returns (google.protobuf.Empty) {} + // Gets the current status of the blade rpc GetStatus(google.protobuf.Empty) returns (StatusResponse) {} } diff --git a/api/bladeapi/v1alpha1/blade_grpc.pb.go b/api/bladeapi/v1alpha1/blade_grpc.pb.go index 6e6cddb..011281f 100644 --- a/api/bladeapi/v1alpha1/blade_grpc.pb.go +++ b/api/bladeapi/v1alpha1/blade_grpc.pb.go @@ -23,6 +23,7 @@ const ( BladeAgentService_EmitEvent_FullMethodName = "/api.bladeapi.v1alpha1.BladeAgentService/EmitEvent" BladeAgentService_WaitForIdentifyConfirm_FullMethodName = "/api.bladeapi.v1alpha1.BladeAgentService/WaitForIdentifyConfirm" BladeAgentService_SetFanSpeed_FullMethodName = "/api.bladeapi.v1alpha1.BladeAgentService/SetFanSpeed" + BladeAgentService_SetFanSpeedAuto_FullMethodName = "/api.bladeapi.v1alpha1.BladeAgentService/SetFanSpeedAuto" BladeAgentService_SetStealthMode_FullMethodName = "/api.bladeapi.v1alpha1.BladeAgentService/SetStealthMode" BladeAgentService_GetStatus_FullMethodName = "/api.bladeapi.v1alpha1.BladeAgentService/GetStatus" ) @@ -35,8 +36,15 @@ type BladeAgentServiceClient interface { EmitEvent(ctx context.Context, in *EmitEventRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) // WaitForIdentifyConfirm blocks until the blades button is pressed WaitForIdentifyConfirm(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) + // Sets the fan speed to a specific value. SetFanSpeed(ctx context.Context, in *SetFanSpeedRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + // Sets the fan speed to automatic mode. + // + // Internally, this is equivalent to calling SetFanSpeed with a nil/empty value. + SetFanSpeedAuto(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) + // Sets the blade to stealth mode (disables all LEDs) SetStealthMode(ctx context.Context, in *StealthModeRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + // Gets the current status of the blade GetStatus(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*StatusResponse, error) } @@ -75,6 +83,15 @@ func (c *bladeAgentServiceClient) SetFanSpeed(ctx context.Context, in *SetFanSpe return out, nil } +func (c *bladeAgentServiceClient) SetFanSpeedAuto(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) { + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, BladeAgentService_SetFanSpeedAuto_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *bladeAgentServiceClient) SetStealthMode(ctx context.Context, in *StealthModeRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { out := new(emptypb.Empty) err := c.cc.Invoke(ctx, BladeAgentService_SetStealthMode_FullMethodName, in, out, opts...) @@ -101,8 +118,15 @@ type BladeAgentServiceServer interface { EmitEvent(context.Context, *EmitEventRequest) (*emptypb.Empty, error) // WaitForIdentifyConfirm blocks until the blades button is pressed WaitForIdentifyConfirm(context.Context, *emptypb.Empty) (*emptypb.Empty, error) + // Sets the fan speed to a specific value. SetFanSpeed(context.Context, *SetFanSpeedRequest) (*emptypb.Empty, error) + // Sets the fan speed to automatic mode. + // + // Internally, this is equivalent to calling SetFanSpeed with a nil/empty value. + SetFanSpeedAuto(context.Context, *emptypb.Empty) (*emptypb.Empty, error) + // Sets the blade to stealth mode (disables all LEDs) SetStealthMode(context.Context, *StealthModeRequest) (*emptypb.Empty, error) + // Gets the current status of the blade GetStatus(context.Context, *emptypb.Empty) (*StatusResponse, error) mustEmbedUnimplementedBladeAgentServiceServer() } @@ -120,6 +144,9 @@ func (UnimplementedBladeAgentServiceServer) WaitForIdentifyConfirm(context.Conte func (UnimplementedBladeAgentServiceServer) SetFanSpeed(context.Context, *SetFanSpeedRequest) (*emptypb.Empty, error) { return nil, status.Errorf(codes.Unimplemented, "method SetFanSpeed not implemented") } +func (UnimplementedBladeAgentServiceServer) SetFanSpeedAuto(context.Context, *emptypb.Empty) (*emptypb.Empty, error) { + return nil, status.Errorf(codes.Unimplemented, "method SetFanSpeedAuto not implemented") +} func (UnimplementedBladeAgentServiceServer) SetStealthMode(context.Context, *StealthModeRequest) (*emptypb.Empty, error) { return nil, status.Errorf(codes.Unimplemented, "method SetStealthMode not implemented") } @@ -193,6 +220,24 @@ func _BladeAgentService_SetFanSpeed_Handler(srv interface{}, ctx context.Context return interceptor(ctx, in, info, handler) } +func _BladeAgentService_SetFanSpeedAuto_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(emptypb.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(BladeAgentServiceServer).SetFanSpeedAuto(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: BladeAgentService_SetFanSpeedAuto_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(BladeAgentServiceServer).SetFanSpeedAuto(ctx, req.(*emptypb.Empty)) + } + return interceptor(ctx, in, info, handler) +} + func _BladeAgentService_SetStealthMode_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(StealthModeRequest) if err := dec(in); err != nil { @@ -248,6 +293,10 @@ var BladeAgentService_ServiceDesc = grpc.ServiceDesc{ MethodName: "SetFanSpeed", Handler: _BladeAgentService_SetFanSpeed_Handler, }, + { + MethodName: "SetFanSpeedAuto", + Handler: _BladeAgentService_SetFanSpeedAuto_Handler, + }, { MethodName: "SetStealthMode", Handler: _BladeAgentService_SetStealthMode_Handler, diff --git a/cmd/agent/main.go b/cmd/agent/main.go index b01ac04..67a1c72 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -13,8 +13,8 @@ import ( "syscall" "time" - "github.com/compute-blade-community/compute-blade-agent/internal/agent" - "github.com/compute-blade-community/compute-blade-agent/internal/api" + internal_agent "github.com/compute-blade-community/compute-blade-agent/internal/agent" + "github.com/compute-blade-community/compute-blade-agent/pkg/agent" "github.com/compute-blade-community/compute-blade-agent/pkg/log" "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/spechtlabs/go-otel-utils/otelprovider" @@ -153,8 +153,8 @@ func main() { } }() - log.FromContext(ctx).Info("Bootstrapping compute-blade-agent") - computebladeAgent, err := agent.NewComputeBladeAgent(ctx, cbAgentConfig) + log.FromContext(ctx).Info("Bootstrapping compute-blade-agent", zap.String("version", Version), zap.String("commit", Commit)) + computebladeAgent, err := internal_agent.NewComputeBladeAgent(ctx, cbAgentConfig) if err != nil { cancelCtx(err) log.FromContext(ctx).WithError(err).Fatal("Failed to create agent") @@ -163,17 +163,6 @@ func main() { // Run agent computebladeAgent.RunAsync(ctx, cancelCtx) - // Setup GRPC server - grpcServer := api.NewGrpcApiServer(ctx, - api.WithComputeBladeAgent(computebladeAgent), - api.WithAuthentication(cbAgentConfig.Listen.GrpcAuthenticated), - api.WithListenAddr(cbAgentConfig.Listen.Grpc), - api.WithListenMode(cbAgentConfig.Listen.GrpcListenMode), - ) - - // Run gRPC API - grpcServer.ServeAsync(ctx, cancelCtx) - // setup prometheus endpoint promServer := runPrometheusEndpoint(ctx, cancelCtx, &cbAgentConfig.Listen) @@ -190,8 +179,11 @@ func main() { wg.Add(1) go func() { defer wg.Done() - otelzap.L().Info("Shutting down grpc server") - grpcServer.GracefulStop() + + log.FromContext(ctx).Info("Shutting down compute blade agent...") + if err := computebladeAgent.GracefulStop(ctx); err != nil { + log.FromContext(ctx).WithError(err).Error("Failed to close compute blade agent") + } }() // Shut-Down Prometheus Endpoint @@ -218,7 +210,7 @@ func main() { } } -func runPrometheusEndpoint(ctx context.Context, cancel context.CancelCauseFunc, apiConfig *api.Config) *http.Server { +func runPrometheusEndpoint(ctx context.Context, cancel context.CancelCauseFunc, apiConfig *agent.ApiConfig) *http.Server { instrumentationHandler := http.NewServeMux() instrumentationHandler.Handle("/metrics", promhttp.Handler()) instrumentationHandler.HandleFunc("/debug/pprof/", pprof.Index) diff --git a/cmd/bladectl/cmd_fan.go b/cmd/bladectl/cmd_fan.go index 686123a..4840441 100644 --- a/cmd/bladectl/cmd_fan.go +++ b/cmd/bladectl/cmd_fan.go @@ -1,38 +1,209 @@ package main import ( + "fmt" + "os" + "sort" + bladeapiv1alpha1 "github.com/compute-blade-community/compute-blade-agent/api/bladeapi/v1alpha1" + "github.com/olekukonko/tablewriter" + "github.com/olekukonko/tablewriter/tw" "github.com/spf13/cobra" + "google.golang.org/protobuf/types/known/emptypb" ) var ( percent int + auto bool ) func init() { - cmdFan.Flags().IntVarP(&percent, "percent", "p", 40, "Fan speed in percent (Default: 40).") - _ = cmdFan.MarkFlagRequired("percent") + cmdSetFan.Flags().IntVarP(&percent, "percent", "p", 40, "Fan speed in percent (Default: 40).") + cmdSetFan.Flags().BoolVarP(&auto, "auto", "a", false, "Set fan speed to automatic mode.") - cmdSet.AddCommand(cmdFan) + cmdSet.AddCommand(cmdSetFan) + cmdGet.AddCommand(cmdGetFan) + cmdRemove.AddCommand(cmdRmFan) + cmdDescribe.AddCommand(cmdDescribeFan) } var ( - cmdFan = &cobra.Command{ + fanAliases = []string{"fan_speed", "rpm"} + + cmdSetFan = &cobra.Command{ Use: "fan", + Aliases: fanAliases, Short: "Control the fan behavior of the compute-blade", Example: "bladectl set fan --percent 50", Args: cobra.ExactArgs(0), RunE: func(cmd *cobra.Command, args []string) error { - var err error + autoSet := cmd.Flags().Changed("auto") + percentSet := cmd.Flags().Changed("percent") + + if autoSet && percentSet { + return fmt.Errorf("only one of --auto or --percent can be specified") + } + + if !autoSet && !percentSet { + return fmt.Errorf("you must specify either --auto or --percent") + } + + ctx := cmd.Context() + clients := clientsFromContext(ctx) + + for _, client := range clients { + var err error + + if auto { + _, err = client.SetFanSpeedAuto(ctx, &emptypb.Empty{}) + } else { + _, err = client.SetFanSpeed(ctx, &bladeapiv1alpha1.SetFanSpeedRequest{ + Percent: int64(percent), + }) + } + + if err != nil { + return err + } + } + + return nil + }, + } + + cmdRmFan = &cobra.Command{ + Use: "fan", + Aliases: fanAliases, + Short: "Remove the fan speed override of the compute-blade", + Example: "bladectl unset fan", + Args: cobra.ExactArgs(0), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + clients := clientsFromContext(ctx) + + for _, client := range clients { + if _, err := client.SetFanSpeedAuto(ctx, &emptypb.Empty{}); err != nil { + return err + } + } + + return nil + }, + } + + cmdGetFan = &cobra.Command{ + Use: "fan", + Aliases: fanAliases, + Short: "Get the fan speed of the compute-blade", + Example: "bladectl get fan", + Args: cobra.ExactArgs(0), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + clients := clientsFromContext(ctx) + + for idx, client := range clients { + bladeStatus, err := client.GetStatus(ctx, &emptypb.Empty{}) + if err != nil { + return err + } + + rpm := bladeStatus.FanRpm + percent := bladeStatus.FanPercent + rowPrefix := bladeNames[idx] + if len(bladeNames) > 1 { + rowPrefix += ": " + } else { + rowPrefix = "" + } + + fmt.Println(rpmStyle(rpm).Render(fmt.Sprint(rowPrefix + rpmLabel(rpm) + " (" + percentLabel(percent) + ")"))) + } + + return nil + }, + } + cmdDescribeFan = &cobra.Command{ + Use: "fan", + Aliases: fanAliases, + Short: "Get the fan speed curve of the compute-blade", + Example: "bladectl describe fan", + Args: cobra.ExactArgs(0), + RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - client := clientFromContext(ctx) + clients := clientsFromContext(ctx) - _, err = client.SetFanSpeed(ctx, &bladeapiv1alpha1.SetFanSpeedRequest{ - Percent: int64(percent), - }) + bladeFanCurves := make([][]*bladeapiv1alpha1.FanCurveStep, len(clients)) + criticalTemps := make([]int64, len(clients)) + for idx, client := range clients { + bladeStatus, err := client.GetStatus(ctx, &emptypb.Empty{}) + if err != nil { + return err + } - return err + bladeFanCurves[idx] = bladeStatus.FanCurveSteps + criticalTemps[idx] = bladeStatus.CriticalTemperatureThreshold + } + + printFanCurveTable(bladeFanCurves, criticalTemps) + return nil }, } ) + +func printFanCurveTable(bladeValues [][]*bladeapiv1alpha1.FanCurveStep, criticalTemps []int64) { + bladeCount := len(bladeValues) + + // Map blade index -> temperature -> step + bladeTempMap := make([]map[int64]*bladeapiv1alpha1.FanCurveStep, bladeCount) + allTempsSet := make(map[int64]struct{}) + + for bladeIdx, steps := range bladeValues { + bladeTempMap[bladeIdx] = make(map[int64]*bladeapiv1alpha1.FanCurveStep) + for _, step := range steps { + temp := step.Temperature + bladeTempMap[bladeIdx][temp] = step + allTempsSet[temp] = struct{}{} + } + } + + // Sorted temperature list + var allTemps []int64 + for t := range allTempsSet { + allTemps = append(allTemps, t) + } + + sort.Slice(allTemps, func(i, j int) bool { + return allTemps[i] < allTemps[j] + }) + + // Header: Blade | Temp1 | Temp2 | ... + header := []string{"Blade"} + for _, t := range allTemps { + header = append(header, tempLabel(t)) + } + + // Table writer setup + tbl := tablewriter.NewTable(os.Stdout, + tablewriter.WithHeader(header), + tablewriter.WithHeaderAlignment(tw.AlignLeft), + tablewriter.WithHeaderAutoFormat(tw.Off), + ) + + // Rows: one per blade + for bladeIdx, tempMap := range bladeTempMap { + row := []string{bladeNames[bladeIdx]} + for _, t := range allTemps { + if step, ok := tempMap[t]; ok { + style := tempStyle(step.Temperature, criticalTemps[bladeIdx]) + colored := style.Render(percentLabel(step.Percent)) + row = append(row, colored) + } else { + row = append(row, "") + } + } + _ = tbl.Append(row) + } + + _ = tbl.Render() +} diff --git a/cmd/bladectl/cmd_get_misc.go b/cmd/bladectl/cmd_get_misc.go new file mode 100644 index 0000000..c96eeff --- /dev/null +++ b/cmd/bladectl/cmd_get_misc.go @@ -0,0 +1,105 @@ +package main + +import ( + "fmt" + + "github.com/compute-blade-community/compute-blade-agent/pkg/hal" + "github.com/spf13/cobra" + "google.golang.org/protobuf/types/known/emptypb" +) + +func init() { + cmdGet.AddCommand(cmdGetTemp) + cmdGet.AddCommand(cmdGetCritical) + cmdGet.AddCommand(cmdGetPowerStatus) +} + +var ( + cmdGetTemp = &cobra.Command{ + Use: "temp", + Aliases: []string{"temperature"}, + Short: "Get the temperature of the compute-blade", + Example: "bladectl get temp", + Args: cobra.ExactArgs(0), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + clients := clientsFromContext(ctx) + + for idx, client := range clients { + bladeStatus, err := client.GetStatus(ctx, &emptypb.Empty{}) + if err != nil { + return err + } + + temp := bladeStatus.Temperature + rowPrefix := bladeNames[idx] + if len(bladeNames) > 1 { + rowPrefix += ": " + } else { + rowPrefix = "" + } + + fmt.Println(tempStyle(temp, bladeStatus.CriticalTemperatureThreshold).Render(rowPrefix + tempLabel(temp))) + } + return nil + }, + } + + cmdGetCritical = &cobra.Command{ + Use: "critical", + Short: "Get the critical of the compute-blade", + Example: "bladectl get critical", + Args: cobra.ExactArgs(0), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + clients := clientsFromContext(ctx) + + for idx, client := range clients { + bladeStatus, err := client.GetStatus(ctx, &emptypb.Empty{}) + if err != nil { + return err + } + + rowPrefix := bladeNames[idx] + if len(bladeNames) > 1 { + rowPrefix += ": " + } else { + rowPrefix = "" + } + + fmt.Println(activeStyle(bladeStatus.CriticalActive).Render(rowPrefix + activeLabel(bladeStatus.CriticalActive))) + } + return nil + }, + } + + cmdGetPowerStatus = &cobra.Command{ + Use: "power_status", + Aliases: []string{"powerstatus", "power"}, + Short: "Get the power status of the compute-blade", + Example: "bladectl get power", + Args: cobra.ExactArgs(0), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + clients := clientsFromContext(ctx) + + for idx, client := range clients { + bladeStatus, err := client.GetStatus(ctx, &emptypb.Empty{}) + if err != nil { + return err + } + + rowPrefix := bladeNames[idx] + if len(bladeNames) > 1 { + rowPrefix += ": " + } else { + rowPrefix = "" + } + + fmt.Println(rowPrefix + hal.PowerStatus(bladeStatus.PowerStatus).String()) + } + + return nil + }, + } +) diff --git a/cmd/bladectl/cmd_identify.go b/cmd/bladectl/cmd_identify.go index 436def5..b1f82a7 100644 --- a/cmd/bladectl/cmd_identify.go +++ b/cmd/bladectl/cmd_identify.go @@ -2,6 +2,7 @@ package main import ( "errors" + "fmt" bladeapiv1alpha1 "github.com/compute-blade-community/compute-blade-agent/api/bladeapi/v1alpha1" "github.com/sierrasoftworks/humane-errors-go" @@ -19,69 +20,105 @@ func init() { cmdSetIdentify.Flags().BoolVarP(&wait, "wait", "w", false, "Wait for the identify state to be confirmed (e.g. by a physical button press)") cmdSet.AddCommand(cmdSetIdentify) cmdRemove.AddCommand(cmdRmIdentify) + cmdGet.AddCommand(cmdGetIdentify) } -var cmdSetIdentify = &cobra.Command{ - Use: "identify", - Example: "bladectl set identify --wait", - Short: "interact with the compute-blade identity LED", - RunE: runSetIdentify, -} +var ( + cmdSetIdentify = &cobra.Command{ + Use: "identify", + Example: "bladectl set identify --wait", + Short: "interact with the compute-blade identity LED", + RunE: func(cmd *cobra.Command, _ []string) error { + if len(bladeNames) > 1 && wait { + return fmt.Errorf("cannot enable identify on multiple compute-blades at the same with the --wait flag") + } -var cmdRmIdentify = &cobra.Command{ - Use: "identify", - Example: "bladectl unset identify", - Short: "remove the identify state with the compute-blade identity LED", - RunE: runRemoveIdentify, -} + ctx := cmd.Context() + clients := clientsFromContext(ctx) -func runSetIdentify(cmd *cobra.Command, _ []string) error { - ctx := cmd.Context() - client := clientFromContext(ctx) + for _, client := range clients { + // Check if we should wait for the identify state to be confirmed + event := bladeapiv1alpha1.Event_IDENTIFY + if confirm { + event = bladeapiv1alpha1.Event_IDENTIFY_CONFIRM + } - // Check if we should wait for the identify state to be confirmed - event := bladeapiv1alpha1.Event_IDENTIFY - if confirm { - event = bladeapiv1alpha1.Event_IDENTIFY_CONFIRM - } + // Emit the event to the compute-blade-agent + _, err := client.EmitEvent(ctx, &bladeapiv1alpha1.EmitEventRequest{Event: event}) + if err != nil { + return errors.New(humane.Wrap(err, + "failed to emit event", + "ensure the compute-blade agent is running and responsive to requests", + "check the compute-blade agent logs for more information using 'journalctl -u compute-blade-agent.service'", + ).Display()) + } - // Emit the event to the compute-blade-agent - _, err := client.EmitEvent(ctx, &bladeapiv1alpha1.EmitEventRequest{Event: event}) - if err != nil { - return errors.New(humane.Wrap(err, - "failed to emit event", - "ensure the compute-blade agent is running and responsive to requests", - "check the compute-blade agent logs for more information using 'journalctl -u compute-blade-agent.service'", - ).Display(), - ) - } + // Check if we should wait for the identify state to be confirmed + if wait { + if _, err := client.WaitForIdentifyConfirm(ctx, &emptypb.Empty{}); err != nil { + return errors.New( + humane.Wrap(err, "unable to wait for confirmation", + "ensure the compute-blade agent is running and responsive to requests", + "check the compute-blade agent logs for more information using 'journalctl -u compute-blade-agent.service'", + ).Display()) + } + } + } - // Check if we should wait for the identify state to be confirmed - if !wait { - return nil + return nil + }, } - if _, err := client.WaitForIdentifyConfirm(ctx, &emptypb.Empty{}); err != nil { - return humane.Wrap(err, "unable to wait for confirmation", "ensure the compute-blade agent is running and responsive to requests", "check the compute-blade agent logs for more information using 'journalctl -u compute-blade-agent.service'") - } + cmdRmIdentify = &cobra.Command{ + Use: "identify", + Example: "bladectl unset identify", + Short: "remove the identify state with the compute-blade identity LED", + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + clients := clientsFromContext(ctx) - return nil -} + for _, client := range clients { + // Emit the event to the compute-blade-agent + _, err := client.EmitEvent(ctx, &bladeapiv1alpha1.EmitEventRequest{Event: bladeapiv1alpha1.Event_IDENTIFY_CONFIRM}) + if err != nil { + return errors.New(humane.Wrap(err, + "failed to emit event", + "ensure the compute-blade agent is running and responsive to requests", + "check the compute-blade agent logs for more information using 'journalctl -u compute-blade-agent.service'", + ).Display()) + } + } -func runRemoveIdentify(cmd *cobra.Command, _ []string) error { - ctx := cmd.Context() - client := clientFromContext(ctx) - - // Emit the event to the compute-blade-agent - _, err := client.EmitEvent(ctx, &bladeapiv1alpha1.EmitEventRequest{Event: bladeapiv1alpha1.Event_IDENTIFY_CONFIRM}) - if err != nil { - return errors.New(humane.Wrap(err, - "failed to emit event", - "ensure the compute-blade agent is running and responsive to requests", - "check the compute-blade agent logs for more information using 'journalctl -u compute-blade-agent.service'", - ).Display(), - ) + return nil + }, } - return nil -} + cmdGetIdentify = &cobra.Command{ + Use: "identify", + Example: "bladectl get identify", + Short: "get the identify state of the compute-blade identity LED", + Args: cobra.ExactArgs(0), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + clients := clientsFromContext(ctx) + + for idx, client := range clients { + bladeStatus, err := client.GetStatus(ctx, &emptypb.Empty{}) + if err != nil { + return err + } + + rowPrefix := bladeNames[idx] + if len(bladeNames) > 1 { + rowPrefix += ": " + } else { + rowPrefix = "" + } + + fmt.Println(activeStyle(bladeStatus.IdentifyActive).Render(rowPrefix, activeLabel(bladeStatus.IdentifyActive))) + } + + return nil + }, + } +) diff --git a/cmd/bladectl/cmd_monitor.go b/cmd/bladectl/cmd_monitor.go new file mode 100644 index 0000000..7cb6fa2 --- /dev/null +++ b/cmd/bladectl/cmd_monitor.go @@ -0,0 +1,169 @@ +package main + +import ( + "context" + "errors" + "fmt" + "math" + "time" + + bladeapiv1alpha1 "github.com/compute-blade-community/compute-blade-agent/api/bladeapi/v1alpha1" + "github.com/compute-blade-community/compute-blade-agent/pkg/hal" + ui "github.com/gizak/termui/v3" + "github.com/gizak/termui/v3/widgets" + "github.com/spf13/cobra" + "google.golang.org/protobuf/types/known/emptypb" +) + +func init() { + rootCmd.AddCommand(cmdMonitor) +} + +var cmdMonitor = &cobra.Command{ + Use: "monitor", + Aliases: fanAliases, + Short: "Render a line-chart of the fan speed and temperature of the compute-blade", + Example: "bladectl chart status", + Args: cobra.ExactArgs(0), + RunE: func(cmd *cobra.Command, args []string) error { + if len(bladeNames) > 1 { + return fmt.Errorf("cannot monitor multiple blades at once, please specify a single blade with --blade") + } + + ctx := cmd.Context() + client := clientFromContext(ctx) + + if err := ui.Init(); err != nil { + return fmt.Errorf("failed to initialize UI: %w", err) + } + defer ui.Close() + + events := ui.PollEvents() + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + labelBox := widgets.NewParagraph() + labelBox.Title = fmt.Sprintf(" %s: Blade Status ", bladeNames[0]) + labelBox.Border = true + labelBox.TextStyle = ui.NewStyle(ui.ColorWhite) + + fanPlot := newPlot(fmt.Sprintf(" %s: Fan Speed (RPM) ", bladeNames[0]), ui.ColorGreen) + tempPlot := newPlot(fmt.Sprintf(" %s: SoC Temperature (\u00b0C) ", bladeNames[0]), ui.ColorCyan) + + fanData := []float64{math.NaN(), math.NaN()} + tempData := []float64{math.NaN(), math.NaN()} + + for { + select { + case <-ctx.Done(): + if errors.Is(ctx.Err(), context.Canceled) { + return nil + } + return ctx.Err() + + case e := <-events: + switch e.ID { + case "q", "": + return nil + case "": + renderCharts(nil, fanPlot, tempPlot, labelBox) + ui.Clear() + ui.Render(labelBox, fanPlot, tempPlot) + } + + case <-ticker.C: + status, err := client.GetStatus(ctx, &emptypb.Empty{}) + if err != nil { + labelBox.Text = "Error retrieving blade status: " + err.Error() + ui.Render(labelBox) + continue + } + + fanData = append(fanData, float64(status.FanRpm)) + tempData = append(tempData, float64(status.Temperature)) + + fanPlot.Data[0] = reversedFloats(fanData) + tempPlot.Data[0] = reversedFloats(tempData) + + renderCharts(status, fanPlot, tempPlot, labelBox) + ui.Render(labelBox, fanPlot, tempPlot) + } + } + }, +} + +func reversedFloats(s []float64) []float64 { + r := make([]float64, len(s)) + for i := range s { + r[len(s)-1-i] = s[i] + } + return r +} + +func newPlot(title string, color ui.Color) *widgets.Plot { + plot := widgets.NewPlot() + plot.Title = title + plot.Data = [][]float64{{}} + plot.LineColors = []ui.Color{color} + plot.AxesColor = ui.ColorWhite + plot.DrawDirection = widgets.DrawLeft + plot.HorizontalScale = 2 + return plot +} + +func renderCharts(status *bladeapiv1alpha1.StatusResponse, fanPlot, tempPlot *widgets.Plot, labelBox *widgets.Paragraph) { + width, height := ui.TerminalDimensions() + labelHeight := 4 + + if status != nil { + if status.CriticalActive { + labelBox.Text = fmt.Sprintf( + "Critical: %s | %s", + activeLabel(status.CriticalActive), + labelBox.Text, + ) + } + + labelBox.Text = fmt.Sprintf( + "Temp: %d°C | Fan: %d RPM (%d%%)", + status.Temperature, + status.FanRpm, + status.FanPercent, + ) + + if !status.FanSpeedAutomatic { + labelBox.Text = fmt.Sprintf( + "%s | Fan Override: %s", + labelBox.Text, + fanSpeedOverrideLabel(status.FanSpeedAutomatic, status.FanPercent), + ) + } + + if status.StealthMode { + labelBox.Text = fmt.Sprintf( + "%s | Stealth: %s", + labelBox.Text, + activeLabel(status.StealthMode), + ) + } + + labelBox.Text = fmt.Sprintf( + "%s | Identify: %s | Power: %s", + labelBox.Text, + activeLabel(status.IdentifyActive), + hal.PowerStatus(status.PowerStatus).String(), + ) + + } + + labelBox.SetRect(0, 0, width, labelHeight) + + if width >= 140 { + fanPlot.SetRect(0, labelHeight, width/2, height) + tempPlot.SetRect(width/2, labelHeight, width, height) + } else { + midY := (height-labelHeight)/2 + labelHeight + fanPlot.SetRect(0, labelHeight, width, midY) + tempPlot.SetRect(0, midY, width, height) + } +} diff --git a/cmd/bladectl/cmd_root.go b/cmd/bladectl/cmd_root.go index 656646c..ffb4457 100644 --- a/cmd/bladectl/cmd_root.go +++ b/cmd/bladectl/cmd_root.go @@ -27,12 +27,14 @@ import ( ) var ( - bladeName string - timeout time.Duration + allBlades bool + bladeNames []string + timeout time.Duration ) func init() { - rootCmd.PersistentFlags().StringVar(&bladeName, "blade", "", "Name of the compute-blade to control. If not provided, the compute-blade specified in `current-blade` will be used.") + rootCmd.PersistentFlags().BoolVarP(&allBlades, "all", "a", false, "control all compute-blades at the same time") + rootCmd.PersistentFlags().StringArrayVar(&bladeNames, "blade", []string{""}, "Name of the compute-blade to control. If not provided, the compute-blade specified in `current-blade` will be used.") rootCmd.PersistentFlags().DurationVar(&timeout, "timeout", time.Minute, "timeout for gRPC requests") } @@ -40,30 +42,20 @@ var rootCmd = &cobra.Command{ Use: "bladectl", Short: "bladectl interacts with the compute-blade-agent and allows you to manage hardware-features of your compute blade(s)", PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { - origCtx := cmd.Context() + ctx, cancelCtx := context.WithCancelCause(cmd.Context()) - // Load potential file configs + // load configuration + var bladectlCfg config.BladectlConfig if err := viper.ReadInConfig(); err != nil { + cancelCtx(err) return err } - - // load configuration - var bladectlCfg config.BladectlConfig if err := viper.Unmarshal(&bladectlCfg); err != nil { + cancelCtx(err) return err } - var blade *config.Blade - - blade, herr := bladectlCfg.FindBlade(bladeName) - if herr != nil { - return errors.New(herr.Display()) - } - // setup signal handlers for SIGINT and SIGTERM - ctx, cancelCtx := context.WithTimeout(origCtx, timeout) - - // setup signal handler channels sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT) go func() { @@ -73,6 +65,8 @@ var rootCmd = &cobra.Command{ // Wait for signal case sig := <-sigs: + fmt.Println("Received signal", sig.String()) + switch sig { case syscall.SIGTERM: fallthrough @@ -80,7 +74,7 @@ var rootCmd = &cobra.Command{ fallthrough case syscall.SIGQUIT: // On terminate signal, cancel context causing the program to terminate - cancelCtx() + cancelCtx(context.Canceled) default: log.FromContext(ctx).Warn("Received unknown signal", zap.String("signal", sig.String())) @@ -88,69 +82,66 @@ var rootCmd = &cobra.Command{ } }() - // Create our gRPC Transport Credentials - credentials := insecure.NewCredentials() - certData := blade.Certificate - - // If we're presented with certificate data in the config, we try to create a mTLS connection - if len(certData.ClientCertificateData) > 0 && len(certData.ClientKeyData) > 0 && len(certData.CertificateAuthorityData) > 0 { - var err error + // Allow to easily select all blades + if allBlades { + bladeNames = make([]string, len(bladectlCfg.Blades)) + for idx, blade := range bladectlCfg.Blades { + bladeNames[idx] = blade.Name + } + } - serverName := blade.Server - if strings.Contains(serverName, ":") { - if serverName, _, err = net.SplitHostPort(blade.Server); err != nil { - return fmt.Errorf("failed to parse server address: %w", err) - } + clients := make([]bladeapiv1alpha1.BladeAgentServiceClient, len(bladeNames)) + for idx, bladeName := range bladeNames { + var blade *config.Blade + blade, herr := bladectlCfg.FindBlade(bladeName) + if herr != nil { + cancelCtx(herr) + return errors.New(herr.Display()) } - if credentials, err = loadTlsCredentials(serverName, certData); err != nil { - return err + client, herr := buildClient(blade) + if herr != nil { + cancelCtx(herr) + return errors.New(herr.Display()) } - } - conn, err := grpc.NewClient(blade.Server, grpc.WithTransportCredentials(credentials)) - if err != nil { - return errors.New( - humane.Wrap(err, - "failed to dial grpc server", - "ensure the gRPC server you are trying to connect to is running and the address is correct", - ).Display(), - ) + clients[idx] = client } - client := bladeapiv1alpha1.NewBladeAgentServiceClient(conn) - cmd.SetContext(clientIntoContext(ctx, client)) + ctx = clientIntoContext(ctx, clients[0]) // Add the default client + ctx = clientsIntoContext(ctx, clients) // Add all clients + cmd.SetContext(ctx) return nil }, } -func loadTlsCredentials(server string, certData config.Certificate) (credentials.TransportCredentials, error) { +func loadTlsCredentials(server string, certData config.Certificate) (credentials.TransportCredentials, humane.Error) { // Decode base64 certificate, key, and CA certPEM, err := base64.StdEncoding.DecodeString(certData.ClientCertificateData) if err != nil { - return nil, fmt.Errorf("invalid base64 client cert: %w", err) + return nil, humane.Wrap(err, "invalid base64 client cert") } keyPEM, err := base64.StdEncoding.DecodeString(certData.ClientKeyData) if err != nil { - return nil, fmt.Errorf("invalid base64 client key: %w", err) + return nil, humane.Wrap(err, "invalid base64 client key") } caPEM, err := base64.StdEncoding.DecodeString(certData.CertificateAuthorityData) if err != nil { - return nil, fmt.Errorf("invalid base64 CA cert: %w", err) + return nil, humane.Wrap(err, "invalid base64 CA cert") } // Load client cert/key pair tlsCert, err := tls.X509KeyPair(certPEM, keyPEM) if err != nil { - return nil, fmt.Errorf("failed to parse client cert/key pair: %w", err) + return nil, humane.Wrap(err, "failed to parse client cert/key pair") } // Load CA into CertPool caPool := x509.NewCertPool() if !caPool.AppendCertsFromPEM(caPEM) { - return nil, fmt.Errorf("failed to append CA certificate") + return nil, humane.Wrap(err, "failed to append CA certificate") } tlsConfig := &tls.Config{ @@ -161,3 +152,35 @@ func loadTlsCredentials(server string, certData config.Certificate) (credentials return credentials.NewTLS(tlsConfig), nil } + +func buildClient(blade *config.Blade) (bladeapiv1alpha1.BladeAgentServiceClient, humane.Error) { + // Create our gRPC Transport Credentials + creds := insecure.NewCredentials() + certData := blade.Certificate + + // If we're presented with certificate data in the config, we try to create a mTLS connection + if len(certData.ClientCertificateData) > 0 && len(certData.ClientKeyData) > 0 && len(certData.CertificateAuthorityData) > 0 { + serverName := blade.Server + if strings.Contains(serverName, ":") { + var err error + if serverName, _, err = net.SplitHostPort(blade.Server); err != nil { + return nil, humane.Wrap(err, "failed to parse server address") + } + } + + var err humane.Error + if creds, err = loadTlsCredentials(serverName, certData); err != nil { + return nil, err + } + } + + conn, err := grpc.NewClient(blade.Server, grpc.WithTransportCredentials(creds)) + if err != nil { + return nil, humane.Wrap(err, + "failed to dial grpc server", + "ensure the gRPC server you are trying to connect to is running and the address is correct", + ) + } + + return bladeapiv1alpha1.NewBladeAgentServiceClient(conn), nil +} diff --git a/cmd/bladectl/cmd_status.go b/cmd/bladectl/cmd_status.go new file mode 100644 index 0000000..96a91f6 --- /dev/null +++ b/cmd/bladectl/cmd_status.go @@ -0,0 +1,78 @@ +package main + +import ( + "os" + + bladeapiv1alpha1 "github.com/compute-blade-community/compute-blade-agent/api/bladeapi/v1alpha1" + "github.com/compute-blade-community/compute-blade-agent/pkg/hal" + "github.com/compute-blade-community/compute-blade-agent/pkg/util" + "github.com/olekukonko/tablewriter" + "github.com/olekukonko/tablewriter/tw" + "github.com/spf13/cobra" + "google.golang.org/protobuf/types/known/emptypb" +) + +func init() { + cmdGet.AddCommand(cmdGetStatus) +} + +var cmdGetStatus = &cobra.Command{ + Use: "status", + Short: "Get in-depth information about the current state of the compute-blade", + Example: "bladectl get status", + Args: cobra.ExactArgs(0), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + clients := clientsFromContext(ctx) + + bladeStatus := make([]*bladeapiv1alpha1.StatusResponse, len(clients)) + for idx, client := range clients { + var err error + if bladeStatus[idx], err = client.GetStatus(ctx, &emptypb.Empty{}); err != nil { + return err + } + } + + printStatusTable(bladeStatus) + return nil + }, +} + +func printStatusTable(bladeStatus []*bladeapiv1alpha1.StatusResponse) { + // Header: Blade | Stat1 | Stat2 | ... + header := []string{ + "Blade", + "Temperature", + "Fan Speed Override", + "Fan Speed", + "Stealth Mode", + "Identify", + "Critical Mode", + "Power Status", + } + + // Table writer setup + tbl := tablewriter.NewTable(os.Stdout, + tablewriter.WithHeader(header), + tablewriter.WithHeaderAlignment(tw.AlignLeft), + tablewriter.WithHeaderAutoFormat(tw.Off), + ) + + // Rows: one per blade + for bladeIdx, status := range bladeStatus { + row := []string{ + bladeNames[bladeIdx], + tempStyle(status.Temperature, status.CriticalTemperatureThreshold).Render(tempLabel(status.Temperature)), + speedOverrideStyle(status.FanSpeedAutomatic).Render(fanSpeedOverrideLabel(status.FanSpeedAutomatic, status.FanPercent)), + rpmStyle(status.FanRpm).Render(rpmLabel(status.FanRpm) + " (" + percentLabel(status.FanPercent) + ")"), + activeStyle(status.StealthMode).Render(activeLabel(status.StealthMode)), + activeStyle(status.IdentifyActive).Render(activeLabel(status.IdentifyActive)), + activeStyle(status.CriticalActive).Render(activeLabel(status.CriticalActive)), + util.OkStyle().Render(hal.PowerStatus(status.PowerStatus).String()), + } + + _ = tbl.Append(row) + } + + _ = tbl.Render() +} diff --git a/cmd/bladectl/cmd_stealth.go b/cmd/bladectl/cmd_stealth.go new file mode 100644 index 0000000..b0f1005 --- /dev/null +++ b/cmd/bladectl/cmd_stealth.go @@ -0,0 +1,88 @@ +package main + +import ( + "fmt" + + bladeapiv1alpha1 "github.com/compute-blade-community/compute-blade-agent/api/bladeapi/v1alpha1" + "github.com/spf13/cobra" + "google.golang.org/protobuf/types/known/emptypb" +) + +var disable bool + +func init() { + cmdSetStealth.Flags().BoolVarP(&disable, "disable", "e", false, "disable stealth mode") + + cmdSet.AddCommand(cmdSetStealth) + cmdRemove.AddCommand(cmdRmStealth) + cmdGet.AddCommand(cmdGetStealth) +} + +var ( + cmdSetStealth = &cobra.Command{ + Use: "stealth", + Short: "Enable or disable stealth mode on the compute-blade", + Example: "bladectl set stealth --disable", + Args: cobra.ExactArgs(0), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + clients := clientsFromContext(ctx) + for _, client := range clients { + _, err := client.SetStealthMode(ctx, &bladeapiv1alpha1.StealthModeRequest{Enable: !disable}) + if err != nil { + return err + } + } + + return nil + }, + } + + cmdRmStealth = &cobra.Command{ + Use: "stealth", + Short: "Disable stealth mode on the compute-blade", + Example: "bladectl remove stealth", + Args: cobra.ExactArgs(0), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + clients := clientsFromContext(ctx) + for _, client := range clients { + _, err := client.SetStealthMode(ctx, &bladeapiv1alpha1.StealthModeRequest{Enable: false}) + if err != nil { + return err + } + } + + return nil + }, + } + + cmdGetStealth = &cobra.Command{ + Use: "stealth", + Short: "Get the stealth mode status of the compute-blade", + Example: "bladectl get stealth", + Args: cobra.ExactArgs(0), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + clients := clientsFromContext(ctx) + + for idx, client := range clients { + bladeStatus, err := client.GetStatus(ctx, &emptypb.Empty{}) + if err != nil { + return err + } + + rowPrefix := bladeNames[idx] + if len(bladeNames) > 1 { + rowPrefix += ": " + } else { + rowPrefix = "" + } + + fmt.Println(activeStyle(bladeStatus.StealthMode).Render(rowPrefix, activeLabel(bladeStatus.StealthMode))) + } + + return nil + }, + } +) diff --git a/cmd/bladectl/cmd_verbs.go b/cmd/bladectl/cmd_verbs.go index 52e20ee..de6a699 100644 --- a/cmd/bladectl/cmd_verbs.go +++ b/cmd/bladectl/cmd_verbs.go @@ -7,6 +7,8 @@ import ( func init() { rootCmd.AddCommand(cmdGet) rootCmd.AddCommand(cmdSet) + rootCmd.AddCommand(cmdRemove) + rootCmd.AddCommand(cmdDescribe) } var ( @@ -16,6 +18,12 @@ var ( Long: "Prints information about compute-blade related information, e.g. fan speed, temperature, etc.", } + cmdDescribe = &cobra.Command{ + Use: "describe", + Short: "Display compute-blade related information", + Long: "Prints information about compute-blade related information, e.g. fan speed curve steps, etc.", + } + cmdSet = &cobra.Command{ Use: "set", Short: "Configure compute-blade", diff --git a/cmd/bladectl/main.go b/cmd/bladectl/main.go index e9264d8..19ba58f 100644 --- a/cmd/bladectl/main.go +++ b/cmd/bladectl/main.go @@ -12,7 +12,8 @@ import ( type grpcClientContextKey int const ( - defaultGrpcClientContextKey grpcClientContextKey = 0 + defaultGrpcClientContextKey grpcClientContextKey = 0 + defaultGrpcClientsContextKey grpcClientContextKey = 1 ) var ( @@ -25,6 +26,10 @@ func clientIntoContext(ctx context.Context, client bladeapiv1alpha1.BladeAgentSe return context.WithValue(ctx, defaultGrpcClientContextKey, client) } +func clientsIntoContext(ctx context.Context, clients []bladeapiv1alpha1.BladeAgentServiceClient) context.Context { + return context.WithValue(ctx, defaultGrpcClientsContextKey, clients) +} + func clientFromContext(ctx context.Context) bladeapiv1alpha1.BladeAgentServiceClient { client, ok := ctx.Value(defaultGrpcClientContextKey).(bladeapiv1alpha1.BladeAgentServiceClient) if !ok { @@ -33,6 +38,14 @@ func clientFromContext(ctx context.Context) bladeapiv1alpha1.BladeAgentServiceCl return client } +func clientsFromContext(ctx context.Context) []bladeapiv1alpha1.BladeAgentServiceClient { + clients, ok := ctx.Value(defaultGrpcClientsContextKey).([]bladeapiv1alpha1.BladeAgentServiceClient) + if !ok { + panic("grpc client not found in context") + } + return clients +} + func main() { // Setup configuration viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) diff --git a/cmd/bladectl/util.go b/cmd/bladectl/util.go new file mode 100644 index 0000000..be46c8f --- /dev/null +++ b/cmd/bladectl/util.go @@ -0,0 +1,74 @@ +package main + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" + "github.com/compute-blade-community/compute-blade-agent/pkg/util" +) + +func fanSpeedOverrideLabel(automatic bool, percent uint32) string { + if automatic { + return "Not set" + } + return fmt.Sprintf("%d%%", percent) +} + +func tempLabel(temp int64) string { + return fmt.Sprintf("%d°C", temp) +} + +func percentLabel(percent uint32) string { + return fmt.Sprintf("%d%%", percent) +} + +func rpmLabel(rpm int64) string { + return fmt.Sprintf("%d RPM", rpm) +} + +func activeLabel(b bool) string { + if b { + return "Active" + } + return "Off" +} + +func speedOverrideStyle(automaticMode bool) lipgloss.Style { + if automaticMode { + return lipgloss.NewStyle().Foreground(util.ColorOk) + } + + return lipgloss.NewStyle().Foreground(util.ColorCritical) +} + +func activeStyle(active bool) lipgloss.Style { + if active { + return lipgloss.NewStyle().Foreground(util.ColorCritical) + } + + return lipgloss.NewStyle().Foreground(util.ColorOk) +} + +func tempStyle(temp int64, criticalTemp int64) lipgloss.Style { + color := util.ColorOk + + if temp >= criticalTemp { + color = util.ColorCritical + } else if temp >= criticalTemp-10 { + color = util.ColorWarning + } + + return lipgloss.NewStyle().Foreground(color) +} + +func rpmStyle(rpm int64) lipgloss.Style { + color := util.ColorOk + + if rpm > 6000 { + color = util.ColorCritical + } else if rpm > 5250 { + color = util.ColorWarning + } + + return lipgloss.NewStyle().Foreground(color) +} diff --git a/go.mod b/go.mod index 4b23683..3606209 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,10 @@ module github.com/compute-blade-community/compute-blade-agent go 1.24.0 require ( + github.com/charmbracelet/lipgloss v1.1.0 + github.com/gizak/termui/v3 v3.1.0 github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 + github.com/olekukonko/tablewriter v1.0.7 github.com/prometheus/client_golang v1.22.0 github.com/sierrasoftworks/humane-errors-go v0.0.0-20250507223502-4bb667dc1e16 github.com/spechtlabs/go-otel-utils/otelprovider v0.0.10 @@ -12,7 +15,7 @@ require ( github.com/spf13/pflag v1.0.6 github.com/spf13/viper v1.20.1 github.com/stretchr/testify v1.10.0 - github.com/warthog618/gpiod v0.9.1 + github.com/warthog618/gpiod v0.8.1 go.bug.st/serial v1.6.4 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 go.uber.org/zap v1.27.0 @@ -25,11 +28,17 @@ require ( require ( github.com/aws/smithy-go v1.22.3 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.8.0 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/creack/goselect v0.1.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/fatih/color v1.15.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -38,18 +47,29 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d // indirect + github.com/olekukonko/errors v0.0.0-20250405072817-4e6d85265da6 // indirect + github.com/olekukonko/ll v0.0.8 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.63.0 // indirect github.com/prometheus/procfs v0.16.1 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/locafero v0.9.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.14.0 // indirect github.com/spf13/cast v1.8.0 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/otel v1.36.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.11.0 // indirect diff --git a/go.sum b/go.sum index 0a5e083..ece40cb 100644 --- a/go.sum +++ b/go.sum @@ -1,20 +1,36 @@ github.com/aws/smithy-go v1.22.3 h1:Z//5NuZCSW6R4PhQ93hShNbyBbn8BWCmCVCt+Q8Io5k= github.com/aws/smithy-go v1.22.3/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= +github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/goselect v0.1.3 h1:MaGNMclRo7P2Jl21hBpR1Cn33ITSbKP6E49RtfblLKc= github.com/creack/goselect v0.1.3/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= +github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gizak/termui/v3 v3.1.0 h1:ZZmVDgwHl7gR7elfKf1xc4IudXZ5qqfDh4wExk4Iajc= +github.com/gizak/termui/v3 v3.1.0/go.mod h1:bXQEBkJpzxUAKf0+xq9MSWAvWZlE7c+aidmyFlkYTrY= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -44,8 +60,30 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM= +github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d h1:x3S6kxmy49zXVVyhcnrFqxvNVCBPb2KZ9hV2RBdS840= +github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d/go.mod h1:IuKpRQcYE1Tfu+oAQqaLisqDeXgjyyltCfsaoYN18NQ= +github.com/olekukonko/errors v0.0.0-20250405072817-4e6d85265da6 h1:r3FaAI0NZK3hSmtTDrBVREhKULp8oUeqLT5Eyl2mSPo= +github.com/olekukonko/errors v0.0.0-20250405072817-4e6d85265da6/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= +github.com/olekukonko/ll v0.0.8 h1:sbGZ1Fx4QxJXEqL/6IG8GEFnYojUSQ45dJVwN2FH2fc= +github.com/olekukonko/ll v0.0.8/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g= +github.com/olekukonko/tablewriter v1.0.7 h1:HCC2e3MM+2g72M81ZcJU11uciw6z/p82aEnm4/ySDGw= +github.com/olekukonko/tablewriter v1.0.7/go.mod h1:H428M+HzoUXC6JU2Abj9IT9ooRmdq9CxuDmKMtrOCMs= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pilebones/go-udev v0.9.0 h1:N1uEO/SxUwtIctc0WLU0t69JeBxIYEYnj8lT/Nabl9Q= @@ -60,6 +98,9 @@ github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -91,6 +132,8 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/warthog618/gpiod v0.8.1 h1:+8iHpHd3fljAd6l4AT8jPbMDQNKdvBIpW/hmLgAcHiM= github.com/warthog618/gpiod v0.8.1/go.mod h1:A7v1hGR2eTsnkN+e9RoAPYgJG9bLJWtwyIIK+pgqC7s= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A= go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= @@ -129,10 +172,14 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d h1:0olWaB5pg3+oychR51GUVCEsGkeCU/2JxjBgIo4f3M0= +golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= diff --git a/internal/agent/agent.go b/internal/agent/agent.go index e652bb2..fa1ecb7 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -1,13 +1,14 @@ -package agent +package internal_agent import ( "context" "errors" "fmt" - "sync" + "net" "time" - agent2 "github.com/compute-blade-community/compute-blade-agent/pkg/agent" + bladeapiv1alpha1 "github.com/compute-blade-community/compute-blade-agent/api/bladeapi/v1alpha1" + "github.com/compute-blade-community/compute-blade-agent/pkg/agent" "github.com/compute-blade-community/compute-blade-agent/pkg/events" "github.com/compute-blade-community/compute-blade-agent/pkg/fancontroller" "github.com/compute-blade-community/compute-blade-agent/pkg/hal" @@ -16,7 +17,9 @@ import ( "github.com/compute-blade-community/compute-blade-agent/pkg/log" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/sierrasoftworks/humane-errors-go" "go.uber.org/zap" + "google.golang.org/grpc" ) var ( @@ -35,56 +38,51 @@ var ( }, []string{"type"}) ) -// computeBladeAgentImpl is the implementation of the ComputeBladeAgent interface -type computeBladeAgentImpl struct { - opts ComputeBladeAgentConfig +// computeBladeAgent manages the operation and coordination of hardware components and services for a compute blade agent. +type computeBladeAgent struct { + bladeapiv1alpha1.UnimplementedBladeAgentServiceServer + config agent.ComputeBladeAgentConfig blade hal.ComputeBladeHal - state agent2.ComputebladeState + state agent.ComputebladeState edgeLedEngine ledengine.LedEngine topLedEngine ledengine.LedEngine fanController fancontroller.FanController eventChan chan events.Event + server *grpc.Server } -func NewComputeBladeAgent(ctx context.Context, opts ComputeBladeAgentConfig) (agent2.ComputeBladeAgent, error) { - var err error - - // blade, err := hal.NewCm4Hal(hal.ComputeBladeHalOpts{ - blade, err := hal.NewCm4Hal(ctx, opts.ComputeBladeHalOpts) +// NewComputeBladeAgent creates and initializes a new ComputeBladeAgent, including gRPC server setup and hardware interfaces. +func NewComputeBladeAgent(ctx context.Context, config agent.ComputeBladeAgentConfig) (agent.ComputeBladeAgent, error) { + blade, err := hal.NewCm4Hal(ctx, config.ComputeBladeHalOpts) if err != nil { return nil, err } - edgeLedEngine := ledengine.NewLedEngine(ledengine.Options{ - LedIdx: hal.LedEdge, - Hal: blade, - }) - - topLedEngine := ledengine.NewLedEngine(ledengine.Options{ - LedIdx: hal.LedTop, - Hal: blade, - }) - - fanController, err := fancontroller.NewLinearFanController(opts.FanControllerConfig) + fanController, err := fancontroller.NewLinearFanController(config.FanControllerConfig) if err != nil { return nil, err } - return &computeBladeAgentImpl{ - opts: opts, + a := &computeBladeAgent{ + config: config, blade: blade, - edgeLedEngine: edgeLedEngine, - topLedEngine: topLedEngine, + edgeLedEngine: ledengine.New(blade, hal.LedEdge), + topLedEngine: ledengine.New(blade, hal.LedTop), fanController: fanController, - state: agent2.NewComputeBladeState(), - eventChan: make( - chan events.Event, - 10, - ), // backlog of 10 events. They should process fast but we e.g. don't want to miss button presses - }, nil + state: agent.NewComputeBladeState(), + eventChan: make(chan events.Event, 10), + } + + if err := a.setupGrpcServer(ctx); err != nil { + return nil, err + } + + bladeapiv1alpha1.RegisterBladeAgentServiceServer(a.server, a) + return a, nil } -func (a *computeBladeAgentImpl) RunAsync(ctx context.Context, cancel context.CancelCauseFunc) { +// RunAsync starts the agent in a separate goroutine and handles errors, allowing cancellation through the provided context. +func (a *computeBladeAgent) RunAsync(ctx context.Context, cancel context.CancelCauseFunc) { go func() { log.FromContext(ctx).Info("Starting agent") err := a.Run(ctx) @@ -95,11 +93,10 @@ func (a *computeBladeAgentImpl) RunAsync(ctx context.Context, cancel context.Can }() } -func (a *computeBladeAgentImpl) Run(origCtx context.Context) error { - var wg sync.WaitGroup +// Run initializes and starts the compute blade agent, setting up necessary components and processes, and waits for termination. +func (a *computeBladeAgent) Run(origCtx context.Context) error { ctx, cancelCtx := context.WithCancelCause(origCtx) defer cancelCtx(fmt.Errorf("cancel")) - defer a.cleanup(ctx) log.FromContext(ctx).Info("Starting ComputeBlade agent") @@ -107,104 +104,43 @@ func (a *computeBladeAgentImpl) Run(origCtx context.Context) error { a.state.RegisterEvent(events.NoopEvent) // Set defaults - if err := a.blade.SetStealthMode(a.opts.StealthModeEnabled); err != nil { + if err := a.blade.SetStealthMode(a.config.StealthModeEnabled); err != nil { return err } // Run HAL - wg.Add(1) - go func() { - defer wg.Done() - log.FromContext(ctx).Info("Starting HAL") - if err := a.blade.Run(ctx); err != nil && !errors.Is(err, context.Canceled) { - log.FromContext(ctx).WithError(err).Error("HAL failed") - cancelCtx(err) - } - }() + + go a.runHal(ctx, cancelCtx) // Start edge button event handler - wg.Add(1) - go func() { - defer wg.Done() - log.FromContext(ctx).Info("Starting edge button event handler") - for { - err := a.blade.WaitForEdgeButtonPress(ctx) - if err != nil && !errors.Is(err, context.Canceled) { - log.FromContext(ctx).WithError(err).Error("Edge button event handler failed") - cancelCtx(err) - } else if err != nil { - return - } - select { - case a.eventChan <- events.Event(events.EdgeButtonEvent): - default: - log.FromContext(ctx).Warn("Edge button press event dropped due to backlog") - droppedEventCounter.WithLabelValues(events.Event(events.EdgeButtonEvent).String()).Inc() - } - } - }() + + go a.runEdgeButtonHandler(ctx, cancelCtx) // Start top LED engine - wg.Add(1) - go func() { - defer wg.Done() - log.FromContext(ctx).Info("Starting top LED engine") - err := a.runTopLedEngine(ctx) - if err != nil && !errors.Is(err, context.Canceled) { - log.FromContext(ctx).WithError(err).Error("Top LED engine failed") - cancelCtx(err) - } - }() + go a.runTopLedEngine(ctx, cancelCtx) // Start edge LED engine - wg.Add(1) - go func() { - defer wg.Done() - log.FromContext(ctx).Info("Starting edge LED engine") - err := a.runEdgeLedEngine(ctx) - if err != nil && !errors.Is(err, context.Canceled) { - log.FromContext(ctx).WithError(err).Error("Edge LED engine failed") - cancelCtx(err) - } - }() + go a.runEdgeLedEngine(ctx, cancelCtx) // Start fan controller - wg.Add(1) - go func() { - defer wg.Done() - log.FromContext(ctx).Info("Starting fan controller") - err := a.runFanController(ctx) - if err != nil && !errors.Is(err, context.Canceled) { - log.FromContext(ctx).WithError(err).Error("Fan Controller Failed") - cancelCtx(err) - } - }() + go a.runFanController(ctx, cancelCtx) // Start event handler - wg.Add(1) - go func() { - defer wg.Done() - log.FromContext(ctx).Info("Starting event handler") - for { - select { - case <-ctx.Done(): - return - case event := <-a.eventChan: - err := a.handleEvent(ctx, event) - if err != nil && !errors.Is(err, context.Canceled) { - log.FromContext(ctx).WithError(err).Error("Event handler failed") - cancelCtx(err) - } - } - } - }() + go a.runEventHandler(ctx, cancelCtx) + + // Start gRPC API + go a.runGRpcApi(ctx, cancelCtx) + + // wait till we're done + <-ctx.Done() - wg.Wait() return ctx.Err() } -// cleanup restores sane defaults before exiting. Ignores canceled context! -func (a *computeBladeAgentImpl) cleanup(ctx context.Context) { +// GracefulStop gracefully stops the gRPC server, ensuring all in-progress RPCs are completed before shutting down. +func (a *computeBladeAgent) GracefulStop(ctx context.Context) error { + a.server.GracefulStop() + log.FromContext(ctx).Info("Exiting, restoring safe settings") if err := a.blade.SetFanSpeed(100); err != nil { log.FromContext(ctx).WithError(err).Error("Failed to set fan speed to 100%") @@ -215,162 +151,69 @@ func (a *computeBladeAgentImpl) cleanup(ctx context.Context) { if err := a.blade.SetLed(hal.LedTop, led.Color{}); err != nil { log.FromContext(ctx).WithError(err).Error("Failed to set edge LED to off") } - if err := a.Close(); err != nil { - log.FromContext(ctx).WithError(err).Error("Failed to close blade") - } -} -// EmitEvent dispatches an event to the event handler -func (a *computeBladeAgentImpl) EmitEvent(ctx context.Context, event events.Event) error { - select { - case a.eventChan <- event: - return nil - case <-ctx.Done(): - return ctx.Err() - } + return a.blade.Close() } -// SetFanSpeed sets the fan speed -func (a *computeBladeAgentImpl) SetFanSpeed(_ context.Context, speed uint8) error { - if a.state.CriticalActive() { - return errors.New("cannot set fan speed while the blade is in a critical state") +// runHal initializes and starts the HAL service within the given context, handling errors and supporting graceful cancellation. +func (a *computeBladeAgent) runHal(ctx context.Context, cancel context.CancelCauseFunc) { + log.FromContext(ctx).Info("Starting HAL") + if err := a.blade.Run(ctx); err != nil && !errors.Is(err, context.Canceled) { + log.FromContext(ctx).WithError(err).Error("HAL failed") + cancel(err) } - a.fanController.Override(&fancontroller.FanOverrideOpts{Percent: speed}) - return nil } -// SetStealthMode enables/disables the stealth mode -func (a *computeBladeAgentImpl) SetStealthMode(_ context.Context, enabled bool) error { - if a.state.CriticalActive() { - return errors.New("cannot set stealth mode while the blade is in a critical state") +// runTopLedEngine runs the top LED engine +// FIXME the top LED is only used to indicate emergency situations +func (a *computeBladeAgent) runTopLedEngine(ctx context.Context, cancel context.CancelCauseFunc) { + log.FromContext(ctx).Info("Starting top LED engine") + if err := a.topLedEngine.SetPattern(ledengine.NewStaticPattern(led.Color{})); err != nil && !errors.Is(err, context.Canceled) { + log.FromContext(ctx).WithError(err).Error("Top LED engine failed") + cancel(err) } - return a.blade.SetStealthMode(enabled) -} - -// WaitForIdentifyConfirm waits for the identify confirm event -func (a *computeBladeAgentImpl) WaitForIdentifyConfirm(ctx context.Context) error { - return a.state.WaitForIdentifyConfirm(ctx) -} -// Close shuts down the underlying blade instance and releases any associated resources, returning a combined error if any. -func (a *computeBladeAgentImpl) Close() error { - return errors.Join(a.blade.Close()) -} - -func (a *computeBladeAgentImpl) handleEvent(ctx context.Context, event events.Event) error { - log.FromContext(ctx).Info("Handling event", zap.String("event", event.String())) - eventCounter.WithLabelValues(event.String()).Inc() - - // register event in state - a.state.RegisterEvent(event) - - // Dispatch incoming events to the right handler(s) - switch event { - case events.CriticalEvent: - // Handle critical event - return a.handleCriticalActive(ctx) - case events.CriticalResetEvent: - // Handle critical event - return a.handleCriticalReset(ctx) - case events.IdentifyEvent: - // Handle identify event - return a.handleIdentifyActive(ctx) - case events.IdentifyConfirmEvent: - // Handle identify event - return a.handleIdentifyConfirm(ctx) - case events.EdgeButtonEvent: - // Handle edge button press to toggle identify mode - event := events.Event(events.IdentifyEvent) - if a.state.IdentifyActive() { - event = events.Event(events.IdentifyConfirmEvent) - } - select { - case a.eventChan <- event: - default: - log.FromContext(ctx).Warn("Edge button press event dropped due to backlog") - droppedEventCounter.WithLabelValues(event.String()).Inc() - } - case events.NoopEvent: + if err := a.topLedEngine.Run(ctx); err != nil && !errors.Is(err, context.Canceled) { + log.FromContext(ctx).WithError(err).Error("Top LED engine failed") + cancel(err) } - - return nil -} - -func (a *computeBladeAgentImpl) handleIdentifyActive(ctx context.Context) error { - log.FromContext(ctx).Info("Identify active") - return a.edgeLedEngine.SetPattern(ledengine.NewBurstPattern(led.Color{}, a.opts.IdentifyLedColor)) } -func (a *computeBladeAgentImpl) handleIdentifyConfirm(ctx context.Context) error { - log.FromContext(ctx).Info("Identify confirmed/cleared") - return a.edgeLedEngine.SetPattern(ledengine.NewStaticPattern(a.opts.IdleLedColor)) -} - -func (a *computeBladeAgentImpl) handleCriticalActive(ctx context.Context) error { - log.FromContext(ctx).Warn("Blade in critical state, setting fan speed to 100% and turning on LEDs") - - // Set fan speed to 100% - a.fanController.Override(&fancontroller.FanOverrideOpts{Percent: 100}) - - // Disable stealth mode (turn on LEDs) - setStealthModeError := a.blade.SetStealthMode(false) - - // Set critical pattern for top LED - setPatternTopLedErr := a.topLedEngine.SetPattern( - ledengine.NewSlowBlinkPattern(led.Color{}, a.opts.CriticalLedColor), - ) - // Combine errors, but don't stop execution flow for now - return errors.Join(setStealthModeError, setPatternTopLedErr) -} - -func (a *computeBladeAgentImpl) handleCriticalReset(ctx context.Context) error { - log.FromContext(ctx).Info("Critical state cleared, setting fan speed to default and restoring LEDs to default state") - // Reset fan controller overrides - a.fanController.Override(nil) +// runEdgeLedEngine runs the edge LED engine +func (a *computeBladeAgent) runEdgeLedEngine(ctx context.Context, cancel context.CancelCauseFunc) { + log.FromContext(ctx).Info("Starting edge LED engine") - // Reset stealth mode - if err := a.blade.SetStealthMode(a.opts.StealthModeEnabled); err != nil { - return err + if err := a.edgeLedEngine.SetPattern(ledengine.NewStaticPattern(a.config.IdleLedColor)); err != nil && !errors.Is(err, context.Canceled) { + log.FromContext(ctx).WithError(err).Error("Edge LED engine failed") + cancel(err) } - // Set top LED off - if err := a.topLedEngine.SetPattern(ledengine.NewStaticPattern(led.Color{})); err != nil { - return err - } - - return nil -} - -// runTopLedEngine runs the top LED engine -func (a *computeBladeAgentImpl) runTopLedEngine(ctx context.Context) error { - // FIXME the top LED is only used to indicate emergency situations - err := a.topLedEngine.SetPattern(ledengine.NewStaticPattern(led.Color{})) - if err != nil { - return err + if err := a.edgeLedEngine.Run(ctx); err != nil && !errors.Is(err, context.Canceled) { + log.FromContext(ctx).WithError(err).Error("Edge LED engine failed") + cancel(err) } - return a.topLedEngine.Run(ctx) } -// runEdgeLedEngine runs the edge LED engine -func (a *computeBladeAgentImpl) runEdgeLedEngine(ctx context.Context) error { - err := a.edgeLedEngine.SetPattern(ledengine.NewStaticPattern(a.opts.IdleLedColor)) - if err != nil { - return err - } - return a.edgeLedEngine.Run(ctx) -} +// runFanController initializes and manages a periodic task to control fan speed based on temperature readings. +// The method uses a ticker to execute fan speed adjustments and handles context cancellation for cleanup. +// If obtaining temperature or setting fan speed fails, appropriate error logs are recorded. +func (a *computeBladeAgent) runFanController(ctx context.Context, cancel context.CancelCauseFunc) { + log.FromContext(ctx).Info("Starting fan controller") -func (a *computeBladeAgentImpl) runFanController(ctx context.Context) error { // Update fan speed periodically ticker := time.NewTicker(5 * time.Second) for { - // Wait for the next tick select { case <-ctx.Done(): ticker.Stop() - return ctx.Err() + + if err := ctx.Err(); err != nil && !errors.Is(err, context.Canceled) { + log.FromContext(ctx).WithError(err).Error("Fan Controller Failed") + cancel(err) + } + return case <-ticker.C: } @@ -381,10 +224,78 @@ func (a *computeBladeAgentImpl) runFanController(ctx context.Context) error { temp = 100 // set to a high value to trigger the maximum speed defined by the fan curve } // Derive fan speed from temperature - speed := a.fanController.GetFanSpeed(temp) + speed := a.fanController.GetFanSpeedPercent(temp) // Set fan speed if err := a.blade.SetFanSpeed(speed); err != nil { log.FromContext(ctx).WithError(err).Error("Failed to set fan speed") } } } + +// runEdgeButtonHandler initializes and handles edge button press events in a loop until the context is canceled. +// It waits for edge button presses and sends corresponding events to the event channel, logging errors and warnings. +// If an unrecoverable error occurs, the cancel function is triggered to terminate the operation. +func (a *computeBladeAgent) runEdgeButtonHandler(ctx context.Context, cancel context.CancelCauseFunc) { + log.FromContext(ctx).Info("Starting edge button event handler") + for { + if err := a.blade.WaitForEdgeButtonPress(ctx); err != nil { + if !errors.Is(err, context.Canceled) { + log.FromContext(ctx).WithError(err).Error("Edge button event handler failed") + cancel(err) + } + + return + } + + select { + case a.eventChan <- events.Event(events.EdgeButtonEvent): + default: + log.FromContext(ctx).Warn("Edge button press event dropped due to backlog") + droppedEventCounter.WithLabelValues(events.Event(events.EdgeButtonEvent).String()).Inc() + } + } +} + +// runEventHandler processes events from the agent's event channel, handles them, and cancels on critical failure or context cancellation. +func (a *computeBladeAgent) runEventHandler(ctx context.Context, cancel context.CancelCauseFunc) { + log.FromContext(ctx).Info("Starting event handler") + for { + select { + case <-ctx.Done(): + return + + case event := <-a.eventChan: + err := a.handleEvent(ctx, event) + if err != nil && !errors.Is(err, context.Canceled) { + log.FromContext(ctx).WithError(err).Error("Event handler failed") + cancel(err) + } + } + } +} + +// runGRpcApi starts the gRPC server for the agent based on the configuration and gracefully handles errors or cancellation. +func (a *computeBladeAgent) runGRpcApi(ctx context.Context, cancel context.CancelCauseFunc) { + if len(a.config.Listen.Grpc) == 0 { + err := humane.New("no listen address provided", + "ensure you are passing a valid listen config to the grpc server", + ) + log.FromContext(ctx).Error("no listen address provided, not starting gRPC server", humane.Zap(err)...) + cancel(err) + } + + grpcListen, err := net.Listen(a.config.Listen.GrpcListenMode, a.config.Listen.Grpc) + if err != nil { + err := humane.Wrap(err, "failed to create grpc listener", + "ensure the gRPC server you are trying to serve to is not already running and the address is not bound by another process", + ) + log.FromContext(ctx).Error("failed to create grpc listener, not starting gRPC server", humane.Zap(err)...) + cancel(err) + } + + log.FromContext(ctx).Info("Starting grpc server", zap.String("address", a.config.Listen.Grpc)) + if err := a.server.Serve(grpcListen); err != nil && !errors.Is(err, grpc.ErrServerStopped) { + log.FromContext(ctx).Error("failed to start grpc server", humane.Zap(err)...) + cancel(err) + } +} diff --git a/internal/agent/api.go b/internal/agent/api.go new file mode 100644 index 0000000..b3b83dd --- /dev/null +++ b/internal/agent/api.go @@ -0,0 +1,149 @@ +package internal_agent + +import ( + "context" + "crypto/tls" + + bladeapiv1alpha1 "github.com/compute-blade-community/compute-blade-agent/api/bladeapi/v1alpha1" + "github.com/compute-blade-community/compute-blade-agent/pkg/fancontroller" + "github.com/compute-blade-community/compute-blade-agent/pkg/log" + grpczap "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/logging" + "github.com/sierrasoftworks/humane-errors-go" + "github.com/spechtlabs/go-otel-utils/otelzap" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/protobuf/types/known/emptypb" +) + +// setupGrpcServer initializes and configures the gRPC server with authentication, logging, and server options. +func (a *computeBladeAgent) setupGrpcServer(ctx context.Context) error { + listenMode, err := ListenModeFromString(a.config.Listen.GrpcListenMode) + if err != nil { + return err + } + + var grpcOpts []grpc.ServerOption + + if listenMode == ModeTcp && a.config.Listen.GrpcAuthenticated { + tlsCfg, err := createServerTLSConfig(ctx) + if err != nil { + return err + } + grpcOpts = append(grpcOpts, grpc.Creds(credentials.NewTLS(tlsCfg))) + + if err := EnsureAuthenticatedBladectlConfig(ctx, a.config.Listen.Grpc, listenMode); err != nil { + return err + } + } else { + if err := EnsureUnauthenticatedBladectlConfig(ctx, a.config.Listen.Grpc, listenMode); err != nil { + return err + } + } + + logger := log.InterceptorLogger(otelzap.L()) + grpcOpts = append(grpcOpts, + grpc.ChainUnaryInterceptor(grpczap.UnaryServerInterceptor(logger)), + grpc.ChainStreamInterceptor(grpczap.StreamServerInterceptor(logger)), + ) + + a.server = grpc.NewServer(grpcOpts...) + return nil +} + +// createServerTLSConfig creates and returns a TLS configuration for a server, enforcing client authentication. +// It generates or loads the necessary certificates and certificate pools, logging fatal errors if certificate loading fails. +func createServerTLSConfig(ctx context.Context) (*tls.Config, error) { + cert, certPool, err := EnsureServerCertificate(ctx) + if err != nil { + log.FromContext(ctx).WithError(err).Fatal("failed to load server key pair") + } + return &tls.Config{ + Certificates: []tls.Certificate{cert}, + ClientAuth: tls.RequireAndVerifyClientCert, + ClientCAs: certPool, + }, nil +} + +// EmitEvent dispatches an event to the event handler +func (a *computeBladeAgent) EmitEvent(ctx context.Context, req *bladeapiv1alpha1.EmitEventRequest) (*emptypb.Empty, error) { + event, err := fromProto(req.GetEvent()) + if err != nil { + return nil, err + } + + select { + case a.eventChan <- event: + return &emptypb.Empty{}, nil + case <-ctx.Done(): + return &emptypb.Empty{}, ctx.Err() + } +} + +// SetFanSpeed sets the fan speed +func (a *computeBladeAgent) SetFanSpeed(_ context.Context, req *bladeapiv1alpha1.SetFanSpeedRequest) (*emptypb.Empty, error) { + if a.state.CriticalActive() { + return &emptypb.Empty{}, humane.New("cannot set fan speed while the blade is in a critical state", "improve cooling on your blade before attempting to overwrite the fan speed") + } + + a.fanController.Override(&fancontroller.FanOverrideOpts{Percent: uint8(req.GetPercent())}) + return &emptypb.Empty{}, nil +} + +// SetFanSpeedAuto sets the fan speed to automatic mode +func (a *computeBladeAgent) SetFanSpeedAuto(context.Context, *emptypb.Empty) (*emptypb.Empty, error) { + a.fanController.Override(nil) + return &emptypb.Empty{}, nil +} + +// SetStealthMode enables/disables the stealth mode +func (a *computeBladeAgent) SetStealthMode(_ context.Context, req *bladeapiv1alpha1.StealthModeRequest) (*emptypb.Empty, error) { + if a.state.CriticalActive() { + return &emptypb.Empty{}, humane.New("cannot set stealth mode while the blade is in a critical state", "improve cooling on your blade before attempting to enable stealth mode again") + } + return &emptypb.Empty{}, a.blade.SetStealthMode(req.GetEnable()) +} + +// GetStatus aggregates the status of the blade +func (a *computeBladeAgent) GetStatus(_ context.Context, _ *emptypb.Empty) (*bladeapiv1alpha1.StatusResponse, error) { + rpm, err := a.blade.GetFanRPM() + if err != nil { + return nil, err + } + + temp, err := a.blade.GetTemperature() + if err != nil { + return nil, err + } + + powerStatus, err := a.blade.GetPowerStatus() + if err != nil { + return nil, err + } + + steps := a.fanController.Config().Steps + fanCurveSteps := make([]*bladeapiv1alpha1.FanCurveStep, len(steps)) + for idx, step := range steps { + fanCurveSteps[idx] = &bladeapiv1alpha1.FanCurveStep{ + Temperature: int64(step.Temperature), + Percent: uint32(step.Percent), + } + } + + return &bladeapiv1alpha1.StatusResponse{ + StealthMode: a.blade.StealthModeActive(), + IdentifyActive: a.state.IdentifyActive(), + CriticalActive: a.state.CriticalActive(), + Temperature: int64(temp), + FanRpm: int64(rpm), + FanPercent: uint32(a.fanController.GetFanSpeedPercent(temp)), + FanSpeedAutomatic: a.fanController.IsAutomaticSpeed(), + PowerStatus: bladeapiv1alpha1.PowerStatus(powerStatus), + FanCurveSteps: fanCurveSteps, + CriticalTemperatureThreshold: int64(a.config.CriticalTemperatureThreshold), + }, nil +} + +// WaitForIdentifyConfirm blocks until the identify confirmation process is completed or an error occurs. +func (a *computeBladeAgent) WaitForIdentifyConfirm(ctx context.Context, _ *emptypb.Empty) (*emptypb.Empty, error) { + return &emptypb.Empty{}, a.state.WaitForIdentifyConfirm(ctx) +} diff --git a/internal/api/api_certificates.go b/internal/agent/api_certificates.go similarity index 99% rename from internal/api/api_certificates.go rename to internal/agent/api_certificates.go index 962ccdd..3a2dc66 100644 --- a/internal/api/api_certificates.go +++ b/internal/agent/api_certificates.go @@ -1,4 +1,4 @@ -package api +package internal_agent import ( "context" diff --git a/internal/agent/handler.go b/internal/agent/handler.go new file mode 100644 index 0000000..5f37678 --- /dev/null +++ b/internal/agent/handler.go @@ -0,0 +1,104 @@ +package internal_agent + +import ( + "context" + "errors" + + "github.com/compute-blade-community/compute-blade-agent/pkg/events" + "github.com/compute-blade-community/compute-blade-agent/pkg/fancontroller" + "github.com/compute-blade-community/compute-blade-agent/pkg/hal/led" + "github.com/compute-blade-community/compute-blade-agent/pkg/ledengine" + "github.com/compute-blade-community/compute-blade-agent/pkg/log" + "go.uber.org/zap" +) + +// handleEvent processes an incoming event, updates state, and dispatches it to the appropriate handler based on the event type. +func (a *computeBladeAgent) handleEvent(ctx context.Context, event events.Event) error { + log.FromContext(ctx).Info("Handling event", zap.String("event", event.String())) + eventCounter.WithLabelValues(event.String()).Inc() + + // register event in state + a.state.RegisterEvent(event) + + // Dispatch incoming events to the right handler(s) + switch event { + case events.CriticalEvent: + // Handle critical event + return a.handleCriticalActive(ctx) + case events.CriticalResetEvent: + // Handle critical event + return a.handleCriticalReset(ctx) + case events.IdentifyEvent: + // Handle identify event + return a.handleIdentifyActive(ctx) + case events.IdentifyConfirmEvent: + // Handle identify event + return a.handleIdentifyConfirm(ctx) + case events.EdgeButtonEvent: + // Handle edge button press to toggle identify mode + event := events.Event(events.IdentifyEvent) + if a.state.IdentifyActive() { + event = events.Event(events.IdentifyConfirmEvent) + } + select { + case a.eventChan <- event: + default: + log.FromContext(ctx).Warn("Edge button press event dropped due to backlog") + droppedEventCounter.WithLabelValues(event.String()).Inc() + } + case events.NoopEvent: + } + + return nil +} + +// handleIdentifyActive is responsible for handling the identify event by setting a burst LED pattern based on the configuration. +func (a *computeBladeAgent) handleIdentifyActive(ctx context.Context) error { + log.FromContext(ctx).Info("Identify active") + return a.edgeLedEngine.SetPattern(ledengine.NewBurstPattern(led.Color{}, a.config.IdentifyLedColor)) +} + +// handleIdentifyConfirm handles the confirmation of an identify event by updating the LED engine with a static idle pattern. +func (a *computeBladeAgent) handleIdentifyConfirm(ctx context.Context) error { + log.FromContext(ctx).Info("Identify confirmed/cleared") + return a.edgeLedEngine.SetPattern(ledengine.NewStaticPattern(a.config.IdleLedColor)) +} + +// handleCriticalActive handles the system's response to a critical state by adjusting fan speed and LED indications. +// It sets the fan speed to 100%, disables stealth mode, and applies a critical LED pattern. +// Returns any errors encountered during the process as a combined error. +func (a *computeBladeAgent) handleCriticalActive(ctx context.Context) error { + log.FromContext(ctx).Warn("Blade in critical state, setting fan speed to 100% and turning on LEDs") + + // Set fan speed to 100% + a.fanController.Override(&fancontroller.FanOverrideOpts{Percent: 100}) + + // Disable stealth mode (turn on LEDs) + setStealthModeError := a.blade.SetStealthMode(false) + + // Set critical pattern for top LED + setPatternTopLedErr := a.topLedEngine.SetPattern( + ledengine.NewSlowBlinkPattern(led.Color{}, a.config.CriticalLedColor), + ) + // Combine errors, but don't stop execution flow for now + return errors.Join(setStealthModeError, setPatternTopLedErr) +} + +// handleCriticalReset handles the reset of a critical state by restoring default hardware settings for fans and LEDs. +func (a *computeBladeAgent) handleCriticalReset(ctx context.Context) error { + log.FromContext(ctx).Info("Critical state cleared, setting fan speed to default and restoring LEDs to default state") + // Reset fan controller overrides + a.fanController.Override(nil) + + // Reset stealth mode + if err := a.blade.SetStealthMode(a.config.StealthModeEnabled); err != nil { + return err + } + + // Set top LED off + if err := a.topLedEngine.SetPattern(ledengine.NewStaticPattern(led.Color{})); err != nil { + return err + } + + return nil +} diff --git a/internal/agent/options.go b/internal/agent/options.go new file mode 100644 index 0000000..fdef151 --- /dev/null +++ b/internal/agent/options.go @@ -0,0 +1,30 @@ +package internal_agent + +import ( + "github.com/sierrasoftworks/humane-errors-go" +) + +type ListenMode string + +const ( + ModeTcp ListenMode = "tcp" + ModeUnix ListenMode = "unix" +) + +func ListenModeFromString(s string) (ListenMode, humane.Error) { + switch s { + case string(ModeTcp): + return ModeTcp, nil + case string(ModeUnix): + return ModeUnix, nil + default: + return "", humane.New("invalid listen mode", + "ensure you are passing a valid listen mode to the grpc server", + "valid modes are: [tcp, unix]", + ) + } +} + +func (l ListenMode) String() string { + return string(l) +} diff --git a/internal/agent/utils.go b/internal/agent/utils.go new file mode 100644 index 0000000..8ee919c --- /dev/null +++ b/internal/agent/utils.go @@ -0,0 +1,25 @@ +package internal_agent + +import ( + bladeapiv1alpha1 "github.com/compute-blade-community/compute-blade-agent/api/bladeapi/v1alpha1" + "github.com/compute-blade-community/compute-blade-agent/pkg/events" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// fromProto converts a `bladeapiv1alpha1.Event` into a corresponding `events.Event` type. +// Returns an error if the event type is invalid. +func fromProto(event bladeapiv1alpha1.Event) (events.Event, error) { + switch event { + case bladeapiv1alpha1.Event_IDENTIFY: + return events.IdentifyEvent, nil + case bladeapiv1alpha1.Event_IDENTIFY_CONFIRM: + return events.IdentifyConfirmEvent, nil + case bladeapiv1alpha1.Event_CRITICAL: + return events.CriticalEvent, nil + case bladeapiv1alpha1.Event_CRITICAL_RESET: + return events.CriticalResetEvent, nil + default: + return events.NoopEvent, status.Errorf(codes.InvalidArgument, "invalid event type") + } +} diff --git a/internal/api/api.go b/internal/api/api.go deleted file mode 100644 index e40d39d..0000000 --- a/internal/api/api.go +++ /dev/null @@ -1,191 +0,0 @@ -package api - -import ( - "context" - "crypto/tls" - "errors" - "net" - - bladeapiv1alpha1 "github.com/compute-blade-community/compute-blade-agent/api/bladeapi/v1alpha1" - agent2 "github.com/compute-blade-community/compute-blade-agent/pkg/agent" - "github.com/compute-blade-community/compute-blade-agent/pkg/events" - "github.com/compute-blade-community/compute-blade-agent/pkg/log" - grpczap "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/logging" - "github.com/sierrasoftworks/humane-errors-go" - "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" - "go.uber.org/zap" - "google.golang.org/grpc" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/credentials" - "google.golang.org/grpc/status" - "google.golang.org/protobuf/types/known/emptypb" -) - -type ListenMode string - -const ( - ModeTcp ListenMode = "tcp" - ModeUnix ListenMode = "unix" -) - -func ListenModeFromString(s string) (ListenMode, humane.Error) { - switch s { - case string(ModeTcp): - return ModeTcp, nil - case string(ModeUnix): - return ModeUnix, nil - default: - return "", humane.New("invalid listen mode", - "ensure you are passing a valid listen mode to the grpc server", - "valid modes are: [tcp, unix]", - ) - } -} - -func (l ListenMode) String() string { - return string(l) -} - -// AgentGrpcService represents a gRPC server implementation for managing compute blade agents. -// It embeds UnimplementedBladeAgentServiceServer for forward compatibility and integrates ComputeBladeAgent logic. -// The type allows for serving gRPC requests and gracefully shutting down the server. -type AgentGrpcService struct { - bladeapiv1alpha1.UnimplementedBladeAgentServiceServer - agent agent2.ComputeBladeAgent - server *grpc.Server - authenticated bool - listenAddr string - listenMode ListenMode -} - -// NewGrpcApiServer creates a new gRPC service -func NewGrpcApiServer(ctx context.Context, options ...GrpcApiServiceOption) *AgentGrpcService { - service := &AgentGrpcService{} - - for _, option := range options { - option(service) - } - - grpcOpts := make([]grpc.ServerOption, 0) - - // If we run our gRPC Server TLS with authentication enabled - if service.listenMode == ModeTcp && service.authenticated { - // Load server's certificate and private key - cert, certPool, err := EnsureServerCertificate(ctx) - if err != nil { - log.FromContext(ctx).WithError(err).Fatal("failed to load server key pair") - } - - // Create the TLS config that enforces mTLS for client authentication - tlsConfig := &tls.Config{ - Certificates: []tls.Certificate{cert}, - ClientAuth: tls.RequireAndVerifyClientCert, - ClientCAs: certPool, - } - - // Append the mTLS credentials to our gRPC Options to enable authenticated clients - grpcOpts = append(grpcOpts, grpc.Creds(credentials.NewTLS(tlsConfig))) - - // Make sure we have a local bladectl config with authentication enabled - if err := EnsureAuthenticatedBladectlConfig(ctx, service.listenAddr, service.listenMode); err != nil { - log.FromContext(ctx).WithError(err).Fatal("failed to ensure proper local bladectl config") - } - } else { - // Make sure we have a local bladectl config with no authentication enabled - if err := EnsureUnauthenticatedBladectlConfig(ctx, service.listenAddr, service.listenMode); err != nil { - log.FromContext(ctx).WithError(err).Fatal("failed to ensure proper local bladectl config") - } - } - - // Add Logging Middleware - grpcOpts = append(grpcOpts, grpc.ChainUnaryInterceptor(grpczap.UnaryServerInterceptor(log.InterceptorLogger(log.FromContext(ctx))))) - grpcOpts = append(grpcOpts, grpc.ChainStreamInterceptor(grpczap.StreamServerInterceptor(log.InterceptorLogger(log.FromContext(ctx))))) - grpcOpts = append(grpcOpts, grpc.StatsHandler(otelgrpc.NewServerHandler())) - - // Make server - service.server = grpc.NewServer(grpcOpts...) - bladeapiv1alpha1.RegisterBladeAgentServiceServer(service.server, service) - - return service -} - -// ServeAsync starts the gRPC server asynchronously in a new goroutine and cancels the context if an error occurs. -func (s *AgentGrpcService) ServeAsync(ctx context.Context, cancel context.CancelCauseFunc) { - go func() { - err := s.Serve(ctx) - if err != nil { - log.FromContext(ctx).WithError(err).Error("Failed to start grpc server") - - cancel(err.Cause()) - } - }() -} - -// Serve starts the gRPC server using the configured listen address and mode, returning an error if it fails. -func (s *AgentGrpcService) Serve(ctx context.Context) humane.Error { - if len(s.listenAddr) == 0 { - return humane.New("no listen address provided", - "ensure you are passing a valid listen config to the grpc server", - ) - } - - grpcListen, err := net.Listen(s.listenMode.String(), s.listenAddr) - if err != nil { - return humane.Wrap(err, "failed to create grpc listener", - "ensure the gRPC server you are trying to serve to is not already running and the address is not bound by another process", - ) - } - - log.FromContext(ctx).Info("Starting grpc server", zap.String("address", s.listenAddr)) - if err := s.server.Serve(grpcListen); err != nil && !errors.Is(err, grpc.ErrServerStopped) { - return humane.Wrap(err, "failed to start grpc server", - "ensure the gRPC server you are trying to serve to is not already running and the address is not bound by another process", - ) - } - - return nil -} - -// GracefulStop gracefully stops the gRPC server, ensuring all in-progress RPCs are completed before shutting down. -func (s *AgentGrpcService) GracefulStop() { - s.server.GracefulStop() -} - -// EmitEvent emits an event to the agent runtime -func (s *AgentGrpcService) EmitEvent(ctx context.Context, req *bladeapiv1alpha1.EmitEventRequest) (*emptypb.Empty, error) { - switch req.GetEvent() { - case bladeapiv1alpha1.Event_IDENTIFY: - return &emptypb.Empty{}, s.agent.EmitEvent(ctx, events.IdentifyEvent) - case bladeapiv1alpha1.Event_IDENTIFY_CONFIRM: - return &emptypb.Empty{}, s.agent.EmitEvent(ctx, events.IdentifyConfirmEvent) - case bladeapiv1alpha1.Event_CRITICAL: - return &emptypb.Empty{}, s.agent.EmitEvent(ctx, events.CriticalEvent) - case bladeapiv1alpha1.Event_CRITICAL_RESET: - return &emptypb.Empty{}, s.agent.EmitEvent(ctx, events.CriticalResetEvent) - default: - return &emptypb.Empty{}, status.Errorf(codes.InvalidArgument, "invalid event type") - } -} - -// WaitForIdentifyConfirm blocks until the identify confirmation process is completed or an error occurs. -func (s *AgentGrpcService) WaitForIdentifyConfirm(ctx context.Context, _ *emptypb.Empty) (*emptypb.Empty, error) { - return &emptypb.Empty{}, s.agent.WaitForIdentifyConfirm(ctx) -} - -// SetFanSpeed sets the fan speed of the blade -func (s *AgentGrpcService) SetFanSpeed( - ctx context.Context, - req *bladeapiv1alpha1.SetFanSpeedRequest, -) (*emptypb.Empty, error) { - return &emptypb.Empty{}, s.agent.SetFanSpeed(ctx, uint8(req.GetPercent())) -} - -// SetStealthMode enables/disables stealth mode on the blade -func (s *AgentGrpcService) SetStealthMode(ctx context.Context, req *bladeapiv1alpha1.StealthModeRequest) (*emptypb.Empty, error) { - return &emptypb.Empty{}, s.agent.SetStealthMode(ctx, req.GetEnable()) -} - -// GetStatus aggregates the status of the blade -func (s *AgentGrpcService) GetStatus(context.Context, *emptypb.Empty) (*bladeapiv1alpha1.StatusResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method GetStatus not implemented") -} diff --git a/internal/api/config.go b/internal/api/config.go deleted file mode 100644 index 2a1ba68..0000000 --- a/internal/api/config.go +++ /dev/null @@ -1,8 +0,0 @@ -package api - -type Config struct { - Metrics string `mapstructure:"metrics"` - Grpc string `mapstructure:"grpc"` - GrpcAuthenticated bool `mapstructure:"authenticated"` - GrpcListenMode string `mapstructure:"mode"` -} diff --git a/internal/api/options.go b/internal/api/options.go deleted file mode 100644 index f72c96a..0000000 --- a/internal/api/options.go +++ /dev/null @@ -1,46 +0,0 @@ -package api - -import ( - "github.com/compute-blade-community/compute-blade-agent/pkg/agent" - "github.com/spechtlabs/go-otel-utils/otelzap" - "go.uber.org/zap" -) - -// GrpcApiServiceOption defines a functional option for configuring an AgentGrpcService instance. -type GrpcApiServiceOption func(*AgentGrpcService) - -// WithComputeBladeAgent sets the ComputeBladeAgent implementation for the AgentGrpcService. -func WithComputeBladeAgent(agent agent.ComputeBladeAgent) GrpcApiServiceOption { - return func(service *AgentGrpcService) { - service.agent = agent - } -} - -// WithAuthentication configures the authentication requirement for the gRPC service by enabling or disabling it. -func WithAuthentication(enabled bool) GrpcApiServiceOption { - return func(service *AgentGrpcService) { - service.authenticated = enabled - } -} - -// WithListenAddr sets the server's listen address on an AgentGrpcService instance. -func WithListenAddr(server string) GrpcApiServiceOption { - return func(service *AgentGrpcService) { - service.listenAddr = server - } -} - -// WithListenMode configures the listen mode for the AgentGrpcService using the provided mode string. -func WithListenMode(mode string) GrpcApiServiceOption { - return func(service *AgentGrpcService) { - lMode, err := ListenModeFromString(mode) - if err != nil { - otelzap.L().Fatal(err.Error(), - zap.String("mode", mode), - zap.Strings("advice", err.Advice()), - ) - } - - service.listenMode = lMode - } -} diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index d964034..421eb6d 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -3,21 +3,17 @@ package agent import ( "context" - "github.com/compute-blade-community/compute-blade-agent/pkg/events" + bladeapiv1alpha1 "github.com/compute-blade-community/compute-blade-agent/api/bladeapi/v1alpha1" ) // ComputeBladeAgent implements the core-logic of the agent. It is responsible for handling events and interfacing with the hardware. +// any ComputeBladeAgent must also be a bladeapiv1alpha1.BladeAgentServiceServer to handle the gRPC API requests. type ComputeBladeAgent interface { + bladeapiv1alpha1.BladeAgentServiceServer // RunAsync dispatches the agent until the context is canceled or an error occurs RunAsync(ctx context.Context, cancel context.CancelCauseFunc) // Run dispatches the agent and blocks until the context is canceled or an error occurs Run(ctx context.Context) error - // EmitEvent emits an event to the agent - EmitEvent(ctx context.Context, event events.Event) error - // SetFanSpeed sets the fan speed in percent - SetFanSpeed(_ context.Context, speed uint8) error - // SetStealthMode sets the stealth mode - SetStealthMode(_ context.Context, enabled bool) error - // WaitForIdentifyConfirm blocks until the user confirms the identify mode - WaitForIdentifyConfirm(ctx context.Context) error + // GracefulStop gracefully stops the gRPC server, ensuring all in-progress RPCs are completed before shutting down. + GracefulStop(ctx context.Context) error } diff --git a/internal/agent/config.go b/pkg/agent/config.go similarity index 86% rename from internal/agent/config.go rename to pkg/agent/config.go index a475238..78a3da0 100644 --- a/internal/agent/config.go +++ b/pkg/agent/config.go @@ -1,7 +1,6 @@ package agent import ( - "github.com/compute-blade-community/compute-blade-agent/internal/api" "github.com/compute-blade-community/compute-blade-agent/pkg/fancontroller" "github.com/compute-blade-community/compute-blade-agent/pkg/hal" "github.com/compute-blade-community/compute-blade-agent/pkg/hal/led" @@ -11,12 +10,19 @@ type LogConfiguration struct { Mode string `mapstructure:"mode"` } +type ApiConfig struct { + Metrics string `mapstructure:"metrics"` + Grpc string `mapstructure:"grpc"` + GrpcAuthenticated bool `mapstructure:"authenticated"` + GrpcListenMode string `mapstructure:"mode"` +} + type ComputeBladeAgentConfig struct { // Log is the logging configuration Log LogConfiguration `mapstructure:"log"` // Listen is the listen configuration for the server - Listen api.Config `mapstructure:"listen"` + Listen ApiConfig `mapstructure:"listen"` // Hal is the hardware abstraction layer configuration Hal hal.Config `mapstructure:"hal"` diff --git a/pkg/fancontroller/fancontroller.go b/pkg/fancontroller/fancontroller.go index 072c44b..38c6608 100644 --- a/pkg/fancontroller/fancontroller.go +++ b/pkg/fancontroller/fancontroller.go @@ -10,7 +10,13 @@ import ( type FanController interface { Override(opts *FanOverrideOpts) - GetFanSpeed(temperature float64) uint8 + // GetFanSpeedPercent returns the fan speed in percent based on the current temperature + GetFanSpeedPercent(temperature float64) uint8 + // IsAutomaticSpeed returns true if the FanSpeed is determined by the fan controller logic, or false if determined + // by an FanOverrideOpts + IsAutomaticSpeed() bool + // Config returns a copy of the FanController Config + Config() Config } // FanController is a simple fan controller that reacts to temperature changes with a linear function @@ -60,14 +66,18 @@ func NewLinearFanController(config Config) (FanController, humane.Error) { }, nil } +func (f *fanControllerLinear) Config() Config { + return f.config +} + func (f *fanControllerLinear) Override(opts *FanOverrideOpts) { f.mu.Lock() defer f.mu.Unlock() f.overrideOpts = opts } -// GetFanSpeed returns the fan speed in percent based on the current temperature -func (f *fanControllerLinear) GetFanSpeed(temperature float64) uint8 { +// GetFanSpeedPercent returns the fan speed in percent based on the current temperature +func (f *fanControllerLinear) GetFanSpeedPercent(temperature float64) uint8 { f.mu.Lock() defer f.mu.Unlock() @@ -90,3 +100,7 @@ func (f *fanControllerLinear) GetFanSpeed(temperature float64) uint8 { return uint8(speed) } + +func (f *fanControllerLinear) IsAutomaticSpeed() bool { + return f.overrideOpts == nil +} diff --git a/pkg/fancontroller/fancontroller_test.go b/pkg/fancontroller/fancontroller_test.go index 3a647d5..5f7e247 100644 --- a/pkg/fancontroller/fancontroller_test.go +++ b/pkg/fancontroller/fancontroller_test.go @@ -35,7 +35,7 @@ func TestFanControllerLinear_GetFanSpeed(t *testing.T) { temperature := tc.temperature t.Run("", func(t *testing.T) { t.Parallel() - speed := controller.GetFanSpeed(temperature) + speed := controller.GetFanSpeedPercent(temperature) if speed != expected { t.Errorf("For temperature %.2f, expected speed %d but got %d", temperature, expected, speed) } @@ -75,7 +75,7 @@ func TestFanControllerLinear_GetFanSpeedWithOverride(t *testing.T) { temperature := tc.temperature t.Run("", func(t *testing.T) { t.Parallel() - speed := controller.GetFanSpeed(temperature) + speed := controller.GetFanSpeedPercent(temperature) if speed != expected { t.Errorf("For temperature %.2f, expected speed %d but got %d", temperature, expected, speed) } diff --git a/pkg/hal/hal.go b/pkg/hal/hal.go index 280ed56..617388f 100644 --- a/pkg/hal/hal.go +++ b/pkg/hal/hal.go @@ -32,8 +32,10 @@ const ( PowerPoe802at ) +type LedIndex uint8 + const ( - LedTop = iota + LedTop LedIndex = iota LedEdge ) @@ -53,8 +55,10 @@ type ComputeBladeHal interface { GetFanRPM() (float64, error) // SetStealthMode enables/disables stealth mode of the blade (turning on/off the LEDs) SetStealthMode(enabled bool) error + // StealthModeActive returns if stealth mode of the blade is currently active + StealthModeActive() bool // SetLed sets the color of the LEDs - SetLed(idx uint, color led.Color) error + SetLed(idx LedIndex, color led.Color) error // GetPowerStatus returns the current power status of the blade GetPowerStatus() (PowerStatus, error) // GetTemperature returns the current temperature of the SoC in °C @@ -65,7 +69,6 @@ type ComputeBladeHal interface { // FanUnit abstracts the fan unit type FanUnit interface { - // Kind returns the kind of the fan FanUnit Kind() FanUnitKind diff --git a/pkg/hal/hal_bcm2711.go b/pkg/hal/hal_bcm2711.go index ff66f7e..ef75881 100644 --- a/pkg/hal/hal_bcm2711.go +++ b/pkg/hal/hal_bcm2711.go @@ -386,6 +386,14 @@ func (bcm *bcm2711) SetStealthMode(enable bool) error { } } +func (bcm *bcm2711) StealthModeActive() bool { + val, err := bcm.stealthModeLine.Value() + if err != nil { + return false + } + return val > 0 +} + // serializePwmDataFrame converts a byte to a 24 bit PWM data frame for WS281x LEDs func serializePwmDataFrame(data uint8) uint32 { var result uint32 = 0 @@ -402,7 +410,7 @@ func serializePwmDataFrame(data uint8) uint32 { return result } -func (bcm *bcm2711) SetLed(idx uint, color led.Color) error { +func (bcm *bcm2711) SetLed(idx LedIndex, color led.Color) error { if idx >= 2 { return fmt.Errorf("invalid led index %d, supported: [0, 1]", idx) } diff --git a/pkg/hal/hal_bcm2711_simulated.go b/pkg/hal/hal_bcm2711_simulated.go index f8a32c2..de0d27a 100644 --- a/pkg/hal/hal_bcm2711_simulated.go +++ b/pkg/hal/hal_bcm2711_simulated.go @@ -16,7 +16,8 @@ var _ ComputeBladeHal = &SimulatedHal{} // SimulatedHal implements a mock for the ComputeBladeHal interface type SimulatedHal struct { - logger *zap.Logger + logger *zap.Logger + isStealthMode bool } func NewCm4Hal(_ context.Context, _ ComputeBladeHalOpts) (ComputeBladeHal, error) { @@ -58,10 +59,16 @@ func (m *SimulatedHal) SetStealthMode(enabled bool) error { } else { stealthModeEnabled.Set(0) } + + m.isStealthMode = enabled m.logger.Info("SetStealthMode", zap.Bool("enabled", enabled)) return nil } +func (m *SimulatedHal) StealthModeActive() bool { + return m.isStealthMode +} + func (m *SimulatedHal) GetPowerStatus() (PowerStatus, error) { m.logger.Info("GetPowerStatus") powerStatus.WithLabelValues("simulated").Set(1) @@ -79,9 +86,9 @@ func (m *SimulatedHal) WaitForEdgeButtonPress(ctx context.Context) error { } } -func (m *SimulatedHal) SetLed(idx uint, color led.Color) error { +func (m *SimulatedHal) SetLed(idx LedIndex, color led.Color) error { ledColorChangeEventCount.Inc() - m.logger.Info("SetLed", zap.Uint("idx", idx), zap.Any("color", color)) + m.logger.Info("SetLed", zap.Uint("idx", uint(idx)), zap.Any("color", color)) return nil } diff --git a/pkg/hal/hal_bcm2711_standardfanunit.go b/pkg/hal/hal_bcm2711_standardfanunit.go index 08e26af..70d8827 100644 --- a/pkg/hal/hal_bcm2711_standardfanunit.go +++ b/pkg/hal/hal_bcm2711_standardfanunit.go @@ -6,10 +6,8 @@ import ( "context" "math" - "github.com/compute-blade-community/compute-blade-agent/pkg/log" - "go.uber.org/zap" - "github.com/compute-blade-community/compute-blade-agent/pkg/hal/led" + "github.com/compute-blade-community/compute-blade-agent/pkg/log" "github.com/warthog618/gpiod" "github.com/warthog618/gpiod/device/rpi" ) @@ -50,7 +48,7 @@ func (fu standardFanUnitBcm2711) Run(ctx context.Context) error { defer func(fanEdgeLine *gpiod.Line) { err := fanEdgeLine.Close() if err != nil { - log.FromContext(ctx).Error("failed to close fanEdgeLine", zap.Error(err)) + log.FromContext(ctx).WithError(err).Error("failed to close fanEdgeLine") } }(fu.fanEdgeLine) } diff --git a/pkg/hal/hal_mock.go b/pkg/hal/hal_mock.go index 98d9ecf..d9c031d 100644 --- a/pkg/hal/hal_mock.go +++ b/pkg/hal/hal_mock.go @@ -40,6 +40,11 @@ func (m *ComputeBladeHalMock) SetStealthMode(enabled bool) error { return args.Error(0) } +func (m *ComputeBladeHalMock) StealthModeActive() bool { + args := m.Called() + return args.Bool(0) +} + func (m *ComputeBladeHalMock) GetPowerStatus() (PowerStatus, error) { args := m.Called() return args.Get(0).(PowerStatus), args.Error(1) @@ -50,7 +55,7 @@ func (m *ComputeBladeHalMock) WaitForEdgeButtonPress(ctx context.Context) error return args.Error(0) } -func (m *ComputeBladeHalMock) SetLed(idx uint, color led.Color) error { +func (m *ComputeBladeHalMock) SetLed(idx LedIndex, color led.Color) error { args := m.Called(idx, color) return args.Error(0) } diff --git a/pkg/ledengine/ledengine.go b/pkg/ledengine/ledengine.go index 7d06ef7..32d9b8e 100644 --- a/pkg/ledengine/ledengine.go +++ b/pkg/ledengine/ledengine.go @@ -20,7 +20,7 @@ type LedEngine interface { // ledEngineImpl is the implementation of the LedEngine interface type ledEngineImpl struct { - ledIdx uint + ledIdx hal.LedIndex restart chan struct{} pattern BlinkPattern hal hal.ComputeBladeHal @@ -101,6 +101,13 @@ func NewSlowBlinkPattern(baseColor led.Color, activeColor led.Color) BlinkPatter } } +func New(hal hal.ComputeBladeHal, ledIdx hal.LedIndex) LedEngine { + return NewLedEngine(Options{ + Hal: hal, + LedIdx: ledIdx, + }) +} + func NewLedEngine(opts Options) LedEngine { clock := opts.Clock if clock == nil { diff --git a/pkg/ledengine/ledengine_test.go b/pkg/ledengine/ledengine_test.go index 93b9c1e..76d73c1 100644 --- a/pkg/ledengine/ledengine_test.go +++ b/pkg/ledengine/ledengine_test.go @@ -131,8 +131,8 @@ func Test_LedEngine_SetPattern_WhileRunning(t *testing.T) { clk.On("After", time.Hour).Times(2).Return(clkAfterChan) cbMock := hal.ComputeBladeHalMock{} - cbMock.On("SetLed", uint(0), led.Color{Green: 0, Blue: 0, Red: 0}).Once().Return(nil) - cbMock.On("SetLed", uint(0), led.Color{Green: 0, Blue: 0, Red: 255}).Once().Return(nil) + cbMock.On("SetLed", hal.LedTop, led.Color{Green: 0, Blue: 0, Red: 0}).Once().Return(nil) + cbMock.On("SetLed", hal.LedTop, led.Color{Green: 0, Blue: 0, Red: 255}).Once().Return(nil) opts := ledengine.Options{ Hal: &cbMock, @@ -178,7 +178,7 @@ func Test_LedEngine_SetPattern_BeforeRun(t *testing.T) { clk.On("After", time.Hour).Once().Return(clkAfterChan) cbMock := hal.ComputeBladeHalMock{} - cbMock.On("SetLed", uint(0), led.Color{Green: 0, Blue: 0, Red: 255}).Once().Return(nil) + cbMock.On("SetLed", hal.LedTop, led.Color{Green: 0, Blue: 0, Red: 255}).Once().Return(nil) opts := ledengine.Options{ Hal: &cbMock, @@ -220,8 +220,8 @@ func Test_LedEngine_SetPattern_SetLedFailureInPattern(t *testing.T) { clk.On("After", time.Hour).Once().Return(clkAfterChan) cbMock := hal.ComputeBladeHalMock{} - call0 := cbMock.On("SetLed", uint(0), led.Color{Green: 0, Blue: 0, Red: 0}).Once().Return(nil) - cbMock.On("SetLed", uint(0), led.Color{Green: 0, Blue: 0, Red: 0}).Once().Return(errors.New("failure")).NotBefore(call0) + call0 := cbMock.On("SetLed", hal.LedTop, led.Color{Green: 0, Blue: 0, Red: 0}).Once().Return(nil) + cbMock.On("SetLed", hal.LedTop, led.Color{Green: 0, Blue: 0, Red: 0}).Once().Return(errors.New("failure")).NotBefore(call0) opts := ledengine.Options{ Hal: &cbMock, diff --git a/pkg/ledengine/options.go b/pkg/ledengine/options.go index 5a64fec..2060429 100644 --- a/pkg/ledengine/options.go +++ b/pkg/ledengine/options.go @@ -8,7 +8,7 @@ import ( // Options are the options for the LedEngine type Options struct { // LedIdx is the index of the LED to control - LedIdx uint + LedIdx hal.LedIndex // Hal is the computeblade hardware abstraction layer Hal hal.ComputeBladeHal // Clock is the clock used for timing diff --git a/pkg/util/kv_print.go b/pkg/util/kv_print.go new file mode 100644 index 0000000..f4de936 --- /dev/null +++ b/pkg/util/kv_print.go @@ -0,0 +1,16 @@ +package util + +import ( + "github.com/charmbracelet/lipgloss" +) + +const ( + ColorCritical = lipgloss.Color("#cc0000") + ColorWarning = lipgloss.Color("#e69138") + ColorOk = lipgloss.Color("#04B575") + ColorUnknown = lipgloss.Color("#68228B") +) + +func OkStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(ColorOk) +} From bc3c8c7339ac7c9bfd2828c4d0b2d979cb8b7ea5 Mon Sep 17 00:00:00 2001 From: Cedric Kienzler Date: Fri, 6 Jun 2025 19:50:11 +0200 Subject: [PATCH 2/3] test: improve unit-testing --- cmd/bladectl/cmd_status.go | 3 +- cmd/bladectl/util.go | 31 +++++++---- internal/agent/api.go | 2 +- pkg/fancontroller/fancontroller.go | 9 ++-- pkg/fancontroller/fancontroller_test.go | 21 ++++---- pkg/ledengine/ledengine_test.go | 37 +++++++++++++ pkg/util/clock_test.go | 72 +++++++++++++++++++++++++ pkg/util/file_exist_test.go | 25 +++++++++ pkg/util/host_ips_test.go | 18 +++++++ pkg/util/kv_print.go | 16 ------ 10 files changed, 189 insertions(+), 45 deletions(-) create mode 100644 pkg/util/clock_test.go create mode 100644 pkg/util/file_exist_test.go create mode 100644 pkg/util/host_ips_test.go delete mode 100644 pkg/util/kv_print.go diff --git a/cmd/bladectl/cmd_status.go b/cmd/bladectl/cmd_status.go index 96a91f6..84ce82d 100644 --- a/cmd/bladectl/cmd_status.go +++ b/cmd/bladectl/cmd_status.go @@ -5,7 +5,6 @@ import ( bladeapiv1alpha1 "github.com/compute-blade-community/compute-blade-agent/api/bladeapi/v1alpha1" "github.com/compute-blade-community/compute-blade-agent/pkg/hal" - "github.com/compute-blade-community/compute-blade-agent/pkg/util" "github.com/olekukonko/tablewriter" "github.com/olekukonko/tablewriter/tw" "github.com/spf13/cobra" @@ -68,7 +67,7 @@ func printStatusTable(bladeStatus []*bladeapiv1alpha1.StatusResponse) { activeStyle(status.StealthMode).Render(activeLabel(status.StealthMode)), activeStyle(status.IdentifyActive).Render(activeLabel(status.IdentifyActive)), activeStyle(status.CriticalActive).Render(activeLabel(status.CriticalActive)), - util.OkStyle().Render(hal.PowerStatus(status.PowerStatus).String()), + okStyle().Render(hal.PowerStatus(status.PowerStatus).String()), } _ = tbl.Append(row) diff --git a/cmd/bladectl/util.go b/cmd/bladectl/util.go index be46c8f..3797222 100644 --- a/cmd/bladectl/util.go +++ b/cmd/bladectl/util.go @@ -4,7 +4,12 @@ import ( "fmt" "github.com/charmbracelet/lipgloss" - "github.com/compute-blade-community/compute-blade-agent/pkg/util" +) + +const ( + ColorCritical = lipgloss.Color("#cc0000") + ColorWarning = lipgloss.Color("#e69138") + ColorOk = lipgloss.Color("#04B575") ) func fanSpeedOverrideLabel(automatic bool, percent uint32) string { @@ -35,40 +40,44 @@ func activeLabel(b bool) string { func speedOverrideStyle(automaticMode bool) lipgloss.Style { if automaticMode { - return lipgloss.NewStyle().Foreground(util.ColorOk) + return lipgloss.NewStyle().Foreground(ColorOk) } - return lipgloss.NewStyle().Foreground(util.ColorCritical) + return lipgloss.NewStyle().Foreground(ColorCritical) } func activeStyle(active bool) lipgloss.Style { if active { - return lipgloss.NewStyle().Foreground(util.ColorCritical) + return lipgloss.NewStyle().Foreground(ColorCritical) } - return lipgloss.NewStyle().Foreground(util.ColorOk) + return lipgloss.NewStyle().Foreground(ColorOk) } func tempStyle(temp int64, criticalTemp int64) lipgloss.Style { - color := util.ColorOk + color := ColorOk if temp >= criticalTemp { - color = util.ColorCritical + color = ColorCritical } else if temp >= criticalTemp-10 { - color = util.ColorWarning + color = ColorWarning } return lipgloss.NewStyle().Foreground(color) } func rpmStyle(rpm int64) lipgloss.Style { - color := util.ColorOk + color := ColorOk if rpm > 6000 { - color = util.ColorCritical + color = ColorCritical } else if rpm > 5250 { - color = util.ColorWarning + color = ColorWarning } return lipgloss.NewStyle().Foreground(color) } + +func okStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(ColorOk) +} diff --git a/internal/agent/api.go b/internal/agent/api.go index b3b83dd..2c158da 100644 --- a/internal/agent/api.go +++ b/internal/agent/api.go @@ -120,7 +120,7 @@ func (a *computeBladeAgent) GetStatus(_ context.Context, _ *emptypb.Empty) (*bla return nil, err } - steps := a.fanController.Config().Steps + steps := a.fanController.Steps() fanCurveSteps := make([]*bladeapiv1alpha1.FanCurveStep, len(steps)) for idx, step := range steps { fanCurveSteps[idx] = &bladeapiv1alpha1.FanCurveStep{ diff --git a/pkg/fancontroller/fancontroller.go b/pkg/fancontroller/fancontroller.go index 38c6608..299ef08 100644 --- a/pkg/fancontroller/fancontroller.go +++ b/pkg/fancontroller/fancontroller.go @@ -15,8 +15,9 @@ type FanController interface { // IsAutomaticSpeed returns true if the FanSpeed is determined by the fan controller logic, or false if determined // by an FanOverrideOpts IsAutomaticSpeed() bool - // Config returns a copy of the FanController Config - Config() Config + + // Steps returns the list of temperature and fan speed steps configured for the fan controller. + Steps() []Step } // FanController is a simple fan controller that reacts to temperature changes with a linear function @@ -66,8 +67,8 @@ func NewLinearFanController(config Config) (FanController, humane.Error) { }, nil } -func (f *fanControllerLinear) Config() Config { - return f.config +func (f *fanControllerLinear) Steps() []Step { + return f.config.Steps } func (f *fanControllerLinear) Override(opts *FanOverrideOpts) { diff --git a/pkg/fancontroller/fancontroller_test.go b/pkg/fancontroller/fancontroller_test.go index 5f7e247..d534767 100644 --- a/pkg/fancontroller/fancontroller_test.go +++ b/pkg/fancontroller/fancontroller_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/compute-blade-community/compute-blade-agent/pkg/fancontroller" + "github.com/stretchr/testify/assert" ) func TestFanControllerLinear_GetFanSpeed(t *testing.T) { @@ -30,15 +31,16 @@ func TestFanControllerLinear_GetFanSpeed(t *testing.T) { {35, 60}, // Should use the maximum speed } + assert.Equal(t, controller.Steps(), config.Steps) + for _, tc := range testCases { expected := tc.expected temperature := tc.temperature t.Run("", func(t *testing.T) { t.Parallel() speed := controller.GetFanSpeedPercent(temperature) - if speed != expected { - t.Errorf("For temperature %.2f, expected speed %d but got %d", temperature, expected, speed) - } + assert.Equal(t, expected, speed) + assert.True(t, controller.IsAutomaticSpeed(), "Expected fan speed to be automatic, but it was not") }) } } @@ -76,9 +78,8 @@ func TestFanControllerLinear_GetFanSpeedWithOverride(t *testing.T) { t.Run("", func(t *testing.T) { t.Parallel() speed := controller.GetFanSpeedPercent(temperature) - if speed != expected { - t.Errorf("For temperature %.2f, expected speed %d but got %d", temperature, expected, speed) - } + assert.Equal(t, expected, speed) + assert.False(t, controller.IsAutomaticSpeed(), "Expected fan speed to be overridden, but it was not") }) } } @@ -127,11 +128,9 @@ func TestFanControllerLinear_ConstructionErrors(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() _, err := fancontroller.NewLinearFanController(config) - if err == nil { - t.Errorf("Expected error with message '%s', but got no error", expectedErrMsg) - } else if err.Error() != expectedErrMsg { - t.Errorf("Expected error message '%s', but got '%s'", expectedErrMsg, err.Error()) - } + + assert.NotNil(t, err, "Expected error with message '%s', but got no error", expectedErrMsg) + assert.EqualError(t, err, expectedErrMsg) }) } } diff --git a/pkg/ledengine/ledengine_test.go b/pkg/ledengine/ledengine_test.go index 76d73c1..a3aff45 100644 --- a/pkg/ledengine/ledengine_test.go +++ b/pkg/ledengine/ledengine_test.go @@ -123,6 +123,17 @@ func TestNewLedEngine(t *testing.T) { assert.NotNil(t, engine) } +func TestLedEngine_NewLedEngineWithoutClock(t *testing.T) { + opts := ledengine.Options{ + Clock: nil, + LedIdx: 0, + Hal: &hal.ComputeBladeHalMock{}, + } + + engine := ledengine.NewLedEngine(opts) + assert.NotNil(t, engine) +} + func Test_LedEngine_SetPattern_WhileRunning(t *testing.T) { t.Parallel() @@ -254,3 +265,29 @@ func Test_LedEngine_SetPattern_SetLedFailureInPattern(t *testing.T) { clk.AssertExpectations(t) cbMock.AssertExpectations(t) } + +func Test_LedEngine_SetPattern_NoDelay(t *testing.T) { + t.Parallel() + + clk := util.MockClock{} + clkAfterChan := make(chan time.Time) + clk.On("After", time.Hour).Once().Return(clkAfterChan) + + cbMock := hal.ComputeBladeHalMock{} + cbMock.On("SetLed", hal.LedTop, led.Color{Green: 0, Blue: 0, Red: 255}).Once().Return(nil) + + opts := ledengine.Options{ + Hal: &cbMock, + Clock: &clk, + LedIdx: 0, + } + + engine := ledengine.NewLedEngine(opts) + invalidPattern := ledengine.NewStaticPattern(led.Color{Red: 255}) + invalidPattern.Delays = []time.Duration{} + // We want to change the pattern BEFORE the engine is started + t.Log("Setting pattern") + err := engine.SetPattern(invalidPattern) + assert.Error(t, err) + assert.ErrorContains(t, err, "pattern must have at least one delay") +} diff --git a/pkg/util/clock_test.go b/pkg/util/clock_test.go new file mode 100644 index 0000000..024616c --- /dev/null +++ b/pkg/util/clock_test.go @@ -0,0 +1,72 @@ +package util_test + +import ( + "testing" + "time" + + "github.com/compute-blade-community/compute-blade-agent/pkg/util" + + "github.com/stretchr/testify/assert" +) + +// TestRealClock_Now ensures that RealClock.Now() returns a time close to the actual time. +func TestRealClock_Now(t *testing.T) { + rc := util.RealClock{} + before := time.Now() + got := rc.Now() + after := time.Now() + + if got.Before(before) || got.After(after) { + t.Errorf("RealClock.Now() = %v, want between %v and %v", got, before, after) + } +} + +// TestRealClock_After ensures that RealClock.After() returns a channel that sends after the given duration. +func TestRealClock_After(t *testing.T) { + rc := util.RealClock{} + delay := 50 * time.Millisecond + + start := time.Now() + ch := rc.After(delay) + <-ch + elapsed := time.Since(start) + + if elapsed < delay { + t.Errorf("RealClock.After(%v) triggered too early after %v", delay, elapsed) + } +} + +// TestMockClock_Now tests that MockClock.Now() returns the expected time and records the call. +func TestMockClock_Now(t *testing.T) { + mockClock := new(util.MockClock) + expectedTime := time.Date(2025, time.June, 6, 12, 0, 0, 0, time.UTC) + + mockClock.On("Now").Return(expectedTime) + + actualTime := mockClock.Now() + assert.Equal(t, expectedTime, actualTime) + mockClock.AssertCalled(t, "Now") + mockClock.AssertExpectations(t) +} + +// TestMockClock_After tests that MockClock.After() returns the expected channel and records the call. +func TestMockClock_After(t *testing.T) { + mockClock := new(util.MockClock) + duration := 100 * time.Millisecond + expectedChan := make(chan time.Time, 1) + expectedTime := time.Now().Add(duration) + expectedChan <- expectedTime + + mockClock.On("After", duration).Return(expectedChan) + + resultChan := mockClock.After(duration) + select { + case result := <-resultChan: + assert.WithinDuration(t, expectedTime, result, time.Second) + case <-time.After(time.Second): + t.Fatal("timeout waiting for result from MockClock.After") + } + + mockClock.AssertCalled(t, "After", duration) + mockClock.AssertExpectations(t) +} diff --git a/pkg/util/file_exist_test.go b/pkg/util/file_exist_test.go new file mode 100644 index 0000000..d0ea6b6 --- /dev/null +++ b/pkg/util/file_exist_test.go @@ -0,0 +1,25 @@ +package util_test + +import ( + "os" + "testing" + + "github.com/compute-blade-community/compute-blade-agent/pkg/util" + "github.com/stretchr/testify/assert" +) + +func TestFileExists(t *testing.T) { + // Create a temporary file + tmpFile, err := os.CreateTemp("", "fileexists-test") + assert.NoError(t, err) + + // It should exist + assert.True(t, util.FileExists(tmpFile.Name()), "Expected file to exist") + + // Close and remove the file + assert.NoError(t, tmpFile.Close()) + assert.NoError(t, os.Remove(tmpFile.Name())) + + // It should not exist anymore + assert.False(t, util.FileExists(tmpFile.Name()), "Expected file not to exist") +} diff --git a/pkg/util/host_ips_test.go b/pkg/util/host_ips_test.go new file mode 100644 index 0000000..7a1439d --- /dev/null +++ b/pkg/util/host_ips_test.go @@ -0,0 +1,18 @@ +package util_test + +import ( + "testing" + + "github.com/compute-blade-community/compute-blade-agent/pkg/util" + "github.com/stretchr/testify/assert" +) + +func TestGetHostIPs_ReturnsNonLoopbackIPs(t *testing.T) { + ips, err := util.GetHostIPs() + assert.NoError(t, err) + + for _, ip := range ips { + assert.False(t, ip.IsLoopback(), "Should not return loopback IPs") + assert.False(t, ip.IsUnspecified(), "Should not return unspecified IPs") + } +} diff --git a/pkg/util/kv_print.go b/pkg/util/kv_print.go deleted file mode 100644 index f4de936..0000000 --- a/pkg/util/kv_print.go +++ /dev/null @@ -1,16 +0,0 @@ -package util - -import ( - "github.com/charmbracelet/lipgloss" -) - -const ( - ColorCritical = lipgloss.Color("#cc0000") - ColorWarning = lipgloss.Color("#e69138") - ColorOk = lipgloss.Color("#04B575") - ColorUnknown = lipgloss.Color("#68228B") -) - -func OkStyle() lipgloss.Style { - return lipgloss.NewStyle().Foreground(ColorOk) -} From 4722cd88413ee0c7719ac1d49dd9d1e9f73311ea Mon Sep 17 00:00:00 2001 From: Cedric Kienzler Date: Fri, 6 Jun 2025 22:58:49 +0200 Subject: [PATCH 3/3] fix: pin github.com/warthog618/gpiod --- renovate.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/renovate.json b/renovate.json index 140f2ee..4ecdda7 100644 --- a/renovate.json +++ b/renovate.json @@ -15,5 +15,8 @@ "automerge": true, "automergeType": "branch" } + ], + "ignoreDeps": [ + "github.com/warthog618/gpiod" ] } \ No newline at end of file