package collector import ( "bee/audit/internal/schema" "log/slog" "os/exec" "strconv" "strings" ) func collectPSUs() []schema.HardwarePowerSupply { // ipmitool requires /dev/ipmi0 — not available on non-server hardware out, err := exec.Command("ipmitool", "fru", "print").Output() if err != nil { slog.Info("psu: ipmitool unavailable, skipping", "err", err) return nil } psus := parseFRU(string(out)) slog.Info("psu: collected", "count", len(psus)) return psus } // parseFRU parses ipmitool fru print output. // Each FRU record starts with "FRU Device Description : (ID )" // followed by indented key: value lines. func parseFRU(output string) []schema.HardwarePowerSupply { var psus []schema.HardwarePowerSupply slot := 0 for _, block := range splitFRUBlocks(output) { psu, ok := parseFRUBlock(block, slot) if !ok { continue } psus = append(psus, psu) slot++ } return psus } func splitFRUBlocks(output string) []string { var blocks []string var cur strings.Builder for _, line := range strings.Split(output, "\n") { if strings.HasPrefix(line, "FRU Device Description") { if cur.Len() > 0 { blocks = append(blocks, cur.String()) cur.Reset() } } cur.WriteString(line) cur.WriteByte('\n') } if cur.Len() > 0 { blocks = append(blocks, cur.String()) } return blocks } func parseFRUBlock(block string, slotIdx int) (schema.HardwarePowerSupply, bool) { fields := map[string]string{} header := "" for _, line := range strings.Split(block, "\n") { if strings.HasPrefix(line, "FRU Device Description") { header = line continue } idx := strings.Index(line, " : ") if idx < 0 { continue } key := strings.TrimSpace(line[:idx]) val := strings.TrimSpace(line[idx+3:]) fields[key] = val } // Only process PSU FRU records headerLower := strings.ToLower(header) if !strings.Contains(headerLower, "psu") && !strings.Contains(headerLower, "power supply") && !strings.Contains(headerLower, "power_supply") { return schema.HardwarePowerSupply{}, false } present := true psu := schema.HardwarePowerSupply{Present: &present} slotStr := strconv.Itoa(slotIdx) psu.Slot = &slotStr if v := cleanDMIValue(fields["Board Product"]); v != "" { psu.Model = &v } if v := cleanDMIValue(fields["Board Mfg"]); v != "" { psu.Vendor = &v } if v := cleanDMIValue(fields["Board Serial"]); v != "" { psu.SerialNumber = &v } if v := cleanDMIValue(fields["Board Part Number"]); v != "" { psu.PartNumber = &v } if v := cleanDMIValue(fields["Board Extra"]); v != "" { psu.Firmware = &v } // wattage: some vendors put it in product name e.g. "PSU 800W" if psu.Model != nil { if w := parseWattage(*psu.Model); w > 0 { psu.WattageW = &w } } status := "OK" psu.Status = &status return psu, true } // parseWattage extracts wattage from strings like "PSU 800W", "1200W PLATINUM". func parseWattage(s string) int { s = strings.ToUpper(s) for _, part := range strings.Fields(s) { part = strings.TrimSuffix(part, "W") if n, err := strconv.Atoi(part); err == nil && n > 0 && n <= 5000 { return n } } return 0 }