From 258ecb3453885c1f018609a6794f4b82c772fe43 Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Fri, 19 Jun 2026 08:58:19 +0300 Subject: [PATCH] Add RAID Controller Management to Tools page Unified card for LSI/Broadcom and Intel VROC controllers: auto-detects foreign configurations and warns the operator with Import/Clear actions; allows creating RAID 1 mirrors from unconfigured drives regardless of controller type. Live output streams via SSE into an inline terminal. Co-Authored-By: Claude Sonnet 4.6 --- audit/internal/webui/page_export_tools.go | 4 +- audit/internal/webui/raid_mgmt.go | 689 ++++++++++++++++++++++ audit/internal/webui/server.go | 3 + audit/internal/webui/task_runner.go | 16 + audit/internal/webui/tasks.go | 3 + 5 files changed, 714 insertions(+), 1 deletion(-) create mode 100644 audit/internal/webui/raid_mgmt.go diff --git a/audit/internal/webui/page_export_tools.go b/audit/internal/webui/page_export_tools.go index 9ce2f51..f43027e 100644 --- a/audit/internal/webui/page_export_tools.go +++ b/audit/internal/webui/page_export_tools.go @@ -406,7 +406,9 @@ func renderTools() string { ` + renderSAADMICard() + ` -` + renderIPMIFRUCard() +` + renderIPMIFRUCard() + ` + +` + renderRAIDMgmtCard() } func renderExportIndex(exportDir string) (string, error) { diff --git a/audit/internal/webui/raid_mgmt.go b/audit/internal/webui/raid_mgmt.go new file mode 100644 index 0000000..28e123f --- /dev/null +++ b/audit/internal/webui/raid_mgmt.go @@ -0,0 +1,689 @@ +package webui + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "os/exec" + "regexp" + "strconv" + "strings" + "time" +) + +// --- Response types --- + +type raidDriveInfo struct { + Slot string `json:"slot,omitempty"` + Device string `json:"device,omitempty"` + Model string `json:"model,omitempty"` + SizeGB float64 `json:"size_gb,omitempty"` + Serial string `json:"serial,omitempty"` + State string `json:"state,omitempty"` +} + +type raidArrayInfo struct { + Name string `json:"name"` + Level string `json:"level,omitempty"` + Members []string `json:"members"` + Degraded bool `json:"degraded"` +} + +type raidControllerInfo struct { + ID string `json:"id"` + Type string `json:"type"` + Index int `json:"index"` + Model string `json:"model"` + ForeignDrives []raidDriveInfo `json:"foreign_drives"` + FreeDrives []raidDriveInfo `json:"free_drives"` + Arrays []raidArrayInfo `json:"arrays,omitempty"` +} + +type raidStatusResp struct { + Controllers []raidControllerInfo `json:"controllers"` +} + +// --- LSI/storcli detection --- + +func detectLSIControllers() []raidControllerInfo { + ctrlOut, err := exec.Command("storcli64", "/call", "show", "J").Output() + if err != nil { + return nil + } + + var ctrlDoc struct { + Controllers []struct { + ResponseData struct { + Basics struct { + Controller int `json:"Controller"` + Model string `json:"Model"` + } `json:"Basics"` + } `json:"Response Data"` + } `json:"Controllers"` + } + if err := json.Unmarshal(ctrlOut, &ctrlDoc); err != nil || len(ctrlDoc.Controllers) == 0 { + return nil + } + + driveOut, _ := exec.Command("storcli64", "/call/eall/sall", "show", "all", "J").Output() + + 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"` + } `json:"Controllers"` + } + if len(driveOut) > 0 { + json.Unmarshal(driveOut, &driveDoc) //nolint:errcheck + } + + var controllers []raidControllerInfo + for i, c := range ctrlDoc.Controllers { + ctrl := raidControllerInfo{ + ID: fmt.Sprintf("lsi-%d", c.ResponseData.Basics.Controller), + Type: "lsi", + Index: c.ResponseData.Basics.Controller, + Model: c.ResponseData.Basics.Model, + ForeignDrives: []raidDriveInfo{}, + FreeDrives: []raidDriveInfo{}, + } + if ctrl.Model == "" { + ctrl.Model = fmt.Sprintf("LSI Controller %d", ctrl.Index) + } + + 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), + } + switch strings.TrimSpace(d.State) { + case "Frgn": + ctrl.ForeignDrives = append(ctrl.ForeignDrives, info) + case "UGood", "JBOD": + ctrl.FreeDrives = append(ctrl.FreeDrives, info) + } + } + } + + controllers = append(controllers, ctrl) + } + return controllers +} + +// --- VROC/mdadm detection --- + +var raidMDStatDegradedRx = regexp.MustCompile(`\[[U_]+\]`) + +type mdStatEntry struct { + Name string + Level string + Members []string + Degraded bool +} + +func parseRAIDMDStat(raw string) []mdStatEntry { + var entries []mdStatEntry + var cur *mdStatEntry + for _, line := range strings.Split(raw, "\n") { + if strings.HasPrefix(line, "Personalities") || strings.HasPrefix(line, "unused devices") { + continue + } + if idx := strings.Index(line, " : "); idx > 0 { + name := strings.TrimSpace(line[:idx]) + rest := line[idx+3:] + entry := mdStatEntry{Name: name} + for _, tok := range strings.Fields(rest) { + if strings.HasPrefix(tok, "raid") || strings.HasPrefix(tok, "linear") { + entry.Level = tok + } + if bk := strings.Index(tok, "["); bk > 0 && strings.HasSuffix(tok, "]") { + entry.Members = append(entry.Members, tok[:bk]) + } + } + entries = append(entries, entry) + cur = &entries[len(entries)-1] + continue + } + if cur != nil { + if m := raidMDStatDegradedRx.FindString(line); m != "" && strings.Contains(m, "_") { + cur.Degraded = true + } + } + } + return entries +} + +func detectVROCController() *raidControllerInfo { + out, err := exec.Command("mdadm", "--detail-platform").CombinedOutput() + if err != nil && len(out) == 0 { + return nil + } + hasVROC := false + for _, line := range strings.Split(string(out), "\n") { + lower := strings.ToLower(line) + if strings.Contains(lower, "license") || strings.Contains(lower, "intel") || strings.Contains(lower, "platform") { + hasVROC = true + break + } + } + if !hasVROC { + return nil + } + + ctrl := &raidControllerInfo{ + ID: "vroc-0", + Type: "vroc", + Model: "Intel VROC", + ForeignDrives: []raidDriveInfo{}, + FreeDrives: []raidDriveInfo{}, + } + + inArray := map[string]bool{} + raw, err := os.ReadFile("/proc/mdstat") + if err == nil { + for _, arr := range parseRAIDMDStat(string(raw)) { + ctrl.Arrays = append(ctrl.Arrays, raidArrayInfo{ + Name: arr.Name, + Level: arr.Level, + Members: arr.Members, + Degraded: arr.Degraded, + }) + for _, m := range arr.Members { + inArray[m] = true + } + } + } + + lsblkOut, err := exec.Command("lsblk", "-J", "-d", "-o", "NAME,SIZE,TYPE,MODEL,SERIAL").Output() + if err == nil { + var lsblkDoc struct { + BlockDevices []struct { + Name string `json:"name"` + Size string `json:"size"` + Type string `json:"type"` + Model string `json:"model"` + Serial string `json:"serial"` + } `json:"blockdevices"` + } + if json.Unmarshal(lsblkOut, &lsblkDoc) == nil { + for _, d := range lsblkDoc.BlockDevices { + if d.Type != "disk" || inArray[d.Name] { + continue + } + ctrl.FreeDrives = append(ctrl.FreeDrives, raidDriveInfo{ + Device: "/dev/" + d.Name, + Model: strings.TrimSpace(d.Model), + Serial: strings.TrimSpace(d.Serial), + State: "available", + }) + } + } + } + + return ctrl +} + +// --- API handlers --- + +func (h *handler) handleAPIRAIDStatus(w http.ResponseWriter, r *http.Request) { + resp := raidStatusResp{Controllers: []raidControllerInfo{}} + + if lsi := detectLSIControllers(); len(lsi) > 0 { + resp.Controllers = append(resp.Controllers, lsi...) + } + if vroc := detectVROCController(); vroc != nil { + resp.Controllers = append(resp.Controllers, *vroc) + } + + writeJSON(w, resp) +} + +func (h *handler) handleAPIRAIDForeignAction(w http.ResponseWriter, r *http.Request) { + var req struct { + ControllerID string `json:"controller_id"` + Action string `json:"action"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON") + return + } + if req.Action != "import" && req.Action != "clear" { + writeError(w, http.StatusBadRequest, "action must be 'import' or 'clear'") + return + } + ctrlIdx, ok := parseLSIControllerIndex(req.ControllerID) + if !ok { + writeError(w, http.StatusBadRequest, "invalid controller_id") + return + } + + target := "raid-foreign-clear" + name := fmt.Sprintf("RAID Foreign Clear (ctrl %d)", ctrlIdx) + if req.Action == "import" { + target = "raid-foreign-import" + name = fmt.Sprintf("RAID Foreign Import (ctrl %d)", ctrlIdx) + } + + t := &Task{ + ID: newJobID(target), + Name: name, + Target: target, + Priority: defaultTaskPriority(target, taskParams{}), + Status: TaskPending, + CreatedAt: time.Now(), + params: taskParams{RAIDController: ctrlIdx}, + } + globalQueue.enqueue(t) + writeJSON(w, map[string]string{"task_id": t.ID}) +} + +func (h *handler) handleAPIRAIDCreateMirror(w http.ResponseWriter, r *http.Request) { + var req struct { + ControllerID string `json:"controller_id"` + Devices []string `json:"devices"` + ArrayName string `json:"array_name"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON") + return + } + if len(req.Devices) < 2 { + writeError(w, http.StatusBadRequest, "at least 2 devices required") + return + } + + var target, name string + var params taskParams + + switch { + case strings.HasPrefix(req.ControllerID, "lsi-"): + ctrlIdx, ok := parseLSIControllerIndex(req.ControllerID) + if !ok { + writeError(w, http.StatusBadRequest, "invalid controller_id") + return + } + target = "raid-lsi-create-mirror" + name = fmt.Sprintf("Create RAID 1 Mirror (LSI ctrl %d)", ctrlIdx) + params = taskParams{RAIDController: ctrlIdx, RAIDDevices: req.Devices} + + case req.ControllerID == "vroc-0": + arrayName := strings.TrimSpace(req.ArrayName) + if arrayName == "" { + arrayName = "bee-mirror0" + } + target = "raid-vroc-create-mirror" + name = fmt.Sprintf("Create VROC RAID 1 (%s)", arrayName) + params = taskParams{RAIDDevices: req.Devices, RAIDArrayName: arrayName} + + default: + writeError(w, http.StatusBadRequest, "unknown controller_id") + return + } + + t := &Task{ + ID: newJobID(target), + Name: name, + Target: target, + Priority: defaultTaskPriority(target, taskParams{}), + Status: TaskPending, + CreatedAt: time.Now(), + params: params, + } + globalQueue.enqueue(t) + writeJSON(w, map[string]string{"task_id": t.ID}) +} + +func parseLSIControllerIndex(id string) (int, bool) { + if !strings.HasPrefix(id, "lsi-") { + return 0, false + } + n, err := strconv.Atoi(strings.TrimPrefix(id, "lsi-")) + if err != nil || n < 0 { + return 0, false + } + return n, true +} + +// --- Task runner functions --- + +func runRAIDForeignClearTask(ctx context.Context, j *jobState, ctrl int) error { + j.append(fmt.Sprintf("Clearing foreign configuration on controller %d...", ctrl)) + cmd := exec.CommandContext(ctx, "storcli64", fmt.Sprintf("/c%d/fall", ctrl), "del", "noprompt") + return streamCmdJob(j, cmd) +} + +func runRAIDForeignImportTask(ctx context.Context, j *jobState, ctrl int) error { + j.append(fmt.Sprintf("Importing foreign configuration on controller %d...", ctrl)) + cmd := exec.CommandContext(ctx, "storcli64", fmt.Sprintf("/c%d/fall", ctrl), "import", "noprompt") + return streamCmdJob(j, cmd) +} + +func runRAIDLSICreateMirrorTask(ctx context.Context, j *jobState, ctrl int, drives []string) error { + driveList := strings.Join(drives, ",") + j.append(fmt.Sprintf("Creating RAID 1 on controller %d with drives: %s", ctrl, driveList)) + cmd := exec.CommandContext(ctx, "storcli64", + fmt.Sprintf("/c%d", ctrl), + "add", "vd", "type=raid1", + fmt.Sprintf("drives=%s", driveList), + "pdperarray=2", + ) + return streamCmdJob(j, cmd) +} + +func runRAIDVROCCreateMirrorTask(ctx context.Context, j *jobState, devices []string, arrayName string) error { + if arrayName == "" { + arrayName = "bee-mirror0" + } + devPath := "/dev/md/" + arrayName + args := []string{ + "--create", devPath, + "--level=1", + fmt.Sprintf("--raid-devices=%d", len(devices)), + "--run", + } + args = append(args, devices...) + j.append(fmt.Sprintf("Creating VROC RAID 1 array %s with: %s", devPath, strings.Join(devices, " "))) + cmd := exec.CommandContext(ctx, "mdadm", args...) + return streamCmdJob(j, cmd) +} + +// raidParseHumanSizeGB parses storcli size strings like "1.818 TB", "745.211 GB". +func raidParseHumanSizeGB(s string) float64 { + s = strings.TrimSpace(s) + if s == "" { + return 0 + } + upper := strings.ToUpper(s) + var mul float64 + var numStr string + switch { + case strings.Contains(upper, " TB"): + mul = 1024 + numStr = strings.TrimSpace(strings.SplitN(upper, " T", 2)[0]) + case strings.Contains(upper, " GB"): + mul = 1 + numStr = strings.TrimSpace(strings.SplitN(upper, " G", 2)[0]) + case strings.Contains(upper, " MB"): + mul = 1.0 / 1024 + numStr = strings.TrimSpace(strings.SplitN(upper, " M", 2)[0]) + default: + return 0 + } + v, err := strconv.ParseFloat(numStr, 64) + if err != nil { + return 0 + } + return v * mul +} + +// --- UI card --- + +func renderRAIDMgmtCard() string { + return `
RAID Controller Management
+
Loading...
+
+ +
+` +} diff --git a/audit/internal/webui/server.go b/audit/internal/webui/server.go index 737c401..c8e36d9 100644 --- a/audit/internal/webui/server.go +++ b/audit/internal/webui/server.go @@ -318,6 +318,9 @@ func NewHandler(opts HandlerOptions) http.Handler { mux.HandleFunc("POST /api/tools/saa-dmi/write", h.handleAPISAADMIWrite) mux.HandleFunc("GET /api/tools/ipmi-fru", h.handleAPIIPMIFRURead) mux.HandleFunc("POST /api/tools/ipmi-fru/write", h.handleAPIIPMIFRUWrite) + mux.HandleFunc("GET /api/tools/raid/status", h.handleAPIRAIDStatus) + mux.HandleFunc("POST /api/tools/raid/foreign", h.handleAPIRAIDForeignAction) + mux.HandleFunc("POST /api/tools/raid/create-mirror", h.handleAPIRAIDCreateMirror) // GPU presence / tools mux.HandleFunc("GET /api/gpu/presence", h.handleAPIGPUPresence) diff --git a/audit/internal/webui/task_runner.go b/audit/internal/webui/task_runner.go index 1200616..85c5ea5 100644 --- a/audit/internal/webui/task_runner.go +++ b/audit/internal/webui/task_runner.go @@ -394,6 +394,22 @@ func executeTaskWithOptions(opts *HandlerOptions, t *Task, j *jobState, ctx cont break } err = runIPMIFRUWriteTask(ctx, j, opts.ExportDir, t.params) + case "raid-foreign-clear": + err = runRAIDForeignClearTask(ctx, j, t.params.RAIDController) + case "raid-foreign-import": + err = runRAIDForeignImportTask(ctx, j, t.params.RAIDController) + case "raid-lsi-create-mirror": + if len(t.params.RAIDDevices) < 2 { + err = fmt.Errorf("at least 2 drives required") + break + } + err = runRAIDLSICreateMirrorTask(ctx, j, t.params.RAIDController, t.params.RAIDDevices) + case "raid-vroc-create-mirror": + if len(t.params.RAIDDevices) < 2 { + err = fmt.Errorf("at least 2 devices required") + break + } + err = runRAIDVROCCreateMirrorTask(ctx, j, t.params.RAIDDevices, t.params.RAIDArrayName) default: j.append("ERROR: unknown target: " + t.Target) j.finish("unknown target") diff --git a/audit/internal/webui/tasks.go b/audit/internal/webui/tasks.go index 44af697..8cd0594 100644 --- a/audit/internal/webui/tasks.go +++ b/audit/internal/webui/tasks.go @@ -142,6 +142,9 @@ type taskParams struct { PlatformComponents []string `json:"platform_components,omitempty"` SAADmiChanges []saaChange `json:"saa_dmi_changes,omitempty"` FRUChanges []fruChange `json:"fru_changes,omitempty"` + RAIDController int `json:"raid_controller,omitempty"` + RAIDDevices []string `json:"raid_devices,omitempty"` + RAIDArrayName string `json:"raid_array_name,omitempty"` } type persistedTask struct {