fix(parser): support Inspur onekeylog per-file component/*.txt D-Bus layout
Some onekeylog BMC firmware variants split the combined component.log into per-file D-Bus GetAll transcripts under component/ (e.g. PowerSupplyInfo.txt, FanInfo.txt), which the inspur parser did not read, leaving PSU and fan data empty. Add a GETALL block parser and wire it as a fallback for PSU and fan telemetry when component.log is absent; document the layout and known gaps in bible-local. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
4ce0251ce4
commit
5677c49998
@@ -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_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 |
|
| `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 |
|
| `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 |
|
| `lenovo_xcc` | Lenovo XCC mini-log ZIP archives | JSON inventory + platform event logs |
|
||||||
| `nvidia` | HGX Field Diagnostics | GPU- and fabric-heavy diagnostic input |
|
| `nvidia` | HGX Field Diagnostics | GPU- and fabric-heavy diagnostic input |
|
||||||
| `nvidia_bug_report` | `nvidia-bug-report-*.log.gz` | dmidecode, lspci, NVIDIA driver sections |
|
| `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 <Section> info:` JSON blocks (PSU, fan, HDD, PCIe, network, ...).
|
||||||
|
2. Per-file layout (seen on newer/OEM firmware): archive root is `dump_<serial>_<timestamp>/`
|
||||||
|
(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> 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`)
|
### xFusion iBMC Dump / File Export (`xfusion`)
|
||||||
|
|
||||||
**Status:** Ready (v1.1.0). Tested on xFusion G5500 V7 `tar.gz` exports.
|
**Status:** Ready (v1.1.0). Tested on xFusion G5500 V7 `tar.gz` exports.
|
||||||
|
|||||||
@@ -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
|
- Report-level filtering or extra report sections must be implemented in `internal/chart`, not in
|
||||||
`web/static/js/app.js`.
|
`web/static/js/app.js`.
|
||||||
- Removing legacy DOM renderers from the shell is a correctness fix, not a behavior regression.
|
- 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> OBJect` blocks, tab-separated `"field" "type":"x" "data":value`
|
||||||
|
triples, not valid JSON). The archive root is also named `dump_<serial>_<timestamp>/` 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.
|
||||||
|
|||||||
+204
@@ -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 <name> 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<n>_<rotor>_Speed (RPM); PWM duty cycle objects are
|
||||||
|
// named Pwm_<n> (percent). FAN<n>_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
|
||||||
|
}
|
||||||
+19
-1
@@ -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.1"
|
const parserVersion = "2.2"
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
parser.Register(&Parser{})
|
parser.Register(&Parser{})
|
||||||
@@ -59,6 +59,14 @@ func (p *Parser) Detect(files []parser.ExtractedFile) int {
|
|||||||
if strings.Contains(path, "component/component.log") {
|
if strings.Contains(path, "component/component.log") {
|
||||||
confidence += 15
|
confidence += 15
|
||||||
}
|
}
|
||||||
|
// Per-file component/ variant (no combined component.log), e.g. dumps
|
||||||
|
// named dump_<serial>_<timestamp>/ 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
|
// Check for asset.json with Inspur-specific structure
|
||||||
if strings.HasSuffix(path, "asset.json") {
|
if strings.HasSuffix(path, "asset.json") {
|
||||||
@@ -183,6 +191,16 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
|
|||||||
Description: desc,
|
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),
|
// Enrich runtime component data from Redis snapshot (serials, FW, telemetry),
|
||||||
|
|||||||
Reference in New Issue
Block a user