Publish ITWorx Pulse source
Public source validation / validate (push) Failing after 3m8s

This commit is contained in:
ITWorx Pulse release export
2026-09-03 02:09:19 +02:00
commit bd774932d5
614 changed files with 77116 additions and 0 deletions
+187
View File
@@ -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
}
}
+52
View File
@@ -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")
}
}
+187
View File
@@ -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
}
+55
View File
@@ -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")
}
}
+398
View File
@@ -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
}
+108
View File
@@ -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")
}
}