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 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-08-27 17:05:40 +03:00
co-authored by Claude Sonnet 5
parent 9d701885da
commit 07c270cda2
13 changed files with 122 additions and 19 deletions
+3 -2
View File
@@ -3311,6 +3311,7 @@ func parseCPUs(docs []map[string]interface{}) []models.CPU {
if serial == "" && publicSerial == "" { if serial == "" && publicSerial == "" {
serial = findFirstNormalizedStringByKeys(doc, "SerialNumber") serial = findFirstNormalizedStringByKeys(doc, "SerialNumber")
} }
ppin := firstNonEmpty(findFirstNormalizedStringByKeys(doc, "PPIN", "ProtectedIdentificationNumber"), publicSerial)
cpus = append(cpus, models.CPU{ cpus = append(cpus, models.CPU{
Socket: socket, Socket: socket,
Model: firstNonEmpty(asString(doc["Model"]), asString(doc["Name"])), Model: firstNonEmpty(asString(doc["Model"]), asString(doc["Name"])),
@@ -3318,8 +3319,8 @@ func parseCPUs(docs []map[string]interface{}) []models.CPU {
Threads: asInt(doc["TotalThreads"]), Threads: asInt(doc["TotalThreads"]),
FrequencyMHz: int(redfishFirstNumeric(doc, "OperatingSpeedMHz", "CurrentSpeedMHz", "FrequencyMHz")), FrequencyMHz: int(redfishFirstNumeric(doc, "OperatingSpeedMHz", "CurrentSpeedMHz", "FrequencyMHz")),
MaxFreqMHz: int(redfishFirstNumeric(doc, "MaxSpeedMHz", "TurboEnableMaxSpeedMHz", "TurboDisableMaxSpeedMHz")), MaxFreqMHz: int(redfishFirstNumeric(doc, "MaxSpeedMHz", "TurboEnableMaxSpeedMHz", "TurboDisableMaxSpeedMHz")),
PPIN: firstNonEmpty(findFirstNormalizedStringByKeys(doc, "PPIN", "ProtectedIdentificationNumber"), publicSerial), PPIN: ppin,
SerialNumber: serial, SerialNumber: models.ResolveCPUSerialNumber(serial, ppin),
L1CacheKB: l1, L1CacheKB: l1,
L2CacheKB: l2, L2CacheKB: l2,
L3CacheKB: l3, L3CacheKB: l3,
+2 -2
View File
@@ -1557,8 +1557,8 @@ func TestParseCPU_UsesPublicSerialAsPPINAndCurrentSpeedMHz(t *testing.T) {
if cpus[0].PPIN != "6FB5241E81CECDFD" { if cpus[0].PPIN != "6FB5241E81CECDFD" {
t.Fatalf("expected PPIN from Oem.Public.SerialNumber, got %+v", cpus[0]) t.Fatalf("expected PPIN from Oem.Public.SerialNumber, got %+v", cpus[0])
} }
if cpus[0].SerialNumber != "" { if cpus[0].SerialNumber != "6FB5241E81CECDFD" {
t.Fatalf("expected empty CPU serial number when only Public serial exists, got %+v", cpus[0]) t.Fatalf("expected CPU serial number to fall back to PPIN, got %+v", cpus[0])
} }
if cpus[0].FrequencyMHz != 2700 { if cpus[0].FrequencyMHz != 2700 {
t.Fatalf("expected CPU frequency from Oem.Public.CurrentSpeedMHz, got %+v", cpus[0]) t.Fatalf("expected CPU frequency from Oem.Public.CurrentSpeedMHz, got %+v", cpus[0])
+2 -2
View File
@@ -20,7 +20,7 @@ func TestExportCSV_IncludesAllComponentTypesWithUsableSerials(t *testing.T) {
Manufacturer: "Supermicro", Manufacturer: "Supermicro",
}, },
CPUs: []models.CPU{ CPUs: []models.CPU{
{Socket: 0, Model: "Xeon", SerialNumber: "CPU-001"}, {Socket: 0, Model: "Xeon", PPIN: "D46E5D6B1D3E40E1"},
}, },
Memory: []models.MemoryDIMM{ Memory: []models.MemoryDIMM{
{Slot: "DIMM0", PartNumber: "MEM-PN", SerialNumber: "MEM-001", Manufacturer: "Samsung"}, {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 { for _, sn := range want {
if !serials[sn] { if !serials[sn] {
t.Fatalf("expected serial %s in csv export", sn) t.Fatalf("expected serial %s in csv export", sn)
+7 -4
View File
@@ -113,12 +113,13 @@ func buildDevicesFromLegacy(hw *models.HardwareConfig) []models.HardwareDevice {
details := mergeDetailMaps(nil, cpu.Details) details := mergeDetailMaps(nil, cpu.Details)
details = mergeDetailMaps(details, map[string]any{ details = mergeDetailMaps(details, map[string]any{
"socket": cpu.Socket, "socket": cpu.Socket,
"ppin": cpu.PPIN,
}) })
appendDevice(models.HardwareDevice{ appendDevice(models.HardwareDevice{
Kind: models.DeviceKindCPU, Kind: models.DeviceKindCPU,
Slot: fmt.Sprintf("CPU%d", cpu.Socket), Slot: fmt.Sprintf("CPU%d", cpu.Socket),
Model: cpu.Model, Model: cpu.Model,
SerialNumber: cpu.SerialNumber, SerialNumber: models.ResolveCPUSerialNumber(cpu.SerialNumber, cpu.PPIN),
Cores: cpu.Cores, Cores: cpu.Cores,
Threads: cpu.Threads, Threads: cpu.Threads,
FrequencyMHz: cpu.FrequencyMHz, FrequencyMHz: cpu.FrequencyMHz,
@@ -837,7 +838,10 @@ func convertCPUsFromDevices(devices []models.HardwareDevice, collectedAt, boardS
UncorrectableErrorCount: int64FromDetailMap(d.Details, "uncorrectable_error_count"), UncorrectableErrorCount: int64FromDetailMap(d.Details, "uncorrectable_error_count"),
LifeRemainingPct: floatFromDetailMap(d.Details, "life_remaining_pct"), LifeRemainingPct: floatFromDetailMap(d.Details, "life_remaining_pct"),
LifeUsedPct: floatFromDetailMap(d.Details, "life_used_pct"), LifeUsedPct: floatFromDetailMap(d.Details, "life_used_pct"),
SerialNumber: strings.TrimSpace(d.SerialNumber), SerialNumber: models.ResolveCPUSerialNumber(
d.SerialNumber,
stringFromDetailMap(d.Details, "ppin"),
),
Firmware: firstNonEmptyString( Firmware: firstNonEmptyString(
stringFromDetailMap(d.Details, "microcode"), stringFromDetailMap(d.Details, "microcode"),
microcodeBySocket[socket], microcodeBySocket[socket],
@@ -1529,7 +1533,7 @@ func convertCPUs(cpus []models.CPU, collectedAt string) []ReanimatorCPU {
Threads: cpu.Threads, Threads: cpu.Threads,
FrequencyMHz: cpu.FrequencyMHz, FrequencyMHz: cpu.FrequencyMHz,
MaxFrequencyMHz: cpu.MaxFreqMHz, MaxFrequencyMHz: cpu.MaxFreqMHz,
SerialNumber: strings.TrimSpace(cpu.SerialNumber), SerialNumber: models.ResolveCPUSerialNumber(cpu.SerialNumber, cpu.PPIN),
Firmware: "", Firmware: "",
Manufacturer: manufacturer, Manufacturer: manufacturer,
Status: cpuStatus, Status: cpuStatus,
@@ -2793,4 +2797,3 @@ func inferTargetHost(targetHost, filename string) string {
return "" return ""
} }
@@ -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) { func TestConvertToReanimator_ExportsEventLogsAndOmitsPCIeBDFJSON(t *testing.T) {
input := &models.AnalysisResult{ input := &models.AnalysisResult{
Filename: "events.json", Filename: "events.json",
+23
View File
@@ -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
}
}
+25
View File
@@ -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)
}
})
}
}
+4 -2
View File
@@ -19,7 +19,7 @@ import (
"git.mchus.pro/mchus/logpile/internal/parser/vendors/pciids" "git.mchus.pro/mchus/logpile/internal/parser/vendors/pciids"
) )
const parserVersion = "3.0" const parserVersion = "3.1"
func init() { func init() {
parser.Register(&Parser{}) parser.Register(&Parser{})
@@ -323,6 +323,7 @@ func parseCPUView(props map[string]string, result *models.AnalysisResult) {
if model == "" { if model == "" {
return return
} }
ppin := strings.TrimSpace(props["ppin"])
cpu := models.CPU{ cpu := models.CPU{
Socket: parseSocketFromFQDD(firstNonEmpty(props["fqdd"], props["instanceid"])), Socket: parseSocketFromFQDD(firstNonEmpty(props["fqdd"], props["instanceid"])),
Model: model, Model: model,
@@ -331,7 +332,8 @@ func parseCPUView(props map[string]string, result *models.AnalysisResult) {
Threads: parseIntLoose(props["numberofenabledthreads"]), Threads: parseIntLoose(props["numberofenabledthreads"]),
FrequencyMHz: parseIntLoose(props["currentclockspeed"]), FrequencyMHz: parseIntLoose(props["currentclockspeed"]),
MaxFreqMHz: parseIntLoose(props["maxclockspeed"]), MaxFreqMHz: parseIntLoose(props["maxclockspeed"]),
PPIN: strings.TrimSpace(props["ppin"]), PPIN: ppin,
SerialNumber: models.ResolveCPUSerialNumber(props["serialnumber"], ppin),
Status: normalizeStatus(props["primarystatus"]), Status: normalizeStatus(props["primarystatus"]),
} }
result.Hardware.CPUs = append(result.Hardware.CPUs, cpu) result.Hardware.CPUs = append(result.Hardware.CPUs, cpu)
+3
View File
@@ -152,6 +152,9 @@ func TestParseNestedTSRZip(t *testing.T) {
if got := result.Hardware.CPUs[0].Model; got != "Intel(R) Xeon(R) Gold 6330" { if got := result.Hardware.CPUs[0].Model; got != "Intel(R) Xeon(R) Gold 6330" {
t.Fatalf("unexpected cpu model: %q", got) 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 { if len(result.Hardware.NetworkAdapters) != 1 {
t.Fatalf("expected 1 network adapter, got %d", len(result.Hardware.NetworkAdapters)) t.Fatalf("expected 1 network adapter, got %d", len(result.Hardware.NetworkAdapters))
+9 -6
View File
@@ -20,8 +20,8 @@ import (
) )
const ( const (
parserVersionG5 = "2.1" parserVersionG5 = "2.2"
parserVersionG6 = "2.1" parserVersionG6 = "2.2"
) )
func init() { func init() {
@@ -506,6 +506,8 @@ func parseHardwareInfoNetworkAdapters(content []byte) []models.NetworkAdapter {
func parseCPUFromHardwareInfoSection(sectionName string, section map[string]string) models.CPU { func parseCPUFromHardwareInfoSection(sectionName string, section map[string]string) models.CPU {
socket := parseSectionIndex(sectionName, `(?i)processor\s+(\d+)`) socket := parseSectionIndex(sectionName, `(?i)processor\s+(\d+)`)
statusRaw := getSectionValue(section, "Status") statusRaw := getSectionValue(section, "Status")
ppin := getSectionValue(section, "CPU PPIN", "PPIN")
serial := getSectionValue(section, "Processor ID", "Serial Number")
return models.CPU{ return models.CPU{
Socket: socket, Socket: socket,
@@ -516,8 +518,8 @@ func parseCPUFromHardwareInfoSection(sectionName string, section map[string]stri
L1CacheKB: parseMaybeIntLoose(getSectionValue(section, "L1 Cache")), L1CacheKB: parseMaybeIntLoose(getSectionValue(section, "L1 Cache")),
L2CacheKB: parseMaybeIntLoose(getSectionValue(section, "L2 Cache")), L2CacheKB: parseMaybeIntLoose(getSectionValue(section, "L2 Cache")),
L3CacheKB: parseMaybeIntLoose(getSectionValue(section, "L3 Cache")), L3CacheKB: parseMaybeIntLoose(getSectionValue(section, "L3 Cache")),
PPIN: getSectionValue(section, "CPU PPIN", "PPIN"), PPIN: ppin,
SerialNumber: getSectionValue(section, "Processor ID", "Serial Number"), SerialNumber: models.ResolveCPUSerialNumber(serial, ppin),
Status: normalizeComponentStatus(statusRaw), Status: normalizeComponentStatus(statusRaw),
} }
} }
@@ -1503,6 +1505,7 @@ func parseCPUXML(content []byte, result *models.AnalysisResult) {
} }
socket := parseSocketID(node.XMLName.Local) socket := parseSocketID(node.XMLName.Local)
ppin := strings.TrimSpace(fields["PPIN"])
cpu := models.CPU{ cpu := models.CPU{
Socket: socket, Socket: socket,
Model: model, Model: model,
@@ -1511,8 +1514,8 @@ func parseCPUXML(content []byte, result *models.AnalysisResult) {
Threads: parseMaybeInt(fields["TotalThreads"]), Threads: parseMaybeInt(fields["TotalThreads"]),
FrequencyMHz: parseMaybeInt(fields["ProcessorSpeed"]), FrequencyMHz: parseMaybeInt(fields["ProcessorSpeed"]),
MaxFreqMHz: parseMaybeInt(fields["ProcessorMaxSpeed"]), MaxFreqMHz: parseMaybeInt(fields["ProcessorMaxSpeed"]),
SerialNumber: strings.TrimSpace(fields["SerialNumber"]), SerialNumber: models.ResolveCPUSerialNumber(fields["SerialNumber"], ppin),
PPIN: strings.TrimSpace(fields["PPIN"]), PPIN: ppin,
Status: normalizePresenceStatus(fields["Status"]), Status: normalizePresenceStatus(fields["Status"]),
} }
+3
View File
@@ -831,6 +831,9 @@ Pre-Init,Info,0x0,Management Subsystem Health,Health,Assertion event,Pre-Init,"M
if result.Hardware.CPUs[0].FrequencyMHz != 2800 { if result.Hardware.CPUs[0].FrequencyMHz != 2800 {
t.Fatalf("expected CPU frequency 2800MHz, got %d", result.Hardware.CPUs[0].FrequencyMHz) 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 { if len(result.Hardware.Memory) != 2 {
t.Fatalf("expected 2 DIMMs from hardware_info.ini, got %d", len(result.Hardware.Memory)) t.Fatalf("expected 2 DIMMs from hardware_info.ini, got %d", len(result.Hardware.Memory))
+1 -1
View File
@@ -57,7 +57,7 @@ func BuildHardwareDevices(hw *models.HardwareConfig) []models.HardwareDevice {
Source: "cpus", Source: "cpus",
Slot: fmt.Sprintf("CPU%d", cpu.Socket), Slot: fmt.Sprintf("CPU%d", cpu.Socket),
Model: cpu.Model, Model: cpu.Model,
SerialNumber: cpu.SerialNumber, SerialNumber: models.ResolveCPUSerialNumber(cpu.SerialNumber, cpu.PPIN),
Cores: cpu.Cores, Cores: cpu.Cores,
Threads: cpu.Threads, Threads: cpu.Threads,
FrequencyMHz: cpu.FrequencyMHz, FrequencyMHz: cpu.FrequencyMHz,
+15
View File
@@ -315,3 +315,18 @@ func TestHandleGetConfig_ReturnsCanonicalHardware(t *testing.T) {
t.Fatalf("did not expect legacy hardware.cpus in config response") 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")
}