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>
808 lines
23 KiB
Go
808 lines
23 KiB
Go
// Package probes collects hardware facts from a booted Linux system.
|
|
//
|
|
// 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"
|
|
"regexp"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"vetting/internal/spec"
|
|
)
|
|
|
|
// 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 = 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 --------------------------------------------------------------
|
|
|
|
// 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() }()
|
|
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 -----------------------------------------------------------
|
|
|
|
// 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 0, false
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
sc := bufio.NewScanner(f)
|
|
for sc.Scan() {
|
|
fields := strings.Fields(sc.Text())
|
|
if len(fields) >= 2 && fields[0] == "MemTotal:" {
|
|
if kb, err := strconv.ParseInt(fields[1], 10, 64); err == nil {
|
|
return int(kb / 1024 / 1024), true
|
|
}
|
|
}
|
|
}
|
|
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 ------------------------------------------------------------
|
|
|
|
// 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
|
|
for _, e := range entries {
|
|
name := e.Name()
|
|
if !isRealDisk(name) {
|
|
continue
|
|
}
|
|
base := filepath.Join("/sys/class/block", name)
|
|
size := diskSizeGB(base)
|
|
serial := diskSerial(name)
|
|
model := diskModel(name)
|
|
transport := diskTransport(name)
|
|
rotational := diskRotational(base)
|
|
if size == 0 && serial == "" && model == "" {
|
|
continue
|
|
}
|
|
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 {
|
|
if strings.HasPrefix(name, "loop") || strings.HasPrefix(name, "ram") ||
|
|
strings.HasPrefix(name, "zram") || strings.HasPrefix(name, "dm-") {
|
|
return false
|
|
}
|
|
partPath := filepath.Join("/sys/class/block", name, "partition")
|
|
if _, err := os.Stat(partPath); err == nil {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func diskSizeGB(base string) int {
|
|
b, err := os.ReadFile(filepath.Join(base, "size"))
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
sectors, err := strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return int(sectors * 512 / 1_000_000_000)
|
|
}
|
|
|
|
func diskSerial(name string) string {
|
|
for _, rel := range []string{
|
|
filepath.Join("/sys/block", name, "device", "serial"),
|
|
filepath.Join("/sys/block", name, "device", "vpd_pg80"),
|
|
filepath.Join("/sys/block", name, "serial"),
|
|
} {
|
|
if b, err := os.ReadFile(rel); err == nil {
|
|
s := sanitizeASCII(string(b))
|
|
if s != "" {
|
|
return s
|
|
}
|
|
}
|
|
}
|
|
cmd := exec.Command("udevadm", "info", "--query=property", "--name="+name)
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
for _, line := range strings.Split(string(out), "\n") {
|
|
if v, ok := strings.CutPrefix(line, "ID_SERIAL_SHORT="); ok {
|
|
return sanitizeASCII(v)
|
|
}
|
|
}
|
|
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 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
|
|
for _, e := range entries {
|
|
name := e.Name()
|
|
if name == "lo" {
|
|
continue
|
|
}
|
|
base := filepath.Join(root, name)
|
|
mac := readLine(filepath.Join(base, "address"))
|
|
if mac == "" || mac == "00:00:00:00:00:00" {
|
|
continue
|
|
}
|
|
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
|
|
}
|
|
}
|
|
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 -------------------------------------------------------------
|
|
|
|
// 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
|
|
for _, line := range strings.Split(string(out), "\n") {
|
|
low := strings.ToLower(line)
|
|
if !strings.Contains(low, "vga compatible controller") &&
|
|
!strings.Contains(low, "3d controller") {
|
|
continue
|
|
}
|
|
// With -D the first quoted field is the PCI address.
|
|
fields := splitQuoted(line)
|
|
if len(fields) < 3 {
|
|
continue
|
|
}
|
|
// -D -mm emits the address as the first non-quoted token.
|
|
// splitQuoted strips unquoted whitespace, so the address is
|
|
// indexed at 0 and the quoted class/vendor/device follow.
|
|
// On older lspci the first field is the slot in quotes instead;
|
|
// cover both: if fields[0] doesn't look like an address, strip
|
|
// it from the space-separated prefix.
|
|
addr := ""
|
|
if pciAddrRE.MatchString(fields[0]) {
|
|
addr = fields[0]
|
|
} else if i := strings.IndexByte(line, '"'); i > 0 {
|
|
prefix := strings.TrimSpace(line[:i])
|
|
if pciAddrRE.MatchString(prefix) {
|
|
addr = prefix
|
|
}
|
|
}
|
|
model := stripPCIID(fields[2])
|
|
if !pciAddrRE.MatchString(fields[0]) {
|
|
// With no -D support the device is at [1], model at [2]: same
|
|
// as old code path. Keep existing behavior.
|
|
model = stripPCIID(fields[2])
|
|
}
|
|
model = sanitizeASCII(model)
|
|
if model == "" {
|
|
continue
|
|
}
|
|
g := spec.GPUSpec{Model: model, PCIAddr: addr}
|
|
if addr != "" {
|
|
if vv, err := exec.Command("lspci", "-vv", "-s", addr).Output(); err == nil {
|
|
g.VRAMGiB, g.Driver = parseLspciVerbose(string(vv))
|
|
}
|
|
}
|
|
gpus = append(gpus, g)
|
|
log.info(fmt.Sprintf(" GPUs: %s pci=%s driver=%s vram=%dGiB",
|
|
model, nonEmpty(addr, "?"), nonEmpty(g.Driver, "?"), g.VRAMGiB))
|
|
}
|
|
return gpus
|
|
}
|
|
|
|
var pciAddrRE = regexp.MustCompile(`^[0-9a-fA-F]{4}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}\.[0-9a-fA-F]$`)
|
|
|
|
// parseLspciVerbose pulls the largest prefetchable memory region (VRAM)
|
|
// in GiB and the "Kernel driver in use" name from an lspci -vv block.
|
|
// The memory line looks like: "Memory at ... (64-bit, prefetchable) [size=8G]"
|
|
func parseLspciVerbose(s string) (vramGiB int, driver string) {
|
|
sizeRE := regexp.MustCompile(`prefetchable\) \[size=(\d+)([KMGT])`)
|
|
for _, line := range strings.Split(s, "\n") {
|
|
trim := strings.TrimSpace(line)
|
|
if v, ok := strings.CutPrefix(trim, "Kernel driver in use:"); ok {
|
|
driver = strings.TrimSpace(v)
|
|
continue
|
|
}
|
|
if m := sizeRE.FindStringSubmatch(line); m != nil {
|
|
n, _ := strconv.Atoi(m[1])
|
|
switch m[2] {
|
|
case "T":
|
|
n *= 1024
|
|
case "G":
|
|
// already GiB
|
|
case "M":
|
|
n /= 1024
|
|
case "K":
|
|
n /= (1024 * 1024)
|
|
}
|
|
if n > vramGiB {
|
|
vramGiB = n
|
|
}
|
|
}
|
|
}
|
|
return vramGiB, driver
|
|
}
|
|
|
|
// ----- System / Baseboard -----------------------------------------------
|
|
|
|
func System(log Logger) spec.SystemSpec {
|
|
if _, err := exec.LookPath("dmidecode"); err != nil {
|
|
log.warn(" System: dmidecode not installed; skipped")
|
|
return spec.SystemSpec{}
|
|
}
|
|
log.info(" System: dmidecode -t system")
|
|
out, err := exec.Command("dmidecode", "-t", "system").Output()
|
|
if err != nil {
|
|
log.warn(" System: dmidecode -t system failed: " + err.Error())
|
|
return spec.SystemSpec{}
|
|
}
|
|
kv := parseDmidecodeSection(strings.NewReader(string(out)), "System Information")
|
|
if kv == nil {
|
|
log.warn(" System: no System Information section in dmidecode output")
|
|
return spec.SystemSpec{}
|
|
}
|
|
s := spec.SystemSpec{
|
|
Manufacturer: cleanDmidecodeValue(kv["Manufacturer"]),
|
|
ProductName: cleanDmidecodeValue(kv["Product Name"]),
|
|
SerialNumber: cleanDmidecodeValue(kv["Serial Number"]),
|
|
UUID: cleanDmidecodeValue(kv["UUID"]),
|
|
}
|
|
log.info(fmt.Sprintf(" System: manufacturer=%s product=%q serial=%s uuid=%s",
|
|
nonEmpty(s.Manufacturer, "?"), s.ProductName,
|
|
nonEmpty(s.SerialNumber, "?"), nonEmpty(s.UUID, "?")))
|
|
return s
|
|
}
|
|
|
|
func Baseboard(log Logger) spec.BaseboardSpec {
|
|
if _, err := exec.LookPath("dmidecode"); err != nil {
|
|
log.warn(" Baseboard: dmidecode not installed; skipped")
|
|
return spec.BaseboardSpec{}
|
|
}
|
|
log.info(" Baseboard: dmidecode -t baseboard")
|
|
out, err := exec.Command("dmidecode", "-t", "baseboard").Output()
|
|
if err != nil {
|
|
log.warn(" Baseboard: dmidecode -t baseboard failed: " + err.Error())
|
|
return spec.BaseboardSpec{}
|
|
}
|
|
kv := parseDmidecodeSection(strings.NewReader(string(out)), "Base Board Information")
|
|
if kv == nil {
|
|
log.warn(" Baseboard: no Base Board Information section in dmidecode output")
|
|
return spec.BaseboardSpec{}
|
|
}
|
|
b := spec.BaseboardSpec{
|
|
Manufacturer: cleanDmidecodeValue(kv["Manufacturer"]),
|
|
ProductName: cleanDmidecodeValue(kv["Product Name"]),
|
|
SerialNumber: cleanDmidecodeValue(kv["Serial Number"]),
|
|
}
|
|
log.info(fmt.Sprintf(" Baseboard: manufacturer=%s product=%q serial=%s",
|
|
nonEmpty(b.Manufacturer, "?"), b.ProductName, nonEmpty(b.SerialNumber, "?")))
|
|
return b
|
|
}
|
|
|
|
// ----- PSU --------------------------------------------------------------
|
|
|
|
func PSU(log Logger) []spec.PowerSupplySpec {
|
|
if _, err := exec.LookPath("dmidecode"); err != nil {
|
|
log.warn(" PSU: dmidecode not installed; skipped")
|
|
return nil
|
|
}
|
|
log.info(" PSU: dmidecode -t 39")
|
|
out, err := exec.Command("dmidecode", "-t", "39").Output()
|
|
if err != nil {
|
|
log.warn(" PSU: dmidecode -t 39 failed: " + err.Error())
|
|
return nil
|
|
}
|
|
blocks := parseDmidecodeAllSections(strings.NewReader(string(out)), "System Power Supply")
|
|
if len(blocks) == 0 {
|
|
log.warn(" PSU: dmidecode -t 39 returned no data (mini-PC or SMBIOS lacks type 39)")
|
|
return nil
|
|
}
|
|
var out2 []spec.PowerSupplySpec
|
|
for _, kv := range blocks {
|
|
p := spec.PowerSupplySpec{
|
|
Slot: cleanDmidecodeValue(kv["Location"]),
|
|
Manufacturer: cleanDmidecodeValue(kv["Manufacturer"]),
|
|
Model: cleanDmidecodeValue(kv["Model Part Number"]),
|
|
Status: cleanDmidecodeValue(kv["Status"]),
|
|
}
|
|
if p.Model == "" {
|
|
p.Model = cleanDmidecodeValue(kv["Name"])
|
|
}
|
|
if watts := cleanDmidecodeValue(kv["Max Power Capacity"]); watts != "" {
|
|
// "760 W" or "760W"
|
|
clean := strings.TrimSuffix(strings.TrimSpace(watts), "W")
|
|
clean = strings.TrimSpace(clean)
|
|
if n, err := strconv.Atoi(strings.Fields(clean)[0]); err == nil {
|
|
p.MaxWatts = n
|
|
}
|
|
}
|
|
out2 = append(out2, p)
|
|
log.info(fmt.Sprintf(" PSU: slot=%s manufacturer=%s model=%q %dW status=%q",
|
|
nonEmpty(p.Slot, "?"), nonEmpty(p.Manufacturer, "?"), p.Model, p.MaxWatts, p.Status))
|
|
}
|
|
return out2
|
|
}
|
|
|
|
// ----- OS ---------------------------------------------------------------
|
|
|
|
func OS(log Logger) spec.OSSpec {
|
|
o := spec.OSSpec{}
|
|
// /proc/sys/kernel/osrelease is equivalent to `uname -r` and a plain
|
|
// file read — keeps the probe buildable on non-linux dev hosts
|
|
// without syscall.Uname gymnastics. Missing on non-linux; falls back
|
|
// to running `uname -r`.
|
|
if b, err := os.ReadFile("/proc/sys/kernel/osrelease"); err == nil {
|
|
o.Kernel = strings.TrimSpace(string(b))
|
|
} else if out, err := exec.Command("uname", "-r").Output(); err == nil {
|
|
o.Kernel = strings.TrimSpace(string(out))
|
|
}
|
|
if b, err := os.ReadFile("/etc/os-release"); err == nil {
|
|
o.Distribution, o.Version = parseOSRelease(string(b))
|
|
}
|
|
log.info(fmt.Sprintf(" OS: kernel=%s distro=%q version=%s",
|
|
nonEmpty(o.Kernel, "?"), o.Distribution, nonEmpty(o.Version, "?")))
|
|
return o
|
|
}
|
|
|
|
// parseOSRelease returns PRETTY_NAME (distribution) and VERSION_ID
|
|
// (version). Quotes are stripped per the spec.
|
|
func parseOSRelease(s string) (dist, version string) {
|
|
sc := bufio.NewScanner(strings.NewReader(s))
|
|
for sc.Scan() {
|
|
line := sc.Text()
|
|
k, v, ok := strings.Cut(line, "=")
|
|
if !ok {
|
|
continue
|
|
}
|
|
v = strings.Trim(strings.TrimSpace(v), `"`)
|
|
switch strings.TrimSpace(k) {
|
|
case "PRETTY_NAME":
|
|
dist = v
|
|
case "VERSION_ID":
|
|
version = v
|
|
}
|
|
}
|
|
return dist, version
|
|
}
|
|
|
|
// ----- shared -----------------------------------------------------------
|
|
|
|
func splitQuoted(line string) []string {
|
|
// Extended variant: splits space-separated tokens outside quotes AND
|
|
// preserves the content inside quotes as single tokens. Needed so
|
|
// `lspci -D -mm -nn` output (leading PCI address token outside any
|
|
// quotes) parses cleanly alongside the quoted class/vendor fields.
|
|
var out []string
|
|
var cur strings.Builder
|
|
inQ := false
|
|
flush := func() {
|
|
if cur.Len() > 0 {
|
|
out = append(out, cur.String())
|
|
cur.Reset()
|
|
}
|
|
}
|
|
for _, r := range line {
|
|
switch {
|
|
case r == '"':
|
|
if inQ {
|
|
out = append(out, cur.String())
|
|
cur.Reset()
|
|
}
|
|
inQ = !inQ
|
|
case r == ' ' && !inQ:
|
|
flush()
|
|
default:
|
|
cur.WriteRune(r)
|
|
}
|
|
}
|
|
flush()
|
|
return out
|
|
}
|
|
|
|
func readLine(path string) string {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(string(b))
|
|
}
|
|
|
|
func sanitizeASCII(s string) string {
|
|
var b strings.Builder
|
|
b.Grow(len(s))
|
|
for i := 0; i < len(s); i++ {
|
|
c := s[i]
|
|
if c >= 0x20 && c <= 0x7E {
|
|
b.WriteByte(c)
|
|
}
|
|
}
|
|
return strings.TrimSpace(b.String())
|
|
}
|
|
|
|
var pciIDTail = regexp.MustCompile(` *\[[0-9a-fA-F]{4}\]$`)
|
|
|
|
func stripPCIID(s string) string {
|
|
return pciIDTail.ReplaceAllString(s, "")
|
|
}
|
|
|