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_<model>_<serial>_IDL, sel.log, blackbox.log,
  MegaRAID raid0.log, and the flat AMI <severity>.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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017VL8wLGD6Lnp6hZCpqT6cZ
This commit is contained in:
Mikhail Chusavitin
2026-09-02 12:51:53 +03:00
co-authored by Claude Sonnet 5
parent be37f1852b
commit 3311bafd8e
18 changed files with 1827 additions and 5 deletions
+4
View File
@@ -8,6 +8,9 @@ Core behavior:
- registration uses `init()` side effects - registration uses `init()` side effects
- all registered parsers run `Detect()` - all registered parsers run `Detect()`
- the highest-confidence parser wins - 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 - generic fallback stays last and low-confidence
`VendorParser` contract: `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 | | `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 | | `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` | 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 `<sev>.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 | | `lenovo_xcc` | Lenovo XCC mini-log ZIP archives | JSON inventory + platform event logs |
| `nvidia` | HGX Field Diagnostics | GPU- and fabric-heavy diagnostic input | | `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`) | | `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`) |
+49
View File
@@ -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 ` (...)` 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). `7.11.02` vs the live-CD's IPMI-truncated `7.11` is inherent and left as-is).
Test `TestExtractComponentFirmware_StripsBuildStamp`. 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_<model>_<serial>_IDL` plus a pipe-delimited
`sel.log`, and BMC syslog in flat `<severity>.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.
+26 -3
View File
@@ -81,6 +81,29 @@ type DetectResult struct {
Confidence int 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 // DetectFormat tries to detect archive format and returns best matching parser
func DetectFormat(files []ExtractedFile) (VendorParser, error) { func DetectFormat(files []ExtractedFile) (VendorParser, error) {
registryLock.RLock() registryLock.RLock()
@@ -102,9 +125,9 @@ func DetectFormat(files []ExtractedFile) (VendorParser, error) {
return nil, fmt.Errorf("no parser found for this archive format") 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 { 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 return results[0].Parser, nil
@@ -128,7 +151,7 @@ func DetectAllFormats(files []ExtractedFile) []DetectResult {
} }
sort.Slice(results, func(i, j int) bool { sort.Slice(results, func(i, j int) bool {
return results[i].Confidence > results[j].Confidence return lessByConfidenceThenPriority(results[i], results[j])
}) })
return results return results
+62
View File
@@ -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())
}
}
+284
View File
@@ -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
// "<KEY>: 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_<idx>_" 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
}
@@ -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")
}
}
+98
View File
@@ -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:])
}
+115
View File
@@ -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))
}
}
+54
View File
@@ -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
})
}
+268
View File
@@ -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)
}
}
+104
View File
@@ -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")
}
}
+61
View File
@@ -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_<model>_<serial>_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
}
+123
View File
@@ -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
}
+191
View File
@@ -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_<model>_<serial>_IDL text file plus a
// pipe-delimited sel.log, and BMC syslog in flat <sev>.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_<model>_<serial>_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
}
+85
View File
@@ -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")
}
}
+98
View File
@@ -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
}
+116
View File
@@ -0,0 +1,116 @@
package inspurlegacy
import (
"regexp"
"strconv"
"strings"
"time"
"git.mchus.pro/mchus/logpile/internal/models"
)
// amiSyslogRe matches "<pri> <ISO8601> 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
}
}
+3 -2
View File
@@ -9,13 +9,14 @@ import (
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/h3c" _ "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/hpe_ilo_ahs"
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/inspur" _ "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"
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/nvidia_bug_report" _ "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/unraid"
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/xfusion" _ "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/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) // Generic fallback parser (must be last for lowest priority)
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/generic" _ "git.mchus.pro/mchus/logpile/internal/parser/vendors/generic"