Improve Lenovo XCC inventory enrichment

This commit is contained in:
Mikhail Chusavitin
2026-04-29 16:38:30 +03:00
parent aba7a54990
commit cf9cf5d0cf
2 changed files with 314 additions and 12 deletions

View File

@@ -100,9 +100,11 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
}
if f := findByPath(files, "tmp/inventory_ipmi_fru.log"); f != nil {
result.FRU = parseFRU(f.Content)
enrichBoardFromFRU(result)
}
if f := findByPath(files, "tmp/inventory_ipmi_sensor.log"); f != nil {
result.Sensors = parseSensors(f.Content)
result.Hardware.PowerSupply = enrichPSUsFromSensors(result.Hardware.PowerSupply, result.Sensors)
}
for _, f := range findEventFiles(files) {
result.Events = append(result.Events, parseEvents(f.Content)...)
@@ -341,9 +343,13 @@ func parseBasicSysInfo(content []byte, result *models.AnalysisResult) {
item := doc.Items[0]
result.Hardware.BoardInfo = models.BoardInfo{
ProductName: strings.TrimSpace(item.MachineTypeModel),
SerialNumber: strings.TrimSpace(item.SerialNumber),
UUID: strings.TrimSpace(item.UUID),
ProductName: cleanXCCValue(item.MachineTypeModel),
SerialNumber: cleanXCCValue(item.SerialNumber),
UUID: cleanXCCValue(item.UUID),
}
if host := cleanXCCValue(item.MachineName); host != "" {
result.TargetHost = host
}
if t, err := parseXCCTime(item.CurrentTime); err == nil {
@@ -464,17 +470,21 @@ func parseDisks(content []byte) []models.Storage {
stateStr := strings.TrimSpace(d.StateStr)
present := !strings.EqualFold(stateStr, "absent") &&
!strings.EqualFold(stateStr, "not present")
status := mapDiskHealthStatus(d.HealthStatus, stateStr)
disk := models.Storage{
Slot: fmt.Sprintf("%d", d.SlotNo),
Type: strings.TrimSpace(d.Media),
Model: strings.TrimSpace(d.ProductName),
Model: cleanXCCValue(d.ProductName),
SizeGB: sizeGB,
SerialNumber: strings.TrimSpace(d.SerialNo),
Manufacturer: strings.TrimSpace(d.Manufacture),
Firmware: strings.TrimSpace(d.FWVersion),
SerialNumber: cleanXCCValue(d.SerialNo),
Manufacturer: cleanXCCValue(d.Manufacture),
Firmware: cleanXCCValue(d.FWVersion),
Interface: strings.TrimSpace(d.Interface),
Present: present,
Status: stateStr,
Status: status,
}
if d.Temperature > 0 {
disk.Details = map[string]any{"temperature_c": d.Temperature}
}
if d.RemainLife >= 0 && d.RemainLife <= 100 {
v := d.RemainLife
@@ -551,13 +561,18 @@ func parsePSUs(content []byte) []models.PSU {
var out []models.PSU
for _, item := range doc.Items {
for _, p := range item.Power {
model := cleanXCCValue(p.FRUNumber)
if model == "" {
model = cleanXCCValue(p.PartNumber)
}
psu := models.PSU{
Slot: fmt.Sprintf("%d", p.Name),
Present: true,
Model: model,
WattageW: p.RatedPower,
SerialNumber: strings.TrimSpace(p.SerialNumber),
PartNumber: strings.TrimSpace(p.PartNumber),
Vendor: strings.TrimSpace(p.ManufID),
SerialNumber: cleanXCCValue(p.SerialNumber),
PartNumber: cleanXCCValue(p.PartNumber),
Vendor: cleanXCCValue(p.ManufID),
Status: strings.TrimSpace(p.Status),
}
out = append(out, psu)
@@ -611,11 +626,13 @@ func parseSensors(content []byte) []models.SensorReading {
if name == "" {
continue
}
unit := strings.TrimSpace(s.Unit)
sr := models.SensorReading{
Name: name,
RawValue: strings.TrimSpace(s.Value),
Unit: strings.TrimSpace(s.Unit),
Unit: unit,
Status: strings.TrimSpace(s.Status),
Type: classifySensorType(name, unit),
}
if v, err := strconv.ParseFloat(sr.RawValue, 64); err == nil {
sr.Value = v
@@ -646,6 +663,151 @@ func parseEvents(content []byte) []models.Event {
return out
}
// --- Cross-reference enrichment ---
// enrichBoardFromFRU sets BoardInfo.Manufacturer from the system board FRU entry
// when it is not already populated. Mirrors bee's board parsing from dmidecode type 1.
func enrichBoardFromFRU(result *models.AnalysisResult) {
if result.Hardware.BoardInfo.Manufacturer != "" {
return
}
for _, fru := range result.FRU {
desc := strings.ToLower(fru.Description)
if !strings.Contains(desc, "system board") &&
!strings.Contains(desc, "planar") &&
!strings.Contains(desc, "backplane") {
continue
}
if mfg := cleanXCCValue(fru.Manufacturer); mfg != "" {
result.Hardware.BoardInfo.Manufacturer = mfg
return
}
}
}
// psuSensorSlot extracts a 1-based PSU slot number from a sensor name.
// Recognises patterns: "PSU1 ...", "PSU 2 ...", "Power Supply 1 ...", "PWS1 ..."
var psuSensorSlotPattern = regexp.MustCompile(`(?i)(?:PSU|Power\s+Supply|PWS)\s*(\d+)`)
// enrichPSUsFromSensors cross-references sensor readings into PSU InputPowerW /
// OutputPowerW / InputVoltage. Mirrors bee's enrichPSUsWithTelemetry approach.
func enrichPSUsFromSensors(psus []models.PSU, sensors []models.SensorReading) []models.PSU {
if len(psus) == 0 || len(sensors) == 0 {
return psus
}
for i := range psus {
slot, err := strconv.Atoi(psus[i].Slot)
if err != nil {
continue
}
for _, s := range sensors {
m := psuSensorSlotPattern.FindStringSubmatch(s.Name)
if len(m) < 2 {
continue
}
sensorSlot, err := strconv.Atoi(m[1])
if err != nil || sensorSlot != slot {
continue
}
nameLower := strings.ToLower(s.Name)
switch {
case isPSUInputPower(nameLower):
psus[i].InputPowerW = int(s.Value)
case isPSUOutputPower(nameLower):
psus[i].OutputPowerW = int(s.Value)
case isPSUInputVoltage(nameLower):
psus[i].InputVoltage = s.Value
}
}
}
return psus
}
func isPSUInputPower(name string) bool {
return strings.Contains(name, "input power") ||
strings.Contains(name, "input watts") ||
strings.Contains(name, "_pin") ||
strings.Contains(name, " pin")
}
func isPSUOutputPower(name string) bool {
return strings.Contains(name, "output power") ||
strings.Contains(name, "output watts") ||
strings.Contains(name, "_pout") ||
strings.Contains(name, " pout")
}
func isPSUInputVoltage(name string) bool {
return strings.Contains(name, "input voltage") ||
strings.Contains(name, "ac voltage") ||
strings.Contains(name, "_vin") ||
strings.Contains(name, " vin")
}
// mapDiskHealthStatus maps an XCC disk healthStatus integer to a canonical status
// string. Mirrors bee's mapRAIDDriveStatus logic.
// XCC codes: 1=Warning, 2=Normal, 3=Critical, 4=PredictiveFailure; 0=Unknown.
func mapDiskHealthStatus(code int, stateStr string) string {
switch code {
case 2:
return "OK"
case 1, 4:
return "Warning"
case 3:
return "Critical"
default:
if stateStr != "" {
return stateStr
}
return "Unknown"
}
}
// classifySensorType returns a sensor category based on bee's classification logic:
// fan / temperature / power / voltage / current / other.
func classifySensorType(name, unit string) string {
u := strings.ToLower(strings.TrimSpace(unit))
switch u {
case "rpm":
return "fan"
case "c", "celsius", "°c":
return "temperature"
case "w", "watts":
return "power"
case "v", "volts":
return "voltage"
case "a", "amps":
return "current"
}
n := strings.ToLower(name)
switch {
case strings.Contains(n, "fan"):
return "fan"
case strings.Contains(n, "temp"):
return "temperature"
case strings.Contains(n, "power") || strings.Contains(n, " pwr"):
return "power"
case strings.Contains(n, "volt") || strings.Contains(n, " vin") || strings.Contains(n, " vout"):
return "voltage"
case strings.Contains(n, "curr") || strings.Contains(n, " amp"):
return "current"
default:
return "other"
}
}
// cleanXCCValue strips XCC placeholder strings, returning "" for non-values.
// Mirrors bee's cleanDMIValue for IPMI/XCC context.
func cleanXCCValue(v string) string {
v = strings.TrimSpace(v)
switch strings.ToLower(v) {
case "", "n/a", "na", "none", "unknown", "not available",
"not applicable", "not present", "not specified", "-":
return ""
}
return v
}
// --- Helpers ---
func xccSeverity(s, message string) models.Severity {

View File

@@ -364,3 +364,143 @@ func TestApplyDIMMWarningsFromEvents_UpdatesDIMMStatusForExport(t *testing.T) {
t.Fatalf("expected warning status history entry, got %#v", dimm.StatusHistory)
}
}
func TestParseBasicSysInfo_CleansPlaceholderValuesAndSetsTargetHost(t *testing.T) {
result := &models.AnalysisResult{Hardware: &models.HardwareConfig{}}
content := []byte(`{
"items": [{
"machine_name": " sr650v3-node01 ",
"machine_typemodel": " 7D76CTO1WW ",
"serial_number": " Not Specified ",
"uuid": "N/A"
}]
}`)
parseBasicSysInfo(content, result)
if result.TargetHost != "sr650v3-node01" {
t.Fatalf("unexpected target host: %q", result.TargetHost)
}
if result.Hardware.BoardInfo.ProductName != "7D76CTO1WW" {
t.Fatalf("unexpected product name: %q", result.Hardware.BoardInfo.ProductName)
}
if result.Hardware.BoardInfo.SerialNumber != "" {
t.Fatalf("expected serial number to be cleaned, got %q", result.Hardware.BoardInfo.SerialNumber)
}
if result.Hardware.BoardInfo.UUID != "" {
t.Fatalf("expected UUID to be cleaned, got %q", result.Hardware.BoardInfo.UUID)
}
}
func TestEnrichBoardFromFRU_SystemBoardManufacturerOnly(t *testing.T) {
result := &models.AnalysisResult{
Hardware: &models.HardwareConfig{},
FRU: []models.FRUInfo{
{Description: "Power Supply 1", Manufacturer: "Ignore Me"},
{Description: "System Board", Manufacturer: " Lenovo "},
},
}
enrichBoardFromFRU(result)
if result.Hardware.BoardInfo.Manufacturer != "Lenovo" {
t.Fatalf("unexpected manufacturer: %q", result.Hardware.BoardInfo.Manufacturer)
}
}
func TestEnrichPSUsFromSensors_AssignsTelemetryBySlot(t *testing.T) {
psus := []models.PSU{
{Slot: "1"},
{Slot: "2"},
}
sensors := []models.SensorReading{
{Name: "PSU1 Input Power", Value: 430},
{Name: "Power Supply 1 Output Power", Value: 390},
{Name: "PWS1 AC Voltage", Value: 230.5},
{Name: "PSU2 Input Power", Value: 0},
{Name: "PSU3 Input Power", Value: 999},
{Name: "Fan 1", Value: 12000},
}
got := enrichPSUsFromSensors(psus, sensors)
if got[0].InputPowerW != 430 {
t.Fatalf("unexpected PSU1 input power: %d", got[0].InputPowerW)
}
if got[0].OutputPowerW != 390 {
t.Fatalf("unexpected PSU1 output power: %d", got[0].OutputPowerW)
}
if got[0].InputVoltage != 230.5 {
t.Fatalf("unexpected PSU1 input voltage: %v", got[0].InputVoltage)
}
if got[1].InputPowerW != 0 || got[1].OutputPowerW != 0 || got[1].InputVoltage != 0 {
t.Fatalf("unexpected telemetry assigned to PSU2: %+v", got[1])
}
}
func TestMapDiskHealthStatus(t *testing.T) {
tests := []struct {
name string
code int
stateStr string
want string
}{
{name: "normal", code: 2, stateStr: "Online", want: "OK"},
{name: "warning", code: 1, stateStr: "Online", want: "Warning"},
{name: "predictive failure", code: 4, stateStr: "Online", want: "Warning"},
{name: "critical", code: 3, stateStr: "Failed", want: "Critical"},
{name: "fallback state", code: 0, stateStr: "Rebuilding", want: "Rebuilding"},
{name: "unknown", code: 0, stateStr: "", want: "Unknown"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := mapDiskHealthStatus(tt.code, tt.stateStr); got != tt.want {
t.Fatalf("got %q, want %q", got, tt.want)
}
})
}
}
func TestClassifySensorType(t *testing.T) {
tests := []struct {
name string
in string
unit string
want string
}{
{name: "unit rpm", in: "Fan 1", unit: "RPM", want: "fan"},
{name: "unit celsius", in: "CPU Temp", unit: "C", want: "temperature"},
{name: "unit watts", in: "PSU1 Input Power", unit: "W", want: "power"},
{name: "unit volts", in: "PWS1 AC Voltage", unit: "V", want: "voltage"},
{name: "unit amps", in: "PSU1 Current", unit: "A", want: "current"},
{name: "name fallback", in: "GPU Temp", unit: "", want: "temperature"},
{name: "other", in: "Presence", unit: "", want: "other"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := classifySensorType(tt.in, tt.unit); got != tt.want {
t.Fatalf("got %q, want %q", got, tt.want)
}
})
}
}
func TestCleanXCCValue(t *testing.T) {
tests := []struct {
in string
want string
}{
{in: " Lenovo ", want: "Lenovo"},
{in: "N/A", want: ""},
{in: " not specified ", want: ""},
{in: "-", want: ""},
}
for _, tt := range tests {
if got := cleanXCCValue(tt.in); got != tt.want {
t.Fatalf("cleanXCCValue(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}