Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63ed8a384e | ||
|
|
1162ccd22e | ||
|
|
3887df6547 | ||
|
|
a82fb227e5 |
2
bible
2
bible
Submodule bible updated: 456c1f022c...d2600f1279
Submodule internal/chart updated: c025ae0477...2a15bc87f1
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -159,6 +159,16 @@ func buildDevicesFromLegacy(hw *models.HardwareConfig) []models.HardwareDevice {
|
||||
}
|
||||
for _, stor := range hw.Storage {
|
||||
present := stor.Present
|
||||
storDetails := mergeDetailMaps(nil, stor.Details)
|
||||
if stor.LogicalBlockSizeBytes != 0 {
|
||||
storDetails = mergeDetailMaps(storDetails, map[string]any{"logical_block_size_bytes": stor.LogicalBlockSizeBytes})
|
||||
}
|
||||
if stor.PhysicalBlockSizeBytes != 0 {
|
||||
storDetails = mergeDetailMaps(storDetails, map[string]any{"physical_block_size_bytes": stor.PhysicalBlockSizeBytes})
|
||||
}
|
||||
if stor.MetadataBytesPerBlock != 0 {
|
||||
storDetails = mergeDetailMaps(storDetails, map[string]any{"metadata_bytes_per_block": stor.MetadataBytesPerBlock})
|
||||
}
|
||||
appendDevice(models.HardwareDevice{
|
||||
Kind: models.DeviceKindStorage,
|
||||
Slot: stor.Slot,
|
||||
@@ -177,27 +187,38 @@ func buildDevicesFromLegacy(hw *models.HardwareConfig) []models.HardwareDevice {
|
||||
StatusAtCollect: stor.StatusAtCollect,
|
||||
StatusHistory: stor.StatusHistory,
|
||||
ErrorDescription: stor.ErrorDescription,
|
||||
Details: mergeDetailMaps(nil, stor.Details),
|
||||
Details: storDetails,
|
||||
})
|
||||
}
|
||||
for _, pcie := range hw.PCIeDevices {
|
||||
// Use PartNumber as model when available; fall back to chip description.
|
||||
// Description contains the chip/product name (e.g. "BCM57414 NetXtreme-E …")
|
||||
// while PartNumber is a part/product code. Prefer PartNumber when set.
|
||||
pcieModel := pcie.PartNumber
|
||||
if pcieModel == "" {
|
||||
pcieModel = pcie.Description
|
||||
}
|
||||
// Priority: PartNumber (vendor P/N) > Model (product name) > Description (chip label).
|
||||
pcieModel := firstNonEmptyString(pcie.PartNumber, pcie.Model, pcie.Description)
|
||||
details := mergeDetailMaps(nil, pcie.Details)
|
||||
pcieFirmware := stringFromDetailMap(details, "firmware")
|
||||
// Firmware: prefer direct field, fall back to details, then NVSwitch lookup.
|
||||
pcieFirmware := firstNonEmptyString(pcie.Firmware, stringFromDetailMap(details, "firmware"))
|
||||
if pcieFirmware == "" && isNVSwitchPCIeDevice(pcie) {
|
||||
pcieFirmware = nvswitchFirmwareBySlot[normalizeNVSwitchSlotForLookup(pcie.Slot)]
|
||||
if pcieFirmware != "" {
|
||||
details = mergeDetailMaps(details, map[string]any{
|
||||
"firmware": pcieFirmware,
|
||||
})
|
||||
}
|
||||
}
|
||||
if pcieFirmware != "" {
|
||||
details = mergeDetailMaps(details, map[string]any{"firmware": pcieFirmware})
|
||||
}
|
||||
// Telemetry fields: put into details so convertPCIeFromDevices can pick them up.
|
||||
if pcie.TemperatureC != nil {
|
||||
details = mergeDetailMaps(details, map[string]any{"temperature_c": *pcie.TemperatureC})
|
||||
}
|
||||
if pcie.PowerW != nil {
|
||||
details = mergeDetailMaps(details, map[string]any{"power_w": *pcie.PowerW})
|
||||
}
|
||||
if pcie.ECCCorrectedTotal != nil {
|
||||
details = mergeDetailMaps(details, map[string]any{"ecc_corrected_total": *pcie.ECCCorrectedTotal})
|
||||
}
|
||||
if pcie.ECCUncorrectedTotal != nil {
|
||||
details = mergeDetailMaps(details, map[string]any{"ecc_uncorrected_total": *pcie.ECCUncorrectedTotal})
|
||||
}
|
||||
if pcie.HWSlowdown != nil {
|
||||
details = mergeDetailMaps(details, map[string]any{"hw_slowdown": *pcie.HWSlowdown})
|
||||
}
|
||||
present := pcie.Present
|
||||
appendDevice(models.HardwareDevice{
|
||||
Kind: models.DeviceKindPCIe,
|
||||
Slot: pcie.Slot,
|
||||
@@ -209,11 +230,13 @@ func buildDevicesFromLegacy(hw *models.HardwareConfig) []models.HardwareDevice {
|
||||
PartNumber: pcie.PartNumber,
|
||||
Manufacturer: pcie.Manufacturer,
|
||||
SerialNumber: pcie.SerialNumber,
|
||||
MACAddresses: append([]string(nil), pcie.MACAddresses...),
|
||||
LinkWidth: pcie.LinkWidth,
|
||||
LinkSpeed: pcie.LinkSpeed,
|
||||
MaxLinkWidth: pcie.MaxLinkWidth,
|
||||
MaxLinkSpeed: pcie.MaxLinkSpeed,
|
||||
NUMANode: pcie.NUMANode,
|
||||
Present: present,
|
||||
Status: pcie.Status,
|
||||
StatusCheckedAt: pcie.StatusCheckedAt,
|
||||
StatusChangedAt: pcie.StatusChangedAt,
|
||||
@@ -738,36 +761,39 @@ func convertStorageFromDevices(devices []models.HardwareDevice, collectedAt stri
|
||||
meta := buildStatusMeta(status, d.StatusCheckedAt, d.StatusChangedAt, d.StatusHistory, d.ErrorDescription, collectedAt)
|
||||
presentValue := present
|
||||
result = append(result, ReanimatorStorage{
|
||||
Slot: d.Slot,
|
||||
Type: d.Type,
|
||||
Model: d.Model,
|
||||
SizeGB: d.SizeGB,
|
||||
SerialNumber: d.SerialNumber,
|
||||
Manufacturer: d.Manufacturer,
|
||||
Firmware: d.Firmware,
|
||||
Interface: d.Interface,
|
||||
Present: &presentValue,
|
||||
TemperatureC: floatFromDetailMap(d.Details, "temperature_c"),
|
||||
PowerOnHours: int64FromDetailMap(d.Details, "power_on_hours"),
|
||||
PowerCycles: int64FromDetailMap(d.Details, "power_cycles"),
|
||||
UnsafeShutdowns: int64FromDetailMap(d.Details, "unsafe_shutdowns"),
|
||||
MediaErrors: int64FromDetailMap(d.Details, "media_errors"),
|
||||
ErrorLogEntries: int64FromDetailMap(d.Details, "error_log_entries"),
|
||||
WrittenBytes: int64FromDetailMap(d.Details, "written_bytes"),
|
||||
ReadBytes: int64FromDetailMap(d.Details, "read_bytes"),
|
||||
LifeUsedPct: floatFromDetailMap(d.Details, "life_used_pct"),
|
||||
RemainingEndurancePct: d.RemainingEndurancePct,
|
||||
LifeRemainingPct: floatFromDetailMap(d.Details, "life_remaining_pct"),
|
||||
AvailableSparePct: floatFromDetailMap(d.Details, "available_spare_pct"),
|
||||
ReallocatedSectors: int64FromDetailMap(d.Details, "reallocated_sectors"),
|
||||
CurrentPendingSectors: int64FromDetailMap(d.Details, "current_pending_sectors"),
|
||||
OfflineUncorrectable: int64FromDetailMap(d.Details, "offline_uncorrectable"),
|
||||
Status: status,
|
||||
StatusCheckedAt: meta.StatusCheckedAt,
|
||||
StatusChangedAt: meta.StatusChangedAt,
|
||||
ManufacturedYearWeek: manufacturedYearWeekFromDetails(d.Details),
|
||||
StatusHistory: meta.StatusHistory,
|
||||
ErrorDescription: meta.ErrorDescription,
|
||||
Slot: d.Slot,
|
||||
Type: d.Type,
|
||||
Model: d.Model,
|
||||
SizeGB: d.SizeGB,
|
||||
SerialNumber: d.SerialNumber,
|
||||
Manufacturer: d.Manufacturer,
|
||||
Firmware: d.Firmware,
|
||||
Interface: d.Interface,
|
||||
Present: &presentValue,
|
||||
LogicalBlockSizeBytes: int64FromDetailMap(d.Details, "logical_block_size_bytes"),
|
||||
PhysicalBlockSizeBytes: int64FromDetailMap(d.Details, "physical_block_size_bytes"),
|
||||
MetadataBytesPerBlock: int64FromDetailMap(d.Details, "metadata_bytes_per_block"),
|
||||
TemperatureC: floatFromDetailMap(d.Details, "temperature_c"),
|
||||
PowerOnHours: int64FromDetailMap(d.Details, "power_on_hours"),
|
||||
PowerCycles: int64FromDetailMap(d.Details, "power_cycles"),
|
||||
UnsafeShutdowns: int64FromDetailMap(d.Details, "unsafe_shutdowns"),
|
||||
MediaErrors: int64FromDetailMap(d.Details, "media_errors"),
|
||||
ErrorLogEntries: int64FromDetailMap(d.Details, "error_log_entries"),
|
||||
WrittenBytes: int64FromDetailMap(d.Details, "written_bytes"),
|
||||
ReadBytes: int64FromDetailMap(d.Details, "read_bytes"),
|
||||
LifeUsedPct: floatFromDetailMap(d.Details, "life_used_pct"),
|
||||
RemainingEndurancePct: d.RemainingEndurancePct,
|
||||
LifeRemainingPct: floatFromDetailMap(d.Details, "life_remaining_pct"),
|
||||
AvailableSparePct: floatFromDetailMap(d.Details, "available_spare_pct"),
|
||||
ReallocatedSectors: int64FromDetailMap(d.Details, "reallocated_sectors"),
|
||||
CurrentPendingSectors: int64FromDetailMap(d.Details, "current_pending_sectors"),
|
||||
OfflineUncorrectable: int64FromDetailMap(d.Details, "offline_uncorrectable"),
|
||||
Status: status,
|
||||
StatusCheckedAt: meta.StatusCheckedAt,
|
||||
StatusChangedAt: meta.StatusChangedAt,
|
||||
ManufacturedYearWeek: manufacturedYearWeekFromDetails(d.Details),
|
||||
StatusHistory: meta.StatusHistory,
|
||||
ErrorDescription: meta.ErrorDescription,
|
||||
})
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -12,15 +12,16 @@ type ReanimatorExport struct {
|
||||
|
||||
// ReanimatorHardware contains all hardware components
|
||||
type ReanimatorHardware struct {
|
||||
Board ReanimatorBoard `json:"board"`
|
||||
Firmware []ReanimatorFirmware `json:"firmware,omitempty"`
|
||||
CPUs []ReanimatorCPU `json:"cpus,omitempty"`
|
||||
Memory []ReanimatorMemory `json:"memory,omitempty"`
|
||||
Storage []ReanimatorStorage `json:"storage,omitempty"`
|
||||
PCIeDevices []ReanimatorPCIe `json:"pcie_devices,omitempty"`
|
||||
PowerSupplies []ReanimatorPSU `json:"power_supplies,omitempty"`
|
||||
Sensors *ReanimatorSensors `json:"sensors,omitempty"`
|
||||
EventLogs []ReanimatorEventLog `json:"event_logs,omitempty"`
|
||||
Board ReanimatorBoard `json:"board"`
|
||||
Firmware []ReanimatorFirmware `json:"firmware,omitempty"`
|
||||
CPUs []ReanimatorCPU `json:"cpus,omitempty"`
|
||||
Memory []ReanimatorMemory `json:"memory,omitempty"`
|
||||
Storage []ReanimatorStorage `json:"storage,omitempty"`
|
||||
PCIeDevices []ReanimatorPCIe `json:"pcie_devices,omitempty"`
|
||||
PowerSupplies []ReanimatorPSU `json:"power_supplies,omitempty"`
|
||||
Sensors *ReanimatorSensors `json:"sensors,omitempty"`
|
||||
EventLogs []ReanimatorEventLog `json:"event_logs,omitempty"`
|
||||
PlatformConfig map[string]any `json:"platform_config,omitempty"`
|
||||
}
|
||||
|
||||
// ReanimatorBoard represents motherboard/server information
|
||||
@@ -101,17 +102,20 @@ type ReanimatorMemory struct {
|
||||
|
||||
// ReanimatorStorage represents a storage device
|
||||
type ReanimatorStorage struct {
|
||||
Slot string `json:"slot"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Model string `json:"model"`
|
||||
SizeGB int `json:"size_gb,omitempty"`
|
||||
SerialNumber string `json:"serial_number"`
|
||||
Manufacturer string `json:"manufacturer,omitempty"`
|
||||
Firmware string `json:"firmware,omitempty"`
|
||||
Interface string `json:"interface,omitempty"`
|
||||
Present *bool `json:"present,omitempty"`
|
||||
TemperatureC float64 `json:"temperature_c,omitempty"`
|
||||
PowerOnHours int64 `json:"power_on_hours,omitempty"`
|
||||
Slot string `json:"slot"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Model string `json:"model"`
|
||||
SizeGB int `json:"size_gb,omitempty"`
|
||||
SerialNumber string `json:"serial_number"`
|
||||
Manufacturer string `json:"manufacturer,omitempty"`
|
||||
Firmware string `json:"firmware,omitempty"`
|
||||
Interface string `json:"interface,omitempty"`
|
||||
Present *bool `json:"present,omitempty"`
|
||||
LogicalBlockSizeBytes int64 `json:"logical_block_size_bytes,omitempty"`
|
||||
PhysicalBlockSizeBytes int64 `json:"physical_block_size_bytes,omitempty"`
|
||||
MetadataBytesPerBlock int64 `json:"metadata_bytes_per_block,omitempty"`
|
||||
TemperatureC float64 `json:"temperature_c,omitempty"`
|
||||
PowerOnHours int64 `json:"power_on_hours,omitempty"`
|
||||
PowerCycles int64 `json:"power_cycles,omitempty"`
|
||||
UnsafeShutdowns int64 `json:"unsafe_shutdowns,omitempty"`
|
||||
MediaErrors int64 `json:"media_errors,omitempty"`
|
||||
|
||||
@@ -245,6 +245,9 @@ type Storage struct {
|
||||
Location string `json:"location,omitempty"` // Front/Rear
|
||||
BackplaneID int `json:"backplane_id,omitempty"`
|
||||
RemainingEndurancePct *int `json:"remaining_endurance_pct,omitempty"` // 0-100 %; nil = not reported
|
||||
LogicalBlockSizeBytes int64 `json:"logical_block_size_bytes,omitempty"`
|
||||
PhysicalBlockSizeBytes int64 `json:"physical_block_size_bytes,omitempty"`
|
||||
MetadataBytesPerBlock int64 `json:"metadata_bytes_per_block,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
|
||||
@@ -277,6 +280,8 @@ type PCIeDevice struct {
|
||||
BDF string `json:"bdf"`
|
||||
DeviceClass string `json:"device_class"`
|
||||
Manufacturer string `json:"manufacturer,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Firmware string `json:"firmware,omitempty"`
|
||||
LinkWidth int `json:"link_width"`
|
||||
LinkSpeed string `json:"link_speed"`
|
||||
MaxLinkWidth int `json:"max_link_width"`
|
||||
@@ -285,8 +290,17 @@ type PCIeDevice struct {
|
||||
SerialNumber string `json:"serial_number,omitempty"`
|
||||
MACAddresses []string `json:"mac_addresses,omitempty"`
|
||||
NUMANode int `json:"numa_node,omitempty"` // 0 = not reported/N/A
|
||||
Present *bool `json:"present,omitempty"`
|
||||
IOMMUGroup *int `json:"iommu_group,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
|
||||
// GPU telemetry fields (populated by bee audit for GPU devices)
|
||||
TemperatureC *float64 `json:"temperature_c,omitempty"`
|
||||
PowerW *float64 `json:"power_w,omitempty"`
|
||||
ECCCorrectedTotal *int64 `json:"ecc_corrected_total,omitempty"`
|
||||
ECCUncorrectedTotal *int64 `json:"ecc_uncorrected_total,omitempty"`
|
||||
HWSlowdown *bool `json:"hw_slowdown,omitempty"`
|
||||
|
||||
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
|
||||
StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
|
||||
StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"`
|
||||
|
||||
189
internal/parser/vendors/lenovo_xcc/parser.go
vendored
189
internal/parser/vendors/lenovo_xcc/parser.go
vendored
@@ -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 {
|
||||
|
||||
140
internal/parser/vendors/lenovo_xcc/parser_test.go
vendored
140
internal/parser/vendors/lenovo_xcc/parser_test.go
vendored
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user