mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
fix(truenas): reject synthesised CPU temperature aggregate
TrueNAS SCALE on AMD reports the bare `cpu` cputemp legend series at roughly 1.5x the hottest per-core reading, and the provider trusted that mapped `cpu_package` value unconditionally, firing an 80C threshold alert at a real ~60C. Compare `cpu_package` against the hottest `cpu*` per-core entry and fall back to the per-core max only when the package value exceeds it by more than 20%. Package-only and per-core-only payloads keep their existing behaviour. Adds focused provider coverage and records the selection contract for #2122. Change-source: pulse-maintainer
This commit is contained in:
@@ -4120,3 +4120,23 @@ still recorded. Focused proof lives in
|
||||
(`TestMergeHostAgentSMARTIntoDisks_AgentWearoutDoesNotHideLowPVELife` and
|
||||
`TestMergeHostAgentSMARTIntoDisks_AgentWearoutFillsUnreportedPVELife`). This is
|
||||
synthetic merge evidence, not reporter acceptance or hardware confirmation.
|
||||
|
||||
### TrueNAS system temperature rejects a synthesised CPU aggregate
|
||||
|
||||
TrueNAS reports the `cputemp` series through a bare `cpu` legend entry, which
|
||||
`canonicalSystemTemperatureKey` maps to the canonical `cpu_package` key. On
|
||||
SCALE 25.10.7 running AMD hardware that aggregate has been observed at roughly
|
||||
1.5x the hottest per-core reading (for example an 88C aggregate against a 59C
|
||||
core max, with the cores matching `k10temp`/`Tctl`). Monitoring previously
|
||||
trusted `cpu_package` unconditionally in `maxTrueNASSystemTemperature`, so the
|
||||
false-high value reached the host temperature and fired a configured 80C
|
||||
threshold alert at a real ~60C. The selector now compares `cpu_package` with
|
||||
the hottest `cpu*` per-core entry and falls back to the per-core max only when
|
||||
the package value exceeds it by more than 20%. A real package sensor stays
|
||||
within a few degrees of the hottest core, so the margin retains genuine
|
||||
package readings while discarding the synthesised aggregate. Package-only
|
||||
payloads and per-core-only payloads keep their existing behaviour. Focused
|
||||
proof lives in `internal/truenas/provider_test.go`
|
||||
(`TestMaxTrueNASSystemTemperaturePrefersCoresOverSynthesisedAggregate`). This
|
||||
is source-level proof, not reporter acceptance or appliance confirmation
|
||||
(#2122).
|
||||
|
||||
@@ -1032,31 +1032,49 @@ func agentDataFromTrueNASSystem(connectionID string, system SystemInfo, disks []
|
||||
return agent
|
||||
}
|
||||
|
||||
// trueNASCPUPackageOutlierRatio bounds how far the aggregated `cpu_package`
|
||||
// reading may exceed the hottest individual core before Pulse treats it as a
|
||||
// synthesised appliance aggregate. TrueNAS SCALE on AMD has been observed to
|
||||
// report a bare `cpu` legend series roughly 1.5x the per-core values, while a
|
||||
// real package sensor (for example Intel "Package id 0") stays within a few
|
||||
// degrees of the hottest core. See #2122.
|
||||
const trueNASCPUPackageOutlierRatio = 1.2
|
||||
|
||||
func maxTrueNASSystemTemperature(system SystemInfo) *float64 {
|
||||
if len(system.TemperatureCelsius) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
packageValue := 0.0
|
||||
hasPackage := false
|
||||
if value, ok := system.TemperatureCelsius["cpu_package"]; ok && value > 0 {
|
||||
canonical := value
|
||||
return &canonical
|
||||
packageValue = value
|
||||
hasPackage = true
|
||||
}
|
||||
|
||||
var best float64
|
||||
found := false
|
||||
var coreMax float64
|
||||
hasCore := false
|
||||
for key, value := range system.TemperatureCelsius {
|
||||
key = strings.TrimSpace(strings.ToLower(key))
|
||||
if value <= 0 || !strings.HasPrefix(key, "cpu") {
|
||||
if value <= 0 || key == "cpu_package" || !strings.HasPrefix(key, "cpu") {
|
||||
continue
|
||||
}
|
||||
if !found || value > best {
|
||||
best = value
|
||||
found = true
|
||||
if !hasCore || value > coreMax {
|
||||
coreMax = value
|
||||
hasCore = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
|
||||
if hasPackage {
|
||||
if hasCore && packageValue > coreMax*trueNASCPUPackageOutlierRatio {
|
||||
return &coreMax
|
||||
}
|
||||
return &packageValue
|
||||
}
|
||||
if !hasCore {
|
||||
return nil
|
||||
}
|
||||
return &best
|
||||
return &coreMax
|
||||
}
|
||||
|
||||
func sensorMetaFromTrueNASSystem(system SystemInfo, disks []Disk) *unifiedresources.HostSensorMeta {
|
||||
|
||||
@@ -1434,3 +1434,74 @@ func TestProviderSystemMetricHistoryUsesCanonicalAgentMetricIDs(t *testing.T) {
|
||||
t.Fatalf("expected netin history points, got %+v", history)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxTrueNASSystemTemperaturePrefersCoresOverSynthesisedAggregate(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
temps map[string]float64
|
||||
want *float64
|
||||
}{
|
||||
{
|
||||
name: "synthesised cpu aggregate is ignored in favour of per-core max",
|
||||
temps: map[string]float64{
|
||||
"cpu0": 59, "cpu1": 58, "cpu2": 57, "cpu3": 56,
|
||||
"cpu4": 55, "cpu5": 54, "cpu6": 53, "cpu7": 52,
|
||||
"cpu_package": 88,
|
||||
},
|
||||
want: floatPtr(59),
|
||||
},
|
||||
{
|
||||
name: "real package reading near the hottest core is retained",
|
||||
temps: map[string]float64{"cpu_package": 61.5, "cpu_core_0": 58.0, "cpu_core_1": 59.0},
|
||||
want: floatPtr(61.5),
|
||||
},
|
||||
{
|
||||
name: "package within the margin is retained even with one core present",
|
||||
temps: map[string]float64{"cpu_package": 60, "cpu0": 55},
|
||||
want: floatPtr(60),
|
||||
},
|
||||
{
|
||||
name: "package exactly at the margin is retained",
|
||||
temps: map[string]float64{"cpu_package": 66, "cpu0": 55},
|
||||
want: floatPtr(66),
|
||||
},
|
||||
{
|
||||
name: "package-only payload is unchanged",
|
||||
temps: map[string]float64{"cpu_package": 55},
|
||||
want: floatPtr(55),
|
||||
},
|
||||
{
|
||||
name: "per-core-only payload still reports the hottest core",
|
||||
temps: map[string]float64{"cpu0": 41, "cpu1": 47, "cpu2": 44},
|
||||
want: floatPtr(47),
|
||||
},
|
||||
{
|
||||
name: "empty payload has no temperature",
|
||||
temps: nil,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "non-positive readings are ignored",
|
||||
temps: map[string]float64{"cpu_package": 0, "cpu0": 0},
|
||||
want: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := maxTrueNASSystemTemperature(SystemInfo{TemperatureCelsius: tc.temps})
|
||||
switch {
|
||||
case tc.want == nil && got != nil:
|
||||
t.Fatalf("temperature = %v, want nil", *got)
|
||||
case tc.want != nil && got == nil:
|
||||
t.Fatalf("temperature = nil, want %v", *tc.want)
|
||||
case tc.want != nil && got != nil && *got != *tc.want:
|
||||
t.Fatalf("temperature = %v, want %v", *got, *tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func floatPtr(value float64) *float64 {
|
||||
return &value
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user