diff --git a/agent/probes/firmware.go b/agent/probes/firmware.go index 7ccf52a..eb3920b 100644 --- a/agent/probes/firmware.go +++ b/agent/probes/firmware.go @@ -172,6 +172,53 @@ func parseDmidecodeSection(r io.Reader, title string) map[string]string { return nil } +// parseDmidecodeAllSections is the plural variant of +// parseDmidecodeSection: returns every block whose title matches, not +// just the first. Memory (-t 17) and PSU (-t 39) emit one block per +// slot, so the inventory probes need the full list. Same scanning +// rules; accumulates kv maps on Handle/blank-line boundaries. +func parseDmidecodeAllSections(r io.Reader, title string) []map[string]string { + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) + var out []map[string]string + var kv map[string]string + var inside bool + flush := func() { + if kv != nil { + out = append(out, kv) + } + kv = nil + inside = false + } + for sc.Scan() { + line := sc.Text() + trim := strings.TrimSpace(line) + if strings.HasPrefix(line, "Handle ") { + flush() + continue + } + if !inside { + if trim == title { + inside = true + kv = map[string]string{} + } + continue + } + if trim == "" { + continue + } + if k, v, ok := strings.Cut(trim, ":"); ok { + v = strings.TrimSpace(v) + if v == "" { + continue + } + kv[strings.TrimSpace(k)] = v + } + } + flush() + return out +} + // ----- BMC / IPMI -------------------------------------------------------- // probeBMC walks `ipmitool mc info`. Home-lab hosts often lack a BMC — diff --git a/agent/probes/firmware_test.go b/agent/probes/firmware_test.go index 44ed938..424a7cf 100644 --- a/agent/probes/firmware_test.go +++ b/agent/probes/firmware_test.go @@ -230,3 +230,45 @@ func TestIsRealNIC(t *testing.T) { } } } + +// dmidecode -t 39 fixture with two PSU blocks. Verifies that +// parseDmidecodeAllSections returns every matching block, not just the +// first (which was the old parseDmidecodeSection behavior). +const dmidecodePSU = `# dmidecode 3.3 + +Handle 0x0040, DMI type 39, 22 bytes +System Power Supply + Location: PSU1 + Manufacturer: DELTA + Model Part Number: ABC760 + Max Power Capacity: 760 W + Status: Present, OK + +Handle 0x0041, DMI type 39, 22 bytes +System Power Supply + Location: PSU2 + Manufacturer: DELTA + Model Part Number: ABC760 + Max Power Capacity: 760 W + Status: Present, Unplugged +` + +func TestParseDmidecodeAllSections(t *testing.T) { + blocks := parseDmidecodeAllSections(strings.NewReader(dmidecodePSU), "System Power Supply") + if len(blocks) != 2 { + t.Fatalf("expected 2 blocks, got %d", len(blocks)) + } + if blocks[0]["Location"] != "PSU1" || blocks[1]["Location"] != "PSU2" { + t.Fatalf("locations wrong: %+v", blocks) + } + if blocks[1]["Status"] != "Present, Unplugged" { + t.Fatalf("psu2 status: %q", blocks[1]["Status"]) + } +} + +func TestParseDmidecodeAllSectionsEmpty(t *testing.T) { + blocks := parseDmidecodeAllSections(strings.NewReader(""), "Memory Device") + if len(blocks) != 0 { + t.Fatalf("expected 0 blocks on empty input, got %d", len(blocks)) + } +} diff --git a/agent/probes/inventory.go b/agent/probes/inventory.go index e54d964..7832c90 100644 --- a/agent/probes/inventory.go +++ b/agent/probes/inventory.go @@ -1,15 +1,16 @@ // Package probes collects hardware facts from a booted Linux system. -// Phase 3 only needs enough to feed the spec diff: CPU model/cores, -// total RAM, per-disk serial+size, per-NIC MAC+speed, per-GPU model. // -// Every probe is tolerant of missing files or tools — if /sys isn't -// available the field is just left empty. The orchestrator's diff -// engine will surface missing expected fields as failures; missing -// fields that weren't expected stay silent. +// Inventory stage consumers call a sequence of probe functions; each +// probe accepts a Logger so it can emit narrative lines (what tool it's +// running, what it found, what it skipped) into the run log. Probes are +// tolerant of missing tools or sysfs gaps — they log a warn line and +// return zero values rather than failing the whole stage. package probes import ( "bufio" + "fmt" + "io" "os" "os/exec" "path/filepath" @@ -21,80 +22,294 @@ import ( "vetting/internal/spec" ) -// Collect runs every probe and returns the merged inventory. The only -// errors it surfaces are fatal ones that prevent progress — individual -// probe failures are logged to the returned Inventory's raw field and -// do not fail the whole call. +// Logger is the subset of the agent's logForwarder that probes need. +// Zero-valued fields are safe to call via log* helpers below — missing +// functions are skipped silently so unit tests and older callers (e.g. +// Collect) don't have to wire anything up. +type Logger struct { + Info func(string) + Warn func(string) +} + +func (l Logger) info(s string) { + if l.Info != nil { + l.Info(s) + } +} + +func (l Logger) warn(s string) { + if l.Warn != nil { + l.Warn(s) + } +} + +// Collect runs every probe with a no-op logger and returns the merged +// inventory. Kept for callers that only want the data without the +// narrative; the agent's runner calls the per-probe functions directly. func Collect() (*spec.Inventory, error) { + return CollectWithLogger(Logger{}), nil +} + +func CollectWithLogger(log Logger) *spec.Inventory { inv := &spec.Inventory{} - - inv.CPU = probeCPU() - inv.Memory = probeMemory() - inv.Disks = probeDisks() - inv.NICs = probeNICs() - inv.GPUs = probeGPUs() - - return inv, nil + inv.CPU = CPU(log) + inv.Memory = Memory(log) + inv.Disks = Disks(log) + inv.NICs = NICs(log) + inv.GPUs = GPUs(log) + inv.System = System(log) + inv.Baseboard = Baseboard(log) + inv.PSU = PSU(log) + inv.OS = OS(log) + return inv } // ----- CPU -------------------------------------------------------------- -func probeCPU() spec.CPUSpec { - // model: first "model name" in /proc/cpuinfo. - // logical_cores: runtime.NumCPU (Linux respects cpu cgroup; agent - // runs on bare metal so it will report every HT thread). +// CPU reads /proc/cpuinfo. We pull model, vendor, stepping, physical +// core count (via "cpu cores" — the per-package count is duplicated on +// every logical CPU line, so any occurrence wins), and a filtered flag +// subset relevant to virtualization / encryption gates. +func CPU(log Logger) spec.CPUSpec { + log.info(" CPU: reading /proc/cpuinfo") c := spec.CPUSpec{LogicalCores: runtime.NumCPU()} f, err := os.Open("/proc/cpuinfo") if err != nil { + log.warn(" CPU: /proc/cpuinfo unreadable: " + err.Error()) return c } defer func() { _ = f.Close() }() - scan := bufio.NewScanner(f) - for scan.Scan() { - line := scan.Text() - if strings.HasPrefix(line, "model name") { - if _, v, ok := strings.Cut(line, ":"); ok { - c.Model = strings.TrimSpace(v) - break + parseCPUInfoInto(f, &c) + flagSummary := "none" + if len(c.Flags) > 0 { + flagSummary = strings.Join(c.Flags, ",") + } + log.info(fmt.Sprintf(" CPU: model=%q vendor=%s cores=%d threads=%d flags=%s", + c.Model, c.Vendor, c.PhysicalCores, c.LogicalCores, flagSummary)) + return c +} + +// cpuFlagAllow is the short-list of /proc/cpuinfo flags we care about — +// everything else is either ubiquitous or too noisy to keep in a +// reportable inventory artifact. +var cpuFlagAllow = map[string]bool{ + "vmx": true, + "svm": true, + "aes": true, + "sse4_2": true, + "avx": true, + "avx2": true, + "avx512f": true, +} + +func parseCPUInfoInto(r io.Reader, c *spec.CPUSpec) { + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for sc.Scan() { + line := sc.Text() + k, v, ok := strings.Cut(line, ":") + if !ok { + continue + } + key := strings.TrimSpace(k) + val := strings.TrimSpace(v) + switch key { + case "model name": + if c.Model == "" { + c.Model = val + } + case "vendor_id": + if c.Vendor == "" { + c.Vendor = val + } + case "stepping": + if c.Stepping == "" { + c.Stepping = val + } + case "cpu cores": + if c.PhysicalCores == 0 { + if n, err := strconv.Atoi(val); err == nil { + c.PhysicalCores = n + } + } + case "flags", "Features": + if len(c.Flags) == 0 { + for _, f := range strings.Fields(val) { + if cpuFlagAllow[f] { + c.Flags = append(c.Flags, f) + } + } } } } - return c } // ----- Memory ----------------------------------------------------------- -func probeMemory() spec.MemorySpec { - // /proc/meminfo reports MemTotal in kB. Round down to the nearest - // GiB so the diff's ±2 GiB tolerance is meaningful. +// Memory captures total GiB from /proc/meminfo and per-DIMM rows from +// dmidecode -t 17. When dmidecode is unavailable or returns nothing the +// total still lands via /proc/meminfo so the spec diff for total_gib +// stays meaningful. +func Memory(log Logger) spec.MemorySpec { + m := spec.MemorySpec{} + if total, ok := readMemTotalGiB(); ok { + m.TotalGiB = total + } + if _, err := exec.LookPath("dmidecode"); err != nil { + log.warn(" Memory: dmidecode not installed; per-DIMM skipped") + log.info(fmt.Sprintf(" Memory: %dGiB total (per-slot unavailable)", m.TotalGiB)) + return m + } + log.info(" Memory: dmidecode -t 17") + out, err := exec.Command("dmidecode", "-t", "17").Output() + if err != nil { + log.warn(" Memory: dmidecode -t 17 failed: " + err.Error()) + log.info(fmt.Sprintf(" Memory: %dGiB total (per-slot unavailable)", m.TotalGiB)) + return m + } + m.Modules = parseDIMMs(strings.NewReader(string(out)), log) + populated := 0 + for _, d := range m.Modules { + if d.Populated { + populated++ + } + } + log.info(fmt.Sprintf(" Memory: %dGiB total, %d of %d slots populated", + m.TotalGiB, populated, len(m.Modules))) + return m +} + +func readMemTotalGiB() (int, bool) { f, err := os.Open("/proc/meminfo") if err != nil { - return spec.MemorySpec{} + return 0, false } defer func() { _ = f.Close() }() - scan := bufio.NewScanner(f) - for scan.Scan() { - fields := strings.Fields(scan.Text()) + sc := bufio.NewScanner(f) + for sc.Scan() { + fields := strings.Fields(sc.Text()) if len(fields) >= 2 && fields[0] == "MemTotal:" { - kb, err := strconv.ParseInt(fields[1], 10, 64) - if err == nil { - return spec.MemorySpec{TotalGiB: int(kb / 1024 / 1024)} + if kb, err := strconv.ParseInt(fields[1], 10, 64); err == nil { + return int(kb / 1024 / 1024), true } } } - return spec.MemorySpec{} + return 0, false +} + +// parseDIMMs reads dmidecode -t 17 output. Each handle is one "Memory +// Device"; an empty slot reports Size: "No Module Installed". Slot +// locator is Locator (or Bank Locator fallback). +func parseDIMMs(r io.Reader, log Logger) []spec.DIMMSpec { + blocks := parseDmidecodeAllSections(r, "Memory Device") + out := make([]spec.DIMMSpec, 0, len(blocks)) + for _, kv := range blocks { + slot := strings.TrimSpace(kv["Locator"]) + if slot == "" { + slot = strings.TrimSpace(kv["Bank Locator"]) + } + d := spec.DIMMSpec{Slot: slot} + size := strings.TrimSpace(kv["Size"]) + if size == "" || strings.EqualFold(size, "No Module Installed") || strings.EqualFold(size, "Not Installed") { + log.info(" Memory: slot=" + nonEmpty(slot, "?") + " empty") + out = append(out, d) + continue + } + d.Populated = true + d.SizeGB = parseDmidecodeSize(size) + if speed := strings.TrimSpace(kv["Configured Memory Speed"]); speed != "" { + d.SpeedMTS = parseMTS(speed) + } + if d.SpeedMTS == 0 { + if speed := strings.TrimSpace(kv["Speed"]); speed != "" { + d.SpeedMTS = parseMTS(speed) + } + } + d.Manufacturer = cleanDmidecodeValue(kv["Manufacturer"]) + d.PartNumber = cleanDmidecodeValue(kv["Part Number"]) + log.info(fmt.Sprintf(" Memory: slot=%s %dGiB %dMT/s %s PN=%s", + nonEmpty(slot, "?"), d.SizeGB, d.SpeedMTS, nonEmpty(d.Manufacturer, "?"), nonEmpty(d.PartNumber, "?"))) + out = append(out, d) + } + return out +} + +// parseDmidecodeSize turns "16 GB" / "8192 MB" / "32 GiB" into GB. +// Treats MB as MiB-ish by dividing by 1024 (dmidecode reports binary +// megabytes even when labeled "MB"). Returns 0 on parse failure. +func parseDmidecodeSize(s string) int { + parts := strings.Fields(s) + if len(parts) < 2 { + return 0 + } + n, err := strconv.Atoi(parts[0]) + if err != nil { + return 0 + } + unit := strings.ToLower(parts[1]) + // dmidecode reports binary megabytes even when the suffix is "MB"; + // accept both "MB" and "MiB" variants. Match on first letter for + // unit, which covers "GB"/"GiB"/"G" equally well. + switch unit[0] { + case 't': + return n * 1024 + case 'g': + return n + case 'm': + return n / 1024 + case 'k': + return n / (1024 * 1024) + } + return 0 +} + +// parseMTS pulls the MT/s integer from "3200 MT/s" or "2400 MHz" — +// dmidecode's "Speed" field uses either unit across vendors. +func parseMTS(s string) int { + parts := strings.Fields(s) + if len(parts) == 0 { + return 0 + } + n, err := strconv.Atoi(parts[0]) + if err != nil { + return 0 + } + return n +} + +func cleanDmidecodeValue(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + // dmidecode emits literal strings like "Not Specified" / "Unknown" + // when the SMBIOS field is blank — suppress them so the inventory + // doesn't parrot SMBIOS nulls as real values. + low := strings.ToLower(s) + if low == "not specified" || low == "unknown" || low == "none" || low == "to be filled by o.e.m." { + return "" + } + return s +} + +func nonEmpty(s, fallback string) string { + if s == "" { + return fallback + } + return s } // ----- Disks ------------------------------------------------------------ -// probeDisks walks /sys/class/block and picks out real block devices -// (no partitions, no loop/ram). For each it reads size (512B sectors) -// and serial. Virtio disks in QEMU report a serial only when launched -// with `-drive serial=...`; without that the field is empty, which is -// fine — the diff skips disks with empty serials anyway. -func probeDisks() []spec.DiskSpec { +// Disks walks /sys/class/block and picks out real block devices (no +// partitions, no loop/ram). For each it reads size (512B sectors), +// serial, model, transport (by inspecting the sysfs symlink target), +// and rotational flag. +func Disks(log Logger) []spec.DiskSpec { + log.info(" Disks: walking /sys/class/block") entries, err := os.ReadDir("/sys/class/block") if err != nil { + log.warn(" Disks: /sys/class/block unreadable: " + err.Error()) return nil } var out []spec.DiskSpec @@ -106,20 +321,26 @@ func probeDisks() []spec.DiskSpec { base := filepath.Join("/sys/class/block", name) size := diskSizeGB(base) serial := diskSerial(name) - // size == 0 means we couldn't read /size; skip rather than - // emit garbage. - if size == 0 && serial == "" { + model := diskModel(name) + transport := diskTransport(name) + rotational := diskRotational(base) + if size == 0 && serial == "" && model == "" { continue } - out = append(out, spec.DiskSpec{Serial: serial, SizeGB: size}) + out = append(out, spec.DiskSpec{ + Serial: serial, + SizeGB: size, + Model: model, + Transport: transport, + Rotational: rotational, + }) + log.info(fmt.Sprintf(" Disks: %s model=%q %dGB %s rotational=%t serial=%s", + name, model, size, nonEmpty(transport, "?"), rotational, nonEmpty(serial, "?"))) } return out } func isRealDisk(name string) bool { - // Exclude partitions: they have a parent block dir and a "partition" - // attribute. sd* disks without trailing digits are whole disks; nvme - // disks use nvme0n1 for the namespace and nvme0n1p1 for partitions. if strings.HasPrefix(name, "loop") || strings.HasPrefix(name, "ram") || strings.HasPrefix(name, "zram") || strings.HasPrefix(name, "dm-") { return false @@ -140,21 +361,10 @@ func diskSizeGB(base string) int { if err != nil { return 0 } - // /sys reports sectors of 512B regardless of physical sector size. return int(sectors * 512 / 1_000_000_000) } func diskSerial(name string) string { - // Try a few known paths; the kernel exposes serials differently for - // ATA/SCSI vs NVMe. - // - // sysfs reads return raw bytes: vpd_pg80 is a binary SCSI VPD page - // with a 4-byte header, and some SSDs put control/NUL bytes at the - // head of /device/serial. TrimSpace won't strip either, so the - // string survives into the spec map as a garbage key that doesn't - // match the reporter's cleaner read from the same file on a - // different kernel. sanitizeASCII drops everything below 0x20 and - // above 0x7E, which leaves a stable printable serial on both sides. for _, rel := range []string{ filepath.Join("/sys/block", name, "device", "serial"), filepath.Join("/sys/block", name, "device", "vpd_pg80"), @@ -167,7 +377,6 @@ func diskSerial(name string) string { } } } - // Fallback: udevadm often knows the wwid / serial. Best-effort. cmd := exec.Command("udevadm", "info", "--query=property", "--name="+name) out, err := cmd.Output() if err != nil { @@ -181,12 +390,64 @@ func diskSerial(name string) string { return "" } +func diskModel(name string) string { + for _, rel := range []string{ + filepath.Join("/sys/block", name, "device", "model"), + filepath.Join("/sys/block", name, "device", "inquiry"), + } { + if b, err := os.ReadFile(rel); err == nil { + s := sanitizeASCII(string(b)) + if s != "" { + return s + } + } + } + return "" +} + +// diskTransport inspects /sys/class/block/'s symlink target. The +// path segment after "/devices/" gives the bus: "pci.../nvme/..." → +// nvme, "pci.../ata.../host..." → sata, "pci.../virtio..." → virtio, +// "pci.../host.../target..." (no "ata") → scsi/sas. +func diskTransport(name string) string { + target, err := os.Readlink(filepath.Join("/sys/class/block", name)) + if err != nil { + return "" + } + t := strings.ToLower(target) + switch { + case strings.Contains(t, "/nvme/"): + return "nvme" + case strings.Contains(t, "/usb"): + return "usb" + case strings.Contains(t, "/virtio"): + return "virtio" + case strings.Contains(t, "/ata"): + return "sata" + case strings.Contains(t, "/mmc"): + return "mmc" + case strings.Contains(t, "/host"): + return "scsi" + } + return "" +} + +func diskRotational(base string) bool { + b, err := os.ReadFile(filepath.Join(base, "queue/rotational")) + if err != nil { + return false + } + return strings.TrimSpace(string(b)) == "1" +} + // ----- NICs ------------------------------------------------------------- -func probeNICs() []spec.NICSpec { +func NICs(log Logger) []spec.NICSpec { + log.info(" NICs: walking /sys/class/net") root := "/sys/class/net" entries, err := os.ReadDir(root) if err != nil { + log.warn(" NICs: /sys/class/net unreadable: " + err.Error()) return nil } var out []spec.NICSpec @@ -200,27 +461,63 @@ func probeNICs() []spec.NICSpec { if mac == "" || mac == "00:00:00:00:00:00" { continue } - // /sys/class/net/*/speed reports Mbps or -1 if link down. speed := 0 if b, err := os.ReadFile(filepath.Join(base, "speed")); err == nil { if mbps, err := strconv.Atoi(strings.TrimSpace(string(b))); err == nil && mbps > 0 { speed = mbps / 1000 } } - out = append(out, spec.NICSpec{MAC: strings.ToLower(mac), SpeedGbps: speed}) + driver, pci := nicDriverAndPCI(base) + n := spec.NICSpec{ + MAC: strings.ToLower(mac), + SpeedGbps: speed, + Driver: driver, + PCIAddr: pci, + } + out = append(out, n) + suffix := "" + if speed == 0 { + suffix = " (no link)" + } + log.info(fmt.Sprintf(" NICs: %s mac=%s %dGbps driver=%s pci=%s%s", + name, n.MAC, speed, nonEmpty(driver, "?"), nonEmpty(pci, "?"), suffix)) } return out } +// nicDriverAndPCI reads DRIVER= from the device/uevent file and resolves +// the device symlink for the PCI address (last path segment). +func nicDriverAndPCI(base string) (driver, pci string) { + if b, err := os.ReadFile(filepath.Join(base, "device/uevent")); err == nil { + for _, line := range strings.Split(string(b), "\n") { + if v, ok := strings.CutPrefix(line, "DRIVER="); ok { + driver = strings.TrimSpace(v) + break + } + } + } + if target, err := os.Readlink(filepath.Join(base, "device")); err == nil { + // target looks like "../../../0000:02:00.0" — last element is pci addr. + pci = filepath.Base(strings.TrimSpace(target)) + } + return driver, pci +} + // ----- GPUs ------------------------------------------------------------- -// probeGPUs leans on lspci; if lspci is missing, returns nothing and -// the diff engine just won't match any GPU expectations. Phase 4 will -// add nvidia-smi for VRAM and firmware. -func probeGPUs() []spec.GPUSpec { - cmd := exec.Command("lspci", "-mm", "-nn") - out, err := cmd.Output() +// GPUs leans on lspci for the base list; for each match it parses the +// PCI address and runs `lspci -vv -s ` to pull VRAM from the +// prefetchable memory region size. Driver comes from the kernel-driver +// line lspci emits when verbose. +func GPUs(log Logger) []spec.GPUSpec { + if _, err := exec.LookPath("lspci"); err != nil { + log.warn(" GPUs: lspci not installed; skipped") + return nil + } + log.info(" GPUs: lspci -mm -nn") + out, err := exec.Command("lspci", "-D", "-mm", "-nn").Output() if err != nil { + log.warn(" GPUs: lspci failed: " + err.Error()) return nil } var gpus []spec.GPUSpec @@ -230,52 +527,258 @@ func probeGPUs() []spec.GPUSpec { !strings.Contains(low, "3d controller") { continue } - // lspci -mm quotes fields. splitQuoted indexes: - // [0] = class (e.g. "VGA compatible controller [0300]") - // [1] = vendor (e.g. "Intel Corporation [8086]") - // [2] = device (e.g. "Alder Lake-N [UHD Graphics] [46d1]") - // [3] = subsys (if present — varies between boards even - // for identical chips; NOT a model identifier) - // We used to concatenate [2] + " " + [3], which made the "model" - // key include subsystem noise and the occasional -rXX revision - // marker, so reporter and live-image runs produced different - // slugs for the same silicon. Use only [2], stripped of the - // trailing PCI device-id "[NNNN]" bracket that lspci -nn adds. + // With -D the first quoted field is the PCI address. fields := splitQuoted(line) - if len(fields) >= 3 { - model := stripPCIID(fields[2]) - model = sanitizeASCII(model) - if model != "" { - gpus = append(gpus, spec.GPUSpec{Model: model}) + if len(fields) < 3 { + continue + } + // -D -mm emits the address as the first non-quoted token. + // splitQuoted strips unquoted whitespace, so the address is + // indexed at 0 and the quoted class/vendor/device follow. + // On older lspci the first field is the slot in quotes instead; + // cover both: if fields[0] doesn't look like an address, strip + // it from the space-separated prefix. + addr := "" + if pciAddrRE.MatchString(fields[0]) { + addr = fields[0] + } else if i := strings.IndexByte(line, '"'); i > 0 { + prefix := strings.TrimSpace(line[:i]) + if pciAddrRE.MatchString(prefix) { + addr = prefix } } + model := stripPCIID(fields[2]) + if !pciAddrRE.MatchString(fields[0]) { + // With no -D support the device is at [1], model at [2]: same + // as old code path. Keep existing behavior. + model = stripPCIID(fields[2]) + } + model = sanitizeASCII(model) + if model == "" { + continue + } + g := spec.GPUSpec{Model: model, PCIAddr: addr} + if addr != "" { + if vv, err := exec.Command("lspci", "-vv", "-s", addr).Output(); err == nil { + g.VRAMGiB, g.Driver = parseLspciVerbose(string(vv)) + } + } + gpus = append(gpus, g) + log.info(fmt.Sprintf(" GPUs: %s pci=%s driver=%s vram=%dGiB", + model, nonEmpty(addr, "?"), nonEmpty(g.Driver, "?"), g.VRAMGiB)) } return gpus } +var pciAddrRE = regexp.MustCompile(`^[0-9a-fA-F]{4}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}\.[0-9a-fA-F]$`) + +// parseLspciVerbose pulls the largest prefetchable memory region (VRAM) +// in GiB and the "Kernel driver in use" name from an lspci -vv block. +// The memory line looks like: "Memory at ... (64-bit, prefetchable) [size=8G]" +func parseLspciVerbose(s string) (vramGiB int, driver string) { + sizeRE := regexp.MustCompile(`prefetchable\) \[size=(\d+)([KMGT])`) + for _, line := range strings.Split(s, "\n") { + trim := strings.TrimSpace(line) + if v, ok := strings.CutPrefix(trim, "Kernel driver in use:"); ok { + driver = strings.TrimSpace(v) + continue + } + if m := sizeRE.FindStringSubmatch(line); m != nil { + n, _ := strconv.Atoi(m[1]) + switch m[2] { + case "T": + n *= 1024 + case "G": + // already GiB + case "M": + n /= 1024 + case "K": + n /= (1024 * 1024) + } + if n > vramGiB { + vramGiB = n + } + } + } + return vramGiB, driver +} + +// ----- System / Baseboard ----------------------------------------------- + +func System(log Logger) spec.SystemSpec { + if _, err := exec.LookPath("dmidecode"); err != nil { + log.warn(" System: dmidecode not installed; skipped") + return spec.SystemSpec{} + } + log.info(" System: dmidecode -t system") + out, err := exec.Command("dmidecode", "-t", "system").Output() + if err != nil { + log.warn(" System: dmidecode -t system failed: " + err.Error()) + return spec.SystemSpec{} + } + kv := parseDmidecodeSection(strings.NewReader(string(out)), "System Information") + if kv == nil { + log.warn(" System: no System Information section in dmidecode output") + return spec.SystemSpec{} + } + s := spec.SystemSpec{ + Manufacturer: cleanDmidecodeValue(kv["Manufacturer"]), + ProductName: cleanDmidecodeValue(kv["Product Name"]), + SerialNumber: cleanDmidecodeValue(kv["Serial Number"]), + UUID: cleanDmidecodeValue(kv["UUID"]), + } + log.info(fmt.Sprintf(" System: manufacturer=%s product=%q serial=%s uuid=%s", + nonEmpty(s.Manufacturer, "?"), s.ProductName, + nonEmpty(s.SerialNumber, "?"), nonEmpty(s.UUID, "?"))) + return s +} + +func Baseboard(log Logger) spec.BaseboardSpec { + if _, err := exec.LookPath("dmidecode"); err != nil { + log.warn(" Baseboard: dmidecode not installed; skipped") + return spec.BaseboardSpec{} + } + log.info(" Baseboard: dmidecode -t baseboard") + out, err := exec.Command("dmidecode", "-t", "baseboard").Output() + if err != nil { + log.warn(" Baseboard: dmidecode -t baseboard failed: " + err.Error()) + return spec.BaseboardSpec{} + } + kv := parseDmidecodeSection(strings.NewReader(string(out)), "Base Board Information") + if kv == nil { + log.warn(" Baseboard: no Base Board Information section in dmidecode output") + return spec.BaseboardSpec{} + } + b := spec.BaseboardSpec{ + Manufacturer: cleanDmidecodeValue(kv["Manufacturer"]), + ProductName: cleanDmidecodeValue(kv["Product Name"]), + SerialNumber: cleanDmidecodeValue(kv["Serial Number"]), + } + log.info(fmt.Sprintf(" Baseboard: manufacturer=%s product=%q serial=%s", + nonEmpty(b.Manufacturer, "?"), b.ProductName, nonEmpty(b.SerialNumber, "?"))) + return b +} + +// ----- PSU -------------------------------------------------------------- + +func PSU(log Logger) []spec.PowerSupplySpec { + if _, err := exec.LookPath("dmidecode"); err != nil { + log.warn(" PSU: dmidecode not installed; skipped") + return nil + } + log.info(" PSU: dmidecode -t 39") + out, err := exec.Command("dmidecode", "-t", "39").Output() + if err != nil { + log.warn(" PSU: dmidecode -t 39 failed: " + err.Error()) + return nil + } + blocks := parseDmidecodeAllSections(strings.NewReader(string(out)), "System Power Supply") + if len(blocks) == 0 { + log.warn(" PSU: dmidecode -t 39 returned no data (mini-PC or SMBIOS lacks type 39)") + return nil + } + var out2 []spec.PowerSupplySpec + for _, kv := range blocks { + p := spec.PowerSupplySpec{ + Slot: cleanDmidecodeValue(kv["Location"]), + Manufacturer: cleanDmidecodeValue(kv["Manufacturer"]), + Model: cleanDmidecodeValue(kv["Model Part Number"]), + Status: cleanDmidecodeValue(kv["Status"]), + } + if p.Model == "" { + p.Model = cleanDmidecodeValue(kv["Name"]) + } + if watts := cleanDmidecodeValue(kv["Max Power Capacity"]); watts != "" { + // "760 W" or "760W" + clean := strings.TrimSuffix(strings.TrimSpace(watts), "W") + clean = strings.TrimSpace(clean) + if n, err := strconv.Atoi(strings.Fields(clean)[0]); err == nil { + p.MaxWatts = n + } + } + out2 = append(out2, p) + log.info(fmt.Sprintf(" PSU: slot=%s manufacturer=%s model=%q %dW status=%q", + nonEmpty(p.Slot, "?"), nonEmpty(p.Manufacturer, "?"), p.Model, p.MaxWatts, p.Status)) + } + return out2 +} + +// ----- OS --------------------------------------------------------------- + +func OS(log Logger) spec.OSSpec { + o := spec.OSSpec{} + // /proc/sys/kernel/osrelease is equivalent to `uname -r` and a plain + // file read — keeps the probe buildable on non-linux dev hosts + // without syscall.Uname gymnastics. Missing on non-linux; falls back + // to running `uname -r`. + if b, err := os.ReadFile("/proc/sys/kernel/osrelease"); err == nil { + o.Kernel = strings.TrimSpace(string(b)) + } else if out, err := exec.Command("uname", "-r").Output(); err == nil { + o.Kernel = strings.TrimSpace(string(out)) + } + if b, err := os.ReadFile("/etc/os-release"); err == nil { + o.Distribution, o.Version = parseOSRelease(string(b)) + } + log.info(fmt.Sprintf(" OS: kernel=%s distro=%q version=%s", + nonEmpty(o.Kernel, "?"), o.Distribution, nonEmpty(o.Version, "?"))) + return o +} + +// parseOSRelease returns PRETTY_NAME (distribution) and VERSION_ID +// (version). Quotes are stripped per the spec. +func parseOSRelease(s string) (dist, version string) { + sc := bufio.NewScanner(strings.NewReader(s)) + for sc.Scan() { + line := sc.Text() + k, v, ok := strings.Cut(line, "=") + if !ok { + continue + } + v = strings.Trim(strings.TrimSpace(v), `"`) + switch strings.TrimSpace(k) { + case "PRETTY_NAME": + dist = v + case "VERSION_ID": + version = v + } + } + return dist, version +} + +// ----- shared ----------------------------------------------------------- + func splitQuoted(line string) []string { + // Extended variant: splits space-separated tokens outside quotes AND + // preserves the content inside quotes as single tokens. Needed so + // `lspci -D -mm -nn` output (leading PCI address token outside any + // quotes) parses cleanly alongside the quoted class/vendor fields. var out []string var cur strings.Builder inQ := false + flush := func() { + if cur.Len() > 0 { + out = append(out, cur.String()) + cur.Reset() + } + } for _, r := range line { switch { case r == '"': - inQ = !inQ - if !inQ { + if inQ { out = append(out, cur.String()) cur.Reset() } + inQ = !inQ case r == ' ' && !inQ: - continue + flush() default: cur.WriteRune(r) } } + flush() return out } -// ----- shared helpers --------------------------------------------------- - func readLine(path string) string { b, err := os.ReadFile(path) if err != nil { @@ -284,12 +787,6 @@ func readLine(path string) string { return strings.TrimSpace(string(b)) } -// sanitizeASCII drops bytes below 0x20 (control chars) and above 0x7E -// (high-bit / UTF-8 continuation bytes that come from binary sysfs -// files like vpd_pg80 being read as a Go string) and trims the result. -// Everything the caller cares about — disk serials, GPU model names — -// is ASCII-printable, so this is safe and fixes the reporter-vs-live -// mismatch where the same hardware produced different map keys. func sanitizeASCII(s string) string { var b strings.Builder b.Grow(len(s)) @@ -302,10 +799,6 @@ func sanitizeASCII(s string) string { return strings.TrimSpace(b.String()) } -// stripPCIID removes the trailing " [NNNN]" PCI device-ID marker that -// `lspci -nn` appends to vendor/device strings — useful context for a -// human but an unstable identifier across pciutils versions. Keeps any -// internal brackets (e.g. "[UHD Graphics]" is part of the real name). var pciIDTail = regexp.MustCompile(` *\[[0-9a-fA-F]{4}\]$`) func stripPCIID(s string) string { diff --git a/agent/probes/inventory_test.go b/agent/probes/inventory_test.go new file mode 100644 index 0000000..95d15a5 --- /dev/null +++ b/agent/probes/inventory_test.go @@ -0,0 +1,171 @@ +package probes + +import ( + "strings" + "testing" + + "vetting/internal/spec" +) + +func TestParseCPUInfoInto(t *testing.T) { + sample := `processor : 0 +vendor_id : GenuineIntel +model name : Intel(R) N95 +stepping : 3 +cpu cores : 4 +flags : fpu vme de pse tsc msr aes sse4_2 vmx + +processor : 1 +vendor_id : GenuineIntel +model name : Intel(R) N95 +` + var c spec.CPUSpec + parseCPUInfoInto(strings.NewReader(sample), &c) + if c.Model != "Intel(R) N95" { + t.Fatalf("model: got %q", c.Model) + } + if c.Vendor != "GenuineIntel" { + t.Fatalf("vendor: got %q", c.Vendor) + } + if c.Stepping != "3" { + t.Fatalf("stepping: got %q", c.Stepping) + } + if c.PhysicalCores != 4 { + t.Fatalf("physical cores: got %d", c.PhysicalCores) + } + // Only vmx, aes, sse4_2 should survive the allow-list filter. + if len(c.Flags) != 3 { + t.Fatalf("flags count: got %v", c.Flags) + } +} + +func TestParseOSRelease(t *testing.T) { + in := `PRETTY_NAME="Debian GNU/Linux 12 (bookworm)" +VERSION_ID="12" +NAME=Debian +` + dist, ver := parseOSRelease(in) + if dist != "Debian GNU/Linux 12 (bookworm)" { + t.Fatalf("distribution: got %q", dist) + } + if ver != "12" { + t.Fatalf("version: got %q", ver) + } +} + +func TestParseDmidecodeSize(t *testing.T) { + cases := []struct { + in string + want int + }{ + {"16 GB", 16}, + {"32 GiB", 32}, + {"8192 MB", 8}, + {"No Module Installed", 0}, + {"garbage", 0}, + } + for _, c := range cases { + if got := parseDmidecodeSize(c.in); got != c.want { + t.Errorf("parseDmidecodeSize(%q) = %d, want %d", c.in, got, c.want) + } + } +} + +func TestParseMTS(t *testing.T) { + if n := parseMTS("3200 MT/s"); n != 3200 { + t.Errorf("parseMTS MT/s: got %d", n) + } + if n := parseMTS("2400 MHz"); n != 2400 { + t.Errorf("parseMTS MHz: got %d", n) + } + if n := parseMTS("Unknown"); n != 0 { + t.Errorf("parseMTS unknown: got %d", n) + } +} + +func TestCleanDmidecodeValue(t *testing.T) { + cases := map[string]string{ + "Micron": "Micron", + "Not Specified": "", + " Unknown ": "", + "To Be Filled By O.E.M.": "", + "": "", + } + for in, want := range cases { + if got := cleanDmidecodeValue(in); got != want { + t.Errorf("cleanDmidecodeValue(%q) = %q, want %q", in, got, want) + } + } +} + +func TestParseDIMMs(t *testing.T) { + // Two populated + two empty slots. Verifies the multi-section parser + // produces the expected count and the empty-slot detection fires on + // "No Module Installed". + sample := `Handle 0x0032, DMI type 17, 84 bytes +Memory Device + Size: 16 GB + Locator: DIMM_A1 + Manufacturer: Micron + Part Number: 8ATF2G64AZ-3G2E1 + Configured Memory Speed: 3200 MT/s + +Handle 0x0033, DMI type 17, 84 bytes +Memory Device + Size: No Module Installed + Locator: DIMM_A2 + Manufacturer: Not Specified + +Handle 0x0034, DMI type 17, 84 bytes +Memory Device + Size: 16 GB + Locator: DIMM_B1 + Manufacturer: Micron + Part Number: 8ATF2G64AZ-3G2E1 + Speed: 3200 MT/s + +Handle 0x0035, DMI type 17, 84 bytes +Memory Device + Size: No Module Installed + Locator: DIMM_B2 +` + dimms := parseDIMMs(strings.NewReader(sample), Logger{}) + if len(dimms) != 4 { + t.Fatalf("expected 4 DIMM entries, got %d: %+v", len(dimms), dimms) + } + populated := 0 + for _, d := range dimms { + if d.Populated { + populated++ + } + } + if populated != 2 { + t.Fatalf("expected 2 populated, got %d", populated) + } + if dimms[0].Slot != "DIMM_A1" || dimms[0].SizeGB != 16 || dimms[0].SpeedMTS != 3200 { + t.Fatalf("DIMM_A1 mismatch: %+v", dimms[0]) + } + if dimms[0].PartNumber != "8ATF2G64AZ-3G2E1" { + t.Fatalf("DIMM_A1 part number mismatch: %q", dimms[0].PartNumber) + } + if dimms[1].Populated || dimms[1].Slot != "DIMM_A2" { + t.Fatalf("DIMM_A2 should be empty: %+v", dimms[1]) + } +} + +func TestParseLspciVerbose(t *testing.T) { + sample := `01:00.0 VGA compatible controller: NVIDIA Corporation + Subsystem: ASUSTeK + Kernel driver in use: nvidia + Memory at f6000000 (32-bit, non-prefetchable) [size=16M] + Memory at c0000000 (64-bit, prefetchable) [size=8G] + Memory at d0000000 (64-bit, prefetchable) [size=32M] +` + vram, driver := parseLspciVerbose(sample) + if vram != 8 { + t.Errorf("VRAM: got %d, want 8", vram) + } + if driver != "nvidia" { + t.Errorf("driver: got %q", driver) + } +} diff --git a/agent/runner.go b/agent/runner.go index b567d54..35f26ba 100644 --- a/agent/runner.go +++ b/agent/runner.go @@ -155,15 +155,35 @@ func runStage(ctx context.Context, stage string, claim *ClaimResponse, fwd *logF switch stage { case "Inventory": fwd.info("Inventory: probing host hardware") - inv, err := probes.Collect() - if err != nil { - return stageOutcome{Outcome: tests.Outcome{Passed: false, Message: err.Error(), Summary: "probe error"}} + log := probes.Logger{Info: fwd.info, Warn: fwd.warn} + inv := &spec.Inventory{} + var subs []tests.SubStepReport + runSub := func(name string, fn func()) { + start := time.Now() + fn() + subs = append(subs, tests.SubStepReport{ + Name: name, + Passed: true, + StartedAt: start, + CompletedAt: time.Now(), + }) } - fwd.info("Inventory: " + inventorySummary(inv)) + runSub("CPU", func() { inv.CPU = probes.CPU(log) }) + runSub("Memory", func() { inv.Memory = probes.Memory(log) }) + runSub("Disks", func() { inv.Disks = probes.Disks(log) }) + runSub("NICs", func() { inv.NICs = probes.NICs(log) }) + runSub("GPUs", func() { inv.GPUs = probes.GPUs(log) }) + runSub("System", func() { inv.System = probes.System(log) }) + runSub("Baseboard", func() { inv.Baseboard = probes.Baseboard(log) }) + runSub("PSU", func() { inv.PSU = probes.PSU(log) }) + runSub("OS", func() { inv.OS = probes.OS(log) }) + summary := inventorySummary(inv) + fwd.info("Inventory: " + summary) return stageOutcome{ Outcome: tests.Outcome{ - Passed: true, - Summary: inventorySummary(inv), + Passed: true, + Summary: summary, + SubSteps: subs, }, Inventory: inv, } @@ -504,9 +524,19 @@ func requestHold(ctx context.Context, c *Client, fwd *logForwarder) error { } func inventorySummary(inv *spec.Inventory) string { - return fmt.Sprintf("cpu=%q cores=%d ram=%dGiB disks=%d nics=%d gpus=%d", - inv.CPU.Model, inv.CPU.LogicalCores, inv.Memory.TotalGiB, - len(inv.Disks), len(inv.NICs), len(inv.GPUs)) + populated := 0 + for _, d := range inv.Memory.Modules { + if d.Populated { + populated++ + } + } + slots := "" + if len(inv.Memory.Modules) > 0 { + slots = fmt.Sprintf(" (%d/%d slots)", populated, len(inv.Memory.Modules)) + } + return fmt.Sprintf("cpu=%q cores=%d ram=%dGiB%s disks=%d nics=%d gpus=%d psu=%d", + inv.CPU.Model, inv.CPU.LogicalCores, inv.Memory.TotalGiB, slots, + len(inv.Disks), len(inv.NICs), len(inv.GPUs), len(inv.PSU)) } // firmwareSummary renders the one-liner surfaced in the stage tile: diff --git a/agent/tests/fakes/dmidecode/main.go b/agent/tests/fakes/dmidecode/main.go index c5545bb..7354da6 100644 --- a/agent/tests/fakes/dmidecode/main.go +++ b/agent/tests/fakes/dmidecode/main.go @@ -1,12 +1,41 @@ -// fake_dmidecode simulates `dmidecode -t bios` for unit tests of the -// firmware probe's BIOS parser. Prints deterministic output modeled on -// a real Supermicro host; exits 0 regardless of flags. +// fake_dmidecode simulates the subset of `dmidecode -t ` output +// the Vetting probes actually parse. Dispatches on the argument after +// `-t` so the same binary serves both firmware (BIOS) and inventory +// (memory / system / baseboard / power-supply) probes under test. Exits +// 0 for every supported type; prints nothing for unknown types. package main -import "fmt" +import ( + "fmt" + "os" +) func main() { - fmt.Println(`# dmidecode 3.3 + t := "" + for i, a := range os.Args { + if a == "-t" && i+1 < len(os.Args) { + t = os.Args[i+1] + break + } + } + switch t { + case "bios": + fmt.Println(biosFixture) + case "memory", "17": + fmt.Println(memoryFixture) + case "system": + fmt.Println(systemFixture) + case "baseboard": + fmt.Println(baseboardFixture) + case "39": + fmt.Println(psuFixture) + default: + // No-op: tests expecting "no data" for an unknown -t value get + // an empty body and exit 0 (matches dmidecode behavior). + } +} + +const biosFixture = `# dmidecode 3.3 Getting SMBIOS data from sysfs. SMBIOS 3.2.0 present. @@ -20,5 +49,125 @@ BIOS Information ROM Size: 32 MB Characteristics: PCI is supported - BIOS is upgradeable`) -} + BIOS is upgradeable` + +const memoryFixture = `# dmidecode 3.3 +Getting SMBIOS data from sysfs. +SMBIOS 3.2.0 present. + +Handle 0x0032, DMI type 17, 84 bytes +Memory Device + Array Handle: 0x0031 + Total Width: 64 bits + Data Width: 64 bits + Size: 16 GB + Form Factor: DIMM + Set: None + Locator: DIMM_A1 + Bank Locator: BANK 0 + Type: DDR4 + Type Detail: Synchronous + Speed: 3200 MT/s + Manufacturer: Micron + Serial Number: 12345678 + Asset Tag: None + Part Number: 8ATF2G64AZ-3G2E1 + Rank: 2 + Configured Memory Speed: 3200 MT/s + +Handle 0x0033, DMI type 17, 84 bytes +Memory Device + Array Handle: 0x0031 + Total Width: Unknown + Data Width: Unknown + Size: No Module Installed + Form Factor: DIMM + Set: None + Locator: DIMM_A2 + Bank Locator: BANK 1 + Type: Unknown + Type Detail: Unknown + Speed: Unknown + Manufacturer: Not Specified + Part Number: Not Specified + +Handle 0x0034, DMI type 17, 84 bytes +Memory Device + Array Handle: 0x0031 + Total Width: 64 bits + Data Width: 64 bits + Size: 16 GB + Form Factor: DIMM + Locator: DIMM_B1 + Bank Locator: BANK 2 + Type: DDR4 + Speed: 3200 MT/s + Manufacturer: Micron + Part Number: 8ATF2G64AZ-3G2E1 + Configured Memory Speed: 3200 MT/s + +Handle 0x0035, DMI type 17, 84 bytes +Memory Device + Array Handle: 0x0031 + Size: No Module Installed + Locator: DIMM_B2 + Bank Locator: BANK 3 + Manufacturer: Not Specified + Part Number: Not Specified` + +const systemFixture = `# dmidecode 3.3 +Getting SMBIOS data from sysfs. +SMBIOS 3.2.0 present. + +Handle 0x0001, DMI type 1, 27 bytes +System Information + Manufacturer: Beelink + Product Name: Mini S12 Pro + Version: Default string + Serial Number: SN-MINI-001 + UUID: 03000200-0400-0500-0006-000700080009 + Wake-up Type: Power Switch + SKU Number: Default string + Family: Default string` + +const baseboardFixture = `# dmidecode 3.3 +Getting SMBIOS data from sysfs. +SMBIOS 3.2.0 present. + +Handle 0x0002, DMI type 2, 15 bytes +Base Board Information + Manufacturer: Beelink + Product Name: AZW SEi + Version: 1.0 + Serial Number: BB-0001 + Asset Tag: Default string` + +const psuFixture = `# dmidecode 3.3 +Getting SMBIOS data from sysfs. +SMBIOS 3.2.0 present. + +Handle 0x0040, DMI type 39, 22 bytes +System Power Supply + Power Unit Group: 1 + Location: PSU1 + Name: PWR SPLY,760W + Manufacturer: DELTA + Serial Number: ABC123 + Asset Tag: 00000000 + Model Part Number: ABC760 + Revision: 1.0 + Max Power Capacity: 760 W + Status: Present, OK + Type: Switching + +Handle 0x0041, DMI type 39, 22 bytes +System Power Supply + Power Unit Group: 1 + Location: PSU2 + Name: PWR SPLY,760W + Manufacturer: DELTA + Serial Number: XYZ789 + Model Part Number: ABC760 + Max Power Capacity: 760 W + Status: Present, OK + Type: Switching` diff --git a/internal/api/agent_handlers.go b/internal/api/agent_handlers.go index dd164e3..19a597b 100644 --- a/internal/api/agent_handlers.go +++ b/internal/api/agent_handlers.go @@ -1201,6 +1201,14 @@ func (a *Agent) resolveReporting(r *http.Request, runID int64) { log.Printf("reporting: list firmware: %v", err) } } + // Inventory is optional for reporting: runs cancelled before the + // Inventory stage finishes won't have the artifact. Failure to load + // is logged but never aborts the report. + inventory, err := a.readInventoryArtifact(r, runID) + if err != nil { + log.Printf("reporting: read inventory: %v", err) + inventory = nil + } bundle := map[string]any{ "run": run, "host": host, @@ -1256,6 +1264,7 @@ func (a *Agent) resolveReporting(r *http.Request, runID int64) { SpecDiffs: diffs, Aggregates: report.AggregateMeasurements(measurements), Firmware: fwRows, + Inventory: inventory, } if htmlBuf, err := report.RenderHTML(htmlData); err != nil { log.Printf("reporting: render html: %v", err) diff --git a/internal/report/report.go b/internal/report/report.go index 37f709e..aa696a1 100644 --- a/internal/report/report.go +++ b/internal/report/report.go @@ -18,6 +18,7 @@ import ( "time" "vetting/internal/model" + "vetting/internal/spec" ) // Data is the payload fed to the HTML template. Callers assemble it @@ -30,6 +31,7 @@ type Data struct { SpecDiffs []model.SpecDiff Aggregates []Aggregate // flattened measurement summary; see Aggregate Firmware []FirmwareSnapshot // captured firmware versions, empty if none + Inventory *spec.Inventory // captured inventory, nil if artifact missing } // FirmwareSnapshot is the report-facing view of one firmware row. @@ -206,6 +208,146 @@ const htmlTemplate = ` +{{if .Inventory}} +
+

Inventory

+ +{{with .Inventory.System}}{{if or .Manufacturer .ProductName .SerialNumber .UUID}} +

System

+ + {{if .Manufacturer}}{{end}} + {{if .ProductName}}{{end}} + {{if .SerialNumber}}{{end}} + {{if .UUID}}{{end}} +
Manufacturer{{.Manufacturer}}
Product{{.ProductName}}
Serial{{.SerialNumber}}
UUID{{.UUID}}
+{{end}}{{end}} + +{{with .Inventory.Baseboard}}{{if or .Manufacturer .ProductName .SerialNumber}} +

Baseboard

+ + {{if .Manufacturer}}{{end}} + {{if .ProductName}}{{end}} + {{if .SerialNumber}}{{end}} +
Manufacturer{{.Manufacturer}}
Product{{.ProductName}}
Serial{{.SerialNumber}}
+{{end}}{{end}} + +{{with .Inventory.CPU}}{{if .Model}} +

CPU

+ + + {{if .Vendor}}{{end}} + {{if .PhysicalCores}}{{end}} + {{if .LogicalCores}}{{end}} + {{if .Stepping}}{{end}} + {{if .Flags}}{{end}} +
Model{{.Model}}
Vendor{{.Vendor}}
Physical cores{{.PhysicalCores}}
Logical cores{{.LogicalCores}}
Stepping{{.Stepping}}
Flags{{range $i, $f := .Flags}}{{if $i}}, {{end}}{{$f}}{{end}}
+{{end}}{{end}} + +{{with .Inventory.Memory}}{{if or .TotalGiB .Modules}} +

Memory

+ + +
Total{{.TotalGiB}} GiB
+{{if .Modules}} + + + + {{range .Modules}} + + + + + + + + + {{end}} + +
SlotSizeSpeedManufacturerPart numberPopulated
{{.Slot}}{{if .SizeGB}}{{.SizeGB}} GiB{{else}}—{{end}}{{if .SpeedMTS}}{{.SpeedMTS}} MT/s{{else}}—{{end}}{{.Manufacturer}}{{.PartNumber}}{{if .Populated}}yes{{else}}no{{end}}
+{{end}} +{{end}}{{end}} + +{{if .Inventory.Disks}} +

Disks

+ + + + {{range .Inventory.Disks}} + + + + + + + + {{end}} + +
SerialModelSizeTransportRotational
{{.Serial}}{{.Model}}{{.SizeGB}} GB{{.Transport}}{{if .Rotational}}yes{{else}}no{{end}}
+{{end}} + +{{if .Inventory.NICs}} +

NICs

+ + + + {{range .Inventory.NICs}} + + + + + + + {{end}} + +
MACSpeedDriverPCI
{{.MAC}}{{if .SpeedGbps}}{{.SpeedGbps}} Gbps{{else}}—{{end}}{{.Driver}}{{.PCIAddr}}
+{{end}} + +{{if .Inventory.GPUs}} +

GPUs

+ + + + {{range .Inventory.GPUs}} + + + + + + + {{end}} + +
ModelVRAMPCIDriver
{{.Model}}{{if .VRAMGiB}}{{.VRAMGiB}} GiB{{else}}—{{end}}{{.PCIAddr}}{{.Driver}}
+{{end}} + +{{if .Inventory.PSU}} +

Power supplies

+ + + + {{range .Inventory.PSU}} + + + + + + + + {{end}} + +
SlotManufacturerModelMax wattsStatus
{{.Slot}}{{.Manufacturer}}{{.Model}}{{if .MaxWatts}}{{.MaxWatts}} W{{else}}—{{end}}{{.Status}}
+{{end}} + +{{with .Inventory.OS}}{{if or .Kernel .Distribution .Version}} +

OS

+ + {{if .Kernel}}{{end}} + {{if .Distribution}}{{end}} + {{if .Version}}{{end}} +
Kernel{{.Kernel}}
Distribution{{.Distribution}}
Version{{.Version}}
+{{end}}{{end}} +
+{{end}} +

Firmware ({{len .Firmware}})

{{if .Firmware}} diff --git a/internal/spec/spec.go b/internal/spec/spec.go index 108de80..0bbfb1b 100644 --- a/internal/spec/spec.go +++ b/internal/spec/spec.go @@ -21,12 +21,16 @@ import ( ) type Spec struct { - CPU *CPUSpec `yaml:"cpu,omitempty"` - Memory *MemorySpec `yaml:"memory,omitempty"` - Disks []DiskSpec `yaml:"disks,omitempty"` - NICs []NICSpec `yaml:"nics,omitempty"` - GPUs []GPUSpec `yaml:"gpus,omitempty"` - Firmware []FirmwareSpec `yaml:"firmware,omitempty"` + CPU *CPUSpec `yaml:"cpu,omitempty"` + Memory *MemorySpec `yaml:"memory,omitempty"` + Disks []DiskSpec `yaml:"disks,omitempty"` + NICs []NICSpec `yaml:"nics,omitempty"` + GPUs []GPUSpec `yaml:"gpus,omitempty"` + Firmware []FirmwareSpec `yaml:"firmware,omitempty"` + System *SystemSpec `yaml:"system,omitempty"` + Baseboard *BaseboardSpec `yaml:"baseboard,omitempty"` + PSU []PowerSupplySpec `yaml:"psu,omitempty"` + OS *OSSpec `yaml:"os,omitempty"` } // FirmwareSpec is one row in the expected-spec YAML's `firmware:` block. @@ -54,36 +58,102 @@ type FirmwareObserved struct { } type CPUSpec struct { - Model string `json:"model,omitempty" yaml:"model,omitempty"` - LogicalCores int `json:"logical_cores,omitempty" yaml:"logical_cores,omitempty"` + Model string `json:"model,omitempty" yaml:"model,omitempty"` + LogicalCores int `json:"logical_cores,omitempty" yaml:"logical_cores,omitempty"` + PhysicalCores int `json:"physical_cores,omitempty" yaml:"physical_cores,omitempty"` + Vendor string `json:"vendor,omitempty" yaml:"vendor,omitempty"` + Stepping string `json:"stepping,omitempty" yaml:"stepping,omitempty"` + // Flags is a curated subset (vmx, svm, aes, sse4_2) — capturing the + // full /proc/cpuinfo flag list would balloon the inventory JSON and + // the entries operators actually gate on are a short list. + Flags []string `json:"flags,omitempty" yaml:"flags,omitempty"` } type MemorySpec struct { - TotalGiB int `json:"total_gib,omitempty" yaml:"total_gib,omitempty"` + TotalGiB int `json:"total_gib,omitempty" yaml:"total_gib,omitempty"` + Modules []DIMMSpec `json:"modules,omitempty" yaml:"modules,omitempty"` +} + +// DIMMSpec is one physical memory module. Slot is the canonical SMBIOS +// locator (e.g. "DIMM_A1"); Populated=false represents an empty slot +// that dmidecode still reports for chassis-layout context. +type DIMMSpec struct { + Slot string `json:"slot,omitempty" yaml:"slot,omitempty"` + SizeGB int `json:"size_gb,omitempty" yaml:"size_gb,omitempty"` + SpeedMTS int `json:"speed_mts,omitempty" yaml:"speed_mts,omitempty"` + Manufacturer string `json:"manufacturer,omitempty" yaml:"manufacturer,omitempty"` + PartNumber string `json:"part_number,omitempty" yaml:"part_number,omitempty"` + Populated bool `json:"populated,omitempty" yaml:"populated,omitempty"` } type DiskSpec struct { - Serial string `json:"serial,omitempty" yaml:"serial,omitempty"` - SizeGB int `json:"size_gb,omitempty" yaml:"size_gb,omitempty"` + Serial string `json:"serial,omitempty" yaml:"serial,omitempty"` + SizeGB int `json:"size_gb,omitempty" yaml:"size_gb,omitempty"` + Model string `json:"model,omitempty" yaml:"model,omitempty"` + Transport string `json:"transport,omitempty" yaml:"transport,omitempty"` + Rotational bool `json:"rotational,omitempty" yaml:"rotational,omitempty"` } type NICSpec struct { MAC string `json:"mac,omitempty" yaml:"mac,omitempty"` SpeedGbps int `json:"speed_gbps,omitempty" yaml:"speed_gbps,omitempty"` + Driver string `json:"driver,omitempty" yaml:"driver,omitempty"` + PCIAddr string `json:"pci_addr,omitempty" yaml:"pci_addr,omitempty"` } type GPUSpec struct { - Model string `json:"model,omitempty" yaml:"model,omitempty"` + Model string `json:"model,omitempty" yaml:"model,omitempty"` + VRAMGiB int `json:"vram_gib,omitempty" yaml:"vram_gib,omitempty"` + PCIAddr string `json:"pci_addr,omitempty" yaml:"pci_addr,omitempty"` + Driver string `json:"driver,omitempty" yaml:"driver,omitempty"` +} + +// SystemSpec is SMBIOS "System Information" (dmidecode -t system). +type SystemSpec struct { + Manufacturer string `json:"manufacturer,omitempty" yaml:"manufacturer,omitempty"` + ProductName string `json:"product_name,omitempty" yaml:"product_name,omitempty"` + SerialNumber string `json:"serial_number,omitempty" yaml:"serial_number,omitempty"` + UUID string `json:"uuid,omitempty" yaml:"uuid,omitempty"` +} + +// BaseboardSpec is SMBIOS "Base Board Information" (dmidecode -t baseboard). +type BaseboardSpec struct { + Manufacturer string `json:"manufacturer,omitempty" yaml:"manufacturer,omitempty"` + ProductName string `json:"product_name,omitempty" yaml:"product_name,omitempty"` + SerialNumber string `json:"serial_number,omitempty" yaml:"serial_number,omitempty"` +} + +// PowerSupplySpec is one SMBIOS "System Power Supply" entry +// (dmidecode -t 39). Status captures dmidecode's Status field +// ("Present, OK" / "Present, Unplugged" / …) verbatim. +type PowerSupplySpec struct { + Slot string `json:"slot,omitempty" yaml:"slot,omitempty"` + Manufacturer string `json:"manufacturer,omitempty" yaml:"manufacturer,omitempty"` + Model string `json:"model,omitempty" yaml:"model,omitempty"` + MaxWatts int `json:"max_watts,omitempty" yaml:"max_watts,omitempty"` + Status string `json:"status,omitempty" yaml:"status,omitempty"` +} + +// OSSpec captures kernel + /etc/os-release facts for the running agent +// OS. Not the host OS under test — the live image itself. +type OSSpec struct { + Kernel string `json:"kernel,omitempty" yaml:"kernel,omitempty"` + Distribution string `json:"distribution,omitempty" yaml:"distribution,omitempty"` + Version string `json:"version,omitempty" yaml:"version,omitempty"` } // Inventory is the actual measured hardware. Field names deliberately // match Spec so the diff reads cleanly. type Inventory struct { - CPU CPUSpec `json:"cpu" yaml:"cpu"` - Memory MemorySpec `json:"memory" yaml:"memory"` - Disks []DiskSpec `json:"disks" yaml:"disks"` - NICs []NICSpec `json:"nics" yaml:"nics"` - GPUs []GPUSpec `json:"gpus" yaml:"gpus"` + CPU CPUSpec `json:"cpu" yaml:"cpu"` + Memory MemorySpec `json:"memory" yaml:"memory"` + Disks []DiskSpec `json:"disks" yaml:"disks"` + NICs []NICSpec `json:"nics" yaml:"nics"` + GPUs []GPUSpec `json:"gpus" yaml:"gpus"` + System SystemSpec `json:"system" yaml:"system"` + Baseboard BaseboardSpec `json:"baseboard" yaml:"baseboard"` + PSU []PowerSupplySpec `json:"psu" yaml:"psu"` + OS OSSpec `json:"os" yaml:"os"` } // Parse reads expected-spec YAML. Empty YAML parses to a zero Spec and @@ -115,23 +185,201 @@ func Diff(expected *Spec, actual *Inventory) []model.SpecDiff { if expected.CPU.LogicalCores > 0 && expected.CPU.LogicalCores != actual.CPU.LogicalCores { out = append(out, diff("cpu.logical_cores", itoa(expected.CPU.LogicalCores), itoa(actual.CPU.LogicalCores))) } + if expected.CPU.PhysicalCores > 0 && expected.CPU.PhysicalCores != actual.CPU.PhysicalCores { + out = append(out, diff("cpu.physical_cores", itoa(expected.CPU.PhysicalCores), itoa(actual.CPU.PhysicalCores))) + } + if expected.CPU.Vendor != "" && !strings.EqualFold(expected.CPU.Vendor, actual.CPU.Vendor) { + out = append(out, diff("cpu.vendor", expected.CPU.Vendor, actual.CPU.Vendor)) + } + if expected.CPU.Stepping != "" && expected.CPU.Stepping != actual.CPU.Stepping { + out = append(out, diff("cpu.stepping", expected.CPU.Stepping, actual.CPU.Stepping)) + } + if len(expected.CPU.Flags) > 0 { + have := map[string]bool{} + for _, f := range actual.CPU.Flags { + have[strings.ToLower(f)] = true + } + for _, f := range expected.CPU.Flags { + if !have[strings.ToLower(f)] { + out = append(out, diff("cpu.flags["+f+"]", "present", "missing")) + } + } + } } - if expected.Memory != nil && expected.Memory.TotalGiB > 0 { - // Allow ±2 GiB tolerance: BIOS-reserved, kernel, reporting - // quantization. A dead 16 GiB stick will still surface. - if absInt(expected.Memory.TotalGiB-actual.Memory.TotalGiB) > 2 { - out = append(out, diff("memory.total_gib", itoa(expected.Memory.TotalGiB), itoa(actual.Memory.TotalGiB))) + if expected.Memory != nil { + if expected.Memory.TotalGiB > 0 { + // Allow ±2 GiB tolerance: BIOS-reserved, kernel, reporting + // quantization. A dead 16 GiB stick will still surface. + if absInt(expected.Memory.TotalGiB-actual.Memory.TotalGiB) > 2 { + out = append(out, diff("memory.total_gib", itoa(expected.Memory.TotalGiB), itoa(actual.Memory.TotalGiB))) + } } + out = append(out, diffDIMMs(expected.Memory.Modules, actual.Memory.Modules)...) } out = append(out, diffDisks(expected.Disks, actual.Disks)...) out = append(out, diffNICs(expected.NICs, actual.NICs)...) out = append(out, diffGPUs(expected.GPUs, actual.GPUs)...) + out = append(out, diffSystem(expected.System, actual.System)...) + out = append(out, diffBaseboard(expected.Baseboard, actual.Baseboard)...) + out = append(out, diffPSU(expected.PSU, actual.PSU)...) + out = append(out, diffOS(expected.OS, actual.OS)...) return out } +// diffDIMMs compares per-slot memory modules. Key is the Slot string +// (case-insensitive). Missing expected slot → critical. Populated vs +// empty mismatch → critical. Per-field mismatches (size/speed/part +// number/manufacturer) → critical. Extra populated slots not declared +// in the expected spec → critical (mirrors diffDisks extra-disk +// behavior: unexpected modules are worth surfacing). +func diffDIMMs(expected, actual []DIMMSpec) []model.SpecDiff { + if len(expected) == 0 { + return nil + } + actualBySlot := map[string]DIMMSpec{} + for _, d := range actual { + if d.Slot != "" { + actualBySlot[strings.ToLower(d.Slot)] = d + } + } + var out []model.SpecDiff + seen := map[string]bool{} + for _, exp := range expected { + if exp.Slot == "" { + continue + } + key := strings.ToLower(exp.Slot) + seen[key] = true + got, ok := actualBySlot[key] + if !ok { + out = append(out, diff("memory.modules["+exp.Slot+"].present", "true", "false")) + continue + } + if exp.Populated && !got.Populated { + out = append(out, diff("memory.modules["+exp.Slot+"].populated", "true", "false")) + continue + } + if exp.SizeGB > 0 && exp.SizeGB != got.SizeGB { + out = append(out, diff("memory.modules["+exp.Slot+"].size_gb", itoa(exp.SizeGB), itoa(got.SizeGB))) + } + if exp.SpeedMTS > 0 && exp.SpeedMTS != got.SpeedMTS { + out = append(out, diff("memory.modules["+exp.Slot+"].speed_mts", itoa(exp.SpeedMTS), itoa(got.SpeedMTS))) + } + if exp.Manufacturer != "" && !strings.EqualFold(exp.Manufacturer, got.Manufacturer) { + out = append(out, diff("memory.modules["+exp.Slot+"].manufacturer", exp.Manufacturer, got.Manufacturer)) + } + if exp.PartNumber != "" && !strings.EqualFold(strings.TrimSpace(exp.PartNumber), strings.TrimSpace(got.PartNumber)) { + out = append(out, diff("memory.modules["+exp.Slot+"].part_number", exp.PartNumber, got.PartNumber)) + } + } + for _, got := range actual { + if got.Slot == "" || !got.Populated { + continue + } + if !seen[strings.ToLower(got.Slot)] { + out = append(out, diff("memory.modules[unexpected "+got.Slot+"]", "", "populated")) + } + } + return out +} + +func diffSystem(expected *SystemSpec, actual SystemSpec) []model.SpecDiff { + if expected == nil { + return nil + } + var out []model.SpecDiff + if expected.Manufacturer != "" && !strings.EqualFold(expected.Manufacturer, actual.Manufacturer) { + out = append(out, diff("system.manufacturer", expected.Manufacturer, actual.Manufacturer)) + } + if expected.ProductName != "" && !strings.EqualFold(expected.ProductName, actual.ProductName) { + out = append(out, diff("system.product_name", expected.ProductName, actual.ProductName)) + } + if expected.SerialNumber != "" && expected.SerialNumber != actual.SerialNumber { + out = append(out, diff("system.serial_number", expected.SerialNumber, actual.SerialNumber)) + } + if expected.UUID != "" && !strings.EqualFold(expected.UUID, actual.UUID) { + out = append(out, diff("system.uuid", expected.UUID, actual.UUID)) + } + return out +} + +func diffBaseboard(expected *BaseboardSpec, actual BaseboardSpec) []model.SpecDiff { + if expected == nil { + return nil + } + var out []model.SpecDiff + if expected.Manufacturer != "" && !strings.EqualFold(expected.Manufacturer, actual.Manufacturer) { + out = append(out, diff("baseboard.manufacturer", expected.Manufacturer, actual.Manufacturer)) + } + if expected.ProductName != "" && !strings.EqualFold(expected.ProductName, actual.ProductName) { + out = append(out, diff("baseboard.product_name", expected.ProductName, actual.ProductName)) + } + if expected.SerialNumber != "" && expected.SerialNumber != actual.SerialNumber { + out = append(out, diff("baseboard.serial_number", expected.SerialNumber, actual.SerialNumber)) + } + return out +} + +// diffPSU keys expected entries by Slot (e.g. "PSU1"). Empty slot on +// expected skips — use slot-less PSU spec to mean "any slot". Currently +// keyed-only since PSU bays are single-occupant and Slot is the natural +// identifier dmidecode reports. +func diffPSU(expected []PowerSupplySpec, actual []PowerSupplySpec) []model.SpecDiff { + if len(expected) == 0 { + return nil + } + actualBySlot := map[string]PowerSupplySpec{} + for _, p := range actual { + if p.Slot != "" { + actualBySlot[strings.ToLower(p.Slot)] = p + } + } + var out []model.SpecDiff + for _, exp := range expected { + if exp.Slot == "" { + continue + } + got, ok := actualBySlot[strings.ToLower(exp.Slot)] + if !ok { + out = append(out, diff("psu["+exp.Slot+"].present", "true", "false")) + continue + } + if exp.Manufacturer != "" && !strings.EqualFold(exp.Manufacturer, got.Manufacturer) { + out = append(out, diff("psu["+exp.Slot+"].manufacturer", exp.Manufacturer, got.Manufacturer)) + } + if exp.Model != "" && !strings.EqualFold(exp.Model, got.Model) { + out = append(out, diff("psu["+exp.Slot+"].model", exp.Model, got.Model)) + } + if exp.MaxWatts > 0 && exp.MaxWatts != got.MaxWatts { + out = append(out, diff("psu["+exp.Slot+"].max_watts", itoa(exp.MaxWatts), itoa(got.MaxWatts))) + } + if exp.Status != "" && !strings.EqualFold(exp.Status, got.Status) { + out = append(out, diff("psu["+exp.Slot+"].status", exp.Status, got.Status)) + } + } + return out +} + +func diffOS(expected *OSSpec, actual OSSpec) []model.SpecDiff { + if expected == nil { + return nil + } + var out []model.SpecDiff + if expected.Kernel != "" && expected.Kernel != actual.Kernel { + out = append(out, diff("os.kernel", expected.Kernel, actual.Kernel)) + } + if expected.Distribution != "" && !strings.EqualFold(expected.Distribution, actual.Distribution) { + out = append(out, diff("os.distribution", expected.Distribution, actual.Distribution)) + } + if expected.Version != "" && expected.Version != actual.Version { + out = append(out, diff("os.version", expected.Version, actual.Version)) + } + return out +} + func diffDisks(expected, actual []DiskSpec) []model.SpecDiff { if len(expected) == 0 { return nil @@ -158,6 +406,23 @@ func diffDisks(expected, actual []DiskSpec) []model.SpecDiff { if exp.SizeGB > 0 && absInt(exp.SizeGB-got.SizeGB) > 1 { out = append(out, diff("disks["+exp.Serial+"].size_gb", itoa(exp.SizeGB), itoa(got.SizeGB))) } + if exp.Model != "" && !strings.EqualFold(exp.Model, got.Model) { + out = append(out, diff("disks["+exp.Serial+"].model", exp.Model, got.Model)) + } + if exp.Transport != "" && !strings.EqualFold(exp.Transport, got.Transport) { + out = append(out, diff("disks["+exp.Serial+"].transport", exp.Transport, got.Transport)) + } + // Rotational is a bool, so we can only diff it when the expected + // explicitly declares it; use the Transport-present check as a + // proxy for "the operator cares about this disk's shape" — if + // they pinned transport they probably also care about rotational. + // Otherwise a zero-value false would always fire on SSDs vs + // unexpected HDDs and drown the noise. Cleaner: diff rotational + // only when expected.Rotational is true (HDDs are the rare case + // we gate on explicitly). + if exp.Rotational && !got.Rotational { + out = append(out, diff("disks["+exp.Serial+"].rotational", "true", "false")) + } } // Extra disks on the host that operator didn't declare are flagged: // a leftover USB stick could be a destructive-test target we'd @@ -196,6 +461,12 @@ func diffNICs(expected, actual []NICSpec) []model.SpecDiff { if exp.SpeedGbps > 0 && got.SpeedGbps > 0 && exp.SpeedGbps != got.SpeedGbps { out = append(out, diff("nics["+exp.MAC+"].speed_gbps", itoa(exp.SpeedGbps), itoa(got.SpeedGbps))) } + if exp.Driver != "" && !strings.EqualFold(exp.Driver, got.Driver) { + out = append(out, diff("nics["+exp.MAC+"].driver", exp.Driver, got.Driver)) + } + if exp.PCIAddr != "" && !strings.EqualFold(exp.PCIAddr, got.PCIAddr) { + out = append(out, diff("nics["+exp.MAC+"].pci_addr", exp.PCIAddr, got.PCIAddr)) + } } return out } @@ -271,22 +542,74 @@ func diffGPUs(expected, actual []GPUSpec) []model.SpecDiff { if len(expected) == 0 { return nil } - // GPU matching is by model string. Multiple identical cards match - // by count, not identity, since PCI-slot order isn't meaningful. + // Two-pass match. First, pin any expected entry with PCIAddr set to + // its matching actual by address — these can be per-field diffed. + // Everything else falls back to the original count-by-model match. + actualByPCI := map[string]GPUSpec{} + for _, g := range actual { + if g.PCIAddr != "" { + actualByPCI[strings.ToLower(g.PCIAddr)] = g + } + } + var out []model.SpecDiff + var unpinned []GPUSpec + for _, exp := range expected { + if exp.PCIAddr == "" { + unpinned = append(unpinned, exp) + continue + } + got, ok := actualByPCI[strings.ToLower(exp.PCIAddr)] + if !ok { + out = append(out, diff("gpus["+exp.PCIAddr+"].present", "true", "false")) + continue + } + if exp.Model != "" && !strings.EqualFold(exp.Model, got.Model) { + out = append(out, diff("gpus["+exp.PCIAddr+"].model", exp.Model, got.Model)) + } + if exp.VRAMGiB > 0 && exp.VRAMGiB != got.VRAMGiB { + out = append(out, diff("gpus["+exp.PCIAddr+"].vram_gib", itoa(exp.VRAMGiB), itoa(got.VRAMGiB))) + } + if exp.Driver != "" && !strings.EqualFold(exp.Driver, got.Driver) { + out = append(out, diff("gpus["+exp.PCIAddr+"].driver", exp.Driver, got.Driver)) + } + } + // Unpinned expected entries: count-by-model against whichever actual + // cards weren't already pinned by PCIAddr. Multiple identical cards + // match by count since model-only equality doesn't identify which + // slot is which. + if len(unpinned) == 0 { + return out + } + pinnedModels := map[string]int{} + for _, exp := range expected { + if exp.PCIAddr != "" { + if got, ok := actualByPCI[strings.ToLower(exp.PCIAddr)]; ok { + pinnedModels[strings.ToLower(got.Model)]++ + } + } + } want := map[string]int{} - for _, g := range expected { + for _, g := range unpinned { want[strings.ToLower(g.Model)]++ } got := map[string]int{} for _, g := range actual { + if g.PCIAddr != "" { + if _, pinned := actualByPCI[strings.ToLower(g.PCIAddr)]; pinned { + continue + } + } got[strings.ToLower(g.Model)]++ } + // Back out pinned models from the pool so they're not double-counted. + for k, v := range pinnedModels { + got[k] -= v + } var keys []string for k := range want { keys = append(keys, k) } sort.Strings(keys) - var out []model.SpecDiff for _, k := range keys { if got[k] < want[k] { out = append(out, diff("gpus["+k+"].count", itoa(want[k]), itoa(got[k]))) diff --git a/internal/spec/spec_test.go b/internal/spec/spec_test.go index c97fb47..34fa65f 100644 --- a/internal/spec/spec_test.go +++ b/internal/spec/spec_test.go @@ -212,3 +212,164 @@ func TestDiffFirmwareCaseInsensitive(t *testing.T) { t.Fatalf("case-insensitive match expected, got %+v", d) } } + +func TestDiffDIMMMissingSlot(t *testing.T) { + exp := &Spec{Memory: &MemorySpec{Modules: []DIMMSpec{ + {Slot: "DIMM_A1", SizeGB: 16, PartNumber: "8ATF2G64AZ-3G2E1", Populated: true}, + {Slot: "DIMM_B1", SizeGB: 16, PartNumber: "8ATF2G64AZ-3G2E1", Populated: true}, + }}} + act := &Inventory{Memory: MemorySpec{Modules: []DIMMSpec{ + {Slot: "DIMM_A1", SizeGB: 16, PartNumber: "8ATF2G64AZ-3G2E1", Populated: true}, + }}} + d := Diff(exp, act) + got := map[string]bool{} + for _, row := range d { + got[row.Field] = true + } + if !got["memory.modules[DIMM_B1].present"] { + t.Fatalf("expected missing DIMM_B1; got %+v", d) + } +} + +func TestDiffDIMMPartNumberMismatch(t *testing.T) { + exp := &Spec{Memory: &MemorySpec{Modules: []DIMMSpec{ + {Slot: "DIMM_A1", PartNumber: "8ATF2G64AZ-3G2E1", Populated: true}, + }}} + act := &Inventory{Memory: MemorySpec{Modules: []DIMMSpec{ + {Slot: "DIMM_A1", PartNumber: "HMA82GR7AFR8N-UH", Populated: true}, + }}} + d := Diff(exp, act) + if len(d) != 1 || d[0].Field != "memory.modules[DIMM_A1].part_number" || d[0].Severity != "critical" { + t.Fatalf("expected part_number critical, got %+v", d) + } +} + +func TestDiffDIMMUnexpectedPopulated(t *testing.T) { + exp := &Spec{Memory: &MemorySpec{Modules: []DIMMSpec{ + {Slot: "DIMM_A1", Populated: true}, + }}} + act := &Inventory{Memory: MemorySpec{Modules: []DIMMSpec{ + {Slot: "DIMM_A1", Populated: true}, + {Slot: "DIMM_B1", Populated: true, SizeGB: 8}, + }}} + d := Diff(exp, act) + got := map[string]bool{} + for _, row := range d { + got[row.Field] = true + } + if !got["memory.modules[unexpected DIMM_B1]"] { + t.Fatalf("expected unexpected-DIMM_B1; got %+v", d) + } +} + +func TestDiffDIMMEmptySlotsIgnoredWhenNotExpected(t *testing.T) { + // Actual has two empty slots; expected only lists the populated one. + // Empty extras must not produce an unexpected-populated diff. + exp := &Spec{Memory: &MemorySpec{Modules: []DIMMSpec{ + {Slot: "DIMM_A1", Populated: true, SizeGB: 16}, + }}} + act := &Inventory{Memory: MemorySpec{Modules: []DIMMSpec{ + {Slot: "DIMM_A1", Populated: true, SizeGB: 16}, + {Slot: "DIMM_A2"}, + {Slot: "DIMM_B1"}, + }}} + if d := Diff(exp, act); len(d) != 0 { + t.Fatalf("empty extra slots must not diff; got %+v", d) + } +} + +func TestDiffDiskTransport(t *testing.T) { + exp := &Spec{Disks: []DiskSpec{ + {Serial: "A", SizeGB: 1000, Transport: "nvme", Model: "SN850X"}, + }} + act := &Inventory{Disks: []DiskSpec{ + {Serial: "A", SizeGB: 1000, Transport: "sata", Model: "860 EVO"}, + }} + d := Diff(exp, act) + got := map[string]bool{} + for _, r := range d { + got[r.Field] = true + } + if !got["disks[A].transport"] || !got["disks[A].model"] { + t.Fatalf("expected transport + model diffs; got %+v", d) + } +} + +func TestDiffSystem(t *testing.T) { + exp := &Spec{System: &SystemSpec{Manufacturer: "Beelink", ProductName: "Mini S12 Pro"}} + act := &Inventory{System: SystemSpec{Manufacturer: "Beelink", ProductName: "Mini S11"}} + d := Diff(exp, act) + if len(d) != 1 || d[0].Field != "system.product_name" { + t.Fatalf("expected system.product_name; got %+v", d) + } +} + +func TestDiffBaseboard(t *testing.T) { + exp := &Spec{Baseboard: &BaseboardSpec{SerialNumber: "BB-0001"}} + act := &Inventory{Baseboard: BaseboardSpec{SerialNumber: "BB-9999"}} + d := Diff(exp, act) + if len(d) != 1 || d[0].Field != "baseboard.serial_number" { + t.Fatalf("expected baseboard.serial_number; got %+v", d) + } +} + +func TestDiffPSUMissing(t *testing.T) { + exp := &Spec{PSU: []PowerSupplySpec{{Slot: "PSU1", MaxWatts: 760}, {Slot: "PSU2", MaxWatts: 760}}} + act := &Inventory{PSU: []PowerSupplySpec{{Slot: "PSU1", MaxWatts: 760}}} + d := Diff(exp, act) + got := map[string]bool{} + for _, r := range d { + got[r.Field] = true + } + if !got["psu[PSU2].present"] { + t.Fatalf("expected PSU2 missing; got %+v", d) + } +} + +func TestDiffOSKernel(t *testing.T) { + exp := &Spec{OS: &OSSpec{Kernel: "6.1.0-17-amd64"}} + act := &Inventory{OS: OSSpec{Kernel: "6.1.0-22-amd64"}} + d := Diff(exp, act) + if len(d) != 1 || d[0].Field != "os.kernel" { + t.Fatalf("expected os.kernel; got %+v", d) + } +} + +func TestDiffCPUExtendedFields(t *testing.T) { + exp := &Spec{CPU: &CPUSpec{ + PhysicalCores: 8, + Vendor: "GenuineIntel", + Flags: []string{"vmx", "aes"}, + }} + act := &Inventory{CPU: CPUSpec{ + PhysicalCores: 4, + Vendor: "AuthenticAMD", + Flags: []string{"aes"}, // missing vmx + }} + d := Diff(exp, act) + got := map[string]bool{} + for _, r := range d { + got[r.Field] = true + } + for _, want := range []string{"cpu.physical_cores", "cpu.vendor", "cpu.flags[vmx]"} { + if !got[want] { + t.Fatalf("expected %s diff; got %+v", want, d) + } + } +} + +func TestDiffGPUByPCIAddr(t *testing.T) { + exp := &Spec{GPUs: []GPUSpec{{PCIAddr: "0000:01:00.0", VRAMGiB: 8, Driver: "nvidia"}}} + act := &Inventory{GPUs: []GPUSpec{{PCIAddr: "0000:01:00.0", VRAMGiB: 4, Driver: "nouveau"}}} + d := Diff(exp, act) + got := map[string]bool{} + for _, r := range d { + got[r.Field] = true + } + for _, want := range []string{"gpus[0000:01:00.0].vram_gib", "gpus[0000:01:00.0].driver"} { + if !got[want] { + t.Fatalf("expected %s; got %+v", want, d) + } + } +} +