package xfusion import ( "fmt" "math" "strconv" "strings" "time" "git.mchus.pro/mchus/logpile/internal/models" "git.mchus.pro/mchus/logpile/internal/parser" ) type xfusionNICCard struct { Slot string Model string ProductName string Vendor string VendorID int DeviceID int BDF string SerialNumber string PartNumber string } type xfusionNetcardPort struct { BDF string MAC string ActualMAC string } type xfusionNetcardSnapshot struct { Timestamp time.Time Slot string ProductName string Manufacturer string Firmware string Ports []xfusionNetcardPort } // ── FRU ────────────────────────────────────────────────────────────────────── // parseFRUInfo parses fruinfo.txt and populates result.FRU and result.Hardware.BoardInfo. // The file contains IPMI FRU blocks separated by "FRU Device Description" header lines. func parseFRUInfo(content []byte, result *models.AnalysisResult) { type fruBlock struct { header string fields map[string]string } var blocks []fruBlock var current *fruBlock for _, line := range strings.Split(string(content), "\n") { trimmed := strings.TrimSpace(line) if strings.HasPrefix(trimmed, "FRU Device Description") { if current != nil { blocks = append(blocks, *current) } current = &fruBlock{header: trimmed, fields: make(map[string]string)} continue } if current == nil { continue } idx := strings.Index(trimmed, " : ") if idx < 0 { continue } key := strings.TrimSpace(trimmed[:idx]) val := strings.TrimSpace(trimmed[idx+3:]) if key != "" && current.fields[key] == "" { current.fields[key] = val } } if current != nil { blocks = append(blocks, *current) } for _, b := range blocks { f := b.fields fru := models.FRUInfo{ Description: extractFRUHeaderDesc(b.header), Manufacturer: firstNonEmpty(f["Board Manufacturer"], f["Product Manufacturer"]), ProductName: f["Product Name"], SerialNumber: firstNonEmpty(f["Product Serial Number"], f["Board Serial Number"]), PartNumber: firstNonEmpty(f["Product Part Number"], f["Board Part Number"]), MfgDate: f["Board Mfg. Date"], } if fru.Description != "" || fru.ProductName != "" || fru.SerialNumber != "" { result.FRU = append(result.FRU, fru) } } // Set BoardInfo from the mainboard block (ID 0). for _, b := range blocks { hdr := strings.ToLower(b.header) if strings.Contains(hdr, "id 0") || strings.Contains(hdr, "mainboard") { f := b.fields result.Hardware.BoardInfo = models.BoardInfo{ Manufacturer: firstNonEmpty(f["Product Manufacturer"], f["Board Manufacturer"]), ProductName: firstNonEmpty(f["Product Name"], f["Board Product Name"]), SerialNumber: firstNonEmpty(f["Product Serial Number"], f["Board Serial Number"]), PartNumber: firstNonEmpty(f["Product Part Number"], f["Board Part Number"]), } break } } } func extractFRUHeaderDesc(header string) string { // "FRU Device Description : Builtin FRU Device (ID 0, Mainboard)" idx := strings.Index(header, " : ") if idx >= 0 { return strings.TrimSpace(header[idx+3:]) } return header } func firstNonEmpty(vals ...string) string { for _, v := range vals { v = strings.TrimSpace(v) if v != "" { return v } } return "" } // ── Sensors ─────────────────────────────────────────────────────────────────── // parseSensorInfo parses the pipe-delimited IPMI sensor table from sensor_info.txt. // Columns: sensor id | sensor name | value | unit | status | thresholds... func parseSensorInfo(content []byte) []models.SensorReading { var sensors []models.SensorReading inTable := false for _, line := range strings.Split(string(content), "\n") { if strings.Contains(line, "sensor id") && strings.Contains(line, "sensor name") { inTable = true continue } if inTable && strings.HasPrefix(strings.TrimSpace(line), "**") { // "*** Detailed Voltage Object Information ***" signals end of main table inTable = false continue } if !inTable || !strings.Contains(line, "|") { continue } parts := strings.Split(line, "|") if len(parts) < 5 { continue } name := strings.TrimSpace(parts[1]) valueStr := strings.TrimSpace(parts[2]) unit := strings.TrimSpace(parts[3]) status := strings.TrimSpace(parts[4]) if name == "" || valueStr == "na" || unit == "discrete" || unit == "unspecified" { continue } value, err := strconv.ParseFloat(valueStr, 64) if err != nil { continue } sensors = append(sensors, models.SensorReading{ Name: name, Type: sensorType(name, unit), Value: value, Unit: mapSensorUnit(unit), RawValue: valueStr, Status: status, }) } return sensors } func mapSensorUnit(u string) string { switch strings.ToLower(strings.TrimSpace(u)) { case "degrees c": return "C" case "volts": return "V" case "watts": return "W" case "rpm": return "RPM" case "amps": return "A" default: return u } } func sensorType(name, unit string) string { u := strings.ToLower(unit) n := strings.ToLower(name) switch { case strings.Contains(u, "degrees"): return "temperature" case strings.Contains(u, "volts"): return "voltage" case strings.Contains(u, "watts"): return "power" case strings.Contains(u, "rpm") || strings.Contains(n, "fan") && strings.Contains(n, "speed"): return "fan" case strings.Contains(u, "amps"): return "current" default: return "" } } // ── CPU ─────────────────────────────────────────────────────────────────────── // parseCPUInfo parses the comma-separated cpu_info file. // Columns: slot, presence, model, processorID, cores, threads, flags, L1, L2, L3, partNum, devName, location, SN func parseCPUInfo(content []byte) []models.CPU { var cpus []models.CPU lines := strings.Split(string(content), "\n") for i, line := range lines { if i == 0 { // skip header continue } line = strings.TrimSpace(line) if line == "" { continue } parts := strings.Split(line, ",") if len(parts) < 6 { continue } slot := strings.TrimSpace(parts[0]) if !strings.HasPrefix(strings.ToLower(slot), "cpu") { continue } if strings.ToLower(strings.TrimSpace(parts[1])) != "present" { continue } socketNum := 0 fmt.Sscanf(strings.ToLower(slot), "cpu%d", &socketNum) model := strings.TrimSpace(parts[2]) cores := 0 fmt.Sscanf(strings.TrimSpace(parts[4]), "%d", &cores) threads := 0 fmt.Sscanf(strings.TrimSpace(parts[5]), "%d", &threads) l1, l2, l3 := 0, 0, 0 if len(parts) >= 10 { l1 = parseCacheSizeKB(parts[7]) l2 = parseCacheSizeKB(parts[8]) l3 = parseCacheSizeKB(parts[9]) } sn := "" if len(parts) >= 14 { sn = strings.TrimSpace(parts[13]) } cpus = append(cpus, models.CPU{ Socket: socketNum, Model: model, Cores: cores, Threads: threads, L1CacheKB: l1, L2CacheKB: l2, L3CacheKB: l3, SerialNumber: sn, Status: "ok", }) } return cpus } func parseCacheSizeKB(s string) int { var n int fmt.Sscanf(strings.TrimSpace(s), "%d", &n) return n } // ── Memory ──────────────────────────────────────────────────────────────────── // parseMemInfo parses the comma-separated mem_info file. // Columns: slot, location, dimmName, manufacturer, size, maxSpeed, curSpeed, type, SN, voltage, rank, bitWidth, tech, bom, partNum, ..., health func parseMemInfo(content []byte) []models.MemoryDIMM { var dimms []models.MemoryDIMM for i, line := range reassembleMemInfoRows(content) { if i == 0 { continue } line = strings.TrimSpace(line) if line == "" { continue } parts := strings.Split(line, ",") if len(parts) < 9 { continue } sn := strings.TrimSpace(parts[8]) if strings.ToLower(sn) == "no dimm" || sn == "" { continue } // Column 3 ("dimm name", e.g. "DIMM071") is the device locator that matches // dmidecode/OS-level collectors; column 1 ("Memory071") is BMC-internal only. // Column 2 ("mainboard") carries no cross-source signal, so drop it. slot := strings.TrimSpace(parts[2]) if slot == "" { slot = strings.TrimSpace(parts[0]) } manufacturer := strings.TrimSpace(parts[3]) if strings.ToLower(manufacturer) == "unknown" { manufacturer = "" } sizeMB := 0 fmt.Sscanf(strings.TrimSpace(parts[4]), "%d MB", &sizeMB) maxSpeedMHz := 0 fmt.Sscanf(strings.TrimSpace(parts[5]), "%d MT/s", &maxSpeedMHz) curSpeedMHz := 0 fmt.Sscanf(strings.TrimSpace(parts[6]), "%d MT/s", &curSpeedMHz) memType := strings.TrimSpace(parts[7]) if strings.ToLower(memType) == "unknown" { memType = "" } ranks := 0 if len(parts) >= 11 { fmt.Sscanf(strings.TrimSpace(parts[10]), "%d rank", &ranks) } partNum := "" if len(parts) >= 15 { v := strings.TrimSpace(parts[14]) if strings.ToLower(v) != "no dimm" && strings.ToLower(v) != "unknown" { partNum = v } } status := "ok" if len(parts) >= 22 { if s := strings.TrimSpace(parts[21]); strings.ToLower(s) != "ok" && s != "" { status = strings.ToLower(s) } } dimms = append(dimms, models.MemoryDIMM{ Slot: slot, Present: true, SizeMB: sizeMB, Type: memType, MaxSpeedMHz: maxSpeedMHz, CurrentSpeedMHz: curSpeedMHz, Manufacturer: manufacturer, SerialNumber: sn, PartNumber: partNum, Ranks: ranks, Status: status, }) } return dimms } // reassembleMemInfoRows splits mem_info into logical rows, rejoining any line // that is a continuation of the previous one. // // The BMC copies the raw SPD "bom number" field into column 14 verbatim, and it // contains arbitrary binary bytes — including, occasionally, a 0x0A newline that // splits a DIMM record across two physical lines. A genuine data row always // starts with the slot token "Memory"; any line that does not is a // tail fragment and is folded back into the row above it. func reassembleMemInfoRows(content []byte) []string { physical := strings.Split(string(content), "\n") var rows []string for _, raw := range physical { line := strings.TrimRight(raw, "\r") if len(rows) > 0 && !looksLikeMemInfoRowStart(line) { rows[len(rows)-1] += line continue } rows = append(rows, line) } return rows } // looksLikeMemInfoRowStart reports whether a line begins a new mem_info row, // i.e. starts with "Memory" followed by a digit (after leading whitespace). // The header line ("slot(col 1), ...") also returns true so it stays row 0. func looksLikeMemInfoRowStart(line string) bool { t := strings.TrimLeft(line, " \t") if strings.HasPrefix(t, "slot(col 1)") { return true } rest, ok := strings.CutPrefix(t, "Memory") if !ok || rest == "" { return false } return rest[0] >= '0' && rest[0] <= '9' } // ── Card Info (GPU + NIC) ───────────────────────────────────────────────────── // parseCardInfo parses card_info file, extracting GPU and OCP NIC card inventory. // The file has named sections ("GPU Card Info", "OCP Card Info", etc.) each with a pipe-table. func parseCardInfo(content []byte) (gpus []models.GPU, nicCards []xfusionNICCard) { sections := splitPipeSections(content) // Build BDF and VendorID/DeviceID map from PCIe Card Info: slot → info type pcieEntry struct { bdf string vendorID int deviceID int desc string } slotPCIe := make(map[string]pcieEntry) for _, row := range sections["pcie card info"] { slot := strings.TrimSpace(row["slot"]) slotPCIe[slot] = pcieEntry{ bdf: bdfFromCardInfoRow(row), vendorID: parseHexInt(row["vender id"]), deviceID: parseHexInt(row["device id"]), desc: strings.TrimSpace(row["card desc"]), } } // GPU Card Info: slot, name, manufacturer, serialNum, firmVer, SBE/DBE counts for _, row := range sections["gpu card info"] { slot := strings.TrimSpace(row["slot"]) name := strings.TrimSpace(row["name"]) manufacturer := strings.TrimSpace(row["manufacturer"]) serial := strings.TrimSpace(row["serialnum"]) firmware := strings.TrimSpace(row["firmver"]) sbeCount, dbeCount := 0, 0 fmt.Sscanf(strings.TrimSpace(row["sbe"]), "%d", &sbeCount) fmt.Sscanf(strings.TrimSpace(row["dbe"]), "%d", &dbeCount) pcie := slotPCIe[slot] // Reanimator contract: pcie_devices.slot is the BDF. Use it when known so // the GPU matches the same device seen by an OS-level lspci collection; // fall back to the physical slot label only when no BDF is available. gpuSlot := slot if pcie.bdf != "" { gpuSlot = pcie.bdf } gpu := models.GPU{ Slot: gpuSlot, Model: name, Manufacturer: manufacturer, SerialNumber: serial, Firmware: firmware, BDF: pcie.bdf, VendorID: pcie.vendorID, DeviceID: pcie.deviceID, Status: "ok", } if dbeCount > 0 { gpu.Status = "warning" } gpus = append(gpus, gpu) } // OCP Card Info: NIC cards. The "Pcie Card Info" table only lists GPU slots, // so slotPCIe[slot] here would resolve to an unrelated GPU's BDF — build the // BDF from this row's own segment/bus/device/function columns instead. for _, row := range sections["ocp card info"] { slot := strings.TrimSpace(row["slot"]) nicCards = append(nicCards, xfusionNICCard{ Slot: slot, Model: strings.TrimSpace(row["card desc"]), ProductName: strings.TrimSpace(row["card desc"]), VendorID: parseHexInt(row["vender id"]), DeviceID: parseHexInt(row["device id"]), BDF: bdfFromCardInfoRow(row), SerialNumber: strings.TrimSpace(row["serialnumber"]), PartNumber: strings.TrimSpace(row["partnum"]), }) } return gpus, nicCards } // bdfFromCardInfoRow builds a "0000:bb:dd.f" BDF from a card_info pipe-table row // that carries segment/bus/device/function columns. Returns "" if the bus column // is absent (some sections, e.g. "RAID Card Info", have no PCI address columns). func bdfFromCardInfoRow(row map[string]string) string { if strings.TrimSpace(row["bus number"]) == "" { return "" } return fmt.Sprintf("%04x:%02x:%02x.%d", parseHexInt(row["segment number"]), parseHexInt(row["bus number"]), parseHexInt(row["device number"]), parseHexInt(row["function number"]), ) } // splitPipeSections parses a multi-section file where each section starts with a // plain header line (no "|") ending in "Info" or "info", followed by a pipe-table. // Returns a map from lowercased section name → rows (each row is a map of lowercase header → value). func splitPipeSections(content []byte) map[string][]map[string]string { result := make(map[string][]map[string]string) var sectionName string var headers []string for _, line := range strings.Split(string(content), "\n") { trimmed := strings.TrimSpace(line) if trimmed == "" { continue } if !strings.Contains(trimmed, "|") { if strings.HasSuffix(strings.ToLower(trimmed), "info") { sectionName = strings.ToLower(trimmed) headers = nil } continue } parts := strings.Split(line, "|") if len(parts) < 2 { continue } cols := make([]string, len(parts)) for i, p := range parts { cols[i] = strings.TrimSpace(p) } if headers == nil { headers = make([]string, len(cols)) for i, h := range cols { headers[i] = strings.ToLower(h) } continue } row := make(map[string]string, len(headers)) for i, h := range headers { if i < len(cols) { row[h] = cols[i] } } result[sectionName] = append(result[sectionName], row) } return result } func parseHexInt(s string) int { s = strings.TrimSpace(s) s = strings.TrimPrefix(strings.ToLower(s), "0x") n, _ := strconv.ParseInt(s, 16, 64) return int(n) } func parseNetcardInfo(content []byte) []xfusionNetcardSnapshot { if len(content) == 0 { return nil } var snapshots []xfusionNetcardSnapshot var current *xfusionNetcardSnapshot var currentPort *xfusionNetcardPort flushPort := func() { if current == nil || currentPort == nil { return } current.Ports = append(current.Ports, *currentPort) currentPort = nil } flushSnapshot := func() { if current == nil || !current.hasData() { return } flushPort() snapshots = append(snapshots, *current) current = nil } for _, rawLine := range strings.Split(string(content), "\n") { line := strings.TrimSpace(rawLine) if line == "" { flushPort() continue } if ts, ok := parseXFusionUTCTimestamp(line); ok { if current == nil { current = &xfusionNetcardSnapshot{Timestamp: ts} continue } if current.hasData() { flushSnapshot() current = &xfusionNetcardSnapshot{Timestamp: ts} continue } current.Timestamp = ts continue } if current == nil { current = &xfusionNetcardSnapshot{} } if port := parseNetcardPortHeader(line); port != nil { flushPort() currentPort = port continue } if currentPort != nil { if value, ok := parseSimpleKV(line, "MacAddr"); ok { currentPort.MAC = value continue } if value, ok := parseSimpleKV(line, "ActualMac"); ok { currentPort.ActualMAC = value continue } } if value, ok := parseSimpleKV(line, "ProductName"); ok { current.ProductName = value continue } if value, ok := parseSimpleKV(line, "Manufacture"); ok { current.Manufacturer = value continue } if value, ok := parseSimpleKV(line, "FirmwareVersion"); ok { current.Firmware = value continue } if value, ok := parseSimpleKV(line, "SlotId"); ok { current.Slot = value } } flushSnapshot() bestIndexBySlot := make(map[string]int) for i, snapshot := range snapshots { slot := strings.TrimSpace(snapshot.Slot) if slot == "" { continue } prevIdx, exists := bestIndexBySlot[slot] if !exists || snapshot.isBetterThan(snapshots[prevIdx]) { bestIndexBySlot[slot] = i } } ordered := make([]xfusionNetcardSnapshot, 0, len(bestIndexBySlot)) for i, snapshot := range snapshots { slot := strings.TrimSpace(snapshot.Slot) bestIdx, ok := bestIndexBySlot[slot] if !ok || bestIdx != i { continue } ordered = append(ordered, snapshot) delete(bestIndexBySlot, slot) } return ordered } func mergeNetworkAdapters(cards []xfusionNICCard, snapshots []xfusionNetcardSnapshot) ([]models.NetworkAdapter, []models.NIC) { bySlotCard := make(map[string]xfusionNICCard, len(cards)) bySlotSnapshot := make(map[string]xfusionNetcardSnapshot, len(snapshots)) orderedSlots := make([]string, 0, len(cards)+len(snapshots)) seenSlots := make(map[string]struct{}, len(cards)+len(snapshots)) for _, card := range cards { slot := strings.TrimSpace(card.Slot) if slot == "" { continue } bySlotCard[slot] = card if _, seen := seenSlots[slot]; !seen { orderedSlots = append(orderedSlots, slot) seenSlots[slot] = struct{}{} } } for _, snapshot := range snapshots { slot := strings.TrimSpace(snapshot.Slot) if slot == "" { continue } bySlotSnapshot[slot] = snapshot if _, seen := seenSlots[slot]; !seen { orderedSlots = append(orderedSlots, slot) seenSlots[slot] = struct{}{} } } adapters := make([]models.NetworkAdapter, 0, len(orderedSlots)) legacyNICs := make([]models.NIC, 0, len(orderedSlots)) for _, slot := range orderedSlots { card := bySlotCard[slot] snapshot := bySlotSnapshot[slot] model := firstNonEmpty(card.Model, snapshot.ProductName) description := "" if !strings.EqualFold(strings.TrimSpace(model), strings.TrimSpace(snapshot.ProductName)) { description = strings.TrimSpace(snapshot.ProductName) } macs := snapshot.macAddresses() firmware := normalizeXFusionValue(snapshot.Firmware) manufacturer := firstNonEmpty(snapshot.Manufacturer, card.Vendor) // netcard_info reports the system OEM ("XFUSION") as the NIC manufacturer. // When the PCI vendor ID is known, leave it blank so the exporter resolves // the real silicon vendor (Mellanox/Broadcom/Intel) from pci.ids, matching // what an OS-level collector reports. if card.VendorID != 0 && isSystemOEMName(manufacturer) { manufacturer = "" } // Emit one adapter per PCI function (port), keyed by that port's own BDF, // so the card matches an OS-level lspci view of the same NIC. Fall back to // a single card-level entry only when no per-port BDF is available. // // The per-port entries deliberately carry NO serial number. The physical // card has one serial across both ports; an in-band collector (lspci) sees // none and identifies each port by its MAC. Reanimator keys a NIC on // serial → MAC → synthetic(slot): if we emitted the card serial here, its // two ports would collapse to one component keyed by that serial, while // the same NIC from a live-CD bundle stays two components keyed by MAC — // producing a spurious remove+reinstall every time the two sources // alternate. Leaving serial empty makes both sources key on MAC and agree. type portEntry struct { bdf string mac string } var ports []portEntry for _, p := range snapshot.Ports { if bdf := strings.TrimSpace(p.BDF); bdf != "" { ports = append(ports, portEntry{bdf: bdf, mac: firstNonEmpty(normalizeMAC(p.ActualMAC), normalizeMAC(p.MAC))}) } } if len(ports) == 0 { ports = append(ports, portEntry{bdf: card.BDF}) } for _, p := range ports { var portMACs []string if p.mac != "" { portMACs = []string{p.mac} } else if len(ports) == 1 { portMACs = macs } // No serial: an lspci-based collector has none, and reanimator keys a // serial-bearing NIC on the serial (collapsing ports) but a // serial-less one on the MAC. Keeping this empty makes a BMC dump and // a live-CD run agree. The card serial still reaches the export via // this card's FRU record. serialPerPort := "" if len(portMACs) == 0 { serialPerPort = card.SerialNumber } adapters = append(adapters, models.NetworkAdapter{ Slot: firstNonEmpty(p.bdf, slot), Location: "OCP", Present: true, BDF: p.bdf, Model: model, Description: description, Vendor: manufacturer, VendorID: card.VendorID, DeviceID: card.DeviceID, SerialNumber: serialPerPort, PartNumber: card.PartNumber, Firmware: firmware, PortCount: 1, PortType: "ethernet", MACAddresses: portMACs, Status: "ok", }) } legacyNICs = append(legacyNICs, models.NIC{ Name: fmt.Sprintf("OCP%s", slot), Model: model, Description: description, MACAddress: firstNonEmpty(macs...), SerialNumber: card.SerialNumber, }) } return adapters, legacyNICs } func parseXFusionUTCTimestamp(line string) (time.Time, bool) { ts, err := time.Parse("2006-01-02 15:04:05 MST", strings.TrimSpace(line)) if err != nil { return time.Time{}, false } return ts, true } func parseNetcardPortHeader(line string) *xfusionNetcardPort { fields := strings.Fields(strings.TrimSpace(line)) if len(fields) < 2 || !strings.HasPrefix(strings.ToLower(fields[0]), "port") { return nil } joined := strings.Join(fields[1:], " ") if !strings.HasPrefix(strings.ToLower(joined), "bdf:") { return nil } return &xfusionNetcardPort{BDF: strings.TrimSpace(joined[len("BDF:"):])} } func parseSimpleKV(line, key string) (string, bool) { idx := strings.Index(line, ":") if idx < 0 { return "", false } gotKey := strings.TrimSpace(line[:idx]) if !strings.EqualFold(gotKey, key) { return "", false } return strings.TrimSpace(line[idx+1:]), true } func normalizeXFusionValue(value string) string { value = strings.TrimSpace(value) switch strings.ToUpper(value) { case "", "N/A", "NA", "UNKNOWN": return "" default: return value } } func (s xfusionNetcardSnapshot) hasData() bool { return strings.TrimSpace(s.Slot) != "" || strings.TrimSpace(s.ProductName) != "" || strings.TrimSpace(s.Manufacturer) != "" || strings.TrimSpace(s.Firmware) != "" || len(s.Ports) > 0 } func (s xfusionNetcardSnapshot) score() int { score := len(s.Ports) if normalizeXFusionValue(s.Firmware) != "" { score += 10 } score += len(s.macAddresses()) * 2 return score } func (s xfusionNetcardSnapshot) isBetterThan(other xfusionNetcardSnapshot) bool { if s.score() != other.score() { return s.score() > other.score() } if !s.Timestamp.Equal(other.Timestamp) { return s.Timestamp.After(other.Timestamp) } return len(s.Ports) > len(other.Ports) } func (s xfusionNetcardSnapshot) primaryBDF() string { for _, port := range s.Ports { if bdf := strings.TrimSpace(port.BDF); bdf != "" { return bdf } } return "" } func (s xfusionNetcardSnapshot) macAddresses() []string { out := make([]string, 0, len(s.Ports)) seen := make(map[string]struct{}, len(s.Ports)) for _, port := range s.Ports { for _, candidate := range []string{port.ActualMAC, port.MAC} { mac := normalizeMAC(candidate) if mac == "" { continue } if _, exists := seen[mac]; exists { continue } seen[mac] = struct{}{} out = append(out, mac) break } } return out } func isSystemOEMName(name string) bool { switch strings.ToLower(strings.TrimSpace(name)) { case "xfusion", "huawei", "oem", "": return true } return false } func normalizeMAC(value string) string { value = strings.ToUpper(strings.TrimSpace(value)) switch value { case "", "N/A", "NA", "UNKNOWN", "00:00:00:00:00:00": return "" default: return value } } // ── PSU ─────────────────────────────────────────────────────────────────────── // parsePSUInfo parses the pipe-delimited psu_info.txt. // Columns: Slot | presence | Manufacturer | Type | SN | Version | Rated Power | InputMode | PartNum | DeviceName | Vin | ... func parsePSUInfo(content []byte) []models.PSU { var psus []models.PSU var headers []string for _, line := range strings.Split(string(content), "\n") { if !strings.Contains(line, "|") { continue } parts := strings.Split(line, "|") cols := make([]string, len(parts)) for i, p := range parts { cols[i] = strings.TrimSpace(p) } if headers == nil { headers = make([]string, len(cols)) for i, h := range cols { headers[i] = strings.ToLower(h) } continue } row := make(map[string]string, len(headers)) for i, h := range headers { if i < len(cols) { row[h] = cols[i] } } if strings.ToLower(row["presence"]) != "present" { continue } wattage := 0 fmt.Sscanf(row["rated power"], "%d", &wattage) inputVoltage := 0.0 fmt.Sscanf(row["vin"], "%f", &inputVoltage) psus = append(psus, models.PSU{ Slot: row["slot"], Present: true, Model: row["type"], Vendor: row["manufacturer"], SerialNumber: row["sn"], PartNumber: row["partnum"], Firmware: row["version"], WattageW: wattage, InputType: row["inputmode"], InputVoltage: inputVoltage, Status: "ok", }) } return psus } // ── Storage ─────────────────────────────────────────────────────────────────── // parseStorageControllerInfo parses RAID_Controller_Info.txt and adds firmware entries. func parseStorageControllerInfo(content []byte, result *models.AnalysisResult) { // File may contain multiple controller blocks; parse key:value pairs from each. // We only look at the first occurrence of each key (first controller). seen := make(map[string]struct{}, len(result.Hardware.Firmware)) for _, fw := range result.Hardware.Firmware { key := strings.ToLower(strings.TrimSpace(fw.DeviceName + "\x00" + fw.Version + "\x00" + fw.Description)) seen[key] = struct{}{} } text := string(content) blocks := strings.Split(text, "RAID Controller #") for _, block := range blocks[1:] { // skip pre-block preamble fields := parseKeyValueBlock([]byte(block)) name := firstNonEmpty(fields["Component Name"], fields["Controller Name"], fields["Controller Type"]) firmware := fields["Firmware Version"] if name != "" && firmware != "" { appendXFusionFirmware(result, seen, models.FirmwareInfo{ DeviceName: name, Description: fields["Controller Name"], Version: firmware, }) } } } func parseAppRevision(content []byte, result *models.AnalysisResult) { type firmwareLine struct { deviceName string description string buildKey string } known := map[string]firmwareLine{ "Active iBMC Version": {deviceName: "iBMC", description: "active iBMC", buildKey: "Active iBMC Built"}, "Active BIOS Version": {deviceName: "BIOS", description: "active BIOS", buildKey: "Active BIOS Built"}, "CPLD Version": {deviceName: "CPLD", description: "mainboard CPLD"}, "SDK Version": {deviceName: "SDK", description: "iBMC SDK", buildKey: "SDK Built"}, "Active Uboot Version": {deviceName: "U-Boot", description: "active U-Boot"}, "Active Secure Bootloader Version": {deviceName: "Secure Bootloader", description: "active secure bootloader"}, "Active Secure Firmware Version": {deviceName: "Secure Firmware", description: "active secure firmware"}, } values := parseAlignedKeyValues(content) if result.Hardware.BoardInfo.ProductName == "" { if productName := values["Product Name"]; productName != "" { result.Hardware.BoardInfo.ProductName = productName } } seen := make(map[string]struct{}, len(result.Hardware.Firmware)) for _, fw := range result.Hardware.Firmware { key := strings.ToLower(strings.TrimSpace(fw.DeviceName + "\x00" + fw.Version + "\x00" + fw.Description)) seen[key] = struct{}{} } for key, meta := range known { version := stripXFusionChipDesignator(normalizeXFusionValue(values[key])) if version == "" { continue } appendXFusionFirmware(result, seen, models.FirmwareInfo{ DeviceName: meta.deviceName, Description: meta.description, Version: version, BuildTime: normalizeXFusionValue(values[meta.buildKey]), }) } } // stripXFusionChipDesignator removes a leading PCB reference-designator tag that // the iBMC prepends to firmware versions, e.g. "(U6216)01.02.08.17" -> // "01.02.08.17", so the BIOS/BMC version matches what an in-band tool reports. func stripXFusionChipDesignator(version string) string { v := strings.TrimSpace(version) if !strings.HasPrefix(v, "(U") { return v } if i := strings.IndexByte(v, ')'); i > 0 { return strings.TrimSpace(v[i+1:]) } return v } func parseAlignedKeyValues(content []byte) map[string]string { values := make(map[string]string) for _, rawLine := range strings.Split(string(content), "\n") { line := strings.TrimRight(rawLine, "\r") if !strings.Contains(line, ":") { continue } idx := strings.Index(line, ":") if idx < 0 { continue } key := strings.TrimRight(line[:idx], " \t") value := strings.TrimSpace(line[idx+1:]) if key == "" || value == "" || values[key] != "" { continue } values[key] = value } return values } func appendXFusionFirmware(result *models.AnalysisResult, seen map[string]struct{}, fw models.FirmwareInfo) { if result == nil || result.Hardware == nil { return } key := strings.ToLower(strings.TrimSpace(fw.DeviceName + "\x00" + fw.Version + "\x00" + fw.Description)) if key == "" { return } if _, exists := seen[key]; exists { return } seen[key] = struct{}{} result.Hardware.Firmware = append(result.Hardware.Firmware, fw) } // parseDiskInfo parses a single PhysicalDrivesInfo/DiskN/disk_info file. func parseDiskInfo(content []byte) *models.Storage { fields := parseKeyValueBlock(content) model := fields["Model"] sn := fields["Serial Number"] if model == "" && sn == "" { return nil } sizeGB := parseDiskCapacityGB(fields["Capacity"]) var wearPct *int if wearStr := fields["Remnant Media Wearout"]; wearStr != "" { var pct int if _, err := fmt.Sscanf(wearStr, "%d%%", &pct); err == nil { wearPct = &pct } } status := "ok" if h := strings.ToLower(fields["Health Status"]); h != "" && h != "normal" { status = h } return &models.Storage{ Slot: firstNonEmpty(fields["Device Name"], fields["ID"]), Type: fields["Media Type"], Model: model, SizeGB: sizeGB, SerialNumber: sn, Manufacturer: fields["Manufacturer"], Firmware: fields["Firmware Version"], Interface: fields["Interface Type"], Present: true, RemainingEndurancePct: wearPct, Status: status, } } // parseDiskCapacityGB converts an iBMC disk_info "Capacity" value to decimal GB. // iBMC labels the number "GB"/"TB" but the value is binary (GiB/TiB): a // "6.986 TB" NVMe drive is the vendor's 7.68 TB / 7680 GB part, which is also // what the BEE-SP live-CD inventory reports. Normalize to decimal GB so a // BMC-dump and a live-CD export of the same drive carry the same size_gb. func parseDiskCapacityGB(s string) int { f := strings.Fields(strings.TrimSpace(s)) if len(f) < 2 { return 0 } val, err := strconv.ParseFloat(f[0], 64) if err != nil || val <= 0 { return 0 } var binaryBytes float64 switch strings.ToUpper(f[1]) { case "TB", "TIB": binaryBytes = val * (1 << 40) case "GB", "GIB": binaryBytes = val * (1 << 30) case "MB", "MIB": binaryBytes = val * (1 << 20) default: return 0 } return int(math.Round(binaryBytes / 1e9)) } // parseKeyValueBlock parses "Key (spaces) : Value" lines from a text block. func parseKeyValueBlock(content []byte) map[string]string { result := make(map[string]string) for _, line := range strings.Split(string(content), "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "=") || strings.HasPrefix(line, "-") { continue } idx := strings.Index(line, " : ") if idx < 0 { continue } key := strings.TrimSpace(line[:idx]) val := strings.TrimSpace(line[idx+3:]) if key != "" && result[key] == "" { result[key] = val } } return result } // ── Events ──────────────────────────────────────────────────────────────────── // parseMaintenanceLog parses the iBMC maintenance_log file. // Line format: "YYYY-MM-DD HH:MM:SS LEVEL : CODE,description" func parseMaintenanceLog(content []byte) []models.Event { var events []models.Event for _, line := range strings.Split(string(content), "\n") { line = strings.TrimSpace(line) if len(line) < 20 { continue } ts, err := time.Parse("2006-01-02 15:04:05", line[:19]) if err != nil || ts.Year() <= 1970 { continue // skip epoch-0 boot artifacts } rest := strings.TrimSpace(line[19:]) sepIdx := strings.Index(rest, " : ") if sepIdx < 0 { sepIdx = strings.Index(rest, ": ") if sepIdx < 0 { continue } } else { sepIdx++ // skip leading space for " : " } levelStr := strings.TrimSpace(rest[:sepIdx-1]) body := strings.TrimSpace(rest[sepIdx+2:]) code := body description := "" if ci := strings.Index(body, ","); ci >= 0 { code = body[:ci] description = strings.TrimSpace(body[ci+1:]) } var severity models.Severity switch strings.ToUpper(levelStr) { case "WARN", "WARNING": severity = models.SeverityWarning case "ERROR", "ERR", "CRIT", "CRITICAL": severity = models.SeverityCritical default: severity = models.SeverityInfo } events = append(events, models.Event{ Timestamp: ts, Source: "ibmc", EventType: code, Severity: severity, Description: description, RawData: line, }) } return events } // ── unused import guard ─────────────────────────────────────────────────────── var _ = parser.ExtractedFile{}