feat(inspur): source-PRI timezone timeline, PPIN serial, NVMe fault storage status
Event ingestion now uses the source syslog PRI and an explicit-offset timezone timeline instead of assuming host-local time. CPU PPIN is exported as the source-backed CPU serial, and an active NVMe fault SEL event promotes the matching drive's storage status. See ADL-056, ADL-057. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
07c270cda2
commit
e7b8a8badc
+2
-1
@@ -129,7 +129,8 @@ func ParseAssetJSON(content []byte, pcieSlotDeviceNames map[int]string, pcieSlot
|
|||||||
L2CacheKB: cpu.L2Cache,
|
L2CacheKB: cpu.L2Cache,
|
||||||
L3CacheKB: cpu.L3Cache,
|
L3CacheKB: cpu.L3Cache,
|
||||||
TDP: cpu.CpuTdp,
|
TDP: cpu.CpuTdp,
|
||||||
PPIN: cpu.PPIN,
|
PPIN: strings.TrimSpace(cpu.PPIN),
|
||||||
|
SerialNumber: models.ResolveCPUSerialNumber("", cpu.PPIN),
|
||||||
})
|
})
|
||||||
|
|
||||||
if cpu.MicroCodeVer != "" {
|
if cpu.MicroCodeVer != "" {
|
||||||
|
|||||||
+9
-2
@@ -164,6 +164,12 @@ func parseCPUInfo(text string, hw *models.HardwareConfig) {
|
|||||||
if strings.TrimSpace(hw.CPUs[idx].Status) == "" {
|
if strings.TrimSpace(hw.CPUs[idx].Status) == "" {
|
||||||
hw.CPUs[idx].Status = status
|
hw.CPUs[idx].Status = status
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(hw.CPUs[idx].PPIN) == "" {
|
||||||
|
hw.CPUs[idx].PPIN = strings.TrimSpace(proc.PPIN)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(hw.CPUs[idx].SerialNumber) == "" {
|
||||||
|
hw.CPUs[idx].SerialNumber = models.ResolveCPUSerialNumber("", proc.PPIN)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
hw.CPUs = append(hw.CPUs, models.CPU{
|
hw.CPUs = append(hw.CPUs, models.CPU{
|
||||||
Socket: proc.ProcID,
|
Socket: proc.ProcID,
|
||||||
@@ -176,7 +182,8 @@ func parseCPUInfo(text string, hw *models.HardwareConfig) {
|
|||||||
L2CacheKB: proc.L2Cache,
|
L2CacheKB: proc.L2Cache,
|
||||||
L3CacheKB: proc.L3Cache,
|
L3CacheKB: proc.L3Cache,
|
||||||
TDP: proc.TDP,
|
TDP: proc.TDP,
|
||||||
PPIN: proc.PPIN,
|
PPIN: strings.TrimSpace(proc.PPIN),
|
||||||
|
SerialNumber: models.ResolveCPUSerialNumber("", proc.PPIN),
|
||||||
Status: status,
|
Status: status,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -243,7 +250,7 @@ func parseMemoryInfo(text string, hw *models.HardwareConfig) {
|
|||||||
Slot: mem.MemModSlot,
|
Slot: mem.MemModSlot,
|
||||||
Location: mem.MemModSlot,
|
Location: mem.MemModSlot,
|
||||||
// status=1 with a known serial/part is definitely present even if BMC reports size=0
|
// status=1 with a known serial/part is definitely present even if BMC reports size=0
|
||||||
Present: mem.MemModStatus == 1 && (mem.MemModSize > 0 || strings.TrimSpace(mem.MemModSerial) != "" || strings.TrimSpace(mem.MemModPartNum) != ""),
|
Present: mem.MemModStatus == 1 && (mem.MemModSize > 0 || strings.TrimSpace(mem.MemModSerial) != "" || strings.TrimSpace(mem.MemModPartNum) != ""),
|
||||||
SizeMB: mem.MemModSize * 1024, // Convert GB to MB
|
SizeMB: mem.MemModSize * 1024, // Convert GB to MB
|
||||||
Type: mem.MemModType,
|
Type: mem.MemModType,
|
||||||
Technology: strings.TrimSpace(mem.MemModTechnology),
|
Technology: strings.TrimSpace(mem.MemModTechnology),
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ func TestParseCPUInfo_FromComponentLog(t *testing.T) {
|
|||||||
if hw.CPUs[1].PPIN != "475AC1221D41F557" {
|
if hw.CPUs[1].PPIN != "475AC1221D41F557" {
|
||||||
t.Errorf("unexpected CPU1 PPIN: %s", hw.CPUs[1].PPIN)
|
t.Errorf("unexpected CPU1 PPIN: %s", hw.CPUs[1].PPIN)
|
||||||
}
|
}
|
||||||
|
if hw.CPUs[0].SerialNumber != "47149E2253E81688" || hw.CPUs[1].SerialNumber != "475AC1221D41F557" {
|
||||||
|
t.Fatalf("expected source-backed PPIN values as CPU serials, got %+v", hw.CPUs)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseMemoryInfo_PresentWithZeroSize(t *testing.T) {
|
func TestParseMemoryInfo_PresentWithZeroSize(t *testing.T) {
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package inspur
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseAssetJSONUsesPPINAsSourceBackedCPUSerial(t *testing.T) {
|
||||||
|
content := []byte(`{
|
||||||
|
"CpuInfo": [
|
||||||
|
{"ProcessorName":"Intel Xeon", "PPIN":"D46E5D6B1D3E40E1"},
|
||||||
|
{"ProcessorName":"Intel Xeon", "PPIN":"D44F8D6B9155EE0E"}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
hw, err := ParseAssetJSON(content, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse asset: %v", err)
|
||||||
|
}
|
||||||
|
if len(hw.CPUs) != 2 {
|
||||||
|
t.Fatalf("expected 2 CPUs, got %d", len(hw.CPUs))
|
||||||
|
}
|
||||||
|
if hw.CPUs[0].SerialNumber != "D46E5D6B1D3E40E1" || hw.CPUs[1].SerialNumber != "D44F8D6B9155EE0E" {
|
||||||
|
t.Fatalf("unexpected CPU serial mapping: %+v", hw.CPUs)
|
||||||
|
}
|
||||||
|
}
|
||||||
+72
-1
@@ -1,6 +1,12 @@
|
|||||||
package inspur
|
package inspur
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/models"
|
||||||
|
baseparser "git.mchus.pro/mchus/logpile/internal/parser"
|
||||||
|
)
|
||||||
|
|
||||||
func TestParseIDLLog_UsesBMCSourceForEventLogs(t *testing.T) {
|
func TestParseIDLLog_UsesBMCSourceForEventLogs(t *testing.T) {
|
||||||
content := []byte(`|2025-12-02T17:54:27+08:00|MEMORY|Assert|Warning|0C180401|CPU1_C4D0 Memory Device Disabled - Assert|`)
|
content := []byte(`|2025-12-02T17:54:27+08:00|MEMORY|Assert|Warning|0C180401|CPU1_C4D0 Memory Device Disabled - Assert|`)
|
||||||
@@ -17,6 +23,14 @@ func TestParseIDLLog_UsesBMCSourceForEventLogs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseIDLLog_DeassertedFaultIsInfo(t *testing.T) {
|
||||||
|
content := []byte(`|2026-05-07T23:57:08+08:00|BMC|Deassert|Critical|1800B002|Sys_Health Transition to Critical from less severe - Deassert|`)
|
||||||
|
events := ParseIDLLog(content)
|
||||||
|
if len(events) != 1 || events[0].Severity != models.SeverityInfo {
|
||||||
|
t.Fatalf("expected deasserted IDL fault to be informational, got %+v", events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseSyslog_UsesHostSourceAndProcessAsSensorName(t *testing.T) {
|
func TestParseSyslog_UsesHostSourceAndProcessAsSensorName(t *testing.T) {
|
||||||
content := []byte(`<13>2026-03-15T14:03:11+00:00 host123 systemd[1]: Started Example Service`)
|
content := []byte(`<13>2026-03-15T14:03:11+00:00 host123 systemd[1]: Started Example Service`)
|
||||||
|
|
||||||
@@ -31,3 +45,60 @@ func TestParseSyslog_UsesHostSourceAndProcessAsSensorName(t *testing.T) {
|
|||||||
t.Fatalf("expected process name in sensor/component slot, got %#v", events[0])
|
t.Fatalf("expected process name in sensor/component slot, got %#v", events[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseSyslog_UsesPriorityAndDowngradesKnownBenignMessages(t *testing.T) {
|
||||||
|
content := []byte("<1> 1970-01-01T08:01:38.795020+08:00 host kernel: [ 17.763621] Helper Module Driver Version 1.2\n" +
|
||||||
|
"<1> 2026-01-29T16:24:12.888636+08:00 host kernel: [ 16.191801] Helper Module Driver Version 1.2\n" +
|
||||||
|
"<132> 2026-07-25T08:42:47.001654+03:00 host kernel: [100009.801150] Color Depth is 15 bpp or higher\n" +
|
||||||
|
"<130> 2026-07-25T08:43:48.001964+03:00 host MCTPMain: MCTP_ERROR: No Response for Application. Timing-out...TimeoutMsec:4000\n")
|
||||||
|
|
||||||
|
events := ParseSyslog(content, "onekeylog/log/syslog/alert.log")
|
||||||
|
if len(events) != 3 {
|
||||||
|
t.Fatalf("expected 3 events, got %d", len(events))
|
||||||
|
}
|
||||||
|
if events[0].Severity != models.SeverityInfo {
|
||||||
|
t.Fatalf("expected benign driver alert to be info, got %#v", events[0])
|
||||||
|
}
|
||||||
|
if events[1].Severity != models.SeverityInfo {
|
||||||
|
t.Fatalf("expected KVM status line to be info, got %#v", events[1])
|
||||||
|
}
|
||||||
|
if events[2].Severity != models.SeverityCritical {
|
||||||
|
t.Fatalf("expected PRI=2 MCTP fault to be critical, got %#v", events[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParserIncludesCriticalSyslogMCTPAndMESelfTest(t *testing.T) {
|
||||||
|
files := []baseparser.ExtractedFile{{
|
||||||
|
Path: "onekeylog/log/syslog/crit.log",
|
||||||
|
Content: []byte("<130> 2026-07-25T08:42:47.001654+03:00 host MCTPMain: MCTP_ERROR: No Response for Application. Timing-out...TimeoutMsec:4000\n" +
|
||||||
|
"<130> 2026-07-25T08:43:28.072198+03:00 host IPMIMain: Me self-tests is not normal, return code is 195!\n"),
|
||||||
|
}}
|
||||||
|
|
||||||
|
result, err := (&Parser{}).Parse(files)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if len(result.Events) != 2 {
|
||||||
|
t.Fatalf("expected both critical syslog events, got %d: %+v", len(result.Events), result.Events)
|
||||||
|
}
|
||||||
|
for _, event := range result.Events {
|
||||||
|
if event.Severity != models.SeverityCritical {
|
||||||
|
t.Fatalf("expected critical event, got %#v", event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if result.Events[0].Timestamp.After(result.Events[1].Timestamp) {
|
||||||
|
t.Fatalf("events are not sorted: %+v", result.Events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSortInspurEventsPlacesUnknownTimestampsLast(t *testing.T) {
|
||||||
|
events := []models.Event{
|
||||||
|
{ID: "late", Timestamp: time.Date(2026, 7, 25, 8, 0, 0, 0, time.UTC)},
|
||||||
|
{ID: "unknown"},
|
||||||
|
{ID: "early", Timestamp: time.Date(2026, 7, 24, 8, 0, 0, 0, time.UTC)},
|
||||||
|
}
|
||||||
|
sortInspurEvents(events)
|
||||||
|
if events[0].ID != "early" || events[1].ID != "late" || events[2].ID != "unknown" {
|
||||||
|
t.Fatalf("unexpected event order: %+v", events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+3
@@ -43,6 +43,9 @@ func ParseIDLLog(content []byte) []models.Event {
|
|||||||
|
|
||||||
// Map severity
|
// Map severity
|
||||||
severity := mapIDLSeverity(severityStr)
|
severity := mapIDLSeverity(severityStr)
|
||||||
|
if strings.Contains(strings.ToLower(eventType), "deassert") {
|
||||||
|
severity = models.SeverityInfo
|
||||||
|
}
|
||||||
|
|
||||||
// Clean up description
|
// Clean up description
|
||||||
description = cleanDescription(description)
|
description = cleanDescription(description)
|
||||||
|
|||||||
+19
-6
@@ -16,7 +16,7 @@ import (
|
|||||||
|
|
||||||
// parserVersion - version of this parser module
|
// parserVersion - version of this parser module
|
||||||
// IMPORTANT: Increment this version when making changes to parser logic!
|
// IMPORTANT: Increment this version when making changes to parser logic!
|
||||||
const parserVersion = "2.3"
|
const parserVersion = "2.5"
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
parser.Register(&Parser{})
|
parser.Register(&Parser{})
|
||||||
@@ -96,6 +96,7 @@ func containsInspurMarkers(content []byte) bool {
|
|||||||
// Parse parses Inspur/Kaytus archive
|
// Parse parses Inspur/Kaytus archive
|
||||||
func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, error) {
|
func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, error) {
|
||||||
selLocation := inferInspurArchiveLocation(files)
|
selLocation := inferInspurArchiveLocation(files)
|
||||||
|
selTimezone := inferInspurTimezoneResolver(files, selLocation)
|
||||||
|
|
||||||
result := &models.AnalysisResult{
|
result := &models.AnalysisResult{
|
||||||
Events: make([]models.Event, 0),
|
Events: make([]models.Event, 0),
|
||||||
@@ -239,15 +240,18 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
|
|||||||
// Parse SEL list (selelist.csv), falling back to log/sel.csv on the
|
// Parse SEL list (selelist.csv), falling back to log/sel.csv on the
|
||||||
// dump_<serial>_<timestamp>/ layout that has no selelist.csv.
|
// dump_<serial>_<timestamp>/ layout that has no selelist.csv.
|
||||||
if f := parser.FindFileByName(files, "selelist.csv"); f != nil {
|
if f := parser.FindFileByName(files, "selelist.csv"); f != nil {
|
||||||
selEvents := ParseSELListWithLocation(f.Content, selLocation)
|
selEvents := parseSELListWithResolver(f.Content, selTimezone)
|
||||||
result.Events = append(result.Events, selEvents...)
|
result.Events = append(result.Events, selEvents...)
|
||||||
} else if f := parser.FindFileByName(files, "sel.csv"); f != nil {
|
} else if f := parser.FindFileByName(files, "sel.csv"); f != nil {
|
||||||
selEvents := ParseSELListWithLocation(f.Content, selLocation)
|
selEvents := parseSELListWithResolver(f.Content, selTimezone)
|
||||||
result.Events = append(result.Events, selEvents...)
|
result.Events = append(result.Events, selEvents...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse syslog files
|
// Parse syslog files
|
||||||
syslogFiles := parser.FindFileByPattern(files, "syslog/alert", "syslog/warning", "syslog/notice", "syslog/info")
|
syslogFiles := parser.FindFileByPattern(files,
|
||||||
|
"syslog/emerg", "syslog/alert", "syslog/crit", "syslog/error",
|
||||||
|
"syslog/warning", "syslog/notice", "syslog/info",
|
||||||
|
)
|
||||||
for _, f := range syslogFiles {
|
for _, f := range syslogFiles {
|
||||||
events := ParseSyslog(f.Content, f.Path)
|
events := ParseSyslog(f.Content, f.Path)
|
||||||
result.Events = append(result.Events, events...)
|
result.Events = append(result.Events, events...)
|
||||||
@@ -257,9 +261,11 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
|
|||||||
// controller, switch VR/power, switch CPLD and slot presence failures.
|
// controller, switch VR/power, switch CPLD and slot presence failures.
|
||||||
result.Events = append(result.Events, ParseCommerCompEvents(files, selLocation)...)
|
result.Events = append(result.Events, ParseCommerCompEvents(files, selLocation)...)
|
||||||
|
|
||||||
// Same SEL event can be reported twice by the BMC (e.g. once via sel.csv,
|
// Same event can be reported by both IDL and SEL. After timezone
|
||||||
// once via idl.log); collapse exact duplicates so they don't double-count.
|
// normalization, retain the richer offset-bearing IDL event and remove the
|
||||||
|
// equivalent SEL copy, then provide a deterministic chronological stream.
|
||||||
result.Events = dedupSELEvents(result.Events)
|
result.Events = dedupSELEvents(result.Events)
|
||||||
|
sortInspurEvents(result.Events)
|
||||||
|
|
||||||
// Fallback for archives where board serial is missing in parsed FRU/asset data:
|
// Fallback for archives where board serial is missing in parsed FRU/asset data:
|
||||||
// recover it from log content, never from archive filename.
|
// recover it from log content, never from archive filename.
|
||||||
@@ -284,6 +290,13 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
|
|||||||
// Mark problematic GPUs from IDL errors like "BIOS miss F_GPU6".
|
// Mark problematic GPUs from IDL errors like "BIOS miss F_GPU6".
|
||||||
if result.Hardware != nil {
|
if result.Hardware != nil {
|
||||||
applyGPUStatusFromEvents(result.Hardware, result.Events)
|
applyGPUStatusFromEvents(result.Hardware, result.Events)
|
||||||
|
var currentDeviceEvents []models.Event
|
||||||
|
currentDeviceSnapshotAvailable := false
|
||||||
|
if f := parser.FindFileByName(files, "dev_status.log"); f != nil {
|
||||||
|
currentDeviceSnapshotAvailable = true
|
||||||
|
currentDeviceEvents = ParseIDLLog(f.Content)
|
||||||
|
}
|
||||||
|
applyStorageStatusFromEvents(result.Hardware, result.Events, currentDeviceEvents, currentDeviceSnapshotAvailable)
|
||||||
enrichStorageFromSerialFallbackFiles(files, result.Hardware)
|
enrichStorageFromSerialFallbackFiles(files, result.Hardware)
|
||||||
// Enrich storage serial numbers from smartd output in SOLHostCapture.log.
|
// Enrich storage serial numbers from smartd output in SOLHostCapture.log.
|
||||||
// Fills in serial, model, firmware for backplane slots that the BMC HDD API left empty.
|
// Fills in serial, model, firmware for backplane slots that the BMC HDD API left empty.
|
||||||
|
|||||||
+108
-26
@@ -2,8 +2,10 @@ package inspur
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/logpile/internal/models"
|
"git.mchus.pro/mchus/logpile/internal/models"
|
||||||
"git.mchus.pro/mchus/logpile/internal/parser"
|
"git.mchus.pro/mchus/logpile/internal/parser"
|
||||||
@@ -19,6 +21,14 @@ func ParseSELList(content []byte) []models.Event {
|
|||||||
// ParseSELListWithLocation parses selelist.csv using provided source timezone
|
// ParseSELListWithLocation parses selelist.csv using provided source timezone
|
||||||
// for timestamps that don't contain an explicit offset.
|
// for timestamps that don't contain an explicit offset.
|
||||||
func ParseSELListWithLocation(content []byte, location *time.Location) []models.Event {
|
func ParseSELListWithLocation(content []byte, location *time.Location) []models.Event {
|
||||||
|
return parseSELList(content, func(local time.Time) *time.Location { return location })
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSELListWithResolver(content []byte, resolver *inspurTimezoneResolver) []models.Event {
|
||||||
|
return parseSELList(content, resolver.locationFor)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSELList(content []byte, locationFor func(time.Time) *time.Location) []models.Event {
|
||||||
var events []models.Event
|
var events []models.Event
|
||||||
|
|
||||||
text := string(content)
|
text := string(content)
|
||||||
@@ -55,7 +65,7 @@ func ParseSELListWithLocation(content []byte, location *time.Location) []models.
|
|||||||
status := strings.TrimSpace(records[5])
|
status := strings.TrimSpace(records[5])
|
||||||
|
|
||||||
// Parse timestamp: MM/DD/YYYY HH:MM:SS
|
// Parse timestamp: MM/DD/YYYY HH:MM:SS
|
||||||
timestamp := parseSELTimestamp(dateStr, timeStr, location)
|
timestamp := parseSELTimestampWithResolver(dateStr, timeStr, locationFor)
|
||||||
|
|
||||||
// Extract sensor type and name
|
// Extract sensor type and name
|
||||||
sensorType, sensorName := parseSensorInfo(sensorStr)
|
sensorType, sensorName := parseSensorInfo(sensorStr)
|
||||||
@@ -82,23 +92,24 @@ func ParseSELListWithLocation(content []byte, location *time.Location) []models.
|
|||||||
return events
|
return events
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseSELTimestampWithResolver(dateStr, timeStr string, locationFor func(time.Time) *time.Location) time.Time {
|
||||||
|
timestampStr := dateStr + " " + timeStr
|
||||||
|
local, err := time.ParseInLocation("01/02/2006 15:04:05", timestampStr, time.UTC)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
location := parser.DefaultArchiveLocation()
|
||||||
|
if locationFor != nil {
|
||||||
|
if resolved := locationFor(local); resolved != nil {
|
||||||
|
location = resolved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Date(local.Year(), local.Month(), local.Day(), local.Hour(), local.Minute(), local.Second(), 0, location)
|
||||||
|
}
|
||||||
|
|
||||||
// parseSELTimestamp parses MM/DD/YYYY and HH:MM:SS into time.Time
|
// parseSELTimestamp parses MM/DD/YYYY and HH:MM:SS into time.Time
|
||||||
func parseSELTimestamp(dateStr, timeStr string, location *time.Location) time.Time {
|
func parseSELTimestamp(dateStr, timeStr string, location *time.Location) time.Time {
|
||||||
// Combine date and time: MM/DD/YYYY HH:MM:SS
|
return parseSELTimestampWithResolver(dateStr, timeStr, func(time.Time) *time.Location { return location })
|
||||||
timestampStr := dateStr + " " + timeStr
|
|
||||||
|
|
||||||
if location == nil {
|
|
||||||
location = parser.DefaultArchiveLocation()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try parsing with MM/DD/YYYY format
|
|
||||||
t, err := time.ParseInLocation("01/02/2006 15:04:05", timestampStr, location)
|
|
||||||
if err != nil {
|
|
||||||
// Fallback to current time
|
|
||||||
return time.Now()
|
|
||||||
}
|
|
||||||
|
|
||||||
return t
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseSensorInfo extracts sensor type and name from sensor string
|
// parseSensorInfo extracts sensor type and name from sensor string
|
||||||
@@ -128,6 +139,9 @@ func determineSELSeverity(sensorStr, eventDesc, status string) models.Severity {
|
|||||||
lowerSensor := strings.ToLower(sensorStr)
|
lowerSensor := strings.ToLower(sensorStr)
|
||||||
lowerEvent := strings.ToLower(eventDesc)
|
lowerEvent := strings.ToLower(eventDesc)
|
||||||
lowerStatus := strings.ToLower(status)
|
lowerStatus := strings.ToLower(status)
|
||||||
|
if strings.Contains(lowerStatus, "deassert") {
|
||||||
|
return models.SeverityInfo
|
||||||
|
}
|
||||||
|
|
||||||
// Critical indicators
|
// Critical indicators
|
||||||
criticalKeywords := []string{
|
criticalKeywords := []string{
|
||||||
@@ -176,32 +190,100 @@ func determineSELSeverity(sensorStr, eventDesc, status string) models.Severity {
|
|||||||
return models.SeverityInfo
|
return models.SeverityInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
// dedupSELEvents collapses events reported more than once with the same
|
// dedupSELEvents removes exact SEL repeats and equivalent IDL/SEL copies at
|
||||||
// (timestamp, event_type, description) triple. Unlike ParseIDLLog's
|
// the same normalized instant. IDL is preferred because it carries an
|
||||||
// dedup (which must keep recurring alarms with distinct timestamps), this
|
// explicit timezone offset and usually a richer component-prefixed message.
|
||||||
// only removes true duplicates: the same SEL entry surfacing through more
|
|
||||||
// than one source file for the exact same moment.
|
|
||||||
func dedupSELEvents(events []models.Event) []models.Event {
|
func dedupSELEvents(events []models.Event) []models.Event {
|
||||||
if len(events) == 0 {
|
if len(events) == 0 {
|
||||||
return events
|
return events
|
||||||
}
|
}
|
||||||
seen := make(map[string]struct{}, len(events))
|
|
||||||
out := make([]models.Event, 0, len(events))
|
out := make([]models.Event, 0, len(events))
|
||||||
|
bySecond := make(map[int64][]int)
|
||||||
for _, e := range events {
|
for _, e := range events {
|
||||||
if e.Source != "SEL" {
|
if e.Timestamp.IsZero() {
|
||||||
out = append(out, e)
|
out = append(out, e)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
key := e.Timestamp.String() + "|" + e.EventType + "|" + e.Description
|
|
||||||
if _, ok := seen[key]; ok {
|
second := e.Timestamp.Unix()
|
||||||
|
duplicate := false
|
||||||
|
for _, idx := range bySecond[second] {
|
||||||
|
previous := out[idx]
|
||||||
|
if !equivalentInspurEvent(previous, e) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if previous.Source == "SEL" && e.Source != "SEL" {
|
||||||
|
out[idx] = e
|
||||||
|
}
|
||||||
|
duplicate = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if duplicate {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[key] = struct{}{}
|
bySecond[second] = append(bySecond[second], len(out))
|
||||||
out = append(out, e)
|
out = append(out, e)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func equivalentInspurEvent(a, b models.Event) bool {
|
||||||
|
if a.Source != "SEL" && b.Source != "SEL" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if a.Severity != b.Severity || eventDirection(a) != eventDirection(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
left := normalizeEventDescription(a.Description)
|
||||||
|
right := normalizeEventDescription(b.Description)
|
||||||
|
if left == "" || right == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if left == right {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
shorter, longer := left, right
|
||||||
|
if len(shorter) > len(longer) {
|
||||||
|
shorter, longer = longer, shorter
|
||||||
|
}
|
||||||
|
return len(shorter) >= 12 && strings.Contains(longer, shorter)
|
||||||
|
}
|
||||||
|
|
||||||
|
func eventDirection(e models.Event) string {
|
||||||
|
value := strings.ToLower(e.EventType + " " + e.RawData)
|
||||||
|
if strings.Contains(value, "deassert") {
|
||||||
|
return "deassert"
|
||||||
|
}
|
||||||
|
if strings.Contains(value, "assert") {
|
||||||
|
return "assert"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeEventDescription(value string) string {
|
||||||
|
value = strings.ToLower(value)
|
||||||
|
value = strings.Map(func(r rune) rune {
|
||||||
|
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
return ' '
|
||||||
|
}, value)
|
||||||
|
return strings.Join(strings.Fields(value), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortInspurEvents(events []models.Event) {
|
||||||
|
sort.SliceStable(events, func(i, j int) bool {
|
||||||
|
left, right := events[i].Timestamp, events[j].Timestamp
|
||||||
|
if left.IsZero() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if right.IsZero() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return left.Before(right)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// buildSELDescription builds human-readable description
|
// buildSELDescription builds human-readable description
|
||||||
func buildSELDescription(eventDesc, status string) string {
|
func buildSELDescription(eventDesc, status string) string {
|
||||||
if status == "Asserted" || status == "Deasserted" {
|
if status == "Asserted" || status == "Deasserted" {
|
||||||
|
|||||||
+41
-6
@@ -5,20 +5,26 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/logpile/internal/models"
|
"git.mchus.pro/mchus/logpile/internal/models"
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/parser"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDedupSELEvents(t *testing.T) {
|
func TestDedupSELEvents(t *testing.T) {
|
||||||
ts := time.Date(2026, 7, 27, 4, 52, 55, 0, time.UTC)
|
ts := time.Date(2026, 7, 27, 4, 52, 55, 0, time.UTC)
|
||||||
events := []models.Event{
|
events := []models.Event{
|
||||||
{Source: "SEL", Timestamp: ts, EventType: "Asserted", Description: "PSU0 fail"},
|
{Source: "BMC", Timestamp: ts, EventType: "Assert", Severity: models.SeverityCritical, Description: "Sys_Health Transition to Critical from less severe"},
|
||||||
{Source: "SEL", Timestamp: ts, EventType: "Asserted", Description: "PSU0 fail"},
|
{Source: "SEL", Timestamp: ts, EventType: "Transition to Critical from less severe", Severity: models.SeverityCritical, Description: "Transition to Critical from less severe", RawData: "Asserted"},
|
||||||
{Source: "BMC", Timestamp: ts, EventType: "Assert", Description: "recurring alarm"},
|
{Source: "SEL", Timestamp: ts.Add(time.Second), EventType: "Asserted", Description: "PSU0 fail"},
|
||||||
{Source: "BMC", Timestamp: ts, EventType: "Assert", Description: "recurring alarm"},
|
{Source: "SEL", Timestamp: ts.Add(time.Second), EventType: "Asserted", Description: "PSU0 fail"},
|
||||||
|
{Source: "BMC", Timestamp: ts.Add(2 * time.Second), EventType: "Assert", Description: "recurring alarm"},
|
||||||
|
{Source: "BMC", Timestamp: ts.Add(2 * time.Second), EventType: "Assert", Description: "recurring alarm"},
|
||||||
}
|
}
|
||||||
|
|
||||||
out := dedupSELEvents(events)
|
out := dedupSELEvents(events)
|
||||||
if len(out) != 3 {
|
if len(out) != 4 {
|
||||||
t.Fatalf("expected 3 events (1 SEL dup removed, BMC untouched), got %d: %+v", len(out), out)
|
t.Fatalf("expected IDL/SEL copy and exact SEL copy removed, recurring BMC alarms retained; got %d: %+v", len(out), out)
|
||||||
|
}
|
||||||
|
if out[0].Source != "BMC" {
|
||||||
|
t.Fatalf("expected richer IDL/BMC event to be retained, got %+v", out[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,3 +54,32 @@ func TestParseTimezoneConfigLocation(t *testing.T) {
|
|||||||
t.Fatalf("unexpected timezone: %q", got)
|
t.Fatalf("unexpected timezone: %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSELTimezoneResolverUsesExplicitOffsetTimeline(t *testing.T) {
|
||||||
|
files := []parser.ExtractedFile{
|
||||||
|
{Path: "onekeylog/configuration/conf/timezone.conf", Content: []byte("[TimeZoneConfig]\ntimezone=Asia/Shanghai\n")},
|
||||||
|
{Path: "onekeylog/log/syslog/idl.log", Content: []byte(
|
||||||
|
"|2026-01-30T14:25:14+08:00|BMC|Assert|Info|1|old timezone|\n" +
|
||||||
|
"|2026-07-24T02:20:38+03:00|BMC|Assert|Critical|2|new timezone|\n")},
|
||||||
|
}
|
||||||
|
fallback := inferInspurArchiveLocation(files)
|
||||||
|
resolver := inferInspurTimezoneResolver(files, fallback)
|
||||||
|
events := parseSELListWithResolver([]byte("sel elist:\n"+
|
||||||
|
"1,01/30/2026,14:25:14,Chassis Sys_Health,old timezone,Asserted\n"+
|
||||||
|
"2,07/24/2026,02:20:38,Chassis Sys_Health,new timezone,Asserted\n"), resolver)
|
||||||
|
if len(events) != 2 {
|
||||||
|
t.Fatalf("expected 2 events, got %d", len(events))
|
||||||
|
}
|
||||||
|
wantOld := time.Date(2026, 1, 30, 6, 25, 14, 0, time.UTC)
|
||||||
|
wantNew := time.Date(2026, 7, 23, 23, 20, 38, 0, time.UTC)
|
||||||
|
if !events[0].Timestamp.UTC().Equal(wantOld) || !events[1].Timestamp.UTC().Equal(wantNew) {
|
||||||
|
t.Fatalf("unexpected resolved timestamps: got %s and %s", events[0].Timestamp.UTC(), events[1].Timestamp.UTC())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetermineSELSeverityDeassertedFaultIsInfo(t *testing.T) {
|
||||||
|
got := determineSELSeverity("Power Supply PSU0", "Failure detected", "Deasserted")
|
||||||
|
if got != models.SeverityInfo {
|
||||||
|
t.Fatalf("expected cleared fault to be info, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
package inspur
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
reNVMeEventIndex = regexp.MustCompile(`(?i)\bNvmeIndex\s*:\s*(\d+)\b`)
|
||||||
|
reNVMeLabelIndex = regexp.MustCompile(`(?i)\bNVME(\d+)\b`)
|
||||||
|
reOBStorageSlot = regexp.MustCompile(`(?i)^OB(\d+)$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// applyStorageStatusFromEvents projects source-backed NVMe fault state onto
|
||||||
|
// the physical storage inventory. Healthy-looking presence data is not enough
|
||||||
|
// to claim OK, so unaffected drives keep their existing (usually Unknown)
|
||||||
|
// status. dev_status.log, when available, is the authoritative active-fault
|
||||||
|
// snapshot; IDL supplies transition history.
|
||||||
|
func applyStorageStatusFromEvents(hw *models.HardwareConfig, events, currentDeviceEvents []models.Event, currentSnapshotAvailable bool) {
|
||||||
|
if hw == nil || len(hw.Storage) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
storageByIndex := make(map[int]*models.Storage)
|
||||||
|
for i := range hw.Storage {
|
||||||
|
if idx, ok := storageIndexFromSlot(hw.Storage[i].Slot); ok {
|
||||||
|
storageByIndex[idx] = &hw.Storage[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
relevant := relevantStorageFaultEvents(events)
|
||||||
|
sort.SliceStable(relevant, func(i, j int) bool {
|
||||||
|
return relevant[i].Timestamp.Before(relevant[j].Timestamp)
|
||||||
|
})
|
||||||
|
touched := make(map[int]bool)
|
||||||
|
for _, event := range relevant {
|
||||||
|
idx, ok := storageFaultIndex(event)
|
||||||
|
storage := storageByIndex[idx]
|
||||||
|
if !ok || storage == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
touched[idx] = true
|
||||||
|
applyStorageFaultTransition(storage, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !currentSnapshotAvailable {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
active := make(map[int]models.Event)
|
||||||
|
for _, event := range relevantStorageFaultEvents(currentDeviceEvents) {
|
||||||
|
idx, ok := storageFaultIndex(event)
|
||||||
|
if !ok || eventIsDeassert(event) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
active[idx] = event
|
||||||
|
}
|
||||||
|
for idx := range touched {
|
||||||
|
if _, ok := active[idx]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
storage := storageByIndex[idx]
|
||||||
|
storage.Status = "Unknown"
|
||||||
|
storage.ErrorDescription = ""
|
||||||
|
}
|
||||||
|
for idx, event := range active {
|
||||||
|
if storage := storageByIndex[idx]; storage != nil {
|
||||||
|
applyStorageFaultTransition(storage, event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func relevantStorageFaultEvents(events []models.Event) []models.Event {
|
||||||
|
out := make([]models.Event, 0)
|
||||||
|
for _, event := range events {
|
||||||
|
if _, ok := storageFaultIndex(event); ok {
|
||||||
|
out = append(out, event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func storageFaultIndex(event models.Event) (int, bool) {
|
||||||
|
description := strings.TrimSpace(event.Description)
|
||||||
|
lower := strings.ToLower(description)
|
||||||
|
if !strings.Contains(lower, "drive fault") && !strings.EqualFold(strings.TrimSpace(event.ID), "0DFF0702") {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
for _, re := range []*regexp.Regexp{reNVMeEventIndex, reNVMeLabelIndex} {
|
||||||
|
match := re.FindStringSubmatch(description)
|
||||||
|
if len(match) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idx, err := strconv.Atoi(match[1])
|
||||||
|
if err == nil && idx >= 0 {
|
||||||
|
return idx, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func storageIndexFromSlot(slot string) (int, bool) {
|
||||||
|
match := reOBStorageSlot.FindStringSubmatch(strings.TrimSpace(slot))
|
||||||
|
if len(match) != 2 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
oneBased, err := strconv.Atoi(match[1])
|
||||||
|
if err != nil || oneBased < 1 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return oneBased - 1, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyStorageFaultTransition(storage *models.Storage, event models.Event) {
|
||||||
|
status := "Critical"
|
||||||
|
description := strings.TrimSpace(event.Description)
|
||||||
|
if eventIsDeassert(event) {
|
||||||
|
status = "Unknown"
|
||||||
|
description = ""
|
||||||
|
}
|
||||||
|
if storage.Status != status && !event.Timestamp.IsZero() {
|
||||||
|
storage.StatusHistory = append(storage.StatusHistory, models.StatusHistoryEntry{
|
||||||
|
Status: status,
|
||||||
|
ChangedAt: event.Timestamp,
|
||||||
|
Details: strings.TrimSpace(event.Description),
|
||||||
|
})
|
||||||
|
ts := event.Timestamp
|
||||||
|
storage.StatusChangedAt = &ts
|
||||||
|
}
|
||||||
|
storage.Status = status
|
||||||
|
storage.ErrorDescription = description
|
||||||
|
if !event.Timestamp.IsZero() {
|
||||||
|
ts := event.Timestamp
|
||||||
|
storage.StatusCheckedAt = &ts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func eventIsDeassert(event models.Event) bool {
|
||||||
|
return strings.Contains(strings.ToLower(event.EventType+" "+event.RawData), "deassert")
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package inspur
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestApplyStorageStatusFromEventsMarksOnlyFaultyNVMeCritical(t *testing.T) {
|
||||||
|
hw := &models.HardwareConfig{}
|
||||||
|
for i := 1; i <= 7; i++ {
|
||||||
|
hw.Storage = append(hw.Storage, models.Storage{Slot: fmt.Sprintf("OB%02d", i), Type: "NVMe", Present: true})
|
||||||
|
}
|
||||||
|
ts := time.Date(2026, 7, 23, 23, 20, 32, 0, time.UTC)
|
||||||
|
fault := models.Event{
|
||||||
|
ID: "0DFF0702",
|
||||||
|
Timestamp: ts,
|
||||||
|
Source: "BMC",
|
||||||
|
EventType: "Assert",
|
||||||
|
Severity: models.SeverityCritical,
|
||||||
|
Description: "NVME BP:Front0 NvmeIndex:6 drive fault: Status_Flags error.",
|
||||||
|
}
|
||||||
|
|
||||||
|
applyStorageStatusFromEvents(hw, []models.Event{fault}, []models.Event{fault}, true)
|
||||||
|
|
||||||
|
for i := range hw.Storage {
|
||||||
|
if hw.Storage[i].Slot == "OB07" {
|
||||||
|
if hw.Storage[i].Status != "Critical" || hw.Storage[i].ErrorDescription == "" {
|
||||||
|
t.Fatalf("expected OB07 critical with details, got %+v", hw.Storage[i])
|
||||||
|
}
|
||||||
|
if len(hw.Storage[i].StatusHistory) != 1 || hw.Storage[i].StatusChangedAt == nil {
|
||||||
|
t.Fatalf("expected one source-backed status transition, got %+v", hw.Storage[i])
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if hw.Storage[i].Status != "" {
|
||||||
|
t.Fatalf("unaffected %s must remain unknown/unset, got %q", hw.Storage[i].Slot, hw.Storage[i].Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyStorageStatusFromEventsDeassertClearsCriticalToUnknown(t *testing.T) {
|
||||||
|
hw := &models.HardwareConfig{Storage: []models.Storage{{Slot: "OB07", Type: "NVMe", Present: true}}}
|
||||||
|
asserted := models.Event{
|
||||||
|
ID: "0DFF0702", Timestamp: time.Date(2026, 7, 23, 23, 20, 32, 0, time.UTC),
|
||||||
|
EventType: "Assert", Severity: models.SeverityCritical,
|
||||||
|
Description: "NVME BP:Front0 NvmeIndex:6 drive fault: Status_Flags error.",
|
||||||
|
}
|
||||||
|
deasserted := asserted
|
||||||
|
deasserted.Timestamp = asserted.Timestamp.Add(time.Hour)
|
||||||
|
deasserted.EventType = "Deassert"
|
||||||
|
deasserted.Severity = models.SeverityInfo
|
||||||
|
|
||||||
|
applyStorageStatusFromEvents(hw, []models.Event{asserted, deasserted}, nil, true)
|
||||||
|
|
||||||
|
if hw.Storage[0].Status != "Unknown" || hw.Storage[0].ErrorDescription != "" {
|
||||||
|
t.Fatalf("expected cleared drive fault to become Unknown, got %+v", hw.Storage[0])
|
||||||
|
}
|
||||||
|
if len(hw.Storage[0].StatusHistory) != 2 {
|
||||||
|
t.Fatalf("expected assert/deassert history, got %+v", hw.Storage[0].StatusHistory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStorageFaultIndexIgnoresNVMeInventoryChange(t *testing.T) {
|
||||||
|
event := models.Event{Description: "PCIE Device #NVME6 Device ID changed; Vendor ID changed"}
|
||||||
|
if _, ok := storageFaultIndex(event); ok {
|
||||||
|
t.Fatalf("inventory change must not be treated as an active drive fault")
|
||||||
|
}
|
||||||
|
}
|
||||||
+50
-6
@@ -3,6 +3,7 @@ package inspur
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -11,16 +12,14 @@ import (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
// Syslog format: <priority> timestamp hostname process: message
|
// Syslog format: <priority> timestamp hostname process: message
|
||||||
syslogRegex = regexp.MustCompile(`^<(\d+)>\s*(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[^\s]*)\s+(\S+)\s+(\S+):\s*(.*)$`)
|
syslogRegex = regexp.MustCompile(`^<(\d+)>\s*(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[^\s]*)\s+(\S+)\s+(\S+):\s*(.*)$`)
|
||||||
|
bootRelativeMessageRegex = regexp.MustCompile(`^\[\s*\d+(?:\.\d+)?\]`)
|
||||||
)
|
)
|
||||||
|
|
||||||
// ParseSyslog parses syslog format logs
|
// ParseSyslog parses syslog format logs
|
||||||
func ParseSyslog(content []byte, sourcePath string) []models.Event {
|
func ParseSyslog(content []byte, sourcePath string) []models.Event {
|
||||||
var events []models.Event
|
var events []models.Event
|
||||||
|
|
||||||
// Determine severity from file path
|
|
||||||
severity := determineSeverityFromPath(sourcePath)
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(strings.NewReader(string(content)))
|
scanner := bufio.NewScanner(strings.NewReader(string(content)))
|
||||||
lineNum := 0
|
lineNum := 0
|
||||||
|
|
||||||
@@ -35,6 +34,10 @@ func ParseSyslog(content []byte, sourcePath string) []models.Event {
|
|||||||
if matches == nil {
|
if matches == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
priority, err := strconv.Atoi(matches[1])
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
timestamp, err := time.Parse(time.RFC3339, matches[2])
|
timestamp, err := time.Parse(time.RFC3339, matches[2])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -44,6 +47,14 @@ func ParseSyslog(content []byte, sourcePath string) []models.Event {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
message := strings.TrimSpace(matches[5])
|
||||||
|
// Some AMI BMC boots emit a fabricated 1970 wall-clock timestamp while
|
||||||
|
// the payload only carries seconds since boot. Without a trustworthy
|
||||||
|
// boot epoch this cannot become a wall-clock event, so omit it instead
|
||||||
|
// of exporting either 1970 or the archive collection time.
|
||||||
|
if timestamp.Year() <= 1971 && bootRelativeMessageRegex.MatchString(message) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
event := models.Event{
|
event := models.Event{
|
||||||
ID: generateEventID(sourcePath, lineNum),
|
ID: generateEventID(sourcePath, lineNum),
|
||||||
@@ -51,8 +62,8 @@ func ParseSyslog(content []byte, sourcePath string) []models.Event {
|
|||||||
Source: "syslog",
|
Source: "syslog",
|
||||||
SensorType: "syslog",
|
SensorType: "syslog",
|
||||||
SensorName: matches[4],
|
SensorName: matches[4],
|
||||||
Description: matches[5],
|
Description: message,
|
||||||
Severity: severity,
|
Severity: determineSyslogSeverity(priority, message, sourcePath),
|
||||||
RawData: line,
|
RawData: line,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +73,39 @@ func ParseSyslog(content []byte, sourcePath string) []models.Event {
|
|||||||
return events
|
return events
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func determineSyslogSeverity(priority int, message, sourcePath string) models.Severity {
|
||||||
|
// These AMI driver start-up/status strings are routed to alert.log and
|
||||||
|
// warning.log despite not describing a fault. The PRI value is therefore
|
||||||
|
// not trustworthy for this small, observed set of benign messages.
|
||||||
|
lowerMessage := strings.ToLower(message)
|
||||||
|
benignMessages := []string{
|
||||||
|
"helper module driver version",
|
||||||
|
"copyright (c)",
|
||||||
|
"color depth is 15 bpp or higher",
|
||||||
|
"new driver 0 directmode 1",
|
||||||
|
}
|
||||||
|
for _, benign := range benignMessages {
|
||||||
|
if strings.Contains(lowerMessage, benign) {
|
||||||
|
return models.SeverityInfo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC 5424 severity is stored in the low three bits of PRI:
|
||||||
|
// 0..2 emergency/alert/critical, 3..4 error/warning, 5..7 notice/info/debug.
|
||||||
|
if priority >= 0 {
|
||||||
|
switch priority & 7 {
|
||||||
|
case 0, 1, 2:
|
||||||
|
return models.SeverityCritical
|
||||||
|
case 3, 4:
|
||||||
|
return models.SeverityWarning
|
||||||
|
default:
|
||||||
|
return models.SeverityInfo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return determineSeverityFromPath(sourcePath)
|
||||||
|
}
|
||||||
|
|
||||||
func determineSeverityFromPath(path string) models.Severity {
|
func determineSeverityFromPath(path string) models.Severity {
|
||||||
pathLower := strings.ToLower(path)
|
pathLower := strings.ToLower(path)
|
||||||
|
|
||||||
|
|||||||
+127
@@ -0,0 +1,127 @@
|
|||||||
|
package inspur
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/parser"
|
||||||
|
)
|
||||||
|
|
||||||
|
var explicitInspurTimestampRegex = regexp.MustCompile(`\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}:\d{2}`)
|
||||||
|
|
||||||
|
type inspurOffsetSample struct {
|
||||||
|
local time.Time
|
||||||
|
offset int
|
||||||
|
}
|
||||||
|
|
||||||
|
// inspurTimezoneResolver handles dumps where timezone.conf is stale or the BMC
|
||||||
|
// timezone changed during the retained SEL history. Offset-bearing IDL/syslog
|
||||||
|
// timestamps are authoritative; timezone.conf remains the fallback.
|
||||||
|
type inspurTimezoneResolver struct {
|
||||||
|
fallback *time.Location
|
||||||
|
samples []inspurOffsetSample
|
||||||
|
}
|
||||||
|
|
||||||
|
func inferInspurTimezoneResolver(files []parser.ExtractedFile, fallback *time.Location) *inspurTimezoneResolver {
|
||||||
|
if fallback == nil {
|
||||||
|
fallback = parser.DefaultArchiveLocation()
|
||||||
|
}
|
||||||
|
r := &inspurTimezoneResolver{fallback: fallback}
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
preferIDL := false
|
||||||
|
for _, f := range files {
|
||||||
|
if strings.Contains(strings.ToLower(f.Path), "idl") {
|
||||||
|
preferIDL = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for pass := 0; pass < 2; pass++ {
|
||||||
|
idlOnly := preferIDL && pass == 0
|
||||||
|
if pass == 1 && (!preferIDL || len(r.samples) > 0) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
for _, f := range files {
|
||||||
|
path := strings.ToLower(f.Path)
|
||||||
|
if idlOnly && !strings.Contains(path, "idl") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.Contains(path, "/log/") && !strings.Contains(path, "idl") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
scanner := bufio.NewScanner(bytes.NewReader(f.Content))
|
||||||
|
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||||
|
for scanner.Scan() {
|
||||||
|
matches := explicitInspurTimestampRegex.FindAllString(scanner.Text(), -1)
|
||||||
|
if len(matches) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// idl.log is itself a syslog stream. Its outer timestamp can retain
|
||||||
|
// an obsolete BMC offset while the embedded IDL record has the offset
|
||||||
|
// that was active for the hardware event. Prefer the embedded value.
|
||||||
|
raw := matches[len(matches)-1]
|
||||||
|
ts, err := time.Parse(time.RFC3339Nano, raw)
|
||||||
|
if err != nil || ts.Year() < 2000 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, offset := ts.Zone()
|
||||||
|
local := time.Date(ts.Year(), ts.Month(), ts.Day(), ts.Hour(), ts.Minute(), ts.Second(), 0, time.UTC)
|
||||||
|
// One sample per local day and offset is sufficient to identify BMC
|
||||||
|
// timezone eras while bounding memory on very large rotated logs.
|
||||||
|
key := local.Format("2006-01-02") + "|" + strings.TrimSpace(raw[len(raw)-6:])
|
||||||
|
if _, ok := seen[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
r.samples = append(r.samples, inspurOffsetSample{local: local, offset: offset})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(r.samples, func(i, j int) bool { return r.samples[i].local.Before(r.samples[j].local) })
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *inspurTimezoneResolver) locationFor(local time.Time) *time.Location {
|
||||||
|
if r == nil || len(r.samples) == 0 {
|
||||||
|
if r != nil && r.fallback != nil {
|
||||||
|
return r.fallback
|
||||||
|
}
|
||||||
|
return parser.DefaultArchiveLocation()
|
||||||
|
}
|
||||||
|
|
||||||
|
best := r.samples[0]
|
||||||
|
bestDistance := absDuration(local.Sub(best.local))
|
||||||
|
for _, sample := range r.samples[1:] {
|
||||||
|
distance := absDuration(local.Sub(sample.local))
|
||||||
|
if distance < bestDistance {
|
||||||
|
best = sample
|
||||||
|
bestDistance = distance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.FixedZone(formatUTCOffset(best.offset), best.offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func absDuration(v time.Duration) time.Duration {
|
||||||
|
if v < 0 {
|
||||||
|
return -v
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatUTCOffset(offset int) string {
|
||||||
|
sign := "+"
|
||||||
|
if offset < 0 {
|
||||||
|
sign = "-"
|
||||||
|
offset = -offset
|
||||||
|
}
|
||||||
|
return "UTC" + sign + twoDigits(offset/3600) + ":" + twoDigits((offset%3600)/60)
|
||||||
|
}
|
||||||
|
|
||||||
|
func twoDigits(v int) string {
|
||||||
|
return string([]byte{'0' + byte(v/10), '0' + byte(v%10)})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user