Files
pulse/internal/monitoring/physical_disk_pool_membership.go
T
2026-04-23 20:38:33 +01:00

223 lines
6.1 KiB
Go

package monitoring
import (
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/pkg/proxmox"
)
// diskPoolAssignment maps a disk's canonical key (lowercase, prefix-stripped) to
// its owning pool. Keys are derived from leaf-device names in ZFS pool trees and
// are consulted with several candidate forms of a physical disk's identity.
type diskPoolAssignment struct {
keyToPool map[string]string // normalised leaf name / by-id path → pool name
serialPool map[string]string // lowercase serial fragment → pool name (for by-id matches)
}
// buildDiskPoolAssignment flattens the ZFS pool trees returned by Proxmox and
// indexes every leaf device under several normalised keys so a physical disk
// can be matched regardless of whether zpool references it by /dev path,
// /dev/disk/by-id path, or partition name.
func buildDiskPoolAssignment(pools []proxmox.ZFSPoolInfo) *diskPoolAssignment {
assignment := &diskPoolAssignment{
keyToPool: make(map[string]string),
serialPool: make(map[string]string),
}
for _, pool := range pools {
if pool.Name == "" {
continue
}
for _, dev := range pool.Devices {
assignment.indexLeaves(dev, pool.Name)
}
}
return assignment
}
func (a *diskPoolAssignment) indexLeaves(dev proxmox.ZFSPoolDevice, poolName string) {
if dev.Leaf == 1 {
name := strings.TrimSpace(dev.Name)
if name != "" {
for _, key := range normaliseLeafKeys(name) {
if _, exists := a.keyToPool[key]; !exists {
a.keyToPool[key] = poolName
}
}
if serial := serialFromByID(name); serial != "" {
if _, exists := a.serialPool[serial]; !exists {
a.serialPool[serial] = poolName
}
}
}
}
for _, child := range dev.Children {
a.indexLeaves(child, poolName)
}
}
// lookup returns the pool name a disk belongs to, or "" if no match.
func (a *diskPoolAssignment) lookup(disk models.PhysicalDisk) string {
if a == nil {
return ""
}
for _, key := range diskLookupKeys(disk) {
if pool, ok := a.keyToPool[key]; ok {
return pool
}
}
serial := strings.ToLower(strings.TrimSpace(disk.Serial))
if serial != "" {
if pool, ok := a.serialPool[serial]; ok {
return pool
}
}
wwn := strings.ToLower(strings.TrimSpace(disk.WWN))
wwn = strings.TrimPrefix(wwn, "0x")
if wwn != "" {
if pool, ok := a.serialPool[wwn]; ok {
return pool
}
}
return ""
}
// normaliseLeafKeys derives candidate lookup keys from a zpool leaf-device name.
// Keys are lowercased and stripped of common prefixes so they can match against
// physical-disk DevPath variants.
func normaliseLeafKeys(raw string) []string {
name := strings.ToLower(strings.TrimSpace(raw))
if name == "" {
return nil
}
keys := map[string]struct{}{name: {}}
trimmed := strings.TrimPrefix(name, "/dev/")
trimmed = strings.TrimPrefix(trimmed, "disk/by-id/")
trimmed = strings.TrimPrefix(trimmed, "disk/by-path/")
trimmed = strings.TrimPrefix(trimmed, "disk/by-uuid/")
if trimmed != "" {
keys[trimmed] = struct{}{}
}
if base := stripPartitionSuffix(trimmed); base != "" {
keys[base] = struct{}{}
}
out := make([]string, 0, len(keys))
for k := range keys {
out = append(out, k)
}
return out
}
// diskLookupKeys derives candidate lookup keys from a physical disk's
// identifying fields. Order matters: the most specific matches come first.
func diskLookupKeys(disk models.PhysicalDisk) []string {
devPath := strings.ToLower(strings.TrimSpace(disk.DevPath))
if devPath == "" {
return nil
}
seen := map[string]struct{}{}
add := func(k string) {
if k == "" {
return
}
seen[k] = struct{}{}
}
add(devPath)
add(strings.TrimPrefix(devPath, "/dev/"))
base := strings.TrimPrefix(devPath, "/dev/")
add(stripPartitionSuffix(base))
out := make([]string, 0, len(seen))
for k := range seen {
out = append(out, k)
}
return out
}
// stripPartitionSuffix removes a trailing partition identifier. Handles both
// sdX style (trailing digits after an all-alphabetic prefix) and the
// p-separator style used by devices whose name already contains digits
// (nvme0n1p3, mmcblk0p1).
func stripPartitionSuffix(name string) string {
if name == "" {
return ""
}
// p-separator form: the base name already contains a digit, and the
// partition is appended after a literal "p". nvme0n1p3 → nvme0n1.
if idx := strings.LastIndex(name, "p"); idx > 0 {
suffix := name[idx+1:]
if suffix != "" && allDigits(suffix) {
prev := name[:idx]
if len(prev) > 0 && isDigit(prev[len(prev)-1]) {
return prev
}
}
}
// sdX / hdX form: strip trailing digits only when the remaining prefix
// is all alphabetic. This avoids over-stripping device names that
// carry digits as part of their identifier (nvme0n1, mmcblk0).
i := len(name)
for i > 0 && isDigit(name[i-1]) {
i--
}
if i == len(name) || i == 0 {
return name
}
prefix := name[:i]
for j := 0; j < len(prefix); j++ {
if !isAlpha(prefix[j]) {
return name
}
}
return prefix
}
func isAlpha(b byte) bool {
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z')
}
// serialFromByID extracts a lowercase serial-like fragment from a by-id path.
// Proxmox commonly references ZFS members via /dev/disk/by-id/ata-MODEL_SERIAL
// or nvme-MODEL_SERIAL; the trailing token is the disk serial.
func serialFromByID(raw string) string {
name := strings.ToLower(strings.TrimSpace(raw))
name = strings.TrimPrefix(name, "/dev/")
name = strings.TrimPrefix(name, "disk/by-id/")
if !(strings.HasPrefix(name, "ata-") ||
strings.HasPrefix(name, "scsi-") ||
strings.HasPrefix(name, "nvme-") ||
strings.HasPrefix(name, "wwn-")) {
return ""
}
// Strip optional partition suffix like "-part1"
if idx := strings.LastIndex(name, "-part"); idx > 0 {
name = name[:idx]
}
// The last underscore-separated token is typically the serial.
if idx := strings.LastIndex(name, "_"); idx > 0 {
return name[idx+1:]
}
if strings.HasPrefix(name, "wwn-0x") {
return strings.TrimPrefix(name, "wwn-0x")
}
if strings.HasPrefix(name, "wwn-") {
return strings.TrimPrefix(name, "wwn-")
}
return ""
}
func allDigits(s string) bool {
for i := 0; i < len(s); i++ {
if !isDigit(s[i]) {
return false
}
}
return len(s) > 0
}
func isDigit(b byte) bool { return b >= '0' && b <= '9' }