diff --git a/bible-local/06-parsers.md b/bible-local/06-parsers.md index d46f3bb..a56908c 100644 --- a/bible-local/06-parsers.md +++ b/bible-local/06-parsers.md @@ -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 | diff --git a/bible-local/08-build-release.md b/bible-local/08-build-release.md index da7d26b..66a9b62 100644 --- a/bible-local/08-build-release.md +++ b/bible-local/08-build-release.md @@ -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 diff --git a/bible-local/10-decisions.md b/bible-local/10-decisions.md index eae278b..7566f19 100644 --- a/bible-local/10-decisions.md +++ b/bible-local/10-decisions.md @@ -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. diff --git a/internal/collector/redfish_logentries.go b/internal/collector/redfish_logentries.go index bbd083f..e945547 100644 --- a/internal/collector/redfish_logentries.go +++ b/internal/collector/redfish_logentries.go @@ -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) { diff --git a/internal/collector/redfish_logentries_test.go b/internal/collector/redfish_logentries_test.go new file mode 100644 index 0000000..fe5a38a --- /dev/null +++ b/internal/collector/redfish_logentries_test.go @@ -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) + } +} diff --git a/internal/parser/vendors/lenovo_xcc/parser.go b/internal/parser/vendors/lenovo_xcc/parser.go index 2d53c68..28c8eec 100644 --- a/internal/parser/vendors/lenovo_xcc/parser.go +++ b/internal/parser/vendors/lenovo_xcc/parser.go @@ -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{ diff --git a/internal/parser/vendors/lenovo_xcc/parser_test.go b/internal/parser/vendors/lenovo_xcc/parser_test.go index 41e2ba3..3e7132b 100644 --- a/internal/parser/vendors/lenovo_xcc/parser_test.go +++ b/internal/parser/vendors/lenovo_xcc/parser_test.go @@ -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) + } +} diff --git a/scripts/release.sh b/scripts/release.sh index a45402c..7838f2d 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -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:"