unraid: parse dimm/nic/pcie and annotate duplicate serials
This commit is contained in:
448
internal/parser/vendors/unraid/parser.go
vendored
448
internal/parser/vendors/unraid/parser.go
vendored
@@ -10,10 +10,11 @@ import (
|
|||||||
|
|
||||||
"git.mchus.pro/mchus/logpile/internal/models"
|
"git.mchus.pro/mchus/logpile/internal/models"
|
||||||
"git.mchus.pro/mchus/logpile/internal/parser"
|
"git.mchus.pro/mchus/logpile/internal/parser"
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/parser/vendors/pciids"
|
||||||
)
|
)
|
||||||
|
|
||||||
// parserVersion - increment when parsing logic changes.
|
// parserVersion - increment when parsing logic changes.
|
||||||
const parserVersion = "1.0.0"
|
const parserVersion = "1.2"
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
parser.Register(&Parser{})
|
parser.Register(&Parser{})
|
||||||
@@ -97,6 +98,10 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
|
|||||||
|
|
||||||
// Track storage by slot to avoid duplicates
|
// Track storage by slot to avoid duplicates
|
||||||
storageBySlot := make(map[string]*models.Storage)
|
storageBySlot := make(map[string]*models.Storage)
|
||||||
|
hasDetailedMemory := false
|
||||||
|
ethtoolByIface := make(map[string]ethtoolInfo)
|
||||||
|
ethtoolByBDF := make(map[string]ethtoolInfo)
|
||||||
|
ifconfigByIface := make(map[string]ifconfigInfo)
|
||||||
|
|
||||||
// Parse different file types
|
// Parse different file types
|
||||||
for _, f := range files {
|
for _, f := range files {
|
||||||
@@ -116,8 +121,23 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
|
|||||||
case strings.HasSuffix(path, "/system/memory.txt") || strings.HasSuffix(path, "\\system\\memory.txt"):
|
case strings.HasSuffix(path, "/system/memory.txt") || strings.HasSuffix(path, "\\system\\memory.txt"):
|
||||||
parseMemory(content, result)
|
parseMemory(content, result)
|
||||||
|
|
||||||
|
case strings.HasSuffix(path, "/system/meminfo.txt") || strings.HasSuffix(path, "\\system\\meminfo.txt"):
|
||||||
|
if parseMemoryDIMMs(content, result) > 0 {
|
||||||
|
hasDetailedMemory = true
|
||||||
|
}
|
||||||
|
|
||||||
|
case strings.HasSuffix(path, "/system/ifconfig.txt") || strings.HasSuffix(path, "\\system\\ifconfig.txt"):
|
||||||
|
parseIfconfig(content, ifconfigByIface)
|
||||||
|
|
||||||
|
case strings.HasSuffix(path, "/system/ethtool.txt") || strings.HasSuffix(path, "\\system\\ethtool.txt"):
|
||||||
|
parseEthtool(content, ethtoolByIface, ethtoolByBDF)
|
||||||
|
|
||||||
|
case strings.HasSuffix(path, "/system/lspci.txt") || strings.HasSuffix(path, "\\system\\lspci.txt"):
|
||||||
|
parseLSPCI(content, ifconfigByIface, ethtoolByIface, ethtoolByBDF, result)
|
||||||
|
|
||||||
case strings.HasSuffix(path, "/system/vars.txt") || strings.HasSuffix(path, "\\system\\vars.txt"):
|
case strings.HasSuffix(path, "/system/vars.txt") || strings.HasSuffix(path, "\\system\\vars.txt"):
|
||||||
parseVarsToMap(content, storageBySlot, result)
|
parseVarsToMap(content, storageBySlot, result)
|
||||||
|
parseHostIdentityFromVars(content, result)
|
||||||
|
|
||||||
case strings.Contains(path, "/smart/") && strings.HasSuffix(path, ".txt"):
|
case strings.Contains(path, "/smart/") && strings.HasSuffix(path, ".txt"):
|
||||||
parseSMARTFileToMap(content, f.Path, storageBySlot, result)
|
parseSMARTFileToMap(content, f.Path, storageBySlot, result)
|
||||||
@@ -127,6 +147,17 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if hasDetailedMemory {
|
||||||
|
filtered := make([]models.MemoryDIMM, 0, len(result.Hardware.Memory))
|
||||||
|
for _, dimm := range result.Hardware.Memory {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(dimm.Slot), "system") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, dimm)
|
||||||
|
}
|
||||||
|
result.Hardware.Memory = filtered
|
||||||
|
}
|
||||||
|
|
||||||
// Convert storage map to slice
|
// Convert storage map to slice
|
||||||
for _, disk := range storageBySlot {
|
for _, disk := range storageBySlot {
|
||||||
result.Hardware.Storage = append(result.Hardware.Storage, *disk)
|
result.Hardware.Storage = append(result.Hardware.Storage, *disk)
|
||||||
@@ -238,19 +269,19 @@ func parseMotherboard(content string, result *models.AnalysisResult) {
|
|||||||
func parseMemory(content string, result *models.AnalysisResult) {
|
func parseMemory(content string, result *models.AnalysisResult) {
|
||||||
// Parse memory from free output
|
// Parse memory from free output
|
||||||
// Example: Mem: 50Gi 11Gi 1.4Gi 565Mi 39Gi 39Gi
|
// Example: Mem: 50Gi 11Gi 1.4Gi 565Mi 39Gi 39Gi
|
||||||
if m := regexp.MustCompile(`(?m)^Mem:\s+(\d+(?:\.\d+)?)(Ki|Mi|Gi|Ti)`).FindStringSubmatch(content); len(m) >= 3 {
|
if m := regexp.MustCompile(`(?m)^Mem:\s+(\d+(?:\.\d+)?)(Ki|Mi|Gi|Ti|KB|MB|GB|TB)`).FindStringSubmatch(content); len(m) >= 3 {
|
||||||
size := parseFloat(m[1])
|
size := parseFloat(m[1])
|
||||||
unit := m[2]
|
unit := strings.ToUpper(m[2])
|
||||||
|
|
||||||
var sizeMB int
|
var sizeMB int
|
||||||
switch unit {
|
switch unit {
|
||||||
case "Ki":
|
case "KI", "KB":
|
||||||
sizeMB = int(size / 1024)
|
sizeMB = int(size / 1024)
|
||||||
case "Mi":
|
case "MI", "MB":
|
||||||
sizeMB = int(size)
|
sizeMB = int(size)
|
||||||
case "Gi":
|
case "GI", "GB":
|
||||||
sizeMB = int(size * 1024)
|
sizeMB = int(size * 1024)
|
||||||
case "Ti":
|
case "TI", "TB":
|
||||||
sizeMB = int(size * 1024 * 1024)
|
sizeMB = int(size * 1024 * 1024)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,6 +297,358 @@ func parseMemory(content string, result *models.AnalysisResult) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseMemoryDIMMs(content string, result *models.AnalysisResult) int {
|
||||||
|
blocks := strings.Split(content, "Handle ")
|
||||||
|
added := 0
|
||||||
|
for _, block := range blocks {
|
||||||
|
b := strings.TrimSpace(block)
|
||||||
|
if b == "" || !strings.Contains(b, "DMI type 17") || !strings.Contains(b, "Memory Device") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
sizeRaw := extractFieldValue(b, "Size:")
|
||||||
|
if sizeRaw == "" || strings.Contains(strings.ToLower(sizeRaw), "no module installed") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
sizeMB := parseDIMMSizeMB(sizeRaw)
|
||||||
|
if sizeMB <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
slot := extractFieldValue(b, "Locator:")
|
||||||
|
if slot == "" {
|
||||||
|
slot = extractFieldValue(b, "Bank Locator:")
|
||||||
|
}
|
||||||
|
if slot == "" {
|
||||||
|
slot = "dimm"
|
||||||
|
}
|
||||||
|
|
||||||
|
dimm := models.MemoryDIMM{
|
||||||
|
Slot: slot,
|
||||||
|
Location: extractFieldValue(b, "Bank Locator:"),
|
||||||
|
Present: true,
|
||||||
|
SizeMB: sizeMB,
|
||||||
|
Type: extractFieldValue(b, "Type:"),
|
||||||
|
MaxSpeedMHz: parseSpeedMTs(extractFieldValue(b, "Speed:")),
|
||||||
|
CurrentSpeedMHz: parseSpeedMTs(extractFieldValue(b, "Configured Memory Speed:")),
|
||||||
|
Manufacturer: strings.TrimSpace(extractFieldValue(b, "Manufacturer:")),
|
||||||
|
SerialNumber: strings.TrimSpace(extractFieldValue(b, "Serial Number:")),
|
||||||
|
PartNumber: strings.TrimSpace(extractFieldValue(b, "Part Number:")),
|
||||||
|
Ranks: parseInt(extractFieldValue(b, "Rank:")),
|
||||||
|
Status: "ok",
|
||||||
|
}
|
||||||
|
if dimm.Type == "" || strings.EqualFold(dimm.Type, "Unknown") {
|
||||||
|
dimm.Type = "DRAM"
|
||||||
|
}
|
||||||
|
if dimm.CurrentSpeedMHz == 0 {
|
||||||
|
dimm.CurrentSpeedMHz = dimm.MaxSpeedMHz
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Hardware.Memory = append(result.Hardware.Memory, dimm)
|
||||||
|
added++
|
||||||
|
}
|
||||||
|
return added
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractFieldValue(block, key string) string {
|
||||||
|
for _, line := range strings.Split(block, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(line, key) {
|
||||||
|
return strings.TrimSpace(strings.TrimPrefix(line, key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDIMMSizeMB(s string) int {
|
||||||
|
s = strings.TrimSpace(strings.ToUpper(s))
|
||||||
|
if s == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
parts := strings.Fields(s)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
v := parseFloat(parts[0])
|
||||||
|
switch parts[1] {
|
||||||
|
case "KB", "KIB":
|
||||||
|
return int(v / 1024)
|
||||||
|
case "MB", "MIB":
|
||||||
|
return int(v)
|
||||||
|
case "GB", "GIB":
|
||||||
|
return int(v * 1024)
|
||||||
|
case "TB", "TIB":
|
||||||
|
return int(v * 1024 * 1024)
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSpeedMTs(s string) int {
|
||||||
|
s = strings.TrimSpace(strings.ToUpper(s))
|
||||||
|
if s == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
re := regexp.MustCompile(`(\d+)\s*MT/S`)
|
||||||
|
if m := re.FindStringSubmatch(s); len(m) == 2 {
|
||||||
|
return parseInt(m[1])
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
type ethtoolInfo struct {
|
||||||
|
Interface string
|
||||||
|
BusInfo string
|
||||||
|
Driver string
|
||||||
|
Firmware string
|
||||||
|
SpeedMbps int
|
||||||
|
LinkUp bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type ifconfigInfo struct {
|
||||||
|
Interface string
|
||||||
|
State string
|
||||||
|
Addresses []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseIfconfig(content string, out map[string]ifconfigInfo) {
|
||||||
|
lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
iface := strings.Split(fields[0], "@")[0]
|
||||||
|
if iface == "" || strings.HasPrefix(iface, "lo") || strings.HasPrefix(iface, "docker") || strings.HasPrefix(iface, "veth") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
state := fields[1]
|
||||||
|
addrs := make([]string, 0, 2)
|
||||||
|
for _, f := range fields[2:] {
|
||||||
|
if strings.Contains(f, ".") || strings.Contains(f, ":") {
|
||||||
|
addrs = append(addrs, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out[iface] = ifconfigInfo{
|
||||||
|
Interface: iface,
|
||||||
|
State: state,
|
||||||
|
Addresses: addrs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseEthtool(content string, byIface, byBDF map[string]ethtoolInfo) {
|
||||||
|
sections := strings.Split(content, "--------------------------------")
|
||||||
|
for _, sec := range sections {
|
||||||
|
s := strings.TrimSpace(sec)
|
||||||
|
if s == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var info ethtoolInfo
|
||||||
|
for _, line := range strings.Split(s, "\n") {
|
||||||
|
t := strings.TrimSpace(line)
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(t, "Settings for "):
|
||||||
|
info.Interface = strings.TrimSuffix(strings.TrimPrefix(t, "Settings for "), ":")
|
||||||
|
case strings.HasPrefix(t, "driver:"):
|
||||||
|
info.Driver = strings.TrimSpace(strings.TrimPrefix(t, "driver:"))
|
||||||
|
case strings.HasPrefix(t, "firmware-version:"):
|
||||||
|
info.Firmware = strings.TrimSpace(strings.TrimPrefix(t, "firmware-version:"))
|
||||||
|
case strings.HasPrefix(t, "bus-info:"):
|
||||||
|
info.BusInfo = normalizeBDF(strings.TrimSpace(strings.TrimPrefix(t, "bus-info:")))
|
||||||
|
case strings.HasPrefix(t, "Speed:"):
|
||||||
|
info.SpeedMbps = parseSpeedMbps(strings.TrimSpace(strings.TrimPrefix(t, "Speed:")))
|
||||||
|
case strings.HasPrefix(t, "Link detected:"):
|
||||||
|
info.LinkUp = strings.EqualFold(strings.TrimSpace(strings.TrimPrefix(t, "Link detected:")), "yes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if info.Interface != "" {
|
||||||
|
byIface[info.Interface] = info
|
||||||
|
}
|
||||||
|
if info.BusInfo != "" {
|
||||||
|
byBDF[info.BusInfo] = info
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLSPCI(
|
||||||
|
content string,
|
||||||
|
iface map[string]ifconfigInfo,
|
||||||
|
ethtoolByIface map[string]ethtoolInfo,
|
||||||
|
ethtoolByBDF map[string]ethtoolInfo,
|
||||||
|
result *models.AnalysisResult,
|
||||||
|
) {
|
||||||
|
lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
|
||||||
|
lspciLineRe := regexp.MustCompile(`^([0-9a-fA-F:.]+)\s+(.+?)\s+\[[0-9a-fA-F]{4}\]:\s+(.+?)\s+\[([0-9a-fA-F]{4}):([0-9a-fA-F]{4})\]`)
|
||||||
|
|
||||||
|
hasPCIe := make(map[string]bool)
|
||||||
|
hasAdapter := make(map[string]bool)
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
m := lspciLineRe.FindStringSubmatch(strings.TrimSpace(line))
|
||||||
|
if len(m) != 6 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
bdf := normalizeBDF(m[1])
|
||||||
|
class := strings.TrimSpace(m[2])
|
||||||
|
desc := strings.TrimSpace(m[3])
|
||||||
|
vendorID := parseHexID(m[4])
|
||||||
|
deviceID := parseHexID(m[5])
|
||||||
|
|
||||||
|
if bdf == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if isInterestingPCIClass(class) && !hasPCIe[bdf] {
|
||||||
|
vendor := pciids.VendorName(vendorID)
|
||||||
|
result.Hardware.PCIeDevices = append(result.Hardware.PCIeDevices, models.PCIeDevice{
|
||||||
|
Slot: bdf,
|
||||||
|
BDF: bdf,
|
||||||
|
DeviceClass: class,
|
||||||
|
Description: desc,
|
||||||
|
VendorID: vendorID,
|
||||||
|
DeviceID: deviceID,
|
||||||
|
Manufacturer: vendor,
|
||||||
|
Status: "ok",
|
||||||
|
})
|
||||||
|
hasPCIe[bdf] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isNICClass(class) || hasAdapter[bdf] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
etInfo := ethtoolByBDF[bdf]
|
||||||
|
if etInfo.Interface == "" {
|
||||||
|
for _, it := range ethtoolByIface {
|
||||||
|
if normalizeBDF(it.BusInfo) == bdf {
|
||||||
|
etInfo = it
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if etInfo.Driver == "bonding" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
model := desc
|
||||||
|
if devName := pciids.DeviceName(vendorID, deviceID); devName != "" {
|
||||||
|
model = devName
|
||||||
|
}
|
||||||
|
vendor := pciids.VendorName(vendorID)
|
||||||
|
if vendor == "" {
|
||||||
|
vendor = firstWords(desc, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
slot := etInfo.Interface
|
||||||
|
if slot == "" {
|
||||||
|
slot = bdf
|
||||||
|
}
|
||||||
|
status := "ok"
|
||||||
|
if etInfo.Interface != "" && !etInfo.LinkUp {
|
||||||
|
status = "warning"
|
||||||
|
} else if etInfo.Interface != "" {
|
||||||
|
if ifInfo, ok := iface[etInfo.Interface]; ok && !strings.EqualFold(ifInfo.State, "UP") {
|
||||||
|
status = "warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
adapter := models.NetworkAdapter{
|
||||||
|
Slot: slot,
|
||||||
|
Location: bdf,
|
||||||
|
Present: true,
|
||||||
|
Model: model,
|
||||||
|
Vendor: vendor,
|
||||||
|
VendorID: vendorID,
|
||||||
|
DeviceID: deviceID,
|
||||||
|
Firmware: strings.TrimSpace(etInfo.Firmware),
|
||||||
|
PortCount: 1,
|
||||||
|
Status: status,
|
||||||
|
}
|
||||||
|
result.Hardware.NetworkAdapters = append(result.Hardware.NetworkAdapters, adapter)
|
||||||
|
|
||||||
|
result.Hardware.NetworkCards = append(result.Hardware.NetworkCards, models.NIC{
|
||||||
|
Name: slot,
|
||||||
|
Model: model,
|
||||||
|
SpeedMbps: etInfo.SpeedMbps,
|
||||||
|
})
|
||||||
|
|
||||||
|
hasAdapter[bdf] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isNICClass(class string) bool {
|
||||||
|
c := strings.ToLower(strings.TrimSpace(class))
|
||||||
|
return strings.Contains(c, "ethernet controller") || strings.Contains(c, "network controller")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isInterestingPCIClass(class string) bool {
|
||||||
|
c := strings.ToLower(strings.TrimSpace(class))
|
||||||
|
if isNICClass(c) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.Contains(c, "scsi storage controller"),
|
||||||
|
strings.Contains(c, "sata controller"),
|
||||||
|
strings.Contains(c, "raid bus controller"),
|
||||||
|
strings.Contains(c, "vga compatible controller"),
|
||||||
|
strings.Contains(c, "3d controller"),
|
||||||
|
strings.Contains(c, "display controller"),
|
||||||
|
strings.Contains(c, "non-volatile memory controller"),
|
||||||
|
strings.Contains(c, "processing accelerators"):
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseHexID(s string) int {
|
||||||
|
v, err := strconv.ParseInt(strings.TrimSpace(s), 16, 32)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return int(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSpeedMbps(s string) int {
|
||||||
|
s = strings.TrimSpace(strings.ToUpper(s))
|
||||||
|
if s == "" || s == "UNKNOWN!" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if m := regexp.MustCompile(`(\d+)MB/S`).FindStringSubmatch(s); len(m) == 2 {
|
||||||
|
return parseInt(m[1])
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeBDF(bdf string) string {
|
||||||
|
bdf = strings.TrimSpace(strings.ToLower(bdf))
|
||||||
|
if bdf == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.Count(bdf, ":") == 1 {
|
||||||
|
return "0000:" + bdf
|
||||||
|
}
|
||||||
|
return bdf
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstWords(s string, n int) string {
|
||||||
|
parts := strings.Fields(strings.TrimSpace(s))
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if len(parts) < n {
|
||||||
|
n = len(parts)
|
||||||
|
}
|
||||||
|
return strings.Join(parts[:n], " ")
|
||||||
|
}
|
||||||
|
|
||||||
func parseVarsToMap(content string, storageBySlot map[string]*models.Storage, result *models.AnalysisResult) {
|
func parseVarsToMap(content string, storageBySlot map[string]*models.Storage, result *models.AnalysisResult) {
|
||||||
// Normalize line endings
|
// Normalize line endings
|
||||||
content = strings.ReplaceAll(content, "\r\n", "\n")
|
content = strings.ReplaceAll(content, "\r\n", "\n")
|
||||||
@@ -385,6 +768,57 @@ func parseVarsToMap(content string, storageBySlot map[string]*models.Storage, re
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseHostIdentityFromVars(content string, result *models.AnalysisResult) {
|
||||||
|
if result == nil || result.Hardware == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serial := strings.TrimSpace(result.Hardware.BoardInfo.SerialNumber)
|
||||||
|
if isUsableHostIdentifier(serial) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
flashGUID := findVarValue(content, "flashGUID")
|
||||||
|
regGUID := findVarValue(content, "regGUID")
|
||||||
|
rawUUID := findVarValue(content, "uuid")
|
||||||
|
|
||||||
|
candidates := []string{flashGUID, regGUID, rawUUID}
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
candidate = strings.TrimSpace(candidate)
|
||||||
|
if !isUsableHostIdentifier(candidate) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.Hardware.BoardInfo.SerialNumber = candidate
|
||||||
|
if result.Hardware.BoardInfo.UUID == "" && candidate == rawUUID {
|
||||||
|
result.Hardware.BoardInfo.UUID = candidate
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findVarValue(content, key string) string {
|
||||||
|
re := regexp.MustCompile(`(?m)^\s*\[` + regexp.QuoteMeta(key) + `\]\s*=>\s*(.+?)\s*$`)
|
||||||
|
if m := re.FindStringSubmatch(content); len(m) == 2 {
|
||||||
|
return strings.TrimSpace(m[1])
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func isUsableHostIdentifier(v string) bool {
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
if v == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
l := strings.ToLower(v)
|
||||||
|
if l == "n/a" || l == "unknown" || l == "none" || l == "not available" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Unraid may redact GUID values as 1... or 1..7 in diagnostics.
|
||||||
|
if strings.Contains(v, "...") || strings.Contains(v, "..") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func extractDiskSection(content, diskName string) string {
|
func extractDiskSection(content, diskName string) string {
|
||||||
// Find the start of this disk's array section
|
// Find the start of this disk's array section
|
||||||
startPattern := regexp.MustCompile(`(?m)^\s+\[` + regexp.QuoteMeta(diskName) + `\]\s+=>\s+Array\s*\n\s+\(`)
|
startPattern := regexp.MustCompile(`(?m)^\s+\[` + regexp.QuoteMeta(diskName) + `\]\s+=>\s+Array\s*\n\s+\(`)
|
||||||
|
|||||||
116
internal/parser/vendors/unraid/parser_test.go
vendored
116
internal/parser/vendors/unraid/parser_test.go
vendored
@@ -275,3 +275,119 @@ func TestParser_Metadata(t *testing.T) {
|
|||||||
t.Error("Version() should not be empty")
|
t.Error("Version() should not be empty")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParse_MemoryDIMMsFromMeminfo(t *testing.T) {
|
||||||
|
memInfo := `MemTotal: 53393436 kB
|
||||||
|
|
||||||
|
Handle 0x002D, DMI type 17, 34 bytes
|
||||||
|
Memory Device
|
||||||
|
Size: 16 GB
|
||||||
|
Locator: Node0_Dimm1
|
||||||
|
Bank Locator: Node0_Bank0
|
||||||
|
Type: DDR3
|
||||||
|
Speed: 1333 MT/s
|
||||||
|
Manufacturer: Samsung
|
||||||
|
Serial Number: 238F7649
|
||||||
|
Part Number: M393B2G70BH0-
|
||||||
|
Rank: 4
|
||||||
|
Configured Memory Speed: 1333 MT/s
|
||||||
|
|
||||||
|
Handle 0x002F, DMI type 17, 34 bytes
|
||||||
|
Memory Device
|
||||||
|
Size: No Module Installed
|
||||||
|
Locator: Node0_Dimm2
|
||||||
|
`
|
||||||
|
|
||||||
|
files := []parser.ExtractedFile{
|
||||||
|
{Path: "diagnostics/system/memory.txt", Content: []byte("Mem: 50Gi")},
|
||||||
|
{Path: "diagnostics/system/meminfo.txt", Content: []byte(memInfo)},
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &Parser{}
|
||||||
|
result, err := p.Parse(files)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := len(result.Hardware.Memory); got != 1 {
|
||||||
|
t.Fatalf("expected only installed DIMM entries, got %d entries", got)
|
||||||
|
}
|
||||||
|
dimm := result.Hardware.Memory[0]
|
||||||
|
if dimm.Slot != "Node0_Dimm1" {
|
||||||
|
t.Errorf("Slot = %q, want Node0_Dimm1", dimm.Slot)
|
||||||
|
}
|
||||||
|
if dimm.SizeMB != 16*1024 {
|
||||||
|
t.Errorf("SizeMB = %d, want %d", dimm.SizeMB, 16*1024)
|
||||||
|
}
|
||||||
|
if dimm.Type != "DDR3" {
|
||||||
|
t.Errorf("Type = %q, want DDR3", dimm.Type)
|
||||||
|
}
|
||||||
|
if dimm.SerialNumber != "238F7649" {
|
||||||
|
t.Errorf("SerialNumber = %q, want 238F7649", dimm.SerialNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParse_NetworkAndPCIeFromLSPCIAndEthtool(t *testing.T) {
|
||||||
|
lspci := `03:00.0 SCSI storage controller [0100]: Broadcom / LSI SAS2008 PCI-Express Fusion-MPT SAS-2 [Falcon] [1000:0072] (rev 03)
|
||||||
|
07:00.0 Ethernet controller [0200]: Realtek Semiconductor Co., Ltd. RTL8111/8168/8211/8411 PCI Express Gigabit Ethernet Controller [10ec:8168] (rev 06)
|
||||||
|
`
|
||||||
|
ethtool := `Settings for eth0:
|
||||||
|
Speed: 1000Mb/s
|
||||||
|
Link detected: yes
|
||||||
|
driver: r8168
|
||||||
|
firmware-version:
|
||||||
|
bus-info: 0000:07:00.0
|
||||||
|
--------------------------------
|
||||||
|
`
|
||||||
|
files := []parser.ExtractedFile{
|
||||||
|
{Path: "diagnostics/system/lspci.txt", Content: []byte(lspci)},
|
||||||
|
{Path: "diagnostics/system/ethtool.txt", Content: []byte(ethtool)},
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &Parser{}
|
||||||
|
result, err := p.Parse(files)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Hardware.NetworkAdapters) != 1 {
|
||||||
|
t.Fatalf("expected 1 network adapter, got %d", len(result.Hardware.NetworkAdapters))
|
||||||
|
}
|
||||||
|
nic := result.Hardware.NetworkAdapters[0]
|
||||||
|
if nic.Location != "0000:07:00.0" {
|
||||||
|
t.Errorf("Location = %q, want 0000:07:00.0", nic.Location)
|
||||||
|
}
|
||||||
|
if nic.Model == "" {
|
||||||
|
t.Error("Model should not be empty")
|
||||||
|
}
|
||||||
|
if nic.Vendor == "" {
|
||||||
|
t.Error("Vendor should not be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Hardware.PCIeDevices) < 2 {
|
||||||
|
t.Fatalf("expected at least 2 PCIe devices, got %d", len(result.Hardware.PCIeDevices))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParse_HostSerialFallbackFromVarsUUID(t *testing.T) {
|
||||||
|
vars := ` [flashGUID] => 1...
|
||||||
|
[regGUID] => 1...7
|
||||||
|
[uuid] => 2713440667722491190
|
||||||
|
`
|
||||||
|
files := []parser.ExtractedFile{
|
||||||
|
{Path: "diagnostics/system/vars.txt", Content: []byte(vars)},
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &Parser{}
|
||||||
|
result, err := p.Parse(files)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Hardware.BoardInfo.SerialNumber != "2713440667722491190" {
|
||||||
|
t.Fatalf("BoardInfo.SerialNumber = %q, want vars uuid", result.Hardware.BoardInfo.SerialNumber)
|
||||||
|
}
|
||||||
|
if result.Hardware.BoardInfo.UUID != "2713440667722491190" {
|
||||||
|
t.Fatalf("BoardInfo.UUID = %q, want vars uuid", result.Hardware.BoardInfo.UUID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -300,7 +300,7 @@ func BuildHardwareDevices(hw *models.HardwareConfig) []models.HardwareDevice {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return dedupeDevices(all)
|
return annotateDuplicateSerials(dedupeDevices(all))
|
||||||
}
|
}
|
||||||
|
|
||||||
func isEmptyPCIeDevice(p models.PCIeDevice) bool {
|
func isEmptyPCIeDevice(p models.PCIeDevice) bool {
|
||||||
@@ -443,9 +443,22 @@ func shouldMergeDevices(a, b models.HardwareDevice) bool {
|
|||||||
bSN := strings.ToLower(normalizedSerial(b.SerialNumber))
|
bSN := strings.ToLower(normalizedSerial(b.SerialNumber))
|
||||||
aBDF := strings.ToLower(strings.TrimSpace(a.BDF))
|
aBDF := strings.ToLower(strings.TrimSpace(a.BDF))
|
||||||
bBDF := strings.ToLower(strings.TrimSpace(b.BDF))
|
bBDF := strings.ToLower(strings.TrimSpace(b.BDF))
|
||||||
|
aSlot := normalizeSlot(a.Slot)
|
||||||
|
bSlot := normalizeSlot(b.Slot)
|
||||||
|
|
||||||
|
// Memory DIMMs can legitimately share serial number in some dumps.
|
||||||
|
// Never merge DIMMs with different slots.
|
||||||
|
if a.Kind == models.DeviceKindMemory && b.Kind == models.DeviceKindMemory {
|
||||||
|
if aSlot != "" && bSlot != "" && aSlot != bSlot {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Hard conflicts.
|
// Hard conflicts.
|
||||||
if aSN != "" && bSN != "" && aSN == bSN {
|
if aSN != "" && bSN != "" && aSN == bSN {
|
||||||
|
if a.Kind == models.DeviceKindMemory && b.Kind == models.DeviceKindMemory {
|
||||||
|
return aSlot != "" && bSlot != "" && aSlot == bSlot
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if aSN != "" && bSN != "" && aSN != bSN {
|
if aSN != "" && bSN != "" && aSN != bSN {
|
||||||
@@ -465,7 +478,7 @@ func shouldMergeDevices(a, b models.HardwareDevice) bool {
|
|||||||
if hasMACOverlap(a.MACAddresses, b.MACAddresses) {
|
if hasMACOverlap(a.MACAddresses, b.MACAddresses) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if normalizeSlot(a.Slot) != "" && normalizeSlot(a.Slot) == normalizeSlot(b.Slot) {
|
if aSlot != "" && aSlot == bSlot {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@@ -481,7 +494,7 @@ func shouldMergeDevices(a, b models.HardwareDevice) bool {
|
|||||||
if sameManufacturer(a, b) {
|
if sameManufacturer(a, b) {
|
||||||
score += 2
|
score += 2
|
||||||
}
|
}
|
||||||
if normalizeSlot(a.Slot) != "" && normalizeSlot(a.Slot) == normalizeSlot(b.Slot) {
|
if aSlot != "" && aSlot == bSlot {
|
||||||
score += 2
|
score += 2
|
||||||
}
|
}
|
||||||
if hasMACOverlap(a.MACAddresses, b.MACAddresses) {
|
if hasMACOverlap(a.MACAddresses, b.MACAddresses) {
|
||||||
@@ -732,3 +745,35 @@ func buildFirmwareBySlot(firmware []models.FirmwareInfo) map[string]slotFirmware
|
|||||||
func normalizeSlotKey(slot string) string {
|
func normalizeSlotKey(slot string) string {
|
||||||
return strings.ToLower(strings.TrimSpace(slot))
|
return strings.ToLower(strings.TrimSpace(slot))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func annotateDuplicateSerials(items []models.HardwareDevice) []models.HardwareDevice {
|
||||||
|
if len(items) < 2 {
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
countByKindSerial := make(map[string]int)
|
||||||
|
for _, d := range items {
|
||||||
|
serial := normalizedSerial(d.SerialNumber)
|
||||||
|
if serial == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := d.Kind + "|" + strings.ToLower(serial)
|
||||||
|
countByKindSerial[key]++
|
||||||
|
}
|
||||||
|
|
||||||
|
seenByKindSerial := make(map[string]int)
|
||||||
|
for i := range items {
|
||||||
|
serial := normalizedSerial(items[i].SerialNumber)
|
||||||
|
if serial == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := items[i].Kind + "|" + strings.ToLower(serial)
|
||||||
|
if countByKindSerial[key] < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenByKindSerial[key]++
|
||||||
|
items[i].SerialNumber = serial + " (DUP#" + strconv.Itoa(seenByKindSerial[key]) + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|||||||
@@ -65,6 +65,54 @@ func TestBuildHardwareDevices_SkipsEmptyMemorySlots(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildHardwareDevices_MemorySameSerialDifferentSlots_NotDeduped(t *testing.T) {
|
||||||
|
hw := &models.HardwareConfig{
|
||||||
|
Memory: []models.MemoryDIMM{
|
||||||
|
{Slot: "Node0_Dimm1", Location: "Node0_Bank0", Present: true, SizeMB: 16384, SerialNumber: "238F7649", PartNumber: "M393B2G70BH0-"},
|
||||||
|
{Slot: "Node0_Dimm3", Location: "Node0_Bank0", Present: true, SizeMB: 16384, SerialNumber: "238F7649", PartNumber: "M393B2G70BH0-"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
devices := BuildHardwareDevices(hw)
|
||||||
|
memorySlots := make(map[string]bool)
|
||||||
|
for _, d := range devices {
|
||||||
|
if d.Kind != models.DeviceKindMemory {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
memorySlots[d.Slot] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(memorySlots) != 2 {
|
||||||
|
t.Fatalf("expected 2 memory devices, got %d", len(memorySlots))
|
||||||
|
}
|
||||||
|
if !memorySlots["Node0_Dimm1"] || !memorySlots["Node0_Dimm3"] {
|
||||||
|
t.Fatalf("expected both Node0_Dimm1 and Node0_Dimm3 to remain")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildHardwareDevices_DuplicateSerials_AreAnnotated(t *testing.T) {
|
||||||
|
hw := &models.HardwareConfig{
|
||||||
|
Memory: []models.MemoryDIMM{
|
||||||
|
{Slot: "A1", Location: "BANK0", Present: true, SizeMB: 16384, SerialNumber: "SN-1"},
|
||||||
|
{Slot: "A2", Location: "BANK1", Present: true, SizeMB: 16384, SerialNumber: "SN-1"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
devices := BuildHardwareDevices(hw)
|
||||||
|
var serials []string
|
||||||
|
for _, d := range devices {
|
||||||
|
if d.Kind == models.DeviceKindMemory {
|
||||||
|
serials = append(serials, d.SerialNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(serials) != 2 {
|
||||||
|
t.Fatalf("expected 2 memory devices, got %d", len(serials))
|
||||||
|
}
|
||||||
|
if serials[0] != "SN-1 (DUP#1)" || serials[1] != "SN-1 (DUP#2)" {
|
||||||
|
t.Fatalf("unexpected annotated serials: %+v", serials)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildHardwareDevices_DedupCrossKindByBDF(t *testing.T) {
|
func TestBuildHardwareDevices_DedupCrossKindByBDF(t *testing.T) {
|
||||||
hw := &models.HardwareConfig{
|
hw := &models.HardwareConfig{
|
||||||
PCIeDevices: []models.PCIeDevice{
|
PCIeDevices: []models.PCIeDevice{
|
||||||
|
|||||||
Reference in New Issue
Block a user