feat(inventory): deep hardware capture + per-probe substeps + verbose logs
CI / Lint + build + test (push) Successful in 1m35s
Release / release (push) Successful in 9m34s

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:
2026-04-19 22:21:17 -04:00
parent 481b67fb69
commit 8acef92a60
10 changed files with 1715 additions and 148 deletions
+9
View File
@@ -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)
+142
View File
@@ -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}}
+349 -26
View File
@@ -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])))
+161
View File
@@ -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)
}
}
}