From 93f0897b8165585e81a9ebfd8960e4ab55d8d1aa Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Thu, 13 Aug 2026 18:09:49 +0300 Subject: [PATCH] fix(exporter): dedup NICs by normalized MAC, fix garbage redis serial fallback MAC-format mismatches between collector sources (dash vs colon separators) were preventing duplicate NIC/PCIe entries from merging in the canonical device dedup pass. Add MAC address normalization and merge devices that share a normalized MAC before the existing serial/BDF-based dedup runs. Also fix a bug in the Inspur redis-dump serial fallback parser: when a field's inline value was the placeholder "N/A", the code incorrectly fell through to a window-scan fallback that could pick up an unrelated adjacent Redis key name (e.g. "AssetInfoPCIEMMIOSpace") as a fake serial number. Co-Authored-By: Claude Sonnet 5 --- internal/exporter/reanimator_converter.go | 154 ++++++++++++++++++- internal/parser/vendors/inspur/redis_dump.go | 7 +- 2 files changed, 157 insertions(+), 4 deletions(-) diff --git a/internal/exporter/reanimator_converter.go b/internal/exporter/reanimator_converter.go index 0c5ae0d..2a31b15 100644 --- a/internal/exporter/reanimator_converter.go +++ b/internal/exporter/reanimator_converter.go @@ -237,7 +237,7 @@ func buildDevicesFromLegacy(hw *models.HardwareConfig) []models.HardwareDevice { PartNumber: pcie.PartNumber, Manufacturer: pcie.Manufacturer, SerialNumber: pcie.SerialNumber, - MACAddresses: append([]string(nil), pcie.MACAddresses...), + MACAddresses: normalizeMACAddresses(pcie.MACAddresses), LinkWidth: pcie.LinkWidth, LinkSpeed: pcie.LinkSpeed, MaxLinkWidth: pcie.MaxLinkWidth, @@ -312,7 +312,7 @@ func buildDevicesFromLegacy(hw *models.HardwareConfig) []models.HardwareDevice { Firmware: nic.Firmware, PortCount: nic.PortCount, PortType: nic.PortType, - MACAddresses: nic.MACAddresses, + MACAddresses: normalizeMACAddresses(nic.MACAddresses), LinkWidth: nic.LinkWidth, LinkSpeed: nic.LinkSpeed, MaxLinkWidth: nic.MaxLinkWidth, @@ -357,7 +357,157 @@ func buildDevicesFromLegacy(hw *models.HardwareConfig) []models.HardwareDevice { return dedupeCanonicalDevices(all) } +// normalizeMACAddress converts a MAC address to a canonical "AA:BB:CC:DD:EE:FF" +// form regardless of the separator/casing used by the source (colon, dash, dot, +// or none). Returns "" if the value doesn't contain exactly 12 hex digits. +func normalizeMACAddress(mac string) string { + var hexDigits [12]byte + n := 0 + for i := 0; i < len(mac); i++ { + c := mac[i] + switch { + case c >= '0' && c <= '9', c >= 'a' && c <= 'f', c >= 'A' && c <= 'F': + if n == 12 { + return "" + } + hexDigits[n] = c + n++ + } + } + if n != 12 { + return "" + } + var b strings.Builder + b.Grow(17) + for i := 0; i < 12; i += 2 { + if i > 0 { + b.WriteByte(':') + } + b.WriteByte(hexDigits[i]) + b.WriteByte(hexDigits[i+1]) + } + return strings.ToUpper(b.String()) +} + +// normalizeMACAddresses normalizes each address to canonical form, dropping +// unparsable entries and de-duplicating while preserving order. +func normalizeMACAddresses(macs []string) []string { + if len(macs) == 0 { + return nil + } + seen := make(map[string]struct{}, len(macs)) + out := make([]string, 0, len(macs)) + for _, mac := range macs { + nm := normalizeMACAddress(mac) + if nm == "" { + continue + } + if _, ok := seen[nm]; ok { + continue + } + seen[nm] = struct{}{} + out = append(out, nm) + } + if len(out) == 0 { + return nil + } + return out +} + +// mergeDevicesBySharedMAC merges network-class devices that share at least one +// normalized MAC address, regardless of their Serial/BDF/slot identity. This +// runs before the serial/BDF based canonicalKey grouping so that a device with +// a bogus or mismatched serial/slot (e.g. from a different collector source) +// still merges with its true duplicate as long as the hardware MAC overlaps. +func mergeDevicesBySharedMAC(items []models.HardwareDevice) []models.HardwareDevice { + if len(items) <= 1 { + return items + } + + eligible := func(kind string) bool { + return kind == models.DeviceKindNetwork || kind == models.DeviceKindPCIe + } + + parent := make([]int, len(items)) + for i := range parent { + parent[i] = i + } + var find func(int) int + find = func(x int) int { + for parent[x] != x { + parent[x] = parent[parent[x]] + x = parent[x] + } + return x + } + union := func(a, b int) { + ra, rb := find(a), find(b) + if ra != rb { + parent[ra] = rb + } + } + + macToIndices := make(map[string][]int) + for i, item := range items { + if !eligible(item.Kind) { + continue + } + for _, mac := range item.MACAddresses { + nm := normalizeMACAddress(mac) + if nm == "" { + continue + } + macToIndices[nm] = append(macToIndices[nm], i) + } + } + merged := false + for _, idxs := range macToIndices { + for k := 1; k < len(idxs); k++ { + union(idxs[0], idxs[k]) + merged = true + } + } + if !merged { + return items + } + + order := make([]int, 0, len(items)) + groups := make(map[int][]int, len(items)) + for i := range items { + root := find(i) + if _, ok := groups[root]; !ok { + order = append(order, root) + } + groups[root] = append(groups[root], i) + } + + out := make([]models.HardwareDevice, 0, len(order)) + for _, root := range order { + members := groups[root] + if len(members) == 1 { + out = append(out, items[members[0]]) + continue + } + best := members[0] + for _, idx := range members[1:] { + if canonicalScore(items[idx]) > canonicalScore(items[best]) { + best = idx + } + } + mergedItem := items[best] + for _, idx := range members { + if idx == best { + continue + } + mergedItem = mergeCanonicalDevice(mergedItem, items[idx]) + } + out = append(out, mergedItem) + } + return out +} + func dedupeCanonicalDevices(items []models.HardwareDevice) []models.HardwareDevice { + items = mergeDevicesBySharedMAC(items) type scored struct { item models.HardwareDevice score int diff --git a/internal/parser/vendors/inspur/redis_dump.go b/internal/parser/vendors/inspur/redis_dump.go index 62db7ab..33dddb5 100644 --- a/internal/parser/vendors/inspur/redis_dump.go +++ b/internal/parser/vendors/inspur/redis_dump.go @@ -212,8 +212,11 @@ func extractRedisInlineValue(content []byte, start int) string { } func extractRedisCandidateValue(content []byte, start int) string { - // Fast-path for simple inline string values. - if v := extractRedisInlineValue(content, start); normalizeRedisValue(v) != "" { + // Fast-path for simple inline string values. A cleanly-read inline value is + // authoritative even when it normalizes to a placeholder like "N/A" — + // falling through to the window scan below in that case would risk + // mistaking an unrelated adjacent Redis key name for the real value. + if v := extractRedisInlineValue(content, start); v != "" { return v }