- cpu.go: collectCPUs(), parseCPUs(), parseCPUSection() - splitDMISections(): splits multi-section dmidecode output generically - parseFieldLines(): reusable key→value parser for DMI sections - parseCPUStatus(): Populated/Unpopulated → OK/WARNING/EMPTY/UNKNOWN - parseSocketIndex(): CPU0/Processor 1/Socket 2 → integer - cleanManufacturer(): strips (R), Corporation, Inc. suffixes - parseMHz(), parseInt(): field value parsers - Serial fallback: <board_serial>-CPU-<socket> when DMI serial absent - readMicrocode(): /sys/devices/system/cpu/cpu0/microcode/version - cpu_test.go: dual-socket, unpopulated skipped, status, socket, manufacturer, MHz Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
240 lines
6.0 KiB
Go
240 lines
6.0 KiB
Go
package collector
|
|
|
|
import (
|
|
"bee/audit/internal/schema"
|
|
"bufio"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// collectCPUs runs dmidecode -t 4 and reads microcode version from sysfs.
|
|
func collectCPUs(boardSerial string) ([]schema.HardwareCPU, []schema.HardwareFirmwareRecord) {
|
|
out, err := runDmidecode("4")
|
|
if err != nil {
|
|
slog.Warn("cpu: dmidecode type 4 failed", "err", err)
|
|
return nil, nil
|
|
}
|
|
|
|
cpus := parseCPUs(out, boardSerial)
|
|
|
|
var firmware []schema.HardwareFirmwareRecord
|
|
if mc := readMicrocode(); mc != "" {
|
|
firmware = append(firmware, schema.HardwareFirmwareRecord{
|
|
DeviceName: "CPU Microcode",
|
|
Version: mc,
|
|
})
|
|
}
|
|
|
|
slog.Info("cpu: collected", "count", len(cpus))
|
|
return cpus, firmware
|
|
}
|
|
|
|
// parseCPUs splits dmidecode output into per-processor sections and parses each.
|
|
func parseCPUs(output, boardSerial string) []schema.HardwareCPU {
|
|
sections := splitDMISections(output, "Processor Information")
|
|
cpus := make([]schema.HardwareCPU, 0, len(sections))
|
|
|
|
for _, section := range sections {
|
|
cpu, ok := parseCPUSection(section, boardSerial)
|
|
if !ok {
|
|
continue
|
|
}
|
|
cpus = append(cpus, cpu)
|
|
}
|
|
return cpus
|
|
}
|
|
|
|
// parseCPUSection parses one "Processor Information" block into a HardwareCPU.
|
|
// Returns false if the socket is unpopulated.
|
|
func parseCPUSection(fields map[string]string, boardSerial string) (schema.HardwareCPU, bool) {
|
|
status := parseCPUStatus(fields["Status"])
|
|
if status == "EMPTY" {
|
|
return schema.HardwareCPU{}, false
|
|
}
|
|
|
|
cpu := schema.HardwareCPU{}
|
|
cpu.Status = &status
|
|
|
|
if socket, ok := parseSocketIndex(fields["Socket Designation"]); ok {
|
|
cpu.Socket = &socket
|
|
}
|
|
|
|
if v := cleanDMIValue(fields["Version"]); v != "" {
|
|
cpu.Model = &v
|
|
}
|
|
if v := cleanManufacturer(fields["Manufacturer"]); v != "" {
|
|
cpu.Manufacturer = &v
|
|
}
|
|
if v := cleanDMIValue(fields["Serial Number"]); v != "" {
|
|
cpu.SerialNumber = &v
|
|
} else if boardSerial != "" && cpu.Socket != nil {
|
|
// Intel Xeon never exposes serial via DMI — generate stable fallback
|
|
// matching core's generateCPUVendorSerial() logic
|
|
fb := fmt.Sprintf("%s-CPU-%d", boardSerial, *cpu.Socket)
|
|
cpu.SerialNumber = &fb
|
|
}
|
|
|
|
if v := parseMHz(fields["Max Speed"]); v > 0 {
|
|
cpu.MaxFrequencyMHz = &v
|
|
}
|
|
if v := parseMHz(fields["Current Speed"]); v > 0 {
|
|
cpu.FrequencyMHz = &v
|
|
}
|
|
if v := parseInt(fields["Core Count"]); v > 0 {
|
|
cpu.Cores = &v
|
|
}
|
|
if v := parseInt(fields["Thread Count"]); v > 0 {
|
|
cpu.Threads = &v
|
|
}
|
|
|
|
return cpu, true
|
|
}
|
|
|
|
// parseCPUStatus maps dmidecode Status field to our status vocabulary.
|
|
func parseCPUStatus(raw string) string {
|
|
raw = strings.TrimSpace(raw)
|
|
upper := strings.ToUpper(raw)
|
|
switch {
|
|
case upper == "" || upper == "UNKNOWN":
|
|
return "UNKNOWN"
|
|
case strings.Contains(upper, "UNPOPULATED") || strings.Contains(upper, "NOT POPULATED"):
|
|
return "EMPTY"
|
|
case strings.Contains(upper, "ENABLED"):
|
|
return "OK"
|
|
case strings.Contains(upper, "DISABLED"):
|
|
return "WARNING"
|
|
default:
|
|
return "UNKNOWN"
|
|
}
|
|
}
|
|
|
|
// parseSocketIndex extracts the integer socket index from strings like
|
|
// "CPU0", "CPU1", "Processor 1", "Socket 0", etc.
|
|
func parseSocketIndex(raw string) (int, bool) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return 0, false
|
|
}
|
|
// strip leading non-digit prefix and parse the first integer found
|
|
digits := ""
|
|
for _, r := range raw {
|
|
if r >= '0' && r <= '9' {
|
|
digits += string(r)
|
|
} else if digits != "" {
|
|
break
|
|
}
|
|
}
|
|
if digits == "" {
|
|
return 0, false
|
|
}
|
|
n, err := strconv.Atoi(digits)
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
return n, true
|
|
}
|
|
|
|
// cleanManufacturer normalises CPU manufacturer strings.
|
|
// "Intel(R) Corporation" → "Intel", "AMD" → "AMD".
|
|
func cleanManufacturer(v string) string {
|
|
v = cleanDMIValue(v)
|
|
if v == "" {
|
|
return ""
|
|
}
|
|
// strip "(R)" and "(TM)" suffixes, trim "Corporation" / "Inc."
|
|
v = strings.ReplaceAll(v, "(R)", "")
|
|
v = strings.ReplaceAll(v, "(TM)", "")
|
|
v = strings.ReplaceAll(v, " Corporation", "")
|
|
v = strings.ReplaceAll(v, " Inc.", "")
|
|
v = strings.ReplaceAll(v, " Inc", "")
|
|
return strings.TrimSpace(v)
|
|
}
|
|
|
|
// parseMHz parses "4000 MHz" → 4000. Returns 0 on failure.
|
|
func parseMHz(v string) int {
|
|
v = strings.TrimSpace(v)
|
|
v = strings.TrimSuffix(v, " MHz")
|
|
v = strings.TrimSpace(v)
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return n
|
|
}
|
|
|
|
// parseInt parses a plain integer string. Returns 0 on failure or "N/A".
|
|
func parseInt(v string) int {
|
|
v = strings.TrimSpace(v)
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return n
|
|
}
|
|
|
|
// readMicrocode reads the CPU microcode revision from sysfs.
|
|
// Returns empty string if unavailable.
|
|
func readMicrocode() string {
|
|
data, err := os.ReadFile("/sys/devices/system/cpu/cpu0/microcode/version")
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(string(data))
|
|
}
|
|
|
|
// splitDMISections splits dmidecode output into sections by a given section title.
|
|
// Returns a slice of field maps, one per matching section.
|
|
func splitDMISections(output, sectionTitle string) []map[string]string {
|
|
var sections []map[string]string
|
|
var current []string
|
|
inSection := false
|
|
|
|
scanner := bufio.NewScanner(strings.NewReader(output))
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
|
|
if strings.TrimSpace(line) == sectionTitle {
|
|
if inSection && len(current) > 0 {
|
|
sections = append(sections, parseFieldLines(current))
|
|
}
|
|
current = nil
|
|
inSection = true
|
|
continue
|
|
}
|
|
|
|
if inSection {
|
|
if strings.HasPrefix(line, "Handle ") {
|
|
sections = append(sections, parseFieldLines(current))
|
|
current = nil
|
|
inSection = false
|
|
continue
|
|
}
|
|
current = append(current, line)
|
|
}
|
|
}
|
|
if inSection && len(current) > 0 {
|
|
sections = append(sections, parseFieldLines(current))
|
|
}
|
|
|
|
return sections
|
|
}
|
|
|
|
// parseFieldLines converts raw section lines into a key→value map.
|
|
// Skips sub-list items (double-tab indented lines).
|
|
func parseFieldLines(lines []string) map[string]string {
|
|
fields := make(map[string]string)
|
|
for _, line := range lines {
|
|
if strings.HasPrefix(line, "\t\t") {
|
|
continue
|
|
}
|
|
trimmed := strings.TrimPrefix(line, "\t")
|
|
if idx := strings.Index(trimmed, ": "); idx >= 0 {
|
|
fields[trimmed[:idx]] = trimmed[idx+2:]
|
|
}
|
|
}
|
|
return fields
|
|
}
|