From e79f972fb591b431ff616b1a8d984ad432154a06 Mon Sep 17 00:00:00 2001 From: Michael Chus Date: Thu, 5 Mar 2026 14:54:12 +0300 Subject: [PATCH] add: PSU collector (1.7) via ipmitool fru, skips gracefully without IPMI --- audit/internal/collector/collector.go | 3 +- audit/internal/collector/psu.go | 129 ++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 audit/internal/collector/psu.go diff --git a/audit/internal/collector/collector.go b/audit/internal/collector/collector.go index 58e47fb..ad3f5a9 100644 --- a/audit/internal/collector/collector.go +++ b/audit/internal/collector/collector.go @@ -28,8 +28,9 @@ func Run() schema.HardwareIngestRequest { snap.Memory = collectMemory() snap.Storage = collectStorage() snap.PCIeDevices = collectPCIe() + snap.PowerSupplies = collectPSUs() - // remaining collectors added in steps 1.7 – 1.10 + // remaining collectors added in steps 1.8 – 1.10 slog.Info("audit completed", "duration", time.Since(start).Round(time.Millisecond)) diff --git a/audit/internal/collector/psu.go b/audit/internal/collector/psu.go new file mode 100644 index 0000000..55196e7 --- /dev/null +++ b/audit/internal/collector/psu.go @@ -0,0 +1,129 @@ +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 +}