2 Commits
main ... v1.13

Author SHA1 Message Date
Mikhail Chusavitin
1162ccd22e Trim noisy Lenovo Redfish collection paths 2026-04-29 17:02:40 +03:00
Mikhail Chusavitin
3887df6547 Improve Lenovo XCC inventory enrichment 2026-04-29 16:38:30 +03:00
4 changed files with 483 additions and 22 deletions

View File

@@ -352,7 +352,15 @@ func TestMatchProfiles_LenovoXCCSelectsMatchedModeAndExcludesSensors(t *testing.
ChassisManufacturer: "Lenovo",
OEMNamespaces: []string{"Lenovo"},
})
wantExcluded := []string{"/Sensors/", "/Oem/Lenovo/LEDs/", "/Oem/Lenovo/Slots/"}
wantExcluded := []string{
"/Sensors/",
"/Oem/Lenovo/LEDs/",
"/Oem/Lenovo/Slots/",
"/Oem/Lenovo/Configuration",
"/NetworkProtocol/Oem/Lenovo/",
"/VirtualMedia/",
"/ThermalSubsystem/Fans/",
}
for _, want := range wantExcluded {
found := false
for _, ex := range plan.Tuning.SnapshotExcludeContains {
@@ -367,6 +375,46 @@ func TestMatchProfiles_LenovoXCCSelectsMatchedModeAndExcludesSensors(t *testing.
}
}
func TestResolveAcquisitionPlan_LenovoFiltersNonInventoryChassisBranches(t *testing.T) {
signals := MatchSignals{
SystemManufacturer: "Lenovo",
ChassisManufacturer: "Lenovo",
OEMNamespaces: []string{"Lenovo"},
ResourceHints: []string{
"/redfish/v1/Chassis/1/Power",
"/redfish/v1/Chassis/1/Thermal",
"/redfish/v1/Chassis/1/NetworkAdapters",
"/redfish/v1/Chassis/3",
"/redfish/v1/Chassis/IO_Board",
},
}
match := MatchProfiles(signals)
plan := BuildAcquisitionPlan(signals)
resolved := ResolveAcquisitionPlan(match, plan, DiscoveredResources{
ChassisPaths: []string{
"/redfish/v1/Chassis/1",
"/redfish/v1/Chassis/3",
"/redfish/v1/Chassis/IO_Board",
},
}, signals)
if !containsString(resolved.CriticalPaths, "/redfish/v1/Chassis/1/Power") {
t.Fatal("expected primary Lenovo chassis power path to remain critical")
}
if containsString(resolved.CriticalPaths, "/redfish/v1/Chassis/3/Power") {
t.Fatal("did not expect non-inventory Lenovo backplane chassis power path")
}
if containsString(resolved.CriticalPaths, "/redfish/v1/Chassis/IO_Board/Assembly") {
t.Fatal("did not expect IO board assembly path without inventory hints")
}
if containsString(resolved.Plan.PlanBPaths, "/redfish/v1/Chassis/3/Assembly") {
t.Fatal("did not expect non-inventory Lenovo chassis plan-b target")
}
if !containsString(resolved.CriticalPaths, "/redfish/v1/Chassis/3") {
t.Fatal("expected chassis root to remain discoverable even when suffixes are filtered")
}
}
func TestMatchProfiles_OrderingIsDeterministic(t *testing.T) {
signals := MatchSignals{
SystemManufacturer: "Micro-Star International Co., Ltd.",

View File

@@ -1,5 +1,7 @@
package redfishprofile
import "strings"
func lenovoProfile() Profile {
return staticProfile{
name: "lenovo",
@@ -33,14 +35,30 @@ func lenovoProfile() Profile {
// Lenovo OEM subtrees under Oem/Lenovo/LEDs and Oem/Lenovo/Slots also
// enumerate dozens of individual documents not relevant to inventory.
ensureSnapshotExcludeContains(plan,
"/Sensors/", // individual sensor docs (Chassis/1/Sensors/NNN)
"/Oem/Lenovo/LEDs/", // individual LED status entries (~47 per server)
"/Oem/Lenovo/Slots/", // individual slot detail entries (~26 per server)
"/Oem/Lenovo/Metrics/", // operational metrics, not inventory
"/Oem/Lenovo/History", // historical telemetry
"/Oem/Lenovo/ScheduledPower", // power scheduling config
"/Oem/Lenovo/BootSettings/BootOrder", // individual boot order lists
"/PortForwardingMap/", // network port forwarding config
"/Sensors/", // individual sensor docs (Chassis/1/Sensors/NNN)
"/Oem/Lenovo/LEDs/", // individual LED status entries (~47 per server)
"/Oem/Lenovo/Slots/", // individual slot detail entries (~26 per server)
"/Oem/Lenovo/Metrics/", // operational metrics, not inventory
"/Oem/Lenovo/History", // historical telemetry
"/Oem/Lenovo/Configuration", // BMC config service, not inventory
"/Oem/Lenovo/DateTimeService", // BMC time service config
"/Oem/Lenovo/GroupService", // XCC fleet/group management state
"/Oem/Lenovo/Recipients", // alert recipient config
"/Oem/Lenovo/RemoteControl", // remote-media/session management
"/Oem/Lenovo/RemoteMap", // remote-media mapping config
"/Oem/Lenovo/SecureKeyLifecycleService", // key lifecycle/cert config
"/Oem/Lenovo/ServerProfile", // profile export/import config
"/Oem/Lenovo/ServiceData", // support/service metadata
"/Oem/Lenovo/SsoCertificates", // SSO certificate config
"/Oem/Lenovo/SystemGuard", // snapshot/history service
"/Oem/Lenovo/Watchdogs", // watchdog config
"/Oem/Lenovo/ScheduledPower", // power scheduling config
"/Oem/Lenovo/BootSettings/BootOrder", // individual boot order lists
"/NetworkProtocol/Oem/Lenovo/", // DNS/LDAP/SMTP/SNMP manager config
"/PortForwardingMap/", // network port forwarding config
"/VirtualMedia/", // virtual media inventory/config, not hardware
"/Boot/Certificates", // secure boot certificate stores, not inventory
"/ThermalSubsystem/Fans/", // per-fan member docs; replay uses aggregate Thermal only
)
// Lenovo XCC BMC is typically slow (p95 latency often 3-5s even under
// normal load). Set rate thresholds that don't over-throttle on the
@@ -61,5 +79,97 @@ func lenovoProfile() Profile {
})
addPlanNote(plan, "lenovo xcc acquisition extensions enabled: noisy sensor/oem paths excluded from snapshot")
},
refineAcquisition: func(resolved *ResolvedAcquisitionPlan, discovered DiscoveredResources, signals MatchSignals) {
allowedChassis := lenovoAllowedInventoryChassis(discovered.ChassisPaths, signals.ResourceHints)
resolved.SeedPaths = filterLenovoChassisInventoryPaths(resolved.SeedPaths, allowedChassis)
resolved.CriticalPaths = filterLenovoChassisInventoryPaths(resolved.CriticalPaths, allowedChassis)
resolved.Plan.SeedPaths = filterLenovoChassisInventoryPaths(resolved.Plan.SeedPaths, allowedChassis)
resolved.Plan.CriticalPaths = filterLenovoChassisInventoryPaths(resolved.Plan.CriticalPaths, allowedChassis)
resolved.Plan.PlanBPaths = filterLenovoChassisInventoryPaths(resolved.Plan.PlanBPaths, allowedChassis)
},
}
}
func lenovoAllowedInventoryChassis(chassisPaths, resourceHints []string) map[string]struct{} {
allowed := make(map[string]struct{}, len(chassisPaths))
for _, chassisPath := range chassisPaths {
normalized := normalizePath(chassisPath)
if normalized == "" {
continue
}
if normalized == "/redfish/v1/Chassis/1" {
allowed[normalized] = struct{}{}
continue
}
for _, hint := range resourceHints {
hint = normalizePath(hint)
if !strings.HasPrefix(hint, normalized+"/") {
continue
}
if lenovoHintLooksLikeChassisInventory(hint) {
allowed[normalized] = struct{}{}
break
}
}
}
return allowed
}
func lenovoHintLooksLikeChassisInventory(path string) bool {
for _, suffix := range []string{
"/Power",
"/PowerSubsystem",
"/PowerSubsystem/PowerSupplies",
"/Thermal",
"/ThresholdSensors",
"/DiscreteSensors",
"/SensorsList",
"/NetworkAdapters",
"/PCIeDevices",
"/Drives",
"/Assembly",
} {
if strings.HasSuffix(path, suffix) || strings.Contains(path, suffix+"/") {
return true
}
}
return false
}
func filterLenovoChassisInventoryPaths(paths []string, allowedChassis map[string]struct{}) []string {
if len(paths) == 0 {
return nil
}
out := make([]string, 0, len(paths))
for _, path := range paths {
normalized := normalizePath(path)
chassis := lenovoPathChassisRoot(normalized)
if chassis == "" {
out = append(out, normalized)
continue
}
if normalized == chassis {
out = append(out, normalized)
continue
}
if _, ok := allowedChassis[chassis]; ok {
out = append(out, normalized)
}
}
return dedupeSorted(out)
}
func lenovoPathChassisRoot(path string) string {
const prefix = "/redfish/v1/Chassis/"
if !strings.HasPrefix(path, prefix) {
return ""
}
rest := strings.TrimPrefix(path, prefix)
if rest == "" {
return ""
}
if idx := strings.IndexByte(rest, '/'); idx >= 0 {
return prefix + rest[:idx]
}
return prefix + rest
}

View File

@@ -9,6 +9,7 @@ package lenovo_xcc
import (
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
"time"
@@ -17,7 +18,7 @@ import (
"git.mchus.pro/mchus/logpile/internal/parser"
)
const parserVersion = "1.1"
const parserVersion = "1.2"
func init() {
parser.Register(&Parser{})
@@ -96,9 +97,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)...)
@@ -317,9 +320,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 {
@@ -440,17 +447,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
@@ -496,13 +507,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)
@@ -556,11 +572,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
@@ -591,6 +609,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

@@ -256,3 +256,143 @@ func TestSeverity_UnqualifiedDIMMMessageBecomesWarning(t *testing.T) {
t.Fatalf("expected warning severity, got %q", got)
}
}
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)
}
}
}