feat(parser): lenovo xcc warnings and redfish logs - v1.1

This commit is contained in:
2026-04-13 20:34:04 +03:00
parent 89b6701f43
commit c9969fc3da
8 changed files with 283 additions and 26 deletions

View File

@@ -55,6 +55,7 @@ When `vendor_id` and `device_id` are known but the model name is missing or gene
| `h3c_g6` | H3C SDS G6 bundles | Similar flow with G6-specific files |
| `hpe_ilo_ahs` | HPE iLO Active Health System (`.ahs`) | Proprietary `ABJR` container with gzip-compressed `zbb` members; parser combines SMBIOS-style inventory strings and embedded Redfish storage JSON |
| `inspur` | onekeylog archives | FRU/SDR plus optional Redis enrichment |
| `lenovo_xcc` | Lenovo XCC mini-log ZIP archives | JSON inventory + platform event logs |
| `nvidia` | HGX Field Diagnostics | GPU- and fabric-heavy diagnostic input |
| `nvidia_bug_report` | `nvidia-bug-report-*.log.gz` | dmidecode, lspci, NVIDIA driver sections |
| `unraid` | Unraid diagnostics/log bundles | Server and storage-focused parsing |
@@ -194,6 +195,7 @@ and `LogDump/` trees.
| Reanimator Easy Bee | `easy_bee` | Ready | `bee-support-*.tar.gz` support bundles |
| HPE iLO AHS | `hpe_ilo_ahs` | Ready | iLO 6 `.ahs` exports |
| Inspur / Kaytus | `inspur` | Ready | KR4268X2 onekeylog |
| Lenovo XCC mini-log | `lenovo_xcc` | Ready | ThinkSystem SR650 V3 XCC mini-log ZIP |
| NVIDIA HGX Field Diag | `nvidia` | Ready | Various HGX servers |
| NVIDIA Bug Report | `nvidia_bug_report` | Ready | H100 systems |
| Unraid | `unraid` | Ready | Unraid diagnostics archives |

View File

@@ -57,6 +57,11 @@ Current behavior:
7. Packages any already-present binaries from `bin/`
8. Generates `SHA256SUMS.txt`
Release tag format:
- project release tags use `vN.M`
- do not create `vN.M.P` tags for LOGPile releases
- release artifacts and `main.version` inherit the exact git tag string
Important limitation:
- `scripts/release.sh` does not run `make build-all` for you
- if you want Linux or additional macOS archives in the release directory, build them before running the script

View File

@@ -1137,3 +1137,20 @@ presented in the UI as "Сбор расширенных данных для ди
- Default live collection skips those heavy diagnostic plan-B retries and reaches replay faster.
- Operators can explicitly opt into the slower diagnostic path when they need deeper collection.
- The same user-facing toggle continues to enable extra debug payload capture for troubleshooting.
---
## ADL-044 — LOGPile project release tags use `vN.M`
**Date:** 2026-04-13
**Context:** The repository accumulated release tags in `vN.M.P` form, while the shared module
versioning contract in `bible/rules/patterns/module-versioning/contract.md` standardizes version
shape as `N.M`. Release tooling reads the git tag verbatim into build metadata and release
artifacts, so inconsistent tag shape leaks directly into packaged versions.
**Decision:** Use `vN.M` for LOGPile project release tags going forward. Do not create new
`vN.M.P` tags for repository releases. Build metadata, release directory names, and release notes
continue to inherit the exact git tag string from `git describe --tags`.
**Consequences:**
- Future project releases have a two-component version string such as `v1.12`.
- Release artifacts and `--version` output stay aligned with the tag shape without extra mapping.
- Existing historical `vN.M.P` tags remain as-is unless explicitly rewritten.

View File

@@ -50,11 +50,15 @@ func (c *RedfishConnector) collectRedfishLogEntries(ctx context.Context, client
}
for _, systemPath := range systemPaths {
collectFrom(joinPath(systemPath, "/LogServices"), isHardwareLogService)
for _, logServicesPath := range c.redfishLinkedCollectionPaths(ctx, client, req, baseURL, systemPath, "LogServices") {
collectFrom(logServicesPath, isHardwareLogService)
}
}
// Managers hold the IPMI SEL on AMI/MSI BMCs — include only the "SEL" service.
for _, managerPath := range managerPaths {
collectFrom(joinPath(managerPath, "/LogServices"), isManagerSELService)
for _, logServicesPath := range c.redfishLinkedCollectionPaths(ctx, client, req, baseURL, managerPath, "LogServices") {
collectFrom(logServicesPath, isManagerSELService)
}
}
if len(out) > 0 {
@@ -63,6 +67,42 @@ func (c *RedfishConnector) collectRedfishLogEntries(ctx context.Context, client
return out
}
func (c *RedfishConnector) redfishLinkedCollectionPaths(
ctx context.Context,
client *http.Client,
req Request,
baseURL, resourcePath, linkKey string,
) []string {
resourcePath = normalizeRedfishPath(resourcePath)
if resourcePath == "" || strings.TrimSpace(linkKey) == "" {
return nil
}
seen := make(map[string]struct{}, 2)
var out []string
add := func(path string) {
path = normalizeRedfishPath(path)
if path == "" {
return
}
if _, ok := seen[path]; ok {
return
}
seen[path] = struct{}{}
out = append(out, path)
}
add(joinPath(resourcePath, "/"+strings.TrimSpace(linkKey)))
resourceDoc, err := c.getJSON(ctx, client, req, baseURL, resourcePath)
if err == nil {
if linked := redfishLinkedPath(resourceDoc, linkKey); linked != "" {
add(linked)
}
}
return out
}
// fetchRedfishLogEntriesWithPaging fetches entries from a LogEntry collection,
// following nextLink pages. Stops early when entries older than cutoff are encountered
// (assumes BMC returns entries newest-first, which is typical).
@@ -182,7 +222,7 @@ func redfishLogServiceEntriesPath(svc map[string]interface{}) string {
// Audit, authentication, and session events are excluded.
func isHardwareLogEntry(entry map[string]interface{}) bool {
entryType := strings.TrimSpace(asString(entry["EntryType"]))
if strings.EqualFold(entryType, "Oem") {
if strings.EqualFold(entryType, "Oem") && !strings.EqualFold(strings.TrimSpace(asString(entry["OemRecordFormat"])), "Lenovo") {
return false
}
@@ -362,6 +402,9 @@ func parseIPMIDumpKV(message string) map[string]string {
// AMI/MSI BMCs often set Severity="OK" on all SEL records regardless of content,
// so we fall back to inferring severity from SensorType when the explicit field is unhelpful.
func redfishLogEntrySeverity(entry map[string]interface{}) models.Severity {
if redfishLogEntryLooksLikeWarning(entry) {
return models.SeverityWarning
}
// Newer Redfish uses MessageSeverity; older uses Severity.
raw := strings.ToLower(firstNonEmpty(
strings.TrimSpace(asString(entry["MessageSeverity"])),
@@ -380,6 +423,16 @@ func redfishLogEntrySeverity(entry map[string]interface{}) models.Severity {
}
}
func redfishLogEntryLooksLikeWarning(entry map[string]interface{}) bool {
joined := strings.ToLower(strings.TrimSpace(strings.Join([]string{
asString(entry["Message"]),
asString(entry["Name"]),
asString(entry["SensorType"]),
asString(entry["EntryCode"]),
}, " ")))
return strings.Contains(joined, "unqualified dimm")
}
// redfishSeverityFromSensorType infers event severity from the IPMI/Redfish SensorType string.
func redfishSeverityFromSensorType(sensorType string) models.Severity {
switch strings.ToLower(sensorType) {

View File

@@ -0,0 +1,125 @@
package collector
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"git.mchus.pro/mchus/logpile/internal/models"
)
func TestCollectRedfishLogEntries_UsesLinkedManagerLogServicesPath(t *testing.T) {
mux := http.NewServeMux()
register := func(path string, payload interface{}) {
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(payload)
})
}
register("/redfish/v1/Managers/1", map[string]interface{}{
"Id": "1",
"LogServices": map[string]interface{}{
"@odata.id": "/redfish/v1/Systems/1/LogServices",
},
})
register("/redfish/v1/Systems/1/LogServices", map[string]interface{}{
"Members": []map[string]string{
{"@odata.id": "/redfish/v1/Systems/1/LogServices/SEL"},
},
})
register("/redfish/v1/Systems/1/LogServices/SEL", map[string]interface{}{
"Id": "SEL",
"Entries": map[string]interface{}{
"@odata.id": "/redfish/v1/Systems/1/LogServices/SEL/Entries",
},
})
register("/redfish/v1/Systems/1/LogServices/SEL/Entries", map[string]interface{}{
"Members": []map[string]string{
{"@odata.id": "/redfish/v1/Systems/1/LogServices/SEL/Entries/1"},
},
})
register("/redfish/v1/Systems/1/LogServices/SEL/Entries/1", map[string]interface{}{
"Id": "1",
"Created": time.Now().UTC().Format(time.RFC3339),
"Message": "System found Unqualified DIMM in slot DIMM A1",
"MessageSeverity": "OK",
"SensorType": "Memory",
"EntryType": "Event",
})
ts := httptest.NewServer(mux)
defer ts.Close()
c := NewRedfishConnector()
got := c.collectRedfishLogEntries(context.Background(), ts.Client(), Request{
Host: ts.URL,
Port: 443,
Protocol: "redfish",
Username: "admin",
AuthType: "password",
Password: "secret",
TLSMode: "strict",
}, ts.URL, nil, []string{"/redfish/v1/Managers/1"})
if len(got) != 1 {
t.Fatalf("expected 1 collected log entry, got %d", len(got))
}
if got[0]["Message"] != "System found Unqualified DIMM in slot DIMM A1" {
t.Fatalf("unexpected collected message: %#v", got[0]["Message"])
}
}
func TestParseRedfishLogEntries_UnqualifiedDIMMBecomesWarning(t *testing.T) {
rawPayloads := map[string]any{
"redfish_log_entries": []any{
map[string]any{
"Id": "sel-1",
"Created": "2026-04-13T12:00:00Z",
"Message": "System found Unqualified DIMM in slot DIMM A1",
"MessageSeverity": "OK",
"SensorType": "Memory",
"EntryType": "Event",
},
},
}
events := parseRedfishLogEntries(rawPayloads, time.Date(2026, 4, 13, 12, 30, 0, 0, time.UTC))
if len(events) != 1 {
t.Fatalf("expected 1 event, got %d", len(events))
}
if events[0].Severity != models.SeverityWarning {
t.Fatalf("expected warning severity, got %q", events[0].Severity)
}
if events[0].Description != "System found Unqualified DIMM in slot DIMM A1" {
t.Fatalf("unexpected description: %q", events[0].Description)
}
}
func TestParseRedfishLogEntries_LenovoOEMEntryIsKept(t *testing.T) {
rawPayloads := map[string]any{
"redfish_log_entries": []any{
map[string]any{
"Id": "plat-55",
"Created": "2026-04-13T12:00:00Z",
"Message": "DIMM A1 is unqualified",
"MessageSeverity": "Warning",
"SensorType": "Memory",
"EntryType": "Oem",
"OemRecordFormat": "Lenovo",
"EntryCode": "Assert",
},
},
}
events := parseRedfishLogEntries(rawPayloads, time.Date(2026, 4, 13, 12, 30, 0, 0, time.UTC))
if len(events) != 1 {
t.Fatalf("expected 1 Lenovo OEM event, got %d", len(events))
}
if events[0].Severity != models.SeverityWarning {
t.Fatalf("expected warning severity, got %q", events[0].Severity)
}
}

View File

@@ -17,7 +17,7 @@ import (
"git.mchus.pro/mchus/logpile/internal/parser"
)
const parserVersion = "1.0"
const parserVersion = "1.1"
func init() {
parser.Register(&Parser{})
@@ -81,7 +81,9 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
result.Hardware.CPUs = parseCPUs(f.Content)
}
if f := findByPath(files, "tmp/inventory_dimm.log"); f != nil {
result.Hardware.Memory = parseDIMMs(f.Content)
memory, events := parseDIMMs(f.Content)
result.Hardware.Memory = memory
result.Events = append(result.Events, events...)
}
if f := findByPath(files, "tmp/inventory_disk.log"); f != nil {
result.Hardware.Storage = parseDisks(f.Content)
@@ -383,16 +385,18 @@ func parseCPUs(content []byte) []models.CPU {
return out
}
func parseDIMMs(content []byte) []models.MemoryDIMM {
func parseDIMMs(content []byte) ([]models.MemoryDIMM, []models.Event) {
var doc xccDIMMDoc
if err := json.Unmarshal(content, &doc); err != nil || len(doc.Items) == 0 {
return nil
return nil, nil
}
var out []models.MemoryDIMM
var events []models.Event
for _, item := range doc.Items {
for _, m := range item.Memory {
present := !strings.EqualFold(strings.TrimSpace(m.Status), "not present") &&
!strings.EqualFold(strings.TrimSpace(m.Status), "absent")
status := strings.TrimSpace(m.Status)
present := !strings.EqualFold(status, "not present") &&
!strings.EqualFold(status, "absent")
// memory_capacity is in GB (int); convert to MB
capacityGB := rawJSONToInt(m.Capacity)
dimm := models.MemoryDIMM{
@@ -406,12 +410,22 @@ func parseDIMMs(content []byte) []models.MemoryDIMM {
Manufacturer: strings.TrimSpace(m.Manufacturer),
SerialNumber: strings.TrimSpace(m.SerialNumber),
PartNumber: strings.TrimSpace(strings.TrimRight(m.PartNumber, " ")),
Status: strings.TrimSpace(m.Status),
Status: status,
}
out = append(out, dimm)
if isUnqualifiedDIMM(status) {
events = append(events, models.Event{
Source: "Memory",
SensorType: "Memory",
SensorName: dimm.Slot,
EventType: "DIMM Qualification",
Severity: models.SeverityWarning,
Description: status,
})
}
}
}
return out
return out, events
}
func parseDisks(content []byte) []models.Storage {
@@ -567,7 +581,7 @@ func parseEvents(content []byte) []models.Event {
ID: e.EventID,
Source: strings.TrimSpace(e.Source),
Description: strings.TrimSpace(e.Message),
Severity: xccSeverity(e.Severity),
Severity: xccSeverity(e.Severity, e.Message),
}
if t, err := parseXCCTime(e.Date); err == nil {
ev.Timestamp = t.UTC()
@@ -579,7 +593,10 @@ func parseEvents(content []byte) []models.Event {
// --- Helpers ---
func xccSeverity(s string) models.Severity {
func xccSeverity(s, message string) models.Severity {
if isUnqualifiedDIMM(message) {
return models.SeverityWarning
}
switch strings.ToUpper(strings.TrimSpace(s)) {
case "C":
return models.SeverityCritical
@@ -592,6 +609,10 @@ func xccSeverity(s string) models.Severity {
}
}
func isUnqualifiedDIMM(value string) bool {
return strings.Contains(strings.ToLower(strings.TrimSpace(value)), "unqualified dimm")
}
func parseXCCTime(s string) (time.Time, error) {
s = strings.TrimSpace(s)
formats := []string{

View File

@@ -1,10 +1,10 @@
package lenovo_xcc_test
package lenovo_xcc
import (
"testing"
"git.mchus.pro/mchus/logpile/internal/models"
"git.mchus.pro/mchus/logpile/internal/parser"
lxcc "git.mchus.pro/mchus/logpile/internal/parser/vendors/lenovo_xcc"
)
const exampleArchive = "/Users/mchusavitin/Documents/git/logpile/example/7D76CTO1WW_JF0002KT_xcc_mini-log_20260413-122150.zip"
@@ -15,7 +15,7 @@ func TestDetect_LenovoXCCMiniLog(t *testing.T) {
t.Skipf("example archive not available: %v", err)
}
p := &lxcc.Parser{}
p := &Parser{}
score := p.Detect(files)
if score < 80 {
t.Errorf("expected Detect score >= 80 for XCC mini-log archive, got %d", score)
@@ -28,7 +28,7 @@ func TestParse_LenovoXCCMiniLog_BasicSysInfo(t *testing.T) {
t.Skipf("example archive not available: %v", err)
}
p := &lxcc.Parser{}
p := &Parser{}
result, err := p.Parse(files)
if err != nil {
t.Fatalf("Parse returned error: %v", err)
@@ -53,7 +53,7 @@ func TestParse_LenovoXCCMiniLog_CPUs(t *testing.T) {
t.Skipf("example archive not available: %v", err)
}
p := &lxcc.Parser{}
p := &Parser{}
result, _ := p.Parse(files)
if result == nil || result.Hardware == nil {
t.Fatal("Parse returned nil")
@@ -73,7 +73,7 @@ func TestParse_LenovoXCCMiniLog_Memory(t *testing.T) {
t.Skipf("example archive not available: %v", err)
}
p := &lxcc.Parser{}
p := &Parser{}
result, _ := p.Parse(files)
if result == nil || result.Hardware == nil {
t.Fatal("Parse returned nil")
@@ -94,7 +94,7 @@ func TestParse_LenovoXCCMiniLog_Storage(t *testing.T) {
t.Skipf("example archive not available: %v", err)
}
p := &lxcc.Parser{}
p := &Parser{}
result, _ := p.Parse(files)
if result == nil || result.Hardware == nil {
t.Fatal("Parse returned nil")
@@ -112,7 +112,7 @@ func TestParse_LenovoXCCMiniLog_PCIeCards(t *testing.T) {
t.Skipf("example archive not available: %v", err)
}
p := &lxcc.Parser{}
p := &Parser{}
result, _ := p.Parse(files)
if result == nil || result.Hardware == nil {
t.Fatal("Parse returned nil")
@@ -130,7 +130,7 @@ func TestParse_LenovoXCCMiniLog_PSUs(t *testing.T) {
t.Skipf("example archive not available: %v", err)
}
p := &lxcc.Parser{}
p := &Parser{}
result, _ := p.Parse(files)
if result == nil || result.Hardware == nil {
t.Fatal("Parse returned nil")
@@ -150,7 +150,7 @@ func TestParse_LenovoXCCMiniLog_Sensors(t *testing.T) {
t.Skipf("example archive not available: %v", err)
}
p := &lxcc.Parser{}
p := &Parser{}
result, _ := p.Parse(files)
if result == nil {
t.Fatal("Parse returned nil")
@@ -168,7 +168,7 @@ func TestParse_LenovoXCCMiniLog_Events(t *testing.T) {
t.Skipf("example archive not available: %v", err)
}
p := &lxcc.Parser{}
p := &Parser{}
result, _ := p.Parse(files)
if result == nil {
t.Fatal("Parse returned nil")
@@ -192,7 +192,7 @@ func TestParse_LenovoXCCMiniLog_FRU(t *testing.T) {
t.Skipf("example archive not available: %v", err)
}
p := &lxcc.Parser{}
p := &Parser{}
result, _ := p.Parse(files)
if result == nil {
t.Fatal("Parse returned nil")
@@ -210,7 +210,7 @@ func TestParse_LenovoXCCMiniLog_Firmware(t *testing.T) {
t.Skipf("example archive not available: %v", err)
}
p := &lxcc.Parser{}
p := &Parser{}
result, _ := p.Parse(files)
if result == nil || result.Hardware == nil {
t.Fatal("Parse returned nil")
@@ -223,3 +223,36 @@ func TestParse_LenovoXCCMiniLog_Firmware(t *testing.T) {
t.Logf("FW[%d]: name=%q version=%q buildtime=%q", i, f.DeviceName, f.Version, f.BuildTime)
}
}
func TestParseDIMMs_UnqualifiedDIMMAddsWarningEvent(t *testing.T) {
content := []byte(`{
"items": [{
"memory": [{
"memory_name": "DIMM A1",
"memory_status": "Unqualified DIMM",
"memory_type": "DDR5",
"memory_capacity": 32
}]
}]
}`)
memory, events := parseDIMMs(content)
if len(memory) != 1 {
t.Fatalf("expected 1 DIMM, got %d", len(memory))
}
if len(events) != 1 {
t.Fatalf("expected 1 warning event, got %d", len(events))
}
if events[0].Severity != models.SeverityWarning {
t.Fatalf("expected warning severity, got %q", events[0].Severity)
}
if events[0].SensorName != "DIMM A1" {
t.Fatalf("unexpected sensor name: %q", events[0].SensorName)
}
}
func TestSeverity_UnqualifiedDIMMMessageBecomesWarning(t *testing.T) {
if got := xccSeverity("I", "System found Unqualified DIMM in slot DIMM A1"); got != models.SeverityWarning {
t.Fatalf("expected warning severity, got %q", got)
}
}

View File

@@ -128,6 +128,7 @@ echo ""
# Show next steps
echo -e "${YELLOW}Next steps:${NC}"
echo " 1. Create git tag:"
echo " # LOGPile release tags use vN.M, for example: v1.12"
echo " git tag -a ${VERSION} -m \"Release ${VERSION}\""
echo ""
echo " 2. Push tag to remote:"