package webui import ( "context" "encoding/json" "fmt" "net/http" "os/exec" "strconv" "strings" "time" ) func (h *handler) handleAPIRAIDStatus(w http.ResponseWriter, r *http.Request) { resp := raidStatusResp{Controllers: []raidControllerInfo{}} lsi2 := detectStorcli2Controllers() if lsi := detectLSIControllers(); len(lsi) > 0 { // 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) } 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 (h *handler) handleAPIRAIDPrepareDrive(w http.ResponseWriter, r *http.Request) { var req struct { ControllerID string `json:"controller_id"` Slot string `json:"slot"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid JSON") return } ctrlIdx, ok := parseLSIControllerIndex(req.ControllerID) if !ok { writeError(w, http.StatusBadRequest, "invalid controller_id") return } if _, _, ok := parseRAIDSlot(req.Slot); !ok { writeError(w, http.StatusBadRequest, "invalid slot") return } t := &Task{ ID: newJobID("raid-lsi-prepare-drive"), Name: fmt.Sprintf("Prepare drive %s (LSI ctrl %d)", req.Slot, ctrlIdx), Target: "raid-lsi-prepare-drive", Priority: defaultTaskPriority("raid-lsi-prepare-drive", taskParams{}), Status: TaskPending, CreatedAt: time.Now(), params: taskParams{RAIDController: ctrlIdx, RAIDSlot: req.Slot}, } 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) } // raidPrepareAction says what (if anything) must be done to a drive in the // given storcli state before it can join a new VD. Derived from the Broadcom // StorCLI drive-state matrix: only UGood drives are accepted by "add vd"; // JBOD/UBad convert with "set good force"; hotspares must be released first; // Frgn/Onln hold configuration data and must not be silently destroyed. type raidPrepareAction int const ( raidPrepNone raidPrepareAction = iota // UGood or unknown — try add vd as-is raidPrepSetGood // JBOD, UBad — "set good force" raidPrepHotspare // GHS, DHS — "delete hotsparedrive", then set good raidPrepBlockedFrgn raidPrepBlockedOnln ) func classifyRAIDPrepareAction(state string) raidPrepareAction { switch strings.TrimSpace(state) { case "JBOD", "UBad": return raidPrepSetGood case "GHS", "DHS": return raidPrepHotspare case "Frgn": return raidPrepBlockedFrgn case "Onln", "Offln": return raidPrepBlockedOnln default: // "UGood", "" (state unknown — let add vd decide) return raidPrepNone } } // raidLSIDriveStates returns EID:Slt -> State for one controller, or nil if // storcli/parsing fails (callers then fall back to unconditional prepare). func raidLSIDriveStates(ctx context.Context, ctrl int) map[string]string { out, err := exec.CommandContext(ctx, "storcli64", fmt.Sprintf("/c%d/eall/sall", ctrl), "show", "all", "J").Output() if err != nil { return nil } var doc struct { Controllers []struct { ResponseData map[string]json.RawMessage `json:"Response Data"` } `json:"Controllers"` } if err := json.Unmarshal(out, &doc); err != nil || len(doc.Controllers) == 0 { return nil } states := map[string]string{} for _, c := range doc.Controllers { for _, d := range parseStorcliResponseDataDrives(c.ResponseData) { states[strings.TrimSpace(d.EIDSlt)] = strings.TrimSpace(d.State) } } return states } func runRAIDLSICreateMirrorTask(ctx context.Context, j *jobState, ctrl int, drives []string) error { driveList := strings.Join(drives, ",") states := raidLSIDriveStates(ctx, ctrl) // Non-UGood drives cannot be added to a VD directly — storcli fails with // "resources already in use" (exit 11) or similar. Fix what is safely // fixable (JBOD/UBad/hotspare), refuse what holds data (Frgn/Onln). for _, drive := range drives { eid, slt, ok := parseRAIDSlot(drive) if !ok { return fmt.Errorf("invalid drive slot %q", drive) } state := states[drive] slotPath := fmt.Sprintf("/c%d/e%d/s%d", ctrl, eid, slt) switch classifyRAIDPrepareAction(state) { case raidPrepBlockedFrgn: return fmt.Errorf("drive %s carries a foreign configuration; run the RAID Foreign Clear (or Import) task first, then retry", drive) case raidPrepBlockedOnln: return fmt.Errorf("drive %s is part of an existing virtual drive (state %s); delete that VD first", drive, state) case raidPrepHotspare: j.append(fmt.Sprintf("Drive %s is a hotspare (%s); releasing it...", drive, state)) rel := exec.CommandContext(ctx, "storcli64", slotPath, "delete", "hotsparedrive") if err := streamCmdJob(j, rel); err != nil { return fmt.Errorf("release hotspare %s: %w", drive, err) } case raidPrepSetGood: j.append(fmt.Sprintf("Drive %s is %s; converting to Unconfigured Good (set good force)...", drive, state)) prep := exec.CommandContext(ctx, "storcli64", slotPath, "set", "good", "force") if err := streamCmdJob(j, prep); err != nil { return fmt.Errorf("set good on %s: %w", drive, err) } case raidPrepNone: if state == "" { // Drive state unknown (storcli query failed) — attempt the // conversion anyway; harmless on an already-UGood drive with // force, and add vd below is the real verdict. j.append(fmt.Sprintf("Preparing drive %s (set good, force)...", drive)) prep := exec.CommandContext(ctx, "storcli64", slotPath, "set", "good", "force") if err := streamCmdJob(j, prep); err != nil { j.append(fmt.Sprintf("note: set good on %s: %v (continuing)", drive, err)) } } } } 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", ) if err := streamCmdJob(j, cmd); err != nil { // A blocked add vd is often preserved cache from a dead VD // ("controller has data in cache for offline or missing virtual // drives"). Surface it so the log is actionable. j.append("add vd failed; checking for preserved cache...") pc := exec.CommandContext(ctx, "storcli64", fmt.Sprintf("/c%d", ctrl), "show", "preservedcache") _ = streamCmdJob(j, pc) j.append(fmt.Sprintf("hint: if preserved cache is listed above, clear it with: storcli64 /c%d/vall delete preservedcache (invalidates cached data of dead VDs), then retry", ctrl)) return err } return nil } // parseRAIDSlot splits a storcli "EID:Slt" identifier (e.g. "252:0") into // enclosure and slot numbers. func parseRAIDSlot(slot string) (eid int, slt int, ok bool) { parts := strings.SplitN(strings.TrimSpace(slot), ":", 2) if len(parts) != 2 { return 0, 0, false } eid, err1 := strconv.Atoi(strings.TrimSpace(parts[0])) slt, err2 := strconv.Atoi(strings.TrimSpace(parts[1])) if err1 != nil || err2 != nil { return 0, 0, false } return eid, slt, true } func runRAIDPrepareDriveTask(ctx context.Context, j *jobState, ctrl int, slot string) error { eid, slt, ok := parseRAIDSlot(slot) if !ok { return fmt.Errorf("invalid slot %q", slot) } j.append(fmt.Sprintf("Preparing drive %s on controller %d (set good, force)...", slot, ctrl)) cmd := exec.CommandContext(ctx, "storcli64", fmt.Sprintf("/c%d/e%d/s%d", ctrl, eid, slt), "set", "good", "force", ) 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...
` }