diff --git a/cmd/logpile-cpu-audit/main_test.go b/cmd/logpile-cpu-audit/main_test.go index ddd96d3..ddfaf33 100644 --- a/cmd/logpile-cpu-audit/main_test.go +++ b/cmd/logpile-cpu-audit/main_test.go @@ -128,3 +128,28 @@ func TestRunAuditWritesFailureReportAndSummary(t *testing.T) { } } } + +func TestCollectAuditJobsAndReanimatorNoCPU(t *testing.T) { + input := t.TempDir() + output := filepath.Join(input, "output") + plain := filepath.Join(input, "nvidia-bug-report.log") + if err := os.WriteFile(plain, []byte("NVIDIA bug report log file\nnvidia-bug-report.sh\n"), 0o644); err != nil { + t.Fatalf("write fixture: %v", err) + } + + jobs, err := collectAuditJobs(input, output, true, true) + if err != nil { + t.Fatalf("collectAuditJobs: %v", err) + } + if len(jobs) != 1 || jobs[0].relative != "nvidia-bug-report.log" { + t.Fatalf("unexpected jobs: %+v", jobs) + } + + summary, err := runReanimatorExport(input, output, "v1", 1, true) + if err != nil { + t.Fatalf("runReanimatorExport: %v", err) + } + if summary.FilesFound != 1 || summary.FilesParsed != 1 || summary.FilesNoCPU != 1 || summary.FilesWithCPU != 0 { + t.Fatalf("unexpected summary: %+v", summary) + } +} diff --git a/internal/collector/redfishprofile/profile_inspur_group_oem_platforms_test.go b/internal/collector/redfishprofile/profile_inspur_group_oem_platforms_test.go index 70ee38c..8ddc6e5 100644 --- a/internal/collector/redfishprofile/profile_inspur_group_oem_platforms_test.go +++ b/internal/collector/redfishprofile/profile_inspur_group_oem_platforms_test.go @@ -186,6 +186,9 @@ func loadRawExportTreeFromExampleZip(t *testing.T, name string) map[string]inter path := filepath.Join("..", "..", "..", "example", name) f, err := os.Open(path) if err != nil { + if os.IsNotExist(err) { + t.Skipf("optional example zip %s is not present", path) + } t.Fatalf("open example zip %s: %v", path, err) } defer f.Close() diff --git a/internal/parser/archive_test.go b/internal/parser/archive_test.go index 3ac2b85..5403457 100644 --- a/internal/parser/archive_test.go +++ b/internal/parser/archive_test.go @@ -28,6 +28,26 @@ func TestExtractArchiveFromReaderTXT(t *testing.T) { } } +func TestArchiveFileSelectionHelpers(t *testing.T) { + extensions := SupportedArchiveExtensions() + if len(extensions) == 0 || extensions[0] != ".ahs" { + t.Fatalf("unexpected supported extensions: %v", extensions) + } + if !IsSupportedArchiveFilename("report.TGZ") || IsSupportedArchiveFilename("report.bin") { + t.Fatal("unexpected supported archive detection") + } + files := []ExtractedFile{{Path: "logs/System.LOG"}, {Path: "config.json"}, {Path: "nested/event.log"}} + if got := FindFileByPattern(files, "log"); len(got) != 2 { + t.Fatalf("FindFileByPattern = %+v", got) + } + if got := FindFileByName(files, "EVENT.LOG"); got == nil || got.Path != "nested/event.log" { + t.Fatalf("FindFileByName = %+v", got) + } + if got := FindFileByName(files, "missing"); got != nil { + t.Fatalf("expected missing file, got %+v", got) + } +} + func TestExtractArchiveTXT(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "sample.txt") diff --git a/internal/parser/vendors/nvidia_bug_report/inventory_test.go b/internal/parser/vendors/nvidia_bug_report/inventory_test.go new file mode 100644 index 0000000..3977a27 --- /dev/null +++ b/internal/parser/vendors/nvidia_bug_report/inventory_test.go @@ -0,0 +1,153 @@ +package nvidia_bug_report + +import ( + "strings" + "testing" + + "git.mchus.pro/mchus/logpile/internal/models" + "git.mchus.pro/mchus/logpile/internal/parser" +) + +func TestParserParseInventorySections(t *testing.T) { + content := `NVIDIA bug report log file +nvidia-bug-report.sh Version: 1 +Date: Fri Dec 12 10:14:49 EST 2025 +System Information + Manufacturer: Acme + Product Name: GPU Server + Version: rev-1 + Serial Number: SYS-1 + UUID: uuid-1 + +Processor Information + Version: NVIDIA Grace CPU + Serial Number: CPU-1 + Core Count: 72 + Thread Count: 144 + Max Speed: 3.2 GHz + Current Speed: 2800 MHz + Status: Populated, Enabled + +Memory Device + Size: 64 GB + Locator: DIMM_A1 + Bank Locator: BANK 0 + Type: DDR5 + Type Detail: Synchronous + Speed: 5600 MT/s + Configured Memory Speed: 5200 MT/s + Manufacturer: Micron + Serial Number: MEM-1 + Part Number: PN-1 + Rank: 2 + +System Power Supply + Location: PSU1 + Name: Power Supply + Manufacturer: Delta + Serial Number: PSU-SN + Model Part Number: DPS-3000 + Revision: 1.0 + Max Power Capacity: 3000 W + Status: Present, OK + +/proc/driver/nvidia/gpus/0000:1a:00.0/information +Model: NVIDIA B200 +IRQ: 42 +GPU UUID: GPU-1 +Video BIOS: 96.00 +Bus Type: PCIe +DMA Size: 47 bits +DMA Mask: 0xffff +Bus Location: 0000:1a:00.0 +Device Minor: 0 +GPU Excluded: No +___ +GPU 00000000:1A:00.0 + Serial Number : GPU-SN +0000:5e:00.0 Ethernet controller [0200]: Mellanox Technologies ConnectX-7 [15b3:101e] + Physical Slot: SLOT-1 + Vital Product Data + Product Name: Dual-port QSFP56 Adapter + [PN] Part number: MCX75310AAS + [SN] Serial number: NIC-1 + [EC] Engineering changes: A1 + End +NVRM version: 570.172.08 +` + + p := &Parser{} + result, err := p.Parse([]parser.ExtractedFile{{Path: "nvidia-bug-report.log", Content: []byte(content)}}) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if result.Hardware.BoardInfo.ProductName != "GPU Server" || result.Hardware.BoardInfo.SerialNumber != "SYS-1" { + t.Fatalf("unexpected board: %+v", result.Hardware.BoardInfo) + } + if len(result.Hardware.CPUs) != 1 || result.Hardware.CPUs[0].Cores != 72 || result.Hardware.CPUs[0].MaxFreqMHz != 3200 { + t.Fatalf("unexpected CPUs: %+v", result.Hardware.CPUs) + } + if len(result.Hardware.Memory) != 1 || result.Hardware.Memory[0].SizeMB != 65536 || result.Hardware.Memory[0].Ranks != 2 { + t.Fatalf("unexpected memory: %+v", result.Hardware.Memory) + } + if len(result.Hardware.PowerSupply) != 1 || !result.Hardware.PowerSupply[0].Present || result.Hardware.PowerSupply[0].WattageW != 3000 { + t.Fatalf("unexpected power supplies: %+v", result.Hardware.PowerSupply) + } + if len(result.Hardware.GPUs) != 1 || result.Hardware.GPUs[0].SerialNumber != "GPU-SN" || result.Hardware.GPUs[0].IRQ != 42 { + t.Fatalf("unexpected GPUs: %+v", result.Hardware.GPUs) + } + if len(result.Hardware.NetworkAdapters) != 1 { + t.Fatalf("unexpected network adapters: %+v", result.Hardware.NetworkAdapters) + } + nic := result.Hardware.NetworkAdapters[0] + if nic.Model != "Dual-port QSFP56 Adapter" || nic.PortCount != 2 || nic.PortType != "QSFP56" || nic.SerialNumber != "NIC-1" { + t.Fatalf("unexpected network adapter: %+v", nic) + } + if !hasEvent(result.Events, "Memory Configuration") || !hasEvent(result.Events, "GPU Detection") || !hasEvent(result.Events, "Driver Version") { + t.Fatalf("expected inventory events, got %+v", result.Events) + } +} + +func TestParserDetectAndScalarParsers(t *testing.T) { + p := &Parser{} + if got := p.Detect([]parser.ExtractedFile{{Path: "nvidia-bug-report.log", Content: []byte("nvidia-bug-report.sh NVIDIA bug report log file")}}); got != 85 { + t.Fatalf("Detect = %d", got) + } + if got := p.Detect(nil); got != 0 { + t.Fatalf("empty Detect = %d", got) + } + if p.Name() == "" || p.Version() == "" { + t.Fatal("expected parser identity") + } + for _, tc := range []struct { + value string + want int + }{ + {"2.5 GHz", 2500}, {"3200 MHz", 3200}, {"bad", 0}, + } { + if got := parseCPUSpeed(tc.value); got != tc.want { + t.Errorf("parseCPUSpeed(%q) = %d, want %d", tc.value, got, tc.want) + } + } + if got := parseMemorySize("2 TB"); got != 2*1024*1024 { + t.Fatalf("parseMemorySize = %d", got) + } + if got := parseMemorySpeed("5600 MT/s"); got != 5600 { + t.Fatalf("parseMemorySpeed = %d", got) + } + if got := parsePowerWattage("1200 Watts"); got != 1200 { + t.Fatalf("parsePowerWattage = %d", got) + } + if got := formatGPUSummary([]models.GPU{{BDF: "0000:1a:00.0", Model: "B200"}}); !strings.Contains(got, "B200") { + t.Fatalf("formatGPUSummary = %q", got) + } +} + +func hasEvent(events []models.Event, eventType string) bool { + for _, event := range events { + if event.EventType == eventType { + return true + } + } + return false +} diff --git a/internal/parser/vendors/unraid/inventory_integration_test.go b/internal/parser/vendors/unraid/inventory_integration_test.go new file mode 100644 index 0000000..615c248 --- /dev/null +++ b/internal/parser/vendors/unraid/inventory_integration_test.go @@ -0,0 +1,102 @@ +package unraid + +import ( + "testing" + + "git.mchus.pro/mchus/logpile/internal/parser" +) + +func TestParseInventoryAndLogs(t *testing.T) { + files := []parser.ExtractedFile{ + {Path: "diagnostics/system/motherboard.txt", Content: []byte(`Acme NAS Board +BIOS Information + Vendor: Acme + Version: 1.2.3 +`)}, + {Path: "diagnostics/system/meminfo.txt", Content: []byte(`Handle 0x0010, DMI type 17, 84 bytes +Memory Device + Size: 32 GB + Locator: DIMM_A1 + Bank Locator: BANK 0 + Type: DDR5 + Speed: 5600 MT/s + Configured Memory Speed: 5200 MT/s + Manufacturer: Micron + Serial Number: MEM-1 + Part Number: PN-1 + Rank: 2 +`)}, + {Path: "diagnostics/system/ifconfig.txt", Content: []byte("eth0 UP 192.0.2.10/24\nlo UP 127.0.0.1\n")}, + {Path: "diagnostics/system/ethtool.txt", Content: []byte(`Settings for eth0: + driver: mlx5_core + firmware-version: 1.0 + bus-info: 0000:5e:00.0 + Speed: 100000Mb/s + Link detected: yes +`)}, + {Path: "diagnostics/system/lspci.txt", Content: []byte(`5e:00.0 Ethernet controller [0200]: Mellanox Technologies Network Adapter [15b3:101e] +3b:00.0 Non-Volatile memory controller [0108]: Acme NVMe [1234:5678] +`)}, + {Path: "diagnostics/system/vars.txt", Content: []byte(`disks +( + [disk1] => Array + ( + [device] => sda + [id] => WDC-1 + [size] => 2097152 + [temp] => 55 + [fsType] => xfs + ) +) +flashGUID +( + [flashGUID] => HOST-1 +) +`)}, + {Path: "diagnostics/logs/syslog.txt", Content: []byte("Feb 5 23:33:01 host kernel: fatal disk error\nFeb 5 23:33:02 host daemon: normal message\n")}, + } + + result, err := (&Parser{}).Parse(files) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if result.Hardware.BoardInfo.Manufacturer != "Acme" || result.Hardware.BoardInfo.ProductName != "Acme NAS Board" { + t.Fatalf("unexpected board: %+v", result.Hardware.BoardInfo) + } + if result.Hardware.BoardInfo.SerialNumber != "HOST-1" { + t.Fatalf("host serial = %q", result.Hardware.BoardInfo.SerialNumber) + } + if len(result.Hardware.Memory) != 1 || result.Hardware.Memory[0].Slot != "DIMM_A1" || result.Hardware.Memory[0].SizeMB != 32768 { + t.Fatalf("unexpected memory: %+v", result.Hardware.Memory) + } + if len(result.Hardware.NetworkAdapters) != 1 || result.Hardware.NetworkAdapters[0].Slot != "eth0" || result.Hardware.NetworkAdapters[0].Status != "ok" { + t.Fatalf("unexpected NICs: %+v", result.Hardware.NetworkAdapters) + } + if len(result.Hardware.PCIeDevices) != 2 { + t.Fatalf("unexpected PCIe devices: %+v", result.Hardware.PCIeDevices) + } + if len(result.Hardware.Storage) != 1 || result.Hardware.Storage[0].Slot != "disk1" || result.Hardware.Storage[0].SizeGB != 2 { + t.Fatalf("unexpected storage: %+v", result.Hardware.Storage) + } + if len(result.Sensors) != 1 || result.Sensors[0].Status != "warning" { + t.Fatalf("unexpected sensors: %+v", result.Sensors) + } + if len(result.Events) != 2 || result.Events[0].Severity != "critical" { + t.Fatalf("unexpected syslog events: %+v", result.Events) + } +} + +func TestUnraidParsingHelpers(t *testing.T) { + if got := firstWords("Mellanox Technologies Adapter", 2); got != "Mellanox Technologies" { + t.Fatalf("firstWords = %q", got) + } + if got := getTempStatus(60); got != "critical" { + t.Fatalf("temperature status = %q", got) + } + if got := getTempStatus(20); got != "ok" { + t.Fatalf("temperature status = %q", got) + } + if got := extractDiskSection(" [disk1] => Array\n (\n [id] => disk\n )", "disk1"); got == "" { + t.Fatal("expected disk section") + } +} diff --git a/internal/parser/vendors/xigmanas/parser.go b/internal/parser/vendors/xigmanas/parser.go index ad98dc1..5097670 100644 --- a/internal/parser/vendors/xigmanas/parser.go +++ b/internal/parser/vendors/xigmanas/parser.go @@ -238,7 +238,7 @@ func parseStorageAndSMART(content string, result *models.AnalysisResult) { storageBySlot := make(map[string]*models.Storage) scsiRe := regexp.MustCompile(`(?m)^<([^>]+)>\s+at\s+scbus\d+\s+target\s+\d+\s+lun\s+\d+\s+\(([^,]+),([^)]+)\)$`) for _, m := range scsiRe.FindAllStringSubmatch(content, -1) { - slot := strings.TrimSpace(m[3]) + slot := strings.TrimSpace(m[2]) model, fw := splitModelAndFirmware(strings.TrimSpace(m[1])) entry := &models.Storage{ Slot: slot, diff --git a/internal/parser/vendors/xigmanas/parser_test.go b/internal/parser/vendors/xigmanas/parser_test.go index d35349c..5ed3ae6 100644 --- a/internal/parser/vendors/xigmanas/parser_test.go +++ b/internal/parser/vendors/xigmanas/parser_test.go @@ -38,6 +38,88 @@ loader_brand="XigmaNAS"`), } } +func TestParserParseInlineDump(t *testing.T) { + p := &Parser{} + result, err := p.Parse([]parser.ExtractedFile{{Path: "diagnostic.txt", Content: []byte(`Version: +-------- +14.3.0.5 +smbios.bios.version="F.22" +smbios.system.maker="Acme" +smbios.system.product="NAS-1" +smbios.system.serial="SN-1" +smbios.system.uuid="uuid-1" +FreeBSD/SMP: 1 package(s) x 4 core(s) +CPU: AMD Ryzen 5 3600 (3600.00-MHz) +CPU: AMD Ryzen 5 3600 (3600.00-MHz) +real memory = 8589934592 (8192 MB) +10:10AM up 2 days, 3 users, load averages: 0.10, 0.20, 0.30 +state: DEGRADED + at scbus0 target 0 lun 0 (da0,pass0) +S.M.A.R.T. [/dev/da0]: +-------- +Device Model: WDC WD10EZEX +Serial Number: DISK-1 +Firmware Version: 1.01 +User Capacity: 1,000,000,000 bytes +SMART overall-health self-assessment test result: FAILED +194 Temperature_Celsius 0x0022 100 100 000 Old_age Always - 41 +Last 275 System log entries: +-------- +2026-01-02T03:04:05+00:00 host kernel: fatal disk error +Last 275 SMARTD log entries: +-------- +Jan 2 03:04:05 host smartd: warning temperature +Last 275 Daemon log entries: +-------- +plain informational line +`)}}) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if result.Hardware.BoardInfo.ProductName != "NAS-1" || len(result.Hardware.Firmware) != 2 { + t.Fatalf("unexpected board or firmware: %+v / %+v", result.Hardware.BoardInfo, result.Hardware.Firmware) + } + if len(result.Hardware.CPUs) != 1 || result.Hardware.CPUs[0].Cores != 4 { + t.Fatalf("unexpected CPUs: %+v", result.Hardware.CPUs) + } + if len(result.Hardware.Memory) != 1 || result.Hardware.Memory[0].SizeMB != 8192 { + t.Fatalf("unexpected memory: %+v", result.Hardware.Memory) + } + if len(result.Hardware.Storage) != 1 || result.Hardware.Storage[0].SizeGB != 1 || result.Hardware.Storage[0].SerialNumber != "DISK-1" { + t.Fatalf("unexpected storage: %+v", result.Hardware.Storage) + } + if len(result.Sensors) != 1 || result.Sensors[0].Status != "warning" { + t.Fatalf("unexpected sensors: %+v", result.Sensors) + } + if len(result.Events) != 6 { + t.Fatalf("expected uptime, ZFS, SMART, and three log events, got %+v", result.Events) + } +} + +func TestXigmaNASHelpers(t *testing.T) { + if model, fw := splitModelAndFirmware("disk model 1.00"); model != "disk model" || fw != "1.00" { + t.Fatalf("splitModelAndFirmware = %q, %q", model, fw) + } + if got := guessStorageType("cd0"); got != "optical" { + t.Fatalf("optical type = %q", got) + } + if got := guessStorageType("ada0"); got != "hdd" { + t.Fatalf("HDD type = %q", got) + } + if got := parseCapacityBytes("invalid"); got != 0 { + t.Fatalf("invalid capacity = %d", got) + } + if got := classifyEventSeverity("all good"); got != "info" { + t.Fatalf("info severity = %q", got) + } + if got := classifyEventSeverity("kernel panic"); got != "critical" { + t.Fatalf("critical severity = %q", got) + } + if got := extractSyslogMessage("a: message"); got != "message" { + t.Fatalf("syslog message = %q", got) + } +} + func TestParserParseExample(t *testing.T) { p := &Parser{} diff --git a/internal/server/chart_view_test.go b/internal/server/chart_view_test.go index 5cd5067..3f10461 100644 --- a/internal/server/chart_view_test.go +++ b/internal/server/chart_view_test.go @@ -43,11 +43,11 @@ func TestHandleChartCurrent_RendersCurrentReanimatorSnapshot(t *testing.T) { if !strings.Contains(body, "SYS-TEST - SN123") { t.Fatalf("expected chart title in body, got %q", body) } - if !strings.Contains(body, `/chart/static/view.css`) { - t.Fatalf("expected rewritten chart css path, got %q", body) + if !strings.Contains(body, `static/view.css`) { + t.Fatalf("expected chart css path, got %q", body) } - if !strings.Contains(body, `/chart/static/view.js`) { - t.Fatalf("expected rewritten chart js path, got %q", body) + if !strings.Contains(body, `static/view.js`) { + t.Fatalf("expected chart js path, got %q", body) } if !strings.Contains(body, "Snapshot Metadata") { t.Fatalf("expected rendered chart output, got %q", body) diff --git a/internal/server/reanimator_example_regression_test.go b/internal/server/reanimator_example_regression_test.go index 5dcca11..f1d5f70 100644 --- a/internal/server/reanimator_example_regression_test.go +++ b/internal/server/reanimator_example_regression_test.go @@ -12,6 +12,9 @@ import ( func TestReanimatorExport_RedfishExampleDoesNotDuplicateCPUs(t *testing.T) { payload, err := os.ReadFile(filepath.Join("..", "..", "example", "2026-03-11 (SYS-821GE-TNHR) - A514359X5C08846.zip")) if err != nil { + if os.IsNotExist(err) { + t.Skip("optional example bundle is not present") + } t.Fatalf("read example bundle: %v", err) }