fix(parser): parse Dell iDRAC10 TSR inventory from captured Redfish walk

iDRAC10-generation TSR bundles no longer ship sysinfo_dcim_view.xml /
sysinfo_dcim_softwareidentity.xml, so the dell parser produced events but
no hardware inventory for them. These bundles instead carry
redfishidracwalk.tar.gz, a captured dump of the iDRAC's own Redfish tree.

Add vendors/redfishtree, a shared helper that reconstructs a path->document
map from a tar.gz/zip-packaged Redfish walk (vendor-independent detection:
path hint + /redfish/v1 service-root/Systems/Chassis structural check) and
replays it through the existing collector.ReplayRedfishFromRawPayloads.
vendors/dell uses it to enrich DCIM-XML-derived data (append-only, existing
dedupe passes resolve overlaps). Also register vendors/redfishwalk, a
low-confidence fallback VendorParser using the same helpers, so any other
vendor that starts shipping this kind of raw Redfish walk is picked up
automatically without a dedicated parser.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-08-11 10:49:32 +03:00
co-authored by Claude Sonnet 5
parent 6e1a8232ec
commit f599215760
9 changed files with 693 additions and 1 deletions
+3
View File
@@ -48,6 +48,8 @@ func (p *Parser) Detect(files []parser.ExtractedFile) int {
confidence += 20
case strings.HasSuffix(path, "curr_lclog.xml"):
confidence += 10
case strings.HasSuffix(path, "redfishidracwalk.tar.gz"):
confidence += 20
case path == "signature":
confidence += 5
}
@@ -94,6 +96,7 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
if f := findBySuffix(expanded, "curr_lclog.xml"); f != nil {
result.Events = append(result.Events, parseLCEventsXML(f.Content)...)
}
parseRedfishWalk(findRedfishWalkArchive(expanded), result)
result.Hardware.Storage = dedupeStorage(result.Hardware.Storage)
result.Hardware.Volumes = dedupeVolumes(result.Hardware.Volumes)
+86
View File
@@ -0,0 +1,86 @@
package dell
import (
"strings"
"git.mchus.pro/mchus/logpile/internal/collector"
"git.mchus.pro/mchus/logpile/internal/models"
"git.mchus.pro/mchus/logpile/internal/parser"
"git.mchus.pro/mchus/logpile/internal/parser/vendors/redfishtree"
)
// redfishidracwalk.tar.gz is the inventory source on newer iDRAC10-generation
// TSR bundles, which no longer ship the sysinfo_dcim_view.xml / softwareidentity
// CIM-XML files. It is a captured directory dump of the iDRAC's own Redfish
// tree (see vendors/redfishtree for the detection/parsing rule), stored under
// tsr/hardware/sysinfo/inventory/.
func findRedfishWalkArchive(expanded []parser.ExtractedFile) *parser.ExtractedFile {
for i := range expanded {
path := strings.ToLower(strings.TrimSpace(expanded[i].Path))
if strings.HasSuffix(path, "redfishidracwalk.tar.gz") {
return &expanded[i]
}
}
// Fall back to the vendor-independent structural detection, in case a
// future iDRAC generation renames the export.
for _, f := range redfishtree.FindCandidateArchives(expanded) {
if redfishtree.Build(f.Content) != nil {
f := f
return &f
}
}
return nil
}
// parseRedfishWalk enriches result with hardware inventory replayed from a
// captured Redfish tree dump. It only adds data; existing dedupe passes in
// Parse() resolve overlaps with the DCIM-XML-derived source in favor of
// whichever entry was appended first (DCIM-derived data, when present).
func parseRedfishWalk(f *parser.ExtractedFile, result *models.AnalysisResult) {
if f == nil {
return
}
tree := redfishtree.Build(f.Content)
if tree == nil {
return
}
replayed, err := collector.ReplayRedfishFromRawPayloads(map[string]any{"redfish_tree": tree}, nil)
if err != nil || replayed == nil {
return
}
mergeRedfishReplay(result, replayed)
}
func mergeRedfishReplay(result *models.AnalysisResult, replayed *models.AnalysisResult) {
if result.Hardware == nil {
result.Hardware = &models.HardwareConfig{}
}
hw := result.Hardware
if rhw := replayed.Hardware; rhw != nil {
setIfEmpty(&hw.BoardInfo.Manufacturer, rhw.BoardInfo.Manufacturer)
setIfEmpty(&hw.BoardInfo.ProductName, rhw.BoardInfo.ProductName)
setIfEmpty(&hw.BoardInfo.Description, rhw.BoardInfo.Description)
setIfEmpty(&hw.BoardInfo.SerialNumber, rhw.BoardInfo.SerialNumber)
setIfEmpty(&hw.BoardInfo.PartNumber, rhw.BoardInfo.PartNumber)
setIfEmpty(&hw.BoardInfo.Version, rhw.BoardInfo.Version)
setIfEmpty(&hw.BoardInfo.UUID, rhw.BoardInfo.UUID)
setIfEmpty(&hw.BoardInfo.BMCMACAddress, rhw.BoardInfo.BMCMACAddress)
hw.Firmware = append(hw.Firmware, rhw.Firmware...)
hw.CPUs = append(hw.CPUs, rhw.CPUs...)
hw.Memory = append(hw.Memory, rhw.Memory...)
hw.Storage = append(hw.Storage, rhw.Storage...)
hw.Volumes = append(hw.Volumes, rhw.Volumes...)
hw.PCIeDevices = append(hw.PCIeDevices, rhw.PCIeDevices...)
hw.GPUs = append(hw.GPUs, rhw.GPUs...)
hw.NetworkAdapters = append(hw.NetworkAdapters, rhw.NetworkAdapters...)
hw.PowerSupply = append(hw.PowerSupply, rhw.PowerSupply...)
}
result.Sensors = append(result.Sensors, replayed.Sensors...)
result.FRU = append(result.FRU, replayed.FRU...)
result.Events = append(result.Events, replayed.Events...)
if result.InventoryLastModifiedAt.IsZero() {
result.InventoryLastModifiedAt = replayed.InventoryLastModifiedAt
}
}
+115
View File
@@ -0,0 +1,115 @@
package dell
import (
"archive/tar"
"bytes"
"compress/gzip"
"testing"
"git.mchus.pro/mchus/logpile/internal/parser"
)
func makeRedfishWalkTarGz(t *testing.T, files map[string]string) []byte {
t.Helper()
var buf bytes.Buffer
gzw := gzip.NewWriter(&buf)
tw := tar.NewWriter(gzw)
for name, content := range files {
hdr := &tar.Header{
Name: name,
Mode: 0o644,
Size: int64(len(content)),
}
if err := tw.WriteHeader(hdr); err != nil {
t.Fatalf("write tar header %s: %v", name, err)
}
if _, err := tw.Write([]byte(content)); err != nil {
t.Fatalf("write tar content %s: %v", name, err)
}
}
if err := tw.Close(); err != nil {
t.Fatalf("close tar: %v", err)
}
if err := gzw.Close(); err != nil {
t.Fatalf("close gzip: %v", err)
}
return buf.Bytes()
}
// TestParseNestedTSRZip_RedfishWalkFillsInventory reproduces an iDRAC10-generation
// TSR bundle: no sysinfo_dcim_view.xml / sysinfo_dcim_softwareidentity.xml, only
// metadata.json, curr_lclog.xml and a captured redfishidracwalk.tar.gz. Hardware
// inventory must come from the Redfish walk instead of being left empty.
func TestParseNestedTSRZip_RedfishWalkFillsInventory(t *testing.T) {
redfishWalk := makeRedfishWalkTarGz(t, map[string]string{
"redfish/v1/index.json": `{"@odata.id":"/redfish/v1"}`,
"redfish/v1/Systems/index.json": `{
"@odata.id":"/redfish/v1/Systems",
"Members":[{"@odata.id":"/redfish/v1/Systems/System.Embedded.1"}]
}`,
"redfish/v1/Systems/System.Embedded.1/index.json": `{
"@odata.id":"/redfish/v1/Systems/System.Embedded.1",
"Id":"System.Embedded.1",
"Manufacturer":"Dell Inc.",
"Model":"PowerEdge R470",
"SKU":"1TVFYL4",
"Processors":{"@odata.id":"/redfish/v1/Systems/System.Embedded.1/Processors"},
"Memory":{"@odata.id":"/redfish/v1/Systems/System.Embedded.1/Memory"}
}`,
"redfish/v1/Systems/System.Embedded.1/Processors/index.json": `{
"@odata.id":"/redfish/v1/Systems/System.Embedded.1/Processors",
"Members":[{"@odata.id":"/redfish/v1/Systems/System.Embedded.1/Processors/CPU.Socket.0"}]
}`,
"redfish/v1/Systems/System.Embedded.1/Processors/CPU.Socket.0/index.json": `{
"@odata.id":"/redfish/v1/Systems/System.Embedded.1/Processors/CPU.Socket.0",
"Id":"CPU.Socket.0",
"ProcessorType":"CPU",
"Model":"Intel Xeon 6740P",
"Manufacturer":"Intel"
}`,
"redfish/v1/Systems/System.Embedded.1/Memory/index.json": `{
"@odata.id":"/redfish/v1/Systems/System.Embedded.1/Memory",
"Members":[{"@odata.id":"/redfish/v1/Systems/System.Embedded.1/Memory/DIMM.Socket.A1"}]
}`,
"redfish/v1/Systems/System.Embedded.1/Memory/DIMM.Socket.A1/index.json": `{
"@odata.id":"/redfish/v1/Systems/System.Embedded.1/Memory/DIMM.Socket.A1",
"Id":"DIMM.Socket.A1",
"DeviceLocator":"DIMM.Socket.A1",
"CapacityMiB":32768,
"SerialNumber":"DIMM-A1-SN"
}`,
})
inner := makeZipArchive(t, map[string][]byte{
"tsr/metadata.json": []byte(`{"Make":"Dell Inc.","Model":"PowerEdge R470","ServiceTag":"1TVFYL4"}`),
"tsr/hardware/sysinfo/lcfiles/curr_lclog.xml": []byte(`<CIM><MESSAGE><SIMPLEREQ/></MESSAGE></CIM>`),
"tsr/hardware/sysinfo/inventory/redfishidracwalk.tar.gz": redfishWalk,
})
p := &Parser{}
files := []parser.ExtractedFile{
{Path: "signature", Content: []byte("ok")},
{Path: "TSR20260721231613_1TVFYL4.pl.zip", Content: inner},
}
if score := p.Detect(files); score < 80 {
t.Fatalf("expected high detect score for iDRAC10-style TSR without DCIM XML, got %d", score)
}
result, err := p.Parse(files)
if err != nil {
t.Fatalf("Parse() failed: %v", err)
}
if result.Hardware == nil {
t.Fatal("expected non-nil Hardware")
}
if got := result.Hardware.BoardInfo.Manufacturer; got != "Dell Inc." {
t.Fatalf("expected board manufacturer from metadata.json, got %q", got)
}
if len(result.Hardware.CPUs) != 1 || result.Hardware.CPUs[0].Model != "Intel Xeon 6740P" {
t.Fatalf("expected CPU inventory from redfishidracwalk.tar.gz, got %+v", result.Hardware.CPUs)
}
if len(result.Hardware.Memory) != 1 || result.Hardware.Memory[0].SerialNumber != "DIMM-A1-SN" {
t.Fatalf("expected memory inventory from redfishidracwalk.tar.gz, got %+v", result.Hardware.Memory)
}
}