Pulse/internal/mdadm/mdadm.go
2025-12-29 17:25:21 +00:00

260 lines
7 KiB
Go

package mdadm
import (
"context"
"fmt"
"os/exec"
"regexp"
"strconv"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/pkg/agents/host"
)
// Pre-compiled regexes for performance (avoid recompilation on each call)
var (
mdDeviceRe = regexp.MustCompile(`^(md\d+)\s*:`)
slotRe = regexp.MustCompile(`^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(.+?)\s+(/dev/.+)$`)
speedRe = regexp.MustCompile(`speed=(\S+)`)
runCommandOutput = func(ctx context.Context, name string, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, name, args...)
return cmd.Output()
}
)
// CollectArrays discovers and collects status for all mdadm RAID arrays on the system.
// Returns an empty slice if mdadm is not available or no arrays are found.
func CollectArrays(ctx context.Context) ([]host.RAIDArray, error) {
// Check if mdadm is available
if !isMdadmAvailable(ctx) {
return nil, nil
}
// Get list of arrays from /proc/mdstat
devices, err := listArrayDevices(ctx)
if err != nil {
return nil, fmt.Errorf("list array devices: %w", err)
}
if len(devices) == 0 {
return nil, nil
}
// Collect detailed info for each array
var arrays []host.RAIDArray
for _, device := range devices {
array, err := collectArrayDetail(ctx, device)
if err != nil {
// Log but don't fail - continue with other arrays
continue
}
arrays = append(arrays, array)
}
return arrays, nil
}
// isMdadmAvailable checks if mdadm binary is accessible
func isMdadmAvailable(ctx context.Context) bool {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
_, err := runCommandOutput(ctx, "mdadm", "--version")
return err == nil
}
// listArrayDevices scans /proc/mdstat to find all md devices
func listArrayDevices(ctx context.Context) ([]string, error) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
output, err := runCommandOutput(ctx, "cat", "/proc/mdstat")
if err != nil {
return nil, fmt.Errorf("read /proc/mdstat: %w", err)
}
// Parse /proc/mdstat to find device names
// Lines like: md0 : active raid1 sdb1[1] sda1[0]
var devices []string
for _, line := range strings.Split(string(output), "\n") {
matches := mdDeviceRe.FindStringSubmatch(line)
if len(matches) > 1 {
devices = append(devices, "/dev/"+matches[1])
}
}
return devices, nil
}
// collectArrayDetail runs mdadm --detail on a specific device and parses the output
func collectArrayDetail(ctx context.Context, device string) (host.RAIDArray, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
output, err := runCommandOutput(ctx, "mdadm", "--detail", device)
if err != nil {
return host.RAIDArray{}, fmt.Errorf("mdadm --detail %s: %w", device, err)
}
return parseDetail(device, string(output))
}
// parseDetail parses the output of mdadm --detail
func parseDetail(device, output string) (host.RAIDArray, error) {
array := host.RAIDArray{
Device: device,
Devices: []host.RAIDDevice{},
}
lines := strings.Split(output, "\n")
inDeviceSection := false
for _, line := range lines {
line = strings.TrimSpace(line)
// Skip empty lines
if line == "" {
continue
}
// Check if we're entering the device list section
if strings.Contains(line, "Number") && strings.Contains(line, "Major") && strings.Contains(line, "Minor") {
inDeviceSection = true
continue
}
// Parse device entries
if inDeviceSection {
matches := slotRe.FindStringSubmatch(line)
if len(matches) >= 7 {
slot, _ := strconv.Atoi(matches[1])
state := strings.TrimSpace(matches[5])
devicePath := strings.TrimSpace(matches[6])
array.Devices = append(array.Devices, host.RAIDDevice{
Device: devicePath,
State: state,
Slot: slot,
})
continue
}
// Handle spare/faulty devices (different format)
if strings.Contains(line, "spare") || strings.Contains(line, "faulty") {
parts := strings.Fields(line)
if len(parts) >= 2 {
state := "spare"
if strings.Contains(line, "faulty") {
state = "faulty"
}
devicePath := parts[len(parts)-1]
array.Devices = append(array.Devices, host.RAIDDevice{
Device: devicePath,
State: state,
Slot: -1,
})
}
}
continue
}
// Parse key-value pairs
if strings.Contains(line, ":") {
// SplitN with n=2 always returns 2 elements when ":" exists
parts := strings.SplitN(line, ":", 2)
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
switch key {
case "Name":
array.Name = value
case "Raid Level":
array.Level = strings.ToLower(value)
case "State":
array.State = strings.ToLower(value)
case "Total Devices":
array.TotalDevices, _ = strconv.Atoi(value)
case "Active Devices":
array.ActiveDevices, _ = strconv.Atoi(value)
case "Working Devices":
array.WorkingDevices, _ = strconv.Atoi(value)
case "Failed Devices":
array.FailedDevices, _ = strconv.Atoi(value)
case "Spare Devices":
array.SpareDevices, _ = strconv.Atoi(value)
case "UUID":
array.UUID = value
case "Rebuild Status":
// Parse rebuild percentage
// Format: "50% complete"
if strings.Contains(value, "%") {
percentStr := strings.TrimSpace(strings.Split(value, "%")[0])
array.RebuildPercent, _ = strconv.ParseFloat(percentStr, 64)
}
case "Reshape Status":
// Handle reshape similarly to rebuild
if strings.Contains(value, "%") {
percentStr := strings.TrimSpace(strings.Split(value, "%")[0])
array.RebuildPercent, _ = strconv.ParseFloat(percentStr, 64)
}
}
}
}
// Check for rebuild/resync info in /proc/mdstat for speed information
if array.RebuildPercent > 0 {
speed := getRebuildSpeed(device)
if speed != "" {
array.RebuildSpeed = speed
}
}
return array, nil
}
// getRebuildSpeed extracts rebuild speed from /proc/mdstat
func getRebuildSpeed(device string) string {
// Remove /dev/ prefix for /proc/mdstat lookup
deviceName := strings.TrimPrefix(device, "/dev/")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
output, err := runCommandOutput(ctx, "cat", "/proc/mdstat")
if err != nil {
return ""
}
// Look for lines containing rebuild/resync speed
// Example: [==>..................] recovery = 12.6% (37043392/293039104) finish=127.5min speed=33440K/sec
lines := strings.Split(string(output), "\n")
inSection := false
for _, line := range lines {
// Check if this is our device
if strings.HasPrefix(strings.TrimSpace(line), deviceName) {
inSection = true
continue
}
// If we're in the right section, look for speed info
if inSection {
if strings.Contains(line, "speed=") {
// Extract speed value using pre-compiled regex
matches := speedRe.FindStringSubmatch(line)
if len(matches) > 1 {
return matches[1]
}
}
// Exit section when we hit a new device or blank line
if strings.TrimSpace(line) == "" || (strings.HasPrefix(strings.TrimSpace(line), "md") && strings.Contains(line, ":")) {
break
}
}
}
return ""
}