diff --git a/audit/internal/collector/raid.go b/audit/internal/collector/raid.go index e605e7f..4f95e0a 100644 --- a/audit/internal/collector/raid.go +++ b/audit/internal/collector/raid.go @@ -41,6 +41,9 @@ func collectRAIDStorage(pcie []schema.HardwarePCIeDevice) []schema.HardwareStora if drives := collectStorcliDrives(); len(drives) > 0 { out = append(out, drives...) } + if drives := collectStorcli2Drives(); len(drives) > 0 { + out = append(out, drives...) + } if drives := collectSASIrcuDrives("sas3ircu"); len(drives) > 0 { out = append(out, drives...) } @@ -99,6 +102,63 @@ func collectStorcliDrives() []schema.HardwareStorage { return drives } +// collectStorcli2Drives covers Broadcom Tri-Mode controllers (e.g. +// SAS3808-iMR/9500 series), which storcli64 can enumerate at a basic level +// but whose drives it never finds — a separate tool/JSON schema (storcli2) +// is required. storcli2's "Drive Information" array shape matches storcli64's +// (confirmed against Broadcom's published drive-info-schema.json), so +// parseStorcliDrivesJSON is reused as-is; only the controller enumeration +// and per-controller invocation syntax differ (storcli2 wants an explicit +// /cN target rather than storcli64's /call wildcard). +func collectStorcli2Drives() []schema.HardwareStorage { + sysOut, err := raidToolQuery("storcli2", "show", "all", "J") + if err != nil { + slog.Info("raid: storcli2 unavailable", "err", err) + return nil + } + indices := parseStorcli2ControllerIndices(sysOut) + if len(indices) == 0 { + return nil + } + + var drives []schema.HardwareStorage + for _, idx := range indices { + out, err := raidToolQuery("storcli2", "/c"+strconv.Itoa(idx)+"/eall/sall", "show", "all", "J") + if err != nil { + continue + } + drives = append(drives, parseStorcliDrivesJSON(out)...) + } + if len(drives) == 0 { + slog.Info("raid: storcli2 returned no drives") + } + return drives +} + +// parseStorcli2ControllerIndices parses "storcli2 show all J" (Broadcom's +// system-schema.json: Controllers[0].Response Data.System Overview[].Ctrl). +func parseStorcli2ControllerIndices(raw []byte) []int { + var doc struct { + Controllers []struct { + ResponseData struct { + SystemOverview []struct { + Ctrl int `json:"Ctrl"` + } `json:"System Overview"` + } `json:"Response Data"` + } `json:"Controllers"` + } + if err := json.Unmarshal(raw, &doc); err != nil { + return nil + } + var indices []int + for _, c := range doc.Controllers { + for _, ov := range c.ResponseData.SystemOverview { + indices = append(indices, ov.Ctrl) + } + } + return indices +} + func collectSASIrcuDrives(tool string) []schema.HardwareStorage { out, err := raidToolQuery(tool, "list") if err != nil { diff --git a/audit/internal/collector/raid_storcli2_test.go b/audit/internal/collector/raid_storcli2_test.go new file mode 100644 index 0000000..4e2132b --- /dev/null +++ b/audit/internal/collector/raid_storcli2_test.go @@ -0,0 +1,75 @@ +package collector + +import "testing" + +// Fixture shape matches Broadcom's published storcli2 system-schema.json +// ("show all" -> Controllers[0].Response Data.System Overview[].Ctrl). +func TestParseStorcli2ControllerIndices(t *testing.T) { + raw := []byte(`{ + "Controllers": [ + { + "Command Status": {"Status": "Success"}, + "Response Data": { + "Number of Controllers": 2, + "System Overview": [ + {"Ctrl": 0, "Product Name": "SAS3808-iMR", "Personality": "RAID", "Status": "Optimal", "PD(s)": 2, "VD(s)": 0}, + {"Ctrl": 1, "Product Name": "SAS3808-iMR", "Personality": "RAID", "Status": "Optimal", "PD(s)": 4, "VD(s)": 1} + ] + } + } + ] + }`) + + got := parseStorcli2ControllerIndices(raw) + if len(got) != 2 || got[0] != 0 || got[1] != 1 { + t.Fatalf("indices=%v want [0 1]", got) + } +} + +func TestParseStorcli2ControllerIndicesEmptyOrMalformed(t *testing.T) { + if got := parseStorcli2ControllerIndices([]byte("not json")); got != nil { + t.Fatalf("malformed input should return nil, got %v", got) + } + if got := parseStorcli2ControllerIndices([]byte(`{"Controllers":[]}`)); got != nil { + t.Fatalf("no controllers should return nil, got %v", got) + } +} + +// TestCollectStorcli2DrivesEndToEnd exercises the full path this bug fix +// targets: storcli64 sees the Tri-Mode controller but no drives (real +// support-bundle behavior for SAS3808-iMR), storcli2 is the tool that +// actually finds them. +func TestCollectStorcli2DrivesEndToEnd(t *testing.T) { + orig := raidToolQuery + t.Cleanup(func() { raidToolQuery = orig }) + raidToolQuery = func(name string, args ...string) ([]byte, error) { + switch name { + case "storcli2": + if len(args) > 0 && args[0] == "show" { + return []byte(`{"Controllers":[{"Response Data":{"System Overview":[{"Ctrl":0,"Product Name":"SAS3808-iMR"}]}}]}`), nil + } + // per-controller drive listing: /c0/eall/sall show all J + return []byte(`{ + "Controllers": [ + { + "Response Data": { + "Drive Information": [ + {"EID:Slt": "252:0", "State": "Onln", "Size": "1.746 TB", "Intf": "NVMe", "Med": "SSD", "Model": "SAMSUNG MZQL21T9HCJR", "SN": "S6EYNE0T123456"}, + {"EID:Slt": "252:1", "State": "UGood", "Size": "1.746 TB", "Intf": "NVMe", "Med": "SSD", "Model": "SAMSUNG MZQL21T9HCJR", "SN": "S6EYNE0T654321"} + ] + } + } + ] + }`), nil + } + return nil, nil + } + + drives := collectStorcli2Drives() + if len(drives) != 2 { + t.Fatalf("drives=%d want 2 (%#v)", len(drives), drives) + } + if drives[0].SerialNumber == nil || *drives[0].SerialNumber != "S6EYNE0T123456" { + t.Fatalf("drives[0]=%#v want serial S6EYNE0T123456", drives[0]) + } +} diff --git a/audit/internal/platform/techdump.go b/audit/internal/platform/techdump.go index 8126f75..c82e524 100644 --- a/audit/internal/platform/techdump.go +++ b/audit/internal/platform/techdump.go @@ -30,6 +30,13 @@ var techDumpFixedCommands = []struct { {Name: "ipmitool", Args: []string{"sel", "list"}, File: "ipmitool-sel.txt"}, {Name: "ipmitool", Args: []string{"sel", "time", "get"}, File: "ipmitool-sel-time.txt"}, {Name: "nvme", Args: []string{"list", "-o", "json"}, File: "nvme-list.json"}, + {Name: "storcli64", Args: []string{"/call/eall/sall", "show", "all", "J"}, File: "storcli64-drives.json"}, + // storcli2 (Tri-Mode controllers, e.g. SAS3808-iMR/9500 series) needs an + // explicit /cN target for drive listing, not a /call wildcard — this + // system-level dump alone (no per-controller loop) is still useful raw + // diagnostics; structured per-drive data is collected separately by + // collectStorcli2Drives in the collector package. + {Name: "storcli2", Args: []string{"show", "all", "J"}, File: "storcli2-show-all.json"}, } var techDumpNvidiaCommands = []struct { diff --git a/audit/internal/webui/raid_mgmt.go b/audit/internal/webui/raid_mgmt.go index a4ee627..57ae6b4 100644 --- a/audit/internal/webui/raid_mgmt.go +++ b/audit/internal/webui/raid_mgmt.go @@ -128,6 +128,105 @@ func detectLSIControllers() []raidControllerInfo { return controllers } +// --- LSI/storcli2 detection (Tri-Mode controllers, e.g. SAS3808-iMR/9500) --- +// +// storcli2 is a separate tool/JSON schema from storcli64, required for +// Broadcom's Tri-Mode MegaRAID line. storcli64 can still enumerate a Tri-Mode +// controller at a basic level (hence it shows up once via detectLSIControllers) +// but its drive-listing JSON parser finds no "Drive Information" for these +// controllers, silently reporting zero drives — this is the storcli2 path +// that actually understands them, run as an additional source alongside +// storcli64/VROC rather than a replacement. +func detectStorcli2Controllers() []raidControllerInfo { + sysOut, err := exec.Command("storcli2", "show", "all", "J").Output() + if err != nil { + return nil + } + + var sysDoc struct { + Controllers []struct { + ResponseData struct { + SystemOverview []struct { + Ctrl int `json:"Ctrl"` + ProductName string `json:"Product Name"` + } `json:"System Overview"` + } `json:"Response Data"` + } `json:"Controllers"` + } + if err := json.Unmarshal(sysOut, &sysDoc); err != nil || len(sysDoc.Controllers) == 0 { + return nil + } + + var controllers []raidControllerInfo + for _, entry := range sysDoc.Controllers { + for _, ov := range entry.ResponseData.SystemOverview { + ctrl := raidControllerInfo{ + ID: fmt.Sprintf("lsi2-%d", ov.Ctrl), + Type: "lsi", + Index: ov.Ctrl, + Model: strings.TrimSpace(ov.ProductName), + ForeignDrives: []raidDriveInfo{}, + FreeDrives: []raidDriveInfo{}, + AllDrives: []raidDriveInfo{}, + } + if ctrl.Model == "" { + ctrl.Model = fmt.Sprintf("LSI Controller %d", ctrl.Index) + } + + driveOut, _ := exec.Command("storcli2", fmt.Sprintf("/c%d/eall/sall", ov.Ctrl), "show", "all", "J").Output() + ctrl.AllDrives, ctrl.ForeignDrives, ctrl.FreeDrives = parseStorcli2DriveInformation(driveOut) + + controllers = append(controllers, ctrl) + } + } + return controllers +} + +// parseStorcli2DriveInformation parses a single controller's +// "storcli2 /cX/eall/sall show all J" output. The "Drive Information" array +// shape matches storcli64's schema (confirmed against Broadcom's published +// drive-info-schema.json), so the same field set applies. +func parseStorcli2DriveInformation(raw []byte) (all, foreign, free []raidDriveInfo) { + if len(raw) == 0 { + return nil, nil, nil + } + var doc struct { + Controllers []struct { + ResponseData struct { + DriveInformation []struct { + EIDSlt string `json:"EID:Slt"` + State string `json:"State"` + Size string `json:"Size"` + Intf string `json:"Intf"` + Med string `json:"Med"` + Model string `json:"Model"` + SN string `json:"SN"` + } `json:"Drive Information"` + } `json:"Response Data"` + } `json:"Controllers"` + } + if err := json.Unmarshal(raw, &doc); err != nil || len(doc.Controllers) == 0 { + return nil, nil, nil + } + for _, d := range doc.Controllers[0].ResponseData.DriveInformation { + info := raidDriveInfo{ + Slot: strings.TrimSpace(d.EIDSlt), + Model: strings.TrimSpace(d.Model), + State: strings.TrimSpace(d.State), + SizeGB: raidParseHumanSizeGB(d.Size), + Serial: strings.TrimSpace(d.SN), + } + all = append(all, info) + switch strings.TrimSpace(d.State) { + case "Frgn": + foreign = append(foreign, info) + case "UGood", "JBOD": + free = append(free, info) + } + } + return all, foreign, free +} + // --- VROC/mdadm detection --- var raidMDStatDegradedRx = regexp.MustCompile(`\[[U_]+\]`) @@ -288,8 +387,23 @@ func detectVROCController() *raidControllerInfo { func (h *handler) handleAPIRAIDStatus(w http.ResponseWriter, r *http.Request) { resp := raidStatusResp{Controllers: []raidControllerInfo{}} + lsi2 := detectStorcli2Controllers() if lsi := detectLSIControllers(); len(lsi) > 0 { - resp.Controllers = append(resp.Controllers, lsi...) + // storcli64 can enumerate a Tri-Mode controller (SAS3808-iMR/9500 + // series) at a basic level but its drive-listing JSON parser finds + // no "Drive Information" for these — a zero-drives entry that + // storcli2 (run above) already covers correctly. Only drop it when + // storcli2 actually found something, so a genuinely drive-populated + // classic controller elsewhere in a mixed setup is never hidden. + for _, c := range lsi { + if len(c.AllDrives) == 0 && len(lsi2) > 0 { + continue + } + resp.Controllers = append(resp.Controllers, c) + } + } + if len(lsi2) > 0 { + resp.Controllers = append(resp.Controllers, lsi2...) } if vroc := detectVROCController(); vroc != nil { resp.Controllers = append(resp.Controllers, *vroc) diff --git a/audit/internal/webui/raid_mgmt_test.go b/audit/internal/webui/raid_mgmt_test.go new file mode 100644 index 0000000..00f7b0a --- /dev/null +++ b/audit/internal/webui/raid_mgmt_test.go @@ -0,0 +1,49 @@ +package webui + +import "testing" + +// Fixture shape matches Broadcom's published storcli2 drive-info-schema.json +// (Controllers[].Response Data.Drive Information[]), which is the same shape +// storcli64 already uses — confirmed by comparing the two tools' schemas. +func TestParseStorcli2DriveInformation(t *testing.T) { + raw := []byte(`{ + "Controllers": [ + { + "Command Status": {"Controller": 0, "Status": "Success"}, + "Response Data": { + "Drive Information": [ + {"EID:Slt": "252:0", "State": "Onln", "Size": "1.746 TB", "Intf": "NVMe", "Med": "SSD", "Model": "SAMSUNG MZQL21T9HCJR", "SN": "S6EYNE0T123456"}, + {"EID:Slt": "252:1", "State": "UGood", "Size": "1.746 TB", "Intf": "NVMe", "Med": "SSD", "Model": "SAMSUNG MZQL21T9HCJR", "SN": "S6EYNE0T654321"} + ] + } + } + ] + }`) + + all, foreign, free := parseStorcli2DriveInformation(raw) + if len(all) != 2 { + t.Fatalf("all=%d want 2 (%#v)", len(all), all) + } + if len(foreign) != 0 { + t.Fatalf("foreign=%d want 0", len(foreign)) + } + if len(free) != 1 || free[0].Serial != "S6EYNE0T654321" { + t.Fatalf("free=%#v want one UGood drive", free) + } + if all[0].Slot != "252:0" || all[0].SizeGB <= 0 { + t.Fatalf("all[0]=%#v want populated slot/size", all[0]) + } +} + +func TestParseStorcli2DriveInformationEmptyOrMalformed(t *testing.T) { + if all, foreign, free := parseStorcli2DriveInformation(nil); all != nil || foreign != nil || free != nil { + t.Fatalf("nil input should return nil slices, got %#v %#v %#v", all, foreign, free) + } + if all, _, _ := parseStorcli2DriveInformation([]byte("not json")); all != nil { + t.Fatalf("malformed JSON should return nil, got %#v", all) + } + // Valid JSON but no Controllers (e.g. tool ran but found nothing) must not panic. + if all, _, _ := parseStorcli2DriveInformation([]byte(`{"Controllers":[]}`)); all != nil { + t.Fatalf("empty Controllers should return nil, got %#v", all) + } +} diff --git a/iso/builder/build.sh b/iso/builder/build.sh index 04c7998..d20e8bc 100755 --- a/iso/builder/build.sh +++ b/iso/builder/build.sh @@ -1480,7 +1480,7 @@ cp "${BUILDER_DIR}/smoketest.sh" "${OVERLAY_STAGE_DIR}/usr/local/bin/bee-smokete chmod +x "${OVERLAY_STAGE_DIR}/usr/local/bin/bee-smoketest" # --- vendor utilities (optional pre-fetched binaries) --- -for tool in storcli64 sas2ircu sas3ircu arcconf ssacli saa; do +for tool in storcli64 storcli2 sas2ircu sas3ircu arcconf ssacli saa; do if [ -f "${VENDOR_DIR}/${tool}" ]; then cp "${VENDOR_DIR}/${tool}" "${OVERLAY_STAGE_DIR}/usr/local/bin/${tool}" chmod +x "${OVERLAY_STAGE_DIR}/usr/local/bin/${tool}" || true diff --git a/iso/vendor/storcli2 b/iso/vendor/storcli2 new file mode 100755 index 0000000..36e1daa Binary files /dev/null and b/iso/vendor/storcli2 differ