feat(inventory): deep hardware capture + per-probe substeps + verbose logs
Extend Inventory stage from a one-liner summary to a per-probe substep emitter with ~20-30 narrative log lines per run. - spec: per-DIMM memory (slot/size/speed/manufacturer/part_number), richer CPU (vendor/stepping/physical_cores/flags), disk model/transport/rotational, NIC driver/pci_addr, GPU vram/pci/driver, new System/Baseboard/PSU/OS top-level sections. All fields omitempty so existing expected-spec YAML and artifacts stay compatible. - spec.Diff: new diffDIMMs/diffSystem/diffBaseboard/diffPSU/diffOS helpers; extended diffDisks/diffNICs/diffGPUs for new fields. GPU diff gains PCIAddr-pinned matching alongside count-by-model. - agent/probes/inventory: CPU (/proc/cpuinfo extended), Memory (dmidecode -t 17 multi-block), Disks (+model/transport/rotational), NICs (+driver/pci from sysfs), GPUs (VRAM from lspci -vv), new System/Baseboard (dmidecode -t system/baseboard), PSU (dmidecode -t 39), OS (/proc/sys/kernel/osrelease + /etc/os-release). All probes accept a Logger and emit per-finding info/warn lines. - agent/probes/firmware: parseDmidecodeAllSections for multi-block fixtures (memory / PSU). - agent/runner: Inventory case becomes 9 substep rows (CPU / Memory / Disks / NICs / GPUs / System / Baseboard / PSU / OS) with per-probe start/complete timestamps. - report: new Inventory HTML section between Stages and Firmware; resolveReporting loads the inventory.json artifact. - agent/tests/fakes/dmidecode: dispatches on -t flag to serve bios / memory / system / baseboard / 39 fixtures for unit tests. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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 —
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+601
-108
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----- 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/<name>'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 <addr>` 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 {
|
||||
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 != "" {
|
||||
gpus = append(gpus, spec.GPUSpec{Model: 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
|
||||
for _, r := range line {
|
||||
switch {
|
||||
case r == '"':
|
||||
inQ = !inQ
|
||||
if !inQ {
|
||||
flush := func() {
|
||||
if cur.Len() > 0 {
|
||||
out = append(out, cur.String())
|
||||
cur.Reset()
|
||||
}
|
||||
}
|
||||
for _, r := range line {
|
||||
switch {
|
||||
case r == '"':
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+38
-8
@@ -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),
|
||||
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:
|
||||
|
||||
@@ -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 <type>` 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`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = `<!doctype html>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{{if .Inventory}}
|
||||
<section>
|
||||
<h2>Inventory</h2>
|
||||
|
||||
{{with .Inventory.System}}{{if or .Manufacturer .ProductName .SerialNumber .UUID}}
|
||||
<h3>System</h3>
|
||||
<table>
|
||||
{{if .Manufacturer}}<tr><th>Manufacturer</th><td>{{.Manufacturer}}</td></tr>{{end}}
|
||||
{{if .ProductName}}<tr><th>Product</th><td>{{.ProductName}}</td></tr>{{end}}
|
||||
{{if .SerialNumber}}<tr><th>Serial</th><td><code>{{.SerialNumber}}</code></td></tr>{{end}}
|
||||
{{if .UUID}}<tr><th>UUID</th><td><code>{{.UUID}}</code></td></tr>{{end}}
|
||||
</table>
|
||||
{{end}}{{end}}
|
||||
|
||||
{{with .Inventory.Baseboard}}{{if or .Manufacturer .ProductName .SerialNumber}}
|
||||
<h3>Baseboard</h3>
|
||||
<table>
|
||||
{{if .Manufacturer}}<tr><th>Manufacturer</th><td>{{.Manufacturer}}</td></tr>{{end}}
|
||||
{{if .ProductName}}<tr><th>Product</th><td>{{.ProductName}}</td></tr>{{end}}
|
||||
{{if .SerialNumber}}<tr><th>Serial</th><td><code>{{.SerialNumber}}</code></td></tr>{{end}}
|
||||
</table>
|
||||
{{end}}{{end}}
|
||||
|
||||
{{with .Inventory.CPU}}{{if .Model}}
|
||||
<h3>CPU</h3>
|
||||
<table>
|
||||
<tr><th>Model</th><td>{{.Model}}</td></tr>
|
||||
{{if .Vendor}}<tr><th>Vendor</th><td>{{.Vendor}}</td></tr>{{end}}
|
||||
{{if .PhysicalCores}}<tr><th>Physical cores</th><td>{{.PhysicalCores}}</td></tr>{{end}}
|
||||
{{if .LogicalCores}}<tr><th>Logical cores</th><td>{{.LogicalCores}}</td></tr>{{end}}
|
||||
{{if .Stepping}}<tr><th>Stepping</th><td>{{.Stepping}}</td></tr>{{end}}
|
||||
{{if .Flags}}<tr><th>Flags</th><td>{{range $i, $f := .Flags}}{{if $i}}, {{end}}<code>{{$f}}</code>{{end}}</td></tr>{{end}}
|
||||
</table>
|
||||
{{end}}{{end}}
|
||||
|
||||
{{with .Inventory.Memory}}{{if or .TotalGiB .Modules}}
|
||||
<h3>Memory</h3>
|
||||
<table>
|
||||
<tr><th>Total</th><td>{{.TotalGiB}} GiB</td></tr>
|
||||
</table>
|
||||
{{if .Modules}}
|
||||
<table>
|
||||
<thead><tr><th>Slot</th><th>Size</th><th>Speed</th><th>Manufacturer</th><th>Part number</th><th>Populated</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Modules}}
|
||||
<tr>
|
||||
<td><code>{{.Slot}}</code></td>
|
||||
<td>{{if .SizeGB}}{{.SizeGB}} GiB{{else}}—{{end}}</td>
|
||||
<td>{{if .SpeedMTS}}{{.SpeedMTS}} MT/s{{else}}—{{end}}</td>
|
||||
<td>{{.Manufacturer}}</td>
|
||||
<td><code>{{.PartNumber}}</code></td>
|
||||
<td>{{if .Populated}}<span class="pass">yes</span>{{else}}<span class="skip">no</span>{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
{{end}}{{end}}
|
||||
|
||||
{{if .Inventory.Disks}}
|
||||
<h3>Disks</h3>
|
||||
<table>
|
||||
<thead><tr><th>Serial</th><th>Model</th><th>Size</th><th>Transport</th><th>Rotational</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Inventory.Disks}}
|
||||
<tr>
|
||||
<td><code>{{.Serial}}</code></td>
|
||||
<td>{{.Model}}</td>
|
||||
<td>{{.SizeGB}} GB</td>
|
||||
<td>{{.Transport}}</td>
|
||||
<td>{{if .Rotational}}yes{{else}}no{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
|
||||
{{if .Inventory.NICs}}
|
||||
<h3>NICs</h3>
|
||||
<table>
|
||||
<thead><tr><th>MAC</th><th>Speed</th><th>Driver</th><th>PCI</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Inventory.NICs}}
|
||||
<tr>
|
||||
<td><code>{{.MAC}}</code></td>
|
||||
<td>{{if .SpeedGbps}}{{.SpeedGbps}} Gbps{{else}}—{{end}}</td>
|
||||
<td>{{.Driver}}</td>
|
||||
<td><code>{{.PCIAddr}}</code></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
|
||||
{{if .Inventory.GPUs}}
|
||||
<h3>GPUs</h3>
|
||||
<table>
|
||||
<thead><tr><th>Model</th><th>VRAM</th><th>PCI</th><th>Driver</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Inventory.GPUs}}
|
||||
<tr>
|
||||
<td>{{.Model}}</td>
|
||||
<td>{{if .VRAMGiB}}{{.VRAMGiB}} GiB{{else}}—{{end}}</td>
|
||||
<td><code>{{.PCIAddr}}</code></td>
|
||||
<td>{{.Driver}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
|
||||
{{if .Inventory.PSU}}
|
||||
<h3>Power supplies</h3>
|
||||
<table>
|
||||
<thead><tr><th>Slot</th><th>Manufacturer</th><th>Model</th><th>Max watts</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Inventory.PSU}}
|
||||
<tr>
|
||||
<td>{{.Slot}}</td>
|
||||
<td>{{.Manufacturer}}</td>
|
||||
<td>{{.Model}}</td>
|
||||
<td>{{if .MaxWatts}}{{.MaxWatts}} W{{else}}—{{end}}</td>
|
||||
<td>{{.Status}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
|
||||
{{with .Inventory.OS}}{{if or .Kernel .Distribution .Version}}
|
||||
<h3>OS</h3>
|
||||
<table>
|
||||
{{if .Kernel}}<tr><th>Kernel</th><td><code>{{.Kernel}}</code></td></tr>{{end}}
|
||||
{{if .Distribution}}<tr><th>Distribution</th><td>{{.Distribution}}</td></tr>{{end}}
|
||||
{{if .Version}}<tr><th>Version</th><td>{{.Version}}</td></tr>{{end}}
|
||||
</table>
|
||||
{{end}}{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<section>
|
||||
<h2>Firmware ({{len .Firmware}})</h2>
|
||||
{{if .Firmware}}
|
||||
|
||||
+328
-5
@@ -27,6 +27,10 @@ type Spec struct {
|
||||
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.
|
||||
@@ -56,24 +60,86 @@ type FirmwareObserved struct {
|
||||
type CPUSpec struct {
|
||||
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"`
|
||||
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"`
|
||||
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"`
|
||||
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
|
||||
@@ -84,6 +150,10 @@ type Inventory struct {
|
||||
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 {
|
||||
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])))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user