Fix QNAP RAID role bitmap parsing

Addresses #1688.
This commit is contained in:
Richard Courtman
2026-08-06 23:39:15 +01:00
parent e84042a4d9
commit 99407ee74c
4 changed files with 94 additions and 4 deletions
@@ -1658,7 +1658,11 @@ the intentionally sparse public response.
baseline for Linux md arrays. `mdadm --detail` may enrich level, state,
member, UUID, and rebuild fields when available, but missing or failing
mdadm detail probes must not hide a kernel-reported md array from the
unified agent report.
unified agent report. Vendor fixed-width role maps must not be treated as
missing members: a QNAP system RAID1 array that reports `[24/2]` while both
active members occupy roles 24 and 25 has two configured members, not 22
failed disks; ordinary in-range degraded maps such as `[4/3] [UUU_]` retain
their missing-member semantics (#1688).
Server-side lifecycle admission must preserve that same continuity across
Pulse restart and upgrade. When a standalone host comes back with the same
durable machine/report identity and token continuity, the shared admission
+24
View File
@@ -251,6 +251,30 @@ func TestBuildReport(t *testing.T) {
mc.raidArraysFn = nil
})
t.Run("RAID collection preserves QNAP sparse role map health", func(t *testing.T) {
mc.raidArraysFn = func(ctx context.Context) ([]agentshost.RAIDArray, error) {
return parseMDStatArrays(`md13 : active raid1 sdb4[25] sda4[24]
458880 blocks super 1.0 [24/2] [UU______________________]
bitmap: 1/1 pages [4KB], 65536KB chunk`), nil
}
report, err := agent.buildReport(context.Background())
if err != nil {
t.Fatalf("buildReport failed: %v", err)
}
if len(report.RAID) != 1 {
t.Fatalf("expected 1 RAID array, got %d", len(report.RAID))
}
array := report.RAID[0]
if array.Device != "/dev/md13" || array.State != "active" {
t.Fatalf("unexpected QNAP RAID array summary: %+v", array)
}
if array.TotalDevices != 2 || array.ActiveDevices != 2 || array.WorkingDevices != 2 || array.FailedDevices != 0 {
t.Fatalf("unexpected QNAP RAID array counts: %+v", array)
}
mc.raidArraysFn = nil
})
// Test case 4: Ceph collection
t.Run("Ceph collection", func(t *testing.T) {
mc.cephStatusFn = func(ctx context.Context) (*CephClusterStatus, error) {
+33 -3
View File
@@ -214,9 +214,6 @@ func parseMDStatArraySection(deviceName string, section []string) agentshost.RAI
active, _ := strconv.Atoi(matches[2])
array.TotalDevices = total
array.ActiveDevices = active
if total > active && !strings.Contains(array.State, "degraded") {
array.State = appendMDStatState(array.State, "degraded")
}
}
if array.Operation == "" {
@@ -255,6 +252,15 @@ func parseMDStatArraySection(deviceName string, section []string) agentshost.RAI
if array.TotalDevices == 0 {
array.TotalDevices = array.ActiveDevices
}
if mdStatUsesSparseRoleBitmap(array, failedDevices) {
// QNAP's system RAID1 arrays expose a fixed-width bay/role bitmap such
// as [24/2] [UU______________________], while their active members use
// roles 24 and 25. Those unused bitmap positions are not failed disks.
array.TotalDevices = array.ActiveDevices
}
if array.TotalDevices > array.ActiveDevices && !strings.Contains(array.State, "degraded") {
array.State = appendMDStatState(array.State, "degraded")
}
if failedDevices == 0 && array.TotalDevices > array.ActiveDevices {
failedDevices = array.TotalDevices - array.ActiveDevices
}
@@ -265,6 +271,30 @@ func parseMDStatArraySection(deviceName string, section []string) agentshost.RAI
return array
}
func mdStatUsesSparseRoleBitmap(array agentshost.RAIDArray, failedDevices int) bool {
if !strings.EqualFold(array.State, "active") ||
!strings.EqualFold(array.Level, "raid1") ||
failedDevices != 0 ||
array.TotalDevices <= array.ActiveDevices ||
array.ActiveDevices == 0 {
return false
}
activeMembers := 0
for _, device := range array.Devices {
state := strings.ToLower(device.State)
if strings.Contains(state, "faulty") || strings.Contains(state, "spare") {
continue
}
activeMembers++
if device.Slot < array.TotalDevices {
return false
}
}
return activeMembers == array.ActiveDevices
}
func parseMDStatDeviceToken(token string, arrayState string) (agentshost.RAIDDevice, bool) {
token = strings.Trim(token, ",")
matches := mdTokenRe.FindStringSubmatch(token)
+32
View File
@@ -699,6 +699,38 @@ unused devices: <none>`
}
}
func TestParseMDStatArraysQNAPSystemRAIDRoleBitmap(t *testing.T) {
mdstat := `Personalities : [linear] [raid0] [raid1] [raid10] [raid6] [raid5] [raid4] [multipath]
md1 : active raid1 sdb3[3] sda3[2]
3897062912 blocks super 1.0 [2/2] [UU]
md321 : active raid1 sda5[2] sdb5[0]
6702656 blocks super 1.0 [2/2] [UU]
bitmap: 0/1 pages [0KB], 65536KB chunk
md13 : active raid1 sdb4[25] sda4[24]
458880 blocks super 1.0 [24/2] [UU______________________]
bitmap: 1/1 pages [4KB], 65536KB chunk
md9 : active raid1 sdb1[25] sda1[24]
530048 blocks super 1.0 [24/2] [UU______________________]
bitmap: 1/1 pages [4KB], 65536KB chunk`
arrays := parseMDStatArrays(mdstat)
if len(arrays) != 4 {
t.Fatalf("array count = %d, want 4", len(arrays))
}
for _, index := range []int{2, 3} {
array := arrays[index]
if array.State != "active" {
t.Fatalf("%s state = %q, want active", array.Device, array.State)
}
if array.TotalDevices != 2 || array.ActiveDevices != 2 || array.WorkingDevices != 2 || array.FailedDevices != 0 {
t.Fatalf("unexpected %s device counts: %+v", array.Device, array)
}
}
}
func TestListArrayDevicesError(t *testing.T) {
withReadProcMDStat(t, func() ([]byte, error) {
return nil, errors.New("read failed")