Files
pulse/pkg/diskinventory/status.go
T
rcourtman 95b29286dd Stop serving retained disk temperatures as current readings
Normalization may keep a disk temperature it did not collect (a disk in
standby, a host agent past its reporting lease) as long as its collection
state says so. Two consumers still presented that value as current.

The metrics-history handler answered an empty disk range with the disk's
temperature as a "live" point at now, regardless of its state. Since the
history writers stopped recording retained temperatures, a short-range
chart for a silent agent's disk or a sleeping disk showed the retained
value as a fresh reading. Mock mode also seeded synthetic series from it
and padded sparse stored series out to now. All of these now require a
temperature collected now (diskinventory.TemperatureCollected, the
shared form of the rule the history writers apply).

Unraid array-inventory rows carry no per-field provenance, so a
temperature taken from one looked collected after the agent went silent.
The adapter now derives its state from the host lease that
ExpireHostTelemetry already records, and from the disk's spin state:
unavailable once the host is offline or the disk is spun down. A SMART
row that borrows the inventory reading takes that state, including
available. The native row records only the withdrawing states, because
the registry merge picks temperature and state separately and an
available claim from it would mask the SMART row's own state. A
withdrawn inventory temperature no longer counts as heat in disk risk.
2026-10-06 20:16:38 +01:00

147 lines
5.1 KiB
Go

package diskinventory
import "strings"
// FieldState describes whether a physical-disk field was observed during the
// current collection pass. It deliberately separates provider limitations
// from transient collection failures and from fields that should have been
// present but were not.
type FieldState string
const (
FieldAvailable FieldState = "available"
FieldUnavailable FieldState = "unavailable"
FieldUnsupported FieldState = "unsupported"
FieldMissing FieldState = "missing"
)
// FieldStatus carries collection state and provenance for one disk signal.
type FieldStatus struct {
State FieldState `json:"state"`
Source string `json:"source,omitempty"`
Reason string `json:"reason,omitempty"`
}
// CollectionStatus is the field-level payload carried by collection evidence;
// it is not a separate trust posture or mutation lifecycle. Operational trust
// may wrap this payload in its shared EvidenceEnvelope, while Collection Trust
// remains responsible for evaluating the observation. Empty means the report
// predates this contract.
type CollectionStatus struct {
Serial FieldStatus `json:"serial,omitempty"`
Temperature FieldStatus `json:"temperature,omitempty"`
IO FieldStatus `json:"io,omitempty"`
Controller FieldStatus `json:"controller,omitempty"`
Pool FieldStatus `json:"pool,omitempty"`
}
func Available(source string) FieldStatus {
return FieldStatus{State: FieldAvailable, Source: strings.TrimSpace(source)}
}
func Unavailable(source, reason string) FieldStatus {
return FieldStatus{
State: FieldUnavailable,
Source: strings.TrimSpace(source),
Reason: strings.TrimSpace(reason),
}
}
func Unsupported(source, reason string) FieldStatus {
return FieldStatus{
State: FieldUnsupported,
Source: strings.TrimSpace(source),
Reason: strings.TrimSpace(reason),
}
}
func Missing(source, reason string) FieldStatus {
return FieldStatus{
State: FieldMissing,
Source: strings.TrimSpace(source),
Reason: strings.TrimSpace(reason),
}
}
func CloneStatus(status *CollectionStatus) *CollectionStatus {
if status == nil {
return nil
}
clone := *status
return &clone
}
// TemperatureCollected reports whether a disk temperature was collected by the
// observation that carries it. Normalization may keep a last-known temperature
// it did not collect (a disk in standby, a host agent past its reporting
// lease) under a non-available state; that value must not be recorded or
// presented as a current reading. A temperature without collection state
// predates this contract and counts as collected.
func TemperatureCollected(temperature int, status *CollectionStatus) bool {
if temperature <= 0 {
return false
}
if status == nil {
return true
}
state := status.Temperature.State
return state == "" || state == FieldAvailable
}
// MergeStatus keeps an available observation over a weaker state while still
// allowing a current available observation to replace older provenance.
func MergeStatus(existing, incoming *CollectionStatus) *CollectionStatus {
if existing == nil {
return CloneStatus(incoming)
}
if incoming == nil {
return CloneStatus(existing)
}
merged := *existing
merged.Serial = mergeFieldStatus(merged.Serial, incoming.Serial)
merged.Temperature = mergeFieldStatus(merged.Temperature, incoming.Temperature)
merged.IO = mergeFieldStatus(merged.IO, incoming.IO)
merged.Controller = mergeFieldStatus(merged.Controller, incoming.Controller)
merged.Pool = mergeFieldStatus(merged.Pool, incoming.Pool)
return &merged
}
// MergeReportedStatus merges a source's own report into existing state that
// may still hold an earlier copy of that report. It behaves like MergeStatus,
// except that a field the report no longer marks available supersedes an
// available state that existing carries from the same source: the source's
// later word on its own field wins, so a stale copy cannot keep claiming the
// field was collected.
func MergeReportedStatus(existing, reported *CollectionStatus) *CollectionStatus {
merged := MergeStatus(existing, reported)
if existing == nil || reported == nil {
return merged
}
merged.Serial = supersedeFieldStatus(merged.Serial, existing.Serial, reported.Serial)
merged.Temperature = supersedeFieldStatus(merged.Temperature, existing.Temperature, reported.Temperature)
merged.IO = supersedeFieldStatus(merged.IO, existing.IO, reported.IO)
merged.Controller = supersedeFieldStatus(merged.Controller, existing.Controller, reported.Controller)
merged.Pool = supersedeFieldStatus(merged.Pool, existing.Pool, reported.Pool)
return merged
}
func supersedeFieldStatus(merged, existing, reported FieldStatus) FieldStatus {
source := strings.TrimSpace(existing.Source)
if existing.State == FieldAvailable &&
reported.State != "" && reported.State != FieldAvailable &&
source != "" && strings.EqualFold(source, strings.TrimSpace(reported.Source)) {
return reported
}
return merged
}
func mergeFieldStatus(existing, incoming FieldStatus) FieldStatus {
if incoming.State == "" {
return existing
}
if existing.State == FieldAvailable && incoming.State != FieldAvailable {
return existing
}
return incoming
}