diff --git a/bible-local/06-parsers.md b/bible-local/06-parsers.md index ade4af0..8c5e762 100644 --- a/bible-local/06-parsers.md +++ b/bible-local/06-parsers.md @@ -58,6 +58,7 @@ When `vendor_id` and `device_id` are known but the model name is missing or gene | `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 | +| `redfish_walk` | Any archive carrying a captured Redfish directory-tree walk (tar.gz/zip of `/index.json` docs) | Low-confidence (35) vendor-agnostic fallback; see `dell` for the first known source and `redfishtree` helper package | | `unraid` | Unraid diagnostics/log bundles | Server and storage-focused parsing | | `xfusion` | xFusion iBMC `tar.gz` dump / file export | AppDump + RTOSDump + LogDump merge for hardware and firmware | | `xigmanas` | XigmaNAS plain logs | FreeBSD/NAS-oriented inventory | @@ -216,6 +217,51 @@ and `LogDump/` trees. --- +### Dell TSR (`dell`) + +**Status:** Ready (v3.0). Two inventory source generations, both handled by the same parser. + +**Archive format:** TSR nested zip (`signature` + `TSR*.pl.zip`). + +**Extracted data:** +- Older/DCIM-XML generation: `sysinfo_dcim_view.xml` (board/CPU/NIC/etc.), `sysinfo_dcim_softwareidentity.xml` + (firmware), `sysinfo_cim_sensor.xml` (sensors), `curr_lclog.xml` (Lifecycle Controller events), + `tsr/metadata.json` (board identity fallback). +- iDRAC10-generation: same `metadata.json`/`curr_lclog.xml`, but no DCIM-XML files — instead + `tsr/hardware/sysinfo/inventory/redfishidracwalk.tar.gz` holds a captured Redfish directory-tree + walk. Parsed via the shared `redfishtree` helper package and replayed through + `collector.ReplayRedfishFromRawPayloads` (`internal/parser/vendors/dell/redfish_walk.go`), then + append-merged into the same `Hardware`/`Sensors`/`FRU`/`Events` slices the DCIM-XML path fills, so + the existing dedupe passes resolve any overlap in favor of DCIM-derived data. See ADL-048. + +--- + +### Generic Redfish walk fallback (`redfish_walk`) + +**Status:** Ready (v1.0.0). + +**Confidence:** 35 (above the `generic` fallback's 15, below every dedicated vendor parser — wins +`Detect()` only when no dedicated parser also recognizes the archive). + +**Purpose:** Vendor-agnostic catch-all for any archive that carries a captured Redfish +directory-tree walk (one JSON document per resource, e.g. +`redfish/v1/Systems/System.Embedded.1/index.json`) but isn't otherwise claimed by a dedicated vendor +parser. First known source is Dell iDRAC10 TSR bundles (handled directly by `dell`, see above); this +exists so any other vendor shipping the same kind of raw Redfish walk is picked up automatically. + +**Detection (`internal/parser/vendors/redfishtree`):** two-step, vendor-independent — +1. Path hint: an archive member path containing `redfish` and ending in `.tar.gz`/`.tgz`/`.zip`. +2. Structural confirmation: the unpacked tree must contain a document whose own `@odata.id` is + exactly `/redfish/v1`, plus a `/redfish/v1/Systems` or `/redfish/v1/Chassis` collection. + +Documents are keyed by their own `@odata.id`, not the on-disk directory name (some resource names +are URL-encoded on disk, e.g. `Assembly%23`, but not in the JSON payload). + +**Extracted data:** whatever `collector.ReplayRedfishFromRawPayloads` produces from the replayed +tree — board identity, firmware, CPUs, memory, storage, PCIe, GPUs, NICs, PSUs, sensors, events. + +--- + ### Generic text fallback (`generic`) **Status:** Ready (v1.0.0). @@ -234,7 +280,8 @@ and `LogDump/` trees. | Vendor | ID | Status | Tested on | |--------|----|--------|-----------| -| Dell TSR | `dell` | Ready | TSR nested zip archives | +| Dell TSR | `dell` | Ready | TSR nested zip archives (DCIM-XML and iDRAC10 Redfish-walk generations) | +| Generic Redfish walk | `redfish_walk` | Ready (fallback) | Any archive with a captured Redfish tree dump | | 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 | diff --git a/bible-local/10-decisions.md b/bible-local/10-decisions.md index de88fd7..b9ef10f 100644 --- a/bible-local/10-decisions.md +++ b/bible-local/10-decisions.md @@ -1229,3 +1229,44 @@ later content-free duplicate cannot blank out an earlier real reading. 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. + +--- + +## ADL-048 — Dell iDRAC10 TSR bundles ship inventory as a raw Redfish walk, not DCIM-XML + +**Date:** 2026-08-11 +**Context:** A TSR from a PowerEdge R7715 (`TSR20260721231613_1TVFYL4.zip`, iDRAC10-generation +firmware) parsed as `dell` and showed events but no hardware inventory. The `dell` parser +(`internal/parser/vendors/dell/parser.go`) built the entire `Hardware` tree from three files inside +the `.pl.zip`: `sysinfo_dcim_view.xml`, `sysinfo_dcim_softwareidentity.xml`, `sysinfo_cim_sensor.xml`. +This iDRAC generation does not produce those files at all; `tsr/hardware/sysinfo/inventory/` instead +contains `redfishidracwalk.tar.gz` — a captured crawl of the iDRAC's own Redfish tree (one JSON +document per resource, stored as `/index.json`, e.g. +`redfish/v1/Systems/System.Embedded.1/index.json`). `Hardware` therefore stayed effectively empty +(only `BoardInfo`/one iDRAC firmware entry from `metadata.json`), while `curr_lclog.xml` still +populated `Events` normally — hence "shows only logs". +**Decision:** Added `internal/parser/vendors/redfishtree` (shared, non-registering helper package) +that reconstructs a `path -> document` tree from a tar.gz- or zip-packaged Redfish walk and feeds it +to the existing `collector.ReplayRedfishFromRawPayloads` replay machinery (previously only used for +live BMC collection and reanimator import). Detection is two-step and vendor-independent: (1) a path +hint — an archive member path containing `redfish` and ending in `.tar.gz`/`.tgz`/`.zip`; (2) +structural confirmation — the unpacked tree must contain a document whose own `@odata.id` is exactly +`/redfish/v1`, plus a `/redfish/v1/Systems` or `/redfish/v1/Chassis` collection. The tree is keyed by +each document's own `@odata.id` (not the on-disk directory name), since some resource names are +URL-encoded on disk (e.g. `Assembly%23`) but not in the JSON payload. +`internal/parser/vendors/dell/redfish_walk.go` uses this to enrich the DCIM-XML-derived result: +append-only merge (`mergeRedfishReplay`) into the same slices the existing `dedupeX` calls already +resolve, so DCIM-derived entries (appended first) win over duplicates from the replay, and the +replay only fills in what DCIM-XML didn't provide. Also registered a new low-confidence fallback +vendor parser, `internal/parser/vendors/redfishwalk` (`Vendor()` = `redfish_walk`, confidence 35 — +above the `generic` fallback's 15, below every dedicated vendor parser), using the same +`redfishtree` helpers directly as its `Parse()`, so any other vendor that starts shipping this kind +of raw Redfish walk is picked up automatically without a dedicated parser. +**Consequences:** +- Dell iDRAC10 TSR bundles now populate full hardware inventory (CPUs, memory, storage, PCIe, NICs, + PSUs, firmware) from `redfishidracwalk.tar.gz` when DCIM-XML is absent, verified end-to-end on + the R7715 archive above (1 CPU, 2 DIMMs, 2 storage, 2 PCIe, 1 NIC, 2 PSU, 19 firmware entries). +- Any future vendor parser that wants to consume a captured Redfish walk as enrichment should reuse + `redfishtree.FindCandidateArchives` + `redfishtree.Build`, not re-implement tar/zip walking. +- `redfishwalk` is a genuine fallback: it only wins `Detect()` when no dedicated vendor parser + scores higher on the same archive, per the registry's highest-confidence-wins rule. diff --git a/internal/parser/vendors/dell/parser.go b/internal/parser/vendors/dell/parser.go index 258a9c0..f2f3d6b 100644 --- a/internal/parser/vendors/dell/parser.go +++ b/internal/parser/vendors/dell/parser.go @@ -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) diff --git a/internal/parser/vendors/dell/redfish_walk.go b/internal/parser/vendors/dell/redfish_walk.go new file mode 100644 index 0000000..9024bcb --- /dev/null +++ b/internal/parser/vendors/dell/redfish_walk.go @@ -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 + } +} diff --git a/internal/parser/vendors/dell/redfish_walk_test.go b/internal/parser/vendors/dell/redfish_walk_test.go new file mode 100644 index 0000000..c98c736 --- /dev/null +++ b/internal/parser/vendors/dell/redfish_walk_test.go @@ -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(``), + "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) + } +} diff --git a/internal/parser/vendors/redfishtree/tree.go b/internal/parser/vendors/redfishtree/tree.go new file mode 100644 index 0000000..c1bca80 --- /dev/null +++ b/internal/parser/vendors/redfishtree/tree.go @@ -0,0 +1,247 @@ +// Package redfishtree reconstructs a Redfish service-tree snapshot from an +// archive member and turns it into the path->document map expected by +// collector.ReplayRedfishFromRawPayloads. +// +// Some BMC support-bundle tools (seen first on Dell iDRAC10-generation TSR +// exports) capture inventory as a raw crawl of their own Redfish API instead +// of a vendor-specific summary format: one JSON document per resource, +// stored as "/index.json" inside a tar.gz or zip, mirroring the +// Redfish URL tree (e.g. "redfish/v1/Systems/System.Embedded.1/index.json"). +// +// Detecting one of these dumps is a two-step rule, both vendor-independent: +// 1. Path hint: an archive member whose path contains "redfish" and ends in +// .tar.gz, .tgz or .zip (e.g. "redfishidracwalk.tar.gz"). +// 2. Structural confirmation: once unpacked, the member must contain a JSON +// document whose own "@odata.id" is exactly "/redfish/v1" (the DMTF +// Redfish service root), plus at least one of "/redfish/v1/Systems" or +// "/redfish/v1/Chassis" as a resource collection. Directory-name hints +// alone are not enough to commit to treating the archive as a Redfish +// walk; this content check is what confirms it. +// +// Any vendor parser can call FindCandidateArchives + Build to pick up this +// kind of source as enrichment. vendors/redfishwalk registers a low-priority +// fallback VendorParser using the same helpers, for archives that carry a +// walk like this but aren't otherwise claimed by a dedicated vendor parser. +package redfishtree + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "encoding/json" + "io" + "strings" + + "git.mchus.pro/mchus/logpile/internal/parser" +) + +const ( + maxEntryBytes = 8 * 1024 * 1024 + maxTotalBytes = 128 * 1024 * 1024 + maxEntries = 50000 +) + +// FindCandidateArchives returns archive members (recursing one level into any +// nested zip, mirroring how vendor support bundles commonly nest archives) +// whose path hints at a captured Redfish walk. +func FindCandidateArchives(files []parser.ExtractedFile) []parser.ExtractedFile { + var out []parser.ExtractedFile + consider := func(f parser.ExtractedFile) { + path := strings.ToLower(strings.TrimSpace(f.Path)) + if !strings.Contains(path, "redfish") { + return + } + if strings.HasSuffix(path, ".tar.gz") || strings.HasSuffix(path, ".tgz") || strings.HasSuffix(path, ".zip") { + out = append(out, f) + } + } + + for _, f := range files { + consider(f) + + path := strings.ToLower(strings.TrimSpace(f.Path)) + if !strings.HasSuffix(path, ".zip") { + continue + } + zr, err := zip.NewReader(bytes.NewReader(f.Content), int64(len(f.Content))) + if err != nil { + continue + } + for _, zf := range zr.File { + if zf.FileInfo().IsDir() || zf.FileInfo().Size() > maxEntryBytes { + continue + } + rc, err := zf.Open() + if err != nil { + continue + } + content, err := io.ReadAll(rc) + rc.Close() + if err != nil { + continue + } + consider(parser.ExtractedFile{Path: zf.Name, Content: content, ModTime: zf.Modified}) + } + } + return out +} + +// Build reconstructs a path->document tree from a candidate archive member, +// trying tar.gz then zip, and returns nil unless the result passes +// LooksLikeWalk. +func Build(content []byte) map[string]interface{} { + if tree := BuildFromTarGz(content); LooksLikeWalk(tree) { + return tree + } + if tree := BuildFromZip(content); LooksLikeWalk(tree) { + return tree + } + return nil +} + +// BuildFromTarGz unpacks a tar.gz snapshot of a Redfish directory walk into a +// path->document map suitable for collector.ReplayRedfishFromRawPayloads. +func BuildFromTarGz(content []byte) map[string]interface{} { + gzr, err := gzip.NewReader(bytes.NewReader(content)) + if err != nil { + return nil + } + defer gzr.Close() + + tree := make(map[string]interface{}) + tr := tar.NewReader(gzr) + totalBytes := 0 + entries := 0 + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + break + } + if hdr.Typeflag != tar.TypeReg || !strings.HasSuffix(strings.ToLower(hdr.Name), ".json") { + continue + } + entries++ + if entries > maxEntries { + break + } + if hdr.Size <= 0 || hdr.Size > maxEntryBytes { + continue + } + totalBytes += int(hdr.Size) + if totalBytes > maxTotalBytes { + break + } + raw := make([]byte, hdr.Size) + if _, err := io.ReadFull(tr, raw); err != nil { + continue + } + addDoc(tree, hdr.Name, raw) + } + if len(tree) == 0 { + return nil + } + return tree +} + +// BuildFromZip does the same as BuildFromTarGz for a zip-packaged walk. +func BuildFromZip(content []byte) map[string]interface{} { + zr, err := zip.NewReader(bytes.NewReader(content), int64(len(content))) + if err != nil { + return nil + } + + tree := make(map[string]interface{}) + totalBytes := 0 + entries := 0 + for _, zf := range zr.File { + if zf.FileInfo().IsDir() || !strings.HasSuffix(strings.ToLower(zf.Name), ".json") { + continue + } + entries++ + if entries > maxEntries { + break + } + size := zf.FileInfo().Size() + if size <= 0 || size > maxEntryBytes { + continue + } + totalBytes += int(size) + if totalBytes > maxTotalBytes { + break + } + rc, err := zf.Open() + if err != nil { + continue + } + raw, err := io.ReadAll(rc) + rc.Close() + if err != nil { + continue + } + addDoc(tree, zf.Name, raw) + } + if len(tree) == 0 { + return nil + } + return tree +} + +func addDoc(tree map[string]interface{}, entryName string, raw []byte) { + var doc map[string]interface{} + if err := json.Unmarshal(raw, &doc); err != nil { + return + } + key := treeKey(entryName, doc) + if key == "" { + return + } + tree[key] = doc +} + +// treeKey prefers the document's own @odata.id (always unencoded) over the +// archive's on-disk path, since some resource names (e.g. "Assembly#") are +// URL-encoded as directory names but not in the JSON payload itself. +func treeKey(entryName string, doc map[string]interface{}) string { + if id, ok := doc["@odata.id"].(string); ok { + if p := normalizePath(id); p != "" { + return p + } + } + name := strings.TrimSuffix(entryName, "index.json") + name = strings.TrimSuffix(name, ".json") + return normalizePath(name) +} + +func normalizePath(p string) string { + p = strings.TrimSpace(p) + if p == "" { + return "" + } + if !strings.HasPrefix(p, "/") { + p = "/" + p + } + if len(p) > 1 { + p = strings.TrimSuffix(p, "/") + } + return p +} + +// LooksLikeWalk reports whether tree has the minimum structural shape of a +// real Redfish service tree: a /redfish/v1 service root plus at least one of +// a Systems or Chassis collection. This is the stable, vendor-independent +// signature used to confirm a candidate archive before committing to it. +func LooksLikeWalk(tree map[string]interface{}) bool { + if tree == nil { + return false + } + if _, ok := tree["/redfish/v1"]; !ok { + return false + } + _, hasSystems := tree["/redfish/v1/Systems"] + _, hasChassis := tree["/redfish/v1/Chassis"] + return hasSystems || hasChassis +} diff --git a/internal/parser/vendors/redfishwalk/parser.go b/internal/parser/vendors/redfishwalk/parser.go new file mode 100644 index 0000000..44d8587 --- /dev/null +++ b/internal/parser/vendors/redfishwalk/parser.go @@ -0,0 +1,60 @@ +// Package redfishwalk is a vendor-agnostic fallback for archives that carry +// a captured Redfish directory-tree walk (see vendors/redfishtree for the +// detection rule) but aren't claimed by a dedicated vendor parser. Dell TSR +// bundles from iDRAC10-generation firmware are the first known source of +// this format, and are handled directly by vendors/dell (which also carries +// other Dell-specific markers and merges the walk into DCIM-XML-derived +// data). This parser exists so any other vendor that starts shipping the +// same kind of raw Redfish walk is picked up automatically, without needing +// a dedicated parser first. +package redfishwalk + +import ( + "fmt" + + "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" +) + +const parserVersion = "1.0" + +func init() { + parser.Register(&Parser{}) +} + +// Parser implements VendorParser for archives containing a raw Redfish +// directory-tree walk with no other recognizable vendor markers. +type Parser struct{} + +func (p *Parser) Name() string { return "Generic Redfish Walk Parser" } +func (p *Parser) Vendor() string { return "redfish_walk" } +func (p *Parser) Version() string { return parserVersion } + +// Detect returns a confidence deliberately placed above the generic text +// fallback (15) but below any dedicated vendor parser, so a vendor-specific +// parser always wins when both recognize the same archive. +func (p *Parser) Detect(files []parser.ExtractedFile) int { + for _, f := range redfishtree.FindCandidateArchives(files) { + if redfishtree.Build(f.Content) != nil { + return 35 + } + } + return 0 +} + +func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, error) { + for _, f := range redfishtree.FindCandidateArchives(files) { + tree := redfishtree.Build(f.Content) + if tree == nil { + continue + } + result, err := collector.ReplayRedfishFromRawPayloads(map[string]any{"redfish_tree": tree}, nil) + if err != nil { + continue + } + return result, nil + } + return nil, fmt.Errorf("redfish_walk: no Redfish tree snapshot found") +} diff --git a/internal/parser/vendors/redfishwalk/parser_test.go b/internal/parser/vendors/redfishwalk/parser_test.go new file mode 100644 index 0000000..a0b1e80 --- /dev/null +++ b/internal/parser/vendors/redfishwalk/parser_test.go @@ -0,0 +1,92 @@ +package redfishwalk + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "testing" + + "git.mchus.pro/mchus/logpile/internal/parser" +) + +func makeWalkTarGz(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() +} + +func minimalWalk(t *testing.T) []byte { + return makeWalkTarGz(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":"Acme Corp", + "Model":"Widget 9000" + }`, + }) +} + +func TestDetect_NoMarker(t *testing.T) { + p := &Parser{} + files := []parser.ExtractedFile{ + {Path: "readme.txt", Content: []byte("hello world")}, + } + if score := p.Detect(files); score != 0 { + t.Fatalf("expected 0 confidence for archive with no Redfish marker, got %d", score) + } +} + +func TestDetect_NameHintWithoutStructure(t *testing.T) { + // A file whose name mentions "redfish" but whose content isn't a real + // service-tree walk must not be treated as a match. + p := &Parser{} + files := []parser.ExtractedFile{ + {Path: "redfish-notes.tar.gz", Content: []byte("not a real archive")}, + } + if score := p.Detect(files); score != 0 { + t.Fatalf("expected 0 confidence for non-archive content, got %d", score) + } +} + +func TestDetectAndParse_GenericVendorWalk(t *testing.T) { + walk := minimalWalk(t) + p := &Parser{} + files := []parser.ExtractedFile{ + {Path: "support-bundle/inventory/some_vendor_redfish_dump.tar.gz", Content: walk}, + } + + score := p.Detect(files) + if score <= 0 || score >= 100 { + t.Fatalf("expected low-but-positive fallback confidence, got %d", score) + } + + result, err := p.Parse(files) + if err != nil { + t.Fatalf("Parse() failed: %v", err) + } + if result.Hardware == nil || result.Hardware.BoardInfo.Manufacturer != "Acme Corp" { + t.Fatalf("expected board info from replayed Redfish tree, got %+v", result.Hardware) + } +} diff --git a/internal/parser/vendors/vendors.go b/internal/parser/vendors/vendors.go index f52efbf..47aa9c9 100644 --- a/internal/parser/vendors/vendors.go +++ b/internal/parser/vendors/vendors.go @@ -15,6 +15,7 @@ import ( _ "git.mchus.pro/mchus/logpile/internal/parser/vendors/xfusion" _ "git.mchus.pro/mchus/logpile/internal/parser/vendors/xigmanas" _ "git.mchus.pro/mchus/logpile/internal/parser/vendors/lenovo_xcc" + _ "git.mchus.pro/mchus/logpile/internal/parser/vendors/redfishwalk" // Generic fallback parser (must be last for lowest priority) _ "git.mchus.pro/mchus/logpile/internal/parser/vendors/generic"