diff --git a/audit/internal/collector/raid.go b/audit/internal/collector/raid.go index 4f95e0a..a460bf5 100644 --- a/audit/internal/collector/raid.go +++ b/audit/internal/collector/raid.go @@ -103,13 +103,16 @@ func collectStorcliDrives() []schema.HardwareStorage { } // 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). +// SAS3808-iMR/9500 series) via storcli2, Broadcom's separate tool for this +// controller line. On at least one confirmed SAS3808-iMR system storcli2 +// itself reports zero controllers even though storcli64 sees the controller +// and its drives fine (a tool-side gap, not something parseable around), so +// this is run as an additional source alongside storcli64, not a +// replacement. parseStorcliDrivesJSON is reused as-is since storcli2 and +// storcli64 share the same "Response Data" drive-listing shape; 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 { @@ -492,41 +495,7 @@ func parseSSACLIInterface(raw string) string { } } -func parseStorcliDrivesJSON(raw []byte) []schema.HardwareStorage { - 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"` - Sp string `json:"Sp"` - Type string `json:"Type"` - } `json:"Drive Information"` - } `json:"Response Data"` - } `json:"Controllers"` - } - if err := json.Unmarshal(raw, &doc); err != nil { - slog.Warn("raid: parse storcli json failed", "err", err) - return nil - } - - var drives []schema.HardwareStorage - for _, ctl := range doc.Controllers { - for _, d := range ctl.ResponseData.DriveInformation { - if s := storcliDriveToStorage(d); s != nil { - drives = append(drives, *s) - } - } - } - return drives -} - -func storcliDriveToStorage(d struct { +type storcliDrive struct { EIDSlt string `json:"EID:Slt"` State string `json:"State"` Size string `json:"Size"` @@ -536,7 +505,60 @@ func storcliDriveToStorage(d struct { SN string `json:"SN"` Sp string `json:"Sp"` Type string `json:"Type"` -}) *schema.HardwareStorage { +} + +// storcliDrivePerSlotKeyRe matches "Response Data" keys like "Drive /c0/e69/s0" +// — real storcli64/storcli2 "eall/sall show all J" output nests each drive +// under its own dynamically-named key instead of a shared "Drive Information" +// array (confirmed against a live SAS3808-iMR dump: the top-level "Drive +// Information" array this parser previously assumed simply isn't present). +// It must not match the paired "Drive /c0/e69/s0 - Detailed Information" key. +var storcliDrivePerSlotKeyRe = regexp.MustCompile(`^Drive /c\d+/e\d+/s\d+$`) + +func parseStorcliDrivesJSON(raw []byte) []schema.HardwareStorage { + var doc struct { + Controllers []struct { + ResponseData map[string]json.RawMessage `json:"Response Data"` + } `json:"Controllers"` + } + if err := json.Unmarshal(raw, &doc); err != nil { + slog.Warn("raid: parse storcli json failed", "err", err) + return nil + } + + var drives []schema.HardwareStorage + for _, ctl := range doc.Controllers { + // Older storcli output (or "show" without "all") reports a single + // "Drive Information" array instead of per-slot keys. + if raw, ok := ctl.ResponseData["Drive Information"]; ok { + var list []storcliDrive + if err := json.Unmarshal(raw, &list); err == nil { + for _, d := range list { + if s := storcliDriveToStorage(d); s != nil { + drives = append(drives, *s) + } + } + } + } + for key, raw := range ctl.ResponseData { + if !storcliDrivePerSlotKeyRe.MatchString(key) { + continue + } + var list []storcliDrive + if err := json.Unmarshal(raw, &list); err != nil { + continue + } + for _, d := range list { + if s := storcliDriveToStorage(d); s != nil { + drives = append(drives, *s) + } + } + } + } + return drives +} + +func storcliDriveToStorage(d storcliDrive) *schema.HardwareStorage { present := true status := mapRAIDDriveStatus(d.State) s := schema.HardwareStorage{ diff --git a/audit/internal/collector/raid_storcli2_test.go b/audit/internal/collector/raid_storcli2_test.go index 4e2132b..dea7424 100644 --- a/audit/internal/collector/raid_storcli2_test.go +++ b/audit/internal/collector/raid_storcli2_test.go @@ -73,3 +73,38 @@ func TestCollectStorcli2DrivesEndToEnd(t *testing.T) { t.Fatalf("drives[0]=%#v want serial S6EYNE0T123456", drives[0]) } } + +// TestParseStorcliDrivesJSONPerSlotKeys regression-tests against a live +// SAS3808-iMR "storcli64 /call/eall/sall show all J" dump: "Response Data" +// has no "Drive Information" array at all, only per-slot keys like +// "Drive /c0/e69/s0" (plus a paired "... - Detailed Information" key that +// must be skipped). The previous parser, which only looked for "Drive +// Information", silently returned zero drives against this real output. +func TestParseStorcliDrivesJSONPerSlotKeys(t *testing.T) { + raw := []byte(`{ + "Controllers": [ + { + "Command Status": {"Status": "Success"}, + "Response Data": { + "Drive /c0/e69/s0": [ + {"EID:Slt": "69:0", "DID": 0, "State": "JBOD", "DG": "-", "Size": "447.130 GB", "Intf": "SATA", "Med": "SSD", "Model": "SAMSUNG MZ7L3480HCHQ-00B7C", "Sp": "U", "Type": "-"} + ], + "Drive /c0/e69/s0 - Detailed Information": { + "Drive /c0/e69/s0 State": {"Shield Counter": 0} + } + } + } + ] + }`) + + drives := parseStorcliDrivesJSON(raw) + if len(drives) != 1 { + t.Fatalf("drives=%d want 1 (%#v)", len(drives), drives) + } + if drives[0].Model == nil || *drives[0].Model != "SAMSUNG MZ7L3480HCHQ-00B7C" { + t.Fatalf("drives[0]=%#v want model SAMSUNG MZ7L3480HCHQ-00B7C", drives[0]) + } + if drives[0].Slot == nil || *drives[0].Slot != "69:0" { + t.Fatalf("drives[0].Slot=%v want 69:0", drives[0].Slot) + } +} diff --git a/audit/internal/webui/raid_mgmt.go b/audit/internal/webui/raid_mgmt.go index 57ae6b4..a3b2124 100644 --- a/audit/internal/webui/raid_mgmt.go +++ b/audit/internal/webui/raid_mgmt.go @@ -72,17 +72,7 @@ func detectLSIControllers() []raidControllerInfo { var driveDoc 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"` + ResponseData map[string]json.RawMessage `json:"Response Data"` } `json:"Controllers"` } if len(driveOut) > 0 { @@ -105,22 +95,8 @@ func detectLSIControllers() []raidControllerInfo { } if i < len(driveDoc.Controllers) { - for _, d := range driveDoc.Controllers[i].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), - } - ctrl.AllDrives = append(ctrl.AllDrives, info) - switch strings.TrimSpace(d.State) { - case "Frgn": - ctrl.ForeignDrives = append(ctrl.ForeignDrives, info) - case "UGood", "JBOD": - ctrl.FreeDrives = append(ctrl.FreeDrives, info) - } - } + ctrl.AllDrives, ctrl.ForeignDrives, ctrl.FreeDrives = classifyStorcliDrives( + parseStorcliResponseDataDrives(driveDoc.Controllers[i].ResponseData)) } controllers = append(controllers, ctrl) @@ -128,15 +104,78 @@ func detectLSIControllers() []raidControllerInfo { return controllers } +// storcliDriveJSON is the per-drive record shape storcli64/storcli2 emit, +// whether nested in a "Drive Information" array or a per-slot "Drive +// /cX/eY/sZ" key (see parseStorcliResponseDataDrives). +type storcliDriveJSON struct { + EIDSlt string `json:"EID:Slt"` + State string `json:"State"` + Size string `json:"Size"` + Model string `json:"Model"` + SN string `json:"SN"` +} + +// storcliDrivePerSlotKeyRe matches "Response Data" keys like "Drive /c0/e69/s0". +// Real storcli64/storcli2 "eall/sall show all J" output nests each drive under +// its own dynamically-named key instead of a shared "Drive Information" array +// — confirmed against a live SAS3808-iMR dump that has no "Drive Information" +// key at all. Must not match the paired "... - Detailed Information" key. +var storcliDrivePerSlotKeyRe = regexp.MustCompile(`^Drive /c\d+/e\d+/s\d+$`) + +// parseStorcliResponseDataDrives extracts drive records from one controller's +// "Response Data" object, supporting both the legacy "Drive Information" +// array shape and the per-slot dynamic-key shape. +func parseStorcliResponseDataDrives(responseData map[string]json.RawMessage) []storcliDriveJSON { + var drives []storcliDriveJSON + appendList := func(raw json.RawMessage) { + var list []storcliDriveJSON + if err := json.Unmarshal(raw, &list); err != nil { + return + } + drives = append(drives, list...) + } + if raw, ok := responseData["Drive Information"]; ok { + appendList(raw) + } + for key, raw := range responseData { + if storcliDrivePerSlotKeyRe.MatchString(key) { + appendList(raw) + } + } + return drives +} + +func classifyStorcliDrives(drives []storcliDriveJSON) (all, foreign, free []raidDriveInfo) { + for _, d := range drives { + 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 +} + // --- 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. +// storcli2 is Broadcom's separate tool/JSON schema for the Tri-Mode MegaRAID +// line; on hardware where it works, storcli64 can still enumerate the same +// controller at a basic level (hence it may also show up via +// detectLSIControllers), so this is run as an additional source alongside +// storcli64/VROC rather than a replacement. On at least one confirmed +// SAS3808-iMR system, storcli2 itself reports zero controllers even though +// storcli64 sees the controller and its drives fine — a separate, +// tool-side gap this webui can't work around beyond reporting nothing here +// and relying on detectLSIControllers. func detectStorcli2Controllers() []raidControllerInfo { sysOut, err := exec.Command("storcli2", "show", "all", "J").Output() if err != nil { @@ -182,49 +221,22 @@ func detectStorcli2Controllers() []raidControllerInfo { 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. +// parseStorcli2DriveInformation parses a single controller's "storcli2 +// /cX/eall/sall show all J" output, which uses the same "Response Data" +// shape as storcli64 (see parseStorcliResponseDataDrives). 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"` + ResponseData map[string]json.RawMessage `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 + return classifyStorcliDrives(parseStorcliResponseDataDrives(doc.Controllers[0].ResponseData)) } // --- VROC/mdadm detection --- diff --git a/audit/internal/webui/raid_mgmt_test.go b/audit/internal/webui/raid_mgmt_test.go index 00f7b0a..32ae1b1 100644 --- a/audit/internal/webui/raid_mgmt_test.go +++ b/audit/internal/webui/raid_mgmt_test.go @@ -47,3 +47,38 @@ func TestParseStorcli2DriveInformationEmptyOrMalformed(t *testing.T) { t.Fatalf("empty Controllers should return nil, got %#v", all) } } + +// TestParseStorcliResponseDataDrivesPerSlotKeys regression-tests against a +// live SAS3808-iMR "storcli64 /call/eall/sall show all J" dump: "Response +// Data" has no "Drive Information" array at all, only per-slot keys like +// "Drive /c0/e69/s0" (plus a paired "... - Detailed Information" key that +// must be skipped). This is what made "RAID Controller Management" show +// "No drives detected" for a controller storcli64 could clearly see. +func TestParseStorcliResponseDataDrivesPerSlotKeys(t *testing.T) { + raw := []byte(`{ + "Controllers": [ + { + "Command Status": {"Status": "Success"}, + "Response Data": { + "Drive /c0/e69/s0": [ + {"EID:Slt": "69:0", "State": "JBOD", "Size": "447.130 GB", "Model": "SAMSUNG MZ7L3480HCHQ-00B7C"} + ], + "Drive /c0/e69/s0 - Detailed Information": { + "Drive /c0/e69/s0 State": {"Shield Counter": 0} + } + } + } + ] + }`) + + all, _, free := parseStorcli2DriveInformation(raw) + if len(all) != 1 { + t.Fatalf("all=%d want 1 (%#v)", len(all), all) + } + if all[0].Model != "SAMSUNG MZ7L3480HCHQ-00B7C" || all[0].Slot != "69:0" { + t.Fatalf("all[0]=%#v want model/slot populated", all[0]) + } + if len(free) != 1 { + t.Fatalf("free=%d want 1 (JBOD is a free drive)", len(free)) + } +}