This commit is contained in:
@@ -0,0 +1,537 @@
|
||||
package pool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const ContractVersion = "v1"
|
||||
|
||||
const (
|
||||
StateHealthy = "healthy"
|
||||
StateDegraded = "degraded"
|
||||
StateFaulted = "faulted"
|
||||
StateUnknown = "unknown"
|
||||
|
||||
Fresh = "fresh"
|
||||
Stale = "stale"
|
||||
Unavailable = "unavailable"
|
||||
|
||||
CapabilityAvailable = "available"
|
||||
CapabilityUnsupported = "unsupported"
|
||||
CapabilityUnavailable = "unavailable"
|
||||
)
|
||||
|
||||
type Limits struct {
|
||||
MaxPools int
|
||||
MaxMembers int
|
||||
MaxScrubHistory int
|
||||
MaxErrors int
|
||||
}
|
||||
|
||||
func (l Limits) withDefaults() Limits {
|
||||
if l.MaxPools == 0 {
|
||||
l.MaxPools = 64
|
||||
}
|
||||
if l.MaxMembers == 0 {
|
||||
l.MaxMembers = 256
|
||||
}
|
||||
if l.MaxScrubHistory == 0 {
|
||||
l.MaxScrubHistory = 64
|
||||
}
|
||||
if l.MaxErrors == 0 {
|
||||
l.MaxErrors = 32
|
||||
}
|
||||
return l
|
||||
}
|
||||
func (l Limits) Validate() error {
|
||||
if l.MaxPools < 1 || l.MaxPools > 256 || l.MaxMembers < 1 || l.MaxMembers > 1024 || l.MaxScrubHistory < 1 || l.MaxScrubHistory > 256 || l.MaxErrors < 1 || l.MaxErrors > 256 {
|
||||
return errors.New("pool limits are outside safe bounds")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Policy struct {
|
||||
FreshnessMaxAge time.Duration
|
||||
WarningUtilizationPercent float64
|
||||
CriticalUtilizationPercent float64
|
||||
}
|
||||
|
||||
func (p Policy) withDefaults() Policy {
|
||||
if p.FreshnessMaxAge == 0 {
|
||||
p.FreshnessMaxAge = time.Minute
|
||||
}
|
||||
if p.WarningUtilizationPercent == 0 {
|
||||
p.WarningUtilizationPercent = 80
|
||||
}
|
||||
if p.CriticalUtilizationPercent == 0 {
|
||||
p.CriticalUtilizationPercent = 95
|
||||
}
|
||||
return p
|
||||
}
|
||||
func (p Policy) Validate() error {
|
||||
if p.FreshnessMaxAge <= 0 || p.FreshnessMaxAge > 24*time.Hour || p.WarningUtilizationPercent < 0 || p.WarningUtilizationPercent > 100 || p.CriticalUtilizationPercent < p.WarningUtilizationPercent || p.CriticalUtilizationPercent > 100 {
|
||||
return errors.New("pool 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 Capabilities struct {
|
||||
Members string `json:"members"`
|
||||
Capacity string `json:"capacity"`
|
||||
Redundancy string `json:"redundancy"`
|
||||
Scrub string `json:"scrub"`
|
||||
FilesystemErrors string `json:"filesystemErrors"`
|
||||
Performance string `json:"performance"`
|
||||
SSDWear string `json:"ssdWear"`
|
||||
MoverSignals string `json:"moverSignals"`
|
||||
}
|
||||
type RawMember struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
State string `json:"state"`
|
||||
CapacityBytes uint64 `json:"capacityBytes"`
|
||||
Errors uint64 `json:"errors"`
|
||||
}
|
||||
type Member struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
State string `json:"state"`
|
||||
CapacityBytes uint64 `json:"capacityBytes"`
|
||||
Errors uint64 `json:"errors"`
|
||||
}
|
||||
type RawError struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Message string `json:"message"`
|
||||
Count uint64 `json:"count"`
|
||||
ObservedAt time.Time `json:"observedAt"`
|
||||
}
|
||||
type PoolError struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Message string `json:"message"`
|
||||
Count uint64 `json:"count"`
|
||||
ObservedAt time.Time `json:"observedAt"`
|
||||
}
|
||||
type RawScrub struct {
|
||||
ID string `json:"id"`
|
||||
State string `json:"state"`
|
||||
ProgressPercent float64 `json:"progressPercent"`
|
||||
Errors uint64 `json:"errors"`
|
||||
BytesChecked uint64 `json:"bytesChecked"`
|
||||
StartedAt *time.Time `json:"startedAt,omitempty"`
|
||||
CompletedAt *time.Time `json:"completedAt,omitempty"`
|
||||
Result string `json:"result,omitempty"`
|
||||
}
|
||||
type Scrub struct {
|
||||
ID string `json:"id"`
|
||||
State string `json:"state"`
|
||||
ProgressPercent float64 `json:"progressPercent"`
|
||||
Errors uint64 `json:"errors"`
|
||||
BytesChecked uint64 `json:"bytesChecked"`
|
||||
StartedAt *time.Time `json:"startedAt,omitempty"`
|
||||
CompletedAt *time.Time `json:"completedAt,omitempty"`
|
||||
Result string `json:"result,omitempty"`
|
||||
}
|
||||
type RawPool struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Filesystem string `json:"filesystem"`
|
||||
State string `json:"state"`
|
||||
UsableBytes uint64 `json:"usableBytes"`
|
||||
UsedBytes uint64 `json:"usedBytes"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
Redundancy string `json:"redundancy,omitempty"`
|
||||
Capabilities Capabilities `json:"capabilities"`
|
||||
Members []RawMember `json:"members,omitempty"`
|
||||
Errors []RawError `json:"errors,omitempty"`
|
||||
Scrub *RawScrub `json:"scrub,omitempty"`
|
||||
ScrubHistory []RawScrub `json:"scrubHistory,omitempty"`
|
||||
}
|
||||
type Pool struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Filesystem string `json:"filesystem"`
|
||||
State string `json:"state"`
|
||||
UsableBytes uint64 `json:"usableBytes"`
|
||||
UsedBytes uint64 `json:"usedBytes"`
|
||||
FreeBytes uint64 `json:"freeBytes"`
|
||||
UtilizationPercent float64 `json:"utilizationPercent"`
|
||||
CapacitySeverity string `json:"capacitySeverity"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
Redundancy string `json:"redundancy,omitempty"`
|
||||
Capabilities Capabilities `json:"capabilities"`
|
||||
Members []Member `json:"members,omitempty"`
|
||||
Errors []PoolError `json:"errors,omitempty"`
|
||||
Scrub *Scrub `json:"scrub,omitempty"`
|
||||
ScrubHistory []Scrub `json:"scrubHistory,omitempty"`
|
||||
}
|
||||
type RawSnapshot struct {
|
||||
Source Source `json:"source"`
|
||||
Pools []RawPool `json:"pools"`
|
||||
ObservedAt time.Time `json:"observedAt"`
|
||||
ReceivedAt time.Time `json:"receivedAt"`
|
||||
}
|
||||
type Snapshot struct {
|
||||
ContractVersion string `json:"contractVersion"`
|
||||
Source Source `json:"source"`
|
||||
Pools []Pool `json:"pools"`
|
||||
Total int `json:"total"`
|
||||
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(), "pools", "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}, Pools: []Pool{}, 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("pool observation is materially in the future")
|
||||
}
|
||||
if len(raw.Pools) > limits.MaxPools {
|
||||
return Snapshot{}, errors.New("pool payload exceeds bounds")
|
||||
}
|
||||
pools := make([]Pool, 0, len(raw.Pools))
|
||||
seen := make(map[string]RawPool, len(raw.Pools))
|
||||
for _, item := range raw.Pools {
|
||||
item.ID = canonicalIdentity(item.ID)
|
||||
if previous, ok := seen[item.ID]; ok {
|
||||
if reflect.DeepEqual(previous, item) {
|
||||
continue
|
||||
}
|
||||
return Snapshot{}, errors.New("conflicting duplicate pool identity")
|
||||
}
|
||||
seen[item.ID] = item
|
||||
normalized, err := normalizePool(item, limits, policy)
|
||||
if err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
pools = append(pools, normalized)
|
||||
}
|
||||
sort.Slice(pools, func(i, j int) bool {
|
||||
if pools[i].Name != pools[j].Name {
|
||||
return pools[i].Name < pools[j].Name
|
||||
}
|
||||
return pools[i].ID < pools[j].ID
|
||||
})
|
||||
source := raw.Source
|
||||
if source.ID == "" {
|
||||
source.ID = "pools"
|
||||
}
|
||||
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"
|
||||
for i := range pools {
|
||||
pools[i].State = StateUnknown
|
||||
pools[i].CapacitySeverity = StateUnknown
|
||||
}
|
||||
}
|
||||
return Snapshot{ContractVersion: ContractVersion, Source: source, Pools: pools, Total: len(pools), ObservedAt: raw.ObservedAt.UTC(), ReceivedAt: raw.ReceivedAt.UTC()}, nil
|
||||
}
|
||||
|
||||
func normalizePool(item RawPool, limits Limits, policy Policy) (Pool, error) {
|
||||
if strings.TrimSpace(item.ID) == "" || strings.TrimSpace(item.Name) == "" || len(item.ID) > 128 || len(item.Name) > 255 {
|
||||
return Pool{}, errors.New("pool identity is invalid")
|
||||
}
|
||||
if item.UsedBytes > item.UsableBytes {
|
||||
return Pool{}, errors.New("pool used bytes exceed usable capacity")
|
||||
}
|
||||
if len(item.Members) > limits.MaxMembers || len(item.Errors) > limits.MaxErrors || len(item.ScrubHistory) > limits.MaxScrubHistory {
|
||||
return Pool{}, errors.New("pool collection exceeds bounds")
|
||||
}
|
||||
utilization := percent(item.UsedBytes, item.UsableBytes)
|
||||
result := Pool{ID: canonicalIdentity(item.ID), Name: item.Name, Filesystem: bounded(item.Filesystem, "unknown"), State: normalizeState(item.State), UsableBytes: item.UsableBytes, UsedBytes: item.UsedBytes, FreeBytes: item.UsableBytes - item.UsedBytes, UtilizationPercent: utilization, CapacitySeverity: capacitySeverity(utilization, item.UsableBytes, policy), Profile: bounded(item.Profile, ""), Redundancy: bounded(item.Redundancy, ""), Capabilities: normalizeCapabilities(item.Capabilities)}
|
||||
result.Members = make([]Member, 0, len(item.Members))
|
||||
seenMembers := make(map[string]RawMember, len(item.Members))
|
||||
for _, member := range item.Members {
|
||||
member.ID = canonicalIdentity(member.ID)
|
||||
if previous, ok := seenMembers[member.ID]; ok {
|
||||
if reflect.DeepEqual(previous, member) {
|
||||
continue
|
||||
}
|
||||
return Pool{}, errors.New("conflicting duplicate pool member identity")
|
||||
}
|
||||
seenMembers[member.ID] = member
|
||||
if strings.TrimSpace(member.ID) == "" || strings.TrimSpace(member.Name) == "" || len(member.ID) > 128 || len(member.Name) > 255 {
|
||||
return Pool{}, errors.New("pool member identity is invalid")
|
||||
}
|
||||
result.Members = append(result.Members, Member{ID: member.ID, Name: member.Name, Role: bounded(member.Role, "member"), State: normalizeMemberState(member.State), CapacityBytes: member.CapacityBytes, Errors: member.Errors})
|
||||
}
|
||||
sort.Slice(result.Members, func(i, j int) bool {
|
||||
if result.Members[i].Role != result.Members[j].Role {
|
||||
return result.Members[i].Role < result.Members[j].Role
|
||||
}
|
||||
if result.Members[i].Name != result.Members[j].Name {
|
||||
return result.Members[i].Name < result.Members[j].Name
|
||||
}
|
||||
return result.Members[i].ID < result.Members[j].ID
|
||||
})
|
||||
result.Errors = make([]PoolError, 0, len(item.Errors))
|
||||
for _, fault := range item.Errors {
|
||||
if strings.TrimSpace(fault.ID) == "" || len(fault.ID) > 128 {
|
||||
return Pool{}, errors.New("pool error identity is invalid")
|
||||
}
|
||||
result.Errors = append(result.Errors, PoolError{ID: fault.ID, Kind: bounded(fault.Kind, "filesystem"), Message: bounded(fault.Message, "pool error"), Count: fault.Count, ObservedAt: fault.ObservedAt.UTC()})
|
||||
}
|
||||
sort.Slice(result.Errors, func(i, j int) bool { return result.Errors[i].ID < result.Errors[j].ID })
|
||||
if item.Scrub != nil {
|
||||
value, err := normalizeScrub(*item.Scrub)
|
||||
if err != nil {
|
||||
return Pool{}, err
|
||||
}
|
||||
result.Scrub = &value
|
||||
}
|
||||
result.ScrubHistory = make([]Scrub, 0, len(item.ScrubHistory))
|
||||
for _, scrub := range item.ScrubHistory {
|
||||
value, err := normalizeScrub(scrub)
|
||||
if err != nil {
|
||||
return Pool{}, err
|
||||
}
|
||||
result.ScrubHistory = append(result.ScrubHistory, value)
|
||||
}
|
||||
sort.SliceStable(result.ScrubHistory, func(i, j int) bool { return scrubTime(result.ScrubHistory[i]).After(scrubTime(result.ScrubHistory[j])) })
|
||||
for _, member := range result.Members {
|
||||
if member.State == "offline" || member.State == "missing" || member.State == "faulted" {
|
||||
if result.State == StateHealthy {
|
||||
result.State = StateDegraded
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(result.Errors) > 0 && result.State == StateHealthy {
|
||||
result.State = StateDegraded
|
||||
}
|
||||
if result.Scrub != nil && (result.Scrub.State == "failed" || result.Scrub.Errors > 0) && result.State == StateHealthy {
|
||||
result.State = StateDegraded
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func canonicalIdentity(value string) string { return strings.ToLower(strings.TrimSpace(value)) }
|
||||
func capacitySeverity(utilization float64, total uint64, policy Policy) string {
|
||||
if total == 0 {
|
||||
return StateUnknown
|
||||
}
|
||||
if utilization >= policy.CriticalUtilizationPercent {
|
||||
return "critical"
|
||||
}
|
||||
if utilization >= policy.WarningUtilizationPercent {
|
||||
return "attention"
|
||||
}
|
||||
return "normal"
|
||||
}
|
||||
func normalizeState(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
switch value {
|
||||
case StateHealthy, StateDegraded, StateFaulted, StateUnknown:
|
||||
return value
|
||||
default:
|
||||
return StateUnknown
|
||||
}
|
||||
}
|
||||
func normalizeMemberState(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
switch value {
|
||||
case "online", "degraded", "offline", "missing", "faulted", "unknown":
|
||||
return value
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
func normalizeCapabilities(value Capabilities) Capabilities {
|
||||
values := []*string{&value.Members, &value.Capacity, &value.Redundancy, &value.Scrub, &value.FilesystemErrors, &value.Performance, &value.SSDWear, &value.MoverSignals}
|
||||
for _, item := range values {
|
||||
switch strings.ToLower(strings.TrimSpace(*item)) {
|
||||
case CapabilityAvailable:
|
||||
*item = CapabilityAvailable
|
||||
case CapabilityUnsupported:
|
||||
*item = CapabilityUnsupported
|
||||
default:
|
||||
*item = CapabilityUnavailable
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
func normalizeScrub(item RawScrub) (Scrub, error) {
|
||||
if strings.TrimSpace(item.ID) == "" || len(item.ID) > 128 || item.ProgressPercent < 0 || item.ProgressPercent > 100 || math.IsNaN(item.ProgressPercent) || math.IsInf(item.ProgressPercent, 0) {
|
||||
return Scrub{}, errors.New("invalid pool scrub")
|
||||
}
|
||||
if item.CompletedAt != nil && item.StartedAt != nil && item.CompletedAt.Before(*item.StartedAt) {
|
||||
return Scrub{}, errors.New("pool scrub completed before start")
|
||||
}
|
||||
return Scrub{ID: item.ID, State: bounded(item.State, "unknown"), ProgressPercent: item.ProgressPercent, Errors: item.Errors, BytesChecked: item.BytesChecked, StartedAt: utcPtr(item.StartedAt), CompletedAt: utcPtr(item.CompletedAt), Result: bounded(item.Result, "")}, nil
|
||||
}
|
||||
func utcPtr(value *time.Time) *time.Time {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
result := value.UTC()
|
||||
return &result
|
||||
}
|
||||
func scrubTime(value Scrub) time.Time {
|
||||
if value.CompletedAt != nil {
|
||||
return *value.CompletedAt
|
||||
}
|
||||
if value.StartedAt != nil {
|
||||
return *value.StartedAt
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
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 bounded(value, fallback string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
if len(value) > 128 {
|
||||
return value[:128]
|
||||
}
|
||||
return value
|
||||
}
|
||||
func PoolByID(snapshot Snapshot, id string) (Pool, bool) {
|
||||
for _, item := range snapshot.Pools {
|
||||
if item.ID == id {
|
||||
return item, true
|
||||
}
|
||||
}
|
||||
return Pool{}, false
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Severity string `json:"severity"`
|
||||
EntityID string `json:"entityId"`
|
||||
OccurredAt time.Time `json:"occurredAt"`
|
||||
Attributes map[string]string `json:"attributes,omitempty"`
|
||||
}
|
||||
|
||||
func TransitionEvents(previous, current Snapshot) []Event {
|
||||
var events []Event
|
||||
at := current.ObservedAt
|
||||
if at.IsZero() {
|
||||
at = current.ReceivedAt
|
||||
}
|
||||
if at.IsZero() {
|
||||
at = time.Now().UTC()
|
||||
}
|
||||
at = at.UTC()
|
||||
add := func(kind, severity, id string, attrs map[string]string) {
|
||||
events = append(events, Event{ID: fmt.Sprintf("%s:%s:%s", kind, id, at.Format(time.RFC3339Nano)), Type: kind, Severity: severity, EntityID: id, OccurredAt: at, Attributes: attrs})
|
||||
}
|
||||
previousByID := map[string]Pool{}
|
||||
for _, item := range previous.Pools {
|
||||
previousByID[item.ID] = item
|
||||
}
|
||||
for _, item := range current.Pools {
|
||||
old, ok := previousByID[item.ID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if old.State != item.State {
|
||||
if item.State == StateDegraded {
|
||||
add("pool.degraded", "warning", item.ID, map[string]string{"state": item.State})
|
||||
}
|
||||
if item.State == StateFaulted {
|
||||
add("pool.faulted", "critical", item.ID, map[string]string{"state": item.State})
|
||||
}
|
||||
if item.State == StateHealthy && (old.State == StateDegraded || old.State == StateFaulted) {
|
||||
add("pool.recovered", "info", item.ID, map[string]string{"state": item.State})
|
||||
}
|
||||
}
|
||||
if old.Scrub == nil && item.Scrub != nil && item.Scrub.State == "failed" {
|
||||
add("pool.scrub_failed", "critical", item.ID, map[string]string{"errors": fmt.Sprint(item.Scrub.Errors)})
|
||||
} else if old.Scrub != nil && item.Scrub != nil && old.Scrub.State != item.Scrub.State && item.Scrub.State == "failed" {
|
||||
add("pool.scrub_failed", "critical", item.ID, map[string]string{"errors": fmt.Sprint(item.Scrub.Errors)})
|
||||
}
|
||||
}
|
||||
return events
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package pool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type rawProvider struct {
|
||||
snapshot RawSnapshot
|
||||
err error
|
||||
}
|
||||
|
||||
func (p rawProvider) Snapshot(context.Context) (RawSnapshot, error) { return p.snapshot, p.err }
|
||||
|
||||
func baseRaw(now time.Time) RawSnapshot {
|
||||
return RawSnapshot{Source: Source{ID: "fixture-pool", Type: "fixture"}, Pools: []RawPool{{ID: "cache", Name: "Cache", Filesystem: "btrfs", State: StateHealthy, UsableBytes: 1000, UsedBytes: 700, Profile: "raid1", Redundancy: "2 copies", Capabilities: Capabilities{Members: CapabilityAvailable, Capacity: CapabilityAvailable, Redundancy: CapabilityAvailable, Scrub: CapabilityAvailable, FilesystemErrors: CapabilityAvailable}, Members: []RawMember{{ID: "disk-b", Name: "Disk B", Role: "data", State: "online"}, {ID: "disk-a", Name: "Disk A", Role: "data", State: "online"}}, Scrub: &RawScrub{ID: "scrub-current", State: "running", ProgressPercent: 42}}}, ObservedAt: now, ReceivedAt: now}
|
||||
}
|
||||
|
||||
func TestNormalizePoolSortsMembersAndPreservesFilesystemCapabilities(t *testing.T) {
|
||||
now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
||||
got, err := Normalize(baseRaw(now), now, Limits{}, Policy{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Total != 1 || got.Pools[0].Filesystem != "btrfs" || got.Pools[0].Members[0].ID != "disk-a" || got.Pools[0].FreeBytes != 300 || got.Pools[0].UtilizationPercent != 70 {
|
||||
t.Fatalf("snapshot=%+v", got)
|
||||
}
|
||||
if got.Pools[0].Capabilities.Scrub != CapabilityAvailable || got.Pools[0].Capabilities.SSDWear != CapabilityUnavailable {
|
||||
t.Fatalf("capabilities=%+v", got.Pools[0].Capabilities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDegradedMemberAndErrors(t *testing.T) {
|
||||
now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
||||
raw := baseRaw(now)
|
||||
raw.Pools[0].Members[0].State = "missing"
|
||||
raw.Pools[0].Errors = []RawError{{ID: "checksum", Kind: "filesystem", Message: "checksum errors", Count: 2}}
|
||||
got, err := Normalize(raw, now, Limits{}, Policy{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Pools[0].State != StateDegraded || len(got.Pools[0].Errors) != 1 {
|
||||
t.Fatalf("pool=%+v", got.Pools[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeStaleMakesPoolUnknownAndRejectsInvalidPayload(t *testing.T) {
|
||||
now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
||||
raw := baseRaw(now.Add(-2 * time.Minute))
|
||||
got, err := Normalize(raw, now, Limits{}, Policy{FreshnessMaxAge: time.Minute})
|
||||
if err != nil || got.Source.Freshness != Stale || got.Source.State != StateUnknown || got.Pools[0].State != StateUnknown {
|
||||
t.Fatalf("stale=%+v err=%v", got, err)
|
||||
}
|
||||
raw = baseRaw(now)
|
||||
raw.Pools[0].UsedBytes = 1001
|
||||
if _, err = Normalize(raw, now, Limits{}, Policy{}); err == nil {
|
||||
t.Fatal("expected capacity validation")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_, err = (Adapter{Source: rawProvider{snapshot: raw}}).Snapshot(ctx)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrubHistoryAndTransitionEventsAreDeterministic(t *testing.T) {
|
||||
now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
||||
old := baseRaw(now)
|
||||
old.Pools[0].Scrub = nil
|
||||
current := baseRaw(now)
|
||||
current.Pools[0].State = StateFaulted
|
||||
current.Pools[0].Scrub.State = "failed"
|
||||
current.Pools[0].Scrub.Errors = 3
|
||||
previous, _ := Normalize(old, now, Limits{}, Policy{})
|
||||
next, _ := Normalize(current, now, Limits{}, Policy{})
|
||||
events := TransitionEvents(previous, next)
|
||||
if len(events) != 2 || events[0].Type != "pool.faulted" || events[1].Type != "pool.scrub_failed" {
|
||||
t.Fatalf("events=%+v", events)
|
||||
}
|
||||
if events[0].ID == "" || events[1].EntityID != "cache" {
|
||||
t.Fatalf("events=%+v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeTargetScale(t *testing.T) {
|
||||
now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
||||
raw := RawSnapshot{ObservedAt: now, ReceivedAt: now, Pools: make([]RawPool, 0, 64)}
|
||||
for poolIndex := 0; poolIndex < 64; poolIndex++ {
|
||||
item := RawPool{ID: fmt.Sprintf("pool-%03d", poolIndex), Name: fmt.Sprintf("Pool %03d", poolIndex), Filesystem: "zfs", State: StateHealthy, UsableBytes: 1_000_000, UsedBytes: 400_000, Capabilities: Capabilities{Members: CapabilityAvailable, Capacity: CapabilityAvailable, Redundancy: CapabilityAvailable, Scrub: CapabilityUnsupported}}
|
||||
for memberIndex := 0; memberIndex < 16; memberIndex++ {
|
||||
item.Members = append(item.Members, RawMember{ID: fmt.Sprintf("disk-%03d-%02d", poolIndex, memberIndex), Name: fmt.Sprintf("Disk %02d", memberIndex), State: "online"})
|
||||
}
|
||||
raw.Pools = append(raw.Pools, item)
|
||||
}
|
||||
got, err := Normalize(raw, now, Limits{MaxPools: 64, MaxMembers: 1024}, Policy{})
|
||||
if err != nil || got.Total != 64 || len(got.Pools[0].Members) != 16 || got.Pools[0].Capabilities.Scrub != CapabilityUnsupported {
|
||||
t.Fatalf("target-scale total=%d first=%+v err=%v", got.Total, got.Pools[0], err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapacitySeverityDoesNotRewritePoolHealth(t *testing.T) {
|
||||
now := time.Date(2026, 8, 11, 23, 45, 0, 0, time.UTC)
|
||||
item := RawPool{ID: " CACHE ", Name: "cache", Filesystem: "zfs", State: StateHealthy, UsableBytes: 1000, UsedBytes: 999, Capabilities: Capabilities{Capacity: CapabilityAvailable}}
|
||||
got, err := Normalize(RawSnapshot{ObservedAt: now, ReceivedAt: now, Pools: []RawPool{item, item}}, now, Limits{}, Policy{})
|
||||
if err != nil || got.Total != 1 {
|
||||
t.Fatalf("pool duplicate was not idempotent: %+v err=%v", got, err)
|
||||
}
|
||||
if got.Pools[0].ID != "cache" || got.Pools[0].State != StateHealthy || got.Pools[0].CapacitySeverity != "critical" {
|
||||
t.Fatalf("pool capacity rewrote health: %+v", got.Pools[0])
|
||||
}
|
||||
conflict := item
|
||||
conflict.ID = "cache"
|
||||
conflict.UsedBytes = 998
|
||||
if _, err := Normalize(RawSnapshot{ObservedAt: now, ReceivedAt: now, Pools: []RawPool{item, conflict}}, now, Limits{}, Policy{}); err == nil {
|
||||
t.Fatal("conflicting duplicate pool must fail closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolMembersDeduplicateAndRejectConflictingRelations(t *testing.T) {
|
||||
now := time.Date(2026, 8, 11, 23, 45, 0, 0, time.UTC)
|
||||
member := RawMember{ID: " DEVICE-1 ", Name: "device1", Role: "data", State: "online", CapacityBytes: 100}
|
||||
item := RawPool{ID: "pool", Name: "pool", Filesystem: "zfs", State: StateHealthy, UsableBytes: 100, Members: []RawMember{member, member}}
|
||||
got, err := Normalize(RawSnapshot{ObservedAt: now, ReceivedAt: now, Pools: []RawPool{item}}, now, Limits{}, Policy{})
|
||||
if err != nil || len(got.Pools[0].Members) != 1 || got.Pools[0].Members[0].ID != "device-1" {
|
||||
t.Fatalf("members=%+v err=%v", got.Pools[0].Members, err)
|
||||
}
|
||||
item.Members[1].Role = "parity"
|
||||
if _, err := Normalize(RawSnapshot{ObservedAt: now, ReceivedAt: now, Pools: []RawPool{item}}, now, Limits{}, Policy{}); err == nil {
|
||||
t.Fatal("conflicting pool-member relation must fail closed")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user