From 586ac782b2496417e20dc2973fb28a310395ffee Mon Sep 17 00:00:00 2001 From: Michael Chus Date: Wed, 29 Jul 2026 10:30:54 +0300 Subject: [PATCH] fix(parser): expose NVIDIA HGX tray/baseboard identity separately from vendor carrier FRU Inspur/Kaytus onekeylog dumps only exposed the mechanical carrier FRU serial (component/fru.txt Board Product) as board identity. That serial doesn't change when the actual NVIDIA HGX baseboard (SXM+NVSwitch "delta board") is swapped, causing false "board unchanged" conclusions. Parse the real HGX tray/baseboard Model/PartNumber/SerialNumber triples from HGX_HWInfo_FWVersion.log into a new HardwareConfig.HGX field, and normalize Redfish's NA/N-A placeholders to empty across HGX identity parsing. Closes #21 Co-Authored-By: Claude Sonnet 5 --- bible-local/10-decisions.md | 35 ++++++ internal/models/models.go | 20 ++++ internal/parser/vendors/inspur/hgx_hwinfo.go | 104 +++++++++++++++++- .../vendors/inspur/hgx_identity_test.go | 78 +++++++++++++ internal/parser/vendors/inspur/parser.go | 1 + 5 files changed, 235 insertions(+), 3 deletions(-) create mode 100644 internal/parser/vendors/inspur/hgx_identity_test.go diff --git a/bible-local/10-decisions.md b/bible-local/10-decisions.md index 55688a3..8f0f51f 100644 --- a/bible-local/10-decisions.md +++ b/bible-local/10-decisions.md @@ -1284,3 +1284,38 @@ dropping inventory a diagnostic case (intermittent GPU PCIe dropout) depended on no acceptance case depended on them yet. - Any future ipmitool-text-output fallback (FRU/sensor/sdr) should extend `component_fallback.go` rather than adding another one-off parser. + +## ADL-049 — HGX tray/baseboard identity is a distinct entity from the vendor mechanical-carrier FRU + +**Date:** 2026-07-29 +**Context:** Issue #21 (Inspur/Kaytus HGX B200 dumps). `hw.BoardInfo`/vendor FRU (`component/fru.txt` +`Board Product : CA`, a `YZCA-*` part) identifies the mechanical carrier/tray shipped by Inspur — +it's bolted to the chassis and does not change when the actual NVIDIA HGX baseboard ("delta +board", SXM+NVSwitch) is swapped. Three dumps from the same server showed the CA carrier serial +constant across all three while the NVIDIA-assigned tray (`699-26612-*`) and baseboard +(`935-26287-*`) serials — read from `log/bmc/oem-commer-log/HGX_HWInfo_FWVersion.log` — stayed +identical between dumps A/B (~4 weeks apart) and both changed together in dump C, i.e. only +`HGX_HWInfo_FWVersion.log` actually reflects a baseboard swap. +**Decision:** +- Added `models.HGXIdentity` (`Tray`, `Baseboard`, each a `Model`/`PartNumber`/`SerialNumber` + triple) as `HardwareConfig.HGX`, populated by `parseHGXIdentity` in + `internal/parser/vendors/inspur/hgx_hwinfo.go` from the same `HGX_HWInfo_FWVersion.log` file + already used for GPU assembly/firmware enrichment. +- The log is a sequence of `# curl ... ` comment lines each followed by that + request's JSON response; `splitCurlBlocks` chunks on the comment lines so fields are attributed + to the path that produced them (classified by `tray`/`baseboard` substring, order-independent + field regexes — unlike the existing fixed-order `reHGXGPUBlock` regex for GPU assembly). Paths + containing `gpu_sxm` or `/processors/` are explicitly excluded so a per-GPU triple can never be + misattributed to the tray/baseboard entity. +- `hgxValue()` normalizes Redfish's `NA`/`N/A` placeholder (seen when GPUs are unpowered but the + baseboard itself still responds) to empty string, applied to both the new identity parser and + the existing per-GPU assembly parser, so `"NA"` never leaks into a serial/model/part field. +- Deliberately left `hw.BoardInfo`/vendor FRU parsing (`fru.go`) unchanged — it is not wrong, just + a different entity (mechanical carrier). Consumers that need "did the actual GPU board change" + must compare `HardwareConfig.HGX`, not `BoardInfo`. +**Consequences:** +- Dump-to-dump baseboard/tray swap detection (issue #21's P2) is now possible by comparing two + `HardwareConfig.HGX` values; not yet wired into any diff/comparison UI. +- GPU-status "baseboard responds, GPU not readable" surfacing (P1) is a natural follow-on now that + GPU fields normalize through the same `NA`-aware path, but no dedicated event/diagnostic was + added yet — deferred, no acceptance case depended on it. diff --git a/internal/models/models.go b/internal/models/models.go index 8aef89d..3bfe073 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -106,6 +106,26 @@ type HardwareConfig struct { NetworkCards []NIC `json:"network_cards,omitempty"` NetworkAdapters []NetworkAdapter `json:"network_adapters,omitempty"` PowerSupply []PSU `json:"power_supplies,omitempty"` + HGX *HGXIdentity `json:"hgx,omitempty"` +} + +// HGXAssemblyIdentity is the Model/PartNumber/SerialNumber triple for one +// NVIDIA HGX Redfish assembly (tray or baseboard), read from HWInfo/FWVersion. +type HGXAssemblyIdentity struct { + Model string `json:"model,omitempty"` + PartNumber string `json:"part_number,omitempty"` + SerialNumber string `json:"serial_number,omitempty"` +} + +// HGXIdentity holds the NVIDIA HGX hardware identity as reported by the BMC's +// HGX_HWInfo_FWVersion.log Redfish snapshot. This is distinct from BoardInfo: +// BoardInfo/vendor FRU describe the mechanical carrier/tray shipped by the +// server OEM (e.g. Inspur's YZCA-* part), which does not change when the +// NVIDIA HGX baseboard ("delta board") itself is replaced. Tray/Baseboard +// here are the actual NVIDIA-assigned identities that do change on a swap. +type HGXIdentity struct { + Tray HGXAssemblyIdentity `json:"tray,omitempty"` + Baseboard HGXAssemblyIdentity `json:"baseboard,omitempty"` } const ( diff --git a/internal/parser/vendors/inspur/hgx_hwinfo.go b/internal/parser/vendors/inspur/hgx_hwinfo.go index b6a65c5..2cce844 100644 --- a/internal/parser/vendors/inspur/hgx_hwinfo.go +++ b/internal/parser/vendors/inspur/hgx_hwinfo.go @@ -48,8 +48,106 @@ var ( reIDLine = regexp.MustCompile(`"Id":\s*"([^"]+)"`) reVersion = regexp.MustCompile(`"Version":\s*"([^"]*)"`) reSlotGPU = regexp.MustCompile(`(?i)gpu\s*#?\s*(\d+)`) + + reCurlLine = regexp.MustCompile(`(?m)^#.*curl.*$`) + reRedfishPath = regexp.MustCompile(`(/redfish/v1/\S+)`) + reFieldModel = regexp.MustCompile(`"Model"\s*:\s*"([^"]*)"`) + reFieldPart = regexp.MustCompile(`"PartNumber"\s*:\s*"([^"]*)"`) + reFieldSerial = regexp.MustCompile(`"SerialNumber"\s*:\s*"([^"]*)"`) ) +// hgxValue normalizes a raw HWInfo field value, treating Redfish's "not +// applicable" placeholders (e.g. an unpowered/absent GPU) as empty so they +// never overwrite or masquerade as real identity data. +func hgxValue(raw string) string { + v := strings.TrimSpace(raw) + switch strings.ToUpper(v) { + case "", "NA", "N/A": + return "" + default: + return v + } +} + +// splitCurlBlocks splits a HGX_HWInfo_FWVersion.log-style dump (a sequence of +// "# curl ... " comment lines each followed by that request's JSON +// response) into per-request chunks, so fields can be attributed to the +// Redfish path that produced them. +func splitCurlBlocks(content []byte) []string { + text := string(content) + locs := reCurlLine.FindAllStringIndex(text, -1) + if len(locs) == 0 { + return []string{text} + } + + blocks := make([]string, 0, len(locs)) + for i, loc := range locs { + end := len(text) + if i+1 < len(locs) { + end = locs[i+1][0] + } + blocks = append(blocks, text[loc[0]:end]) + } + return blocks +} + +// parseHGXIdentity extracts the NVIDIA HGX tray and baseboard hardware +// identity (Model/PartNumber/SerialNumber) from a HGX_HWInfo_FWVersion.log +// dump. Unlike the vendor mechanical-carrier FRU, these serials change when +// the actual HGX tray/baseboard ("delta board") is swapped. +func parseHGXIdentity(content []byte) *models.HGXIdentity { + if len(content) == 0 { + return nil + } + + var identity models.HGXIdentity + found := false + + for _, block := range splitCurlBlocks(content) { + path := reRedfishPath.FindString(block) + if path == "" { + continue + } + lowerPath := strings.ToLower(path) + + // GPU-specific and GPU-scoped paths are handled by + // parseHGXGPUAssembly/parseHGXGPUFirmware; skip them here so a + // per-GPU Model/PartNumber/SerialNumber triple never gets + // misattributed to the tray or baseboard. + if strings.Contains(lowerPath, "gpu_sxm") || strings.Contains(lowerPath, "/processors/") { + continue + } + + info := models.HGXAssemblyIdentity{} + if m := reFieldModel.FindStringSubmatch(block); m != nil { + info.Model = hgxValue(m[1]) + } + if m := reFieldPart.FindStringSubmatch(block); m != nil { + info.PartNumber = hgxValue(m[1]) + } + if m := reFieldSerial.FindStringSubmatch(block); m != nil { + info.SerialNumber = hgxValue(m[1]) + } + if info.Model == "" && info.PartNumber == "" && info.SerialNumber == "" { + continue + } + + switch { + case strings.Contains(lowerPath, "tray"): + identity.Tray = info + found = true + case strings.Contains(lowerPath, "baseboard"): + identity.Baseboard = info + found = true + } + } + + if !found { + return nil + } + return &identity +} + func enrichGPUsFromHGXHWInfo(content []byte, hw *models.HardwareConfig) { if hw == nil || len(hw.GPUs) == 0 || len(content) == 0 { return @@ -157,9 +255,9 @@ func parseHGXGPUAssembly(content []byte) map[int]hgxGPUAssemblyInfo { } result[sxmIdx] = hgxGPUAssemblyInfo{ - Model: strings.TrimSpace(string(m[2])), - Part: strings.TrimSpace(string(m[3])), - Serial: strings.TrimSpace(string(m[4])), + Model: hgxValue(string(m[2])), + Part: hgxValue(string(m[3])), + Serial: hgxValue(string(m[4])), } } return result diff --git a/internal/parser/vendors/inspur/hgx_identity_test.go b/internal/parser/vendors/inspur/hgx_identity_test.go new file mode 100644 index 0000000..242cb14 --- /dev/null +++ b/internal/parser/vendors/inspur/hgx_identity_test.go @@ -0,0 +1,78 @@ +package inspur + +import ( + "testing" +) + +func TestParseHGXIdentity_ExtractsTrayAndBaseboard(t *testing.T) { + content := []byte(` +# curl -X GET http://127.0.0.1/redfish/v1/Chassis/HGX_Tray_0/Assembly +{"Name":"HGX Tray Assembly","Model":"P6612-A04","PartNumber":"699-26612-0000-P00","SerialNumber":"TRAYSN1"} +# curl -X GET http://127.0.0.1/redfish/v1/Systems/HGX_Baseboard_0 +{"Model":"NVIDIA HGX B200 8 GPU","PartNumber":"935-26287-00A0-000","SerialNumber":"BBSN1"} +# curl -X GET http://127.0.0.1/redfish/v1/Systems/HGX_Baseboard_0/Processors/GPU_SXM_1 +{"Model":"B200 180GB HBM3e","PartNumber":"692-2G525-0220-501","SerialNumber":"GPUSN1"} +`) + + identity := parseHGXIdentity(content) + if identity == nil { + t.Fatal("expected non-nil identity") + } + if identity.Tray.Model != "P6612-A04" || identity.Tray.PartNumber != "699-26612-0000-P00" || identity.Tray.SerialNumber != "TRAYSN1" { + t.Fatalf("unexpected tray identity: %+v", identity.Tray) + } + if identity.Baseboard.Model != "NVIDIA HGX B200 8 GPU" || identity.Baseboard.PartNumber != "935-26287-00A0-000" || identity.Baseboard.SerialNumber != "BBSN1" { + t.Fatalf("unexpected baseboard identity: %+v", identity.Baseboard) + } + // The per-GPU Processors path must never leak into baseboard/tray identity. + if identity.Baseboard.SerialNumber == "GPUSN1" || identity.Tray.SerialNumber == "GPUSN1" { + t.Fatalf("GPU serial leaked into baseboard/tray identity: %+v", identity) + } +} + +func TestParseHGXIdentity_DetectsBaseboardSwapAcrossDumps(t *testing.T) { + dumpA := []byte(` +# curl -X GET http://127.0.0.1/redfish/v1/Chassis/HGX_Tray_0/Assembly +{"Name":"HGX Tray Assembly","Model":"P6612-A04","PartNumber":"699-26612-0000-P00","SerialNumber":"TRAY-1"} +# curl -X GET http://127.0.0.1/redfish/v1/Systems/HGX_Baseboard_0 +{"Model":"NVIDIA HGX B200 8 GPU","PartNumber":"935-26287-00A0-000","SerialNumber":"BB-1"} +`) + dumpC := []byte(` +# curl -X GET http://127.0.0.1/redfish/v1/Chassis/HGX_Tray_0/Assembly +{"Name":"HGX Tray Assembly","Model":"P6612-A04","PartNumber":"699-26612-0000-P00","SerialNumber":"TRAY-2"} +# curl -X GET http://127.0.0.1/redfish/v1/Systems/HGX_Baseboard_0 +{"Model":"NVIDIA HGX B200 8 GPU","PartNumber":"935-26287-00A0-000","SerialNumber":"BB-2"} +`) + + a := parseHGXIdentity(dumpA) + c := parseHGXIdentity(dumpC) + if a == nil || c == nil { + t.Fatal("expected identity from both dumps") + } + if a.Baseboard.SerialNumber == c.Baseboard.SerialNumber { + t.Fatal("expected baseboard serial to differ across dumps that recorded a swap") + } + if a.Tray.SerialNumber == c.Tray.SerialNumber { + t.Fatal("expected tray serial to differ across dumps that recorded a swap") + } +} + +func TestParseHGXIdentity_TreatsNAAsAbsent(t *testing.T) { + content := []byte(` +# curl -X GET http://127.0.0.1/redfish/v1/Systems/HGX_Baseboard_0 +{"Model":"NA","PartNumber":"NA","SerialNumber":"NA"} +`) + + if identity := parseHGXIdentity(content); identity != nil { + t.Fatalf("expected nil identity when all fields are NA, got %+v", identity) + } +} + +func TestParseHGXIdentity_NoHGXContentReturnsNil(t *testing.T) { + if identity := parseHGXIdentity(nil); identity != nil { + t.Fatalf("expected nil identity for empty content, got %+v", identity) + } + if identity := parseHGXIdentity([]byte("no redfish paths here")); identity != nil { + t.Fatalf("expected nil identity when no HGX paths present, got %+v", identity) + } +} diff --git a/internal/parser/vendors/inspur/parser.go b/internal/parser/vendors/inspur/parser.go index f9ddaef..7aa9135 100644 --- a/internal/parser/vendors/inspur/parser.go +++ b/internal/parser/vendors/inspur/parser.go @@ -278,6 +278,7 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er if f := parser.FindFileByName(files, "HGX_HWInfo_FWVersion.log"); f != nil && result.Hardware != nil { enrichGPUsFromHGXHWInfo(f.Content, result.Hardware) appendHGXFirmwareFromHWInfo(f.Content, result.Hardware) + result.Hardware.HGX = parseHGXIdentity(f.Content) } // Mark problematic GPUs from IDL errors like "BIOS miss F_GPU6".