Files
ITWorx-Pulse-Public/internal/host/hardware.go
T
ITWorx Pulse release export bd774932d5
Public source validation / validate (push) Failing after 3m8s
Publish ITWorx Pulse source
2026-09-03 02:09:19 +02:00

301 lines
10 KiB
Go

package host
import (
"context"
"errors"
"fmt"
"math"
"sort"
"strings"
)
const (
CapabilityThermal = "host.thermal"
CapabilityFans = "host.fans"
CapabilityGPU = "host.gpu"
)
type HardwareLimits struct {
MaxTemperatures int
MaxFans int
MaxGPUs int
MaxCapabilities int
}
func (l HardwareLimits) withDefaults() HardwareLimits {
if l.MaxTemperatures == 0 {
l.MaxTemperatures = 256
}
if l.MaxFans == 0 {
l.MaxFans = 256
}
if l.MaxGPUs == 0 {
l.MaxGPUs = 16
}
if l.MaxCapabilities == 0 {
l.MaxCapabilities = 32
}
return l
}
func (l HardwareLimits) Validate() error {
if l.MaxTemperatures < 1 || l.MaxTemperatures > 512 || l.MaxFans < 1 || l.MaxFans > 512 || l.MaxGPUs < 1 || l.MaxGPUs > 64 || l.MaxCapabilities < 1 || l.MaxCapabilities > 100 {
return errors.New("hardware limits are outside safe bounds")
}
return nil
}
type ThermalPolicy struct {
AttentionCelsius float64
CriticalCelsius float64
}
func (p ThermalPolicy) withDefaults() ThermalPolicy {
if p.AttentionCelsius == 0 {
p.AttentionCelsius = 75
}
if p.CriticalCelsius == 0 {
p.CriticalCelsius = 85
}
return p
}
func (p ThermalPolicy) Validate() error {
if p.AttentionCelsius <= 0 || p.CriticalCelsius <= p.AttentionCelsius || p.CriticalCelsius > 150 {
return errors.New("thermal policy is outside safe bounds")
}
return nil
}
type Capability struct {
ID string `json:"id"`
Version string `json:"version"`
State string `json:"state"`
Reason string `json:"reason,omitempty"`
}
type RawTemperature struct {
ID string `json:"id,omitempty"`
Name string `json:"name"`
Celsius float64 `json:"celsius"`
}
type Temperature struct {
ID string `json:"id"`
Name string `json:"name"`
Celsius float64 `json:"celsius"`
}
type RawFan struct {
ID string `json:"id,omitempty"`
Name string `json:"name"`
RPM int `json:"rpm"`
}
type Fan struct {
ID string `json:"id"`
Name string `json:"name"`
RPM int `json:"rpm"`
}
type RawGPU struct {
ID string `json:"id,omitempty"`
Name string `json:"name"`
Vendor string `json:"vendor,omitempty"`
Utilization *float64 `json:"utilizationPercent,omitempty"`
MemoryUsedBytes uint64 `json:"memoryUsedBytes,omitempty"`
MemoryTotalBytes uint64 `json:"memoryTotalBytes,omitempty"`
TemperatureCelsius *float64 `json:"temperatureCelsius,omitempty"`
}
type GPU struct {
ID string `json:"id"`
Name string `json:"name"`
Vendor string `json:"vendor,omitempty"`
Utilization *float64 `json:"utilizationPercent,omitempty"`
MemoryUsedBytes uint64 `json:"memoryUsedBytes,omitempty"`
MemoryTotalBytes uint64 `json:"memoryTotalBytes,omitempty"`
MemoryUtilizationPercent float64 `json:"memoryUtilizationPercent,omitempty"`
TemperatureCelsius *float64 `json:"temperatureCelsius,omitempty"`
}
type RawHardware struct {
Capabilities []Capability `json:"capabilities"`
Temperatures []RawTemperature `json:"temperatures"`
Fans []RawFan `json:"fans"`
GPUs []RawGPU `json:"gpus"`
}
type HardwareStatus struct {
State string `json:"state"`
Reasons []StatusReason `json:"reasons,omitempty"`
}
type HardwareSnapshot struct {
Capabilities []Capability `json:"capabilities"`
Temperatures []Temperature `json:"temperatures"`
Fans []Fan `json:"fans"`
GPUs []GPU `json:"gpus"`
Status HardwareStatus `json:"status"`
}
type HardwareSource interface {
Hardware(context.Context) (RawHardware, error)
}
type HardwareAdapter struct {
Source HardwareSource
Limits HardwareLimits
Policy ThermalPolicy
}
func (a HardwareAdapter) Snapshot(ctx context.Context, sourceID string) (HardwareSnapshot, error) {
if err := ctx.Err(); err != nil {
return HardwareSnapshot{}, err
}
if a.Source == nil {
return DisabledHardware(), nil
}
raw, err := a.Source.Hardware(ctx)
if err != nil {
return HardwareSnapshot{}, err
}
return NormalizeHardware(raw, sourceID, a.Limits, a.Policy)
}
func DisabledHardware() HardwareSnapshot {
capabilities := []Capability{
{ID: CapabilityThermal, Version: ContractVersion, State: "disabled", Reason: "capability_absent"},
{ID: CapabilityFans, Version: ContractVersion, State: "disabled", Reason: "capability_absent"},
{ID: CapabilityGPU, Version: ContractVersion, State: "disabled", Reason: "capability_absent"},
}
return HardwareSnapshot{Capabilities: capabilities, Status: HardwareStatus{State: StatusUnknown, Reasons: []StatusReason{{Code: "hardware_capability_absent", Message: "Optionele hardwaretelemetrie is niet beschikbaar."}}}}
}
func NormalizeHardware(raw RawHardware, sourceID string, limits HardwareLimits, policy ThermalPolicy) (HardwareSnapshot, error) {
limits = limits.withDefaults()
policy = policy.withDefaults()
if err := limits.Validate(); err != nil {
return HardwareSnapshot{}, err
}
if err := policy.Validate(); err != nil {
return HardwareSnapshot{}, err
}
if len(raw.Temperatures) > limits.MaxTemperatures || len(raw.Fans) > limits.MaxFans || len(raw.GPUs) > limits.MaxGPUs || len(raw.Capabilities) > limits.MaxCapabilities {
return HardwareSnapshot{}, errors.New("hardware collection exceeds bounds")
}
capabilities := normalizeCapabilities(raw.Capabilities)
for _, required := range []string{CapabilityThermal, CapabilityFans, CapabilityGPU} {
if _, ok := capabilityByID(capabilities, required); !ok {
capabilities = append(capabilities, Capability{ID: required, Version: ContractVersion, State: "disabled", Reason: "capability_absent"})
}
}
sort.Slice(capabilities, func(i, j int) bool { return capabilities[i].ID < capabilities[j].ID })
temperatures := make([]Temperature, 0, len(raw.Temperatures))
for _, item := range raw.Temperatures {
if err := validateName(item.Name, 128); err != nil || math.IsNaN(item.Celsius) || math.IsInf(item.Celsius, 0) || item.Celsius < -100 || item.Celsius > 150 {
return HardwareSnapshot{}, errors.New("invalid temperature sensor")
}
temperatures = append(temperatures, Temperature{ID: stableSensorID(sourceID, "temperature", item.ID, item.Name), Name: item.Name, Celsius: item.Celsius})
}
sort.Slice(temperatures, func(i, j int) bool { return temperatures[i].ID < temperatures[j].ID })
fans := make([]Fan, 0, len(raw.Fans))
for _, item := range raw.Fans {
if err := validateName(item.Name, 128); err != nil || item.RPM < 0 || item.RPM > 100000 {
return HardwareSnapshot{}, errors.New("invalid fan sensor")
}
fans = append(fans, Fan{ID: stableSensorID(sourceID, "fan", item.ID, item.Name), Name: item.Name, RPM: item.RPM})
}
sort.Slice(fans, func(i, j int) bool { return fans[i].ID < fans[j].ID })
gpus := make([]GPU, 0, len(raw.GPUs))
for _, item := range raw.GPUs {
if err := validateName(item.Name, 128); err != nil {
return HardwareSnapshot{}, errors.New("invalid GPU name")
}
if item.Utilization != nil && (*item.Utilization < 0 || *item.Utilization > 100 || math.IsNaN(*item.Utilization) || math.IsInf(*item.Utilization, 0)) {
return HardwareSnapshot{}, errors.New("invalid GPU utilization")
}
if item.MemoryTotalBytes > 0 && item.MemoryUsedBytes > item.MemoryTotalBytes {
return HardwareSnapshot{}, errors.New("invalid GPU memory")
}
if item.TemperatureCelsius != nil && (*item.TemperatureCelsius < -100 || *item.TemperatureCelsius > 150 || math.IsNaN(*item.TemperatureCelsius) || math.IsInf(*item.TemperatureCelsius, 0)) {
return HardwareSnapshot{}, errors.New("invalid GPU temperature")
}
gpu := GPU{ID: stableSensorID(sourceID, "gpu", item.ID, item.Name), Name: item.Name, Vendor: item.Vendor, Utilization: cloneFloat(item.Utilization), MemoryUsedBytes: item.MemoryUsedBytes, MemoryTotalBytes: item.MemoryTotalBytes, TemperatureCelsius: cloneFloat(item.TemperatureCelsius)}
if item.MemoryTotalBytes > 0 {
gpu.MemoryUtilizationPercent = float64(item.MemoryUsedBytes) / float64(item.MemoryTotalBytes) * 100
}
gpus = append(gpus, gpu)
}
sort.Slice(gpus, func(i, j int) bool { return gpus[i].ID < gpus[j].ID })
status := evaluateHardwareStatus(temperatures, gpus, policy)
return HardwareSnapshot{Capabilities: capabilities, Temperatures: temperatures, Fans: fans, GPUs: gpus, Status: status}, nil
}
func evaluateHardwareStatus(temperatures []Temperature, gpus []GPU, policy ThermalPolicy) HardwareStatus {
result := HardwareStatus{State: StatusHealthy}
max := -math.MaxFloat64
sensor := ""
for _, item := range temperatures {
if item.Celsius > max {
max = item.Celsius
sensor = item.Name
}
}
for _, item := range gpus {
if item.TemperatureCelsius != nil && *item.TemperatureCelsius > max {
max = *item.TemperatureCelsius
sensor = item.Name
}
}
if sensor == "" {
return HardwareStatus{State: StatusUnknown, Reasons: []StatusReason{{Code: "thermal_data_absent", Message: "Er is geen betrouwbare temperatuursensor beschikbaar."}}}
}
if max >= policy.CriticalCelsius {
return HardwareStatus{State: "critical", Reasons: []StatusReason{{Code: "thermal_critical", Message: fmt.Sprintf("Temperatuur %.1f °C bij %s overschrijdt de kritieke grens.", max, sensor)}}}
}
if max >= policy.AttentionCelsius {
return HardwareStatus{State: StatusDegraded, Reasons: []StatusReason{{Code: "thermal_attention", Message: fmt.Sprintf("Temperatuur %.1f °C bij %s vraagt aandacht.", max, sensor)}}}
}
return result
}
func normalizeCapabilities(values []Capability) []Capability {
result := make([]Capability, 0, len(values))
seen := map[string]bool{}
for _, value := range values {
if value.ID == "" || seen[value.ID] {
continue
}
if value.Version == "" {
value.Version = ContractVersion
}
if value.State == "" {
value.State = "unavailable"
}
seen[value.ID] = true
result = append(result, value)
}
return result
}
func capabilityByID(values []Capability, id string) (Capability, bool) {
for _, value := range values {
if value.ID == id {
return value, true
}
}
return Capability{}, false
}
func validateName(value string, max int) error {
if strings.TrimSpace(value) == "" || len(value) > max {
return errors.New("name is empty or too long")
}
return nil
}
func stableSensorID(sourceID, kind, externalID, name string) string {
identity := strings.TrimSpace(externalID)
if identity == "" {
identity = strings.TrimSpace(name)
}
identity = strings.ToLower(strings.TrimSpace(identity))
identity = strings.NewReplacer(" ", "-", "/", "-", "\\", "-", ":", "-").Replace(identity)
return strings.ToLower(strings.TrimSpace(sourceID)) + "/" + kind + "/" + identity
}