fix(unifiedresources): merge PVE and agent rows for the same RAID array volume

The PVE disks/list poll and the agent SMART report both observe a RAID
controller's exported volume, but PVE surfaces its NAA identifier as a
bare-hex serial while smartctl reports the same value as a naa.-prefixed
WWN with no serial. The linked-disk join compared serial to serial and
WWN to WWN verbatim, so the volume rendered twice (/dev/sda and sda)
with independent metric histories.

Hardware identity comparison now normalizes reporter framing (naa., eui.,
wwn-, 0x prefixes, case) and folds serial and WWN together before
comparing. Values are never truncated: sibling volumes on one controller
share their leading WWN bytes, and a truncated udev ID_WWN must stay
unequal to a full identifier. Placeholder serials no longer count as an
identity match.

Refs #1720
This commit is contained in:
Pulse Test
2026-08-28 06:20:01 +01:00
parent bcf12535fa
commit 674ca18968
4 changed files with 236 additions and 3 deletions
+4 -3
View File
@@ -3050,9 +3050,10 @@ func (rr *ResourceRegistry) resolveLinkedPhysicalDisk(source DataSource, incomin
continue
}
identityMatch :=
(incomingSerial != "" && strings.EqualFold(incomingSerial, existing.PhysicalDisk.Serial)) ||
(incomingWWN != "" && strings.EqualFold(incomingWWN, existing.PhysicalDisk.WWN))
identityMatch := diskinventory.HardwareIdentityMatch(
incomingSerial, incomingWWN,
existing.PhysicalDisk.Serial, existing.PhysicalDisk.WWN,
)
existingDevice := strings.ToLower(normalizePhysicalDiskDeviceToken(existing.PhysicalDisk.DevPath))
agentReportsSAS := (source == SourceProxmox && strings.EqualFold(existing.PhysicalDisk.DiskType, "sas")) ||
(source == SourceAgent && strings.EqualFold(incoming.PhysicalDisk.DiskType, "sas"))
+113
View File
@@ -6296,3 +6296,116 @@ func TestIssue1720AgentControllerMembersInheritOwningProxmoxScope(t *testing.T)
t.Fatalf("controller members collapsed or disappeared: %v", targets)
}
}
// Follow-up regression on issue #1720: the PVE disks/list poll and the agent
// SMART report both observe the same RAID array volume, but PVE surfaces the
// NAA identifier as a bare-hex serial while smartctl reports it as a
// naa.-prefixed WWN with no serial at all. The two observations must join into
// one resource instead of rendering the volume twice with split histories.
// Sibling volumes on one controller share their truncated udev ID_WWN
// (0x + leading 16 hex), so the join must never match on truncated forms.
func TestIssue1720ArrayVolumeMergesBareSerialWithPrefixedWWN(t *testing.T) {
now := time.Date(2026, 8, 27, 10, 0, 0, 0, time.UTC)
registry := NewRegistry(nil)
registry.IngestSnapshot(models.StateSnapshot{
Nodes: []models.Node{{
ID: "cluster-a-indus",
Name: "indus",
Instance: "cluster-a",
ClusterName: "cluster-a",
LinkedAgentID: "host-1",
Status: "online",
LastSeen: now,
}},
PhysicalDisks: []models.PhysicalDisk{
{
ID: "cluster-a-indus--dev-sda",
Node: "indus",
Instance: "cluster-a",
DevPath: "/dev/sda",
Model: "PERC_H730_Mini",
Serial: "61866da053481f002f58a43b22f964a7",
WWN: "0x61866da053481f00",
Type: "sata",
Health: "UNKNOWN",
Size: 479_559_942_144,
LastChecked: now,
},
{
ID: "cluster-a-indus--dev-sdb",
Node: "indus",
Instance: "cluster-a",
DevPath: "/dev/sdb",
Model: "PERC_H730_Mini",
Serial: "61866da053481f0030543ecb1d3b4cca",
WWN: "0x61866da053481f00",
Type: "sata",
Health: "UNKNOWN",
Size: 3_000_034_656_256,
LastChecked: now,
},
},
Hosts: []models.Host{{
ID: "host-1",
Hostname: "indus",
LinkedNodeID: "cluster-a-indus",
Status: "online",
LastSeen: now,
Sensors: models.HostSensorSummary{SMART: []models.HostDiskSMART{
{
Device: "sda",
Model: "PERC H730 Mini",
WWN: "naa.61866da053481f002f58a43b22f964a7",
Type: "sata",
Controller: "0000:02:00.0",
Target: "0:2:0:0",
SizeBytes: 479_559_942_144,
Health: "UNKNOWN",
Standby: true,
},
{
Device: "sdb",
Model: "PERC H730 Mini",
WWN: "naa.61866da053481f0030543ecb1d3b4cca",
Type: "sata",
Controller: "0000:02:00.0",
Target: "0:2:1:0",
SizeBytes: 3_000_034_656_256,
Health: "UNKNOWN",
Standby: true,
},
}},
}},
})
disks := registry.ListByType(ResourceTypePhysicalDisk)
if len(disks) != 2 {
devs := make([]string, 0, len(disks))
for _, disk := range disks {
if disk.PhysicalDisk != nil {
devs = append(devs, disk.PhysicalDisk.DevPath)
}
}
t.Fatalf("physical disk resources = %d (%v), want 2 merged volumes", len(disks), devs)
}
byPath := make(map[string]Resource, len(disks))
for _, disk := range disks {
if disk.PhysicalDisk == nil {
t.Fatalf("disk %q has no physical-disk facet", disk.ID)
}
byPath[disk.PhysicalDisk.DevPath] = disk
}
for _, devPath := range []string{"/dev/sda", "/dev/sdb"} {
disk, ok := byPath[devPath]
if !ok {
t.Fatalf("no merged resource kept canonical devPath %q: %v", devPath, byPath)
}
if !containsDataSource(disk.Sources, SourceAgent) || !containsDataSource(disk.Sources, SourceProxmox) {
t.Fatalf("disk %q sources = %v, want merged proxmox+agent", devPath, disk.Sources)
}
}
if byPath["/dev/sda"].PhysicalDisk.SizeBytes == byPath["/dev/sdb"].PhysicalDisk.SizeBytes {
t.Fatalf("sibling volumes collapsed into one identity: %+v", byPath)
}
}
+48
View File
@@ -66,6 +66,54 @@ func IsUsableHardwareID(value string) bool {
return !allZero && !allF
}
// normalizeHardwareID canonicalizes a serial or WWN for cross-source
// comparison. Reporters disagree on framing, not identity: smartctl emits
// naa./eui.-prefixed WWNs, udev emits wwn-0x tokens, and PVE surfaces bare
// hex. Values are only ever prefix-stripped and case-folded, never truncated:
// sibling volumes on one RAID controller share their leading WWN bytes, so a
// truncated form must stay unequal to the full identifier.
func normalizeHardwareID(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if !IsUsableHardwareID(value) {
return ""
}
for {
trimmed := value
for _, prefix := range []string{"naa.", "eui.", "wwn-", "0x"} {
trimmed = strings.TrimPrefix(trimmed, prefix)
}
if trimmed == value {
break
}
value = trimmed
}
if !IsUsableHardwareID(value) {
return ""
}
return value
}
// HardwareIdentityMatch reports whether two disk observations carry the same
// stable hardware identity. Serial and WWN are folded together because
// sources disagree on which field holds the durable identifier: PVE reports a
// RAID array volume's NAA identifier as its serial while smartctl reports the
// same value as a naa.-prefixed WWN with no serial at all.
func HardwareIdentityMatch(leftSerial, leftWWN, rightSerial, rightWWN string) bool {
left := [2]string{normalizeHardwareID(leftSerial), normalizeHardwareID(leftWWN)}
right := [2]string{normalizeHardwareID(rightSerial), normalizeHardwareID(rightWWN)}
for _, l := range left {
if l == "" {
continue
}
for _, r := range right {
if r != "" && l == r {
return true
}
}
}
return false
}
// IsControllerMemberTarget reports whether target addresses one member behind
// a shared controller block path. Controller grammars vary after the numeric
// member prefix (for example megaraid,7, areca,1/1, and sssraid,0,1).
+71
View File
@@ -54,3 +54,74 @@ func TestPreferredIDRejectsPlaceholderHardwareIdentity(t *testing.T) {
t.Fatal("real disk serial/WWN was rejected")
}
}
func TestHardwareIdentityMatch(t *testing.T) {
cases := []struct {
name string
aSerial, aWWN string
bSerial, bWWN string
want bool
}{
{
name: "pve bare-hex serial matches smartctl naa wwn",
aSerial: "61866da053481f002f58a43b22f964a7",
aWWN: "0x61866da053481f00",
bWWN: "naa.61866da053481f002f58a43b22f964a7",
want: true,
},
{
name: "same serial different case",
aSerial: "zr5dlayj",
bSerial: "ZR5DLAYJ",
want: true,
},
{
name: "udev wwn-0x token matches naa wwn",
aWWN: "wwn-0x5000c500abcdef01",
bWWN: "naa.5000c500abcdef01",
want: true,
},
{
name: "eui prefix matches bare nvme id",
aWWN: "eui.0025385b91501234",
bWWN: "0025385b91501234",
want: true,
},
{
name: "truncated udev wwn never matches full sibling identifier",
aWWN: "0x61866da053481f00",
bSerial: "61866da053481f0030543ecb1d3b4cca",
bWWN: "naa.61866da053481f0030543ecb1d3b4cca",
want: false,
},
{
name: "placeholder serials do not match each other",
aSerial: "UNKNOWN",
bSerial: "UNKNOWN",
want: false,
},
{
name: "distinct disks stay distinct",
aSerial: "9410A0FWFVL9",
bSerial: "35C0A39YFVL9",
want: false,
},
{
name: "empty observations never match",
want: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := HardwareIdentityMatch(tc.aSerial, tc.aWWN, tc.bSerial, tc.bWWN)
if got != tc.want {
t.Fatalf("HardwareIdentityMatch(%q,%q,%q,%q) = %v, want %v",
tc.aSerial, tc.aWWN, tc.bSerial, tc.bWWN, got, tc.want)
}
mirrored := HardwareIdentityMatch(tc.bSerial, tc.bWWN, tc.aSerial, tc.aWWN)
if mirrored != tc.want {
t.Fatalf("match is not symmetric: mirrored = %v, want %v", mirrored, tc.want)
}
})
}
}