From 803d217e181ade6dbaf483f3e2cd422c2b370023 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 06:29:41 +0100 Subject: [PATCH] Apply Unraid empty-slot semantics in storage health Use the reviewed placeholder-filesystem rule when storage health assesses structured Unraid state, while retaining explicit DISK_NP_MISSING members as critical evidence. Change-source: pulse-maintainer Contract-Neutral: Unraid fsType=auto placeholder normalization is applied consistently at storage-health assessment without changing contracts --- internal/storagehealth/topology.go | 6 +++++- internal/storagehealth/topology_test.go | 24 +++++++++++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/internal/storagehealth/topology.go b/internal/storagehealth/topology.go index c8c6b3dde..25b61af35 100644 --- a/internal/storagehealth/topology.go +++ b/internal/storagehealth/topology.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/rcourtman/pulse-go-rewrite/internal/models" + unraidstatus "github.com/rcourtman/pulse-go-rewrite/internal/unraid" ) func AssessHostRAIDArray(array models.HostRAIDArray) Assessment { @@ -346,13 +347,16 @@ func unraidDiskStateCounts(storage models.HostUnraidStorage) (disabled, invalid, func isUnraidEmptySlot(disk models.HostUnraidDisk) bool { rawStatus := strings.ToUpper(strings.TrimSpace(disk.RawStatus)) status := strings.ToLower(strings.TrimSpace(disk.Status)) + if unraidstatus.IsExplicitMissingMember(rawStatus) { + return false + } if !strings.Contains(rawStatus, "DISK_NP") && status != "missing" { return false } return strings.TrimSpace(disk.Device) == "" && strings.TrimSpace(disk.Model) == "" && strings.TrimSpace(disk.Serial) == "" && - strings.TrimSpace(disk.Filesystem) == "" && + !unraidstatus.HasMeaningfulFilesystem(disk.Filesystem) && disk.SizeBytes == 0 } diff --git a/internal/storagehealth/topology_test.go b/internal/storagehealth/topology_test.go index 9352106c1..e61c095a1 100644 --- a/internal/storagehealth/topology_test.go +++ b/internal/storagehealth/topology_test.go @@ -237,7 +237,8 @@ func TestAssessUnraidStorageTreatsEmptyNoPresentSlotsAsUnprotected(t *testing.T) {Name: "parity", Role: "parity", Status: "missing", RawStatus: "DISK_NP_DSBL"}, {Name: "md1p1", Device: "/dev/sde", Status: "online", RawStatus: "DISK_OK", SizeBytes: 5860522532}, {Name: "disk5", Role: "data", Status: "missing", RawStatus: "DISK_NP", Slot: 5}, - {Name: "parity2", Role: "parity", Status: "missing", RawStatus: "DISK_NP_DSBL", Slot: 29}, + {Name: "disk6", Role: "data", Status: "missing", RawStatus: "DISK_NP", Filesystem: "auto", Slot: 6}, + {Name: "parity2", Role: "parity", Status: "missing", RawStatus: "DISK_NP_DSBL", Filesystem: "auto", Slot: 29}, }, }) @@ -281,6 +282,27 @@ func TestAssessUnraidStorageUsesDiskStatusesOverAggregateCounters(t *testing.T) } } +func TestAssessUnraidStoragePreservesExplicitMissingMemberWithoutIdentity(t *testing.T) { + assessment := AssessUnraidStorage(models.HostUnraidStorage{ + ArrayStarted: true, + Disks: []models.HostUnraidDisk{ + {Name: "parity", Role: "parity", Status: "online"}, + {Name: "disk1", Role: "data", Status: "online"}, + {Name: "disk2", Role: "data", Status: "missing", RawStatus: "DISK_NP_MISSING", Filesystem: "auto"}, + }, + }) + + if assessment.Level != RiskCritical { + t.Fatalf("Level = %q, want %q", assessment.Level, RiskCritical) + } + for _, reason := range assessment.Reasons { + if reason.Code == "unraid_missing_disks" { + return + } + } + t.Fatalf("explicit missing member without identity was not preserved: %+v", assessment.Reasons) +} + func TestAssessUnraidStoragePreservesGenuineStructuredMissingDisk(t *testing.T) { assessment := AssessUnraidStorage(models.HostUnraidStorage{ ArrayStarted: true,