feat(audit): 1.3 — CPU collector (dmidecode type 4, microcode)
- 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>
This commit is contained in:
@@ -21,7 +21,11 @@ func Run() schema.HardwareIngestRequest {
|
||||
snap.Board = board
|
||||
snap.Firmware = append(snap.Firmware, biosFW...)
|
||||
|
||||
// remaining collectors added in steps 1.3 – 1.10
|
||||
cpus, cpuFW := collectCPUs(snap.Board.SerialNumber)
|
||||
snap.CPUs = cpus
|
||||
snap.Firmware = append(snap.Firmware, cpuFW...)
|
||||
|
||||
// remaining collectors added in steps 1.4 – 1.10
|
||||
|
||||
slog.Info("audit completed", "duration", time.Since(start).Round(time.Millisecond))
|
||||
|
||||
|
||||
239
audit/internal/collector/cpu.go
Normal file
239
audit/internal/collector/cpu.go
Normal file
@@ -0,0 +1,239 @@
|
||||
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
|
||||
}
|
||||
143
audit/internal/collector/cpu_test.go
Normal file
143
audit/internal/collector/cpu_test.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package collector
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseCPUs_dual_socket(t *testing.T) {
|
||||
out := mustReadFile(t, "testdata/dmidecode_type4.txt")
|
||||
cpus := parseCPUs(out, "CAR315KA0803B90")
|
||||
|
||||
if len(cpus) != 2 {
|
||||
t.Fatalf("expected 2 CPUs, got %d", len(cpus))
|
||||
}
|
||||
|
||||
cpu0 := cpus[0]
|
||||
if cpu0.Socket == nil || *cpu0.Socket != 0 {
|
||||
t.Errorf("cpu0 socket: got %v, want 0", cpu0.Socket)
|
||||
}
|
||||
if cpu0.Model == nil || *cpu0.Model != "Intel(R) Xeon(R) Gold 6530" {
|
||||
t.Errorf("cpu0 model: got %v", cpu0.Model)
|
||||
}
|
||||
if cpu0.Manufacturer == nil || *cpu0.Manufacturer != "Intel" {
|
||||
t.Errorf("cpu0 manufacturer: got %v, want Intel", cpu0.Manufacturer)
|
||||
}
|
||||
if cpu0.Cores == nil || *cpu0.Cores != 32 {
|
||||
t.Errorf("cpu0 cores: got %v, want 32", cpu0.Cores)
|
||||
}
|
||||
if cpu0.Threads == nil || *cpu0.Threads != 64 {
|
||||
t.Errorf("cpu0 threads: got %v, want 64", cpu0.Threads)
|
||||
}
|
||||
if cpu0.MaxFrequencyMHz == nil || *cpu0.MaxFrequencyMHz != 4000 {
|
||||
t.Errorf("cpu0 max_frequency_mhz: got %v, want 4000", cpu0.MaxFrequencyMHz)
|
||||
}
|
||||
if cpu0.FrequencyMHz == nil || *cpu0.FrequencyMHz != 2100 {
|
||||
t.Errorf("cpu0 frequency_mhz: got %v, want 2100", cpu0.FrequencyMHz)
|
||||
}
|
||||
if cpu0.Status == nil || *cpu0.Status != "OK" {
|
||||
t.Errorf("cpu0 status: got %v, want OK", cpu0.Status)
|
||||
}
|
||||
// Intel Xeon serial not available → fallback
|
||||
if cpu0.SerialNumber == nil || *cpu0.SerialNumber != "CAR315KA0803B90-CPU-0" {
|
||||
t.Errorf("cpu0 serial fallback: got %v, want CAR315KA0803B90-CPU-0", cpu0.SerialNumber)
|
||||
}
|
||||
|
||||
cpu1 := cpus[1]
|
||||
if cpu1.Socket == nil || *cpu1.Socket != 1 {
|
||||
t.Errorf("cpu1 socket: got %v, want 1", cpu1.Socket)
|
||||
}
|
||||
if cpu1.SerialNumber == nil || *cpu1.SerialNumber != "CAR315KA0803B90-CPU-1" {
|
||||
t.Errorf("cpu1 serial fallback: got %v, want CAR315KA0803B90-CPU-1", cpu1.SerialNumber)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCPUs_unpopulated_skipped(t *testing.T) {
|
||||
out := mustReadFile(t, "testdata/dmidecode_type4_disabled.txt")
|
||||
cpus := parseCPUs(out, "BOARD-001")
|
||||
|
||||
if len(cpus) != 1 {
|
||||
t.Fatalf("expected 1 CPU (unpopulated skipped), got %d", len(cpus))
|
||||
}
|
||||
if cpus[0].Socket == nil || *cpus[0].Socket != 0 {
|
||||
t.Errorf("expected socket 0, got %v", cpus[0].Socket)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCPUStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"Populated, Enabled", "OK"},
|
||||
{"Populated, Disabled By User", "WARNING"},
|
||||
{"Populated, Disabled By BIOS", "WARNING"},
|
||||
{"Unpopulated", "EMPTY"},
|
||||
{"Not Populated", "EMPTY"},
|
||||
{"Unknown", "UNKNOWN"},
|
||||
{"", "UNKNOWN"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := parseCPUStatus(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("parseCPUStatus(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSocketIndex(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want int
|
||||
ok bool
|
||||
}{
|
||||
{"CPU0", 0, true},
|
||||
{"CPU1", 1, true},
|
||||
{"Processor 1", 1, true},
|
||||
{"Socket 2", 2, true},
|
||||
{"", 0, false},
|
||||
{"No digits here", 0, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got, ok := parseSocketIndex(tt.input)
|
||||
if ok != tt.ok || got != tt.want {
|
||||
t.Errorf("parseSocketIndex(%q) = (%d, %v), want (%d, %v)", tt.input, got, ok, tt.want, tt.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanManufacturer(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"Intel(R) Corporation", "Intel"},
|
||||
{"AMD", "AMD"},
|
||||
{"To Be Filled By O.E.M.", ""},
|
||||
{" Intel(R) Corporation ", "Intel"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := cleanManufacturer(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("cleanManufacturer(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMHz(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want int
|
||||
}{
|
||||
{"4000 MHz", 4000},
|
||||
{"2100 MHz", 2100},
|
||||
{"Unknown", 0},
|
||||
{"", 0},
|
||||
{"N/A", 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := parseMHz(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("parseMHz(%q) = %d, want %d", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
66
audit/internal/collector/testdata/dmidecode_type4.txt
vendored
Normal file
66
audit/internal/collector/testdata/dmidecode_type4.txt
vendored
Normal file
@@ -0,0 +1,66 @@
|
||||
# dmidecode 3.5
|
||||
Getting SMBIOS data from sysfs.
|
||||
SMBIOS 3.1.1 present.
|
||||
|
||||
Handle 0x0004, DMI type 4, 48 bytes
|
||||
Processor Information
|
||||
Socket Designation: CPU0
|
||||
Type: Central Processor
|
||||
Family: Xeon
|
||||
Manufacturer: Intel(R) Corporation
|
||||
ID: F7 06 08 00 FF FB EB BF
|
||||
Signature: Type 0, Family 6, Model 143, Stepping 7
|
||||
Flags:
|
||||
FPU (Floating-point unit on-chip)
|
||||
VME (Virtual mode extension)
|
||||
DE (Debugging extension)
|
||||
Version: Intel(R) Xeon(R) Gold 6530
|
||||
Voltage: 1.6 V
|
||||
External Clock: 100 MHz
|
||||
Max Speed: 4000 MHz
|
||||
Current Speed: 2100 MHz
|
||||
Status: Populated, Enabled
|
||||
Upgrade: Other
|
||||
L1 Cache Handle: 0x0006
|
||||
L2 Cache Handle: 0x0007
|
||||
L3 Cache Handle: 0x0008
|
||||
Serial Number: Not Specified
|
||||
Asset Tag: Not Specified
|
||||
Part Number: Not Specified
|
||||
Core Count: 32
|
||||
Core Enabled: 32
|
||||
Thread Count: 64
|
||||
Characteristics:
|
||||
64-bit capable
|
||||
Multi-Core
|
||||
Hardware Thread
|
||||
|
||||
Handle 0x0005, DMI type 4, 48 bytes
|
||||
Processor Information
|
||||
Socket Designation: CPU1
|
||||
Type: Central Processor
|
||||
Family: Xeon
|
||||
Manufacturer: Intel(R) Corporation
|
||||
ID: F7 06 08 00 FF FB EB BF
|
||||
Signature: Type 0, Family 6, Model 143, Stepping 7
|
||||
Flags:
|
||||
FPU (Floating-point unit on-chip)
|
||||
Version: Intel(R) Xeon(R) Gold 6530
|
||||
Voltage: 1.6 V
|
||||
External Clock: 100 MHz
|
||||
Max Speed: 4000 MHz
|
||||
Current Speed: 2100 MHz
|
||||
Status: Populated, Enabled
|
||||
Upgrade: Other
|
||||
L1 Cache Handle: 0x000A
|
||||
L2 Cache Handle: 0x000B
|
||||
L3 Cache Handle: 0x000C
|
||||
Serial Number: Not Specified
|
||||
Asset Tag: Not Specified
|
||||
Part Number: Not Specified
|
||||
Core Count: 32
|
||||
Core Enabled: 32
|
||||
Thread Count: 64
|
||||
Characteristics:
|
||||
64-bit capable
|
||||
Multi-Core
|
||||
31
audit/internal/collector/testdata/dmidecode_type4_disabled.txt
vendored
Normal file
31
audit/internal/collector/testdata/dmidecode_type4_disabled.txt
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
# dmidecode 3.5
|
||||
Getting SMBIOS data from sysfs.
|
||||
SMBIOS 3.1.1 present.
|
||||
|
||||
Handle 0x0004, DMI type 4, 48 bytes
|
||||
Processor Information
|
||||
Socket Designation: CPU0
|
||||
Type: Central Processor
|
||||
Family: Xeon
|
||||
Manufacturer: Intel(R) Corporation
|
||||
Version: Intel(R) Xeon(R) Gold 6530
|
||||
Max Speed: 4000 MHz
|
||||
Current Speed: 2100 MHz
|
||||
Status: Populated, Enabled
|
||||
Serial Number: Not Specified
|
||||
Core Count: 32
|
||||
Thread Count: 64
|
||||
|
||||
Handle 0x0005, DMI type 4, 48 bytes
|
||||
Processor Information
|
||||
Socket Designation: CPU1
|
||||
Type: Central Processor
|
||||
Family: Unknown
|
||||
Manufacturer: Unknown
|
||||
Version: Unknown
|
||||
Max Speed: Unknown
|
||||
Current Speed: Unknown
|
||||
Status: Unpopulated
|
||||
Serial Number: Not Specified
|
||||
Core Count: N/A
|
||||
Thread Count: N/A
|
||||
Reference in New Issue
Block a user