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
+537
View File
@@ -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
}
+135
View File
@@ -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")
}
}