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 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 10:30:54 +03:00
co-authored by Claude Sonnet 5
parent f9d328b5cf
commit 586ac782b2
5 changed files with 235 additions and 3 deletions
+35
View File
@@ -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 ... <redfish-path>` 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.
+20
View File
@@ -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 (
+101 -3
View File
@@ -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 ... <url>" 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
+78
View File
@@ -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)
}
}
+1
View File
@@ -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".