// 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) }