feat(bladectl)!: add more bladectl commands (#91)

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: <redacted>
            client-certificate-data: <redacted>
            client-key-data: <redacted>
    - name: 2
      blade:
        server: blade-pi2:8081
        cert:
            certificate-authority-data: <redacted>
            client-certificate-data: <redacted>
            client-key-data: <redacted>
    - name: 3
      blade:
        server: blade-pi3:8081
        cert:
            certificate-authority-data: <redacted>
            client-certificate-data: <redacted>
            client-key-data: <redacted>
    - name: 4
      blade:
        server: blade-pi4:8081
        cert:
            certificate-authority-data: <redacted>
            client-certificate-data: <redacted>
            client-key-data: <redacted>
    - name: 4
      blade:
        server: blade-pi4:8081
        cert:
            certificate-authority-data: <redacted>
            client-certificate-data: <redacted>
            client-key-data: <redacted>
current-blade: 1
```

Fixes #4, #9 (partially), should help with #5

* test: improve unit-testing

* fix: pin github.com/warthog618/gpiod

---------

Co-authored-by: Cedric Kienzler <cedric@specht-labs.de>
This commit is contained in:
Cedric Kienzler
2025-06-06 23:03:22 +02:00
committed by Cedric Kienzler
parent 7ec49ce05c
commit 781ded8e43
41 changed files with 1949 additions and 750 deletions

View File

@@ -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,
},

View File

@@ -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) {}
}

View File

@@ -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,

View File

@@ -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)

View File

@@ -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()
client := clientFromContext(ctx)
clients := clientsFromContext(ctx)
_, err = client.SetFanSpeed(ctx, &bladeapiv1alpha1.SetFanSpeedRequest{
Percent: int64(percent),
})
for _, client := range clients {
var err error
return err
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()
clients := clientsFromContext(ctx)
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
}
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()
}

View File

@@ -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
},
}
)

View File

@@ -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())
}
// 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())
}
}
}
return nil
},
}
// 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(),
)
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)
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())
}
}
return nil
},
}
// Check if we should wait for the identify state to be confirmed
if !wait {
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
},
}
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'")
}
return nil
}
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
}
)

169
cmd/bladectl/cmd_monitor.go Normal file
View File

@@ -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", "<C-c>":
return nil
case "<Resize>":
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)
}
}

View File

@@ -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()
// Load potential file configs
if err := viper.ReadInConfig(); err != nil {
return err
}
ctx, cancelCtx := context.WithCancelCause(cmd.Context())
// load configuration
var bladectlCfg config.BladectlConfig
if err := viper.ReadInConfig(); err != nil {
cancelCtx(err)
return err
}
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
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)
}
}
if credentials, err = loadTlsCredentials(serverName, certData); err != nil {
return err
// Allow to easily select all blades
if allBlades {
bladeNames = make([]string, len(bladectlCfg.Blades))
for idx, blade := range bladectlCfg.Blades {
bladeNames[idx] = blade.Name
}
}
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 := 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())
}
client, herr := buildClient(blade)
if herr != nil {
cancelCtx(herr)
return errors.New(herr.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
}

View File

@@ -0,0 +1,77 @@
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/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)),
okStyle().Render(hal.PowerStatus(status.PowerStatus).String()),
}
_ = tbl.Append(row)
}
_ = tbl.Render()
}

View File

@@ -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
},
}
)

View File

@@ -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",

View File

@@ -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(".", "_"))

83
cmd/bladectl/util.go Normal file
View File

@@ -0,0 +1,83 @@
package main
import (
"fmt"
"github.com/charmbracelet/lipgloss"
)
const (
ColorCritical = lipgloss.Color("#cc0000")
ColorWarning = lipgloss.Color("#e69138")
ColorOk = lipgloss.Color("#04B575")
)
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(ColorOk)
}
return lipgloss.NewStyle().Foreground(ColorCritical)
}
func activeStyle(active bool) lipgloss.Style {
if active {
return lipgloss.NewStyle().Foreground(ColorCritical)
}
return lipgloss.NewStyle().Foreground(ColorOk)
}
func tempStyle(temp int64, criticalTemp int64) lipgloss.Style {
color := ColorOk
if temp >= criticalTemp {
color = ColorCritical
} else if temp >= criticalTemp-10 {
color = ColorWarning
}
return lipgloss.NewStyle().Foreground(color)
}
func rpmStyle(rpm int64) lipgloss.Style {
color := ColorOk
if rpm > 6000 {
color = ColorCritical
} else if rpm > 5250 {
color = ColorWarning
}
return lipgloss.NewStyle().Foreground(color)
}
func okStyle() lipgloss.Style {
return lipgloss.NewStyle().Foreground(ColorOk)
}

22
go.mod
View File

@@ -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

47
go.sum
View File

@@ -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=

View File

@@ -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")
return a.blade.Close()
}
// 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)
}
}
// 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()
}
}
// 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")
}
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")
}
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:
}
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)
// Reset stealth mode
if err := a.blade.SetStealthMode(a.opts.StealthModeEnabled); err != nil {
return 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
// 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)
}
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 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
func (a *computeBladeAgent) runEdgeLedEngine(ctx context.Context, cancel context.CancelCauseFunc) {
log.FromContext(ctx).Info("Starting edge LED engine")
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)
}
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.edgeLedEngine.Run(ctx)
}
func (a *computeBladeAgentImpl) runFanController(ctx context.Context) error {
// 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")
// 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)
}
}

149
internal/agent/api.go Normal file
View File

@@ -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.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)
}

View File

@@ -1,4 +1,4 @@
package api
package internal_agent
import (
"context"

104
internal/agent/handler.go Normal file
View File

@@ -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
}

30
internal/agent/options.go Normal file
View File

@@ -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)
}

25
internal/agent/utils.go Normal file
View File

@@ -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")
}
}

View File

@@ -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")
}

View File

@@ -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"`
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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"`

View File

@@ -10,7 +10,14 @@ 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
// 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
@@ -60,14 +67,18 @@ func NewLinearFanController(config Config) (FanController, humane.Error) {
}, nil
}
func (f *fanControllerLinear) Steps() []Step {
return f.config.Steps
}
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 +101,7 @@ func (f *fanControllerLinear) GetFanSpeed(temperature float64) uint8 {
return uint8(speed)
}
func (f *fanControllerLinear) IsAutomaticSpeed() bool {
return f.overrideOpts == nil
}

View File

@@ -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.GetFanSpeed(temperature)
if speed != expected {
t.Errorf("For temperature %.2f, expected speed %d but got %d", temperature, expected, speed)
}
speed := controller.GetFanSpeedPercent(temperature)
assert.Equal(t, expected, speed)
assert.True(t, controller.IsAutomaticSpeed(), "Expected fan speed to be automatic, but it was not")
})
}
}
@@ -75,10 +77,9 @@ func TestFanControllerLinear_GetFanSpeedWithOverride(t *testing.T) {
temperature := tc.temperature
t.Run("", func(t *testing.T) {
t.Parallel()
speed := controller.GetFanSpeed(temperature)
if speed != expected {
t.Errorf("For temperature %.2f, expected speed %d but got %d", temperature, expected, speed)
}
speed := controller.GetFanSpeedPercent(temperature)
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)
})
}
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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()
@@ -131,8 +142,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 +189,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 +231,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,
@@ -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")
}

View File

@@ -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

72
pkg/util/clock_test.go Normal file
View File

@@ -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)
}

View File

@@ -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")
}

18
pkg/util/host_ips_test.go Normal file
View File

@@ -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")
}
}

View File

@@ -15,5 +15,8 @@
"automerge": true,
"automergeType": "branch"
}
],
"ignoreDeps": [
"github.com/warthog618/gpiod"
]
}