// 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 }