feat(parser): lenovo xcc vroc volume parsing - v1.2
Parse inventory_volume.log: Intel VROC (VMD) RAID volumes including RAID level, capacity (GiB/TiB support added), status and member drives. Add Drives []string to StorageVolume model. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
151
internal/parser/vendors/lenovo_xcc/parser.go
vendored
151
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{})
|
||||
@@ -88,6 +89,9 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
|
||||
if f := findByPath(files, "tmp/inventory_disk.log"); f != nil {
|
||||
result.Hardware.Storage = parseDisks(f.Content)
|
||||
}
|
||||
if f := findByPath(files, "tmp/inventory_volume.log"); f != nil {
|
||||
result.Hardware.Volumes = parseVolumes(f.Content)
|
||||
}
|
||||
if f := findByPath(files, "tmp/inventory_card.log"); f != nil {
|
||||
result.Hardware.PCIeDevices = parseCards(f.Content)
|
||||
}
|
||||
@@ -103,6 +107,7 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
|
||||
for _, f := range findEventFiles(files) {
|
||||
result.Events = append(result.Events, parseEvents(f.Content)...)
|
||||
}
|
||||
applyDIMMWarningsFromEvents(result)
|
||||
|
||||
result.Protocol = "ipmi"
|
||||
result.SourceType = models.SourceTypeArchive
|
||||
@@ -297,6 +302,25 @@ type xccEventDoc struct {
|
||||
Items []xccEvent `json:"items"`
|
||||
}
|
||||
|
||||
type xccVolumeDoc struct {
|
||||
Items []xccVolumeItem `json:"items"`
|
||||
}
|
||||
|
||||
type xccVolumeItem struct {
|
||||
Volumes []xccVolume `json:"volumes"`
|
||||
TotalCapacityStr string `json:"totalCapacityStr"`
|
||||
}
|
||||
|
||||
type xccVolume struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Drives string `json:"drives"` // e.g. "M.2 Drive 0, M.2 Drive 1"
|
||||
RDLvlStr string `json:"rdlvlstr"` // e.g. "RAID 1"
|
||||
CapacityStr string `json:"capacityStr"` // e.g. "893.750 GiB"
|
||||
Status int `json:"status"`
|
||||
StatusStr string `json:"statusStr"` // e.g. "Optimal"
|
||||
}
|
||||
|
||||
type xccEvent struct {
|
||||
Severity string `json:"severity"` // "I", "W", "E", "C"
|
||||
Source string `json:"source"`
|
||||
@@ -462,6 +486,37 @@ func parseDisks(content []byte) []models.Storage {
|
||||
return out
|
||||
}
|
||||
|
||||
func parseVolumes(content []byte) []models.StorageVolume {
|
||||
var doc xccVolumeDoc
|
||||
if err := json.Unmarshal(content, &doc); err != nil || len(doc.Items) == 0 {
|
||||
return nil
|
||||
}
|
||||
var out []models.StorageVolume
|
||||
for _, item := range doc.Items {
|
||||
for _, v := range item.Volumes {
|
||||
vol := models.StorageVolume{
|
||||
ID: fmt.Sprintf("%d", v.ID),
|
||||
Name: strings.TrimSpace(v.Name),
|
||||
RAIDLevel: strings.TrimSpace(v.RDLvlStr),
|
||||
SizeGB: parseCapacityToGB(v.CapacityStr),
|
||||
Status: strings.TrimSpace(v.StatusStr),
|
||||
}
|
||||
drives := strings.TrimSpace(v.Drives)
|
||||
if drives != "" {
|
||||
for _, d := range strings.Split(drives, ",") {
|
||||
vol.Drives = append(vol.Drives, strings.TrimSpace(d))
|
||||
}
|
||||
// M.2 NVMe volumes are managed by Intel VROC (VMD)
|
||||
if strings.Contains(strings.ToLower(drives), "m.2") {
|
||||
vol.Controller = "Intel VROC"
|
||||
}
|
||||
}
|
||||
out = append(out, vol)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseCards(content []byte) []models.PCIeDevice {
|
||||
var doc xccCardDoc
|
||||
if err := json.Unmarshal(content, &doc); err != nil {
|
||||
@@ -613,6 +668,96 @@ func isUnqualifiedDIMM(value string) bool {
|
||||
return strings.Contains(strings.ToLower(strings.TrimSpace(value)), "unqualified dimm")
|
||||
}
|
||||
|
||||
var (
|
||||
unqualifiedDIMMSlotRE = regexp.MustCompile(`(?i)\bunqualified dimm\s+(\d+)\b`)
|
||||
unqualifiedDIMMSerialRE = regexp.MustCompile(`(?i)\bserial number is\s+([A-Z0-9-]+)`)
|
||||
)
|
||||
|
||||
func applyDIMMWarningsFromEvents(result *models.AnalysisResult) {
|
||||
if result == nil || result.Hardware == nil || len(result.Hardware.Memory) == 0 || len(result.Events) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for _, ev := range result.Events {
|
||||
if !isUnqualifiedDIMM(ev.Description) {
|
||||
continue
|
||||
}
|
||||
idx := findDIMMIndexForUnqualifiedEvent(result.Hardware.Memory, ev.Description)
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
dimm := &result.Hardware.Memory[idx]
|
||||
dimm.Status = "Warning"
|
||||
dimm.ErrorDescription = ev.Description
|
||||
if !ev.Timestamp.IsZero() {
|
||||
ts := ev.Timestamp.UTC()
|
||||
dimm.StatusChangedAt = &ts
|
||||
dimm.StatusCheckedAt = &ts
|
||||
}
|
||||
appendDIMMStatusHistory(dimm, ev)
|
||||
}
|
||||
}
|
||||
|
||||
func findDIMMIndexForUnqualifiedEvent(memory []models.MemoryDIMM, description string) int {
|
||||
slot := extractUnqualifiedDIMMSlot(description)
|
||||
serial := normalizeUnqualifiedDIMMSerial(extractUnqualifiedDIMMSerial(description))
|
||||
|
||||
for i := range memory {
|
||||
if slot != "" && strings.EqualFold(strings.TrimSpace(memory[i].Slot), slot) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
for i := range memory {
|
||||
if serial != "" && normalizeUnqualifiedDIMMSerial(memory[i].SerialNumber) == serial {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func extractUnqualifiedDIMMSlot(description string) string {
|
||||
m := unqualifiedDIMMSlotRE.FindStringSubmatch(description)
|
||||
if len(m) < 2 {
|
||||
return ""
|
||||
}
|
||||
return "DIMM " + strings.TrimSpace(m[1])
|
||||
}
|
||||
|
||||
func extractUnqualifiedDIMMSerial(description string) string {
|
||||
m := unqualifiedDIMMSerialRE.FindStringSubmatch(description)
|
||||
if len(m) < 2 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(m[1])
|
||||
}
|
||||
|
||||
func normalizeUnqualifiedDIMMSerial(serial string) string {
|
||||
serial = strings.ToUpper(strings.TrimSpace(serial))
|
||||
if idx := strings.Index(serial, "-"); idx >= 0 {
|
||||
serial = serial[:idx]
|
||||
}
|
||||
return serial
|
||||
}
|
||||
|
||||
func appendDIMMStatusHistory(dimm *models.MemoryDIMM, ev models.Event) {
|
||||
if dimm == nil || ev.Timestamp.IsZero() {
|
||||
return
|
||||
}
|
||||
for _, item := range dimm.StatusHistory {
|
||||
if strings.EqualFold(strings.TrimSpace(item.Status), "Warning") &&
|
||||
item.ChangedAt.Equal(ev.Timestamp.UTC()) &&
|
||||
strings.TrimSpace(item.Details) == strings.TrimSpace(ev.Description) {
|
||||
return
|
||||
}
|
||||
}
|
||||
dimm.StatusHistory = append(dimm.StatusHistory, models.StatusHistoryEntry{
|
||||
Status: "Warning",
|
||||
ChangedAt: ev.Timestamp.UTC(),
|
||||
Details: ev.Description,
|
||||
})
|
||||
}
|
||||
|
||||
func parseXCCTime(s string) (time.Time, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
formats := []string{
|
||||
@@ -674,8 +819,12 @@ func parseCapacityToGB(s string) int {
|
||||
switch strings.ToUpper(parts[1]) {
|
||||
case "TB":
|
||||
return int(v * 1000)
|
||||
case "TIB":
|
||||
return int(v * 1099.511627776) // 1 TiB = 1099.511... GB
|
||||
case "GB":
|
||||
return int(v)
|
||||
case "GIB":
|
||||
return int(v * 1.073741824) // 1 GiB = 1.073741824 GB
|
||||
case "MB":
|
||||
return int(v / 1024)
|
||||
}
|
||||
|
||||
108
internal/parser/vendors/lenovo_xcc/parser_test.go
vendored
108
internal/parser/vendors/lenovo_xcc/parser_test.go
vendored
@@ -2,6 +2,7 @@ package lenovo_xcc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/logpile/internal/models"
|
||||
"git.mchus.pro/mchus/logpile/internal/parser"
|
||||
@@ -224,6 +225,75 @@ func TestParse_LenovoXCCMiniLog_Firmware(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_LenovoXCCMiniLog_VROCVolumes(t *testing.T) {
|
||||
files, err := parser.ExtractArchive(exampleArchive)
|
||||
if err != nil {
|
||||
t.Skipf("example archive not available: %v", err)
|
||||
}
|
||||
|
||||
p := &Parser{}
|
||||
result, _ := p.Parse(files)
|
||||
if result == nil || result.Hardware == nil {
|
||||
t.Fatal("Parse returned nil")
|
||||
}
|
||||
|
||||
if len(result.Hardware.Volumes) == 0 {
|
||||
t.Error("expected at least one VROC volume, got none")
|
||||
}
|
||||
for i, v := range result.Hardware.Volumes {
|
||||
t.Logf("Volume[%d]: id=%s controller=%q raid=%s size=%dGB status=%s drives=%v",
|
||||
i, v.ID, v.Controller, v.RAIDLevel, v.SizeGB, v.Status, v.Drives)
|
||||
if v.RAIDLevel == "" {
|
||||
t.Errorf("Volume[%d]: RAIDLevel is empty", i)
|
||||
}
|
||||
if v.Status == "" {
|
||||
t.Errorf("Volume[%d]: Status is empty", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVolumes_IntelVROC(t *testing.T) {
|
||||
content := []byte(`{
|
||||
"identifier": "storage.id",
|
||||
"items": [{
|
||||
"volumes": [{
|
||||
"id": 1,
|
||||
"name": "",
|
||||
"drives": "M.2 Drive 0, M.2 Drive 1",
|
||||
"rdlvlstr": "RAID 1",
|
||||
"capacityStr": "893.750 GiB",
|
||||
"status": 3,
|
||||
"statusStr": "Optimal"
|
||||
}],
|
||||
"totalCapacityStr": "893.750 GiB"
|
||||
}]
|
||||
}`)
|
||||
|
||||
vols := parseVolumes(content)
|
||||
if len(vols) != 1 {
|
||||
t.Fatalf("expected 1 volume, got %d", len(vols))
|
||||
}
|
||||
v := vols[0]
|
||||
if v.ID != "1" {
|
||||
t.Errorf("expected ID=1, got %q", v.ID)
|
||||
}
|
||||
if v.RAIDLevel != "RAID 1" {
|
||||
t.Errorf("expected RAIDLevel=RAID 1, got %q", v.RAIDLevel)
|
||||
}
|
||||
if v.Status != "Optimal" {
|
||||
t.Errorf("expected Status=Optimal, got %q", v.Status)
|
||||
}
|
||||
if v.Controller != "Intel VROC" {
|
||||
t.Errorf("expected Controller=Intel VROC, got %q", v.Controller)
|
||||
}
|
||||
if len(v.Drives) != 2 {
|
||||
t.Errorf("expected 2 drives, got %d: %v", len(v.Drives), v.Drives)
|
||||
}
|
||||
if v.SizeGB < 900 || v.SizeGB > 1000 {
|
||||
t.Errorf("expected SizeGB ~960, got %d", v.SizeGB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDIMMs_UnqualifiedDIMMAddsWarningEvent(t *testing.T) {
|
||||
content := []byte(`{
|
||||
"items": [{
|
||||
@@ -256,3 +326,41 @@ func TestSeverity_UnqualifiedDIMMMessageBecomesWarning(t *testing.T) {
|
||||
t.Fatalf("expected warning severity, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDIMMWarningsFromEvents_UpdatesDIMMStatusForExport(t *testing.T) {
|
||||
result := &models.AnalysisResult{
|
||||
Events: []models.Event{
|
||||
{
|
||||
Timestamp: time.Date(2026, 4, 13, 11, 37, 38, 0, time.UTC),
|
||||
Severity: models.SeverityWarning,
|
||||
Description: "Unqualified DIMM 3 has been detected, the DIMM serial number is 80CE042328460C5D88-V20.",
|
||||
},
|
||||
},
|
||||
Hardware: &models.HardwareConfig{
|
||||
Memory: []models.MemoryDIMM{
|
||||
{
|
||||
Slot: "DIMM 3",
|
||||
Present: true,
|
||||
SerialNumber: "80CE042328460C5D88",
|
||||
Status: "Normal",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
applyDIMMWarningsFromEvents(result)
|
||||
|
||||
dimm := result.Hardware.Memory[0]
|
||||
if dimm.Status != "Warning" {
|
||||
t.Fatalf("expected DIMM status Warning, got %q", dimm.Status)
|
||||
}
|
||||
if dimm.ErrorDescription == "" || dimm.ErrorDescription != result.Events[0].Description {
|
||||
t.Fatalf("expected DIMM error description to be populated, got %q", dimm.ErrorDescription)
|
||||
}
|
||||
if dimm.StatusChangedAt == nil || !dimm.StatusChangedAt.Equal(result.Events[0].Timestamp) {
|
||||
t.Fatalf("expected status_changed_at from event timestamp, got %#v", dimm.StatusChangedAt)
|
||||
}
|
||||
if len(dimm.StatusHistory) != 1 || dimm.StatusHistory[0].Status != "Warning" {
|
||||
t.Fatalf("expected warning status history entry, got %#v", dimm.StatusHistory)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user