This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
package disk
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RawPerformanceSample struct {
|
||||
ObservedAt time.Time `json:"observedAt"`
|
||||
ReadBytesPerSecond *float64 `json:"readBytesPerSecond,omitempty"`
|
||||
WriteBytesPerSecond *float64 `json:"writeBytesPerSecond,omitempty"`
|
||||
ReadIOPS *float64 `json:"readIops,omitempty"`
|
||||
WriteIOPS *float64 `json:"writeIops,omitempty"`
|
||||
ReadLatencyMs *float64 `json:"readLatencyMs,omitempty"`
|
||||
WriteLatencyMs *float64 `json:"writeLatencyMs,omitempty"`
|
||||
}
|
||||
type PerformanceSample struct {
|
||||
ObservedAt time.Time `json:"observedAt"`
|
||||
ReadBytesPerSecond *float64 `json:"readBytesPerSecond,omitempty"`
|
||||
WriteBytesPerSecond *float64 `json:"writeBytesPerSecond,omitempty"`
|
||||
ReadIOPS *float64 `json:"readIops,omitempty"`
|
||||
WriteIOPS *float64 `json:"writeIops,omitempty"`
|
||||
ReadLatencyMs *float64 `json:"readLatencyMs,omitempty"`
|
||||
WriteLatencyMs *float64 `json:"writeLatencyMs,omitempty"`
|
||||
}
|
||||
type RawPerformance struct {
|
||||
Available bool `json:"available"`
|
||||
Current RawPerformanceSample `json:"current"`
|
||||
History []RawPerformanceSample `json:"history,omitempty"`
|
||||
}
|
||||
type Performance struct {
|
||||
State string `json:"state"`
|
||||
Current PerformanceSample `json:"current"`
|
||||
History []PerformanceSample `json:"history,omitempty"`
|
||||
}
|
||||
type RawTemperature struct {
|
||||
Available bool `json:"available"`
|
||||
Celsius float64 `json:"celsius"`
|
||||
ObservedAt time.Time `json:"observedAt"`
|
||||
}
|
||||
type Temperature struct {
|
||||
State string `json:"state"`
|
||||
Celsius *float64 `json:"celsius,omitempty"`
|
||||
Status string `json:"status"`
|
||||
ObservedAt *time.Time `json:"observedAt,omitempty"`
|
||||
}
|
||||
type RawSpin struct {
|
||||
Supported bool `json:"supported"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
type Spin struct {
|
||||
State string `json:"state"`
|
||||
}
|
||||
type PerformancePolicy struct {
|
||||
MaxHistory int
|
||||
TemperatureWarningCelsius float64
|
||||
TemperatureCriticalCelsius float64
|
||||
TemperatureRecoveryCelsius float64
|
||||
}
|
||||
|
||||
func (p PerformancePolicy) withDefaults() PerformancePolicy {
|
||||
if p.MaxHistory == 0 {
|
||||
p.MaxHistory = 120
|
||||
}
|
||||
if p.TemperatureWarningCelsius == 0 {
|
||||
p.TemperatureWarningCelsius = 50
|
||||
}
|
||||
if p.TemperatureCriticalCelsius == 0 {
|
||||
p.TemperatureCriticalCelsius = 55
|
||||
}
|
||||
if p.TemperatureRecoveryCelsius == 0 {
|
||||
p.TemperatureRecoveryCelsius = 45
|
||||
}
|
||||
return p
|
||||
}
|
||||
func (p PerformancePolicy) Validate() error {
|
||||
if p.MaxHistory < 1 || p.MaxHistory > 1000 || p.TemperatureRecoveryCelsius >= p.TemperatureWarningCelsius || p.TemperatureWarningCelsius >= p.TemperatureCriticalCelsius {
|
||||
return errors.New("disk performance policy is outside safe bounds")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func normalizePerformance(raw *RawPerformance, now time.Time, policy PerformancePolicy) (*Performance, error) {
|
||||
if raw == nil {
|
||||
return nil, nil
|
||||
}
|
||||
policy = policy.withDefaults()
|
||||
if err := policy.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &Performance{State: "unknown", History: []PerformanceSample{}}
|
||||
if !raw.Available {
|
||||
result.State = "unsupported"
|
||||
return result, nil
|
||||
}
|
||||
if len(raw.History) > policy.MaxHistory {
|
||||
return nil, errors.New("disk performance history exceeds bounds")
|
||||
}
|
||||
current, err := normalizeSample(raw.Current, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.State = "available"
|
||||
result.Current = current
|
||||
for _, item := range raw.History {
|
||||
sample, sampleErr := normalizeSample(item, now)
|
||||
if sampleErr != nil {
|
||||
return nil, sampleErr
|
||||
}
|
||||
result.History = append(result.History, sample)
|
||||
}
|
||||
sort.Slice(result.History, func(i, j int) bool { return result.History[i].ObservedAt.After(result.History[j].ObservedAt) })
|
||||
return result, nil
|
||||
}
|
||||
func normalizeSample(raw RawPerformanceSample, now time.Time) (PerformanceSample, error) {
|
||||
observed := raw.ObservedAt
|
||||
if observed.IsZero() {
|
||||
observed = now
|
||||
}
|
||||
if observed.After(now.Add(time.Minute)) {
|
||||
return PerformanceSample{}, errors.New("disk performance observation is materially in the future")
|
||||
}
|
||||
for _, value := range []*float64{raw.ReadBytesPerSecond, raw.WriteBytesPerSecond, raw.ReadIOPS, raw.WriteIOPS, raw.ReadLatencyMs, raw.WriteLatencyMs} {
|
||||
if value != nil && (*value < 0 || math.IsNaN(*value) || math.IsInf(*value, 0)) {
|
||||
return PerformanceSample{}, errors.New("disk performance value is invalid")
|
||||
}
|
||||
}
|
||||
return PerformanceSample{ObservedAt: observed.UTC(), ReadBytesPerSecond: raw.ReadBytesPerSecond, WriteBytesPerSecond: raw.WriteBytesPerSecond, ReadIOPS: raw.ReadIOPS, WriteIOPS: raw.WriteIOPS, ReadLatencyMs: raw.ReadLatencyMs, WriteLatencyMs: raw.WriteLatencyMs}, nil
|
||||
}
|
||||
func normalizeTemperature(raw *RawTemperature, now time.Time, policy PerformancePolicy) (*Temperature, error) {
|
||||
if raw == nil {
|
||||
return nil, nil
|
||||
}
|
||||
result := &Temperature{State: "unknown", Status: "unknown"}
|
||||
if !raw.Available {
|
||||
return result, nil
|
||||
}
|
||||
if raw.Celsius < -50 || raw.Celsius > 150 || math.IsNaN(raw.Celsius) || math.IsInf(raw.Celsius, 0) {
|
||||
return nil, errors.New("disk temperature is invalid")
|
||||
}
|
||||
observed := raw.ObservedAt
|
||||
if observed.IsZero() {
|
||||
observed = now
|
||||
}
|
||||
if observed.After(now.Add(time.Minute)) {
|
||||
return nil, errors.New("disk temperature observation is materially in the future")
|
||||
}
|
||||
result.State = "available"
|
||||
result.Celsius = &raw.Celsius
|
||||
value := observed.UTC()
|
||||
result.ObservedAt = &value
|
||||
result.Status = temperatureStatus("normal", raw.Celsius, policy)
|
||||
return result, nil
|
||||
}
|
||||
func temperatureStatus(previous string, celsius float64, policy PerformancePolicy) string {
|
||||
policy = policy.withDefaults()
|
||||
if celsius >= policy.TemperatureCriticalCelsius {
|
||||
return "critical"
|
||||
}
|
||||
if celsius >= policy.TemperatureWarningCelsius {
|
||||
return "attention"
|
||||
}
|
||||
if (previous == "attention" || previous == "critical") && celsius >= policy.TemperatureRecoveryCelsius {
|
||||
return previous
|
||||
}
|
||||
return "normal"
|
||||
}
|
||||
func TemperatureStatus(previous string, celsius float64, policy PerformancePolicy) string {
|
||||
return temperatureStatus(strings.ToLower(previous), celsius, policy)
|
||||
}
|
||||
func normalizeSpin(raw *RawSpin) (*Spin, error) {
|
||||
if raw == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if !raw.Supported {
|
||||
return &Spin{State: "unsupported"}, nil
|
||||
}
|
||||
state := strings.ToLower(strings.TrimSpace(raw.State))
|
||||
switch state {
|
||||
case "spinning", "idle", "standby", "unknown":
|
||||
return &Spin{State: state}, nil
|
||||
default:
|
||||
return &Spin{State: "unknown"}, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package disk
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func float(value float64) *float64 { return &value }
|
||||
func TestTemperatureHysteresisAndUnsupportedCapabilities(t *testing.T) {
|
||||
policy := PerformancePolicy{}
|
||||
if got := TemperatureStatus("normal", 52, policy); got != "attention" {
|
||||
t.Fatalf("got=%s", got)
|
||||
}
|
||||
if got := TemperatureStatus("attention", 48, policy); got != "attention" {
|
||||
t.Fatalf("got=%s", got)
|
||||
}
|
||||
if got := TemperatureStatus("attention", 44, policy); got != "normal" {
|
||||
t.Fatalf("got=%s", got)
|
||||
}
|
||||
spin, err := normalizeSpin(&RawSpin{Supported: false})
|
||||
if err != nil || spin.State != "unsupported" {
|
||||
t.Fatalf("spin=%+v err=%v", spin, err)
|
||||
}
|
||||
performance, err := normalizePerformance(&RawPerformance{Available: false}, time.Now(), PerformancePolicy{})
|
||||
if err != nil || performance.State != "unsupported" {
|
||||
t.Fatalf("performance=%+v err=%v", performance, err)
|
||||
}
|
||||
}
|
||||
func TestPerformanceHistoryIsBoundedAndSorted(t *testing.T) {
|
||||
now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
||||
old := now.Add(-time.Minute)
|
||||
newer := now.Add(-time.Second)
|
||||
raw := RawPerformance{Available: true, Current: RawPerformanceSample{ObservedAt: now, ReadBytesPerSecond: float(100)}, History: []RawPerformanceSample{{ObservedAt: old}, {ObservedAt: newer}}}
|
||||
got, err := normalizePerformance(&raw, now, PerformancePolicy{MaxHistory: 2})
|
||||
if err != nil || got.State != "available" || len(got.History) != 2 || !got.History[0].ObservedAt.Equal(newer) {
|
||||
t.Fatalf("performance=%+v err=%v", got, err)
|
||||
}
|
||||
raw.History = append(raw.History, RawPerformanceSample{})
|
||||
if _, err = normalizePerformance(&raw, now, PerformancePolicy{MaxHistory: 2}); err == nil {
|
||||
t.Fatal("expected history bounds error")
|
||||
}
|
||||
}
|
||||
func TestTemperatureObservationAndPerformanceValuesRejectInvalid(t *testing.T) {
|
||||
now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
||||
future := now.Add(2 * time.Hour)
|
||||
if _, err := normalizeTemperature(&RawTemperature{Available: true, Celsius: 52, ObservedAt: future}, now, PerformancePolicy{}); err == nil {
|
||||
t.Fatal("expected future temperature error")
|
||||
}
|
||||
if _, err := normalizePerformance(&RawPerformance{Available: true, Current: RawPerformanceSample{ObservedAt: now, ReadBytesPerSecond: float(-1)}}, now, PerformancePolicy{}); err == nil {
|
||||
t.Fatal("expected invalid performance error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package disk
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RawSMARTAttribute struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
RawValue int64 `json:"rawValue"`
|
||||
NormalizedValue *float64 `json:"normalizedValue,omitempty"`
|
||||
Unit string `json:"unit,omitempty"`
|
||||
}
|
||||
type SMARTAttribute struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
RawValue int64 `json:"rawValue"`
|
||||
NormalizedValue *float64 `json:"normalizedValue,omitempty"`
|
||||
Unit string `json:"unit,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Critical bool `json:"critical"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
type RawSelfTest struct {
|
||||
Supported bool `json:"supported"`
|
||||
Result string `json:"result"`
|
||||
CompletedAt *time.Time `json:"completedAt,omitempty"`
|
||||
}
|
||||
type SelfTest struct {
|
||||
Supported bool `json:"supported"`
|
||||
Result string `json:"result"`
|
||||
CompletedAt *time.Time `json:"completedAt,omitempty"`
|
||||
AgeSeconds *float64 `json:"ageSeconds,omitempty"`
|
||||
}
|
||||
type RawSMART struct {
|
||||
Available bool `json:"available"`
|
||||
Overall string `json:"overall"`
|
||||
ObservedAt time.Time `json:"observedAt"`
|
||||
Attributes []RawSMARTAttribute `json:"attributes,omitempty"`
|
||||
SelfTest *RawSelfTest `json:"selfTest,omitempty"`
|
||||
}
|
||||
type SMART struct {
|
||||
State string `json:"state"`
|
||||
Overall string `json:"overall"`
|
||||
ObservedAt *time.Time `json:"observedAt,omitempty"`
|
||||
Attributes []SMARTAttribute `json:"attributes,omitempty"`
|
||||
SelfTest *SelfTest `json:"selfTest,omitempty"`
|
||||
Reasons []string `json:"reasons,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
SMARTAvailable = "available"
|
||||
SMARTUnknown = "unknown"
|
||||
SMARTAttention = "attention"
|
||||
SMARTFailed = "failed"
|
||||
SMARTPassed = "passed"
|
||||
)
|
||||
|
||||
func normalizeSMART(raw *RawSMART, now time.Time, policy Policy) (*SMART, error) {
|
||||
if raw == nil {
|
||||
return nil, nil
|
||||
}
|
||||
result := &SMART{State: SMARTUnknown, Overall: SMARTUnknown, Attributes: []SMARTAttribute{}, Reasons: []string{}}
|
||||
if !raw.Available {
|
||||
result.Reasons = []string{"smart_unavailable"}
|
||||
return result, nil
|
||||
}
|
||||
observed := raw.ObservedAt
|
||||
if observed.IsZero() {
|
||||
observed = now
|
||||
}
|
||||
observed = observed.UTC()
|
||||
result.ObservedAt = &observed
|
||||
if now.Sub(observed) > policy.SMARTFreshnessMaxAge {
|
||||
result.Reasons = []string{"smart_stale"}
|
||||
return result, nil
|
||||
}
|
||||
result.State = SMARTAvailable
|
||||
result.Overall = normalizeOverall(raw.Overall)
|
||||
attrs := make([]SMARTAttribute, 0, len(raw.Attributes))
|
||||
reasons := map[string]bool{}
|
||||
for _, item := range raw.Attributes {
|
||||
if strings.TrimSpace(item.ID) == "" || len(item.ID) > 64 || len(item.Name) > 128 {
|
||||
return nil, errors.New("SMART attribute identity is invalid")
|
||||
}
|
||||
canonical := canonicalAttribute(item.ID, item.Name)
|
||||
critical, reason := criticalAttribute(canonical, item.RawValue)
|
||||
status := "normal"
|
||||
if critical {
|
||||
status = SMARTAttention
|
||||
reasons[reason] = true
|
||||
}
|
||||
attrs = append(attrs, SMARTAttribute{ID: item.ID, Name: item.Name, RawValue: item.RawValue, NormalizedValue: item.NormalizedValue, Unit: bounded(item.Unit, ""), Status: status, Critical: critical, Reason: reason})
|
||||
}
|
||||
sort.Slice(attrs, func(i, j int) bool {
|
||||
if attrs[i].Critical != attrs[j].Critical {
|
||||
return attrs[i].Critical
|
||||
}
|
||||
return attrs[i].ID < attrs[j].ID
|
||||
})
|
||||
if raw.SelfTest != nil {
|
||||
self, err := normalizeSelfTest(raw.SelfTest, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.SelfTest = self
|
||||
}
|
||||
for _, reason := range []string{"reallocated_sectors", "pending_sectors", "offline_uncorrectable", "crc_errors", "wear"} {
|
||||
if reasons[reason] {
|
||||
result.Reasons = append(result.Reasons, reason)
|
||||
}
|
||||
}
|
||||
if len(result.Reasons) > 0 && result.Overall == SMARTPassed {
|
||||
result.Overall = SMARTAttention
|
||||
}
|
||||
if result.Overall == SMARTFailed {
|
||||
result.Reasons = append(result.Reasons, "smart_overall_failed")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func normalizeOverall(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case SMARTPassed:
|
||||
return SMARTPassed
|
||||
case SMARTFailed:
|
||||
return SMARTFailed
|
||||
case SMARTAttention, "warning", "warn":
|
||||
return SMARTAttention
|
||||
default:
|
||||
return SMARTUnknown
|
||||
}
|
||||
}
|
||||
func canonicalAttribute(id, name string) string {
|
||||
value := strings.ToLower(strings.TrimSpace(id) + " " + strings.TrimSpace(name))
|
||||
switch {
|
||||
case strings.Contains(value, "197") || strings.Contains(value, "pending"):
|
||||
return "pending"
|
||||
case strings.Contains(value, "198") || strings.Contains(value, "offline") || strings.Contains(value, "uncorrectable"):
|
||||
return "offline"
|
||||
case strings.Contains(value, "199") || strings.Contains(value, "crc") || strings.Contains(value, "interface"):
|
||||
return "crc"
|
||||
case strings.Contains(value, "realloc") || strings.Contains(value, " 5 "):
|
||||
return "reallocated"
|
||||
case strings.Contains(value, "wear") || strings.Contains(value, "life") || strings.Contains(value, "percent_used") || strings.Contains(value, "177"):
|
||||
return "wear"
|
||||
default:
|
||||
return "other"
|
||||
}
|
||||
}
|
||||
func criticalAttribute(kind string, value int64) (bool, string) {
|
||||
if value <= 0 {
|
||||
return false, ""
|
||||
}
|
||||
switch kind {
|
||||
case "reallocated":
|
||||
return true, "reallocated_sectors"
|
||||
case "pending":
|
||||
return true, "pending_sectors"
|
||||
case "offline":
|
||||
return true, "offline_uncorrectable"
|
||||
case "crc":
|
||||
return true, "crc_errors"
|
||||
case "wear":
|
||||
return true, "wear"
|
||||
default:
|
||||
return false, ""
|
||||
}
|
||||
}
|
||||
func normalizeSelfTest(raw *RawSelfTest, now time.Time) (*SelfTest, error) {
|
||||
result := &SelfTest{Supported: raw.Supported, Result: normalizeOverall(raw.Result)}
|
||||
if raw.CompletedAt != nil {
|
||||
value := raw.CompletedAt.UTC()
|
||||
if value.After(now.Add(time.Minute)) {
|
||||
return nil, errors.New("SMART self-test is materially in the future")
|
||||
}
|
||||
result.CompletedAt = &value
|
||||
age := now.Sub(value).Seconds()
|
||||
if age < 0 {
|
||||
age = 0
|
||||
}
|
||||
result.AgeSeconds = &age
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package disk
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSMARTCriticalAttributesOverrideGenericPassed(t *testing.T) {
|
||||
now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
||||
raw := RawSnapshot{ObservedAt: now, ReceivedAt: now, Disks: []RawDisk{{ID: "disk-smart", Name: "SMART", SizeBytes: 100, SMART: &RawSMART{Available: true, Overall: SMARTPassed, ObservedAt: now, Attributes: []RawSMARTAttribute{{ID: "5", Name: "Reallocated Sector Count", RawValue: 1}, {ID: "197", Name: "Current Pending Sector", RawValue: 2}, {ID: "198", Name: "Offline Uncorrectable", RawValue: 1}, {ID: "199", Name: "UDMA CRC Error Count", RawValue: 3}, {ID: "177", Name: "Wear Leveling Count", RawValue: 95}}}}}}
|
||||
got, err := Normalize(raw, now, Limits{}, Policy{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
smart := got.Disks[0].SMART
|
||||
if smart == nil || smart.State != SMARTAvailable || smart.Overall != SMARTAttention {
|
||||
t.Fatalf("smart=%+v", smart)
|
||||
}
|
||||
if len(smart.Reasons) != 5 {
|
||||
t.Fatalf("reasons=%v", smart.Reasons)
|
||||
}
|
||||
for _, attribute := range smart.Attributes {
|
||||
if !attribute.Critical || attribute.Status != SMARTAttention {
|
||||
t.Fatalf("attribute=%+v", attribute)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestSMARTUnavailableAndStaleAreUnknown(t *testing.T) {
|
||||
now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
||||
unavailable := RawSnapshot{ObservedAt: now, ReceivedAt: now, Disks: []RawDisk{{ID: "disk", Name: "Disk", SizeBytes: 1, SMART: &RawSMART{Available: false, Overall: SMARTPassed}}}}
|
||||
got, err := Normalize(unavailable, now, Limits{}, Policy{})
|
||||
if err != nil || got.Disks[0].SMART.State != SMARTUnknown || got.Disks[0].SMART.Overall != SMARTUnknown {
|
||||
t.Fatalf("unavailable=%+v err=%v", got, err)
|
||||
}
|
||||
stale := unavailable
|
||||
stale.Disks[0].SMART = &RawSMART{Available: true, Overall: SMARTPassed, ObservedAt: now.Add(-2 * time.Hour)}
|
||||
got, err = Normalize(stale, now, Limits{}, Policy{SMARTFreshnessMaxAge: time.Hour})
|
||||
if err != nil || got.Disks[0].SMART.State != SMARTUnknown || got.Disks[0].SMART.Reasons[0] != "smart_stale" {
|
||||
t.Fatalf("stale=%+v err=%v", got, err)
|
||||
}
|
||||
}
|
||||
func TestSMARTSelfTestAgeAndNoFutureTimestamp(t *testing.T) {
|
||||
now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
||||
completed := now.Add(-2 * time.Hour)
|
||||
raw := RawSnapshot{ObservedAt: now, ReceivedAt: now, Disks: []RawDisk{{ID: "disk", Name: "Disk", SizeBytes: 1, SMART: &RawSMART{Available: true, Overall: SMARTPassed, ObservedAt: now, SelfTest: &RawSelfTest{Supported: true, Result: SMARTPassed, CompletedAt: &completed}}}}}
|
||||
got, err := Normalize(raw, now, Limits{}, Policy{})
|
||||
if err != nil || got.Disks[0].SMART.SelfTest == nil || *got.Disks[0].SMART.SelfTest.AgeSeconds != 7200 {
|
||||
t.Fatalf("selftest=%+v err=%v", got.Disks[0].SMART.SelfTest, err)
|
||||
}
|
||||
future := now.Add(2 * time.Hour)
|
||||
raw.Disks[0].SMART.SelfTest.CompletedAt = &future
|
||||
if _, err = Normalize(raw, now, Limits{}, Policy{}); err == nil {
|
||||
t.Fatal("expected future self-test error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
package disk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const ContractVersion = "v1"
|
||||
|
||||
const (
|
||||
StateOnline = "online"
|
||||
StateMissing = "missing"
|
||||
StateDisabled = "disabled"
|
||||
StateEmulated = "emulated"
|
||||
StateUnknown = "unknown"
|
||||
Fresh = "fresh"
|
||||
Stale = "stale"
|
||||
Unavailable = "unavailable"
|
||||
)
|
||||
|
||||
type Limits struct {
|
||||
MaxDisks int
|
||||
MaxHistory int
|
||||
}
|
||||
|
||||
func (l Limits) withDefaults() Limits {
|
||||
if l.MaxDisks == 0 {
|
||||
l.MaxDisks = 150
|
||||
}
|
||||
if l.MaxHistory == 0 {
|
||||
l.MaxHistory = 256
|
||||
}
|
||||
return l
|
||||
}
|
||||
func (l Limits) Validate() error {
|
||||
if l.MaxDisks < 1 || l.MaxDisks > 512 || l.MaxHistory < 1 || l.MaxHistory > 512 {
|
||||
return errors.New("disk limits are outside safe bounds")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Policy struct {
|
||||
FreshnessMaxAge time.Duration
|
||||
SMARTFreshnessMaxAge time.Duration
|
||||
WarningUtilizationPercent float64
|
||||
CriticalUtilizationPercent float64
|
||||
Performance PerformancePolicy
|
||||
}
|
||||
|
||||
func (p Policy) withDefaults() Policy {
|
||||
if p.FreshnessMaxAge == 0 {
|
||||
p.FreshnessMaxAge = 60 * time.Second
|
||||
}
|
||||
if p.SMARTFreshnessMaxAge == 0 {
|
||||
p.SMARTFreshnessMaxAge = 24 * time.Hour
|
||||
|
||||
}
|
||||
if p.WarningUtilizationPercent == 0 {
|
||||
p.WarningUtilizationPercent = 80
|
||||
}
|
||||
if p.CriticalUtilizationPercent == 0 {
|
||||
p.CriticalUtilizationPercent = 95
|
||||
}
|
||||
p.Performance = p.Performance.withDefaults()
|
||||
return p
|
||||
}
|
||||
func (p Policy) Validate() error {
|
||||
if p.FreshnessMaxAge <= 0 || p.FreshnessMaxAge > 24*time.Hour || p.SMARTFreshnessMaxAge <= 0 || p.SMARTFreshnessMaxAge > 30*24*time.Hour || p.WarningUtilizationPercent < 0 || p.WarningUtilizationPercent > 100 || p.CriticalUtilizationPercent < p.WarningUtilizationPercent || p.CriticalUtilizationPercent > 100 || p.Performance.Validate() != nil {
|
||||
return errors.New("disk policy is outside safe bounds")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Source struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
CapabilityVersion string `json:"capabilityVersion"`
|
||||
ObservedAt time.Time `json:"observedAt"`
|
||||
ReceivedAt time.Time `json:"receivedAt"`
|
||||
Freshness string `json:"freshness"`
|
||||
State string `json:"state"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type RawInodes struct {
|
||||
Total uint64 `json:"total"`
|
||||
Used uint64 `json:"used"`
|
||||
}
|
||||
type Inodes struct {
|
||||
Total uint64 `json:"total"`
|
||||
Used uint64 `json:"used"`
|
||||
Free uint64 `json:"free"`
|
||||
UtilizationPercent float64 `json:"utilizationPercent"`
|
||||
}
|
||||
type RawDisk struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
State string `json:"state"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Serial string `json:"serial,omitempty"`
|
||||
Filesystem string `json:"filesystem,omitempty"`
|
||||
SizeBytes uint64 `json:"sizeBytes"`
|
||||
UsedBytes uint64 `json:"usedBytes"`
|
||||
Inodes *RawInodes `json:"inodes,omitempty"`
|
||||
SMART *RawSMART `json:"smart,omitempty"`
|
||||
Performance *RawPerformance `json:"performance,omitempty"`
|
||||
Temperature *RawTemperature `json:"temperature,omitempty"`
|
||||
Spin *RawSpin `json:"spin,omitempty"`
|
||||
}
|
||||
type Disk struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
State string `json:"state"`
|
||||
Model string `json:"model,omitempty"`
|
||||
SerialDisplay string `json:"serialDisplay,omitempty"`
|
||||
Filesystem string `json:"filesystem,omitempty"`
|
||||
SizeBytes uint64 `json:"sizeBytes"`
|
||||
UsedBytes uint64 `json:"usedBytes"`
|
||||
FreeBytes uint64 `json:"freeBytes"`
|
||||
UtilizationPercent float64 `json:"utilizationPercent"`
|
||||
CapacitySeverity string `json:"capacitySeverity"`
|
||||
ThermalSeverity string `json:"thermalSeverity"`
|
||||
Inodes *Inodes `json:"inodes,omitempty"`
|
||||
SMART *SMART `json:"smart,omitempty"`
|
||||
Performance *Performance `json:"performance,omitempty"`
|
||||
Temperature *Temperature `json:"temperature,omitempty"`
|
||||
Spin *Spin `json:"spin,omitempty"`
|
||||
}
|
||||
type RawMissingObservation struct {
|
||||
DiskID string `json:"diskId"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
ObservedAt time.Time `json:"observedAt"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
type MissingObservation struct {
|
||||
DiskID string `json:"diskId"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
ObservedAt time.Time `json:"observedAt"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type RawSnapshot struct {
|
||||
Source Source `json:"source"`
|
||||
Disks []RawDisk `json:"disks"`
|
||||
MissingHistory []RawMissingObservation `json:"missingHistory,omitempty"`
|
||||
ObservedAt time.Time `json:"observedAt"`
|
||||
ReceivedAt time.Time `json:"receivedAt"`
|
||||
}
|
||||
type Snapshot struct {
|
||||
ContractVersion string `json:"contractVersion"`
|
||||
Source Source `json:"source"`
|
||||
Disks []Disk `json:"disks"`
|
||||
Total int `json:"total"`
|
||||
MissingHistory []MissingObservation `json:"missingHistory,omitempty"`
|
||||
ObservedAt time.Time `json:"observedAt"`
|
||||
ReceivedAt time.Time `json:"receivedAt"`
|
||||
}
|
||||
type Provider interface {
|
||||
Snapshot(context.Context) (Snapshot, error)
|
||||
}
|
||||
type RawProvider interface {
|
||||
Snapshot(context.Context) (RawSnapshot, error)
|
||||
}
|
||||
type Adapter struct {
|
||||
Source RawProvider
|
||||
Limits Limits
|
||||
Policy Policy
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func (a Adapter) Snapshot(ctx context.Context) (Snapshot, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
if a.Source == nil {
|
||||
return UnknownSnapshot(time.Now().UTC(), "disks", "unraid", "source_unavailable"), nil
|
||||
}
|
||||
raw, err := a.Source.Snapshot(ctx)
|
||||
if err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if a.Now != nil {
|
||||
now = a.Now()
|
||||
}
|
||||
return Normalize(raw, now, a.Limits, a.Policy)
|
||||
}
|
||||
func UnknownSnapshot(now time.Time, id, typ, reason string) Snapshot {
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
now = now.UTC()
|
||||
return Snapshot{ContractVersion: ContractVersion, Source: Source{ID: id, Type: typ, CapabilityVersion: ContractVersion, ReceivedAt: now, Freshness: Unavailable, State: StateUnknown, Reason: reason}, Disks: []Disk{}, MissingHistory: []MissingObservation{}, ObservedAt: now, ReceivedAt: now}
|
||||
}
|
||||
|
||||
func Normalize(raw RawSnapshot, now time.Time, limits Limits, policy Policy) (Snapshot, error) {
|
||||
limits = limits.withDefaults()
|
||||
policy = policy.withDefaults()
|
||||
if err := limits.Validate(); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
if err := policy.Validate(); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
if raw.ReceivedAt.IsZero() {
|
||||
raw.ReceivedAt = now
|
||||
}
|
||||
if raw.ObservedAt.IsZero() {
|
||||
raw.ObservedAt = raw.ReceivedAt
|
||||
}
|
||||
if raw.ObservedAt.After(now.Add(time.Minute)) {
|
||||
return Snapshot{}, errors.New("disk observation is materially in the future")
|
||||
}
|
||||
if len(raw.Disks) > limits.MaxDisks || len(raw.MissingHistory) > limits.MaxHistory {
|
||||
return Snapshot{}, errors.New("disk payload exceeds bounds")
|
||||
}
|
||||
disks := make([]Disk, 0, len(raw.Disks))
|
||||
seen := make(map[string]RawDisk, len(raw.Disks))
|
||||
for _, item := range raw.Disks {
|
||||
item.ID = canonicalIdentity(item.ID)
|
||||
if previous, ok := seen[item.ID]; ok {
|
||||
if reflect.DeepEqual(previous, item) {
|
||||
continue
|
||||
}
|
||||
return Snapshot{}, errors.New("conflicting duplicate disk identity")
|
||||
}
|
||||
seen[item.ID] = item
|
||||
normalized, err := normalizeDisk(item, now, policy)
|
||||
if err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
disks = append(disks, normalized)
|
||||
}
|
||||
sort.Slice(disks, func(i, j int) bool {
|
||||
if disks[i].Role != disks[j].Role {
|
||||
return disks[i].Role < disks[j].Role
|
||||
}
|
||||
if disks[i].Name != disks[j].Name {
|
||||
return disks[i].Name < disks[j].Name
|
||||
}
|
||||
return disks[i].ID < disks[j].ID
|
||||
})
|
||||
missing := make([]MissingObservation, 0, len(raw.MissingHistory))
|
||||
for _, item := range raw.MissingHistory {
|
||||
if strings.TrimSpace(item.DiskID) == "" || len(item.DiskID) > 128 || len(item.Name) > 255 {
|
||||
return Snapshot{}, errors.New("missing disk history identity is invalid")
|
||||
}
|
||||
observed := item.ObservedAt
|
||||
if observed.IsZero() {
|
||||
observed = raw.ObservedAt
|
||||
}
|
||||
missing = append(missing, MissingObservation{DiskID: item.DiskID, Name: item.Name, Role: bounded(item.Role, "data"), ObservedAt: observed.UTC(), Reason: bounded(item.Reason, "missing")})
|
||||
}
|
||||
sort.Slice(missing, func(i, j int) bool {
|
||||
if !missing[i].ObservedAt.Equal(missing[j].ObservedAt) {
|
||||
return missing[i].ObservedAt.After(missing[j].ObservedAt)
|
||||
}
|
||||
return missing[i].DiskID < missing[j].DiskID
|
||||
})
|
||||
source := raw.Source
|
||||
if source.ID == "" {
|
||||
source.ID = "disks"
|
||||
}
|
||||
if source.Type == "" {
|
||||
source.Type = "unraid"
|
||||
}
|
||||
if source.CapabilityVersion == "" {
|
||||
source.CapabilityVersion = ContractVersion
|
||||
}
|
||||
source.ObservedAt = raw.ObservedAt.UTC()
|
||||
source.ReceivedAt = raw.ReceivedAt.UTC()
|
||||
source.Freshness = Fresh
|
||||
source.State = "healthy"
|
||||
if now.Sub(raw.ObservedAt) > policy.FreshnessMaxAge {
|
||||
source.Freshness = Stale
|
||||
source.State = StateUnknown
|
||||
source.Reason = "stale_source"
|
||||
}
|
||||
result := Snapshot{ContractVersion: ContractVersion, Source: source, Disks: disks, Total: len(disks), MissingHistory: missing, ObservedAt: raw.ObservedAt.UTC(), ReceivedAt: raw.ReceivedAt.UTC()}
|
||||
if source.State == StateUnknown {
|
||||
result.Source.State = StateUnknown
|
||||
for i := range result.Disks {
|
||||
result.Disks[i].State = StateUnknown
|
||||
result.Disks[i].CapacitySeverity = StateUnknown
|
||||
result.Disks[i].ThermalSeverity = StateUnknown
|
||||
if result.Disks[i].Temperature != nil {
|
||||
result.Disks[i].Temperature.Status = StateUnknown
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func normalizeDisk(item RawDisk, now time.Time, policy Policy) (Disk, error) {
|
||||
if strings.TrimSpace(item.ID) == "" || strings.TrimSpace(item.Name) == "" || len(item.ID) > 128 || len(item.Name) > 255 {
|
||||
return Disk{}, errors.New("disk identity is invalid")
|
||||
}
|
||||
if item.UsedBytes > item.SizeBytes {
|
||||
return Disk{}, errors.New("disk used bytes exceed capacity")
|
||||
}
|
||||
state := bounded(item.State, StateUnknown)
|
||||
utilization := percent(item.UsedBytes, item.SizeBytes)
|
||||
result := Disk{ID: canonicalIdentity(item.ID), Name: item.Name, Role: bounded(item.Role, "data"), State: state, Model: bounded(item.Model, ""), SerialDisplay: maskSerial(item.Serial), Filesystem: bounded(item.Filesystem, ""), SizeBytes: item.SizeBytes, UsedBytes: item.UsedBytes, FreeBytes: item.SizeBytes - item.UsedBytes, UtilizationPercent: utilization, CapacitySeverity: capacitySeverity(utilization, item.SizeBytes, policy.WarningUtilizationPercent, policy.CriticalUtilizationPercent), ThermalSeverity: StateUnknown}
|
||||
if item.Inodes != nil {
|
||||
if item.Inodes.Used > item.Inodes.Total {
|
||||
return Disk{}, errors.New("disk used inodes exceed total")
|
||||
}
|
||||
result.Inodes = &Inodes{Total: item.Inodes.Total, Used: item.Inodes.Used, Free: item.Inodes.Total - item.Inodes.Used, UtilizationPercent: percent(item.Inodes.Used, item.Inodes.Total)}
|
||||
}
|
||||
smart, err := normalizeSMART(item.SMART, now, policy)
|
||||
if err != nil {
|
||||
return Disk{}, err
|
||||
}
|
||||
result.SMART = smart
|
||||
performance, err := normalizePerformance(item.Performance, now, policy.Performance)
|
||||
if err != nil {
|
||||
return Disk{}, err
|
||||
}
|
||||
result.Performance = performance
|
||||
temperature, err := normalizeTemperature(item.Temperature, now, policy.Performance)
|
||||
if err != nil {
|
||||
return Disk{}, err
|
||||
}
|
||||
result.Temperature = temperature
|
||||
if temperature != nil {
|
||||
result.ThermalSeverity = temperature.Status
|
||||
}
|
||||
spin, err := normalizeSpin(item.Spin)
|
||||
if err != nil {
|
||||
return Disk{}, err
|
||||
}
|
||||
result.Spin = spin
|
||||
return result, nil
|
||||
}
|
||||
func canonicalIdentity(value string) string { return strings.ToLower(strings.TrimSpace(value)) }
|
||||
func capacitySeverity(utilization float64, total uint64, warning, critical float64) string {
|
||||
if total == 0 {
|
||||
return StateUnknown
|
||||
}
|
||||
if utilization >= critical {
|
||||
return "critical"
|
||||
}
|
||||
if utilization >= warning {
|
||||
return "attention"
|
||||
}
|
||||
return "normal"
|
||||
}
|
||||
func percent(used, total uint64) float64 {
|
||||
if total == 0 {
|
||||
return 0
|
||||
}
|
||||
value := float64(used) / float64(total) * 100
|
||||
if math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
func maskSerial(serial string) string {
|
||||
serial = strings.TrimSpace(serial)
|
||||
if serial == "" {
|
||||
return "niet beschikbaar"
|
||||
}
|
||||
if len(serial) <= 4 {
|
||||
return "verborgen"
|
||||
}
|
||||
return "••••" + serial[len(serial)-4:]
|
||||
}
|
||||
func bounded(value, fallback string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
if len(value) > 128 {
|
||||
return value[:128]
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func DiskByID(snapshot Snapshot, id string) (Disk, bool) {
|
||||
for _, item := range snapshot.Disks {
|
||||
if item.ID == id {
|
||||
return item, true
|
||||
}
|
||||
}
|
||||
return Disk{}, false
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package disk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNormalizeFortyDiskFixtureCapacityAndPrivacy(t *testing.T) {
|
||||
now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
||||
raw := RawSnapshot{Source: Source{ID: "fixture-disks", Type: "fixture"}, ObservedAt: now, ReceivedAt: now}
|
||||
for i := 0; i < 40; i++ {
|
||||
raw.Disks = append(raw.Disks, RawDisk{ID: "disk-" + string(rune('a'+i%26)) + string(rune('0'+i/26)), Name: "Disk " + string(rune('A'+i%26)), Role: "data", State: StateOnline, Model: "Model-X", Serial: "SERIAL-123456789", Filesystem: "xfs", SizeBytes: 1000, UsedBytes: uint64(i), Inodes: &RawInodes{Total: 100, Used: uint64(i)}})
|
||||
}
|
||||
got, err := Normalize(raw, now, Limits{}, Policy{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Total != 40 || len(got.Disks) != 40 {
|
||||
t.Fatalf("total=%d disks=%d", got.Total, len(got.Disks))
|
||||
}
|
||||
if got.Disks[0].UtilizationPercent != 0 {
|
||||
t.Fatalf("first disk capacity=%+v", got.Disks[0])
|
||||
}
|
||||
var usedTen *Disk
|
||||
for i := range got.Disks {
|
||||
if got.Disks[i].UsedBytes == 10 {
|
||||
usedTen = &got.Disks[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if usedTen == nil || usedTen.FreeBytes != 990 || usedTen.Inodes == nil || usedTen.Inodes.Free != 90 {
|
||||
t.Fatalf("capacity/inodes=%+v", usedTen)
|
||||
}
|
||||
if got.Disks[0].SerialDisplay == "SERIAL-123456789" || got.Disks[0].SerialDisplay != "••••6789" {
|
||||
t.Fatalf("serial=%q", got.Disks[0].SerialDisplay)
|
||||
}
|
||||
}
|
||||
func TestMissingDiskHistoryIsPreservedAndSorted(t *testing.T) {
|
||||
now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
||||
older := now.Add(-time.Hour)
|
||||
raw := RawSnapshot{ObservedAt: now, ReceivedAt: now, MissingHistory: []RawMissingObservation{{DiskID: "disk-old", Name: "Old", ObservedAt: older, Reason: "removed"}, {DiskID: "disk-new", Name: "New", ObservedAt: now, Reason: "missing"}}}
|
||||
got, err := Normalize(raw, now, Limits{}, Policy{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.MissingHistory) != 2 || got.MissingHistory[0].DiskID != "disk-new" || got.MissingHistory[1].Reason != "removed" {
|
||||
t.Fatalf("history=%+v", got.MissingHistory)
|
||||
}
|
||||
}
|
||||
func TestInvalidCapacityAndStaleUnknown(t *testing.T) {
|
||||
now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
||||
raw := RawSnapshot{ObservedAt: now, ReceivedAt: now, Disks: []RawDisk{{ID: "disk", Name: "Disk", SizeBytes: 1, UsedBytes: 2}}}
|
||||
if _, err := Normalize(raw, now, Limits{}, Policy{}); err == nil {
|
||||
t.Fatal("expected capacity error")
|
||||
}
|
||||
raw.Disks[0].UsedBytes = 1
|
||||
raw.ObservedAt = now.Add(-2 * time.Minute)
|
||||
got, err := Normalize(raw, now, Limits{}, Policy{FreshnessMaxAge: time.Minute})
|
||||
if err != nil || got.Source.State != StateUnknown || got.Source.Freshness != Stale || got.Disks[0].State != StateUnknown || got.Disks[0].CapacitySeverity != StateUnknown {
|
||||
t.Fatalf("snapshot=%+v err=%v", got, err)
|
||||
}
|
||||
}
|
||||
func TestAdapterContextAndUnknown(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := (Adapter{}).Snapshot(ctx); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
got, err := (Adapter{Now: func() time.Time { return time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) }}).Snapshot(context.Background())
|
||||
if err != nil || got.Source.Reason != "source_unavailable" || got.Source.State != StateUnknown {
|
||||
t.Fatalf("got=%+v err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSeparatesCapacityThermalAndAvailability(t *testing.T) {
|
||||
now := time.Date(2026, 8, 11, 23, 45, 0, 0, time.UTC)
|
||||
raw := RawSnapshot{ObservedAt: now, ReceivedAt: now, Disks: []RawDisk{
|
||||
{ID: " DISK-10 ", Name: "disk10", Role: "data", State: StateOnline, SizeBytes: 10000, UsedBytes: 9999, Temperature: &RawTemperature{Available: true, Celsius: 44, ObservedAt: now}},
|
||||
{ID: "CACHE", Name: "cache", Role: "cache", State: StateOnline, SizeBytes: 100, UsedBytes: 50, Temperature: &RawTemperature{Available: true, Celsius: 61, ObservedAt: now}},
|
||||
}}
|
||||
got, err := Normalize(raw, now, Limits{}, Policy{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Disks[0].ID != "cache" || got.Disks[0].State != StateOnline || got.Disks[0].CapacitySeverity != "normal" || got.Disks[0].ThermalSeverity != "critical" {
|
||||
t.Fatalf("cache signals were conflated: %+v", got.Disks[0])
|
||||
}
|
||||
if got.Disks[1].ID != "disk-10" || got.Disks[1].State != StateOnline || got.Disks[1].CapacitySeverity != "critical" || got.Disks[1].ThermalSeverity != "normal" {
|
||||
t.Fatalf("disk signals were conflated: %+v", got.Disks[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDiskDuplicatesAreIdempotentAndConflictsFail(t *testing.T) {
|
||||
now := time.Date(2026, 8, 11, 23, 45, 0, 0, time.UTC)
|
||||
item := RawDisk{ID: "DISK-1", Name: "disk1", Role: "data", State: StateOnline, SizeBytes: 100, UsedBytes: 20}
|
||||
got, err := Normalize(RawSnapshot{ObservedAt: now, ReceivedAt: now, Disks: []RawDisk{item, item}}, now, Limits{}, Policy{})
|
||||
if err != nil || got.Total != 1 {
|
||||
t.Fatalf("duplicate snapshot was not idempotent: %+v err=%v", got, err)
|
||||
}
|
||||
conflict := item
|
||||
conflict.ID = "disk-1"
|
||||
conflict.UsedBytes = 30
|
||||
if _, err := Normalize(RawSnapshot{ObservedAt: now, ReceivedAt: now, Disks: []RawDisk{item, conflict}}, now, Limits{}, Policy{}); err == nil {
|
||||
t.Fatal("conflicting duplicate disk must fail closed")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user