Improve Lenovo XCC inventory enrichment
This commit is contained in:
186
internal/parser/vendors/lenovo_xcc/parser.go
vendored
186
internal/parser/vendors/lenovo_xcc/parser.go
vendored
@@ -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 {
|
||||
|
||||
140
internal/parser/vendors/lenovo_xcc/parser_test.go
vendored
140
internal/parser/vendors/lenovo_xcc/parser_test.go
vendored
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user