From 07c270cda24fdeda67063b5c7a8a3649a41853f8 Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Thu, 27 Aug 2026 17:05:40 +0300 Subject: [PATCH] feat(models): resolve CPU serial from source PPIN as common fallback Centralize CPU identity in models.ResolveCPUSerialNumber: an explicit source serial wins, otherwise a valid source PPIN is used, placeholders rejected. Dell, H3C and Redfish apply it while parsing; canonical-device and Reanimator conversion apply it again at the output boundary. No identity is synthesized from socket/model/board serial. See ADL-058. Co-Authored-By: Claude Sonnet 5 --- internal/collector/redfish.go | 5 ++-- internal/collector/redfish_test.go | 4 +-- internal/exporter/exporter_csv_test.go | 4 +-- internal/exporter/reanimator_converter.go | 11 +++++--- .../exporter/reanimator_converter_test.go | 25 +++++++++++++++++++ internal/models/cpu_identity.go | 23 +++++++++++++++++ internal/models/cpu_identity_test.go | 25 +++++++++++++++++++ internal/parser/vendors/dell/parser.go | 6 +++-- internal/parser/vendors/dell/parser_test.go | 3 +++ internal/parser/vendors/h3c/parser.go | 15 ++++++----- internal/parser/vendors/h3c/parser_test.go | 3 +++ internal/server/device_repository.go | 2 +- internal/server/device_repository_test.go | 15 +++++++++++ 13 files changed, 122 insertions(+), 19 deletions(-) create mode 100644 internal/models/cpu_identity.go create mode 100644 internal/models/cpu_identity_test.go diff --git a/internal/collector/redfish.go b/internal/collector/redfish.go index 34c6521..1a8e018 100644 --- a/internal/collector/redfish.go +++ b/internal/collector/redfish.go @@ -3311,6 +3311,7 @@ func parseCPUs(docs []map[string]interface{}) []models.CPU { if serial == "" && publicSerial == "" { serial = findFirstNormalizedStringByKeys(doc, "SerialNumber") } + ppin := firstNonEmpty(findFirstNormalizedStringByKeys(doc, "PPIN", "ProtectedIdentificationNumber"), publicSerial) cpus = append(cpus, models.CPU{ Socket: socket, Model: firstNonEmpty(asString(doc["Model"]), asString(doc["Name"])), @@ -3318,8 +3319,8 @@ func parseCPUs(docs []map[string]interface{}) []models.CPU { Threads: asInt(doc["TotalThreads"]), FrequencyMHz: int(redfishFirstNumeric(doc, "OperatingSpeedMHz", "CurrentSpeedMHz", "FrequencyMHz")), MaxFreqMHz: int(redfishFirstNumeric(doc, "MaxSpeedMHz", "TurboEnableMaxSpeedMHz", "TurboDisableMaxSpeedMHz")), - PPIN: firstNonEmpty(findFirstNormalizedStringByKeys(doc, "PPIN", "ProtectedIdentificationNumber"), publicSerial), - SerialNumber: serial, + PPIN: ppin, + SerialNumber: models.ResolveCPUSerialNumber(serial, ppin), L1CacheKB: l1, L2CacheKB: l2, L3CacheKB: l3, diff --git a/internal/collector/redfish_test.go b/internal/collector/redfish_test.go index db0529d..bab9b63 100644 --- a/internal/collector/redfish_test.go +++ b/internal/collector/redfish_test.go @@ -1557,8 +1557,8 @@ func TestParseCPU_UsesPublicSerialAsPPINAndCurrentSpeedMHz(t *testing.T) { if cpus[0].PPIN != "6FB5241E81CECDFD" { t.Fatalf("expected PPIN from Oem.Public.SerialNumber, got %+v", cpus[0]) } - if cpus[0].SerialNumber != "" { - t.Fatalf("expected empty CPU serial number when only Public serial exists, got %+v", cpus[0]) + if cpus[0].SerialNumber != "6FB5241E81CECDFD" { + t.Fatalf("expected CPU serial number to fall back to PPIN, got %+v", cpus[0]) } if cpus[0].FrequencyMHz != 2700 { t.Fatalf("expected CPU frequency from Oem.Public.CurrentSpeedMHz, got %+v", cpus[0]) diff --git a/internal/exporter/exporter_csv_test.go b/internal/exporter/exporter_csv_test.go index 0a44c84..2528a2a 100644 --- a/internal/exporter/exporter_csv_test.go +++ b/internal/exporter/exporter_csv_test.go @@ -20,7 +20,7 @@ func TestExportCSV_IncludesAllComponentTypesWithUsableSerials(t *testing.T) { Manufacturer: "Supermicro", }, CPUs: []models.CPU{ - {Socket: 0, Model: "Xeon", SerialNumber: "CPU-001"}, + {Socket: 0, Model: "Xeon", PPIN: "D46E5D6B1D3E40E1"}, }, Memory: []models.MemoryDIMM{ {Slot: "DIMM0", PartNumber: "MEM-PN", SerialNumber: "MEM-001", Manufacturer: "Samsung"}, @@ -73,7 +73,7 @@ func TestExportCSV_IncludesAllComponentTypesWithUsableSerials(t *testing.T) { } } - want := []string{"FRU-001", "BOARD-001", "CPU-001", "MEM-001", "SSD-001", "GPU-001", "PCIE-001", "NIC-001", "LNIC-001", "PSU-001"} + want := []string{"FRU-001", "BOARD-001", "D46E5D6B1D3E40E1", "MEM-001", "SSD-001", "GPU-001", "PCIE-001", "NIC-001", "LNIC-001", "PSU-001"} for _, sn := range want { if !serials[sn] { t.Fatalf("expected serial %s in csv export", sn) diff --git a/internal/exporter/reanimator_converter.go b/internal/exporter/reanimator_converter.go index db9ac69..d839f12 100644 --- a/internal/exporter/reanimator_converter.go +++ b/internal/exporter/reanimator_converter.go @@ -113,12 +113,13 @@ func buildDevicesFromLegacy(hw *models.HardwareConfig) []models.HardwareDevice { details := mergeDetailMaps(nil, cpu.Details) details = mergeDetailMaps(details, map[string]any{ "socket": cpu.Socket, + "ppin": cpu.PPIN, }) appendDevice(models.HardwareDevice{ Kind: models.DeviceKindCPU, Slot: fmt.Sprintf("CPU%d", cpu.Socket), Model: cpu.Model, - SerialNumber: cpu.SerialNumber, + SerialNumber: models.ResolveCPUSerialNumber(cpu.SerialNumber, cpu.PPIN), Cores: cpu.Cores, Threads: cpu.Threads, FrequencyMHz: cpu.FrequencyMHz, @@ -837,7 +838,10 @@ func convertCPUsFromDevices(devices []models.HardwareDevice, collectedAt, boardS UncorrectableErrorCount: int64FromDetailMap(d.Details, "uncorrectable_error_count"), LifeRemainingPct: floatFromDetailMap(d.Details, "life_remaining_pct"), LifeUsedPct: floatFromDetailMap(d.Details, "life_used_pct"), - SerialNumber: strings.TrimSpace(d.SerialNumber), + SerialNumber: models.ResolveCPUSerialNumber( + d.SerialNumber, + stringFromDetailMap(d.Details, "ppin"), + ), Firmware: firstNonEmptyString( stringFromDetailMap(d.Details, "microcode"), microcodeBySocket[socket], @@ -1529,7 +1533,7 @@ func convertCPUs(cpus []models.CPU, collectedAt string) []ReanimatorCPU { Threads: cpu.Threads, FrequencyMHz: cpu.FrequencyMHz, MaxFrequencyMHz: cpu.MaxFreqMHz, - SerialNumber: strings.TrimSpace(cpu.SerialNumber), + SerialNumber: models.ResolveCPUSerialNumber(cpu.SerialNumber, cpu.PPIN), Firmware: "", Manufacturer: manufacturer, Status: cpuStatus, @@ -2793,4 +2797,3 @@ func inferTargetHost(targetHost, filename string) string { return "" } - diff --git a/internal/exporter/reanimator_converter_test.go b/internal/exporter/reanimator_converter_test.go index 13add30..439fde3 100644 --- a/internal/exporter/reanimator_converter_test.go +++ b/internal/exporter/reanimator_converter_test.go @@ -320,6 +320,31 @@ func TestConvertToReanimator_CPUSerialIsNotSynthesizedAndSocketIsDeduped(t *test } } +func TestConvertToReanimator_CPUSerialUsesPPINFallback(t *testing.T) { + input := &models.AnalysisResult{ + Hardware: &models.HardwareConfig{ + BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"}, + Devices: []models.HardwareDevice{{ + Kind: models.DeviceKindCPU, + Slot: "CPU0", + Model: "Intel Xeon", + Details: map[string]any{ + "socket": 0, + "ppin": "D46E5D6B1D3E40E1", + }, + }}, + }, + } + + out, err := ConvertToReanimator(input) + if err != nil { + t.Fatalf("ConvertToReanimator() failed: %v", err) + } + if len(out.Hardware.CPUs) != 1 || out.Hardware.CPUs[0].SerialNumber != "D46E5D6B1D3E40E1" { + t.Fatalf("expected CPU serial fallback from PPIN, got %+v", out.Hardware.CPUs) + } +} + func TestConvertToReanimator_ExportsEventLogsAndOmitsPCIeBDFJSON(t *testing.T) { input := &models.AnalysisResult{ Filename: "events.json", diff --git a/internal/models/cpu_identity.go b/internal/models/cpu_identity.go new file mode 100644 index 0000000..2efb743 --- /dev/null +++ b/internal/models/cpu_identity.go @@ -0,0 +1,23 @@ +package models + +import "strings" + +// ResolveCPUSerialNumber returns a source-backed processor inventory identity. +// A vendor-provided serial number has priority; PPIN is the documented CPU +// serial identity and is used when a separate serial number is unavailable. +func ResolveCPUSerialNumber(serialNumber, ppin string) string { + if serial := validCPUIdentity(serialNumber); serial != "" { + return serial + } + return validCPUIdentity(ppin) +} + +func validCPUIdentity(value string) string { + value = strings.TrimSpace(value) + switch strings.ToLower(value) { + case "", "-", "n/a", "na", "none", "null", "unknown", "not available", "to be filled by o.e.m.": + return "" + default: + return value + } +} diff --git a/internal/models/cpu_identity_test.go b/internal/models/cpu_identity_test.go new file mode 100644 index 0000000..853a4f2 --- /dev/null +++ b/internal/models/cpu_identity_test.go @@ -0,0 +1,25 @@ +package models + +import "testing" + +func TestResolveCPUSerialNumber(t *testing.T) { + tests := []struct { + name string + serial string + ppin string + want string + }{ + {name: "serial has priority", serial: "CPU-SERIAL", ppin: "D46E5D6B1D3E40E1", want: "CPU-SERIAL"}, + {name: "PPIN fallback", ppin: " D46E5D6B1D3E40E1 ", want: "D46E5D6B1D3E40E1"}, + {name: "placeholder serial falls back", serial: "Unknown", ppin: "D44F8D6B9155EE0E", want: "D44F8D6B9155EE0E"}, + {name: "placeholder PPIN rejected", ppin: "N/A", want: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ResolveCPUSerialNumber(tt.serial, tt.ppin); got != tt.want { + t.Fatalf("ResolveCPUSerialNumber(%q, %q) = %q, want %q", tt.serial, tt.ppin, got, tt.want) + } + }) + } +} diff --git a/internal/parser/vendors/dell/parser.go b/internal/parser/vendors/dell/parser.go index 0783744..68b1ca2 100644 --- a/internal/parser/vendors/dell/parser.go +++ b/internal/parser/vendors/dell/parser.go @@ -19,7 +19,7 @@ import ( "git.mchus.pro/mchus/logpile/internal/parser/vendors/pciids" ) -const parserVersion = "3.0" +const parserVersion = "3.1" func init() { parser.Register(&Parser{}) @@ -323,6 +323,7 @@ func parseCPUView(props map[string]string, result *models.AnalysisResult) { if model == "" { return } + ppin := strings.TrimSpace(props["ppin"]) cpu := models.CPU{ Socket: parseSocketFromFQDD(firstNonEmpty(props["fqdd"], props["instanceid"])), Model: model, @@ -331,7 +332,8 @@ func parseCPUView(props map[string]string, result *models.AnalysisResult) { Threads: parseIntLoose(props["numberofenabledthreads"]), FrequencyMHz: parseIntLoose(props["currentclockspeed"]), MaxFreqMHz: parseIntLoose(props["maxclockspeed"]), - PPIN: strings.TrimSpace(props["ppin"]), + PPIN: ppin, + SerialNumber: models.ResolveCPUSerialNumber(props["serialnumber"], ppin), Status: normalizeStatus(props["primarystatus"]), } result.Hardware.CPUs = append(result.Hardware.CPUs, cpu) diff --git a/internal/parser/vendors/dell/parser_test.go b/internal/parser/vendors/dell/parser_test.go index a1eb5cd..9a06300 100644 --- a/internal/parser/vendors/dell/parser_test.go +++ b/internal/parser/vendors/dell/parser_test.go @@ -152,6 +152,9 @@ func TestParseNestedTSRZip(t *testing.T) { if got := result.Hardware.CPUs[0].Model; got != "Intel(R) Xeon(R) Gold 6330" { t.Fatalf("unexpected cpu model: %q", got) } + if got := result.Hardware.CPUs[0].SerialNumber; got != "ABCD" { + t.Fatalf("expected CPU serial fallback from PPIN, got %q", got) + } if len(result.Hardware.NetworkAdapters) != 1 { t.Fatalf("expected 1 network adapter, got %d", len(result.Hardware.NetworkAdapters)) diff --git a/internal/parser/vendors/h3c/parser.go b/internal/parser/vendors/h3c/parser.go index f674232..2d71f48 100644 --- a/internal/parser/vendors/h3c/parser.go +++ b/internal/parser/vendors/h3c/parser.go @@ -20,8 +20,8 @@ import ( ) const ( - parserVersionG5 = "2.1" - parserVersionG6 = "2.1" + parserVersionG5 = "2.2" + parserVersionG6 = "2.2" ) func init() { @@ -506,6 +506,8 @@ func parseHardwareInfoNetworkAdapters(content []byte) []models.NetworkAdapter { func parseCPUFromHardwareInfoSection(sectionName string, section map[string]string) models.CPU { socket := parseSectionIndex(sectionName, `(?i)processor\s+(\d+)`) statusRaw := getSectionValue(section, "Status") + ppin := getSectionValue(section, "CPU PPIN", "PPIN") + serial := getSectionValue(section, "Processor ID", "Serial Number") return models.CPU{ Socket: socket, @@ -516,8 +518,8 @@ func parseCPUFromHardwareInfoSection(sectionName string, section map[string]stri L1CacheKB: parseMaybeIntLoose(getSectionValue(section, "L1 Cache")), L2CacheKB: parseMaybeIntLoose(getSectionValue(section, "L2 Cache")), L3CacheKB: parseMaybeIntLoose(getSectionValue(section, "L3 Cache")), - PPIN: getSectionValue(section, "CPU PPIN", "PPIN"), - SerialNumber: getSectionValue(section, "Processor ID", "Serial Number"), + PPIN: ppin, + SerialNumber: models.ResolveCPUSerialNumber(serial, ppin), Status: normalizeComponentStatus(statusRaw), } } @@ -1503,6 +1505,7 @@ func parseCPUXML(content []byte, result *models.AnalysisResult) { } socket := parseSocketID(node.XMLName.Local) + ppin := strings.TrimSpace(fields["PPIN"]) cpu := models.CPU{ Socket: socket, Model: model, @@ -1511,8 +1514,8 @@ func parseCPUXML(content []byte, result *models.AnalysisResult) { Threads: parseMaybeInt(fields["TotalThreads"]), FrequencyMHz: parseMaybeInt(fields["ProcessorSpeed"]), MaxFreqMHz: parseMaybeInt(fields["ProcessorMaxSpeed"]), - SerialNumber: strings.TrimSpace(fields["SerialNumber"]), - PPIN: strings.TrimSpace(fields["PPIN"]), + SerialNumber: models.ResolveCPUSerialNumber(fields["SerialNumber"], ppin), + PPIN: ppin, Status: normalizePresenceStatus(fields["Status"]), } diff --git a/internal/parser/vendors/h3c/parser_test.go b/internal/parser/vendors/h3c/parser_test.go index 228000a..0679525 100644 --- a/internal/parser/vendors/h3c/parser_test.go +++ b/internal/parser/vendors/h3c/parser_test.go @@ -831,6 +831,9 @@ Pre-Init,Info,0x0,Management Subsystem Health,Health,Assertion event,Pre-Init,"M if result.Hardware.CPUs[0].FrequencyMHz != 2800 { t.Fatalf("expected CPU frequency 2800MHz, got %d", result.Hardware.CPUs[0].FrequencyMHz) } + if got := result.Hardware.CPUs[0].SerialNumber; got != "49-A9-50-C0-15-9F-2D-DC" { + t.Fatalf("expected CPU serial fallback from PPIN, got %q", got) + } if len(result.Hardware.Memory) != 2 { t.Fatalf("expected 2 DIMMs from hardware_info.ini, got %d", len(result.Hardware.Memory)) diff --git a/internal/server/device_repository.go b/internal/server/device_repository.go index d9ede06..36bc560 100644 --- a/internal/server/device_repository.go +++ b/internal/server/device_repository.go @@ -57,7 +57,7 @@ func BuildHardwareDevices(hw *models.HardwareConfig) []models.HardwareDevice { Source: "cpus", Slot: fmt.Sprintf("CPU%d", cpu.Socket), Model: cpu.Model, - SerialNumber: cpu.SerialNumber, + SerialNumber: models.ResolveCPUSerialNumber(cpu.SerialNumber, cpu.PPIN), Cores: cpu.Cores, Threads: cpu.Threads, FrequencyMHz: cpu.FrequencyMHz, diff --git a/internal/server/device_repository_test.go b/internal/server/device_repository_test.go index 5dd477f..042b9fa 100644 --- a/internal/server/device_repository_test.go +++ b/internal/server/device_repository_test.go @@ -315,3 +315,18 @@ func TestHandleGetConfig_ReturnsCanonicalHardware(t *testing.T) { t.Fatalf("did not expect legacy hardware.cpus in config response") } } + +func TestBuildHardwareDevices_CPUUsesPPINAsSerialFallback(t *testing.T) { + hw := &models.HardwareConfig{CPUs: []models.CPU{{Socket: 0, Model: "Intel Xeon", PPIN: "D46E5D6B1D3E40E1"}}} + + devices := BuildHardwareDevices(hw) + for _, device := range devices { + if device.Kind == models.DeviceKindCPU { + if device.SerialNumber != "D46E5D6B1D3E40E1" { + t.Fatalf("expected CPU serial fallback from PPIN, got %q", device.SerialNumber) + } + return + } + } + t.Fatal("CPU device was not built") +}