raid: add storcli2 support for Tri-Mode controllers (SAS3808-iMR/9500 series)
storcli64 enumerates these controllers but reports zero drives; storcli2 is the tool Broadcom ships for Tri-Mode/MegaRAID8 hardware and uses a compatible JSON schema for drive listing. Wires storcli2 into both the collector (structured drive data) and the webui RAID Management page (dedup so a controller isn't double-listed if storcli64 already sees it with zero drives), plus techdump raw collection and the ISO vendor-tool build step. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
a3377083aa
commit
b7f015c713
@@ -41,6 +41,9 @@ func collectRAIDStorage(pcie []schema.HardwarePCIeDevice) []schema.HardwareStora
|
|||||||
if drives := collectStorcliDrives(); len(drives) > 0 {
|
if drives := collectStorcliDrives(); len(drives) > 0 {
|
||||||
out = append(out, drives...)
|
out = append(out, drives...)
|
||||||
}
|
}
|
||||||
|
if drives := collectStorcli2Drives(); len(drives) > 0 {
|
||||||
|
out = append(out, drives...)
|
||||||
|
}
|
||||||
if drives := collectSASIrcuDrives("sas3ircu"); len(drives) > 0 {
|
if drives := collectSASIrcuDrives("sas3ircu"); len(drives) > 0 {
|
||||||
out = append(out, drives...)
|
out = append(out, drives...)
|
||||||
}
|
}
|
||||||
@@ -99,6 +102,63 @@ func collectStorcliDrives() []schema.HardwareStorage {
|
|||||||
return drives
|
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 {
|
func collectSASIrcuDrives(tool string) []schema.HardwareStorage {
|
||||||
out, err := raidToolQuery(tool, "list")
|
out, err := raidToolQuery(tool, "list")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,13 @@ var techDumpFixedCommands = []struct {
|
|||||||
{Name: "ipmitool", Args: []string{"sel", "list"}, File: "ipmitool-sel.txt"},
|
{Name: "ipmitool", Args: []string{"sel", "list"}, File: "ipmitool-sel.txt"},
|
||||||
{Name: "ipmitool", Args: []string{"sel", "time", "get"}, File: "ipmitool-sel-time.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: "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 {
|
var techDumpNvidiaCommands = []struct {
|
||||||
|
|||||||
@@ -128,6 +128,105 @@ func detectLSIControllers() []raidControllerInfo {
|
|||||||
return controllers
|
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 ---
|
// --- VROC/mdadm detection ---
|
||||||
|
|
||||||
var raidMDStatDegradedRx = regexp.MustCompile(`\[[U_]+\]`)
|
var raidMDStatDegradedRx = regexp.MustCompile(`\[[U_]+\]`)
|
||||||
@@ -288,8 +387,23 @@ func detectVROCController() *raidControllerInfo {
|
|||||||
func (h *handler) handleAPIRAIDStatus(w http.ResponseWriter, r *http.Request) {
|
func (h *handler) handleAPIRAIDStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
resp := raidStatusResp{Controllers: []raidControllerInfo{}}
|
resp := raidStatusResp{Controllers: []raidControllerInfo{}}
|
||||||
|
|
||||||
|
lsi2 := detectStorcli2Controllers()
|
||||||
if lsi := detectLSIControllers(); len(lsi) > 0 {
|
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 {
|
if vroc := detectVROCController(); vroc != nil {
|
||||||
resp.Controllers = append(resp.Controllers, *vroc)
|
resp.Controllers = append(resp.Controllers, *vroc)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
chmod +x "${OVERLAY_STAGE_DIR}/usr/local/bin/bee-smoketest"
|
||||||
|
|
||||||
# --- vendor utilities (optional pre-fetched binaries) ---
|
# --- 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
|
if [ -f "${VENDOR_DIR}/${tool}" ]; then
|
||||||
cp "${VENDOR_DIR}/${tool}" "${OVERLAY_STAGE_DIR}/usr/local/bin/${tool}"
|
cp "${VENDOR_DIR}/${tool}" "${OVERLAY_STAGE_DIR}/usr/local/bin/${tool}"
|
||||||
chmod +x "${OVERLAY_STAGE_DIR}/usr/local/bin/${tool}" || true
|
chmod +x "${OVERLAY_STAGE_DIR}/usr/local/bin/${tool}" || true
|
||||||
|
|||||||
BIN
Binary file not shown.
Reference in New Issue
Block a user