From 3311bafd8e5667c28f4d969fbd2b6212d0739d03 Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Wed, 2 Sep 2026 12:51:53 +0300 Subject: [PATCH] feat(inspur_legacy): new parser for pre-Kaytus Inspur onekeylog Older AMI-BMC Inspur onekeylog archives (NF5466M5 / NF5280M5 generation) opened to an empty result: they carry none of the files the inspur parser keys on (no asset.json, devicefrusdr.log, selelist.csv or component.log). New package internal/parser/vendors/inspur_legacy (vendor id inspur_legacy), separate from inspur: - binary IPMI FRU decode (FRU.bin) -> board identity - Inspur_AssetInfoInventory.log -> CPU / memory / PCIe / PSU inventory - events from Inspur___IDL, sel.log, blackbox.log, MegaRAID raid0.log, and the flat AMI .log files - no live sensors in this archive class -> recorded as a collection error - BMC clock timestamps before 2010 dropped as un-set (1970 / ~2005 RTC) Registry: add optional PrioritizedParser { DetectPriority() int } so a confidence tie is broken by specificity. inspur_legacy returns 10 and also declines (Detect 0) when modern Kaytus markers are present, so the two Inspur parsers never fight over a newer dump. Docs: ADL-065, 06-parsers.md, releases/v1.32. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017VL8wLGD6Lnp6hZCpqT6cZ --- bible-local/06-parsers.md | 4 + bible-local/10-decisions.md | 49 +++ internal/parser/registry.go | 29 +- internal/parser/registry_test.go | 62 ++++ .../vendors/inspur_legacy/asset_inventory.go | 284 ++++++++++++++++++ .../inspur_legacy/asset_inventory_test.go | 86 ++++++ .../parser/vendors/inspur_legacy/blackbox.go | 98 ++++++ .../vendors/inspur_legacy/event_logs_test.go | 115 +++++++ .../parser/vendors/inspur_legacy/events.go | 54 ++++ internal/parser/vendors/inspur_legacy/fru.go | 268 +++++++++++++++++ .../parser/vendors/inspur_legacy/fru_test.go | 104 +++++++ internal/parser/vendors/inspur_legacy/idl.go | 61 ++++ .../parser/vendors/inspur_legacy/megaraid.go | 123 ++++++++ .../parser/vendors/inspur_legacy/parser.go | 191 ++++++++++++ .../vendors/inspur_legacy/parser_test.go | 85 ++++++ internal/parser/vendors/inspur_legacy/sel.go | 98 ++++++ .../parser/vendors/inspur_legacy/syslog.go | 116 +++++++ internal/parser/vendors/vendors.go | 5 +- 18 files changed, 1827 insertions(+), 5 deletions(-) create mode 100644 internal/parser/registry_test.go create mode 100644 internal/parser/vendors/inspur_legacy/asset_inventory.go create mode 100644 internal/parser/vendors/inspur_legacy/asset_inventory_test.go create mode 100644 internal/parser/vendors/inspur_legacy/blackbox.go create mode 100644 internal/parser/vendors/inspur_legacy/event_logs_test.go create mode 100644 internal/parser/vendors/inspur_legacy/events.go create mode 100644 internal/parser/vendors/inspur_legacy/fru.go create mode 100644 internal/parser/vendors/inspur_legacy/fru_test.go create mode 100644 internal/parser/vendors/inspur_legacy/idl.go create mode 100644 internal/parser/vendors/inspur_legacy/megaraid.go create mode 100644 internal/parser/vendors/inspur_legacy/parser.go create mode 100644 internal/parser/vendors/inspur_legacy/parser_test.go create mode 100644 internal/parser/vendors/inspur_legacy/sel.go create mode 100644 internal/parser/vendors/inspur_legacy/syslog.go diff --git a/bible-local/06-parsers.md b/bible-local/06-parsers.md index fd896a2..8fa93e2 100644 --- a/bible-local/06-parsers.md +++ b/bible-local/06-parsers.md @@ -8,6 +8,9 @@ Core behavior: - registration uses `init()` side effects - all registered parsers run `Detect()` - the highest-confidence parser wins +- a confidence tie is broken by the optional `PrioritizedParser` interface + (`DetectPriority() int`, default 0); used so `inspur_legacy` beats the broader + `inspur` parser on shared `onekeylog/` archives (see ADL-065) - generic fallback stays last and low-confidence `VendorParser` contract: @@ -61,6 +64,7 @@ serial from its socket, model, board serial, or another component; reject source | `h3c_g6` | H3C SDS G6 bundles | Similar flow with G6-specific files | | `hpe_ilo_ahs` | HPE iLO Active Health System (`.ahs`) | Proprietary `ABJR` container with gzip-compressed `zbb` members; parser combines SMBIOS-style inventory strings and embedded Redfish storage JSON | | `inspur` | onekeylog archives (classic `component.log` and per-file `component/*.txt` D-Bus layouts) | FRU/SDR, optional Redis enrichment, normalized IDL/SEL/syslog events (see ADL-056) | +| `inspur_legacy` | Legacy AMI-BMC onekeylog (NF5466M5/NF5280M5 gen: `Inspur_AssetInfoInventory.log`, binary `FRU.bin`, `Inspur_*_IDL`, `sel.log`, flat `.log`) | Binary IPMI FRU decode + text inventory + IDL/SEL/blackbox/MegaRAID/syslog events; no live sensors. Wins the `onekeylog/` detect tie via `DetectPriority`. See ADL-065 | | `lenovo_xcc` | Lenovo XCC mini-log ZIP archives | JSON inventory + platform event logs | | `nvidia` | HGX Field Diagnostics | GPU- and fabric-heavy diagnostic input | | `nvidia_bug_report` | `nvidia-bug-report-*.log.gz` | dmidecode, lspci, NVIDIA driver sections; Xid/SXid GPU error events (see ADL-055, `bible-local/docs/nvidia-bug-report-analysis.md`) | diff --git a/bible-local/10-decisions.md b/bible-local/10-decisions.md index c139262..67fa846 100644 --- a/bible-local/10-decisions.md +++ b/bible-local/10-decisions.md @@ -1844,3 +1844,52 @@ FIRMWARE_CHANGED event per import. `cleanFirmwareVersion` now strips a trailing ` (...)` from those entries (BIOS then matches the live-CD exactly; BMC `7.11.02` vs the live-CD's IPMI-truncated `7.11` is inherent and left as-is). Test `TestExtractComponentFirmware_StripsBuildStamp`. + +--- + +## ADL-065 — Separate parser for the legacy Inspur onekeylog format + +**Date:** 2026-09-02 +**Context:** A customer NF5466M5 `onekeylog (14).tar` opened to an empty result. +The archive is a genuine, complete Inspur onekeylog, but from an older AMI-based +BMC generation that predates everything the `inspur` parser keys on: no +`asset.json`, no `devicefrusdr.log`, no `selelist.csv`, no `component.log`. +Inventory lives in `Inspur_AssetInfoInventory.log` (plain text), FRU in a binary +`FRU.bin`, the event log in `Inspur___IDL` plus a pipe-delimited +`sel.log`, and BMC syslog in flat `.log` files at the archive root. +The `inspur` parser still detected it at confidence 100 (every path contains +`onekeylog/`) and produced nothing. +**Decision:** +- New package `internal/parser/vendors/inspur_legacy` (vendor id + `inspur_legacy`), not a change to `inspur`. It decodes the binary IPMI FRU + image, `Inspur_AssetInfoInventory.log` (CPU/memory/PCIe/PSU), the `*_IDL` and + `sel.log` event logs, `blackbox.log`, MegaRAID `raid0.log`, and the flat AMI + syslog files. +- `Detect` returns 0 when any modern-Kaytus marker is present, so the two + Inspur parsers never fight over a newer dump. +- Added an optional `parser.PrioritizedParser { DetectPriority() int }` interface. + `DetectFormat`/`DetectAllFormats` break a confidence tie by priority. + `inspur_legacy` returns priority 10; every other parser defaults to 0. This is + the only way the more specific parser can win the 100-vs-100 tie against the + broader `inspur` parser without editing it. +- Product-area FRU serial is treated as the operator-facing system serial + (matches ADL for the Kaytus `component_fru.go`); board-area part number wins + when the product-area part is the `"0"` placeholder. +- Timestamps before 2010 are dropped as un-set BMC clocks (this hardware shipped + 2018+; alert.log also carries a ~2005 firmware-default RTC in addition to the + 1970 epoch). +**Consequences:** +- This archive class now yields full board identity (`NF5466M5` / + `221353113` / `YZMB-00882-104`), 2 CPUs, 12 DIMMs, 6 PCIe devices, 2 PSUs, + and ~2.9k dated events (PSU0 AC-loss on 2026-05-23, RAID predictive failure / + PD medium errors on slot 37 from 2026-08-31). +- Sensors are empty by design: SDR.dat holds only definitions and there is no + sensor-list capture. Recorded as a `sensors` collection error. +- IDL and SEL both describe the same BMC event log; only exact duplicates are + collapsed, so some events appear once per source. Cross-source dedup is left + for a later iteration. +- Tests: `TestDecodeBinaryFRU_*`, `TestParseAssetInfoInventory*`, + `TestParseIDLEvents`, `TestParseSELLog`, `TestParseBlackbox`, + `TestParseMegaRAIDLog`, `TestParseAMISyslog`, `TestDetect_*`, + `TestParse_EndToEnd`, and `TestDetectFormat_PriorityBreaksConfidenceTie` in + the `parser` package. diff --git a/internal/parser/registry.go b/internal/parser/registry.go index 5940c57..48454f4 100644 --- a/internal/parser/registry.go +++ b/internal/parser/registry.go @@ -81,6 +81,29 @@ type DetectResult struct { Confidence int } +// PrioritizedParser is an optional interface a parser may implement to break +// confidence ties in detection. A more specific parser that overlaps with a +// broader one on the same archive class returns a higher priority so it is +// chosen when both report equal confidence. Parsers that do not implement it +// are treated as priority 0. +type PrioritizedParser interface { + DetectPriority() int +} + +func detectPriority(p VendorParser) int { + if pp, ok := p.(PrioritizedParser); ok { + return pp.DetectPriority() + } + return 0 +} + +func lessByConfidenceThenPriority(a, b DetectResult) bool { + if a.Confidence != b.Confidence { + return a.Confidence > b.Confidence + } + return detectPriority(a.Parser) > detectPriority(b.Parser) +} + // DetectFormat tries to detect archive format and returns best matching parser func DetectFormat(files []ExtractedFile) (VendorParser, error) { registryLock.RLock() @@ -102,9 +125,9 @@ func DetectFormat(files []ExtractedFile) (VendorParser, error) { return nil, fmt.Errorf("no parser found for this archive format") } - // Sort by confidence descending + // Sort by confidence descending, breaking ties by detect priority. sort.Slice(results, func(i, j int) bool { - return results[i].Confidence > results[j].Confidence + return lessByConfidenceThenPriority(results[i], results[j]) }) return results[0].Parser, nil @@ -128,7 +151,7 @@ func DetectAllFormats(files []ExtractedFile) []DetectResult { } sort.Slice(results, func(i, j int) bool { - return results[i].Confidence > results[j].Confidence + return lessByConfidenceThenPriority(results[i], results[j]) }) return results diff --git a/internal/parser/registry_test.go b/internal/parser/registry_test.go new file mode 100644 index 0000000..adbf7b6 --- /dev/null +++ b/internal/parser/registry_test.go @@ -0,0 +1,62 @@ +package parser + +import ( + "testing" + + "git.mchus.pro/mchus/logpile/internal/models" +) + +type stubParser struct { + name string + vendor string + confidence int + priority int + hasPrio bool +} + +func (s stubParser) Name() string { return s.name } +func (s stubParser) Vendor() string { return s.vendor } +func (s stubParser) Version() string { return "1.0" } +func (s stubParser) Detect([]ExtractedFile) int { + return s.confidence +} +func (s stubParser) Parse([]ExtractedFile) (*models.AnalysisResult, error) { + return &models.AnalysisResult{}, nil +} + +type stubPrioParser struct{ stubParser } + +func (s stubPrioParser) DetectPriority() int { return s.priority } + +func TestDetectFormat_PriorityBreaksConfidenceTie(t *testing.T) { + broad := stubParser{name: "broad", vendor: "test_broad", confidence: 100} + specific := stubPrioParser{stubParser{name: "specific", vendor: "test_specific", confidence: 100}} + specific.priority = 10 + + saved := registry + registry = map[string]VendorParser{"a": broad, "b": specific} + defer func() { registry = saved }() + + got, err := DetectFormat(nil) + if err != nil { + t.Fatal(err) + } + if got.Name() != "specific" { + t.Fatalf("chosen %q, want the higher-priority parser on a confidence tie", got.Name()) + } +} + +func TestDetectFormat_HigherConfidenceStillWinsOverPriority(t *testing.T) { + broad := stubParser{name: "broad", vendor: "test_broad", confidence: 100} + specific := stubPrioParser{stubParser{name: "specific", vendor: "test_specific", confidence: 40}} + specific.priority = 10 + + saved := registry + registry = map[string]VendorParser{"a": broad, "b": specific} + defer func() { registry = saved }() + + got, _ := DetectFormat(nil) + if got.Name() != "broad" { + t.Fatalf("chosen %q, want higher-confidence parser", got.Name()) + } +} diff --git a/internal/parser/vendors/inspur_legacy/asset_inventory.go b/internal/parser/vendors/inspur_legacy/asset_inventory.go new file mode 100644 index 0000000..cb83c98 --- /dev/null +++ b/internal/parser/vendors/inspur_legacy/asset_inventory.go @@ -0,0 +1,284 @@ +package inspurlegacy + +import ( + "regexp" + "strconv" + "strings" + + "git.mchus.pro/mchus/logpile/internal/models" +) + +// ParseAssetInfoInventory reads Inspur_AssetInfoInventory.log and fills CPU, +// memory, PCIe and power-supply inventory. The file is organised in bracketed +// sections ("[CPU]", "[Memory]", "[PCIE]", "[PSU]"); each data line is +// ": k:v, k:v, ...". +func ParseAssetInfoInventory(content []byte, hw *models.HardwareConfig) { + section := "" + for _, rawLine := range strings.Split(string(content), "\n") { + line := strings.TrimSpace(rawLine) + if line == "" { + continue + } + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + section = strings.ToUpper(strings.Trim(line, "[]")) + continue + } + + key, rest, ok := strings.Cut(line, ":") + if !ok { + continue + } + key = strings.TrimSpace(key) + kv := parseAssetKVList(rest) + + switch section { + case "CPU": + if cpu, ok := assetCPU(key, kv); ok { + hw.CPUs = append(hw.CPUs, cpu) + } + case "MEMORY": + if dimm, ok := assetDIMM(key, kv); ok { + hw.Memory = append(hw.Memory, dimm) + } + case "PCIE": + if dev, ok := assetPCIe(kv); ok { + hw.PCIeDevices = append(hw.PCIeDevices, dev) + } + case "PSU": + if psu, ok := assetPSU(key, kv); ok { + hw.PowerSupply = append(hw.PowerSupply, psu) + } + } + } +} + +// parseAssetKVList splits "k:v, k:v" into a map. Values may contain parenthesised +// text and colons (e.g. "Model:Intel(R) Xeon(R) ... @ 2.90GHz"); only the first +// colon of each comma-separated segment is treated as the separator, and commas +// inside parentheses do not split. +func parseAssetKVList(s string) map[string]string { + out := make(map[string]string) + for _, seg := range splitTopLevelComma(s) { + seg = strings.TrimSpace(seg) + if seg == "" { + continue + } + // Most pairs use "k:v"; the PCIe bus address uses "B.D.F=x.y.z". + sep := ":" + if !strings.Contains(seg, ":") && strings.Contains(seg, "=") { + sep = "=" + } + k, v, ok := strings.Cut(seg, sep) + if !ok { + continue + } + out[strings.TrimSpace(k)] = strings.TrimSpace(v) + } + return out +} + +func splitTopLevelComma(s string) []string { + var out []string + depth := 0 + start := 0 + for i, r := range s { + switch r { + case '(', '[': + depth++ + case ')', ']': + if depth > 0 { + depth-- + } + case ',': + if depth == 0 { + out = append(out, s[start:i]) + start = i + 1 + } + } + } + out = append(out, s[start:]) + return out +} + +var ( + parenValueRe = regexp.MustCompile(`^([^(]*)\(([^)]*)\)`) + sizeGBRe = regexp.MustCompile(`(\d+)\s*GB`) + freqMHzRe = regexp.MustCompile(`(\d+)\s*MHz`) + wattRe = regexp.MustCompile(`(\d+)\s*W`) + ghzRe = regexp.MustCompile(`@\s*([\d.]+)\s*GHz`) + trailingIdx = regexp.MustCompile(`_(\d+)$`) +) + +// outerParen returns the text between the first "(" and the last ")" of v, so +// "3(PCIE X24 SLOT0(J99) CPU0)" yields "PCIE X24 SLOT0(J99) CPU0". +func outerParen(v string) string { + open := strings.Index(v, "(") + shut := strings.LastIndex(v, ")") + if open < 0 || shut < 0 || shut <= open+1 { + return "" + } + return strings.TrimSpace(v[open+1 : shut]) +} + +// splitParenValue splits "26(DDR4)" into ("26", "DDR4"). +func splitParenValue(v string) (before, inParen string) { + if m := parenValueRe.FindStringSubmatch(v); m != nil { + return strings.TrimSpace(m[1]), strings.TrimSpace(m[2]) + } + return strings.TrimSpace(v), "" +} + +func atoiSafe(s string) int { + n, _ := strconv.Atoi(strings.TrimSpace(s)) + return n +} + +// parseHexID parses "0x15B3" into 5555. +func parseHexID(s string) int { + s = strings.TrimSpace(s) + s = strings.TrimPrefix(strings.TrimPrefix(s, "0x"), "0X") + n, err := strconv.ParseInt(s, 16, 64) + if err != nil { + return 0 + } + return int(n) +} + +func assetCPU(key string, kv map[string]string) (models.CPU, bool) { + model := kv["Model"] + if model == "" { + return models.CPU{}, false + } + cpu := models.CPU{ + Socket: socketFromKey(key), + Model: model, + Cores: atoiSafe(kv["Core"]), + TDP: atoiSafe(strings.TrimSuffix(kv["TDP"], "W")), + } + if m := ghzRe.FindStringSubmatch(model); m != nil { + if ghz, err := strconv.ParseFloat(m[1], 64); err == nil { + cpu.FrequencyMHz = int(ghz * 1000) + } + } + return cpu, true +} + +func socketFromKey(key string) int { + if m := trailingIdx.FindStringSubmatch(key); m != nil { + return atoiSafe(m[1]) + } + return 0 +} + +func assetDIMM(key string, kv map[string]string) (models.MemoryDIMM, bool) { + sizeGB := 0 + if m := sizeGBRe.FindStringSubmatch(kv["Size"]); m != nil { + sizeGB = atoiSafe(m[1]) + } + if sizeGB == 0 { + return models.MemoryDIMM{}, false + } + _, memType := splitParenValue(kv["Type"]) + speed := 0 + if m := freqMHzRe.FindStringSubmatch(kv["Freq"]); m != nil { + speed = atoiSafe(m[1]) + } + // "MEM_0_CPU0_C0D0" -> slot "CPU0_C0D0", drop the "MEM__" prefix. + slot := key + if parts := strings.SplitN(key, "_", 3); len(parts) == 3 { + slot = parts[2] + } + return models.MemoryDIMM{ + Slot: slot, + Location: key, + Present: true, + SizeMB: sizeGB * 1024, + Type: memType, + MaxSpeedMHz: speed, + CurrentSpeedMHz: speed, + Manufacturer: kv["Manufacturer"], + PartNumber: kv["PN"], + SerialNumber: kv["SN"], + }, true +} + +func assetPCIe(kv map[string]string) (models.PCIeDevice, bool) { + if len(kv) == 0 { + return models.PCIeDevice{}, false + } + _, class := splitParenValue(kv["Type"]) + vendHex, vendName := splitParenValue(kv["VendorID"]) + devHex, devName := splitParenValue(kv["DevieID"]) // "DevieID" spelled as in source + if devName == "" { + _, devName = splitParenValue(kv["DeviceID"]) + devHex, _ = splitParenValue(kv["DeviceID"]) + } + widthNum, _ := splitParenValue(kv["Width"]) + _, speedName := splitParenValue(kv["Speed"]) + slotName := outerParen(kv["Slot"]) + if slotName == "" { + slotName = strings.TrimSpace(kv["Slot"]) + } + + dev := models.PCIeDevice{ + Slot: slotName, + DeviceClass: class, + Manufacturer: vendName, + Model: devName, + VendorID: parseHexID(vendHex), + DeviceID: parseHexID(devHex), + BDF: bdfFromDecimal(kv["B.D.F"]), + LinkWidth: atoiSafe(widthNum), + LinkSpeed: speedName, + } + if dev.DeviceClass == "" && dev.BDF == "" && dev.Model == "" { + return models.PCIeDevice{}, false + } + return dev, true +} + +// bdfFromDecimal converts the AssetInfo "bus.dev.func" decimal triple into the +// canonical hex "0000:bb:dd.f" BDF. Domain is not reported and assumed 0. +func bdfFromDecimal(s string) string { + parts := strings.Split(strings.TrimSpace(s), ".") + if len(parts) != 3 { + return "" + } + bus := atoiSafe(parts[0]) + dev := atoiSafe(parts[1]) + fn := atoiSafe(parts[2]) + return strings.ToLower( + zeroPadHex(0, 4) + ":" + zeroPadHex(bus, 2) + ":" + zeroPadHex(dev, 2) + "." + strconv.Itoa(fn), + ) +} + +func zeroPadHex(n, width int) string { + h := strconv.FormatInt(int64(n), 16) + for len(h) < width { + h = "0" + h + } + return h +} + +func assetPSU(key string, kv map[string]string) (models.PSU, bool) { + model := kv["Model"] + vendor := kv["Manufacturer"] + if model == "" && vendor == "" { + return models.PSU{}, false + } + watt := 0 + if m := wattRe.FindStringSubmatch(kv["RatedPower"]); m != nil { + watt = atoiSafe(m[1]) + } + slot := strings.ReplaceAll(key, "_", "") + return models.PSU{ + Slot: slot, + Present: true, + Model: model, + Vendor: vendor, + SerialNumber: kv["SN"], + PartNumber: kv["PN"], + Firmware: kv["FW"], + WattageW: watt, + }, true +} diff --git a/internal/parser/vendors/inspur_legacy/asset_inventory_test.go b/internal/parser/vendors/inspur_legacy/asset_inventory_test.go new file mode 100644 index 0000000..de5cebf --- /dev/null +++ b/internal/parser/vendors/inspur_legacy/asset_inventory_test.go @@ -0,0 +1,86 @@ +package inspurlegacy + +import ( + "testing" + + "git.mchus.pro/mchus/logpile/internal/models" +) + +const sampleAssetInventory = `[CPU] +CPU_0: Model:Intel(R) Xeon(R) Gold 6226R CPU @ 2.90GHz, ID:0x50657, MicroCode:0x5003604, Core:16, UsedCore:16, TDP:150W +CPU_1: Model:Intel(R) Xeon(R) Gold 6226R CPU @ 2.90GHz, ID:0x50657, MicroCode:0x5003604, Core:16, UsedCore:16, TDP:150W + +[Memory] +MEM_0_CPU0_C0D0: Size:64GB, Freq:2933MHz, Type:26(DDR4), Manufacturer:Samsung, PN:M393A8G40AB2-CVF, SN:H0K100010444ECD8BC + +[PCIE] +PCIE_2_INFO: B.D.F=60.0.0, Type:2(Network Controller), Slot:3(PCIE X24 SLOT0(J99) CPU0), RiserType:1(RiserType2-X8+X16), RiserLocation:0(Up), VendorID:0x15B3(Mellanox Technologies), DevieID:0x1017(MT27800 Family [ConnectX-5]), Width:8(X8), Speed:3(GEN3) + +[PSU] +PSU_0: Manufacturer:Great Wall, Model:CRPS1300D, PN:V03103S000000000, SN:2K11C224661, FW:1.050, RatedPower:1300W +` + +func TestParseAssetInfoInventory(t *testing.T) { + hw := &models.HardwareConfig{} + ParseAssetInfoInventory([]byte(sampleAssetInventory), hw) + + if len(hw.CPUs) != 2 { + t.Fatalf("cpus = %d", len(hw.CPUs)) + } + if hw.CPUs[1].Socket != 1 || hw.CPUs[0].Cores != 16 || hw.CPUs[0].TDP != 150 { + t.Errorf("cpu0 = %+v cpu1.socket=%d", hw.CPUs[0], hw.CPUs[1].Socket) + } + if hw.CPUs[0].FrequencyMHz != 2900 { + t.Errorf("cpu freq = %d", hw.CPUs[0].FrequencyMHz) + } + + if len(hw.Memory) != 1 { + t.Fatalf("dimms = %d", len(hw.Memory)) + } + d := hw.Memory[0] + if d.Slot != "CPU0_C0D0" || d.SizeMB != 65536 || d.Type != "DDR4" || d.MaxSpeedMHz != 2933 { + t.Errorf("dimm = %+v", d) + } + if d.Manufacturer != "Samsung" || d.PartNumber != "M393A8G40AB2-CVF" || d.SerialNumber != "H0K100010444ECD8BC" { + t.Errorf("dimm identity = %+v", d) + } + + if len(hw.PCIeDevices) != 1 { + t.Fatalf("pcie = %d", len(hw.PCIeDevices)) + } + p := hw.PCIeDevices[0] + if p.Slot != "PCIE X24 SLOT0(J99) CPU0" { + t.Errorf("pcie slot = %q", p.Slot) + } + if p.BDF != "0000:3c:00.0" { + t.Errorf("pcie bdf = %q", p.BDF) + } + if p.VendorID != 0x15B3 || p.DeviceID != 0x1017 { + t.Errorf("pcie ids = %d/%d", p.VendorID, p.DeviceID) + } + if p.DeviceClass != "Network Controller" || p.Manufacturer != "Mellanox Technologies" { + t.Errorf("pcie class/mfr = %q/%q", p.DeviceClass, p.Manufacturer) + } + if p.LinkWidth != 8 || p.LinkSpeed != "GEN3" { + t.Errorf("pcie link = %d/%q", p.LinkWidth, p.LinkSpeed) + } + + if len(hw.PowerSupply) != 1 { + t.Fatalf("psu = %d", len(hw.PowerSupply)) + } + s := hw.PowerSupply[0] + if s.Slot != "PSU0" || s.Model != "CRPS1300D" || s.Vendor != "Great Wall" || s.WattageW != 1300 { + t.Errorf("psu = %+v", s) + } + if s.SerialNumber != "2K11C224661" || s.Firmware != "1.050" { + t.Errorf("psu identity = %+v", s) + } +} + +func TestParseAssetInfoInventory_Empty(t *testing.T) { + hw := &models.HardwareConfig{} + ParseAssetInfoInventory(nil, hw) + if len(hw.CPUs) != 0 || len(hw.Memory) != 0 { + t.Error("expected nothing parsed from empty input") + } +} diff --git a/internal/parser/vendors/inspur_legacy/blackbox.go b/internal/parser/vendors/inspur_legacy/blackbox.go new file mode 100644 index 0000000..580210a --- /dev/null +++ b/internal/parser/vendors/inspur_legacy/blackbox.go @@ -0,0 +1,98 @@ +package inspurlegacy + +import ( + "regexp" + "strings" + "time" + + "git.mchus.pro/mchus/logpile/internal/models" +) + +var blackboxLineRe = regexp.MustCompile(`^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]\s*:\s*(.*)$`) + +const blackboxTimeLayout = "2006-01-02 15:04:05" + +// blackboxCritical / blackboxWarning classify the free-text message. +var ( + blackboxCritical = []string{"fault", "error", "under voltage protection", "power good detect pin changed from 1 to 0", "critical"} + blackboxWarning = []string{"warning", "predfail", "changed from 0 to 1"} +) + +// ParseBlackbox parses blackbox.log ("[YYYY-MM-DD HH:MM:SS] : message"), the +// BMC's persistent power/thermal/RAID fault ring buffer. +func ParseBlackbox(content []byte) []models.Event { + var events []models.Event + seq := 0 + for _, line := range strings.Split(string(content), "\n") { + line = strings.TrimSpace(line) + m := blackboxLineRe.FindStringSubmatch(line) + if m == nil { + continue + } + ts, err := time.Parse(blackboxTimeLayout, m[1]) + if err != nil || bogusYear(ts) { + continue + } + msg := strings.TrimSpace(m[2]) + msg = strings.TrimPrefix(msg, "[*]") + seq++ + + events = append(events, models.Event{ + ID: "blackbox_" + ts.Format("20060102T150405") + "_" + itoa(seq), + Timestamp: ts, + Source: "BMC/blackbox", + SensorType: "blackbox", + SensorName: blackboxSubject(msg), + Severity: classify(msg, blackboxCritical, blackboxWarning), + Description: msg, + RawData: line, + }) + } + return events +} + +var blackboxSubjectRe = regexp.MustCompile(`^(PSU-?\d+|psu \d+|RAID|raid\s*\d+|CPU-?\d+|FAN-?\d+)`) + +func blackboxSubject(msg string) string { + if m := blackboxSubjectRe.FindString(msg); m != "" { + return strings.TrimSpace(m) + } + return "blackbox" +} + +func classify(msg string, critical, warning []string) models.Severity { + low := strings.ToLower(msg) + for _, p := range critical { + if strings.Contains(low, p) { + return models.SeverityCritical + } + } + for _, p := range warning { + if strings.Contains(low, p) { + return models.SeverityWarning + } + } + return models.SeverityInfo +} + +func itoa(i int) string { + if i == 0 { + return "0" + } + neg := i < 0 + if neg { + i = -i + } + var b [20]byte + pos := len(b) + for i > 0 { + pos-- + b[pos] = byte('0' + i%10) + i /= 10 + } + if neg { + pos-- + b[pos] = '-' + } + return string(b[pos:]) +} diff --git a/internal/parser/vendors/inspur_legacy/event_logs_test.go b/internal/parser/vendors/inspur_legacy/event_logs_test.go new file mode 100644 index 0000000..ee1b0ae --- /dev/null +++ b/internal/parser/vendors/inspur_legacy/event_logs_test.go @@ -0,0 +1,115 @@ +package inspurlegacy + +import ( + "testing" + + "git.mchus.pro/mchus/logpile/internal/models" +) + +func TestParseIDLEvents(t *testing.T) { + in := `1970-01-01T08:00:17|BIOS BOOT|Info|Asssert|1FFF06F0|OS Event System boot completed - boot device not specified +2026-05-23T08:16:41|PSU|Critical|Asssert|080053F2|PSU0 unit input off for insufficient input voltage.. +2026-08-31T17:14:42|PCIE|Warning|Asssert|17FF01F1|Disk error Predictive Failure asserted +` + events := ParseIDLEvents([]byte(in)) + if len(events) != 2 { + t.Fatalf("events = %d (1970 line must be dropped)", len(events)) + } + if events[0].Severity != models.SeverityCritical || events[0].SensorName != "PSU" { + t.Errorf("event0 = %+v", events[0]) + } + if events[0].Description != "PSU0 unit input off for insufficient input voltage" { + t.Errorf("trailing dots not trimmed: %q", events[0].Description) + } + if events[1].Severity != models.SeverityWarning { + t.Errorf("event1 severity = %s", events[1].Severity) + } +} + +func TestParseSELLog(t *testing.T) { + in := ` f | Pre-Init Time-stamp | Button Power_Button | Power Button pressed | Asserted + e34 | 05/23/2026 | 08:16:41 | Power Supply PSU0_Status | Power Supply AC lost | Asserted + e36 | 05/23/2026 | 16:28:35 | Power Supply PSU0_Status | Power Supply AC lost | Deasserted + e45 | 08/31/2026 | 17:14:42 | Add-in Card RAID0_Status | Predictive Failure Asserted +` + events := ParseSELLog([]byte(in)) + if len(events) != 3 { + t.Fatalf("events = %d (pre-init line must be skipped)", len(events)) + } + if events[0].Severity != models.SeverityCritical { + t.Errorf("AC lost assert should be critical: %+v", events[0]) + } + if events[1].Severity != models.SeverityInfo { + t.Errorf("deassert should be info: %+v", events[1]) + } + if events[2].Severity != models.SeverityWarning { + t.Errorf("predictive failure should be warning: %+v", events[2]) + } +} + +func TestParseBlackbox(t *testing.T) { + in := `[1970-01-01 08:00:16] : [*]Power restore after AC power on. +[2026-05-23 08:16:41] : PSU-0 Fault: Input Under Voltage Protection, StatusWord=0x2848 +` + events := ParseBlackbox([]byte(in)) + if len(events) != 1 { + t.Fatalf("events = %d", len(events)) + } + if events[0].Severity != models.SeverityCritical || events[0].SensorName != "PSU-0" { + t.Errorf("event = %+v", events[0]) + } +} + +func TestParseMegaRAIDLog(t *testing.T) { + in := `Event Sequence Number : 47100 +Timestamp : 8/31/2026 ; 11:30:49 +Event code : 93 +Locale : PD event +Class : Information +Description of the event : Patrol Read corrected medium error on PD 16(e0x2e/s37) at 3a0e3b4a0 + +Event Sequence Number : 47200 +Timestamp : 466920 Seconds +Class : Information +Description of the event : Controller cache discarded +` + events := ParseMegaRAIDLog([]byte(in), "raid0.log") + if len(events) != 1 { + t.Fatalf("events = %d (non-wall-clock timestamp must be dropped)", len(events)) + } + if events[0].Severity != models.SeverityWarning { + t.Errorf("medium error should escalate to warning: %+v", events[0]) + } + if events[0].Source != "RAID/raid0" { + t.Errorf("source = %q", events[0].Source) + } +} + +func TestParseAMISyslog(t *testing.T) { + in := `<1> 1970-01-01T08:01:08.130000+08:00 localhost kernel: [ 6.350000] Helper Module Driver Version 1.2 +<9> 2026-03-03T09:10:00.000000+03:00 localhost IPMIMain: CPU0 changed from NULL to Xeon +<27> 2026-07-09T02:29:25.700000+03:00 localhost dhcp6c[957]: client6_send: transmit failed: Network is unreachable +<3> 2026-07-09T03:02:02.330000+03:00 localhost kernel: i2c i2c-4: send_bytes: Timed out sending data +` + events := ParseAMISyslog([]byte(in), "err.log") + if len(events) != 3 { + t.Fatalf("events = %d (1970 line dropped)", len(events)) + } + if events[0].Severity != models.SeverityInfo { + t.Errorf("provisioning notice should be downgraded to info: %s", events[0].Severity) + } + if events[1].SensorName != "dhcp6c[957]" { + t.Errorf("proc = %q", events[1].SensorName) + } + if events[2].Severity != models.SeverityWarning { + t.Errorf("pri 3 (err) -> %s, want warning", events[2].Severity) + } +} + +func TestDedupeEvents(t *testing.T) { + e := models.Event{Source: "BMC/SEL", Description: "x"} + out := dedupeEvents([]models.Event{e, e, {Source: "BMC/IDL", Description: "x"}}) + if len(out) != 2 { + t.Fatalf("dedupe kept %d", len(out)) + } +} diff --git a/internal/parser/vendors/inspur_legacy/events.go b/internal/parser/vendors/inspur_legacy/events.go new file mode 100644 index 0000000..4e27568 --- /dev/null +++ b/internal/parser/vendors/inspur_legacy/events.go @@ -0,0 +1,54 @@ +package inspurlegacy + +import ( + "sort" + "strings" + "time" + + "git.mchus.pro/mchus/logpile/internal/models" +) + +// bogusYear reports whether a timestamp comes from an AMI BMC clock that had +// not yet been set: the 1970/1971 epoch, or the ~2005 firmware-default RTC seen +// in alert.log. This hardware shipped no earlier than 2018, so any event before +// 2010 has no trustworthy wall-clock time. +func bogusYear(t time.Time) bool { return t.Year() < 2010 } + +// severityFromWord maps a free-text severity/class word to models.Severity. +func severityFromWord(s string) models.Severity { + switch strings.ToLower(strings.TrimSpace(s)) { + case "critical", "error", "fatal", "serious", "nonrecoverable", "non-recoverable": + return models.SeverityCritical + case "warning", "warn", "predictive": + return models.SeverityWarning + default: + return models.SeverityInfo + } +} + +// dedupeEvents removes exact duplicates (same timestamp, source, description) +// in place, preserving order. The same fault is often reported by both the IDL +// log and sel.log. +func dedupeEvents(events []models.Event) []models.Event { + seen := make(map[string]struct{}, len(events)) + out := events[:0] + for _, e := range events { + key := e.Timestamp.Format(time.RFC3339) + "|" + e.Source + "|" + e.SensorName + "|" + e.Description + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, e) + } + return out +} + +// sortEvents orders events chronologically, then by source for stability. +func sortEvents(events []models.Event) { + sort.SliceStable(events, func(i, j int) bool { + if !events[i].Timestamp.Equal(events[j].Timestamp) { + return events[i].Timestamp.Before(events[j].Timestamp) + } + return events[i].Source < events[j].Source + }) +} diff --git a/internal/parser/vendors/inspur_legacy/fru.go b/internal/parser/vendors/inspur_legacy/fru.go new file mode 100644 index 0000000..1487756 --- /dev/null +++ b/internal/parser/vendors/inspur_legacy/fru.go @@ -0,0 +1,268 @@ +package inspurlegacy + +import ( + "strings" + "time" + + "git.mchus.pro/mchus/logpile/internal/models" +) + +// binaryFRU holds the fields decoded from a binary IPMI FRU image (FRU.bin). +type binaryFRU struct { + ChassisType string + ChassisPart string + ChassisSerial string + + BoardManufacturer string + BoardProduct string + BoardSerial string + BoardPart string + BoardMfgDate string + + ProductManufacturer string + ProductName string + ProductPart string + ProductVersion string + ProductSerial string + ProductAssetTag string +} + +// chassisTypeNames maps IPMI/SMBIOS chassis type codes to names. Only codes seen +// on Inspur rack servers are listed; unknown codes fall back to the raw number. +var chassisTypeNames = map[byte]string{ + 0x03: "Desktop", + 0x11: "Main Server Chassis", + 0x17: "Rack Mount Chassis", + 0x1C: "Blade", + 0x1D: "Blade Enclosure", +} + +// fruEpoch is the IPMI FRU manufacturing-date epoch (1996-01-01 00:00 UTC). +var fruEpoch = time.Date(1996, 1, 1, 0, 0, 0, 0, time.UTC) + +// DecodeBinaryFRU parses a binary IPMI FRU image. It returns false when the +// common header is missing or no area yields any field. +func DecodeBinaryFRU(data []byte) (binaryFRU, bool) { + var fru binaryFRU + if len(data) < 8 || data[0] != 0x01 { + return fru, false + } + // Common-header area offsets are stored in 8-byte units. + chassisOff := int(data[2]) * 8 + boardOff := int(data[3]) * 8 + productOff := int(data[4]) * 8 + + if chassisOff >= 3 { + decodeChassisArea(data, chassisOff, &fru) + } + if boardOff >= 3 { + decodeBoardArea(data, boardOff, &fru) + } + if productOff >= 3 { + decodeProductArea(data, productOff, &fru) + } + + if fru == (binaryFRU{}) { + return fru, false + } + return fru, true +} + +// areaEnd returns the exclusive end offset of an info area beginning at off, +// clamped to the buffer. ok is false when the area header is invalid. +func areaEnd(data []byte, off int) (end int, ok bool) { + if off < 0 || off+2 > len(data) || data[off] != 0x01 { + return 0, false + } + end = off + int(data[off+1])*8 + if end <= off || end > len(data) { + end = len(data) + } + return end, true +} + +// readField decodes one IPMI type/length-encoded field starting at pos. +// done is true at the 0xC1 end marker or when the buffer is exhausted. +func readField(data []byte, pos, end int) (value string, next int, done bool) { + if pos >= end || pos >= len(data) { + return "", pos, true + } + tl := data[pos] + // Note: 0xC1 is the IPMI "end of fields" sentinel, but this Inspur FRU also + // uses 0xC1 to encode legitimate single-character type-3 strings (the "0" + // placeholders). Terminate only on area end, zero padding or 0xFF instead. + if tl == 0x00 || tl == 0xFF { + return "", pos + 1, true + } + typ := tl >> 6 + length := int(tl & 0x3F) + if pos+1+length > end || pos+1+length > len(data) { + return "", end, true + } + raw := data[pos+1 : pos+1+length] + next = pos + 1 + length + + switch typ { + case 0x03, 0x00: // 8-bit ASCII+Latin1, or unspecified/binary treated as text + return strings.TrimSpace(string(raw)), next, false + case 0x02: // 6-bit packed ASCII + return decode6bitASCII(raw), next, false + case 0x01: // BCD plus + return decodeBCDPlus(raw), next, false + default: + return "", next, false + } +} + +func decode6bitASCII(raw []byte) string { + var b strings.Builder + var acc uint32 + var bits uint + for _, by := range raw { + acc |= uint32(by) << bits + bits += 8 + for bits >= 6 { + b.WriteByte(byte(acc&0x3F) + 0x20) + acc >>= 6 + bits -= 6 + } + } + return strings.TrimSpace(b.String()) +} + +func decodeBCDPlus(raw []byte) string { + const digits = "0123456789 -. " // 0xA space, 0xB dash, 0xC dot + var b strings.Builder + for _, by := range raw { + hi, lo := by>>4, by&0x0F + if int(hi) < len(digits) { + b.WriteByte(digits[hi]) + } + if int(lo) < len(digits) { + b.WriteByte(digits[lo]) + } + } + return strings.TrimSpace(b.String()) +} + +// fieldList reads consecutive type/length fields from start up to the area end +// or the 0xC1 marker. Fixed-position fields map to slots by index; trailing +// custom fields are ignored. +func fieldList(data []byte, start, end int) []string { + var out []string + pos := start + for { + v, next, done := readField(data, pos, end) + if done { + break + } + out = append(out, v) + pos = next + if len(out) > 16 { + break + } + } + return out +} + +func at(list []string, i int) string { + if i < len(list) { + return list[i] + } + return "" +} + +func decodeChassisArea(data []byte, off int, fru *binaryFRU) { + end, ok := areaEnd(data, off) + if !ok || off+3 > len(data) { + return + } + if name, known := chassisTypeNames[data[off+2]]; known { + fru.ChassisType = name + } + f := fieldList(data, off+3, end) + fru.ChassisPart = at(f, 0) + fru.ChassisSerial = at(f, 1) +} + +func decodeBoardArea(data []byte, off int, fru *binaryFRU) { + end, ok := areaEnd(data, off) + if !ok || off+6 > len(data) { + return + } + mins := int(data[off+3]) | int(data[off+4])<<8 | int(data[off+5])<<16 + if mins > 0 { + fru.BoardMfgDate = fruEpoch.Add(time.Duration(mins) * time.Minute).Format("2006-01-02") + } + f := fieldList(data, off+6, end) + fru.BoardManufacturer = at(f, 0) + fru.BoardProduct = at(f, 1) + fru.BoardSerial = at(f, 2) + fru.BoardPart = at(f, 3) +} + +func decodeProductArea(data []byte, off int, fru *binaryFRU) { + end, ok := areaEnd(data, off) + if !ok || off+3 > len(data) { + return + } + f := fieldList(data, off+3, end) + fru.ProductManufacturer = at(f, 0) + fru.ProductName = at(f, 1) + fru.ProductPart = at(f, 2) + fru.ProductVersion = at(f, 3) + fru.ProductSerial = at(f, 4) + fru.ProductAssetTag = at(f, 5) +} + +// placeholder reports whether a decoded FRU value is an empty-ish placeholder +// ("", "0", "NULL", "N/A") that must not overwrite real data. +func placeholder(s string) bool { + switch strings.ToUpper(strings.TrimSpace(s)) { + case "", "0", "NULL", "N/A", "NONE", "TO BE FILLED BY O.E.M.": + return true + } + return false +} + +func firstReal(vals ...string) string { + for _, v := range vals { + if !placeholder(v) { + return strings.TrimSpace(v) + } + } + return "" +} + +// toFRUInfo renders the decoded image as a single builtin FRU record. +func (f binaryFRU) toFRUInfo() models.FRUInfo { + return models.FRUInfo{ + Description: "Builtin FRU Device (ID 0)", + ChassisType: f.ChassisType, + Manufacturer: firstReal(f.ProductManufacturer, f.BoardManufacturer), + ProductName: firstReal(f.ProductName, f.BoardProduct), + SerialNumber: firstReal(f.ProductSerial, f.BoardSerial, f.ChassisSerial), + PartNumber: firstReal(f.ProductPart, f.BoardPart, f.ChassisPart), + Version: firstReal(f.ProductVersion), + AssetTag: firstReal(f.ProductAssetTag), + MfgDate: f.BoardMfgDate, + } +} + +// applyToBoardInfo fills empty BoardInfo identity fields. The product area holds +// the operator-facing system serial; the board area holds the PCB serial and, +// on this hardware, the only real motherboard part number. +func (f binaryFRU) applyToBoardInfo(b *models.BoardInfo) { + if b.Manufacturer == "" { + b.Manufacturer = firstReal(f.ProductManufacturer, f.BoardManufacturer) + } + if b.ProductName == "" { + b.ProductName = firstReal(f.ProductName, f.BoardProduct) + } + if b.SerialNumber == "" { + b.SerialNumber = firstReal(f.ProductSerial, f.BoardSerial) + } + if b.PartNumber == "" { + b.PartNumber = firstReal(f.BoardPart, f.ProductPart) + } +} diff --git a/internal/parser/vendors/inspur_legacy/fru_test.go b/internal/parser/vendors/inspur_legacy/fru_test.go new file mode 100644 index 0000000..fd10a65 --- /dev/null +++ b/internal/parser/vendors/inspur_legacy/fru_test.go @@ -0,0 +1,104 @@ +package inspurlegacy + +import ( + "testing" + + "git.mchus.pro/mchus/logpile/internal/models" +) + +// buildFRU assembles a minimal but spec-shaped IPMI FRU image with a chassis, +// board and product area. Single-char fields are encoded as 0xC1 (type 3, +// length 1) exactly as the real Inspur image does. +func buildFRU() []byte { + tl3 := func(s string) []byte { return append([]byte{byte(0xC0 | len(s))}, []byte(s)...) } + + chassis := []byte{0x01, 0x00, 0x17} // version, len placeholder, Rack Mount + chassis = append(chassis, tl3("0")...) + chassis = append(chassis, tl3("0")...) + chassis = pad8(chassis) + chassis[1] = byte(len(chassis) / 8) + + board := []byte{0x01, 0x00, 0x00, 0x00, 0x00, 0x00} // v, len, lang, 3-byte date + board = append(board, tl3("Inspur")...) + board = append(board, tl3("NF5280M5")...) + board = append(board, tl3("MBL911S22243C30")...) + board = append(board, tl3("YZMB-00882-104")...) + board = pad8(board) + board[1] = byte(len(board) / 8) + + product := []byte{0x01, 0x00, 0x00} // v, len, lang + product = append(product, tl3("Inspur")...) + product = append(product, tl3("NF5466M5")...) + product = append(product, tl3("0")...) // part placeholder + product = append(product, tl3("0")...) // version placeholder + product = append(product, tl3("221353113")...) + product = append(product, tl3("0")...) // asset tag placeholder + product = pad8(product) + product[1] = byte(len(product) / 8) + + header := make([]byte, 8) + header[0] = 0x01 + header[2] = 1 // chassis at 8 + header[3] = byte((8 + len(chassis)) / 8) // board + header[4] = byte((8 + len(chassis) + len(board)) / 8) + + out := append(header, chassis...) + out = append(out, board...) + out = append(out, product...) + return out +} + +func pad8(b []byte) []byte { + for len(b)%8 != 0 { + b = append(b, 0x00) + } + return b +} + +func TestDecodeBinaryFRU_Identity(t *testing.T) { + fru, ok := DecodeBinaryFRU(buildFRU()) + if !ok { + t.Fatal("decode failed") + } + if fru.ProductName != "NF5466M5" { + t.Errorf("product name = %q", fru.ProductName) + } + if fru.ProductSerial != "221353113" { + t.Errorf("product serial = %q", fru.ProductSerial) + } + if fru.BoardPart != "YZMB-00882-104" { + t.Errorf("board part = %q", fru.BoardPart) + } + if fru.ChassisType != "Rack Mount Chassis" { + t.Errorf("chassis type = %q", fru.ChassisType) + } +} + +func TestBinaryFRU_ApplyToBoardInfo_PrefersProductSerialAndBoardPart(t *testing.T) { + fru, _ := DecodeBinaryFRU(buildFRU()) + + var b models.BoardInfo + fru.applyToBoardInfo(&b) + if b.SerialNumber != "221353113" { + t.Errorf("board serial = %q, want operator-facing product serial", b.SerialNumber) + } + if b.PartNumber != "YZMB-00882-104" { + t.Errorf("board part = %q, want board part (product part is placeholder)", b.PartNumber) + } + if b.Manufacturer != "Inspur" { + t.Errorf("board manufacturer = %q", b.Manufacturer) + } + + // An already-populated field is never overwritten. + pre := models.BoardInfo{SerialNumber: "EXISTING"} + fru.applyToBoardInfo(&pre) + if pre.SerialNumber != "EXISTING" { + t.Errorf("existing serial overwritten: %q", pre.SerialNumber) + } +} + +func TestDecodeBinaryFRU_RejectsGarbage(t *testing.T) { + if _, ok := DecodeBinaryFRU([]byte{0x00, 0x00}); ok { + t.Error("expected decode to fail on short/garbage input") + } +} diff --git a/internal/parser/vendors/inspur_legacy/idl.go b/internal/parser/vendors/inspur_legacy/idl.go new file mode 100644 index 0000000..e697b72 --- /dev/null +++ b/internal/parser/vendors/inspur_legacy/idl.go @@ -0,0 +1,61 @@ +package inspurlegacy + +import ( + "strings" + "time" + + "git.mchus.pro/mchus/logpile/internal/models" +) + +// idlTimeLayout is the naive local timestamp used by the legacy IDL log; it +// carries no timezone offset. +const idlTimeLayout = "2006-01-02T15:04:05" + +// ParseIDLEvents parses the "Inspur___IDL" event log. Each line +// is "timestamp|component|severity|assertion|code|description". +func ParseIDLEvents(content []byte) []models.Event { + var events []models.Event + for _, line := range strings.Split(string(content), "\n") { + line = strings.TrimRight(line, "\r ") + if line == "" { + continue + } + parts := strings.Split(line, "|") + if len(parts) < 6 { + continue + } + ts, err := time.Parse(idlTimeLayout, strings.TrimSpace(parts[0])) + if err != nil { + continue + } + component := strings.TrimSpace(parts[1]) + severityWord := strings.TrimSpace(parts[2]) + assertion := strings.TrimSpace(parts[3]) + code := strings.TrimSpace(parts[4]) + desc := strings.TrimSpace(strings.Join(parts[5:], "|")) + desc = strings.TrimRight(desc, ".") + + if bogusYear(ts) { + // Pre-NTP boot spam with no real wall-clock time. + continue + } + + severity := severityFromWord(severityWord) + if strings.Contains(strings.ToLower(assertion), "deassert") { + severity = models.SeverityInfo + } + + events = append(events, models.Event{ + ID: "idl_" + code + "_" + ts.Format("20060102T150405"), + Timestamp: ts, + Source: "BMC/IDL", + SensorType: strings.ToLower(component), + SensorName: component, + EventType: assertion, + Severity: severity, + Description: desc, + RawData: line, + }) + } + return events +} diff --git a/internal/parser/vendors/inspur_legacy/megaraid.go b/internal/parser/vendors/inspur_legacy/megaraid.go new file mode 100644 index 0000000..02a6dba --- /dev/null +++ b/internal/parser/vendors/inspur_legacy/megaraid.go @@ -0,0 +1,123 @@ +package inspurlegacy + +import ( + "regexp" + "strings" + "time" + + "git.mchus.pro/mchus/logpile/internal/models" +) + +const megaraidTimeLayout = "1/2/2006 15:04:05" + +var megaraidDescWarning = []string{ + "medium error", "unexpected sense", "error on pd", "patrol read", "puncturing", + "rebuild", "copyback", "reassign", +} +var megaraidDescCritical = []string{ + "failed", "offline", "predictive failure", "not responding", "removed", + "degraded", "punctured", "bad block", +} + +var megaraidSeqRe = regexp.MustCompile(`^(?:Event Sequence Number|r)\s*:\s*(\d+)`) + +// ParseMegaRAIDLog parses a MegaRAID controller event log (raid0.log). Records +// are separated by blank lines; each carries "Timestamp : M/D/YYYY ; HH:MM:SS", +// a Class, and a "Description of the event" line. +func ParseMegaRAIDLog(content []byte, source string) []models.Event { + var events []models.Event + var cur map[string]string + flush := func() { + if cur == nil { + return + } + if e, ok := megaraidEvent(cur, source); ok { + events = append(events, e) + } + cur = nil + } + + for _, line := range strings.Split(string(content), "\n") { + line = strings.TrimRight(line, "\r") + trimmed := strings.TrimSpace(line) + if trimmed == "" { + flush() + continue + } + if megaraidSeqRe.MatchString(trimmed) { + flush() + cur = make(map[string]string) + } + if cur == nil { + cur = make(map[string]string) + } + k, v, ok := strings.Cut(trimmed, ":") + if !ok { + continue + } + key := strings.TrimSpace(k) + if _, exists := cur[key]; !exists { + cur[key] = strings.TrimSpace(v) + } + } + flush() + return events +} + +func megaraidEvent(m map[string]string, source string) (models.Event, bool) { + desc := m["Description of the event"] + if desc == "" { + return models.Event{}, false + } + ts, ok := parseMegaRAIDTimestamp(m["Timestamp"]) + if !ok || bogusYear(ts) { + return models.Event{}, false + } + + severity := severityFromWord(m["Class"]) + low := strings.ToLower(desc) + for _, p := range megaraidDescCritical { + if strings.Contains(low, p) { + severity = models.SeverityCritical + } + } + if severity == models.SeverityInfo { + for _, p := range megaraidDescWarning { + if strings.Contains(low, p) { + severity = models.SeverityWarning + } + } + } + + seq := m["Event Sequence Number"] + if seq == "" { + seq = m["r"] + } + return models.Event{ + ID: "megaraid_" + source + "_" + seq, + Timestamp: ts, + Source: "RAID/" + strings.TrimSuffix(source, ".log"), + SensorType: "raid", + SensorName: strings.TrimSpace(m["Locale"]), + EventType: strings.TrimSpace(m["Event code"]), + Severity: severity, + Description: desc, + }, true +} + +// parseMegaRAIDTimestamp accepts "M/D/YYYY ; HH:MM:SS". Non-wall-clock forms +// ("... Seconds", "Not Present") are rejected. +func parseMegaRAIDTimestamp(s string) (time.Time, bool) { + s = strings.TrimSpace(s) + if !strings.Contains(s, ";") { + return time.Time{}, false + } + parts := strings.SplitN(s, ";", 2) + datePart := strings.TrimSpace(parts[0]) + timePart := strings.TrimSpace(parts[1]) + ts, err := time.Parse(megaraidTimeLayout, datePart+" "+timePart) + if err != nil { + return time.Time{}, false + } + return ts, true +} diff --git a/internal/parser/vendors/inspur_legacy/parser.go b/internal/parser/vendors/inspur_legacy/parser.go new file mode 100644 index 0000000..6165bc6 --- /dev/null +++ b/internal/parser/vendors/inspur_legacy/parser.go @@ -0,0 +1,191 @@ +// Package inspurlegacy parses the legacy Inspur "onekeylog" BMC diagnostic +// archive produced by older AMI-based Inspur BMCs (e.g. NF5466M5 / NF5280M5 +// generation). That format predates the Kaytus-style onekeylog handled by the +// internal/parser/vendors/inspur package: it has no asset.json, no +// devicefrusdr.log, no selelist.csv and no component.log. Instead hardware +// inventory lives in Inspur_AssetInfoInventory.log, FRU data in a binary +// FRU.bin, the event log in an Inspur___IDL text file plus a +// pipe-delimited sel.log, and BMC syslog in flat .log files at the archive +// root. +// +// IMPORTANT: bump parserVersion on any behavior change (see module-versioning). +package inspurlegacy + +import ( + "strings" + + "git.mchus.pro/mchus/logpile/internal/models" + "git.mchus.pro/mchus/logpile/internal/parser" +) + +// parserVersion follows the N.M module-versioning scheme. +const parserVersion = "1.0" + +func init() { + parser.Register(&Parser{}) +} + +// Parser implements parser.VendorParser for the legacy Inspur onekeylog format. +type Parser struct{} + +// Name returns a human-readable parser name. +func (p *Parser) Name() string { return "Inspur Legacy onekeylog BMC Parser" } + +// Vendor returns the parser identifier. +func (p *Parser) Vendor() string { return "inspur_legacy" } + +// Version returns the parser module version. +func (p *Parser) Version() string { return parserVersion } + +// DetectPriority makes this parser win a confidence tie against the broader +// Kaytus-style Inspur parser, which also matches anything under onekeylog/. +func (p *Parser) DetectPriority() int { return 10 } + +// modernMarkers are files that only appear in the newer Kaytus-style onekeylog. +// Their presence means the other Inspur parser should handle the archive. +var modernMarkers = []string{ + "asset.json", + "devicefrusdr.log", + "selelist.csv", + "component.log", + "onekeylog_dreport.log", +} + +// Detect returns a confidence score for the legacy onekeylog format. +func (p *Parser) Detect(files []parser.ExtractedFile) int { + confidence := 0 + for _, f := range files { + base := strings.ToLower(baseName(f.Path)) + for _, m := range modernMarkers { + if base == m { + return 0 + } + } + + switch { + case base == "inspur_assetinfoinventory.log": + confidence += 40 + case isLegacyIDLName(base): + confidence += 30 + case base == "fru.bin": + confidence += 15 + case base == "blackbox.log": + confidence += 10 + case base == "eepromdata.log": + confidence += 10 + case base == "sdr.dat": + confidence += 10 + case base == "sadtadreg.dat": + confidence += 5 + } + } + + if confidence > 100 { + return 100 + } + return confidence +} + +// Parse extracts events, FRU and hardware inventory from a legacy onekeylog. +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), + } + hw := &models.HardwareConfig{} + result.Hardware = hw + + if f := parser.FindFileByName(files, "FRU.bin"); f != nil { + if fru, ok := DecodeBinaryFRU(f.Content); ok { + result.FRU = append(result.FRU, fru.toFRUInfo()) + fru.applyToBoardInfo(&hw.BoardInfo) + } + } + + if f := parser.FindFileByName(files, "Inspur_AssetInfoInventory.log"); f != nil { + ParseAssetInfoInventory(f.Content, hw) + } + + // Event sources, most structured first. + for _, f := range findLegacyIDLFiles(files) { + result.Events = append(result.Events, ParseIDLEvents(f.Content)...) + } + if f := parser.FindFileByName(files, "sel.log"); f != nil { + result.Events = append(result.Events, ParseSELLog(f.Content)...) + } + if f := parser.FindFileByName(files, "blackbox.log"); f != nil { + result.Events = append(result.Events, ParseBlackbox(f.Content)...) + } + for _, f := range findRAIDLogFiles(files) { + result.Events = append(result.Events, ParseMegaRAIDLog(f.Content, baseName(f.Path))...) + } + for _, f := range findSyslogFiles(files) { + result.Events = append(result.Events, ParseAMISyslog(f.Content, baseName(f.Path))...) + } + + result.Events = dedupeEvents(result.Events) + sortEvents(result.Events) + + // This archive class carries no live sensor readings: SDR.dat holds only + // sensor definitions, and there is no "sensor list" capture. Record why the + // sensor set is empty instead of leaving it silently unexplained. + result.CollectionErrors = append(result.CollectionErrors, models.CollectionError{ + Section: "sensors", + Message: "legacy onekeylog carries no live sensor readings (SDR.dat holds definitions only, no sensor-list capture)", + }) + + return result, nil +} + +func baseName(path string) string { + path = strings.ReplaceAll(path, "\\", "/") + if i := strings.LastIndex(path, "/"); i >= 0 { + return path[i+1:] + } + return path +} + +// isLegacyIDLName matches the "Inspur___IDL" event-log filename +// (no extension), lower-cased. +func isLegacyIDLName(base string) bool { + return strings.HasPrefix(base, "inspur_") && strings.HasSuffix(base, "_idl") +} + +func findLegacyIDLFiles(files []parser.ExtractedFile) []parser.ExtractedFile { + var out []parser.ExtractedFile + for _, f := range files { + if isLegacyIDLName(strings.ToLower(baseName(f.Path))) { + out = append(out, f) + } + } + return out +} + +func findRAIDLogFiles(files []parser.ExtractedFile) []parser.ExtractedFile { + var out []parser.ExtractedFile + for _, f := range files { + base := strings.ToLower(baseName(f.Path)) + if strings.HasPrefix(base, "raid") && strings.HasSuffix(base, ".log") { + out = append(out, f) + } + } + return out +} + +// syslogFileNames are the flat AMI BMC severity logs at the archive root that +// carry fault-relevant records. debug.log and audit.log are intentionally left +// out: they are high-volume housekeeping noise, not diagnostics. +var syslogFileNames = map[string]struct{}{ + "emerg.log": {}, "alert.log": {}, "crit.log": {}, "err.log": {}, "warning.log": {}, +} + +func findSyslogFiles(files []parser.ExtractedFile) []parser.ExtractedFile { + var out []parser.ExtractedFile + for _, f := range files { + if _, ok := syslogFileNames[strings.ToLower(baseName(f.Path))]; ok { + out = append(out, f) + } + } + return out +} diff --git a/internal/parser/vendors/inspur_legacy/parser_test.go b/internal/parser/vendors/inspur_legacy/parser_test.go new file mode 100644 index 0000000..34f8bf3 --- /dev/null +++ b/internal/parser/vendors/inspur_legacy/parser_test.go @@ -0,0 +1,85 @@ +package inspurlegacy + +import ( + "testing" + + "git.mchus.pro/mchus/logpile/internal/parser" +) + +func f(path string, content string) parser.ExtractedFile { + return parser.ExtractedFile{Path: path, Content: []byte(content)} +} + +func TestDetect_LegacyOnekeylog(t *testing.T) { + p := &Parser{} + files := []parser.ExtractedFile{ + f("onekeylog/Inspur_AssetInfoInventory.log", sampleAssetInventory), + f("onekeylog/Inspur_NF5466M5_221353113_IDL", "2026-05-23T08:16:41|PSU|Critical|Asssert|080053F2|x"), + f("onekeylog/FRU.bin", ""), + f("onekeylog/blackbox.log", ""), + f("onekeylog/sel.log", ""), + } + if got := p.Detect(files); got < 80 { + t.Fatalf("confidence = %d, want high", got) + } +} + +func TestDetect_DisqualifiedByModernMarkers(t *testing.T) { + p := &Parser{} + files := []parser.ExtractedFile{ + f("onekeylog/Inspur_AssetInfoInventory.log", ""), + f("onekeylog/devicefrusdr.log", ""), + } + if got := p.Detect(files); got != 0 { + t.Fatalf("confidence = %d, want 0 (modern Kaytus dump)", got) + } +} + +func TestDetect_UnrelatedArchive(t *testing.T) { + p := &Parser{} + files := []parser.ExtractedFile{f("dump/redfish/Systems.json", "{}")} + if got := p.Detect(files); got != 0 { + t.Fatalf("confidence = %d, want 0", got) + } +} + +func TestParse_EndToEnd(t *testing.T) { + p := &Parser{} + files := []parser.ExtractedFile{ + f("onekeylog/FRU.bin", string(buildFRU())), + f("onekeylog/Inspur_AssetInfoInventory.log", sampleAssetInventory), + f("onekeylog/Inspur_NF5466M5_221353113_IDL", + "2026-05-23T08:16:41|PSU|Critical|Asssert|080053F2|PSU0 unit input off.."), + f("onekeylog/sel.log", + " e34 | 05/23/2026 | 08:16:41 | Power Supply PSU0_Status | Power Supply AC lost | Asserted"), + f("onekeylog/warning.log", + "<27> 2026-07-09T02:29:25.700000+03:00 localhost dhcp6c[957]: client6_send: transmit failed"), + } + + res, err := p.Parse(files) + if err != nil { + t.Fatal(err) + } + if res.Hardware.BoardInfo.SerialNumber != "221353113" { + t.Errorf("board serial = %q", res.Hardware.BoardInfo.SerialNumber) + } + if len(res.FRU) != 1 { + t.Errorf("fru = %d", len(res.FRU)) + } + if len(res.Hardware.CPUs) != 2 || len(res.Hardware.PowerSupply) != 1 { + t.Errorf("hw cpus=%d psu=%d", len(res.Hardware.CPUs), len(res.Hardware.PowerSupply)) + } + if len(res.Events) < 3 { + t.Errorf("events = %d", len(res.Events)) + } + // Sensors are legitimately empty for this archive class; the reason is recorded. + var haveSensorNote bool + for _, ce := range res.CollectionErrors { + if ce.Section == "sensors" { + haveSensorNote = true + } + } + if !haveSensorNote { + t.Error("missing sensors collection-error note") + } +} diff --git a/internal/parser/vendors/inspur_legacy/sel.go b/internal/parser/vendors/inspur_legacy/sel.go new file mode 100644 index 0000000..6fca8e4 --- /dev/null +++ b/internal/parser/vendors/inspur_legacy/sel.go @@ -0,0 +1,98 @@ +package inspurlegacy + +import ( + "strings" + "time" + + "git.mchus.pro/mchus/logpile/internal/models" +) + +const selTimeLayout = "01/02/2006 15:04:05" + +// criticalSELPhrases escalate an otherwise-Info SEL record to critical. +var criticalSELPhrases = []string{ + "ac lost", "power supply failure", "redundancy lost", "non-recoverable", + "uncorrectable", "input under voltage error", + "processor error", "thermal trip", "asserted - critical", +} + +// warningSELPhrases escalate an otherwise-Info SEL record to warning. +var warningSELPhrases = []string{ + "correctable", "warning", "degraded", "under voltage warning", + "redundancy degraded", "predictive failure", +} + +// ParseSELLog parses the pipe-delimited sel.log. Real records are +// "id | MM/DD/YYYY | HH:MM:SS | sensor | event | Asserted|Deasserted". +// "Pre-Init Time-stamp" records have no usable timestamp and are skipped. +func ParseSELLog(content []byte) []models.Event { + var events []models.Event + for _, line := range strings.Split(string(content), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + fields := splitTrim(line, "|") + // Real records have 5 or 6 columns: id | date | time | sensor | event + // [ | Asserted/Deasserted ]. The direction is folded into the event text + // on some records (e.g. "Predictive Failure Asserted"). + if len(fields) < 5 { + continue + } + date, tm := fields[1], fields[2] + if !strings.Contains(date, "/") { + continue // Pre-Init Time-stamp and similar + } + ts, err := time.Parse(selTimeLayout, date+" "+tm) + if err != nil || bogusYear(ts) { + continue + } + sensor := fields[3] + event := fields[4] + direction := "" + if len(fields) >= 6 { + direction = fields[5] + } + + severity := selSeverity(event, direction) + + events = append(events, models.Event{ + ID: "sel_" + strings.TrimSpace(fields[0]), + Timestamp: ts, + Source: "BMC/SEL", + SensorType: "sel", + SensorName: sensor, + EventType: direction, + Severity: severity, + Description: strings.TrimSpace(sensor + ": " + event), + RawData: line, + }) + } + return events +} + +func selSeverity(event, direction string) models.Severity { + if strings.EqualFold(strings.TrimSpace(direction), "Deasserted") { + return models.SeverityInfo + } + low := strings.ToLower(event) + for _, p := range criticalSELPhrases { + if strings.Contains(low, p) { + return models.SeverityCritical + } + } + for _, p := range warningSELPhrases { + if strings.Contains(low, p) { + return models.SeverityWarning + } + } + return models.SeverityInfo +} + +func splitTrim(s, sep string) []string { + parts := strings.Split(s, sep) + for i := range parts { + parts[i] = strings.TrimSpace(parts[i]) + } + return parts +} diff --git a/internal/parser/vendors/inspur_legacy/syslog.go b/internal/parser/vendors/inspur_legacy/syslog.go new file mode 100644 index 0000000..2c51ab3 --- /dev/null +++ b/internal/parser/vendors/inspur_legacy/syslog.go @@ -0,0 +1,116 @@ +package inspurlegacy + +import ( + "regexp" + "strconv" + "strings" + "time" + + "git.mchus.pro/mchus/logpile/internal/models" +) + +// amiSyslogRe matches " host proc[pid]: message". +var amiSyslogRe = regexp.MustCompile(`^<(\d+)>\s*(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\S*)\s+(\S+)\s+(\S+?):\s*(.*)$`) + +// ParseAMISyslog parses a flat AMI BMC severity log (err.log, warning.log, ...). +func ParseAMISyslog(content []byte, source string) []models.Event { + var events []models.Event + lineNo := 0 + for _, line := range strings.Split(string(content), "\n") { + lineNo++ + line = strings.TrimSpace(line) + if line == "" { + continue + } + m := amiSyslogRe.FindStringSubmatch(line) + if m == nil { + continue + } + pri, err := strconv.Atoi(m[1]) + if err != nil { + continue + } + ts, err := parseSyslogTime(m[2]) + if err != nil { + continue + } + proc := m[4] + msg := strings.TrimSpace(m[5]) + // AMI BMC pre-NTP lines carry a fabricated 1970 wall clock; without a + // trustworthy boot epoch they cannot become dated events. + if bogusYear(ts) { + continue + } + + severity := syslogSeverity(pri, source) + if isBenignSyslogMessage(msg) { + severity = models.SeverityInfo + } + + events = append(events, models.Event{ + ID: strings.TrimSuffix(source, ".log") + "_" + itoa(lineNo), + Timestamp: ts, + Source: "syslog/" + strings.TrimSuffix(source, ".log"), + SensorType: "syslog", + SensorName: proc, + Severity: severity, + Description: msg, + RawData: line, + }) + } + return events +} + +func parseSyslogTime(s string) (time.Time, error) { + if t, err := time.Parse(time.RFC3339, s); err == nil { + return t, nil + } + return time.Parse("2006-01-02T15:04:05.999999-07:00", s) +} + +// benignSyslogSubstrings are driver banners and inventory-provisioning notices +// that AMI routes to alert.log/warning.log despite describing no fault. The PRI +// value is not trustworthy for this observed set. +var benignSyslogSubstrings = []string{ + "helper module driver version", + "copyright (c)", + "changed from null to", + "sn changed from", + "color depth is", +} + +func isBenignSyslogMessage(msg string) bool { + low := strings.ToLower(msg) + for _, s := range benignSyslogSubstrings { + if strings.Contains(low, s) { + return true + } + } + return false +} + +// syslogSeverity derives severity from the RFC 5424 PRI low three bits, falling +// back to the source filename. +func syslogSeverity(pri int, source string) models.Severity { + switch pri & 7 { + case 0, 1, 2: + return models.SeverityCritical + case 3, 4: + return models.SeverityWarning + case 5, 6, 7: + return models.SeverityInfo + } + return severityFromSource(source) +} + +func severityFromSource(source string) models.Severity { + low := strings.ToLower(source) + switch { + case strings.Contains(low, "emerg"), strings.Contains(low, "alert"), strings.Contains(low, "crit"): + return models.SeverityCritical + case strings.Contains(low, "err"), strings.Contains(low, "warn"): + return models.SeverityWarning + default: + return models.SeverityInfo + } +} diff --git a/internal/parser/vendors/vendors.go b/internal/parser/vendors/vendors.go index 47aa9c9..0cd073b 100644 --- a/internal/parser/vendors/vendors.go +++ b/internal/parser/vendors/vendors.go @@ -9,13 +9,14 @@ import ( _ "git.mchus.pro/mchus/logpile/internal/parser/vendors/h3c" _ "git.mchus.pro/mchus/logpile/internal/parser/vendors/hpe_ilo_ahs" _ "git.mchus.pro/mchus/logpile/internal/parser/vendors/inspur" + _ "git.mchus.pro/mchus/logpile/internal/parser/vendors/inspur_legacy" + _ "git.mchus.pro/mchus/logpile/internal/parser/vendors/lenovo_xcc" _ "git.mchus.pro/mchus/logpile/internal/parser/vendors/nvidia" _ "git.mchus.pro/mchus/logpile/internal/parser/vendors/nvidia_bug_report" + _ "git.mchus.pro/mchus/logpile/internal/parser/vendors/redfishwalk" _ "git.mchus.pro/mchus/logpile/internal/parser/vendors/unraid" _ "git.mchus.pro/mchus/logpile/internal/parser/vendors/xfusion" _ "git.mchus.pro/mchus/logpile/internal/parser/vendors/xigmanas" - _ "git.mchus.pro/mchus/logpile/internal/parser/vendors/lenovo_xcc" - _ "git.mchus.pro/mchus/logpile/internal/parser/vendors/redfishwalk" // Generic fallback parser (must be last for lowest priority) _ "git.mchus.pro/mchus/logpile/internal/parser/vendors/generic"