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:
co-authored by
Claude Sonnet 5
parent
be37f1852b
commit
3311bafd8e
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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:])
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
Vendored
+3
-2
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user