8acef92a60
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>
648 lines
24 KiB
Go
648 lines
24 KiB
Go
// Package spec owns the expected-vs-actual hardware diff for Vetting.
|
|
//
|
|
// The operator writes an expected spec YAML per host when registering.
|
|
// The agent submits an Inventory artifact after boot. Diff() compares
|
|
// them and emits per-field SpecDiff rows; the orchestrator fails the
|
|
// SpecValidate stage if any row is classified critical.
|
|
//
|
|
// Phase 3 rule (operator decision): every mismatch is critical. Missing
|
|
// expected fields skip that check entirely so partial specs stay useful
|
|
// instead of exploding.
|
|
package spec
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
|
|
"vetting/internal/model"
|
|
)
|
|
|
|
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"`
|
|
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.
|
|
// Component is one of bios|bmc|nic|hba|microcode|nvme_fw (matches the
|
|
// on-wire value from agent/probes.FirmwareSnapshot.Component). Identifier
|
|
// is optional — when empty the rule applies to every observed snapshot
|
|
// of that component (use for single-instance things like BIOS/microcode);
|
|
// when set it pins the check to a specific NIC port / NVMe controller /
|
|
// PCI address. Version is the literal string expected; comparison is
|
|
// exact after trimming whitespace.
|
|
type FirmwareSpec struct {
|
|
Component string `yaml:"component"`
|
|
Identifier string `yaml:"identifier,omitempty"`
|
|
Version string `yaml:"version"`
|
|
}
|
|
|
|
// FirmwareObserved is what the agent reported, in a spec-package-local
|
|
// shape so callers don't need to thread store types through the diff.
|
|
// The server converts store.FirmwareSnapshot → FirmwareObserved before
|
|
// calling DiffFirmware.
|
|
type FirmwareObserved struct {
|
|
Component string
|
|
Identifier string
|
|
Version string
|
|
}
|
|
|
|
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
|
|
// 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"`
|
|
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
|
|
// yields an empty diff — i.e. "no expectations" is a legal stance.
|
|
func Parse(src string) (*Spec, error) {
|
|
var s Spec
|
|
if err := yaml.Unmarshal([]byte(src), &s); err != nil {
|
|
return nil, fmt.Errorf("parse spec yaml: %w", err)
|
|
}
|
|
return &s, nil
|
|
}
|
|
|
|
// Diff returns the per-field differences with severity. Phase 3 rule:
|
|
// every present-expected-field-that-mismatches is critical. Missing
|
|
// expected fields are skipped (not info-logged) so the diff list stays
|
|
// focused on real problems.
|
|
func Diff(expected *Spec, actual *Inventory) []model.SpecDiff {
|
|
if expected == nil {
|
|
return nil
|
|
}
|
|
out := []model.SpecDiff{}
|
|
|
|
if expected.CPU != nil {
|
|
if expected.CPU.Model != "" {
|
|
if !cpuModelMatches(expected.CPU.Model, actual.CPU.Model) {
|
|
out = append(out, diff("cpu.model", expected.CPU.Model, actual.CPU.Model))
|
|
}
|
|
}
|
|
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 {
|
|
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
|
|
}
|
|
actualBySerial := map[string]DiskSpec{}
|
|
for _, d := range actual {
|
|
if d.Serial != "" {
|
|
actualBySerial[strings.ToLower(d.Serial)] = d
|
|
}
|
|
}
|
|
var out []model.SpecDiff
|
|
seen := map[string]bool{}
|
|
for _, exp := range expected {
|
|
if exp.Serial == "" {
|
|
continue
|
|
}
|
|
key := strings.ToLower(exp.Serial)
|
|
seen[key] = true
|
|
got, ok := actualBySerial[key]
|
|
if !ok {
|
|
out = append(out, diff("disks["+exp.Serial+"].present", "true", "false"))
|
|
continue
|
|
}
|
|
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
|
|
// rather the operator know about.
|
|
for _, got := range actual {
|
|
if got.Serial == "" {
|
|
continue
|
|
}
|
|
if !seen[strings.ToLower(got.Serial)] {
|
|
out = append(out, diff("disks[unexpected "+got.Serial+"]", "", "present"))
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func diffNICs(expected, actual []NICSpec) []model.SpecDiff {
|
|
if len(expected) == 0 {
|
|
return nil
|
|
}
|
|
actualByMAC := map[string]NICSpec{}
|
|
for _, n := range actual {
|
|
if n.MAC != "" {
|
|
actualByMAC[strings.ToLower(n.MAC)] = n
|
|
}
|
|
}
|
|
var out []model.SpecDiff
|
|
for _, exp := range expected {
|
|
if exp.MAC == "" {
|
|
continue
|
|
}
|
|
got, ok := actualByMAC[strings.ToLower(exp.MAC)]
|
|
if !ok {
|
|
out = append(out, diff("nics["+exp.MAC+"].present", "true", "false"))
|
|
continue
|
|
}
|
|
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
|
|
}
|
|
|
|
// DiffFirmware returns a SpecDiff per firmware expectation that doesn't
|
|
// find a matching observed snapshot. Matching rules:
|
|
// - An expected rule with Identifier set matches by (component, id);
|
|
// a missing observed snapshot yields a "present=false" diff.
|
|
// - An expected rule with Identifier empty applies to every observed
|
|
// snapshot of that component — useful for "all NICs must run fw
|
|
// 8.30" without listing each port. Zero observed snapshots of the
|
|
// component yields a single "present=false" diff, not N.
|
|
// - Version mismatch emits an exact-string expected→actual diff.
|
|
// Case is preserved (firmware versions are case-sensitive in practice).
|
|
func DiffFirmware(expected []FirmwareSpec, actual []FirmwareObserved) []model.SpecDiff {
|
|
if len(expected) == 0 {
|
|
return nil
|
|
}
|
|
byCompIdent := map[string]FirmwareObserved{}
|
|
byComp := map[string][]FirmwareObserved{}
|
|
for _, o := range actual {
|
|
byCompIdent[fwKey(o.Component, o.Identifier)] = o
|
|
byComp[o.Component] = append(byComp[o.Component], o)
|
|
}
|
|
var out []model.SpecDiff
|
|
for _, exp := range expected {
|
|
comp := strings.TrimSpace(exp.Component)
|
|
if comp == "" || strings.TrimSpace(exp.Version) == "" {
|
|
continue
|
|
}
|
|
label := "firmware[" + comp
|
|
if exp.Identifier != "" {
|
|
label += "/" + exp.Identifier
|
|
}
|
|
label += "]"
|
|
if exp.Identifier != "" {
|
|
got, ok := byCompIdent[fwKey(comp, exp.Identifier)]
|
|
if !ok {
|
|
out = append(out, diff(label+".present", "true", "false"))
|
|
continue
|
|
}
|
|
if !strings.EqualFold(strings.TrimSpace(got.Version), strings.TrimSpace(exp.Version)) {
|
|
out = append(out, diff(label+".version", exp.Version, got.Version))
|
|
}
|
|
continue
|
|
}
|
|
// No identifier: fan out across every observed snapshot of this
|
|
// component. Missing is one diff; a mismatching port/controller
|
|
// emits one diff per mismatch.
|
|
observed := byComp[comp]
|
|
if len(observed) == 0 {
|
|
out = append(out, diff(label+".present", "true", "false"))
|
|
continue
|
|
}
|
|
for _, got := range observed {
|
|
if !strings.EqualFold(strings.TrimSpace(got.Version), strings.TrimSpace(exp.Version)) {
|
|
slot := got.Identifier
|
|
if slot == "" {
|
|
slot = "*"
|
|
}
|
|
out = append(out, diff("firmware["+comp+"/"+slot+"].version", exp.Version, got.Version))
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func fwKey(component, identifier string) string {
|
|
return strings.ToLower(component) + "|" + strings.ToLower(identifier)
|
|
}
|
|
|
|
func diffGPUs(expected, actual []GPUSpec) []model.SpecDiff {
|
|
if len(expected) == 0 {
|
|
return nil
|
|
}
|
|
// 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 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)
|
|
for _, k := range keys {
|
|
if got[k] < want[k] {
|
|
out = append(out, diff("gpus["+k+"].count", itoa(want[k]), itoa(got[k])))
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// cpuModelMatches compares model strings case-insensitively and allows
|
|
// the operator to declare a substring (e.g. "E5-2680 v4") that matches
|
|
// the verbose kernel-reported string ("Intel(R) Xeon(R) CPU E5-2680 v4 @ 2.40GHz").
|
|
func cpuModelMatches(expected, actual string) bool {
|
|
e := strings.ToLower(strings.TrimSpace(expected))
|
|
a := strings.ToLower(strings.TrimSpace(actual))
|
|
return e == a || strings.Contains(a, e)
|
|
}
|
|
|
|
// In Phase 3 all diffs are critical. Later phases may tier them.
|
|
func diff(field, expected, actual string) model.SpecDiff {
|
|
return model.SpecDiff{
|
|
Field: field,
|
|
Expected: expected,
|
|
Actual: actual,
|
|
Severity: "critical",
|
|
}
|
|
}
|
|
|
|
func absInt(n int) int {
|
|
if n < 0 {
|
|
return -n
|
|
}
|
|
return n
|
|
}
|
|
|
|
func itoa(n int) string { return fmt.Sprintf("%d", n) }
|