diff --git a/bible-local/06-parsers.md b/bible-local/06-parsers.md index a56908c..ade4af0 100644 --- a/bible-local/06-parsers.md +++ b/bible-local/06-parsers.md @@ -54,7 +54,7 @@ When `vendor_id` and `device_id` are known but the model name is missing or gene | `h3c_g5` | H3C SDS G5 bundles | INI/XML/CSV-driven hardware and event parsing | | `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 | +| `inspur` | onekeylog archives (classic `component.log` and per-file `component/*.txt` D-Bus layouts) | 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 | @@ -150,6 +150,49 @@ entire internal `zbb` schema. --- +### Inspur / Kaytus (`inspur`) + +**Status:** Ready (v2.2). Tested on Inspur NF5468M7 / Kaytus KR4268X2 onekeylog archives. + +**Archive format:** onekeylog BMC diagnostic dump. Two known on-disk layouts: +1. Classic layout: top-level `onekeylog/` directory, single combined `component/component.log` + with `RESTful
info:` JSON blocks (PSU, fan, HDD, PCIe, network, ...). +2. Per-file layout (seen on newer/OEM firmware): archive root is `dump__/` + (not `onekeylog/`), and `component.log` is absent. Each component is instead dumped separately + under `component/*.txt` as a raw D-Bus `GetAll` transcript: `GETALL OBJect` blocks with + tab-separated `"field"\t"type":"x"\t"data":value` triples. This is not valid JSON and needs its + own line-oriented extraction (`internal/parser/vendors/inspur/component_dbus.go`). + +**Detection:** `onekeylog/` path segment, `devicefrusdr.log`, `component/component.log`, +`onekeylog_dreport.log` filename (per-file layout marker), `component/PowerSupplyInfo.txt` +(per-file layout marker), plus `asset.json` content markers (`VersionInfo` + `CpuInfo` + +`MemInfo`). + +**Extracted data (current):** +- CPU / Memory / PCIe / Storage inventory from `asset.json` (both layouts) +- FRU + SDR from `devicefrusdr.log` (classic layout) +- PSU inventory: from `component.log`'s `RESTful PSU info:` block (classic layout) or + `component/PowerSupplyInfo.txt` GETALL transcript (per-file layout) +- Fan RPM/PWM sensors: from `component.log`'s `RESTful fan info:` block (classic layout) or + `component/FanInfo.txt` GETALL transcript (per-file layout) +- Redis snapshot enrichment for serials/firmware/telemetry when `redis-dump.rdb` is present + +**Known gaps (per-file layout only):** +- `component/NetworkAdapter.txt` and `component/HDDBpListInfo.txt` use a different + `busctl --verbose` object-tree dump (not the GETALL format) and are not currently parsed. NIC + identity/MAC data is still available via PCIe device inventory from `asset.json`, so this is not + a data-loss gap for NICs. +- `component/RAID.txt` mixes formats per RAID controller family (`getLsiStorageCollection`, + `getPMCStorageCollection`); not currently parsed. + +**Implementation note:** the same GETALL object name can recur across multiple command sections in +one file with different field subsets (e.g. `Pwm_N` appears once under the FanPWM sensor query +with a real `Value` reading, and again later under FanControl with only a `Target` setpoint). +`parseDBusGetAllObjects` unions fields across recurring object names, first-seen wins per field, so +a later content-free duplicate cannot silently blank out an earlier real reading. + +--- + ### xFusion iBMC Dump / File Export (`xfusion`) **Status:** Ready (v1.1.0). Tested on xFusion G5500 V7 `tar.gz` exports. diff --git a/bible-local/10-decisions.md b/bible-local/10-decisions.md index 4d8f4a1..de88fd7 100644 --- a/bible-local/10-decisions.md +++ b/bible-local/10-decisions.md @@ -1198,3 +1198,34 @@ Do not keep parallel report sections, filters, or table renderers in shell JavaS - Report-level filtering or extra report sections must be implemented in `internal/chart`, not in `web/static/js/app.js`. - Removing legacy DOM renderers from the shell is a correctness fix, not a behavior regression. + +--- + +## ADL-047 — Inspur onekeylog per-file `component/*.txt` D-Bus layout is parsed alongside `component.log` + +**Date:** 2026-07-23 +**Context:** A field dump (`dump_29E201150_20260723-1418.tar.gz`) came from an Inspur/Kaytus +onekeylog BMC that does not produce the combined `component/component.log` the `inspur` parser +expected. Instead it dumps each component separately under `component/*.txt` as raw D-Bus `GetAll` +transcripts (`GETALL OBJect` blocks, tab-separated `"field" "type":"x" "data":value` +triples, not valid JSON). The archive root is also named `dump__/` rather than +`onekeylog/`, so path-based detection did not recognize the layout either. As a result PSU and fan +data were silently empty on this archive class even though the source files carried full data. +**Decision:** Treat the per-file `component/*.txt` D-Bus transcript as a second supported onekeylog +layout, not a new vendor. Added `internal/parser/vendors/inspur/component_dbus.go` with a +line-oriented GETALL block parser (`parseDBusGetAllObjects`), wired as a fallback in `parser.go` +when `component.log` is absent: `ParseComponentDirPowerSupply` from `PowerSupplyInfo.txt` and +`ParseComponentDirFan` from `FanInfo.txt`. Extended `Detect()` with `onekeylog_dreport.log` and +`component/powersupplyinfo.txt` markers so this layout is recognized without relying solely on +`asset.json` content markers. GETALL object names can recur across command sections with different +field subsets (e.g. `Pwm_N` under FanPWM has a `Value` reading, the same name under FanControl has +only a `Target` setpoint); fields are unioned across occurrences with first-seen-wins per key so a +later content-free duplicate cannot blank out an earlier real reading. +**Consequences:** +- PSU and fan RPM/PWM telemetry now parse correctly for this onekeylog layout. +- `component/NetworkAdapter.txt`, `component/HDDBpListInfo.txt` (busctl `--verbose` object-tree + dump) and `component/RAID.txt` (mixed per-controller formats) remain unparsed for this layout; + NIC data is not lost since PCIe inventory from `asset.json` already carries NIC model/MAC. + Revisit only if a real archive needs that specific data and asset.json does not cover it. +- Any future component/*.txt GETALL consumer should reuse `parseDBusGetAllObjects` rather than + re-implementing block splitting or field extraction. diff --git a/internal/parser/vendors/inspur/component_dbus.go b/internal/parser/vendors/inspur/component_dbus.go new file mode 100644 index 0000000..3e6a8e6 --- /dev/null +++ b/internal/parser/vendors/inspur/component_dbus.go @@ -0,0 +1,204 @@ +package inspur + +import ( + "fmt" + "regexp" + "sort" + "strconv" + "strings" + + "git.mchus.pro/mchus/logpile/internal/models" +) + +// Some onekeylog BMC firmware variants do not produce a combined component.log. +// Instead each component is dumped as its own file under component/ (e.g. +// PowerSupplyInfo.txt, FanInfo.txt) using a raw D-Bus GetAll transcript: +// +// -------------------GETALL PSU0 OBJect------------------------- +// Get service Name:: xyz.openbmc_project.PSUSensor +// Get firmware Name :: PSU0 +// Get PSU0 ConfigData::{"type":"a{sv}" "data":[{"Manufacturer" "type":"s" "data":"APLUSPOWER" +// "Model" "type":"s" "data":"AP-CA1300F12B7" +// ... +// "Present" "type":"b" "data":true}}]} +// +// This is not valid JSON (bare tab-separated key/type/data triples), so it needs +// its own line-oriented extraction rather than encoding/json. + +var dbusGetAllObjectHeaderRe = regexp.MustCompile(`-{5,}GETALL\s+(\S+)\s+OBJect-{5,}`) +var psuSlotNameRe = regexp.MustCompile(`(?i)^PSU\d+$`) +var fanSpeedNameRe = regexp.MustCompile(`(?i)^FAN\d+_\d+_Speed$`) +var fanPwmNameRe = regexp.MustCompile(`(?i)^Pwm_\d+$`) +var dbusFieldRe = regexp.MustCompile(`"(\w+)"\t"type":"[a-zA-Z]+"\t"data":("(?:[^"\\]|\\.)*"|true|false|-?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?)`) + +// parseDBusGetAllObjects extracts one field map per "GETALL OBJect" block +// found in a component/*.txt transcript. The same object name can recur across +// multiple command sections (e.g. Pwm_0 shows up both under the FanPWM sensor +// query with a Value reading, and again under FanControl with only a Target +// setpoint), so fields are unioned across occurrences, first-seen wins per key. +func parseDBusGetAllObjects(text string) map[string]map[string]string { + headers := dbusGetAllObjectHeaderRe.FindAllStringSubmatchIndex(text, -1) + if len(headers) == 0 { + return nil + } + + objects := make(map[string]map[string]string, len(headers)) + for i, h := range headers { + name := text[h[2]:h[3]] + start := h[1] + end := len(text) + if i+1 < len(headers) { + end = headers[i+1][0] + } + + fields, ok := objects[name] + if !ok { + fields = make(map[string]string) + objects[name] = fields + } + for _, m := range dbusFieldRe.FindAllStringSubmatch(text[start:end], -1) { + if _, exists := fields[m[1]]; !exists { + fields[m[1]] = unquoteDBusValue(m[2]) + } + } + } + return objects +} + +func unquoteDBusValue(raw string) string { + if len(raw) >= 2 && raw[0] == '"' && raw[len(raw)-1] == '"' { + if unquoted, err := strconv.Unquote(raw); err == nil { + return unquoted + } + return strings.Trim(raw, `"`) + } + return raw +} + +func dbusFieldInt(s string) int { + return int(dbusFieldFloat(s)) +} + +// ParseComponentDirPowerSupply parses component/PowerSupplyInfo.txt, the per-file +// PSU transcript used by onekeylog variants that lack a combined component.log. +func ParseComponentDirPowerSupply(content []byte, hw *models.HardwareConfig) { + if hw == nil { + return + } + + objects := parseDBusGetAllObjects(string(content)) + if len(objects) == 0 { + return + } + + var merged []models.PSU + seen := make(map[string]int) + for _, existing := range hw.PowerSupply { + key := inspurPSUKey(existing) + if key == "" { + continue + } + seen[key] = len(merged) + merged = append(merged, existing) + } + + names := make([]string, 0, len(objects)) + for name := range objects { + names = append(names, name) + } + sort.Strings(names) + + for _, name := range names { + fields := objects[name] + // "Total_Power_Sum" is also tagged Type=PowerSupply but is an aggregate + // pseudo-object with no serial/model; only real PSUn slots are individual units. + if fields["Type"] != "PowerSupply" || !psuSlotNameRe.MatchString(name) { + continue + } + + item := models.PSU{ + Slot: name, + Present: fields["Present"] == "true", + Model: strings.TrimSpace(fields["Model"]), + Vendor: strings.TrimSpace(fields["Manufacturer"]), + WattageW: dbusFieldInt(fields["RatedPower"]), + SerialNumber: strings.TrimSpace(fields["SerialNumber"]), + PartNumber: strings.TrimSpace(fields["PartNumber"]), + Firmware: strings.TrimSpace(fields["Version"]), + } + + key := inspurPSUKey(item) + if idx, ok := seen[key]; ok { + mergeInspurPSU(&merged[idx], item) + continue + } + if key != "" { + seen[key] = len(merged) + } + merged = append(merged, item) + } + + hw.PowerSupply = merged +} + +// ParseComponentDirFan parses component/FanInfo.txt, the per-file fan transcript +// used by onekeylog variants that lack a combined component.log. Tachometer +// objects are named FAN__Speed (RPM); PWM duty cycle objects are +// named Pwm_ (percent). FAN_Status objects carry no telemetry, only +// chassis associations, and are skipped. +func ParseComponentDirFan(content []byte) []models.SensorReading { + objects := parseDBusGetAllObjects(string(content)) + if len(objects) == 0 { + return nil + } + + names := make([]string, 0, len(objects)) + for name := range objects { + names = append(names, name) + } + sort.Strings(names) + + out := make([]models.SensorReading, 0, len(names)) + for _, name := range names { + fields := objects[name] + value := fields["Value"] + if value == "" { + continue + } + + switch { + case fanSpeedNameRe.MatchString(name): + status := "OK" + if fields["Functional"] == "false" || fields["Available"] == "false" { + status = "Critical" + } + out = append(out, models.SensorReading{ + Name: name, + Type: "fan_speed", + Value: dbusFieldFloat(value), + Unit: "RPM", + RawValue: fmt.Sprintf("rpm=%s", value), + Status: status, + }) + case fanPwmNameRe.MatchString(name): + out = append(out, models.SensorReading{ + Name: name, + Type: "fan_pwm", + Value: dbusFieldFloat(value), + Unit: "%", + RawValue: fmt.Sprintf("pct=%s", value), + Status: "OK", + }) + } + } + + return out +} + +func dbusFieldFloat(s string) float64 { + f, err := strconv.ParseFloat(s, 64) + if err != nil { + return 0 + } + return f +} diff --git a/internal/parser/vendors/inspur/parser.go b/internal/parser/vendors/inspur/parser.go index 35ff750..b54b78d 100644 --- a/internal/parser/vendors/inspur/parser.go +++ b/internal/parser/vendors/inspur/parser.go @@ -16,7 +16,7 @@ import ( // parserVersion - version of this parser module // IMPORTANT: Increment this version when making changes to parser logic! -const parserVersion = "2.1" +const parserVersion = "2.2" func init() { parser.Register(&Parser{}) @@ -59,6 +59,14 @@ func (p *Parser) Detect(files []parser.ExtractedFile) int { if strings.Contains(path, "component/component.log") { confidence += 15 } + // Per-file component/ variant (no combined component.log), e.g. dumps + // named dump__/ rather than onekeylog/. + if strings.HasSuffix(path, "onekeylog_dreport.log") { + confidence += 25 + } + if strings.Contains(path, "component/powersupplyinfo.txt") { + confidence += 15 + } // Check for asset.json with Inspur-specific structure if strings.HasSuffix(path, "asset.json") { @@ -183,6 +191,16 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er Description: desc, }) } + } else { + // Some onekeylog BMC variants split component.log into per-file + // D-Bus transcripts under component/ instead of one combined log. + if f := parser.FindFileByName(files, "PowerSupplyInfo.txt"); f != nil { + ParseComponentDirPowerSupply(f.Content, result.Hardware) + } + if f := parser.FindFileByName(files, "FanInfo.txt"); f != nil { + fanSensors := ParseComponentDirFan(f.Content) + result.Sensors = mergeSensorReadings(result.Sensors, fanSensors) + } } // Enrich runtime component data from Redis snapshot (serials, FW, telemetry),