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>
248 lines
6.7 KiB
Go
248 lines
6.7 KiB
Go
// 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
|
|
}
|