This commit is contained in:
@@ -0,0 +1,398 @@
|
||||
package array
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const ContractVersion = "v1"
|
||||
|
||||
const (
|
||||
StateOperational = "operational"
|
||||
StateDegraded = "degraded"
|
||||
StateMissing = "missing"
|
||||
StateUnknown = "unknown"
|
||||
Fresh = "fresh"
|
||||
Stale = "stale"
|
||||
Unavailable = "unavailable"
|
||||
)
|
||||
|
||||
type Limits struct {
|
||||
MaxMembers int
|
||||
MaxHistory int
|
||||
MaxErrors int
|
||||
}
|
||||
|
||||
func (l Limits) withDefaults() Limits {
|
||||
if l.MaxMembers == 0 {
|
||||
l.MaxMembers = 64
|
||||
}
|
||||
if l.MaxHistory == 0 {
|
||||
l.MaxHistory = 64
|
||||
}
|
||||
if l.MaxErrors == 0 {
|
||||
l.MaxErrors = 20
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func (l Limits) Validate() error {
|
||||
if l.MaxMembers < 1 || l.MaxMembers > 256 || l.MaxHistory < 1 || l.MaxHistory > 256 || l.MaxErrors < 1 || l.MaxErrors > 100 {
|
||||
return errors.New("array limits are outside safe bounds")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Policy struct{ FreshnessMaxAge time.Duration }
|
||||
|
||||
func (p Policy) withDefaults() Policy {
|
||||
if p.FreshnessMaxAge == 0 {
|
||||
p.FreshnessMaxAge = 60 * time.Second
|
||||
}
|
||||
return p
|
||||
}
|
||||
func (p Policy) Validate() error {
|
||||
if p.FreshnessMaxAge <= 0 || p.FreshnessMaxAge > 24*time.Hour {
|
||||
return errors.New("array 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 RawMember struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
State string `json:"state"`
|
||||
CapacityBytes uint64 `json:"capacityBytes"`
|
||||
ReadBytes uint64 `json:"readBytes"`
|
||||
WriteBytes uint64 `json:"writeBytes"`
|
||||
}
|
||||
|
||||
type Member struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
State string `json:"state"`
|
||||
CapacityBytes uint64 `json:"capacityBytes"`
|
||||
ReadBytes uint64 `json:"readBytes"`
|
||||
WriteBytes uint64 `json:"writeBytes"`
|
||||
}
|
||||
|
||||
type RawParity struct {
|
||||
Present bool `json:"present"`
|
||||
State string `json:"state"`
|
||||
Errors uint64 `json:"errors"`
|
||||
}
|
||||
type Parity struct {
|
||||
Present bool `json:"present"`
|
||||
State string `json:"state"`
|
||||
Errors uint64 `json:"errors"`
|
||||
}
|
||||
|
||||
type RawCheck struct {
|
||||
ID string `json:"id"`
|
||||
State string `json:"state"`
|
||||
ProgressPercent float64 `json:"progressPercent"`
|
||||
SpeedBytesPerSecond uint64 `json:"speedBytesPerSecond"`
|
||||
Errors uint64 `json:"errors"`
|
||||
StartedAt *time.Time `json:"startedAt,omitempty"`
|
||||
CompletedAt *time.Time `json:"completedAt,omitempty"`
|
||||
}
|
||||
type Check struct {
|
||||
ID string `json:"id"`
|
||||
State string `json:"state"`
|
||||
ProgressPercent float64 `json:"progressPercent"`
|
||||
SpeedBytesPerSecond uint64 `json:"speedBytesPerSecond"`
|
||||
Errors uint64 `json:"errors"`
|
||||
StartedAt *time.Time `json:"startedAt,omitempty"`
|
||||
CompletedAt *time.Time `json:"completedAt,omitempty"`
|
||||
}
|
||||
|
||||
type RawSnapshot struct {
|
||||
Source Source `json:"source"`
|
||||
State string `json:"state"`
|
||||
Parity RawParity `json:"parity"`
|
||||
Members []RawMember `json:"members"`
|
||||
CurrentCheck *RawCheck `json:"currentCheck,omitempty"`
|
||||
History []RawCheck `json:"history,omitempty"`
|
||||
ObservedAt time.Time `json:"observedAt"`
|
||||
ReceivedAt time.Time `json:"receivedAt"`
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
ContractVersion string `json:"contractVersion"`
|
||||
Source Source `json:"source"`
|
||||
State string `json:"state"`
|
||||
Parity Parity `json:"parity"`
|
||||
Members []Member `json:"members"`
|
||||
CurrentCheck *Check `json:"currentCheck,omitempty"`
|
||||
History []Check `json:"history"`
|
||||
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(), "array", "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()
|
||||
}
|
||||
return Snapshot{ContractVersion: ContractVersion, Source: Source{ID: id, Type: typ, CapabilityVersion: ContractVersion, ReceivedAt: now.UTC(), Freshness: Unavailable, State: StateUnknown, Reason: reason}, State: StateUnknown, Members: []Member{}, History: []Check{}, ObservedAt: now.UTC(), ReceivedAt: now.UTC()}
|
||||
}
|
||||
|
||||
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("array observation is materially in the future")
|
||||
}
|
||||
if len(raw.Members) > limits.MaxMembers || len(raw.History) > limits.MaxHistory {
|
||||
return Snapshot{}, errors.New("array payload exceeds bounds")
|
||||
}
|
||||
if raw.CurrentCheck != nil {
|
||||
if err := validateCheck(*raw.CurrentCheck); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
}
|
||||
for _, item := range raw.History {
|
||||
if err := validateCheck(item); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
}
|
||||
members := make([]Member, 0, len(raw.Members))
|
||||
seenMembers := make(map[string]RawMember, len(raw.Members))
|
||||
missing := 0
|
||||
for _, item := range raw.Members {
|
||||
item.ID = canonicalIdentity(item.ID)
|
||||
if previous, ok := seenMembers[item.ID]; ok {
|
||||
if reflect.DeepEqual(previous, item) {
|
||||
continue
|
||||
}
|
||||
return Snapshot{}, errors.New("conflicting duplicate array member identity")
|
||||
}
|
||||
seenMembers[item.ID] = item
|
||||
if strings.TrimSpace(item.ID) == "" || strings.TrimSpace(item.Name) == "" || len(item.ID) > 128 || len(item.Name) > 255 {
|
||||
return Snapshot{}, errors.New("array member identity is invalid")
|
||||
}
|
||||
state := bounded(item.State, StateUnknown)
|
||||
role := bounded(item.Role, "data")
|
||||
if state == "missing" || state == "disabled" || state == "emulated" {
|
||||
missing++
|
||||
}
|
||||
members = append(members, Member{ID: item.ID, Name: item.Name, Role: role, State: state, CapacityBytes: item.CapacityBytes, ReadBytes: item.ReadBytes, WriteBytes: item.WriteBytes})
|
||||
}
|
||||
sort.Slice(members, func(i, j int) bool {
|
||||
if members[i].Role != members[j].Role {
|
||||
return members[i].Role < members[j].Role
|
||||
}
|
||||
if members[i].Name != members[j].Name {
|
||||
return members[i].Name < members[j].Name
|
||||
}
|
||||
return members[i].ID < members[j].ID
|
||||
})
|
||||
parity := Parity{Present: raw.Parity.Present, State: bounded(raw.Parity.State, StateUnknown), Errors: raw.Parity.Errors}
|
||||
state := bounded(raw.State, StateUnknown)
|
||||
if state != StateOperational && state != StateDegraded && state != StateMissing && state != StateUnknown {
|
||||
state = StateUnknown
|
||||
}
|
||||
if missing > 0 && state == StateOperational {
|
||||
state = StateDegraded
|
||||
}
|
||||
if parity.Errors > 0 && state == StateOperational {
|
||||
state = StateDegraded
|
||||
}
|
||||
source := raw.Source
|
||||
if source.ID == "" {
|
||||
source.ID = "array"
|
||||
}
|
||||
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 = state
|
||||
if now.Sub(raw.ObservedAt) > policy.FreshnessMaxAge {
|
||||
source.Freshness = Stale
|
||||
source.State = StateUnknown
|
||||
source.Reason = "stale_source"
|
||||
state = StateUnknown
|
||||
for i := range members {
|
||||
members[i].State = StateUnknown
|
||||
}
|
||||
}
|
||||
result := Snapshot{ContractVersion: ContractVersion, Source: source, State: state, Parity: parity, Members: members, ObservedAt: raw.ObservedAt.UTC(), ReceivedAt: raw.ReceivedAt.UTC()}
|
||||
if raw.CurrentCheck != nil {
|
||||
current := normalizeCheck(*raw.CurrentCheck)
|
||||
result.CurrentCheck = ¤t
|
||||
}
|
||||
result.History = make([]Check, 0, len(raw.History))
|
||||
for _, item := range raw.History {
|
||||
result.History = append(result.History, normalizeCheck(item))
|
||||
}
|
||||
sort.SliceStable(result.History, func(i, j int) bool { return checkTime(result.History[i]).After(checkTime(result.History[j])) })
|
||||
return result, nil
|
||||
}
|
||||
func canonicalIdentity(value string) string { return strings.ToLower(strings.TrimSpace(value)) }
|
||||
|
||||
func validateCheck(item RawCheck) 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 errors.New("invalid parity check")
|
||||
}
|
||||
if item.CompletedAt != nil && item.StartedAt != nil && item.CompletedAt.Before(*item.StartedAt) {
|
||||
return errors.New("parity check completed before start")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func normalizeCheck(item RawCheck) Check {
|
||||
result := Check{ID: item.ID, State: bounded(item.State, StateUnknown), ProgressPercent: item.ProgressPercent, SpeedBytesPerSecond: item.SpeedBytesPerSecond, Errors: item.Errors}
|
||||
if item.StartedAt != nil {
|
||||
value := item.StartedAt.UTC()
|
||||
result.StartedAt = &value
|
||||
}
|
||||
if item.CompletedAt != nil {
|
||||
value := item.CompletedAt.UTC()
|
||||
result.CompletedAt = &value
|
||||
}
|
||||
return result
|
||||
}
|
||||
func checkTime(item Check) time.Time {
|
||||
if item.CompletedAt != nil {
|
||||
return *item.CompletedAt
|
||||
}
|
||||
if item.StartedAt != nil {
|
||||
return *item.StartedAt
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
func bounded(value, fallback string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
if len(value) > 64 {
|
||||
return value[:64]
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
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 string, attrs map[string]string) {
|
||||
events = append(events, Event{ID: eventID(kind, current, at), Type: kind, Severity: severity, EntityID: "array", OccurredAt: at, Attributes: attrs})
|
||||
}
|
||||
if previous.State != current.State {
|
||||
switch current.State {
|
||||
case StateDegraded:
|
||||
add("array.degraded", "warning", map[string]string{"state": current.State})
|
||||
case StateMissing:
|
||||
add("array.missing", "critical", map[string]string{"state": current.State})
|
||||
case StateOperational:
|
||||
if previous.State == StateDegraded || previous.State == StateMissing {
|
||||
add("array.recovered", "info", map[string]string{"state": current.State})
|
||||
}
|
||||
}
|
||||
}
|
||||
if previous.Parity.State != current.Parity.State || previous.Parity.Errors != current.Parity.Errors {
|
||||
severity := "info"
|
||||
if current.Parity.Errors > 0 || current.Parity.State == "failed" {
|
||||
severity = "critical"
|
||||
}
|
||||
add("array.parity_changed", severity, map[string]string{"state": current.Parity.State, "errors": formatUint(current.Parity.Errors)})
|
||||
}
|
||||
return events
|
||||
}
|
||||
func eventID(kind string, snapshot Snapshot, at time.Time) string {
|
||||
return kind + ":" + snapshot.Source.ID + ":" + at.Format(time.RFC3339Nano)
|
||||
}
|
||||
func formatUint(value uint64) string {
|
||||
if value == 0 {
|
||||
return "0"
|
||||
}
|
||||
digits := make([]byte, 0, 20)
|
||||
for value > 0 {
|
||||
digits = append([]byte{byte('0' + value%10)}, digits...)
|
||||
value /= 10
|
||||
}
|
||||
return string(digits)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package array
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"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-array", Type: "fixture"}, State: StateOperational, Parity: RawParity{Present: true, State: "idle"}, Members: []RawMember{{ID: "disk1", Name: "Disk 1", Role: "data", State: "online", CapacityBytes: 100}, {ID: "parity", Name: "Parity", Role: "parity", State: "online", CapacityBytes: 100}}, ObservedAt: now, ReceivedAt: now}
|
||||
}
|
||||
|
||||
func TestNormalizeOperationalDegradedAndMissingFixtures(t *testing.T) {
|
||||
now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
||||
operational, err := Normalize(baseRaw(now), now, Limits{}, Policy{})
|
||||
if err != nil || operational.State != StateOperational || operational.Source.State != StateOperational {
|
||||
t.Fatalf("operational=%+v err=%v", operational, err)
|
||||
}
|
||||
degradedRaw := baseRaw(now)
|
||||
degradedRaw.Members[0].State = "emulated"
|
||||
degraded, err := Normalize(degradedRaw, now, Limits{}, Policy{})
|
||||
if err != nil || degraded.State != StateDegraded {
|
||||
t.Fatalf("degraded=%+v err=%v", degraded, err)
|
||||
}
|
||||
missingRaw := baseRaw(now)
|
||||
missingRaw.State = StateMissing
|
||||
missingRaw.Members[0].State = "missing"
|
||||
missing, err := Normalize(missingRaw, now, Limits{}, Policy{})
|
||||
if err != nil || missing.State != StateMissing {
|
||||
t.Fatalf("missing=%+v err=%v", missing, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeProgressSpeedHistoryAndDeterministicOrder(t *testing.T) {
|
||||
now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
||||
start := now.Add(-10 * time.Minute)
|
||||
older := now.Add(-2 * time.Hour)
|
||||
raw := baseRaw(now)
|
||||
raw.Members = append(raw.Members, RawMember{ID: "disk0", Name: "Disk 0", Role: "data", State: "online"})
|
||||
raw.CurrentCheck = &RawCheck{ID: "check-current", State: "running", ProgressPercent: 42.5, SpeedBytesPerSecond: 123456, StartedAt: &start}
|
||||
raw.History = []RawCheck{{ID: "old", State: "completed", ProgressPercent: 100, CompletedAt: &older}, {ID: "new", State: "failed", ProgressPercent: 87, Errors: 2, CompletedAt: &start}}
|
||||
got, err := Normalize(raw, now, Limits{}, Policy{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.CurrentCheck == nil || got.CurrentCheck.ProgressPercent != 42.5 || got.CurrentCheck.SpeedBytesPerSecond != 123456 {
|
||||
t.Fatalf("current check=%+v", got.CurrentCheck)
|
||||
}
|
||||
if len(got.History) != 2 || got.History[0].ID != "new" {
|
||||
t.Fatalf("history=%+v", got.History)
|
||||
}
|
||||
if got.Members[0].ID != "disk0" || got.Members[1].Role != "data" {
|
||||
t.Fatalf("members=%+v", got.Members)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeStaleIsUnknownAndContextCancellation(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.State != StateUnknown || got.Source.State != StateUnknown || got.Source.Freshness != Stale || got.Members[0].State != StateUnknown {
|
||||
t.Fatalf("stale=%+v err=%v", got, err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_, err = (Adapter{}).Snapshot(ctx)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransitionEventsAreBoundedAndDeterministic(t *testing.T) {
|
||||
at := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
||||
previous := UnknownSnapshot(at, "fixture-array", "fixture", "initial")
|
||||
current := previous
|
||||
current.ObservedAt = at
|
||||
current.Source.State = StateDegraded
|
||||
current.State = StateDegraded
|
||||
current.Parity.State = "failed"
|
||||
current.Parity.Errors = 3
|
||||
events := TransitionEvents(previous, current)
|
||||
if len(events) != 2 {
|
||||
t.Fatalf("events=%+v", events)
|
||||
}
|
||||
if events[0].Type != "array.degraded" || events[1].Type != "array.parity_changed" {
|
||||
t.Fatalf("events=%+v", events)
|
||||
}
|
||||
if events[0].ID == "" || !events[1].OccurredAt.Equal(at) {
|
||||
t.Fatalf("events=%+v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdapterUnknownFallbackAndProviderError(t *testing.T) {
|
||||
now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
||||
unknown, err := (Adapter{Now: func() time.Time { return now }}).Snapshot(context.Background())
|
||||
if err != nil || unknown.State != StateUnknown || unknown.Source.Reason != "source_unavailable" {
|
||||
t.Fatalf("unknown=%+v err=%v", unknown, err)
|
||||
}
|
||||
expected := errors.New("fixture")
|
||||
_, err = (Adapter{Source: rawProvider{err: expected}}).Snapshot(context.Background())
|
||||
if !errors.Is(err, expected) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArrayMembersDeduplicateByCanonicalPhysicalIdentity(t *testing.T) {
|
||||
now := time.Date(2026, 8, 11, 23, 45, 0, 0, time.UTC)
|
||||
raw := baseRaw(now)
|
||||
raw.Members = []RawMember{{ID: " DISK-1 ", Name: "disk1", Role: "data", State: "online", CapacityBytes: 100}, {ID: " DISK-1 ", Name: "disk1", Role: "data", State: "online", CapacityBytes: 100}}
|
||||
got, err := Normalize(raw, now, Limits{}, Policy{})
|
||||
if err != nil || len(got.Members) != 1 || got.Members[0].ID != "disk-1" {
|
||||
t.Fatalf("members=%+v err=%v", got.Members, err)
|
||||
}
|
||||
raw.Members[1].Role = "parity"
|
||||
if _, err := Normalize(raw, now, Limits{}, Policy{}); err == nil {
|
||||
t.Fatal("conflicting physical roles must fail closed")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user