// Package lenovo_xcc provides parser for Lenovo XCC mini-log archives. // Tested with: ThinkSystem SR650 V3 (XCC mini-log zip, exported via XCC UI) // // Archive structure: zip with tmp/ directory containing JSON .log files. // // IMPORTANT: Increment parserVersion when modifying parser logic! package lenovo_xcc import ( "encoding/json" "fmt" "strconv" "strings" "time" "git.mchus.pro/mchus/logpile/internal/models" "git.mchus.pro/mchus/logpile/internal/parser" ) const parserVersion = "1.1" func init() { parser.Register(&Parser{}) } // Parser implements VendorParser for Lenovo XCC mini-log archives. type Parser struct{} func (p *Parser) Name() string { return "Lenovo XCC Mini-Log Parser" } func (p *Parser) Vendor() string { return "lenovo_xcc" } func (p *Parser) Version() string { return parserVersion } // Detect checks if files match the Lenovo XCC mini-log archive format. // Returns confidence score 0-100. func (p *Parser) Detect(files []parser.ExtractedFile) int { confidence := 0 for _, f := range files { path := strings.ToLower(f.Path) switch { case strings.HasSuffix(path, "tmp/basic_sys_info.log"): confidence += 60 case strings.HasSuffix(path, "tmp/inventory_cpu.log"): confidence += 20 case strings.HasSuffix(path, "tmp/xcc_plat_events1.log"): confidence += 20 case strings.HasSuffix(path, "tmp/inventory_dimm.log"): confidence += 10 case strings.HasSuffix(path, "tmp/inventory_fw.log"): confidence += 10 } if confidence >= 100 { return 100 } } return confidence } // Parse parses the Lenovo XCC mini-log archive and returns an analysis result. func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, error) { result := &models.AnalysisResult{ Events: make([]models.Event, 0), FRU: make([]models.FRUInfo, 0), Sensors: make([]models.SensorReading, 0), Hardware: &models.HardwareConfig{ Firmware: make([]models.FirmwareInfo, 0), CPUs: make([]models.CPU, 0), Memory: make([]models.MemoryDIMM, 0), Storage: make([]models.Storage, 0), PCIeDevices: make([]models.PCIeDevice, 0), PowerSupply: make([]models.PSU, 0), }, } if f := findByPath(files, "tmp/basic_sys_info.log"); f != nil { parseBasicSysInfo(f.Content, result) } if f := findByPath(files, "tmp/inventory_fw.log"); f != nil { result.Hardware.Firmware = append(result.Hardware.Firmware, parseFirmware(f.Content)...) } if f := findByPath(files, "tmp/inventory_cpu.log"); f != nil { result.Hardware.CPUs = parseCPUs(f.Content) } if f := findByPath(files, "tmp/inventory_dimm.log"); f != nil { memory, events := parseDIMMs(f.Content) result.Hardware.Memory = memory result.Events = append(result.Events, events...) } if f := findByPath(files, "tmp/inventory_disk.log"); f != nil { result.Hardware.Storage = parseDisks(f.Content) } if f := findByPath(files, "tmp/inventory_card.log"); f != nil { result.Hardware.PCIeDevices = parseCards(f.Content) } if f := findByPath(files, "tmp/inventory_psu.log"); f != nil { result.Hardware.PowerSupply = parsePSUs(f.Content) } if f := findByPath(files, "tmp/inventory_ipmi_fru.log"); f != nil { result.FRU = parseFRU(f.Content) } if f := findByPath(files, "tmp/inventory_ipmi_sensor.log"); f != nil { result.Sensors = parseSensors(f.Content) } for _, f := range findEventFiles(files) { result.Events = append(result.Events, parseEvents(f.Content)...) } result.Protocol = "ipmi" result.SourceType = models.SourceTypeArchive parser.ApplyManufacturedYearWeekFromFRU(result.FRU, result.Hardware) return result, nil } // findByPath returns the first file whose lowercased path ends with the given suffix. func findByPath(files []parser.ExtractedFile, suffix string) *parser.ExtractedFile { for i := range files { if strings.HasSuffix(strings.ToLower(files[i].Path), suffix) { return &files[i] } } return nil } // findEventFiles returns all xcc_plat_eventsN.log files. func findEventFiles(files []parser.ExtractedFile) []parser.ExtractedFile { var out []parser.ExtractedFile for _, f := range files { path := strings.ToLower(f.Path) if strings.Contains(path, "tmp/xcc_plat_events") && strings.HasSuffix(path, ".log") { out = append(out, f) } } return out } // --- JSON structures --- type xccBasicSysInfoDoc struct { Items []xccBasicSysInfoItem `json:"items"` } type xccBasicSysInfoItem struct { MachineName string `json:"machine_name"` MachineTypeModel string `json:"machine_typemodel"` SerialNumber string `json:"serial_number"` UUID string `json:"uuid"` PowerState string `json:"power_state"` ServerState string `json:"server_state"` CurrentTime string `json:"current_time"` } // xccFWEntry covers both basic_sys_info firmware (no type_str) and inventory_fw (has type_str). type xccFWEntry struct { Index int `json:"index"` TypeCode int `json:"type"` TypeStr string `json:"type_str"` // only in inventory_fw.log Version string `json:"version"` Build string `json:"build"` ReleaseDate string `json:"release_date"` } type xccFirmwareDoc struct { Items []xccFWEntry `json:"items"` } type xccCPUDoc struct { Items []xccCPUItem `json:"items"` } type xccCPUItem struct { Processors []xccCPU `json:"processors"` } type xccCPU struct { Name int `json:"processors_name"` Model string `json:"processors_cpu_model"` Cores json.RawMessage `json:"processors_cores"` // may be int or string Threads json.RawMessage `json:"processors_threads"` // may be int or string ClockSpeed string `json:"processors_clock_speed"` L1DataCache string `json:"processors_l1datacache"` L2Cache string `json:"processors_l2cache"` L3Cache string `json:"processors_l3cache"` Status string `json:"processors_status"` SerialNumber string `json:"processors_serial_number"` } type xccDIMMDoc struct { Items []xccDIMMItem `json:"items"` } type xccDIMMItem struct { Memory []xccDIMM `json:"memory"` } type xccDIMM struct { Index int `json:"memory_index"` Status string `json:"memory_status"` Name string `json:"memory_name"` Type string `json:"memory_type"` Capacity json.RawMessage `json:"memory_capacity"` // int (GB) or string PartNumber string `json:"memory_part_number"` SerialNumber string `json:"memory_serial_number"` Manufacturer string `json:"memory_manufacturer"` MemSpeed json.RawMessage `json:"memory_mem_speed"` // int or string ConfigSpeed json.RawMessage `json:"memory_config_speed"` // int or string } type xccDiskDoc struct { Items []xccDiskItem `json:"items"` } type xccDiskItem struct { Disks []xccDisk `json:"disks"` } type xccDisk struct { ID int `json:"id"` SlotNo int `json:"slotNo"` Type string `json:"type"` Interface string `json:"interface"` Media string `json:"media"` SerialNo string `json:"serialNo"` PartNo string `json:"partNo"` CapacityStr string `json:"capacityStr"` // e.g. "3.20 TB" Manufacture string `json:"manufacture"` ProductName string `json:"productName"` RemainLife int `json:"remainLife"` // 0-100 FWVersion string `json:"fwVersion"` Temperature int `json:"temperature"` HealthStatus int `json:"healthStatus"` // int code: 2=Normal State int `json:"state"` StateStr string `json:"statestr"` } type xccCardDoc struct { Items []xccCard `json:"items"` } type xccCard struct { Key int `json:"key"` SlotNo int `json:"slotNo"` AdapterName string `json:"adapterName"` ConnectorLabel string `json:"connectorLabel"` OOBSupported int `json:"oobSupported"` Location int `json:"location"` Functions []xccCardFunc `json:"functions"` } type xccCardFunc struct { FunType int `json:"funType"` BusNo int `json:"generic_busNo"` DevNo int `json:"generic_devNo"` FunNo int `json:"generic_funNo"` VendorID int `json:"generic_vendorId"` // direct int DeviceID int `json:"generic_devId"` // direct int SlotDesignation string `json:"generic_slotDesignation"` } type xccPSUDoc struct { Items []xccPSUItem `json:"items"` } type xccPSUItem struct { Power []xccPSU `json:"power"` } type xccPSU struct { Name int `json:"name"` Status string `json:"status"` RatedPower int `json:"rated_power"` PartNumber string `json:"part_number"` FRUNumber string `json:"fru_number"` SerialNumber string `json:"serial_number"` ManufID string `json:"manuf_id"` } type xccFRUDoc struct { Items []xccFRUItem `json:"items"` } type xccFRUItem struct { BuiltinFRU []map[string]string `json:"builtin_fru_device"` } type xccSensorDoc struct { Items []xccSensor `json:"items"` } type xccSensor struct { Name string `json:"Sensor Name"` Value string `json:"Value"` Status string `json:"status"` Unit string `json:"unit"` } type xccEventDoc struct { Items []xccEvent `json:"items"` } type xccEvent struct { Severity string `json:"severity"` // "I", "W", "E", "C" Source string `json:"source"` Date string `json:"date"` // "2025-12-22T13:24:02.070" Index int `json:"index"` EventID string `json:"eventid"` CmnID string `json:"cmnid"` Message string `json:"message"` } // --- Parsers --- func parseBasicSysInfo(content []byte, result *models.AnalysisResult) { var doc xccBasicSysInfoDoc if err := json.Unmarshal(content, &doc); err != nil || len(doc.Items) == 0 { return } item := doc.Items[0] result.Hardware.BoardInfo = models.BoardInfo{ ProductName: strings.TrimSpace(item.MachineTypeModel), SerialNumber: strings.TrimSpace(item.SerialNumber), UUID: strings.TrimSpace(item.UUID), } if t, err := parseXCCTime(item.CurrentTime); err == nil { result.CollectedAt = t.UTC() } } func parseFirmware(content []byte) []models.FirmwareInfo { var doc xccFirmwareDoc if err := json.Unmarshal(content, &doc); err != nil { return nil } var out []models.FirmwareInfo for _, fw := range doc.Items { if fi := xccFWEntryToModel(fw); fi != nil { out = append(out, *fi) } } return out } func xccFWEntryToModel(fw xccFWEntry) *models.FirmwareInfo { name := strings.TrimSpace(fw.TypeStr) version := strings.TrimSpace(fw.Version) if name == "" && version == "" { return nil } build := strings.TrimSpace(fw.Build) v := version if build != "" { v = version + " (" + build + ")" } return &models.FirmwareInfo{ DeviceName: name, Version: v, BuildTime: strings.TrimSpace(fw.ReleaseDate), } } func parseCPUs(content []byte) []models.CPU { var doc xccCPUDoc if err := json.Unmarshal(content, &doc); err != nil || len(doc.Items) == 0 { return nil } var out []models.CPU for _, item := range doc.Items { for _, c := range item.Processors { cpu := models.CPU{ Socket: c.Name, Model: strings.TrimSpace(c.Model), Cores: rawJSONToInt(c.Cores), Threads: rawJSONToInt(c.Threads), FrequencyMHz: parseMHz(c.ClockSpeed), L1CacheKB: parseKB(c.L1DataCache), L2CacheKB: parseKB(c.L2Cache), L3CacheKB: parseKB(c.L3Cache), Status: strings.TrimSpace(c.Status), SerialNumber: strings.TrimSpace(c.SerialNumber), } out = append(out, cpu) } } return out } func parseDIMMs(content []byte) ([]models.MemoryDIMM, []models.Event) { var doc xccDIMMDoc if err := json.Unmarshal(content, &doc); err != nil || len(doc.Items) == 0 { return nil, nil } var out []models.MemoryDIMM var events []models.Event for _, item := range doc.Items { for _, m := range item.Memory { status := strings.TrimSpace(m.Status) present := !strings.EqualFold(status, "not present") && !strings.EqualFold(status, "absent") // memory_capacity is in GB (int); convert to MB capacityGB := rawJSONToInt(m.Capacity) dimm := models.MemoryDIMM{ Slot: strings.TrimSpace(m.Name), Location: strings.TrimSpace(m.Name), Present: present, SizeMB: capacityGB * 1024, Type: strings.TrimSpace(m.Type), MaxSpeedMHz: rawJSONToInt(m.MemSpeed), CurrentSpeedMHz: rawJSONToInt(m.ConfigSpeed), Manufacturer: strings.TrimSpace(m.Manufacturer), SerialNumber: strings.TrimSpace(m.SerialNumber), PartNumber: strings.TrimSpace(strings.TrimRight(m.PartNumber, " ")), Status: status, } out = append(out, dimm) if isUnqualifiedDIMM(status) { events = append(events, models.Event{ Source: "Memory", SensorType: "Memory", SensorName: dimm.Slot, EventType: "DIMM Qualification", Severity: models.SeverityWarning, Description: status, }) } } } return out, events } func parseDisks(content []byte) []models.Storage { var doc xccDiskDoc if err := json.Unmarshal(content, &doc); err != nil || len(doc.Items) == 0 { return nil } var out []models.Storage for _, item := range doc.Items { for _, d := range item.Disks { sizeGB := parseCapacityToGB(d.CapacityStr) stateStr := strings.TrimSpace(d.StateStr) present := !strings.EqualFold(stateStr, "absent") && !strings.EqualFold(stateStr, "not present") disk := models.Storage{ Slot: fmt.Sprintf("%d", d.SlotNo), Type: strings.TrimSpace(d.Media), Model: strings.TrimSpace(d.ProductName), SizeGB: sizeGB, SerialNumber: strings.TrimSpace(d.SerialNo), Manufacturer: strings.TrimSpace(d.Manufacture), Firmware: strings.TrimSpace(d.FWVersion), Interface: strings.TrimSpace(d.Interface), Present: present, Status: stateStr, } if d.RemainLife >= 0 && d.RemainLife <= 100 { v := d.RemainLife disk.RemainingEndurancePct = &v } out = append(out, disk) } } return out } func parseCards(content []byte) []models.PCIeDevice { var doc xccCardDoc if err := json.Unmarshal(content, &doc); err != nil { return nil } var out []models.PCIeDevice for _, card := range doc.Items { slot := strings.TrimSpace(card.ConnectorLabel) if slot == "" { slot = fmt.Sprintf("%d", card.SlotNo) } dev := models.PCIeDevice{ Slot: slot, Description: strings.TrimSpace(card.AdapterName), } if len(card.Functions) > 0 { fn := card.Functions[0] dev.BDF = fmt.Sprintf("%02x:%02x.%x", fn.BusNo, fn.DevNo, fn.FunNo) dev.VendorID = fn.VendorID dev.DeviceID = fn.DeviceID } out = append(out, dev) } return out } func parsePSUs(content []byte) []models.PSU { var doc xccPSUDoc if err := json.Unmarshal(content, &doc); err != nil || len(doc.Items) == 0 { return nil } var out []models.PSU for _, item := range doc.Items { for _, p := range item.Power { psu := models.PSU{ Slot: fmt.Sprintf("%d", p.Name), Present: true, WattageW: p.RatedPower, SerialNumber: strings.TrimSpace(p.SerialNumber), PartNumber: strings.TrimSpace(p.PartNumber), Vendor: strings.TrimSpace(p.ManufID), Status: strings.TrimSpace(p.Status), } out = append(out, psu) } } return out } func parseFRU(content []byte) []models.FRUInfo { var doc xccFRUDoc if err := json.Unmarshal(content, &doc); err != nil || len(doc.Items) == 0 { return nil } var out []models.FRUInfo for _, item := range doc.Items { for _, entry := range item.BuiltinFRU { fru := models.FRUInfo{ Description: entry["FRU Device Description"], Manufacturer: entry["Board Mfg"], ProductName: entry["Board Product"], SerialNumber: entry["Board Serial"], PartNumber: entry["Board Part Number"], MfgDate: entry["Board Mfg Date"], } if fru.ProductName == "" { fru.ProductName = entry["Product Name"] } if fru.SerialNumber == "" { fru.SerialNumber = entry["Product Serial"] } if fru.PartNumber == "" { fru.PartNumber = entry["Product Part Number"] } if fru.Description == "" && fru.ProductName == "" && fru.SerialNumber == "" { continue } out = append(out, fru) } } return out } func parseSensors(content []byte) []models.SensorReading { var doc xccSensorDoc if err := json.Unmarshal(content, &doc); err != nil { return nil } var out []models.SensorReading for _, s := range doc.Items { name := strings.TrimSpace(s.Name) if name == "" { continue } sr := models.SensorReading{ Name: name, RawValue: strings.TrimSpace(s.Value), Unit: strings.TrimSpace(s.Unit), Status: strings.TrimSpace(s.Status), } if v, err := strconv.ParseFloat(sr.RawValue, 64); err == nil { sr.Value = v } out = append(out, sr) } return out } func parseEvents(content []byte) []models.Event { var doc xccEventDoc if err := json.Unmarshal(content, &doc); err != nil { return nil } var out []models.Event for _, e := range doc.Items { ev := models.Event{ ID: e.EventID, Source: strings.TrimSpace(e.Source), Description: strings.TrimSpace(e.Message), Severity: xccSeverity(e.Severity, e.Message), } if t, err := parseXCCTime(e.Date); err == nil { ev.Timestamp = t.UTC() } out = append(out, ev) } return out } // --- Helpers --- func xccSeverity(s, message string) models.Severity { if isUnqualifiedDIMM(message) { return models.SeverityWarning } switch strings.ToUpper(strings.TrimSpace(s)) { case "C": return models.SeverityCritical case "E": return models.SeverityCritical case "W": return models.SeverityWarning default: return models.SeverityInfo } } func isUnqualifiedDIMM(value string) bool { return strings.Contains(strings.ToLower(strings.TrimSpace(value)), "unqualified dimm") } func parseXCCTime(s string) (time.Time, error) { s = strings.TrimSpace(s) formats := []string{ "2006-01-02T15:04:05.000", "2006-01-02T15:04:05", "2006-01-02 15:04:05", } for _, f := range formats { if t, err := time.Parse(f, s); err == nil { return t, nil } } return time.Time{}, fmt.Errorf("unparseable time: %q", s) } // parseMHz parses "4100 MHz" → 4100 func parseMHz(s string) int { s = strings.TrimSpace(s) parts := strings.Fields(s) if len(parts) == 0 { return 0 } v, _ := strconv.Atoi(parts[0]) return v } // parseKB parses "384 KB" → 384 func parseKB(s string) int { s = strings.TrimSpace(s) parts := strings.Fields(s) if len(parts) == 0 { return 0 } v, _ := strconv.Atoi(parts[0]) return v } // parseMB parses "32768 MB" → 32768 func parseMB(s string) int { return parseKB(s) } // parseMTs parses "4800 MT/s" → 4800 (treated as MHz equivalent) func parseMTs(s string) int { return parseKB(s) } // parseCapacityToGB parses "3.20 TB" or "480 GB" → GB integer func parseCapacityToGB(s string) int { s = strings.TrimSpace(s) parts := strings.Fields(s) if len(parts) < 2 { return 0 } v, err := strconv.ParseFloat(parts[0], 64) if err != nil { return 0 } switch strings.ToUpper(parts[1]) { case "TB": return int(v * 1000) case "GB": return int(v) case "MB": return int(v / 1024) } return int(v) } // rawJSONToInt parses a json.RawMessage that may be an int or a quoted string → int func rawJSONToInt(raw json.RawMessage) int { if len(raw) == 0 { return 0 } // try direct int var n int if err := json.Unmarshal(raw, &n); err == nil { return n } // try string var s string if err := json.Unmarshal(raw, &s); err == nil { v, _ := strconv.Atoi(strings.TrimSpace(s)) return v } return 0 } // parseHexID parses "0x15b3" → 5555 func parseHexID(s string) int { s = strings.TrimSpace(strings.ToLower(s)) s = strings.TrimPrefix(s, "0x") v, _ := strconv.ParseInt(s, 16, 32) return int(v) }