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:
co-authored by
Claude Sonnet 5
parent
6e1a8232ec
commit
f599215760
+3
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
+247
@@ -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 "<url-path>/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
|
||||
}
|
||||
+60
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user