Files
logpile/internal/parser/vendors/xfusion/parser_test.go
T
Mikhail ChusavitinandClaude Sonnet 5 ab8636da04 fix(xfusion): parse disk_info Capacity in TB/binary units for size_gb
parseDiskInfo read capacity with Sscanf(cap, "%f GB"), so every iBMC
"Capacity : 6.986 TB" NVMe line failed to match and size_gb stayed 0
while the BEE-SP live-CD export of the same drive had the real size.
The iBMC number is also binary (GiB/TiB) despite the GB/TB label.

parseDiskCapacityGB parses number + unit (TB/GB/MB), treats it as
binary, and emits decimal GB, matching the drive's marketed capacity
and the live-CD inventory. Found by diffing a G5500 V7 BMC dump against
its live-CD bundle. See ADL-063.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LffAvostt3uMkiUbVUiyM
2026-09-01 11:58:46 +03:00

424 lines
15 KiB
Go

package xfusion
import (
"strings"
"testing"
"git.mchus.pro/mchus/logpile/internal/models"
"git.mchus.pro/mchus/logpile/internal/parser"
)
// loadTestArchive extracts the given archive path for use in tests.
// Skips the test if the file is not found (CI environments without testdata).
func loadTestArchive(t *testing.T, path string) []parser.ExtractedFile {
t.Helper()
files, err := parser.ExtractArchive(path)
if err != nil {
t.Skipf("cannot load test archive %s: %v", path, err)
}
return files
}
func TestDetect_G5500V7(t *testing.T) {
files := loadTestArchive(t, "../../../../example/G5500V7_210619KUGGXGS2000015_20260318-1128.tar.gz")
p := &Parser{}
score := p.Detect(files)
if score < 80 {
t.Fatalf("expected Detect score >= 80, got %d", score)
}
}
func TestDetect_ServerFileExportMarkers(t *testing.T) {
p := &Parser{}
score := p.Detect([]parser.ExtractedFile{
{Path: "dump_info/RTOSDump/versioninfo/app_revision.txt", Content: []byte("Product Name: G5500 V7")},
{Path: "dump_info/LogDump/netcard/netcard_info.txt", Content: []byte("2026-02-04 03:54:06 UTC")},
{Path: "dump_info/AppDump/card_manage/card_info", Content: []byte("OCP Card Info")},
})
if score < 70 {
t.Fatalf("expected Detect score >= 70 for xFusion file export markers, got %d", score)
}
}
func TestDetect_Negative(t *testing.T) {
p := &Parser{}
score := p.Detect([]parser.ExtractedFile{
{Path: "logs/messages.txt", Content: []byte("plain text")},
{Path: "inventory.json", Content: []byte(`{"vendor":"other"}`)},
})
if score != 0 {
t.Fatalf("expected Detect score 0 for non-xFusion input, got %d", score)
}
}
func TestParse_G5500V7_BoardInfo(t *testing.T) {
files := loadTestArchive(t, "../../../../example/G5500V7_210619KUGGXGS2000015_20260318-1128.tar.gz")
p := &Parser{}
result, err := p.Parse(files)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if result.Hardware == nil {
t.Fatal("Hardware is nil")
}
board := result.Hardware.BoardInfo
if board.SerialNumber != "210619KUGGXGS2000015" {
t.Errorf("BoardInfo.SerialNumber = %q, want 210619KUGGXGS2000015", board.SerialNumber)
}
if board.ProductName != "G5500 V7" {
t.Errorf("BoardInfo.ProductName = %q, want G5500 V7", board.ProductName)
}
}
func TestParse_G5500V7_CPUs(t *testing.T) {
files := loadTestArchive(t, "../../../../example/G5500V7_210619KUGGXGS2000015_20260318-1128.tar.gz")
p := &Parser{}
result, err := p.Parse(files)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if len(result.Hardware.CPUs) != 2 {
t.Fatalf("expected 2 CPUs, got %d", len(result.Hardware.CPUs))
}
cpu1 := result.Hardware.CPUs[0]
if cpu1.Cores != 32 {
t.Errorf("CPU1 cores = %d, want 32", cpu1.Cores)
}
if cpu1.Threads != 64 {
t.Errorf("CPU1 threads = %d, want 64", cpu1.Threads)
}
if cpu1.SerialNumber == "" {
t.Error("CPU1 SerialNumber is empty")
}
}
func TestParse_G5500V7_Memory(t *testing.T) {
files := loadTestArchive(t, "../../../../example/G5500V7_210619KUGGXGS2000015_20260318-1128.tar.gz")
p := &Parser{}
result, err := p.Parse(files)
if err != nil {
t.Fatalf("Parse: %v", err)
}
// Only 2 DIMMs are populated (rest are "NO DIMM")
if len(result.Hardware.Memory) != 2 {
t.Fatalf("expected 2 populated DIMMs, got %d", len(result.Hardware.Memory))
}
dimm := result.Hardware.Memory[0]
if dimm.SizeMB != 65536 {
t.Errorf("DIMM0 SizeMB = %d, want 65536", dimm.SizeMB)
}
if dimm.Type != "DDR5" {
t.Errorf("DIMM0 Type = %q, want DDR5", dimm.Type)
}
}
func TestParse_G5500V7_GPUs(t *testing.T) {
files := loadTestArchive(t, "../../../../example/G5500V7_210619KUGGXGS2000015_20260318-1128.tar.gz")
p := &Parser{}
result, err := p.Parse(files)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if len(result.Hardware.GPUs) != 8 {
t.Fatalf("expected 8 GPUs, got %d", len(result.Hardware.GPUs))
}
for _, gpu := range result.Hardware.GPUs {
if gpu.SerialNumber == "" {
t.Errorf("GPU slot %s has empty SerialNumber", gpu.Slot)
}
if gpu.Model == "" {
t.Errorf("GPU slot %s has empty Model", gpu.Slot)
}
if gpu.Firmware == "" {
t.Errorf("GPU slot %s has empty Firmware", gpu.Slot)
}
}
}
func TestParse_G5500V7_NICs(t *testing.T) {
files := loadTestArchive(t, "../../../../example/G5500V7_210619KUGGXGS2000015_20260318-1128.tar.gz")
p := &Parser{}
result, err := p.Parse(files)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if len(result.Hardware.NetworkCards) < 1 {
t.Fatal("expected at least 1 NIC (OCP CX6), got 0")
}
nic := result.Hardware.NetworkCards[0]
if nic.SerialNumber == "" {
t.Errorf("NIC SerialNumber is empty")
}
}
func TestParse_ServerFileExport_NetworkAdaptersAndFirmware(t *testing.T) {
p := &Parser{}
files := []parser.ExtractedFile{
{
Path: "dump_info/AppDump/card_manage/card_info",
Content: []byte(strings.TrimSpace(`
Pcie Card Info
Slot | Vender Id | Device Id | Sub Vender Id | Sub Device Id | Segment Number | Bus Number | Device Number | Function Number | Card Desc | Board Id | PCB Version | CPLD Version | Sub Card Bom Id | PartNum | SerialNumber | OriginalPartNum
1 | 0x15b3 | 0x101f | 0x1f24 | 0x2011 | 0x00 | 0x27 | 0x00 | 0x00 | MT2894 Family [ConnectX-6 Lx] | N/A | N/A | N/A | N/A | 0302Y238 | 02Y238X6RC000058 |
OCP Card Info
Slot | Vender Id | Device Id | Sub Vender Id | Sub Device Id | Segment Number | Bus Number | Device Number | Function Number | Card Desc | Board Id | PCB Version | CPLD Version | Sub Card Bom Id | PartNum | SerialNumber | OriginalPartNum
1 | 0x15b3 | 0x101f | 0x1f24 | 0x2011 | 0x00 | 0x27 | 0x00 | 0x00 | MT2894 Family [ConnectX-6 Lx] | N/A | N/A | N/A | N/A | 0302Y238 | 02Y238X6RC000058 |
`)),
},
{
Path: "dump_info/LogDump/netcard/netcard_info.txt",
Content: []byte(strings.TrimSpace(`
2026-02-04 03:54:06 UTC
ProductName :XC385
Manufacture :XFUSION
FirmwareVersion :26.39.2048
SlotId :1
Port0 BDF:0000:27:00.0
MacAddr:44:1A:4C:16:E8:03
ActualMac:44:1A:4C:16:E8:03
Port1 BDF:0000:27:00.1
MacAddr:00:00:00:00:00:00
ActualMac:44:1A:4C:16:E8:04
`)),
},
{
Path: "dump_info/RTOSDump/versioninfo/app_revision.txt",
Content: []byte(strings.TrimSpace(`
------------------- iBMC INFO -------------------
Active iBMC Version: (U68)3.08.05.85
Active iBMC Built: 16:46:26 Jan 4 2026
SDK Version: 13.16.30.16
SDK Built: 07:55:18 Dec 12 2025
Active BIOS Version: (U6216)01.02.08.17
Active BIOS Built: 00:00:00 Jan 05 2026
Product Name: G5500 V7
`)),
},
}
result, err := p.Parse(files)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if result.Protocol != "ipmi" || result.SourceType != models.SourceTypeArchive {
t.Fatalf("unexpected source metadata: protocol=%q source_type=%q", result.Protocol, result.SourceType)
}
if result.Hardware == nil {
t.Fatal("Hardware is nil")
}
// One adapter per PCI function, keyed by that port's BDF, matching an
// OS-level lspci view. Ports share the card's serial and firmware.
if len(result.Hardware.NetworkAdapters) != 2 {
t.Fatalf("expected 2 network adapters (one per port), got %d", len(result.Hardware.NetworkAdapters))
}
byBDF := map[string]models.NetworkAdapter{}
for _, a := range result.Hardware.NetworkAdapters {
byBDF[a.BDF] = a
if a.Firmware != "26.39.2048" {
t.Fatalf("adapter %s firmware = %q, want 26.39.2048", a.BDF, a.Firmware)
}
// Ports keyed by MAC, not by the shared card serial, so a BMC dump and an
// lspci-based bundle produce the same components.
if a.SerialNumber != "" {
t.Fatalf("adapter %s serial = %q, want empty (MAC-keyed)", a.BDF, a.SerialNumber)
}
if a.PartNumber != "0302Y238" {
t.Fatalf("adapter %s part number = %q, want 0302Y238", a.BDF, a.PartNumber)
}
}
if got := byBDF["0000:27:00.0"].MACAddresses; len(got) != 1 || got[0] != "44:1A:4C:16:E8:03" {
t.Fatalf("port 0 MACs = %#v", got)
}
if got := byBDF["0000:27:00.1"].MACAddresses; len(got) != 1 || got[0] != "44:1A:4C:16:E8:04" {
t.Fatalf("port 1 MACs = %#v", got)
}
fwByDevice := make(map[string]models.FirmwareInfo)
for _, fw := range result.Hardware.Firmware {
fwByDevice[fw.DeviceName] = fw
}
if fwByDevice["iBMC"].Version != "3.08.05.85" {
t.Fatalf("expected iBMC firmware (chip designator stripped) from app_revision.txt, got %#v", fwByDevice["iBMC"])
}
if fwByDevice["BIOS"].Version != "01.02.08.17" {
t.Fatalf("expected BIOS firmware (chip designator stripped) from app_revision.txt, got %#v", fwByDevice["BIOS"])
}
if result.Hardware.BoardInfo.ProductName != "G5500 V7" {
t.Fatalf("expected board product fallback from app_revision.txt, got %q", result.Hardware.BoardInfo.ProductName)
}
}
func TestParse_G5500V7_PSUs(t *testing.T) {
files := loadTestArchive(t, "../../../../example/G5500V7_210619KUGGXGS2000015_20260318-1128.tar.gz")
p := &Parser{}
result, err := p.Parse(files)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if len(result.Hardware.PowerSupply) != 4 {
t.Fatalf("expected 4 PSUs, got %d", len(result.Hardware.PowerSupply))
}
for _, psu := range result.Hardware.PowerSupply {
if psu.WattageW != 3000 {
t.Errorf("PSU slot %s wattage = %d, want 3000", psu.Slot, psu.WattageW)
}
if psu.SerialNumber == "" {
t.Errorf("PSU slot %s has empty SerialNumber", psu.Slot)
}
}
}
func TestParse_G5500V7_Storage(t *testing.T) {
files := loadTestArchive(t, "../../../../example/G5500V7_210619KUGGXGS2000015_20260318-1128.tar.gz")
p := &Parser{}
result, err := p.Parse(files)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if len(result.Hardware.Storage) != 2 {
t.Fatalf("expected 2 storage devices, got %d", len(result.Hardware.Storage))
}
for _, disk := range result.Hardware.Storage {
if disk.SerialNumber == "" {
t.Errorf("disk slot %s has empty SerialNumber", disk.Slot)
}
if disk.Model == "" {
t.Errorf("disk slot %s has empty Model", disk.Slot)
}
}
}
func TestParse_G5500V7_Sensors(t *testing.T) {
files := loadTestArchive(t, "../../../../example/G5500V7_210619KUGGXGS2000015_20260318-1128.tar.gz")
p := &Parser{}
result, err := p.Parse(files)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if len(result.Sensors) < 20 {
t.Fatalf("expected at least 20 sensors, got %d", len(result.Sensors))
}
}
func TestParse_G5500V7_Events(t *testing.T) {
files := loadTestArchive(t, "../../../../example/G5500V7_210619KUGGXGS2000015_20260318-1128.tar.gz")
p := &Parser{}
result, err := p.Parse(files)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if len(result.Events) < 5 {
t.Fatalf("expected at least 5 events, got %d", len(result.Events))
}
// All events should have real timestamps (not epoch 0)
for _, ev := range result.Events {
if ev.Timestamp.Year() <= 1970 {
t.Errorf("event has epoch timestamp: %v %s", ev.Timestamp, ev.Description)
}
}
}
func TestParse_G5500V7_FRU(t *testing.T) {
files := loadTestArchive(t, "../../../../example/G5500V7_210619KUGGXGS2000015_20260318-1128.tar.gz")
p := &Parser{}
result, err := p.Parse(files)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if len(result.FRU) < 3 {
t.Fatalf("expected at least 3 FRU entries, got %d", len(result.FRU))
}
// Check mainboard FRU serial
found := false
for _, f := range result.FRU {
if f.SerialNumber == "210619KUGGXGS2000015" {
found = true
}
}
if !found {
t.Error("mainboard serial 210619KUGGXGS2000015 not found in FRU")
}
}
// TestParseMemInfo_EmbeddedNewlineInBOM guards against the BMC copying a raw SPD
// "bom number" field containing a 0x0A byte, which splits a DIMM record across
// two physical lines. The row must be rejoined, not turned into a phantom DIMM.
func TestParseMemInfo_EmbeddedNewlineInBOM(t *testing.T) {
const header = "slot(col 1), dimm location(col 2), dimm name(col 3),manufacturer(col 4), size(col 5), speed(col 6), current speed(col 7), memory type(col 8), SN(col 9), minimum voltage(col 10), rank(col 11), bit width(col 12), memory technology(col 13), bom number(col 14), part number(col 15),remaining service life(col 16), firmware version(col 17), medium temp(col 18), controller temp(col 19), volatile capacity(col 20), persistent capacity(col 21), health(col 22)\n"
good := "Memory160 , mainboard, DIMM160, Samsung, 65536 MB, 5600 MT/s, 4400 MT/s, DDR5, 13E79B2E, 1100 mV, 2 rank, 64 bit, Synchronous| Registered (Buffered), \x01\x02, M321R8GA0EB2-CWMXH, Unknown, N/A, Unknown, Unknown, Unknown, Unknown, OK\n"
// Memory170: BOM field carries a stray newline mid-record.
split := "Memory170 , mainboard, DIMM170, Samsung, 65536 MB, 5600 MT/s, 4400 MT/s, DDR5, 13E79D42, 1100 mV, 2 rank, 64 bit, Synchronous| Registered (Buffered), \x9d7h!\x8f\n" +
"s, M321R8GA0EB2-CWMXH, Unknown, N/A, Unknown, Unknown, Unknown, Unknown, OK\n"
dimms := parseMemInfo([]byte(header + good + split))
if len(dimms) != 2 {
t.Fatalf("expected 2 DIMMs, got %d: %+v", len(dimms), dimms)
}
m170 := dimms[1]
if m170.Slot != "DIMM170" {
t.Errorf("slot = %q, want DIMM170", m170.Slot)
}
if m170.SerialNumber != "13E79D42" {
t.Errorf("serial = %q, want 13E79D42", m170.SerialNumber)
}
if m170.PartNumber != "M321R8GA0EB2-CWMXH" {
t.Errorf("part number = %q, want M321R8GA0EB2-CWMXH", m170.PartNumber)
}
if m170.SizeMB != 65536 || m170.Type != "DDR5" || m170.CurrentSpeedMHz != 4400 {
t.Errorf("m170 fields not recovered: %+v", m170)
}
}
// TestParseDiskInfo_CapacityUnits guards the disk_info "Capacity" parse: iBMC
// labels the number "GB"/"TB" but the value is binary, and the old code only
// matched a literal " GB" so every "X TB" NVMe drive got size_gb 0 while the
// BEE-SP live-CD export of the same drive reported the real decimal size.
func TestParseDiskInfo_CapacityUnits(t *testing.T) {
cases := []struct {
name string
content string
wantGB int
}{
{
name: "kioxia nvme reported as TB",
content: "Serial Number : 9F30A0440V43\n" +
"Model : KIOXIA KCD8XPUG7T68\n" +
"Media Type : SSD\n" +
"Interface Type : PCIe\n" +
"Capacity : 6.986 TB\n",
wantGB: 7680,
},
{
name: "intel sata reported as TB",
content: "Serial Number : PHYF202100WK3P8EGN\n" +
"Model : INTEL SSDSC2KB038T8\n" +
"Capacity : 3.492 TB\n",
wantGB: 3840,
},
{
name: "boot ssd reported as GB",
content: "Serial Number : X1\nModel : M\nCapacity : 446.625 GB\n",
wantGB: 480,
},
{
name: "missing capacity",
content: "Serial Number : X2\nModel : M\n",
wantGB: 0,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
d := parseDiskInfo([]byte(tc.content))
if d == nil {
t.Fatal("parseDiskInfo returned nil")
}
// iBMC reports binary units; a few GB of rounding slack vs the
// live-CD's exact byte count is acceptable (size_gb is display-only).
if diff := d.SizeGB - tc.wantGB; diff < -2 || diff > 2 {
t.Errorf("SizeGB = %d, want ~%d", d.SizeGB, tc.wantGB)
}
})
}
}