diff --git a/.gitignore b/.gitignore index c1318db36f..960a04719c 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,7 @@ collector/fixtures/sys/ collector/fixtures/udev/ /vendor +.DS_Store +FIELDS.md +sf-node-exporter +examples/* diff --git a/Dockerfile.k8s b/Dockerfile.k8s new file mode 100644 index 0000000000..5f55b2b30e --- /dev/null +++ b/Dockerfile.k8s @@ -0,0 +1,34 @@ +ARG ARCH="amd64" +ARG OS="linux" + +FROM golang:1.25-bookworm AS builder +ARG ARCH +ARG OS +WORKDIR /src +ENV GOPROXY=https://goproxy.cn,direct +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=${OS} GOARCH=${ARCH} go build -ldflags="-s -w" -o /bin/node_exporter . + +FROM ubuntu:22.04 +ARG ARCH +ARG OS + +RUN apt-get update && apt-get install -y --no-install-recommends \ + pciutils \ + util-linux \ + dmidecode \ + iproute2 \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /bin/node_exporter /bin/node_exporter +COPY entrypoint.sh /bin/entrypoint.sh + +RUN printf '#!/bin/sh\nexec /host/root/usr/bin/nvidia-smi "$$@"\n' > /usr/local/bin/nvidia-smi \ + && chmod +x /usr/local/bin/nvidia-smi \ + && printf '#!/bin/sh\nexec /host/root/usr/local/sbin/npu-smi "$$@"\n' > /usr/local/bin/npu-smi \ + && chmod +x /usr/local/bin/npu-smi + +EXPOSE 9100 +ENTRYPOINT ["/bin/entrypoint.sh"] diff --git a/collector/asset/cmdb/cpu.go b/collector/asset/cmdb/cpu.go new file mode 100644 index 0000000000..4e0e0ab0a9 --- /dev/null +++ b/collector/asset/cmdb/cpu.go @@ -0,0 +1,105 @@ +package cmdb + +import ( + "sort" + "strconv" + + "github.com/prometheus/node_exporter/collector/asset/cmdb/model" + "github.com/shirou/gopsutil/v3/cpu" +) + +func CollectCPU() (*model.CPU, error) { + c := &model.CPU{} + + infos, _ := cpu.Info() + logical, _ := cpu.Counts(true) + physical, _ := cpu.Counts(false) + + if logical == 0 { + logical = len(infos) + } + + type socket struct { + coreIDs map[string]struct{} + threads int + hasCore bool + example cpu.InfoStat + } + socks := map[string]*socket{} + var order []string + for _, ci := range infos { + pid := ci.PhysicalID + if pid == "" { + pid = "0" + } + s, ok := socks[pid] + if !ok { + s = &socket{coreIDs: map[string]struct{}{}, example: ci} + socks[pid] = s + order = append(order, pid) + } + s.threads++ + if ci.CoreID != "" { + s.hasCore = true + s.coreIDs[ci.CoreID] = struct{}{} + } + } + + // 退化场景:每个逻辑线程都被报告为独立插槽(部分虚拟机给每个 vCPU + // 分配独立 physical id)。此时拓扑无意义,折叠为单路。 + if len(socks) > 1 && len(socks) == len(infos) { + var ex cpu.InfoStat + if len(infos) > 0 { + ex = infos[0] + } + socks = map[string]*socket{"0": {coreIDs: map[string]struct{}{}, example: ex}} + order = []string{"0"} + } + + sort.Slice(order, func(i, j int) bool { + a, _ := strconv.Atoi(order[i]) + b, _ := strconv.Atoi(order[j]) + return a < b + }) + + totalCores := 0 + c.Devices = make([]model.CPUDevice, 0, len(order)) + for _, pid := range order { + s := socks[pid] + cores := 0 + if s.hasCore { + cores = len(s.coreIDs) + } + totalCores += cores + c.Devices = append(c.Devices, model.CPUDevice{ + ModelName: s.example.ModelName, + VendorID: s.example.VendorID, + Cores: cores, + Threads: s.threads, + Mhz: s.example.Mhz, + CacheKB: int(s.example.CacheSize), + }) + } + + if totalCores > 0 { + // Topology recovered from core_id: per-socket devices are already + // built above. No machine-level aggregates are stored. + } else { + // 无法从 cpuinfo 获取 core_id 拓扑(虚拟机/容器常见): + // 报告单路,核数取物理核数,缺失时退化为逻辑核数。 + cores := physical + if cores == 0 { + cores = logical + } + dev := model.CPUDevice{Cores: cores, Threads: logical} + if len(infos) > 0 { + dev.ModelName = infos[0].ModelName + dev.VendorID = infos[0].VendorID + dev.Mhz = infos[0].Mhz + dev.CacheKB = int(infos[0].CacheSize) + } + c.Devices = append(c.Devices[:0], dev) + } + + return c, nil +} diff --git a/collector/asset/cmdb/disk.go b/collector/asset/cmdb/disk.go new file mode 100644 index 0000000000..87fc947526 --- /dev/null +++ b/collector/asset/cmdb/disk.go @@ -0,0 +1,84 @@ +package cmdb + +import ( + "encoding/json" + "strconv" + "strings" + + "github.com/prometheus/node_exporter/collector/asset/cmdb/model" +) + +type lsblkDevice struct { + Name string `json:"name"` + Model string `json:"model"` + Vendor string `json:"vendor"` + Serial string `json:"serial"` + Size flexUint `json:"size"` + Type string `json:"type"` + Children []lsblkDevice `json:"children,omitempty"` +} + +type flexUint uint64 + +func (f *flexUint) UnmarshalJSON(b []byte) error { + s := strings.Trim(string(b), `"`) + if s == "" || s == "null" { + return nil + } + v, err := strconv.ParseUint(s, 10, 64) + if err != nil { + return err + } + *f = flexUint(v) + return nil +} + +type lsblkOutput struct { + BlockDevices []lsblkDevice `json:"blockdevices"` +} + +func CollectDisk() (*model.Disk, error) { + d := &model.Disk{Devices: []model.DiskDevice{}} + + collectLSBLK(d) + + return d, nil +} + +func collectLSBLK(d *model.Disk) { + if !commandExists("lsblk") { + return + } + + out, err := runCmd("lsblk", "-b", "-J", + "-o", "NAME,MODEL,VENDOR,SERIAL,SIZE,TYPE") + if err != nil { + return + } + + var parsed lsblkOutput + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + return + } + + for _, dev := range parsed.BlockDevices { + walkLSBLK(dev, d) + } +} + +func walkLSBLK(dev lsblkDevice, d *model.Disk) { + if uint64(dev.Size) == 0 { + return + } + if dev.Type != "disk" { + return + } + d.Devices = append(d.Devices, model.DiskDevice{ + Name: dev.Name, + Type: dev.Type, + Model: dev.Model, + Vendor: dev.Vendor, + Serial: dev.Serial, + SizeBytes: uint64(dev.Size), + }) +} diff --git a/collector/asset/cmdb/gpu.go b/collector/asset/cmdb/gpu.go new file mode 100644 index 0000000000..7855f3151a --- /dev/null +++ b/collector/asset/cmdb/gpu.go @@ -0,0 +1,609 @@ +package cmdb + +import ( + "encoding/json" + "regexp" + "strconv" + "strings" + + "github.com/prometheus/node_exporter/collector/asset/cmdb/model" +) + +// gpuVendorCollectors maps a canonical vendor name (as produced by +// identifyLspciVendor) to the specialized smi-based collector for that +// vendor. Adding a new GPU vendor to the fleet = add one entry here plus a +// collectXxx function; CollectGPU / probeGpuVendor need no changes. +var gpuVendorCollectors = map[string]func(*model.GPU) bool{ + "nvidia": collectNVIDIA, + "huawei": collectHuaweiNPU, + "mthreads": collectMThreads, +} + +// CollectGPU enumerates the host's GPUs in two stages: +// 1. Run lspci to detect the GPU vendor from PCI display-class (0x03) and +// processing-accelerator-class (0x12) devices. +// 2. Dispatch the vendor's specialized smi tool (nvidia-smi / npu-smi) to +// fetch identity + runtime fields. Only if the smi tool reports nothing +// (cards passed through to guests, driver broken) does the collector fall +// back to the lspci enumeration (PCI identity only, no runtime metrics). +// +// Vendors without a registered collector (Intel iGPU, AMD, etc.) are dropped. +// The fleet is assumed single-vendor per host, so the first recognized vendor +// drives routing. +func CollectGPU() (*model.GPU, error) { + return collectGPUCore(runLspciOrEmpty(), gpuVendorCollectors), nil +} + +// collectGPUCore is the testable core of CollectGPU (no shell-out). lspciOut is +// the output of `lspci -Dnn`; collectors maps vendor→smi collector. +func collectGPUCore(lspciOut string, collectors map[string]func(*model.GPU) bool) *model.GPU { + g := &model.GPU{Devices: []model.GPUDevice{}} + lspciDevs := parseLspciGPU(lspciOut) + vendor := probeGpuVendor(lspciDevs, collectors) + fn, ok := collectors[vendor] + if !ok { + // Unknown vendor or no GPU/accelerator-class device: nothing to collect. + return g + } + if fn(g) { + // smi succeeded with ≥1 card: use its richer output (runtime + identity). + return g + } + // smi empty (passthrough / driver broken): fall back to lspci enumeration + // of the detected vendor's cards. PCI identity only, no runtime fields. + g.Devices = filterDevs(lspciDevs, vendor) + return g +} + +// runLspciOrEmpty runs `lspci -Dnn` and returns its stdout, or "" on any error +// (lspci not installed, non-zero exit). Returns "" rather than propagating the +// error so CollectGPU degrades gracefully to an empty GPU set. +// +// -D: always print the PCI domain (so bus IDs are unique across hosts). +// -nn: print both textual names and numeric vendor:device IDs — needed to +// +// identify brand-new SKUs whose PCI ID isn't in pci.ids yet (e.g. the +// 0x2b85 RTX 5090 shows up as "Device 2b85" without it). +func runLspciOrEmpty() string { + if !commandExists("lspci") { + return "" + } + out, err := runCmd("lspci", "-Dnn") + if err != nil { + return "" + } + return out +} + +// probeGpuVendor returns the canonical vendor of the first display-class PCI +// device that has a registered collector, or "" if none match. Used to route +// CollectGPU to the right smi tool. +func probeGpuVendor(devs []model.GPUDevice, collectors map[string]func(*model.GPU) bool) string { + for _, d := range devs { + if _, ok := collectors[d.Vendor]; ok { + return d.Vendor + } + } + return "" +} + +// filterDevs returns the subset of devs belonging to vendor, with Index +// renumbered to a contiguous 0..N-1 so downstream consumers (the store's +// change-diff, which keys GPU devices by Index) see a stable sequence. +func filterDevs(devs []model.GPUDevice, vendor string) []model.GPUDevice { + out := make([]model.GPUDevice, 0, len(devs)) + for _, d := range devs { + if d.Vendor == vendor { + out = append(out, d) + } + } + for i := range out { + out[i].Index = i + } + return out +} + +func collectNVIDIA(g *model.GPU) bool { + if !commandExists("nvidia-smi") { + return false + } + out, err := runCmd("nvidia-smi", + "--query-gpu=index,name,uuid,serial,vbios_version,memory.total,memory.used,memory.free,utilization.gpu,temperature.gpu,power.draw,driver_version", + "--format=csv,noheader,nounits") + if err != nil { + return false + } + devs := parseNVIDIA(out) + if len(devs) == 0 { + return false + } + g.Devices = append(g.Devices, devs...) + return true +} + +func parseNVIDIA(out string) []model.GPUDevice { + var devices []model.GPUDevice + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + fields := strings.Split(line, ",") + if len(fields) < 12 { + continue + } + for i := range fields { + fields[i] = strings.TrimSpace(fields[i]) + } + devices = append(devices, model.GPUDevice{ + Index: atoiSafe(fields[0]), + Vendor: "nvidia", + Name: fields[1], + UUID: fields[2], + Serial: fields[3], + FirmwareVersion: fields[4], + MemoryTotalMB: atouSafe(fields[5]), + MemoryUsedMB: atouSafe(fields[6]), + MemoryFreeMB: atouSafe(fields[7]), + Utilization: atofSafe(fields[8]), + Temperature: atofSafe(fields[9]), + PowerW: atofSafe(fields[10]), + DriverVersion: fields[11], + Health: "OK", + RuntimeMetrics: true, + }) + } + return devices +} + +var ( + npuVerRe = regexp.MustCompile(`Version:\s*(\S+)`) + npuMemRe = regexp.MustCompile(`(\d+)\s*/\s*(\d+)`) + npuBusIDRe = regexp.MustCompile(`^[0-9A-Fa-f]{4}:[0-9A-Fa-f]{2}:\d{2}\.\d`) + npuSerialRe = regexp.MustCompile(`(?im)^\s*Serial\s*Number\s*:\s*(\S+)`) + npuFWRe = regexp.MustCompile(`(?im)^\s*Firmware\s*Version\s*:\s*(\S+)`) + pciIDRe = regexp.MustCompile(`\[([0-9A-Fa-f]{4}:[0-9A-Fa-f]{4})\]`) +) + +func collectHuaweiNPU(g *model.GPU) bool { + if !commandExists("npu-smi") { + return false + } + out, err := runCmd("npu-smi", "info") + if err != nil { + return false + } + devices := parseHuaweiNPU(out) + for i := range devices { + boardOut, err := runCmd("npu-smi", "info", "-t", "board", "-i", strconv.Itoa(devices[i].Index)) + if err != nil { + continue + } + if m := npuSerialRe.FindStringSubmatch(boardOut); m != nil { + devices[i].Serial = strings.TrimSpace(m[1]) + } + if m := npuFWRe.FindStringSubmatch(boardOut); m != nil { + devices[i].FirmwareVersion = strings.TrimSpace(m[1]) + } + } + if len(devices) == 0 { + return false + } + g.Devices = append(g.Devices, devices...) + return true +} + +func parseHuaweiNPU(out string) []model.GPUDevice { + var driverVer string + if m := npuVerRe.FindStringSubmatch(out); m != nil { + driverVer = m[1] + } + + var devices []model.GPUDevice + var cur *model.GPUDevice + // afterSep is true after a "+---+ / +===+" separator: only the first + // data line following one is a candidate card header. A single NPU card + // may span several separator-bounded blocks — e.g. 310P3 repeats the + // card header once per chip, with "+---+" between chips. We therefore do + // NOT flush on every separator; we keep merging into the current card as + // long as the NPU index (first column) is unchanged, and only start a + // new card when the NPU index changes. + afterSep := false + flush := func() { + if cur != nil { + cur.RuntimeMetrics = true + devices = append(devices, *cur) + cur = nil + } + } + + for _, line := range strings.Split(out, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + // The trailing "Process info" section lists one row per running + // process: "| NPU Chip | Process id | Process name | Process + // memory |". Those rows have the same shape as card headers + // (numeric NPU idx + chip in col1, numeric Process id in the + // Health column), so without an explicit guard the state machine + // mints one phantom device per running process whose Name is the + // chip number and Health is the process id. Stop as soon as we + // cross into that section, detected by its column header. + if strings.Contains(line, "Process id") { + break + } + if strings.HasPrefix(trimmed, "+") { + afterSep = true + continue + } + if !strings.Contains(line, "|") { + continue + } + cols := splitPipe(line) + if len(cols) < 3 { + continue + } + col1 := strings.Fields(cols[0]) + if len(col1) == 0 { + continue + } + npuIdx, err := strconv.Atoi(col1[0]) + if err != nil { + continue + } + + isHeader := afterSep && len(col1) >= 2 + afterSep = false + + if isHeader { + if cur != nil && npuIdx == cur.Index { + // Another block of the same NPU (e.g. a per-chip header on + // 310P3). Keep merging into the existing card; name/health/ + // power/temp come from the first header and are not reset. + continue + } + flush() + cur = &model.GPUDevice{ + Index: npuIdx, + Vendor: "huawei", + Name: col1[1], + Health: strings.TrimSpace(cols[1]), + DriverVersion: driverVer, + } + col3 := strings.Fields(cols[2]) + if len(col3) >= 1 { + cur.PowerW = atofSafe(col3[0]) + } + if len(col3) >= 2 { + cur.Temperature = atofSafe(col3[1]) + } + continue + } + + // Non-header line within the current card: only chip detail rows + // whose middle column is a Bus-Id carry useful metrics. Extra rows + // (alarm-event rows, chip-id rows, etc.) are ignored so their + // placeholder "0 / 0" values won't clobber real metrics. Memory is + // summed across chips so a multi-chip card reports its aggregate. + if cur == nil { + continue + } + busField := strings.TrimSpace(cols[1]) + if !npuBusIDRe.MatchString(busField) { + continue + } + cur.UUID = busField + col3Fields := strings.Fields(cols[2]) + if len(col3Fields) > 0 { + cur.Utilization = atofSafe(col3Fields[0]) + } + matches := npuMemRe.FindAllStringSubmatch(cols[2], -1) + if len(matches) > 0 { + last := matches[len(matches)-1] + cur.MemoryUsedMB += atouSafe(last[1]) + cur.MemoryTotalMB += atouSafe(last[2]) + } + } + flush() + for i := range devices { + if devices[i].MemoryTotalMB > devices[i].MemoryUsedMB { + devices[i].MemoryFreeMB = devices[i].MemoryTotalMB - devices[i].MemoryUsedMB + } + } + return devices +} + +// collectMThreads enumerates Moore Threads GPUs via `mthreads-gmi --query --json`. +// Returns false (→ lspci fallback) when the tool is absent, fails, or reports no +// cards (passthrough / driver broken). The JSON output is richer than lspci +// (driver version, MTBios, memory, utilization, temperature, power), so on +// success its devices supersede the lspci enumeration. +func collectMThreads(g *model.GPU) bool { + if !commandExists("mthreads-gmi") { + return false + } + out, err := runCmd("mthreads-gmi", "--query", "--json") + if err != nil { + return false + } + devs := parseMThreads(out) + if len(devs) == 0 { + return false + } + g.Devices = append(g.Devices, devs...) + return true +} + +// mthreadsGMI is the top-level shape of `mthreads-gmi --query --json`. +type mthreadsGMI struct { + DriverVersion string `json:"Driver Version"` + GPUs []mthreadsGPU `json:"GPU"` +} + +// mthreadsGPU is one entry of the "GPU" array. PowerReadings is decoded into a +// map rather than a struct because mthreads-gmi emits the power-draw field key +// with a trailing space ("Power Draw "), which is fragile to match verbatim; +// mthreadsMapTrimmed resolves the lookup by trimmed-equals comparison. +type mthreadsGPU struct { + Index string `json:"Index"` + ProductName string `json:"Product Name"` + GPUUUID string `json:"GPU UUID"` + SerialNumber string `json:"Serial Number"` + MTBiosVersion string `json:"MTBios Version"` + FBMemoryUsage mthreadsMem `json:"FB Memory Usage"` + Utilization mthreadsUtil `json:"Utilization"` + Temperature mthreadsTemp `json:"Temperature"` + PowerReadings map[string]string `json:"Power Readings"` +} + +type mthreadsMem struct { + Total string `json:"Total"` + Used string `json:"Used"` + Free string `json:"Free"` +} + +type mthreadsUtil struct { + Gpu string `json:"Gpu"` + Memory string `json:"Memory"` +} + +type mthreadsTemp struct { + CurrentTemp string `json:"GPU Current Temp"` +} + +// parseMThreads decodes `mthreads-gmi --query --json` output into GPUDevice +// records. Every known static + runtime field is populated and RuntimeMetrics +// is set true (the smi run succeeded), mirroring the NVIDIA/Huawei collectors. +// On any decode error it returns nil so the caller falls back to lspci. +func parseMThreads(out string) []model.GPUDevice { + var doc mthreadsGMI + if err := json.Unmarshal([]byte(out), &doc); err != nil { + return nil + } + devs := make([]model.GPUDevice, 0, len(doc.GPUs)) + for _, g := range doc.GPUs { + powerDraw := mthreadsMapTrimmed(g.PowerReadings, "Power Draw") + mem := g.FBMemoryUsage + devs = append(devs, model.GPUDevice{ + Index: atoiSafe(g.Index), + Vendor: "mthreads", + Name: g.ProductName, + UUID: g.GPUUUID, + Serial: g.SerialNumber, + DriverVersion: doc.DriverVersion, + FirmwareVersion: g.MTBiosVersion, + MemoryTotalMB: atouSafe(stripMThreadsUnit(mem.Total, "MiB")), + MemoryUsedMB: atouSafe(stripMThreadsUnit(mem.Used, "MiB")), + MemoryFreeMB: atouSafe(stripMThreadsUnit(mem.Free, "MiB")), + Utilization: atofSafe(stripMThreadsUnit(g.Utilization.Gpu, "%")), + Temperature: atofSafe(stripMThreadsUnit(g.Temperature.CurrentTemp, "C")), + PowerW: atofSafe(stripMThreadsUnit(powerDraw, "W")), + Health: "OK", + RuntimeMetrics: true, + }) + } + return devs +} + +// mthreadsMapTrimmed looks up key in m, comparing after trimming whitespace on +// both sides. Used for mthreads-gmi JSON keys that carry irregular whitespace +// (e.g. "Power Draw " has a trailing space) so the caller never has to match +// the exact spacing. +func mthreadsMapTrimmed(m map[string]string, key string) string { + key = strings.TrimSpace(key) + for k, v := range m { + if strings.TrimSpace(k) == key { + return v + } + } + return "" +} + +// stripMThreadsUnit removes a trailing unit suffix (e.g. "MiB", "%", "C", "W") +// from an mthreads-gmi value string and returns the bare number. Values without +// the suffix (e.g. "N/A") survive unchanged so atoi/atof safely yield 0. +func stripMThreadsUnit(s, suffix string) string { + s = strings.TrimSpace(s) + return strings.TrimSpace(strings.TrimSuffix(s, suffix)) +} + +// parseLspciGPU parses `lspci -Dnn` output and returns one GPUDevice per PCI +// display-class (VGA / 3D / Display / XGA controller) or processing-accelerator +// ("Processing accelerators") function, for ANY vendor — not just NVIDIA. The +// audio subfunction paired with most consumer GPUs is skipped so each card is +// counted once via its display function. Huawei Ascend NPUs expose the +// "Processing accelerators" class (0x12) rather than a display class, which is +// why both classes are matched here. +// +// Example input lines: +// +// 0000:16:00.0 VGA compatible controller [0300]: NVIDIA Corporation Device 2b85 [10de:2b85] (rev ff) +// 00:02.0 VGA compatible controller [0300]: Intel Corporation CoffeeLake-S GT2 [UHD Graphics 630] [8086:3e98] (rev 02) +// 0000:43:00.0 3D controller [0302]: NVIDIA Corporation GA100 [A100 SXM4 40GB] [10de:20b5] (rev a1) +// 0000:18:00.0 Processing accelerators [1200]: Huawei Technologies Co., Ltd. Device d802 [19e5:d802] (rev 20) +// +// Only PCI-level fields are populated: memory/utilization/temperature/power/ +// driver/firmware require a host-bound driver and are left empty. Vendor is +// derived from the numeric PCI vendor:device ID when available (preferred, +// requires `lspci -nn`), with a text-based fallback for plain `lspci` output. +func parseLspciGPU(out string) []model.GPUDevice { + var devices []model.GPUDevice + idx := 0 + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if !isGpuOrAccelerator(line) { + continue + } + + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + bus := fields[0] + // lspci without -D omits the domain; normalize to canonical PCI BDF + // (dom:bus:dev.fn) to align with nvidia-smi / npu-smi bus IDs. + if strings.Count(bus, ":") == 1 { + bus = "0000:" + bus + } + + name, pciID := parseLspciDeviceDesc(line) + devices = append(devices, model.GPUDevice{ + Index: idx, + Vendor: identifyLspciVendor(pciID, name), + Name: name, + UUID: bus, + Health: "Unknown", + Serial: pciID, + }) + idx++ + } + return devices +} + +// isGpuOrAccelerator reports whether an lspci line describes a PCI device of +// interest to GPU collection: display controllers (base class 0x03) or +// processing accelerators (base class 0x12). +// +// Display subclasses (0x03): "VGA compatible controller" (0x0300, used by +// consumer/RTX cards and the ASPEED BMC VGA), "XGA compatible controller" +// (0x0301), "3D controller" (0x0302, compute-only cards like A100/H100), +// "Display controller" (0x0380). +// +// Processing accelerators (0x12): "Processing accelerators" — the class Huawei +// Ascend NPUs (910B2C et al.) expose; they are NOT display controllers, so +// without this branch the huawei routing path would silently drop every NPU +// host. +func isGpuOrAccelerator(line string) bool { + return strings.Contains(line, "VGA compatible controller") || + strings.Contains(line, "XGA compatible controller") || + strings.Contains(line, "3D controller") || + strings.Contains(line, "Display controller") || + strings.Contains(line, "Processing accelerators") +} + +// pciVendorMap maps well-known PCI vendor IDs (lowercase 4-digit hex) to the +// canonical lowercase vendor name used by the collector. Source: +// https://pci-ids.ucw.cz/ — extend as new vendors appear in the fleet. +var pciVendorMap = map[string]string{ + "10de": "nvidia", // NVIDIA Corporation + "1002": "amd", // Advanced Micro Devices, Inc. + "8086": "intel", // Intel Corporation + "19e5": "huawei", // Huawei Technologies Co., Ltd. + "1ed5": "mthreads", // Moore Threads Technology Co.,Ltd +} + +// identifyLspciVendor resolves the canonical vendor name from the numeric PCI +// vendor:device ID (preferred, available with `lspci -nn`) and falls back to +// substring matching on the textual description (plain `lspci` output). When +// neither yields a known vendor the lowercase 4-digit PCI vendor ID is +// returned (e.g. "1cee") so the device stays identifiable downstream; if even +// that is unavailable it returns "unknown". +func identifyLspciVendor(pciID, name string) string { + if len(pciID) >= 4 { + if canonical, ok := pciVendorMap[strings.ToLower(pciID[:4])]; ok { + return canonical + } + } + lower := strings.ToLower(name) + switch { + case strings.Contains(lower, "nvidia"): + return "nvidia" + case strings.Contains(lower, "huawei"), strings.Contains(lower, "ascend"): + return "huawei" + case strings.Contains(lower, "advanced micro devices"), strings.Contains(lower, "amd"): + return "amd" + case strings.Contains(lower, "intel"): + return "intel" + case strings.Contains(lower, "moore threads"): + return "mthreads" + } + if len(pciID) >= 4 { + return strings.ToLower(pciID[:4]) + } + return "unknown" +} + +// parseLspciDeviceDesc extracts the textual device description and the numeric +// PCI vendor:device ID from a single lspci line. The line may carry numeric +// IDs from -nn (preferred) or be plain lspci output. +func parseLspciDeviceDesc(line string) (name, pciID string) { + // Strip the leading " [classcode]: " (with -nn) or + // " : " (plain lspci) prefix to get the vendor + device text. + rest := line + if i := strings.Index(rest, "]: "); i >= 0 { + rest = rest[i+3:] + } else if i := strings.Index(rest, ": "); i >= 0 { + rest = rest[i+2:] + } else { + return "", "" + } + // The vendor:device bracket (e.g. "[10de:2b85]") is the LAST "[hhhh:hhhh]" + // on the line: the class-code bracket was stripped above together with + // the class name. Cut it (and anything after, such as "(rev X)") off the + // name and capture the PCI ID. + if i := strings.LastIndex(rest, " ["); i >= 0 { + tail := rest[i+1:] + rest = rest[:i] + if m := pciIDRe.FindStringSubmatch(tail); m != nil { + pciID = m[1] + } + } + // A trailing "(rev X)" can survive in plain lspci output (no -nn) or when + // the vendor:device bracket is absent because lspci doesn't know the IDs. + if i := strings.LastIndex(rest, "(rev"); i >= 0 { + rest = rest[:i] + } + name = strings.TrimSpace(rest) + return name, pciID +} + +func splitPipe(line string) []string { + parts := strings.Split(line, "|") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} + +func atoiSafe(s string) int { + v, _ := strconv.Atoi(strings.TrimSpace(s)) + return v +} + +func atouSafe(s string) uint64 { + v, _ := strconv.ParseUint(strings.TrimSpace(s), 10, 64) + return v +} + +func atofSafe(s string) float64 { + v, _ := strconv.ParseFloat(strings.TrimSpace(s), 64) + return v +} diff --git a/collector/asset/cmdb/gpu_test.go b/collector/asset/cmdb/gpu_test.go new file mode 100644 index 0000000000..8bcd066e9e --- /dev/null +++ b/collector/asset/cmdb/gpu_test.go @@ -0,0 +1,1329 @@ +package cmdb + +import ( + "reflect" + "strings" + "testing" + + "github.com/prometheus/node_exporter/collector/asset/cmdb/model" +) + +const npuSMISample = `+------------------------------------------------------------------------------------------------+ +| npu-smi 25.5.2 Version: 25.5.2 | ++---------------------------+---------------+----------------------------------------------------+ +| NPU Name | Health | Power(W) Temp(C) Hugepages-Usage(page)| +| Chip | Bus-Id | AICore(%) Memory-Usage(MB) HBM-Usage(MB) | ++===========================+===============+====================================================+ +| 0 910B2C | OK | 94.4 42 0 / 0 | +| 0 | 0000:66:00.0 | 0 0 / 0 3412 / 65536 | ++===========================+===============+====================================================+ +| 1 910B2C | OK | 96.6 44 0 / 0 | +| 0 | 0000:19:00.0 | 0 0 / 0 3404 / 65536 | ++===========================+===============+====================================================+ +| 15 910B2C | OK | 96.4 43 0 / 0 | +| 0 | 0000:D0:00.0 | 0 0 / 0 3404 / 65536 | ++===========================+===============+====================================================+ +` + +const nvidiaSMISample = `0, NVIDIA A100-SXM4-40GB, GPU-1a2b3c4d-aaaa-bbbb-cccc-1234567890ab, 161212300123, 90.00.2C.00.01, 40960, 1234, 39726, 0, 35, 70.5, 535.129.03 +1, NVIDIA A100-SXM4-40GB, GPU-2b3c4d5d-bbbb-cccc-dddd-234567890abc, 161212300124, 90.00.2C.00.01, 40960, 2048, 38912, 12, 38, 75.2, 535.129.03 +` + +const npuBoardSample = ` NPU ID : 0 + Product Name : IT21HMDB02-B2 + Model : NA + Manufacturer : Huawei + Serial Number : 102415083974 + Software Version : 25.5.2 + Firmware Version : 7.8.0.7.220 + Compatibility : OK + Board ID : 0x65 + PCB ID : A + BOM ID : 1 + PCIe Bus Info : 0000:66:00.0 + Slot ID : 0 +` + +func TestParseHuaweiNPU(t *testing.T) { + devs := parseHuaweiNPU(npuSMISample) + if len(devs) != 3 { + t.Fatalf("expected 3 devices, got %d", len(devs)) + } + + d0 := devs[0] + if d0.Index != 0 || d0.Vendor != "huawei" || d0.Name != "910B2C" { + t.Errorf("d0 meta mismatch: %+v", d0) + } + if d0.Health != "OK" { + t.Errorf("d0 health = %q, want OK", d0.Health) + } + if d0.PowerW != 94.4 { + t.Errorf("d0 power = %v, want 94.4", d0.PowerW) + } + if d0.Temperature != 42 { + t.Errorf("d0 temp = %v, want 42", d0.Temperature) + } + if d0.UUID != "0000:66:00.0" { + t.Errorf("d0 uuid = %q, want 0000:66:00.0", d0.UUID) + } + if d0.Utilization != 0 { + t.Errorf("d0 util = %v, want 0", d0.Utilization) + } + if d0.MemoryUsedMB != 3412 || d0.MemoryTotalMB != 65536 || d0.MemoryFreeMB != 62124 { + t.Errorf("d0 mem used/total/free = %d/%d/%d, want 3412/65536/62124", + d0.MemoryUsedMB, d0.MemoryTotalMB, d0.MemoryFreeMB) + } + if d0.DriverVersion != "25.5.2" { + t.Errorf("d0 driver = %q, want 25.5.2", d0.DriverVersion) + } + if !d0.RuntimeMetrics { + t.Error("d0 RuntimeMetrics should be true (npu-smi provided runtime values)") + } + + if devs[2].Index != 15 || devs[2].UUID != "0000:D0:00.0" { + t.Errorf("d2 mismatch: %+v", devs[2]) + } +} + +func TestParseNVIDIA(t *testing.T) { + devs := parseNVIDIA(nvidiaSMISample) + if len(devs) != 2 { + t.Fatalf("expected 2 devices, got %d", len(devs)) + } + + d0 := devs[0] + if d0.Index != 0 || d0.Vendor != "nvidia" || d0.Name != "NVIDIA A100-SXM4-40GB" { + t.Errorf("d0 meta mismatch: %+v", d0) + } + if d0.UUID != "GPU-1a2b3c4d-aaaa-bbbb-cccc-1234567890ab" { + t.Errorf("d0 uuid = %q", d0.UUID) + } + if d0.Serial != "161212300123" { + t.Errorf("d0 serial = %q, want 161212300123", d0.Serial) + } + if d0.MemoryTotalMB != 40960 || d0.MemoryUsedMB != 1234 || d0.MemoryFreeMB != 39726 { + t.Errorf("d0 mem = %d/%d/%d", d0.MemoryTotalMB, d0.MemoryUsedMB, d0.MemoryFreeMB) + } + if d0.Utilization != 0 || d0.Temperature != 35 || d0.PowerW != 70.5 { + t.Errorf("d0 util/temp/power = %v/%v/%v", d0.Utilization, d0.Temperature, d0.PowerW) + } + if d0.DriverVersion != "535.129.03" { + t.Errorf("d0 driver = %q", d0.DriverVersion) + } + if d0.FirmwareVersion != "90.00.2C.00.01" { + t.Errorf("d0 firmware = %q, want 90.00.2C.00.01", d0.FirmwareVersion) + } + if d0.Health != "OK" { + t.Errorf("d0 health = %q, want OK", d0.Health) + } + if !d0.RuntimeMetrics { + t.Error("d0 RuntimeMetrics should be true (nvidia-smi provided runtime values)") + } +} + +// Real-world excerpt of `mthreads-gmi --query --json` (2 of 8 MTT S5000 cards). +// Note the Power Readings key "Power Draw " carries a TRAILING SPACE — the +// parser must not rely on exact key matching. Memory values carry a "MiB" +// suffix, utilization a "%", temperature a "C", power a "W". The driver +// version lives at the top level, not per-GPU. +const mthreadsGMISample = `{ + "Timestamp": "Wed Jul 22 16:07:29 2026", + "Driver Version": "3.3.5-server", + "Attached GPUs": "8", + "GPU": [ + { + "Index": "0", + "Product Name": "MTT S5000", + "Product Brand": "MTT", + "GPU UUID": "399282a8-ba01-1475-893c-2eeca9e302f5", + "Serial Number": "MY10YL225BF06077", + "MTBios Version": "4.3.41", + "PCI": { + "Bus": "0x2A", + "Bus ID": "00000000:2a:00.0", + "Vendor ID": "0x1ED5", + "Device ID": "0x0400" + }, + "FB Memory Usage": { + "Total": "81920MiB", + "Used": "0MiB", + "Free": "81920MiB" + }, + "Utilization": { + "Gpu": "0%", + "Memory": "0%" + }, + "Temperature": { + "GPU Current Temp": "27C" + }, + "Power Readings": { + "Power Draw ": "96.66W", + "Current Power Limit": "950.00W" + } + }, + { + "Index": "1", + "Product Name": "MTT S5000", + "GPU UUID": "5ebdeb63-ba7a-5904-4f16-495b3e1de6c8", + "Serial Number": "MY10YL225BF06005", + "MTBios Version": "4.3.41", + "FB Memory Usage": { + "Total": "81920MiB", + "Used": "0MiB", + "Free": "81920MiB" + }, + "Utilization": { + "Gpu": "0%" + }, + "Temperature": { + "GPU Current Temp": "26C" + }, + "Power Readings": { + "Power Draw ": "97.09W" + } + } + ] +}` + +func TestParseMThreads(t *testing.T) { + devs := parseMThreads(mthreadsGMISample) + if len(devs) != 2 { + t.Fatalf("expected 2 devices, got %d: %+v", len(devs), devs) + } + + d0 := devs[0] + if d0.Index != 0 || d0.Vendor != "mthreads" || d0.Name != "MTT S5000" { + t.Errorf("d0 meta mismatch: %+v", d0) + } + if d0.UUID != "399282a8-ba01-1475-893c-2eeca9e302f5" { + t.Errorf("d0 uuid = %q", d0.UUID) + } + if d0.Serial != "MY10YL225BF06077" { + t.Errorf("d0 serial = %q", d0.Serial) + } + // Driver version comes from the top level, not per-GPU. + if d0.DriverVersion != "3.3.5-server" { + t.Errorf("d0 driver = %q, want 3.3.5-server", d0.DriverVersion) + } + // Firmware = MTBios Version. + if d0.FirmwareVersion != "4.3.41" { + t.Errorf("d0 firmware = %q, want 4.3.41", d0.FirmwareVersion) + } + // Memory carries "MiB" suffix → stripped to uint64 MB. + if d0.MemoryTotalMB != 81920 || d0.MemoryUsedMB != 0 || d0.MemoryFreeMB != 81920 { + t.Errorf("d0 mem total/used/free = %d/%d/%d, want 81920/0/81920", + d0.MemoryTotalMB, d0.MemoryUsedMB, d0.MemoryFreeMB) + } + // Utilization "0%" → 0. + if d0.Utilization != 0 { + t.Errorf("d0 util = %v, want 0", d0.Utilization) + } + // Temperature "27C" → 27. + if d0.Temperature != 27 { + t.Errorf("d0 temp = %v, want 27", d0.Temperature) + } + // Power "96.66W" → 96.66, resolved via trimmed-equals despite the + // trailing space in the "Power Draw " JSON key. + if d0.PowerW != 96.66 { + t.Errorf("d0 power = %v, want 96.66", d0.PowerW) + } + if d0.Health != "OK" { + t.Errorf("d0 health = %q, want OK", d0.Health) + } + if !d0.RuntimeMetrics { + t.Error("d0 RuntimeMetrics should be true (mthreads-gmi provided runtime values)") + } + + d1 := devs[1] + if d1.Index != 1 || d1.Serial != "MY10YL225BF06005" { + t.Errorf("d1 mismatch: %+v", d1) + } + if d1.Temperature != 26 || d1.PowerW != 97.09 { + t.Errorf("d1 temp/power = %v/%v, want 26/97.09", d1.Temperature, d1.PowerW) + } +} + +func TestParseMThreadsEmpty(t *testing.T) { + if devs := parseMThreads(""); len(devs) != 0 { + t.Fatalf("expected 0 devices for empty input, got %d", len(devs)) + } + // Malformed JSON → nil (decode error), not a panic. + if devs := parseMThreads("{not json"); len(devs) != 0 { + t.Fatalf("expected 0 devices for malformed input, got %d", len(devs)) + } +} + +// TestGPUFieldParity verifies both vendors populate the same set of struct fields. +func TestGPUFieldParity(t *testing.T) { + nv := parseNVIDIA(nvidiaSMISample) + npu := parseHuaweiNPU(npuSMISample) + if len(nv) == 0 || len(npu) == 0 { + t.Fatal("need both vendors' devices for parity check") + } + + // Simulate serial enrichment for Huawei (normally done by queryHuaweiNPUSerial) + npu[0].Serial = "1234567890ABCDEF" + + // Both should have the core identifying fields non-empty. + coreFields := []string{"Vendor", "Name", "DriverVersion"} + for _, f := range coreFields { + nvV := reflect.ValueOf(nv[0]).FieldByName(f).String() + npuV := reflect.ValueOf(npu[0]).FieldByName(f).String() + if nvV == "" || npuV == "" { + t.Errorf("field %s empty: nvidia=%q huawei=%q", f, nvV, npuV) + } + } + + // Serial must exist on both after enrichment. + if nv[0].Serial == "" { + t.Error("nvidia serial should be non-empty") + } + if npu[0].Serial == "" { + t.Error("huawei serial should be non-empty after enrichment") + } +} + +func TestQueryHuaweiNPUBoard(t *testing.T) { + // Serial + m := npuSerialRe.FindStringSubmatch(npuBoardSample) + if m == nil { + t.Fatal("serial not found in board sample") + } + if got := strings.TrimSpace(m[1]); got != "102415083974" { + t.Errorf("serial = %q, want 102415083974", got) + } + // Firmware version + mf := npuFWRe.FindStringSubmatch(npuBoardSample) + if mf == nil { + t.Fatal("firmware not found in board sample") + } + if got := strings.TrimSpace(mf[1]); got != "7.8.0.7.220" { + t.Errorf("firmware = %q, want 7.8.0.7.220", got) + } +} + +func TestParseHuaweiNPUEmpty(t *testing.T) { + if devs := parseHuaweiNPU(""); len(devs) != 0 { + t.Fatalf("expected 0 devices, got %d", len(devs)) + } +} + +func TestParseNVIDIAEmpty(t *testing.T) { + if devs := parseNVIDIA(""); len(devs) != 0 { + t.Fatalf("expected 0 devices, got %d", len(devs)) + } +} + +// Multi-row sample simulating an npu-smi output where each NPU block contains +// several rows whose first column has 2 tokens (NPU idx + chip/alarm id) and +// whose Health column carries numeric alarm codes. With the naive +// "len(col1) >= 2 => new device" rule this produces 3 entries for NPU 0 and 4 +// for NPU 2 (matching the duplicate-index bug observed in production). The +// state-machine parser must collapse each `+====+` block into a single device. +const npuMultiRowSample = `+------------------------------------------------------------------------------------------------+ +| npu-smi 25.5.2 Version: 25.5.2 | ++---------------------------+---------------+----------------------------------------------------+ +| NPU Name | Health | Power(W) Temp(C) Hugepages-Usage(page)| +| Chip | Bus-Id | AICore(%) Memory-Usage(MB) HBM-Usage(MB) | ++===========================+===============+====================================================+ +| 0 0 | 4106296 | 94.2 41 0 / 0 | +| 0 0 | 4107830 | 0 0 / 0 | +| 0 0 | 4107829 | 0 0 / 0 | ++===========================+===============+====================================================+ +| 1 0 | 4107830 | 96.4 44 0 / 0 | ++===========================+===============+====================================================+ +| 2 0 | 4111621 | 88.0 43 0 / 0 | +| 2 0 | 4111624 | 0 0 / 0 | +| 2 0 | 4111632 | 0 0 / 0 | +| 2 0 | 4109801 | 0 0 / 0 | ++===========================+===============+====================================================+ +` + +func TestParseHuaweiNPUMultiRowPerCard(t *testing.T) { + devs := parseHuaweiNPU(npuMultiRowSample) + if len(devs) != 3 { + t.Fatalf("expected 3 devices (one per NPU), got %d: %+v", len(devs), devs) + } + + seen := map[int]int{} + for _, d := range devs { + seen[d.Index]++ + } + for idx, c := range seen { + if c != 1 { + t.Errorf("index %d appears %d times, want 1", idx, c) + } + } + + d0 := devs[0] + if d0.Index != 0 || d0.Name != "0" || d0.Health != "4106296" { + t.Errorf("d0 mismatch: idx=%d name=%q health=%q", d0.Index, d0.Name, d0.Health) + } + if d0.PowerW != 94.2 || d0.Temperature != 41 { + t.Errorf("d0 power/temp = %v/%v, want 94.2/41", d0.PowerW, d0.Temperature) + } + if d0.DriverVersion != "25.5.2" { + t.Errorf("d0 driver = %q, want 25.5.2", d0.DriverVersion) + } + + d2 := devs[2] + if d2.Index != 2 || d2.Health != "4111621" { + t.Errorf("d2 mismatch: idx=%d health=%q", d2.Index, d2.Health) + } +} + +// 910B3-style output where each NPU block has a card header row followed by +// per-chip detail rows (all with numeric first column). Verify each NPU +// produces exactly one device and chip rows are merged, not duplicated. +const npuMultiChipSample = `+------------------------------------------------------------------------------------------------+ +| npu-smi 25.5.2 Version: 25.5.2 | ++---------------------------+---------------+----------------------------------------------------+ +| NPU Name | Health | Power(W) Temp(C) Hugepages-Usage(page)| +| Chip | Bus-Id | AICore(%) Memory-Usage(MB) HBM-Usage(MB) | ++===========================+===============+====================================================+ +| 0 910B3 | OK | 94.2 41 0 / 0 | +| 0 | 0000:66:00.0 | 0 0 / 0 3411 / 65536 | +| 0 0 | OK | 0 0 / 0 | +| 0 1 | OK | 0 0 / 0 | ++===========================+===============+====================================================+ +| 1 910B3 | OK | 96.4 44 0 / 0 | +| 1 | 0000:19:00.0 | 0 0 / 0 3404 / 65536 | ++===========================+===============+====================================================+ +` + +func TestParseHuaweiNPUMultiChipPerCard(t *testing.T) { + devs := parseHuaweiNPU(npuMultiChipSample) + if len(devs) != 2 { + t.Fatalf("expected 2 devices, got %d: %+v", len(devs), devs) + } + + d0 := devs[0] + if d0.Index != 0 || d0.Name != "910B3" { + t.Errorf("d0 meta mismatch: %+v", d0) + } + if d0.UUID != "0000:66:00.0" { + t.Errorf("d0 uuid = %q, want 0000:66:00.0", d0.UUID) + } + if d0.MemoryUsedMB != 3411 || d0.MemoryTotalMB != 65536 { + t.Errorf("d0 mem = %d/%d, want 3411/65536", d0.MemoryUsedMB, d0.MemoryTotalMB) + } + + d1 := devs[1] + if d1.Index != 1 || d1.UUID != "0000:19:00.0" { + t.Errorf("d1 mismatch: %+v", d1) + } +} + +// 310P3-style output: each chip of a card gets its own block, with the card +// header repeated per chip and "+---+" (dash) separators between chips. Only +// the column-header boundaries use "+===+". Each chip row carries its own +// Memory-Usage (no HBM column). The parser must collapse the two chips of +// each NPU into a single device and must NOT produce duplicate indexes. +const npu310P3Sample = `+--------------------------------------------------------------------------------------------------------+ +| npu-smi 25.5.1 Version: 25.5.1 | ++-------------------------------+-----------------+------------------------------------------------------+ +| NPU Name | Health | Power(W) Temp(C) Hugepages-Usage(page) | +| Chip Device | Bus-Id | AICore(%) Memory-Usage(MB) | ++===============================+=================+======================================================+ +| 1 310P3 | OK | NA 49 0 / 0 | +| 0 0 | 0000:01:00.0 | 0 1448 / 44278 | ++-------------------------------+-----------------+------------------------------------------------------+ +| 1 310P3 | OK | NA 45 0 / 0 | +| 1 1 | 0000:01:00.0 | 0 1511 / 43693 | ++-------------------------------+-----------------+------------------------------------------------------+ +| 2 310P3 | OK | NA 46 0 / 0 | +| 0 2 | 0000:02:00.0 | 0 1384 / 44278 | ++-------------------------------+-----------------+------------------------------------------------------+ +| 2 310P3 | OK | NA 46 0 / 0 | +| 1 3 | 0000:02:00.0 | 0 1572 / 43693 | ++-------------------------------+-----------------+------------------------------------------------------+ +` + +func TestParseHuaweiNPU310P3PerChipBlocks(t *testing.T) { + devs := parseHuaweiNPU(npu310P3Sample) + if len(devs) != 2 { + t.Fatalf("expected 2 devices (one per NPU), got %d: %+v", len(devs), devs) + } + + seen := map[int]int{} + for _, d := range devs { + seen[d.Index]++ + } + for idx, c := range seen { + if c != 1 { + t.Errorf("index %d appears %d times, want 1", idx, c) + } + } + + d0 := devs[0] + if d0.Index != 1 || d0.Name != "310P3" || d0.Health != "OK" { + t.Errorf("d0 meta mismatch: %+v", d0) + } + if d0.UUID != "0000:01:00.0" { + t.Errorf("d0 uuid = %q, want 0000:01:00.0", d0.UUID) + } + // Memory is summed across the two chips of NPU 1. + if d0.MemoryTotalMB != 44278+43693 || d0.MemoryUsedMB != 1448+1511 { + t.Errorf("d0 mem = %d/%d, want %d/%d", d0.MemoryUsedMB, d0.MemoryTotalMB, 1448+1511, 44278+43693) + } + if d0.MemoryFreeMB != d0.MemoryTotalMB-d0.MemoryUsedMB { + t.Errorf("d0 free = %d, want %d", d0.MemoryFreeMB, d0.MemoryTotalMB-d0.MemoryUsedMB) + } + if d0.Temperature != 49 { + t.Errorf("d0 temp = %v, want 49 (first chip header)", d0.Temperature) + } + if d0.DriverVersion != "25.5.1" { + t.Errorf("d0 driver = %q, want 25.5.1", d0.DriverVersion) + } + + d1 := devs[1] + if d1.Index != 2 || d1.UUID != "0000:02:00.0" { + t.Errorf("d1 mismatch: %+v", d1) + } +} + +// Variant A of the 310P3 output: "+---+" (dash) is used between EVERY data +// block — including between different NPUs (e.g. NPU 1 chip1 -> NPU 2 chip0). +// Only the column-header boundary uses "+===+". This is the exact format +// observed in production that produced 8 duplicated devices (2 per NPU) with +// the old separator-driven flush. The index-driven parser must yield one +// device per NPU (4 total for NPUs 1/2/4/5) with summed per-chip memory. +const npu310P3AllDashSample = `+--------------------------------------------------------------------------------------------------------+ +| npu-smi 25.5.1 Version: 25.5.1 | ++-------------------------------+-----------------+------------------------------------------------------+ +| NPU Name | Health | Power(W) Temp(C) Hugepages-Usage(page) | +| Chip Device | Bus-Id | AICore(%) Memory-Usage(MB) | ++===============================+=================+======================================================+ +| 1 310P3 | OK | NA 49 0 / 0 | +| 0 0 | 0000:01:00.0 | 0 1448 / 44278 | ++-------------------------------+-----------------+------------------------------------------------------+ +| 1 310P3 | OK | NA 45 0 / 0 | +| 1 1 | 0000:01:00.0 | 0 1511 / 43693 | ++-------------------------------+-----------------+------------------------------------------------------+ +| 2 310P3 | OK | NA 46 0 / 0 | +| 0 2 | 0000:02:00.0 | 0 1384 / 44278 | ++-------------------------------+-----------------+------------------------------------------------------+ +| 2 310P3 | OK | NA 46 0 / 0 | +| 1 3 | 0000:02:00.0 | 0 1572 / 43693 | ++-------------------------------+-----------------+------------------------------------------------------+ +| 4 310P3 | OK | NA 47 0 / 0 | +| 0 4 | 0000:81:00.0 | 0 1863 / 44278 | ++-------------------------------+-----------------+------------------------------------------------------+ +| 4 310P3 | OK | NA 48 0 / 0 | +| 1 5 | 0000:81:00.0 | 0 1105 / 43693 | ++-------------------------------+-----------------+------------------------------------------------------+ +| 5 310P3 | OK | NA 48 0 / 0 | +| 0 6 | 0000:82:00.0 | 0 1694 / 44278 | ++-------------------------------+-----------------+------------------------------------------------------+ +| 5 310P3 | OK | NA 46 0 / 0 | +| 1 7 | 0000:82:00.0 | 0 1269 / 43693 | ++-------------------------------+-----------------+------------------------------------------------------+ +` + +func TestParseHuaweiNPU310P3AllDashSeparators(t *testing.T) { + devs := parseHuaweiNPU(npu310P3AllDashSample) + if len(devs) != 4 { + t.Fatalf("expected 4 devices (one per NPU 1/2/4/5), got %d: %+v", len(devs), devs) + } + + // No duplicate indexes. + seen := map[int]int{} + for _, d := range devs { + seen[d.Index]++ + } + wantIdx := map[int]int{1: 1, 2: 1, 4: 1, 5: 1} + if !reflect.DeepEqual(seen, wantIdx) { + t.Errorf("index counts = %+v, want %+v", seen, wantIdx) + } + + byIdx := map[int]model.GPUDevice{} + for _, d := range devs { + byIdx[d.Index] = d + } + + d1 := byIdx[1] + if d1.UUID != "0000:01:00.0" { + t.Errorf("NPU1 uuid = %q, want 0000:01:00.0", d1.UUID) + } + if d1.MemoryUsedMB != 1448+1511 || d1.MemoryTotalMB != 44278+43693 { + t.Errorf("NPU1 mem = %d/%d, want %d/%d", d1.MemoryUsedMB, d1.MemoryTotalMB, 1448+1511, 44278+43693) + } + if d1.MemoryFreeMB != d1.MemoryTotalMB-d1.MemoryUsedMB { + t.Errorf("NPU1 free = %d, want %d", d1.MemoryFreeMB, d1.MemoryTotalMB-d1.MemoryUsedMB) + } + if d1.Temperature != 49 { + t.Errorf("NPU1 temp = %v, want 49", d1.Temperature) + } + + d4 := byIdx[4] + if d4.UUID != "0000:81:00.0" { + t.Errorf("NPU4 uuid = %q, want 0000:81:00.0", d4.UUID) + } + if d4.MemoryUsedMB != 1863+1105 || d4.MemoryTotalMB != 44278+43693 { + t.Errorf("NPU4 mem = %d/%d, want %d/%d", d4.MemoryUsedMB, d4.MemoryTotalMB, 1863+1105, 44278+43693) + } +} + +// 910B2C full output: one chip row per NPU (data memory is in the HBM-Usage +// column, the last "d/d" match), "+===+" between NPUs, followed by a +// "No running processes" footer section. The footer must NOT create phantom +// devices. +const npu910B2CSample = `+------------------------------------------------------------------------------------------------+ +| npu-smi 25.5.2 Version: 25.5.2 | ++---------------------------+---------------+----------------------------------------------------+ +| NPU Name | Health | Power(W) Temp(C) Hugepages-Usage(page)| +| Chip | Bus-Id | AICore(%) Memory-Usage(MB) HBM-Usage(MB) | ++===========================+===============+====================================================+ +| 0 910B2C | OK | 93.9 41 0 / 0 | +| 0 | 0000:66:00.0 | 0 0 / 0 3410 / 65536 | ++===========================+===============+====================================================+ +| 1 910B2C | OK | 96.0 43 0 / 0 | +| 0 | 0000:19:00.0 | 0 0 / 0 3404 / 65536 | ++===========================+===============+====================================================+ +| 15 910B2C | OK | 96.0 41 0 / 0 | +| 0 | 0000:D0:00.0 | 0 0 / 0 3401 / 65536 | ++===========================+===============+====================================================+ ++---------------------------+---------------+----------------------------------------------------+ +| NPU Chip | Process id | Process name | Process memory(MB) | ++===========================+===============+====================================================+ +| No running processes found in NPU 0 | ++===========================+===============+====================================================+ +| No running processes found in NPU 15 | ++===========================+===============+====================================================+ +` + +func TestParseHuaweiNPU910B2CWithFooter(t *testing.T) { + devs := parseHuaweiNPU(npu910B2CSample) + if len(devs) != 3 { + t.Fatalf("expected 3 devices, got %d: %+v", len(devs), devs) + } + + d0 := devs[0] + if d0.Index != 0 || d0.Name != "910B2C" || d0.Health != "OK" { + t.Errorf("d0 meta mismatch: %+v", d0) + } + if d0.UUID != "0000:66:00.0" { + t.Errorf("d0 uuid = %q, want 0000:66:00.0", d0.UUID) + } + // Single chip: memory comes from HBM-Usage (the last d/d match), not + // summed (only one chip row), not the placeholder "0/0". + if d0.MemoryUsedMB != 3410 || d0.MemoryTotalMB != 65536 || d0.MemoryFreeMB != 65536-3410 { + t.Errorf("d0 mem used/total/free = %d/%d/%d, want 3410/65536/%d", + d0.MemoryUsedMB, d0.MemoryTotalMB, d0.MemoryFreeMB, 65536-3410) + } + if d0.PowerW != 93.9 || d0.Temperature != 41 { + t.Errorf("d0 power/temp = %v/%v, want 93.9/41", d0.PowerW, d0.Temperature) + } + if d0.Utilization != 0 { + t.Errorf("d0 util = %v, want 0", d0.Utilization) + } + if d0.DriverVersion != "25.5.2" { + t.Errorf("d0 driver = %q, want 25.5.2", d0.DriverVersion) + } + + d2 := devs[2] + if d2.Index != 15 || d2.UUID != "0000:D0:00.0" { + t.Errorf("d2 mismatch: %+v", d2) + } + if d2.MemoryUsedMB != 3401 || d2.MemoryTotalMB != 65536 { + t.Errorf("d2 mem = %d/%d, want 3401/65536", d2.MemoryUsedMB, d2.MemoryTotalMB) + } +} + +// Real-world `lspci | grep -i nvidia` output from a host whose 8 RTX 5090s +// (PCI device 0x2b85) are passed through to guest VMs: nvidia-smi on the +// host reports zero devices, so the collector falls back to lspci. Each GPU +// shows up as a pair — VGA controller + audio subfunction — and the audio +// functions must NOT be counted as separate devices. The first card carries +// "(rev ff)" which is the typical signature of a card bound to vfio-pci +// (its config-space power state is D3cold), but the parser doesn't need to +// care about the revision. +const lspciPlainSample = `16:00.0 VGA compatible controller: NVIDIA Corporation Device 2b85 (rev ff) +16:00.1 Audio device: NVIDIA Corporation Device 22e8 (rev ff) +27:00.0 VGA compatible controller: NVIDIA Corporation Device 2b85 (rev a1) +27:00.1 Audio device: NVIDIA Corporation Device 22e8 (rev a1) +38:00.0 VGA compatible controller: NVIDIA Corporation Device 2b85 (rev a1) +38:00.1 Audio device: NVIDIA Corporation Device 22e8 (rev a1) +5a:00.0 VGA compatible controller: NVIDIA Corporation Device 2b85 (rev a1) +5a:00.1 Audio device: NVIDIA Corporation Device 22e8 (rev a1) +98:00.0 VGA compatible controller: NVIDIA Corporation Device 2b85 (rev a1) +98:00.1 Audio device: NVIDIA Corporation Device 22e8 (rev a1) +a8:00.0 VGA compatible controller: NVIDIA Corporation Device 2b85 (rev a1) +a8:00.1 Audio device: NVIDIA Corporation Device 22e8 (rev a1) +b8:00.0 VGA compatible controller: NVIDIA Corporation Device 2b85 (rev a1) +b8:00.1 Audio device: NVIDIA Corporation Device 22e8 (rev a1) +d8:00.0 VGA compatible controller: NVIDIA Corporation Device 2b85 (rev a1) +d8:00.1 Audio device: NVIDIA Corporation Device 22e8 (rev a1) +` + +// Same setup, but with `lspci -Dnn` output (the flags the collector actually +// uses): the PCI domain is always printed and numeric vendor:device IDs are +// included in brackets so brand-new SKUs can still be identified. +const lspciDnnSample = `0000:16:00.0 VGA compatible controller [0300]: NVIDIA Corporation Device 2b85 [10de:2b85] (rev ff) +0000:16:00.1 Audio device [0403]: NVIDIA Corporation Device 22e8 [10de:22e8] (rev ff) +0000:27:00.0 VGA compatible controller [0300]: NVIDIA Corporation Device 2b85 [10de:2b85] (rev a1) +0000:27:00.1 Audio device [0403]: NVIDIA Corporation Device 22e8 [10de:22e8] (rev a1) +` + +// When lspci's pci.ids database knows the SKU, the textual description is the +// real product name (here a GA102 RTX 3090) and should be preserved verbatim. +const lspciNamedSample = `0000:01:00.0 VGA compatible controller [0300]: NVIDIA Corporation GA102 [GeForce RTX 3090] [10de:2204] (rev a1) +` + +// A100-SXM4 in pass-through: shows up as "3D controller" (compute-only, no +// VGA), which the parser must still pick up. +const lspci3DControllerSample = `0000:43:00.0 3D controller [0302]: NVIDIA Corporation GA100 [A100 SXM4 40GB] [10de:20b5] (rev a1) +0000:43:00.1 Audio device [0403]: NVIDIA Corporation GA100 High Definition Audio [10de:20b5] (rev a1) +` + +// Production lspci from an 8× RTX 4090 host. Every GPU node's BMC exposes an +// ASPEED VGA controller alongside the real NVIDIA cards; ASPEED's PCI vendor +// ID (1a03) isn't in pciVendorMap, so probeGpuVendor must skip it and route to +// nvidia. Verifies the "first RECOGNIZED vendor" rule against real fleet data. +const lspciAspeedAndNvidiaSample = `0000:03:00.0 VGA compatible controller [0300]: ASPEED Technology, Inc. ASPEED Graphics Family [1a03:2000] (rev 52) +0000:16:00.0 VGA compatible controller [0300]: NVIDIA Corporation Device 2684 [10de:2684] (rev a1) +0000:36:00.0 VGA compatible controller [0300]: NVIDIA Corporation Device 2684 [10de:2684] (rev a1) +0000:46:00.0 VGA compatible controller [0300]: NVIDIA Corporation Device 2684 [10de:2684] (rev a1) +0000:56:00.0 VGA compatible controller [0300]: NVIDIA Corporation Device 2684 [10de:2684] (rev a1) +0000:98:00.0 VGA compatible controller [0300]: NVIDIA Corporation Device 2684 [10de:2684] (rev a1) +0000:b8:00.0 VGA compatible controller [0300]: NVIDIA Corporation Device 2684 [10de:2684] (rev a1) +0000:c8:00.0 VGA compatible controller [0300]: NVIDIA Corporation Device 2684 [10de:2684] (rev a1) +0000:d8:00.0 VGA compatible controller [0300]: NVIDIA Corporation Device 2684 [10de:2684] (rev a1) +` + +// Production lspci (no -nn) from an 8× compute-card host where the GPUs show up +// as "3D controller" (compute-only, no VGA). Used to verify the 0x0302 subclass +// still routes to nvidia. +const lspciNvidia3DControllersSample = `19:00.0 3D controller: NVIDIA Corporation Device 2335 (rev a1) +2a:00.0 3D controller: NVIDIA Corporation Device 2335 (rev a1) +3b:00.0 3D controller: NVIDIA Corporation Device 2335 (rev a1) +5d:00.0 3D controller: NVIDIA Corporation Device 2335 (rev a1) +9b:00.0 3D controller: NVIDIA Corporation Device 2335 (rev a1) +ab:00.0 3D controller: NVIDIA Corporation Device 2335 (rev a1) +bb:00.0 3D controller: NVIDIA Corporation Device 2335 (rev a1) +db:00.0 3D controller: NVIDIA Corporation Device 2335 (rev a1) +` + +// A typical mixed-vendor host: an Intel integrated GPU alongside discrete +// cards from AMD and NVIDIA (here an NVIDIA audio subfunction with no display +// sibling on this host, e.g. a USB-C display output mux). All display-class +// functions of any vendor are captured; audio subfunctions are not. +const lspciMixedVendorSample = `00:02.0 VGA compatible controller [0300]: Intel Corporation CoffeeLake-S GT2 [UHD Graphics 630] [8086:3e98] (rev 02) +00:1f.3 Audio device [0403]: Intel Corporation Cannon Lake PCH cAVS [8086:a348] (rev 10) +01:00.0 VGA compatible controller [0300]: Advanced Micro Devices, Inc. [AMD/ATI] Navi 21 [Radeon RX 6800/6800 XT / 6900 XT] [1002:73bf] (rev c1) +01:00.1 Audio device [0403]: Advanced Micro Devices, Inc. [AMD/ATI] Navi 21 HDMI Audio [1002:ab28] (rev c1) +02:00.0 Audio device [0403]: NVIDIA Corporation GP107GL High Definition Audio Controller [10de:0fb5] (rev a1) +` + +// Moore Threads MTT S5000 shows up as "3D controller" (PCI subclass 0x0302, +// compute-only — same subclass as NVIDIA A100). With -nn the numeric PCI +// vendor:device ID [1ed5:0400] routes it to the "mthreads" collector. +const lspciMthreads3DControllerSample = `0000:2a:00.0 3D controller [0302]: Moore Threads Technology Co.,Ltd Device 0400 [1ed5:0400] (rev 01) +0000:3a:00.0 3D controller [0302]: Moore Threads Technology Co.,Ltd Device 0400 [1ed5:0400] (rev 01) +0000:5c:00.0 3D controller [0302]: Moore Threads Technology Co.,Ltd Device 0400 [1ed5:0400] (rev 01) +` + +// An accelerator from a vendor the collector's pciVendorMap doesn't know — +// the parser must still emit a device, surfacing the (unknown) PCI vendor ID +// in the vendor field so it remains identifiable / mergeable downstream. +const lspciUnknownVendorSample = `0000:41:0c.0 Display controller [0380]: Iluvatar CoreX Triton X100 [1cee:0100] (rev 01) +` + +func TestParseLspciGPUPlain(t *testing.T) { + devs := parseLspciGPU(lspciPlainSample) + if len(devs) != 8 { + t.Fatalf("expected 8 GPUs (one per VGA controller, audio skipped), got %d: %+v", + len(devs), devs) + } + + // Indices run 0..7 contiguously. Plain lspci has no -nn, so there's no + // PCI vendor:device ID to recover; Serial stays empty but the textual + // "Device 2b85" description (which still includes "NVIDIA") lets the + // text-fallback path recognize the vendor as "nvidia". + for i, d := range devs { + if d.Index != i { + t.Errorf("dev %d index = %d, want %d", i, d.Index, i) + } + if d.Vendor != "nvidia" { + t.Errorf("dev %d vendor = %q, want nvidia", i, d.Vendor) + } + if d.Serial != "" { + t.Errorf("dev %d serial = %q, want empty (no -nn)", i, d.Serial) + } + if d.Health != "Unknown" { + t.Errorf("dev %d health = %q, want Unknown (no driver on host)", i, d.Health) + } + if d.DriverVersion != "" || d.FirmwareVersion != "" { + t.Errorf("dev %d driver/firmware should be empty: %q/%q", + i, d.DriverVersion, d.FirmwareVersion) + } + if d.MemoryTotalMB != 0 || d.Utilization != 0 || d.Temperature != 0 { + t.Errorf("dev %d runtime metrics should be 0: mem=%d util=%v temp=%v", + i, d.MemoryTotalMB, d.Utilization, d.Temperature) + } + if d.RuntimeMetrics { + t.Errorf("dev %d RuntimeMetrics should be false for lspci-fallback devices", i) + } + } + + d0 := devs[0] + if d0.Name != "NVIDIA Corporation Device 2b85" { + t.Errorf("d0 name = %q, want 'NVIDIA Corporation Device 2b85'", d0.Name) + } + if d0.UUID != "0000:16:00.0" { + t.Errorf("d0 uuid = %q, want 0000:16:00.0 (domain added)", d0.UUID) + } + + // Bus IDs unique and in the order they appeared. + wantBuses := []string{ + "0000:16:00.0", "0000:27:00.0", "0000:38:00.0", "0000:5a:00.0", + "0000:98:00.0", "0000:a8:00.0", "0000:b8:00.0", "0000:d8:00.0", + } + seen := map[string]bool{} + for i, d := range devs { + if d.UUID != wantBuses[i] { + t.Errorf("dev %d uuid = %q, want %q", i, d.UUID, wantBuses[i]) + } + if seen[d.UUID] { + t.Errorf("duplicate uuid %q", d.UUID) + } + seen[d.UUID] = true + } +} + +func TestParseLspciGPUDnn(t *testing.T) { + devs := parseLspciGPU(lspciDnnSample) + if len(devs) != 2 { + t.Fatalf("expected 2 GPUs, got %d: %+v", len(devs), devs) + } + + d0 := devs[0] + if d0.Name != "NVIDIA Corporation Device 2b85" { + t.Errorf("d0 name = %q", d0.Name) + } + if d0.UUID != "0000:16:00.0" { + t.Errorf("d0 uuid = %q, want 0000:16:00.0 (domain preserved)", d0.UUID) + } + // With -nn the numeric PCI vendor:device ID is recovered into Serial so + // the SKU is identifiable even though pci.ids doesn't know "0x2b85". The + // numeric vendor "10de" is also recognized via pciVendorMap as "nvidia". + if d0.Serial != "10de:2b85" { + t.Errorf("d0 serial = %q, want 10de:2b85", d0.Serial) + } + if d0.Vendor != "nvidia" { + t.Errorf("d0 vendor = %q, want nvidia (from PCI vendor ID 10de)", d0.Vendor) + } + if d1 := devs[1]; d1.UUID != "0000:27:00.0" || d1.Serial != "10de:2b85" { + t.Errorf("d1 mismatch: %+v", d1) + } +} + +func TestParseLspciGPUNamedModel(t *testing.T) { + devs := parseLspciGPU(lspciNamedSample) + if len(devs) != 1 { + t.Fatalf("expected 1 GPU, got %d: %+v", len(devs), devs) + } + d := devs[0] + // When lspci has the real model name it should be preserved verbatim — + // don't strip brackets, don't strip the "NVIDIA Corporation" prefix. + if d.Name != "NVIDIA Corporation GA102 [GeForce RTX 3090]" { + t.Errorf("name = %q", d.Name) + } + if d.UUID != "0000:01:00.0" { + t.Errorf("uuid = %q", d.UUID) + } + if d.Serial != "10de:2204" { + t.Errorf("serial = %q, want 10de:2204", d.Serial) + } +} + +func TestParseLspciGPU3DController(t *testing.T) { + devs := parseLspciGPU(lspci3DControllerSample) + if len(devs) != 1 { + t.Fatalf("expected 1 GPU (3D controller, audio skipped), got %d: %+v", + len(devs), devs) + } + d := devs[0] + if d.Name != "NVIDIA Corporation GA100 [A100 SXM4 40GB]" { + t.Errorf("name = %q", d.Name) + } + if d.UUID != "0000:43:00.0" { + t.Errorf("uuid = %q", d.UUID) + } + if d.Serial != "10de:20b5" { + t.Errorf("serial = %q, want 10de:20b5", d.Serial) + } +} + +// Huawei Ascend 910B2C exposes "Processing accelerators" (PCI class 0x12), NOT +// a display controller — parseLspciGPU must still pick it up, otherwise every +// Huawei host would be misrouted to "unknown vendor" and dropped. +func TestParseLspciGPUHuaweiAccelerator(t *testing.T) { + const in = `0000:18:00.0 Processing accelerators [1200]: Huawei Technologies Co., Ltd. Device d802 [19e5:d802] (rev 20) +0000:18:00.1 Processing accelerators [1200]: Huawei Technologies Co., Ltd. Device d802 [19e5:d802] (rev 20) +` + devs := parseLspciGPU(in) + if len(devs) != 2 { + t.Fatalf("expected 2 NPUs (Processing accelerators class), got %d: %+v", + len(devs), devs) + } + d := devs[0] + if d.Vendor != "huawei" { + t.Errorf("vendor = %q, want huawei (PCI vendor 19e5)", d.Vendor) + } + if d.UUID != "0000:18:00.0" { + t.Errorf("uuid = %q, want 0000:18:00.0", d.UUID) + } + if d.Serial != "19e5:d802" { + t.Errorf("serial = %q, want 19e5:d802", d.Serial) + } +} + +// Any display-class device of ANY vendor is captured — not just NVIDIA. This +// is the case that distinguishes the catch-all lspci fallback from the older +// NVIDIA-only parser: an Intel iGPU and a discrete AMD Radeon show up +// alongside an NVIDIA audio subfunction (which is skipped), each with the +// right canonical vendor derived from its PCI vendor ID. +func TestParseLspciGPUMixedVendor(t *testing.T) { + devs := parseLspciGPU(lspciMixedVendorSample) + if len(devs) != 2 { + t.Fatalf("expected 2 display devices (Intel + AMD, audio/NVIDIA-audio skipped), got %d: %+v", + len(devs), devs) + } + + d0 := devs[0] + if d0.Vendor != "intel" { + t.Errorf("d0 vendor = %q, want intel", d0.Vendor) + } + if d0.Name != "Intel Corporation CoffeeLake-S GT2 [UHD Graphics 630]" { + t.Errorf("d0 name = %q", d0.Name) + } + if d0.UUID != "0000:00:02.0" { + t.Errorf("d0 uuid = %q, want 0000:00:02.0 (domain added)", d0.UUID) + } + if d0.Serial != "8086:3e98" { + t.Errorf("d0 serial = %q, want 8086:3e98", d0.Serial) + } + + d1 := devs[1] + if d1.Vendor != "amd" { + t.Errorf("d1 vendor = %q, want amd", d1.Vendor) + } + if d1.Name != "Advanced Micro Devices, Inc. [AMD/ATI] Navi 21 [Radeon RX 6800/6800 XT / 6900 XT]" { + t.Errorf("d1 name = %q", d1.Name) + } + if d1.UUID != "0000:01:00.0" { + t.Errorf("d1 uuid = %q", d1.UUID) + } + if d1.Serial != "1002:73bf" { + t.Errorf("d1 serial = %q, want 1002:73bf", d1.Serial) + } +} + +// Moore Threads 3D controllers are compute-only (no VGA), same subclass as +// the NVIDIA A100. Verifies the 0x0302 subclass routes to mthreads via the +// numeric PCI vendor ID 1ed5. +func TestParseLspciGPUMthreads3DController(t *testing.T) { + devs := parseLspciGPU(lspciMthreads3DControllerSample) + if len(devs) != 3 { + t.Fatalf("expected 3 GPUs, got %d: %+v", len(devs), devs) + } + for i, d := range devs { + if d.Vendor != "mthreads" { + t.Errorf("dev %d vendor = %q, want mthreads (PCI vendor 1ed5)", i, d.Vendor) + } + if d.Serial != "1ed5:0400" { + t.Errorf("dev %d serial = %q, want 1ed5:0400", i, d.Serial) + } + if d.Index != i { + t.Errorf("dev %d index = %d, want %d", i, d.Index, i) + } + } + d0 := devs[0] + if d0.UUID != "0000:2a:00.0" { + t.Errorf("d0 uuid = %q, want 0000:2a:00.0", d0.UUID) + } + if d0.Name != "Moore Threads Technology Co.,Ltd Device 0400" { + t.Errorf("d0 name = %q", d0.Name) + } +} + +// A device whose vendor isn't in the collector's pciVendorMap: it must still +// be emitted (rather than silently dropped) and the PCI vendor ID is surfaced +// as the canonical vendor name so downstream tools can merge it later. +func TestParseLspciGPUUnknownVendor(t *testing.T) { + devs := parseLspciGPU(lspciUnknownVendorSample) + if len(devs) != 1 { + t.Fatalf("expected 1 device, got %d: %+v", len(devs), devs) + } + d := devs[0] + if d.Vendor != "1cee" { + t.Errorf("vendor = %q, want 1cee (PCI vendor ID hex fallback)", d.Vendor) + } + if d.Name != "Iluvatar CoreX Triton X100" { + t.Errorf("name = %q", d.Name) + } + if d.UUID != "0000:41:0c.0" { + t.Errorf("uuid = %q", d.UUID) + } + if d.Serial != "1cee:0100" { + t.Errorf("serial = %q, want 1cee:0100", d.Serial) + } +} + +func TestParseLspciGPUEmpty(t *testing.T) { + if devs := parseLspciGPU(""); len(devs) != 0 { + t.Fatalf("expected 0 devices, got %d", len(devs)) + } +} + +// Audio devices with no sibling display function (e.g. an NVIDIA HDMI audio +// controller on a headless host) must NOT produce phantom GPUs. +func TestParseLspciGPUAudioOnlyNoDevices(t *testing.T) { + in := `00:1f.3 Audio device [0403]: Intel Corporation Cannon Lake PCH cAVS [8086:a348] (rev 10) +01:00.0 Audio device [0403]: NVIDIA Corporation GP107GL High Definition Audio Controller [10de:0fb5] (rev a1) +` + if devs := parseLspciGPU(in); len(devs) != 0 { + t.Fatalf("expected 0 devices (no display-class functions), got %d: %+v", len(devs), devs) + } +} + +func TestIdentifyLspciVendor(t *testing.T) { + cases := []struct { + pciID string + name string + want string + }{ + // Known PCI vendor IDs — preferred over text matching. + {"10de:2b85", "NVIDIA Corporation Device 2b85", "nvidia"}, + {"8086:3e98", "Intel Corporation CoffeeLake-S GT2", "intel"}, + {"1002:73bf", "Advanced Micro Devices, Inc. [AMD/ATI] Navi 21", "amd"}, + {"1002:AB28", "AMD audio (uppercase hex ID, case-insensitive)", "amd"}, + {"19e5:abcd", "Huawei NPU device", "huawei"}, + {"1ed5:0400", "Moore Threads Technology Co.,Ltd Device 0400", "mthreads"}, + // Plain lspci (no -nn) — fall back to textual description. + {"", "NVIDIA Corporation Device 2b85", "nvidia"}, + {"", "Advanced Micro Devices, Inc. [AMD/ATI]", "amd"}, + {"", "Moore Threads Technology Co.,Ltd Device 0400", "mthreads"}, + // Unknown PCI vendor: surface the hex ID so it stays identifiable. + {"1cee:0100", "Iluvatar CoreX Triton X100", "1cee"}, + // Empty PCI ID + unrecognized text: last-resort "unknown". + {"", "Some unknown controller", "unknown"}, + } + for i, c := range cases { + got := identifyLspciVendor(c.pciID, c.name) + if got != c.want { + t.Errorf("case %d identifyLspciVendor(%q, %q) = %q, want %q", + i, c.pciID, c.name, got, c.want) + } + } +} + +func TestParseLspciDeviceDesc(t *testing.T) { + cases := []struct { + line string + name string + pciID string + }{ + { + line: "0000:16:00.0 VGA compatible controller [0300]: NVIDIA Corporation Device 2b85 [10de:2b85] (rev ff)", + name: "NVIDIA Corporation Device 2b85", + pciID: "10de:2b85", + }, + { + line: "16:00.0 VGA compatible controller: NVIDIA Corporation Device 2b85 (rev ff)", + name: "NVIDIA Corporation Device 2b85", + pciID: "", + }, + { + line: "0000:01:00.0 VGA compatible controller [0300]: NVIDIA Corporation GA102 [GeForce RTX 3090] [10de:2204] (rev a1)", + name: "NVIDIA Corporation GA102 [GeForce RTX 3090]", + pciID: "10de:2204", + }, + { + line: "0000:43:00.0 3D controller [0302]: NVIDIA Corporation GA100 [A100 SXM4 40GB] [10de:20b5] (rev a1)", + name: "NVIDIA Corporation GA100 [A100 SXM4 40GB]", + pciID: "10de:20b5", + }, + { + line: "00:02.0 VGA compatible controller [0300]: Intel Corporation CoffeeLake-S GT2 [UHD Graphics 630] [8086:3e98] (rev 02)", + name: "Intel Corporation CoffeeLake-S GT2 [UHD Graphics 630]", + pciID: "8086:3e98", + }, + { + line: "0000:41:0c.0 Display controller [0380]: Iluvatar CoreX Triton X100 [1cee:0100] (rev 01)", + name: "Iluvatar CoreX Triton X100", + pciID: "1cee:0100", + }, + } + for i, c := range cases { + name, pciID := parseLspciDeviceDesc(c.line) + if name != c.name { + t.Errorf("case %d name = %q, want %q", i, name, c.name) + } + if pciID != c.pciID { + t.Errorf("case %d pciID = %q, want %q", i, pciID, c.pciID) + } + } +} + +// ---------------------------------- collectGPUCore routing + +// smiOK builds a fake smi collector that contributes the given prebuilt +// devices and reports "ok" (≥1 card). Simulates nvidia-smi / npu-smi success +// without shelling out. +func smiOK(devs ...model.GPUDevice) func(*model.GPU) bool { + return func(g *model.GPU) bool { + g.Devices = append(g.Devices, devs...) + return len(devs) > 0 + } +} + +// smiEmpty simulates an smi tool that found no cards (passthrough / broken +// driver) — returns false so collectGPUCore falls back to lspci enumeration. +func smiEmpty(_ *model.GPU) bool { return false } + +// NVIDIA host: lspci sees NVIDIA, smi succeeds → use smi output (richer: runtime +// + identity). The lspci enumeration must NOT also be appended. +func TestCollectGPUCore_NvidiaSmiSucceeds(t *testing.T) { + collectors := map[string]func(*model.GPU) bool{ + "nvidia": smiOK(model.GPUDevice{ + Index: 0, Vendor: "nvidia", Name: "A100", UUID: "GPU-aaa", + DriverVersion: "535.0", MemoryTotalMB: 40960, RuntimeMetrics: true, + }), + } + g := collectGPUCore(lspciDnnSample, collectors) + + if len(g.Devices) != 1 { + t.Fatalf("expected 1 device (from smi), got %d: %+v", len(g.Devices), g.Devices) + } + d := g.Devices[0] + if d.UUID != "GPU-aaa" || !d.RuntimeMetrics || d.DriverVersion != "535.0" { + t.Errorf("expected smi output (runtime+identity), got %+v", d) + } +} + +// NVIDIA host with all cards passed through: smi empty → fall back to lspci +// enumeration of NVIDIA cards (audio subfunctions skipped, indices contiguous). +func TestCollectGPUCore_NvidiaPassthroughFallback(t *testing.T) { + collectors := map[string]func(*model.GPU) bool{"nvidia": smiEmpty} + g := collectGPUCore(lspciPlainSample, collectors) + + if len(g.Devices) != 8 { + t.Fatalf("expected 8 NVIDIA GPUs from lspci (audio skipped), got %d: %+v", + len(g.Devices), g.Devices) + } + for i, d := range g.Devices { + if d.Index != i { + t.Errorf("dev %d index = %d, want %d", i, d.Index, i) + } + if d.Vendor != "nvidia" { + t.Errorf("dev %d vendor = %q, want nvidia", i, d.Vendor) + } + if d.RuntimeMetrics { + t.Errorf("dev %d RuntimeMetrics must be false (lspci fallback)", i) + } + } +} + +// Huawei host with all cards passed through: npu-smi empty → lspci fallback. +// Huawei Ascend 910B2C exposes the "Processing accelerators" PCI class (0x12), +// NOT a display class — this test pins that isGpuOrAccelerator recognizes it. +func TestCollectGPUCore_HuaweiPassthroughFallback(t *testing.T) { + // Production-shaped lspci (-Dnn) excerpt from a 910B2C host: 4 of the 16 + // NPUs, each "Processing accelerators" [1200], vendor 19e5:d802. + const lspciOut = `0000:18:00.0 Processing accelerators [1200]: Huawei Technologies Co., Ltd. Device d802 [19e5:d802] (rev 20) +0000:19:00.0 Processing accelerators [1200]: Huawei Technologies Co., Ltd. Device d802 [19e5:d802] (rev 20) +0000:38:00.0 Processing accelerators [1200]: Huawei Technologies Co., Ltd. Device d802 [19e5:d802] (rev 20) +0000:39:00.0 Processing accelerators [1200]: Huawei Technologies Co., Ltd. Device d802 [19e5:d802] (rev 20) +` + collectors := map[string]func(*model.GPU) bool{"huawei": smiEmpty} + g := collectGPUCore(lspciOut, collectors) + + if len(g.Devices) != 4 { + t.Fatalf("expected 4 NPUs from lspci fallback, got %d: %+v", + len(g.Devices), g.Devices) + } + for i, d := range g.Devices { + if d.Vendor != "huawei" { + t.Errorf("dev %d vendor = %q, want huawei", i, d.Vendor) + } + if d.Serial != "19e5:d802" { + t.Errorf("dev %d serial = %q, want 19e5:d802", i, d.Serial) + } + if d.Index != i { + t.Errorf("dev %d index = %d, want %d (renumbered contiguous)", i, d.Index, i) + } + if d.RuntimeMetrics { + t.Errorf("dev %d RuntimeMetrics must be false (lspci fallback)", i) + } + } +} + +// Unknown vendor (no registered collector) → dropped, empty GPU set. This is +// the "no integrated GPUs" policy: an Intel iGPU must not be recorded. +func TestCollectGPUCore_UnknownVendorDropped(t *testing.T) { + // lspciMixedVendorSample has Intel + AMD display devices, neither registered. + g := collectGPUCore(lspciMixedVendorSample, gpuVendorCollectors) + if len(g.Devices) != 0 { + t.Fatalf("expected 0 devices for unknown vendors, got %d: %+v", + len(g.Devices), g.Devices) + } +} + +// No display-class device at all (empty lspci) → empty GPU set. +func TestCollectGPUCore_NoDisplayDevice(t *testing.T) { + g := collectGPUCore("", gpuVendorCollectors) + if g == nil || len(g.Devices) != 0 { + t.Fatalf("expected empty GPU set, got %+v", g) + } +} + +// lspci sees a non-registered vendor first, then a registered one: routing +// uses the first RECOGNIZED vendor, not the literal first device. +func TestCollectGPUCore_RecognizedVendorChosen(t *testing.T) { + // Intel iGPU first, then an NVIDIA card. probeGpuVendor must skip Intel + // (no collector) and pick "nvidia". + collectors := map[string]func(*model.GPU) bool{ + "nvidia": smiOK(model.GPUDevice{Index: 0, Vendor: "nvidia", Name: "from-smi", UUID: "GPU-x"}), + } + const lspciOut = `00:02.0 VGA compatible controller [0300]: Intel Corporation iGPU [8086:3e98] (rev 02) +01:00.0 VGA compatible controller [0300]: NVIDIA Corporation A100 [10de:20b5] (rev a1) +` + g := collectGPUCore(lspciOut, collectors) + if len(g.Devices) != 1 || g.Devices[0].Name != "from-smi" { + t.Fatalf("expected smi result despite Intel appearing first, got %+v", g.Devices) + } +} + +// Production scenario: an 8× RTX 4090 host whose BMC also exposes an ASPEED +// VGA controller. ASPEED (1a03) isn't a registered vendor, so probeGpuVendor +// skips it and routes to nvidia. Uses the real lspci shape from the fleet. +func TestCollectGPUCore_AspeedBmcSkippedRoutesToNvidia(t *testing.T) { + called := false + collectors := map[string]func(*model.GPU) bool{ + "nvidia": func(g *model.GPU) bool { + called = true + g.Devices = append(g.Devices, model.GPUDevice{ + Index: 0, Vendor: "nvidia", Name: "RTX 4090", UUID: "GPU-smi", + RuntimeMetrics: true, + }) + return true + }, + } + g := collectGPUCore(lspciAspeedAndNvidiaSample, collectors) + + if !called { + t.Fatal("nvidia smi collector was not invoked (ASPEED should not block routing)") + } + if len(g.Devices) != 1 || g.Devices[0].UUID != "GPU-smi" { + t.Fatalf("expected smi output, got %+v", g.Devices) + } +} + +// Production scenario: 8× compute-only NVIDIA cards show up as "3D controller" +// (PCI subclass 0x0302), not VGA. Verifies this subclass routes to nvidia. +func TestCollectGPUCore_Nvidia3DControllersRouteToNvidia(t *testing.T) { + collectors := map[string]func(*model.GPU) bool{ + "nvidia": smiOK(model.GPUDevice{Index: 0, Vendor: "nvidia", Name: "from-smi", UUID: "GPU-x"}), + } + g := collectGPUCore(lspciNvidia3DControllersSample, collectors) + if len(g.Devices) != 1 || g.Devices[0].Name != "from-smi" { + t.Fatalf("expected nvidia smi output for 3D-controller host, got %+v", g.Devices) + } +} + +// Moore Threads host: lspci sees mthreads (1ed5), smi succeeds → use smi +// output (richer: runtime + identity). The lspci enumeration must NOT also be +// appended. +func TestCollectGPUCore_MthreadsSmiSucceeds(t *testing.T) { + collectors := map[string]func(*model.GPU) bool{ + "mthreads": smiOK(model.GPUDevice{ + Index: 0, Vendor: "mthreads", Name: "MTT S5000", UUID: "399282a8", + DriverVersion: "3.3.5-server", MemoryTotalMB: 81920, RuntimeMetrics: true, + }), + } + g := collectGPUCore(lspciMthreads3DControllerSample, collectors) + if len(g.Devices) != 1 { + t.Fatalf("expected 1 device (from smi), got %d: %+v", len(g.Devices), g.Devices) + } + d := g.Devices[0] + if d.UUID != "399282a8" || !d.RuntimeMetrics || d.DriverVersion != "3.3.5-server" { + t.Errorf("expected smi output (runtime+identity), got %+v", d) + } +} + +// Moore Threads host with all cards passed through: smi empty → fall back to +// lspci enumeration of mthreads cards. Indices renumbered contiguous 0..N-1. +func TestCollectGPUCore_MthreadsPassthroughFallback(t *testing.T) { + collectors := map[string]func(*model.GPU) bool{"mthreads": smiEmpty} + g := collectGPUCore(lspciMthreads3DControllerSample, collectors) + if len(g.Devices) != 3 { + t.Fatalf("expected 3 mthreads GPUs from lspci fallback, got %d: %+v", + len(g.Devices), g.Devices) + } + for i, d := range g.Devices { + if d.Vendor != "mthreads" { + t.Errorf("dev %d vendor = %q, want mthreads", i, d.Vendor) + } + if d.Index != i { + t.Errorf("dev %d index = %d, want %d (renumbered)", i, d.Index, i) + } + if d.RuntimeMetrics { + t.Errorf("dev %d RuntimeMetrics must be false (lspci fallback)", i) + } + } +} + +// ---------------------------------- probeGpuVendor + +func TestProbeGpuVendor(t *testing.T) { + collectors := map[string]func(*model.GPU) bool{ + "nvidia": smiEmpty, "huawei": smiEmpty, "mthreads": smiEmpty} + cases := []struct { + name string + devs []model.GPUDevice + want string + }{ + {"empty", nil, ""}, + {"unknown only", []model.GPUDevice{{Vendor: "intel"}, {Vendor: "amd"}}, ""}, + {"nvidia present", []model.GPUDevice{{Vendor: "intel"}, {Vendor: "nvidia"}}, "nvidia"}, + {"huawei present", []model.GPUDevice{{Vendor: "huawei"}}, "huawei"}, + {"mthreads present", []model.GPUDevice{{Vendor: "mthreads"}}, "mthreads"}, + } + for _, c := range cases { + if got := probeGpuVendor(c.devs, collectors); got != c.want { + t.Errorf("%s: probeGpuVendor = %q, want %q", c.name, got, c.want) + } + } +} + +// ---------------------------------- filterDevs + +func TestFilterDevsReindexes(t *testing.T) { + // lspci assigns indices 0..N-1 across all display devices; filterDevs must + // pick only the target vendor AND renumber indices contiguously from 0 so + // the store's index-keyed change-diff isn't confused by gaps. + devs := []model.GPUDevice{ + {Index: 0, Vendor: "intel", UUID: "igpu"}, + {Index: 1, Vendor: "nvidia", UUID: "nv0"}, + {Index: 2, Vendor: "nvidia", UUID: "nv1"}, + {Index: 3, Vendor: "amd", UUID: "amd0"}, + } + got := filterDevs(devs, "nvidia") + if len(got) != 2 { + t.Fatalf("expected 2 nvidia devs, got %d: %+v", len(got), got) + } + if got[0].Index != 0 || got[0].UUID != "nv0" { + t.Errorf("got[0] = %+v, want Index=0 UUID=nv0", got[0]) + } + if got[1].Index != 1 || got[1].UUID != "nv1" { + t.Errorf("got[1] = %+v, want Index=1 UUID=nv1", got[1]) + } +} + +func TestFilterDevsEmpty(t *testing.T) { + if got := filterDevs(nil, "nvidia"); len(got) != 0 { + t.Fatalf("expected empty, got %+v", got) + } +} diff --git a/collector/asset/cmdb/machine.go b/collector/asset/cmdb/machine.go new file mode 100644 index 0000000000..babd7ac739 --- /dev/null +++ b/collector/asset/cmdb/machine.go @@ -0,0 +1,83 @@ +package cmdb + +import ( + "os" + "strings" + + "github.com/jaypipes/ghw" + "github.com/prometheus/node_exporter/collector/asset/cmdb/model" + "github.com/shirou/gopsutil/v3/host" +) + +var vmVendors = map[string]string{ + "vmware": "VMware", + "vmware, inc.": "VMware", + "qemu": "QEMU", + "kvm": "KVM", + "xen": "Xen", + "microsoft corporation": "Hyper-V", + "innotek gmbh": "VirtualBox", + "parallels": "Parallels", + "openvz": "OpenVZ", + "bochs": "Bochs", + "oracle corporation": "VirtualBox", + "amazon ec2": "Xen", +} + +func CollectMachine() (*model.Machine, error) { + m := &model.Machine{} + + var vendor, product string + if prod, err := ghw.Product(); err == nil && prod != nil { + vendor = prod.Vendor + product = prod.Name + } else { + vendor = readDMIFile("sys_vendor") + product = readDMIFile("product_name") + } + + var virtSystem, virtRole string + if info, err := host.Info(); err == nil { + virtSystem = info.VirtualizationSystem + virtRole = info.VirtualizationRole + } + + m.Type = detectMachineType(vendor, product, virtSystem, virtRole) + m.K8sNode = detectK8sNode() + + return m, nil +} + +// detectK8sNode 通过 kubelet 进程或 /var/lib/kubelet 判定本机是否为 K8s 节点。 +// 容器网络网卡会被 net 采集器排除,这里仅做节点类型标记。 +func detectK8sNode() bool { + if commandExists("kubelet") { + if out, err := runCmd("pgrep", "-x", "kubelet"); err == nil && strings.TrimSpace(out) != "" { + return true + } + } + if info, err := os.Stat("/var/lib/kubelet"); err == nil && info.IsDir() { + return true + } + return false +} + +func detectMachineType(vendor, product, virtSystem, virtRole string) string { + if virtRole == "guest" && virtSystem != "" { + return "virtual" + } + + v := strings.ToLower(strings.TrimSpace(vendor)) + p := strings.ToLower(strings.TrimSpace(product)) + for sig := range vmVendors { + if strings.Contains(v, sig) || strings.Contains(p, sig) { + return "virtual" + } + } + + if virtRole == "guest" { + return "virtual" + } + + return "physical" +} diff --git a/collector/asset/cmdb/memory.go b/collector/asset/cmdb/memory.go new file mode 100644 index 0000000000..92e50e42e5 --- /dev/null +++ b/collector/asset/cmdb/memory.go @@ -0,0 +1,98 @@ +package cmdb + +import ( + "regexp" + "strings" + + "github.com/prometheus/node_exporter/collector/asset/cmdb/model" +) + +func CollectMemory(machineType string) (*model.Memory, error) { + mm := &model.Memory{} + + if machineType != "virtual" { + mm.Modules = parseDMIDecodeMemory() + } + + return mm, nil +} + +func parseDMIDecodeMemory() []model.MemoryModule { + if !commandExists("dmidecode") { + return nil + } + + out, err := runCmd("dmidecode", "-t", "memory") + if err != nil { + return nil + } + + var modules []model.MemoryModule + var cur *model.MemoryModule + inDevice := false + + kvRe := regexp.MustCompile(`^\s+([A-Z][\w ]+):\s*(.*)$`) + + for _, line := range strings.Split(out, "\n") { + trimmed := strings.TrimSpace(line) + + if strings.HasPrefix(line, "Memory Device") || strings.HasPrefix(trimmed, "Memory Device") { + if inDevice && cur != nil { + modules = append(modules, *cur) + } + cur = &model.MemoryModule{} + inDevice = true + continue + } + + if line == "" || (!strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t")) { + if inDevice && cur != nil { + modules = append(modules, *cur) + cur = nil + inDevice = false + } + continue + } + + if !inDevice || cur == nil { + continue + } + + if m := kvRe.FindStringSubmatch(line); m != nil { + key := strings.TrimSpace(m[1]) + val := strings.TrimSpace(m[2]) + switch key { + case "Locator": + cur.Locator = val + case "Bank Locator": + cur.BankLocator = val + case "Size": + cur.Size = val + case "Type": + cur.Type = val + case "Speed": + cur.Speed = val + case "Manufacturer": + cur.Manufacturer = val + case "Serial Number": + cur.Serial = val + case "Part Number": + cur.PartNumber = val + } + } + } + + if inDevice && cur != nil { + modules = append(modules, *cur) + } + + filtered := modules[:0] + for _, m := range modules { + if strings.EqualFold(m.Size, "No Module Installed") || (m.Size == "" && m.Type == "") { + continue + } + filtered = append(filtered, m) + } + + return filtered +} diff --git a/collector/asset/cmdb/model/info.go b/collector/asset/cmdb/model/info.go new file mode 100644 index 0000000000..c8e16e573a --- /dev/null +++ b/collector/asset/cmdb/model/info.go @@ -0,0 +1,91 @@ +package model + +type Machine struct { + Type string `json:"type,omitempty"` + K8sNode bool `json:"k8s_node,omitempty"` +} + +// CPU carries only per-socket devices. Aggregate counts (sockets/cores/threads) +// are intentionally NOT stored here: they are derived by consumers from the +// device list (sockets = len(Devices), cores/threads = SUM over devices). The +// exporter no longer emits machine-level cpu_sockets/cpu_cores/cpu_threads +// metrics. +type CPU struct { + Devices []CPUDevice `json:"devices,omitempty"` +} + +type CPUDevice struct { + ModelName string `json:"model_name,omitempty"` + VendorID string `json:"vendor_id,omitempty"` + Cores int `json:"cores"` + Threads int `json:"threads"` + Mhz float64 `json:"mhz,omitempty"` + CacheKB int `json:"cache_kb,omitempty"` +} + +type MemoryModule struct { + Locator string `json:"locator,omitempty"` + BankLocator string `json:"bank_locator,omitempty"` + Size string `json:"size,omitempty"` + Type string `json:"type,omitempty"` + Speed string `json:"speed,omitempty"` + Manufacturer string `json:"manufacturer,omitempty"` + Serial string `json:"serial,omitempty"` + PartNumber string `json:"part_number,omitempty"` +} + +type Memory struct { + Modules []MemoryModule `json:"modules,omitempty"` +} + +type DiskDevice struct { + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Model string `json:"model,omitempty"` + Vendor string `json:"vendor,omitempty"` + Serial string `json:"serial,omitempty"` + SizeBytes uint64 `json:"size_bytes,omitempty"` +} + +type Disk struct { + Devices []DiskDevice `json:"devices,omitempty"` +} + +type GPUDevice struct { + Index int `json:"index"` + Vendor string `json:"vendor,omitempty"` + Name string `json:"name,omitempty"` + Serial string `json:"serial,omitempty"` + UUID string `json:"uuid,omitempty"` + Health string `json:"health,omitempty"` + MemoryTotalMB uint64 `json:"memory_total_mb,omitempty"` + MemoryUsedMB uint64 `json:"memory_used_mb,omitempty"` + MemoryFreeMB uint64 `json:"memory_free_mb,omitempty"` + Utilization float64 `json:"utilization,omitempty"` + Temperature float64 `json:"temperature,omitempty"` + PowerW float64 `json:"power_w,omitempty"` + DriverVersion string `json:"driver_version,omitempty"` + FirmwareVersion string `json:"firmware_version,omitempty"` + // RuntimeMetrics reports whether memory/utilization/temperature/power + // were actually read from the vendor tool. False means the device was + // only enumerated via lspci (e.g. passthrough/vfio) and the runtime + // fields are zero values — emitting them as 0 would be misleading. + RuntimeMetrics bool `json:"runtime_metrics,omitempty"` +} + +type GPU struct { + Devices []GPUDevice `json:"devices,omitempty"` +} + +type NetDevice struct { + Name string `json:"name,omitempty"` + Physical bool `json:"physical"` + Master string `json:"master,omitempty"` + Slaves []string `json:"slaves,omitempty"` + Vendor string `json:"vendor,omitempty"` + Driver string `json:"driver,omitempty"` +} + +type Net struct { + Devices []NetDevice `json:"devices,omitempty"` +} diff --git a/collector/asset/cmdb/net.go b/collector/asset/cmdb/net.go new file mode 100644 index 0000000000..c8b05e391e --- /dev/null +++ b/collector/asset/cmdb/net.go @@ -0,0 +1,144 @@ +package cmdb + +import ( + "os" + "path/filepath" + "strings" + + "github.com/prometheus/node_exporter/collector/asset/cmdb/model" + gpnet "github.com/shirou/gopsutil/v3/net" +) + +// virtualPrefixes 为容器/K8s 网络与回环等非资产网卡前缀,采集时整体排除。 +// 采用方案 A:K8s 容器网络完全不进 CMDB,仅保留物理网卡与宿主机网络配置。 +var virtualPrefixes = []string{ + "lo", // loopback + "docker", // docker0 默认桥 + "br-", // docker 自定义网络桥 + "veth", // 容器 veth pair + "cni", // CNI 桥 + "flannel", // flannel overlay + "calico", // calico + "cilium", // cilium + "tunl", // calico tunnel + "genev", // geneve tunnel + "kube-ipvs", // kube-proxy ipvs dummy + "ovn", // ovn-kubernetes + "nodelocaldns", +} + +func CollectNet() (*model.Net, error) { + n := &model.Net{Devices: []model.NetDevice{}} + + ifaces, err := gpnet.Interfaces() + if err != nil { + return n, err + } + + // 先扫一遍,建立 slave -> bond 映射和 bond -> slaves 映射, + // 用于后续在物理网卡上标记 master、在 bond 上列出 slaves。 + bondOf := map[string]string{} // slaveName -> bondName + slavesOf := map[string][]string{} // bondName -> []slaveName + for _, iface := range ifaces { + if !isBondNIC(iface.Name) { + continue + } + slaves := readBondSlaves(iface.Name) + slavesOf[iface.Name] = slaves + for _, s := range slaves { + bondOf[strings.TrimSpace(s)] = iface.Name + } + } + + for _, iface := range ifaces { + if isVirtualInterface(iface.Name) { + continue + } + + physical := isPhysicalNIC(iface.Name) + bond := isBondNIC(iface.Name) + + // 仅保留: 物理以太网卡(mlx5_core 等驱动的 IPoIB 接口虽挂在 + // /sys/class/net//device 上但属于 InfiniBand 虚拟 L3 口, + // 不计入以太网资产) 与 bond 聚合口。bridge/vlan/tap/ovs 等全部丢弃。 + if physical && isInfiniBandNIC(iface.Name) { + continue + } + if !physical && !bond { + continue + } + + dev := model.NetDevice{ + Name: iface.Name, + Physical: physical, + } + + if physical { + dev.Vendor = readSysNetVendor(iface.Name) + dev.Driver = readSysNetDriver(iface.Name) + // 标记从属的 bond(ens12f0 -> bond0);未加入 bond 的为空,单独显示。 + dev.Master = bondOf[iface.Name] + } else if bond { + dev.Driver = "bonding" + dev.Slaves = slavesOf[iface.Name] + } + + n.Devices = append(n.Devices, dev) + } + + return n, nil +} + +// readBondSlaves 读取 /sys/class/net//bonding/slaves,返回该 bond +// 下挂的物理从口列表(空格分隔)。 +func readBondSlaves(name string) []string { + s := strings.TrimSpace(readSysFile(filepath.Join("/sys/class/net", name, "bonding", "slaves"))) + if s == "" { + return nil + } + return strings.Fields(s) +} + +func isVirtualInterface(name string) bool { + low := strings.ToLower(name) + for _, p := range virtualPrefixes { + if strings.HasPrefix(low, p) { + return true + } + } + return false +} + +// isPhysicalNIC 通过 /sys/class/net//device 是否存在判定是否有 +// PCI/USB 等总线背板(物理网卡)。bonds/vlans/bridges 没有该路径。 +func isPhysicalNIC(name string) bool { + _, err := os.Stat(filepath.Join("/sys/class/net", name, "device")) + return err == nil +} + +// isBondNIC 判定是否为 bond 聚合口。bond master 在 +// /sys/class/net//bonding 下有 mode/slaves 等属性。 +func isBondNIC(name string) bool { + fi, err := os.Stat(filepath.Join("/sys/class/net", name, "bonding")) + return err == nil && fi.IsDir() +} + +// isInfiniBandNIC 通过 /sys/class/net//type 判定接口链路层类型。 +// type=32 (ARPHRD_INFINIBAND) 表示 IPoIB,是跑在 IB 硬件之上的虚拟 L3 口, +// 不计入以太网资产。type=1 (ARPHRD_ETHER) 才是以太网。 +func isInfiniBandNIC(name string) bool { + t := strings.TrimSpace(readSysFile(filepath.Join("/sys/class/net", name, "type"))) + return t == "32" +} + +func readSysNetVendor(name string) string { + return strings.TrimSpace(readSysFile(filepath.Join("/sys/class/net", name, "device", "vendor"))) +} + +func readSysNetDriver(name string) string { + link, err := os.Readlink(filepath.Join("/sys/class/net", name, "device", "driver")) + if err != nil { + return "" + } + return filepath.Base(link) +} diff --git a/collector/asset/cmdb/repro_test.go b/collector/asset/cmdb/repro_test.go new file mode 100644 index 0000000000..0de17c3a5e --- /dev/null +++ b/collector/asset/cmdb/repro_test.go @@ -0,0 +1,77 @@ +package cmdb + +import "testing" + +// Regression: when `npu-smi info` includes a Process info section that +// actually lists running processes (not just "No running processes found"), +// each process row has the same shape as a card header (numeric NPU idx + +// chip in col1, numeric Process id in the Health column). The parser must +// NOT turn those rows into phantom devices. Previously this sample yielded +// 4 devices (2 real + 2 phantom with name="0", health=""). +const npu910B1WithProcs = `+------------------------------------------------------------------------------------------------+ +| npu-smi 25.5.1 Version: 25.5.1 | ++---------------------------+---------------+----------------------------------------------------+ +| NPU Name | Health | Power(W) Temp(C) Hugepages-Usage(page)| +| Chip | Bus-Id | AICore(%) Memory-Usage(MB) HBM-Usage(MB) | ++===========================+===============+====================================================+ +| 0 910B1 | OK | 105.3 39 0 / 0 | +| 0 | 0000:C1:00.0 | 0 0 / 0 56017/ 65536 | ++===========================+===============+====================================================+ +| 1 910B1 | OK | 112.3 41 0 / 0 | +| 0 | 0000:01:00.0 | 0 0 / 0 55358/ 65536 | ++===========================+===============+====================================================+ ++---------------------------+---------------+----------------------------------------------------+ +| NPU Chip | Process id | Process name | Process memory(MB) | ++===========================+===============+====================================================+ +| 0 0 | 3755936 | python | 114 | +| 0 0 | 3755939 | python | 114 | +| 0 0 | 3755935 | python | 52245 | ++===========================+===============+====================================================+ +| 1 0 | 3755936 | python | 51829 | ++===========================+===============+====================================================+ +` + +func TestParseHuaweiNPUWithRunningProcesses(t *testing.T) { + devs := parseHuaweiNPU(npu910B1WithProcs) + if len(devs) != 2 { + t.Fatalf("expected 2 devices, got %d: %+v", len(devs), devs) + } + + seen := map[int]int{} + for _, d := range devs { + seen[d.Index]++ + } + for idx, c := range seen { + if c != 1 { + t.Errorf("index %d appears %d times, want 1", idx, c) + } + } + + d0 := devs[0] + if d0.Index != 0 || d0.Name != "910B1" || d0.Health != "OK" { + t.Errorf("d0 meta mismatch: %+v", d0) + } + if d0.UUID != "0000:C1:00.0" { + t.Errorf("d0 uuid = %q, want 0000:C1:00.0", d0.UUID) + } + if d0.MemoryUsedMB != 56017 || d0.MemoryTotalMB != 65536 { + t.Errorf("d0 mem = %d/%d, want 56017/65536", d0.MemoryUsedMB, d0.MemoryTotalMB) + } + if d0.PowerW != 105.3 || d0.Temperature != 39 { + t.Errorf("d0 power/temp = %v/%v, want 105.3/39", d0.PowerW, d0.Temperature) + } + if d0.DriverVersion != "25.5.1" { + t.Errorf("d0 driver = %q, want 25.5.1", d0.DriverVersion) + } + + d1 := devs[1] + if d1.Index != 1 || d1.UUID != "0000:01:00.0" { + t.Errorf("d1 mismatch: %+v", d1) + } + if d1.Health != "OK" { + t.Errorf("d1 health = %q, want OK (not a process id)", d1.Health) + } + if d1.MemoryUsedMB != 55358 || d1.MemoryTotalMB != 65536 { + t.Errorf("d1 mem = %d/%d, want 55358/65536", d1.MemoryUsedMB, d1.MemoryTotalMB) + } +} diff --git a/collector/asset/cmdb/types.go b/collector/asset/cmdb/types.go new file mode 100644 index 0000000000..0c6d5564bb --- /dev/null +++ b/collector/asset/cmdb/types.go @@ -0,0 +1,17 @@ +package cmdb + +// Re-export the data model types at the cmdb package level so consumers (the +// asset_* Prometheus collectors) can spell the collected value types as +// cmdb.Machine, cmdb.CPU, ... without importing the model sub-package. These +// are type aliases, so *cmdb.Machine is identical to *model.Machine: the values +// returned by CollectMachine/CollectCPU/etc. can be used interchangeably. +import "github.com/prometheus/node_exporter/collector/asset/cmdb/model" + +type ( + Machine = model.Machine + CPU = model.CPU + Memory = model.Memory + Disk = model.Disk + GPU = model.GPU + Net = model.Net +) diff --git a/collector/asset/cmdb/util.go b/collector/asset/cmdb/util.go new file mode 100644 index 0000000000..c68ef396ce --- /dev/null +++ b/collector/asset/cmdb/util.go @@ -0,0 +1,38 @@ +package cmdb + +import ( + "os" + "os/exec" + "strings" +) + +const dmiPath = "/sys/class/dmi/id/" + +func readDMIFile(name string) string { + b, err := os.ReadFile(dmiPath + name) + if err != nil { + return "" + } + return strings.TrimSpace(string(b)) +} + +func runCmd(name string, args ...string) (string, error) { + out, err := exec.Command(name, args...).Output() + if err != nil { + return "", err + } + return string(out), nil +} + +func commandExists(name string) bool { + _, err := exec.LookPath(name) + return err == nil +} + +func readSysFile(path string) string { + b, err := os.ReadFile(path) + if err != nil { + return "" + } + return string(b) +} diff --git a/collector/asset_common.go b/collector/asset_common.go new file mode 100644 index 0000000000..863aa735c7 --- /dev/null +++ b/collector/asset_common.go @@ -0,0 +1,126 @@ +// Copyright 2025 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package collector + +import ( + "fmt" + "os" + "strings" + "sync" + "time" + + "github.com/alecthomas/kingpin/v2" +) + +// Asset collector namespace. Every metric exposed by the asset_* collectors is +// built with this prefix, yielding siliconflow_asset_. +const assetNamespace = "siliconflow_asset" + +// assetCacheTTL bounds how often each asset_* collector re-runs its cmdb +// collection. Asset inventory is static (machine/cpu/memory/disk/net hardware +// doesn't change at runtime; gpu identity is stable), so every asset collector +// caches its cmdb.Collect* result and serves it on every scrape until the TTL +// elapses, then refreshes. This keeps scrape latency low (no dmidecode / +// nvidia-smi / lsblk / lspci shell-out per scrape) and matches a "one snapshot +// per day" inventory cadence. Set to 0 to disable caching and collect on every +// scrape. +var assetCacheTTL = kingpin.Flag( + "collector.asset.cache-ttl", + "Time-to-live for cached asset collection results. All asset collectors "+ + "(asset_cpu, asset_memory, asset_machine, asset_disk, asset_net, "+ + "asset_gpu) cache their cmdb result and serve it on every scrape until "+ + "the TTL elapses, then refresh. Set to 0 to disable caching entirely.", +).Default("24h").Duration() + +// assetUUIDFilePath is the persistent UUID written by --generate-uuid and read +// by every asset_* collector to label its metrics with asset_uuid. Hardcoded by +// design; change here to relocate. Exported as AssetUUIDFilePath so the main +// binary can reference it in the --generate-uuid flag help text. +const assetUUIDFilePath = "/var/lib/siliconflow_asset/uuid" + +// AssetUUIDFilePath is the filesystem path of the persistent asset UUID file. +const AssetUUIDFilePath = assetUUIDFilePath + +// assetUUIDLabel is the label name carrying the persistent asset UUID on every +// siliconflow_asset_* metric. Named asset_uuid (not "uuid") to avoid confusing +// it with the SMBIOS product UUID exposed as the machine_uuid label of +// siliconflow_asset_machine_info. +const assetUUIDLabel = "asset_uuid" + +// readAssetUUID reads the persistent asset UUID written by --generate-uuid. +// It is called by each asset_* collector at scrape time (not at construction +// time) so the UUID may be generated after the exporter has started. On failure +// the collector returns the error: node_scrape_collector_success{collector=...} +// is set to 0 and an error is logged, so a missing UUID never produces metrics +// with an empty asset_uuid label. +func readAssetUUID() (string, error) { + b, err := os.ReadFile(assetUUIDFilePath) + if err != nil { + return "", fmt.Errorf("read asset uuid %s: %w (generate it first with --generate-uuid)", assetUUIDFilePath, err) + } + return strings.TrimSpace(string(b)), nil +} + +// assetLabel sanitizes a string for use as a Prometheus label value. DMI/SMBIOS +// and sysfs strings occasionally contain invalid UTF-8 sequences; replace them +// so the text-format exposition stays valid (mirrors the dmi collector). +func assetLabel(s string) string { + return strings.ToValidUTF8(s, "?") +} + +// assetBool renders a bool as a stable "true"/"false" label value. +func assetBool(b bool) string { + if b { + return "true" + } + return "false" +} + +// assetCache is a thread-safe TTL cache for a single collected value. Each +// asset_* collector holds one instance for its cmdb result. On a cache miss the +// given fetch function runs and its result is cached for ttl; while the cache is +// fresh, scrapes serve the cached value without re-running dmidecode / +// nvidia-smi / lsblk / lspci. If a refresh fails and a previous value exists, +// the stale value is served so a transient collection failure doesn't drop +// inventory metrics; only when no value has ever been collected is the error +// propagated (setting node_scrape_collector_success=0). +type assetCache[T any] struct { + mu sync.Mutex + value T + fetched time.Time + hasValue bool +} + +func (c *assetCache[T]) get(ttl time.Duration, fetch func() (T, error)) (T, error) { + c.mu.Lock() + defer c.mu.Unlock() + if ttl > 0 && c.hasValue && time.Since(c.fetched) < ttl { + return c.value, nil + } + v, err := fetch() + if err != nil { + if c.hasValue { + // Serve stale cache on transient failure rather than dropping + // inventory metrics. The cache timestamp is NOT advanced, so the + // next scrape will retry the refresh. + return c.value, nil + } + var zero T + return zero, err + } + c.value = v + c.fetched = time.Now() + c.hasValue = true + return v, nil +} diff --git a/collector/asset_cpu_linux.go b/collector/asset_cpu_linux.go new file mode 100644 index 0000000000..764a94620b --- /dev/null +++ b/collector/asset_cpu_linux.go @@ -0,0 +1,98 @@ +// Copyright 2025 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux && !noasset_cpu + +package collector + +import ( + "log/slog" + "strconv" + + "github.com/prometheus/client_golang/prometheus" + + "github.com/prometheus/node_exporter/collector/asset/cmdb" +) + +type assetCPUCollector struct { + info *prometheus.Desc + deviceCores *prometheus.Desc + deviceThreads *prometheus.Desc + deviceCache *prometheus.Desc + deviceFrequency *prometheus.Desc + cache assetCache[*cmdb.CPU] + logger *slog.Logger +} + +func init() { + registerCollector("asset_cpu", defaultEnabled, NewAssetCPUCollector) +} + +// NewAssetCPUCollector returns a collector exposing per-socket CPU topology +// and identity under siliconflow_asset_*. Machine-level aggregate counts +// (sockets/cores/threads) are no longer emitted; consumers derive them from +// the per-socket device metrics. +func NewAssetCPUCollector(logger *slog.Logger) (Collector, error) { + return &assetCPUCollector{ + info: prometheus.NewDesc( + prometheus.BuildFQName(assetNamespace, "", "cpu_info"), + "A metric with a constant '1' value labeled by per-socket CPU identity (model name, vendor id).", + []string{assetUUIDLabel, "socket", "model_name", "vendor_id"}, nil, + ), + deviceCores: prometheus.NewDesc( + prometheus.BuildFQName(assetNamespace, "", "cpu_device_cores"), + "Number of physical cores on a single socket.", + []string{assetUUIDLabel, "socket"}, nil, + ), + deviceThreads: prometheus.NewDesc( + prometheus.BuildFQName(assetNamespace, "", "cpu_device_threads"), + "Number of logical threads on a single socket.", + []string{assetUUIDLabel, "socket"}, nil, + ), + deviceCache: prometheus.NewDesc( + prometheus.BuildFQName(assetNamespace, "", "cpu_device_cache_kb"), + "CPU cache size of a single socket in kilobytes.", + []string{assetUUIDLabel, "socket"}, nil, + ), + deviceFrequency: prometheus.NewDesc( + prometheus.BuildFQName(assetNamespace, "", "cpu_device_frequency_mhz"), + "CPU base frequency of a single socket in megahertz (from /proc/cpuinfo).", + []string{assetUUIDLabel, "socket"}, nil, + ), + logger: logger, + }, nil +} + +func (c *assetCPUCollector) Update(ch chan<- prometheus.Metric) error { + uuid, err := readAssetUUID() + if err != nil { + return err + } + cpu, err := c.cache.get(*assetCacheTTL, func() (*cmdb.CPU, error) { + return cmdb.CollectCPU() + }) + if err != nil { + return err + } + + for i, dev := range cpu.Devices { + socket := strconv.Itoa(i) + ch <- prometheus.MustNewConstMetric(c.info, prometheus.GaugeValue, 1, + uuid, socket, assetLabel(dev.ModelName), assetLabel(dev.VendorID)) + ch <- prometheus.MustNewConstMetric(c.deviceCores, prometheus.GaugeValue, float64(dev.Cores), uuid, socket) + ch <- prometheus.MustNewConstMetric(c.deviceThreads, prometheus.GaugeValue, float64(dev.Threads), uuid, socket) + ch <- prometheus.MustNewConstMetric(c.deviceCache, prometheus.GaugeValue, float64(dev.CacheKB), uuid, socket) + ch <- prometheus.MustNewConstMetric(c.deviceFrequency, prometheus.GaugeValue, dev.Mhz, uuid, socket) + } + return nil +} diff --git a/collector/asset_disk_linux.go b/collector/asset_disk_linux.go new file mode 100644 index 0000000000..5c26512c80 --- /dev/null +++ b/collector/asset_disk_linux.go @@ -0,0 +1,82 @@ +// Copyright 2025 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux && !noasset_disk + +package collector + +import ( + "log/slog" + + "github.com/prometheus/client_golang/prometheus" + + "github.com/prometheus/node_exporter/collector/asset/cmdb" +) + +type assetDiskCollector struct { + info *prometheus.Desc + sizeGB *prometheus.Desc + cache assetCache[*cmdb.Disk] + logger *slog.Logger +} + +func init() { + registerCollector("asset_disk", defaultEnabled, NewAssetDiskCollector) +} + +// NewAssetDiskCollector returns a collector exposing block device identity and +// capacity under siliconflow_asset_*. +func NewAssetDiskCollector(logger *slog.Logger) (Collector, error) { + return &assetDiskCollector{ + info: prometheus.NewDesc( + prometheus.BuildFQName(assetNamespace, "", "disk_info"), + "A metric with a constant '1' value labeled by block device identity (name, model, vendor, serial).", + []string{ + assetUUIDLabel, "name", "type", "model", "vendor", "serial", + }, + nil, + ), + sizeGB: prometheus.NewDesc( + prometheus.BuildFQName(assetNamespace, "", "disk_size_gb"), + "Block device capacity in gigabytes (1 GB = 10^9 bytes).", + []string{assetUUIDLabel, "name"}, nil, + ), + logger: logger, + }, nil +} + +func (c *assetDiskCollector) Update(ch chan<- prometheus.Metric) error { + uuid, err := readAssetUUID() + if err != nil { + return err + } + d, err := c.cache.get(*assetCacheTTL, func() (*cmdb.Disk, error) { + return cmdb.CollectDisk() + }) + if err != nil { + return err + } + + for _, dev := range d.Devices { + ch <- prometheus.MustNewConstMetric(c.info, prometheus.GaugeValue, 1, + uuid, + assetLabel(dev.Name), + assetLabel(dev.Type), + assetLabel(dev.Model), + assetLabel(dev.Vendor), + assetLabel(dev.Serial), + ) + ch <- prometheus.MustNewConstMetric(c.sizeGB, prometheus.GaugeValue, float64(dev.SizeBytes)/1e9, uuid, dev.Name) + } + return nil +} diff --git a/collector/asset_gpu_linux.go b/collector/asset_gpu_linux.go new file mode 100644 index 0000000000..cbebe94656 --- /dev/null +++ b/collector/asset_gpu_linux.go @@ -0,0 +1,82 @@ +// Copyright 2025 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux && !noasset_gpu + +package collector + +import ( + "log/slog" + "strconv" + + "github.com/prometheus/client_golang/prometheus" + + "github.com/prometheus/node_exporter/collector/asset/cmdb" +) + +type assetGPUCollector struct { + info *prometheus.Desc + cache assetCache[*cmdb.GPU] + logger *slog.Logger +} + +func init() { + registerCollector("asset_gpu", defaultEnabled, NewAssetGPUCollector) +} + +// NewAssetGPUCollector returns a collector exposing GPU/NPU identity under +// siliconflow_asset_*. NVIDIA, Huawei NPU and a catch-all lspci fallback are +// handled by the vendored cmdb collector. The cmdb call is wrapped in assetCache +// so the per-scrape nvidia-smi/npu-smi/lspci shell-outs only run once per TTL. +func NewAssetGPUCollector(logger *slog.Logger) (Collector, error) { + return &assetGPUCollector{ + info: prometheus.NewDesc( + prometheus.BuildFQName(assetNamespace, "", "gpu_info"), + "A metric with a constant '1' value labeled by per-device GPU/NPU identity (vendor, name, serial, UUID, driver/firmware version, memory total).", + []string{ + assetUUIDLabel, "index", "vendor", "name", "serial", "gpu_uuid", + "driver_version", "firmware_version", "memory_total_mb", + }, + nil, + ), + logger: logger, + }, nil +} + +func (c *assetGPUCollector) Update(ch chan<- prometheus.Metric) error { + uuid, err := readAssetUUID() + if err != nil { + return err + } + g, err := c.cache.get(*assetCacheTTL, func() (*cmdb.GPU, error) { + return cmdb.CollectGPU() + }) + if err != nil { + return err + } + + for _, dev := range g.Devices { + idx := strconv.Itoa(dev.Index) + ch <- prometheus.MustNewConstMetric(c.info, prometheus.GaugeValue, 1, + uuid, idx, + assetLabel(dev.Vendor), + assetLabel(dev.Name), + assetLabel(dev.Serial), + assetLabel(dev.UUID), + assetLabel(dev.DriverVersion), + assetLabel(dev.FirmwareVersion), + strconv.FormatUint(dev.MemoryTotalMB, 10), + ) + } + return nil +} diff --git a/collector/asset_machine_linux.go b/collector/asset_machine_linux.go new file mode 100644 index 0000000000..ee6d2960a5 --- /dev/null +++ b/collector/asset_machine_linux.go @@ -0,0 +1,69 @@ +// Copyright 2025 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux && !noasset_machine + +package collector + +import ( + "log/slog" + + "github.com/prometheus/client_golang/prometheus" + + "github.com/prometheus/node_exporter/collector/asset/cmdb" +) + +type assetMachineCollector struct { + info *prometheus.Desc + cache assetCache[*cmdb.Machine] + logger *slog.Logger +} + +func init() { + registerCollector("asset_machine", defaultEnabled, NewAssetMachineCollector) +} + +// NewAssetMachineCollector returns a collector exposing machine hardware +// identity (vendor/product/serial/board/kernel/OS/...) under siliconflow_asset_*. +func NewAssetMachineCollector(logger *slog.Logger) (Collector, error) { + return &assetMachineCollector{ + info: prometheus.NewDesc( + prometheus.BuildFQName(assetNamespace, "", "machine_info"), + "A metric with a constant '1' value labeled by machine type and k8s_node.", + []string{ + assetUUIDLabel, "type", "k8s_node", + }, + nil, + ), + logger: logger, + }, nil +} + +func (c *assetMachineCollector) Update(ch chan<- prometheus.Metric) error { + uuid, err := readAssetUUID() + if err != nil { + return err + } + m, err := c.cache.get(*assetCacheTTL, func() (*cmdb.Machine, error) { + return cmdb.CollectMachine() + }) + if err != nil { + return err + } + ch <- prometheus.MustNewConstMetric(c.info, prometheus.GaugeValue, 1, + uuid, + assetLabel(m.Type), + assetBool(m.K8sNode), + ) + return nil +} diff --git a/collector/asset_memory_linux.go b/collector/asset_memory_linux.go new file mode 100644 index 0000000000..86e9418463 --- /dev/null +++ b/collector/asset_memory_linux.go @@ -0,0 +1,88 @@ +// Copyright 2025 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux && !noasset_memory + +package collector + +import ( + "log/slog" + + "github.com/prometheus/client_golang/prometheus" + + "github.com/prometheus/node_exporter/collector/asset/cmdb" +) + +type assetMemoryCollector struct { + moduleInfo *prometheus.Desc + cache assetCache[*cmdb.Memory] + logger *slog.Logger +} + +func init() { + registerCollector("asset_memory", defaultEnabled, NewAssetMemoryCollector) +} + +// NewAssetMemoryCollector returns a collector exposing memory size and per-DIMM +// module identity under siliconflow_asset_*. DIMM details are only collected on +// physical machines (synthetic on VMs), so it first determines the machine type +// via CollectMachine and forwards it to CollectMemory. +func NewAssetMemoryCollector(logger *slog.Logger) (Collector, error) { + return &assetMemoryCollector{ + moduleInfo: prometheus.NewDesc( + prometheus.BuildFQName(assetNamespace, "", "memory_module_info"), + "A metric with a constant '1' value labeled by per-DIMM identity (locator, size, type, speed, manufacturer, serial, part number).", + []string{ + assetUUIDLabel, "locator", "bank_locator", "size", "type", "speed", + "manufacturer", "serial", "part_number", + }, + nil, + ), + logger: logger, + }, nil +} + +func (c *assetMemoryCollector) Update(ch chan<- prometheus.Metric) error { + uuid, err := readAssetUUID() + if err != nil { + return err + } + // CollectMemory takes the machine type so it can skip dmidecode on virtual + // machines (whose SMBIOS data is synthetic). Determine it inside the cache + // fetch so the resolved Memory (including the dmidecode result) is cached. + mem, err := c.cache.get(*assetCacheTTL, func() (*cmdb.Memory, error) { + machineType := "" + if m, e := cmdb.CollectMachine(); e == nil { + machineType = m.Type + } + return cmdb.CollectMemory(machineType) + }) + if err != nil { + return err + } + + for _, mod := range mem.Modules { + ch <- prometheus.MustNewConstMetric(c.moduleInfo, prometheus.GaugeValue, 1, + uuid, + assetLabel(mod.Locator), + assetLabel(mod.BankLocator), + assetLabel(mod.Size), + assetLabel(mod.Type), + assetLabel(mod.Speed), + assetLabel(mod.Manufacturer), + assetLabel(mod.Serial), + assetLabel(mod.PartNumber), + ) + } + return nil +} diff --git a/collector/asset_net_linux.go b/collector/asset_net_linux.go new file mode 100644 index 0000000000..1a62db7f67 --- /dev/null +++ b/collector/asset_net_linux.go @@ -0,0 +1,80 @@ +// Copyright 2025 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux && !noasset_net + +package collector + +import ( + "log/slog" + "strings" + + "github.com/prometheus/client_golang/prometheus" + + "github.com/prometheus/node_exporter/collector/asset/cmdb" +) + +type assetNetCollector struct { + info *prometheus.Desc + cache assetCache[*cmdb.Net] + logger *slog.Logger +} + +func init() { + registerCollector("asset_net", defaultEnabled, NewAssetNetCollector) +} + +// NewAssetNetCollector returns a collector exposing physical NIC and bond +// identity and topology under siliconflow_asset_*. Only physical NICs and +// bond interfaces are reported (container/K8s virtual interfaces excluded by the +// vendored cmdb collector). +func NewAssetNetCollector(logger *slog.Logger) (Collector, error) { + return &assetNetCollector{ + info: prometheus.NewDesc( + prometheus.BuildFQName(assetNamespace, "", "net_info"), + "A metric with a constant '1' value labeled by NIC identity (physical, bond master, slaves, vendor, driver).", + []string{ + assetUUIDLabel, "name", "physical", "master", + "slaves", "vendor", "driver", + }, + nil, + ), + logger: logger, + }, nil +} + +func (c *assetNetCollector) Update(ch chan<- prometheus.Metric) error { + uuid, err := readAssetUUID() + if err != nil { + return err + } + n, err := c.cache.get(*assetCacheTTL, func() (*cmdb.Net, error) { + return cmdb.CollectNet() + }) + if err != nil { + return err + } + + for _, dev := range n.Devices { + ch <- prometheus.MustNewConstMetric(c.info, prometheus.GaugeValue, 1, + uuid, + assetLabel(dev.Name), + assetBool(dev.Physical), + assetLabel(dev.Master), + strings.Join(dev.Slaves, ","), + assetLabel(dev.Vendor), + assetLabel(dev.Driver), + ) + } + return nil +} diff --git a/docs/asset_label_migration.md b/docs/asset_label_migration.md new file mode 100644 index 0000000000..6341353cb0 --- /dev/null +++ b/docs/asset_label_migration.md @@ -0,0 +1,200 @@ +# Asset 指标去重变更映射 + +本文档记录 `siliconflow_asset_*` 系列指标中移除的与 node_exporter 默认采集器重复的部分,以及对应的替代指标。 + +--- + +## 1. siliconflow_asset_machine_info + +**变更说明:** 移除了与 `dmi`、`uname`、`os` 采集器重复的标签,仅保留独有字段。 + +### 新指标格式 + +``` +siliconflow_asset_machine_info{uuid="", type="", k8s_node=""} 1 +``` + +### 旧标签 → 替代指标映射 + +| 旧标签 | 替代指标 | 替代标签 | 采集器 | +|---|---|---|---| +| `vendor` | `node_dmi_info` | `system_vendor` | dmi | +| `product` | `node_dmi_info` | `product_name` | dmi | +| `version` | `node_dmi_info` | `product_version` | dmi | +| `serial` | `node_dmi_info` | `product_serial` | dmi | +| `machine_uuid` | `node_dmi_info` | `product_uuid` | dmi | +| `hostname` | `node_uname_info` | `nodename` | uname | +| `kernel` | `node_uname_info` | `release` | uname | +| `kernel_arch` | `node_uname_info` | `machine` | uname | +| `os` | `node_os_info` | `name` | os | +| `os_version` | `node_os_info` | `version_id` | os | +| `board_vendor` | `node_dmi_info` | `board_vendor` | dmi | +| `board_name` | `node_dmi_info` | `board_name` | dmi | +| `board_version` | `node_dmi_info` | `board_version` | dmi | +| `board_serial` | `node_dmi_info` | `board_serial` | dmi | + +### 保留的标签 + +| 标签 | 说明 | 为何保留 | +|---|---|---| +| `uuid` | 机器唯一标识 | asset 采集器的关联键 | +| `type` | 物理机/虚拟机 | 默认采集器无此信息,需通过 ghw + gopsutil 综合判定 | +| `k8s_node` | 是否为 K8s 节点 | 默认采集器无此信息,需检测 kubelet 进程 | + +--- + +## 2. siliconflow_asset_cpu_device_frequency_mhz + +**变更说明:** 原计划移除整个指标、CPU 频率信息由 `cpufreq` 采集器提供。**已回退此变更并恢复该指标**:云 VM(阿里云 ECS 等)的 guest 内核不暴露 `/sys/devices/system/cpu/cpuN/cpufreq/`,`cpufreq` 采集器虽启用但零输出,导致 `node_cpu_frequency_*` 全库缺失、CMDB 的 `mhz` 列全为 0。该 asset 指标读 `/proc/cpuinfo` 的 `cpu MHz`(静态基频,VM 上稳定可得),作为 VM 场景的主源重新保留。 + +### 当前策略(优先级) + +| 优先级 | 指标 | 来源 | 适用场景 | +|---|---|---|---| +| 主源 | `siliconflow_asset_cpu_device_frequency_mhz` | `/proc/cpuinfo` 的 `cpu MHz`(per-socket,asset_cpu 采集器) | VM 及裸金属通用,VM 上为唯一可得来源 | +| 回退 | `node_cpu_frequency_max_hertz` | sysfs cpufreq(÷1e6) | 裸金属 / 暴露 cpufreq sysfs 的主机,asset 指标缺失时由消费方回退使用 | + +### 旧指标 → 替代指标映射(历史记录,供回溯) + +| 旧指标 | 旧标签 | 替代指标 | 替代标签 | 采集器 | +|---|---|---|---|---| +| `siliconflow_asset_cpu_device_frequency_mhz` | `uuid`, `socket` | `node_cpu_frequency_min_hertz` | `cpu` | cpufreq | +| | | `node_cpu_frequency_max_hertz` | `cpu` | cpufreq | +| | | `node_cpu_scaling_frequency_min_hertz` | `cpu` | cpufreq | +| | | `node_cpu_scaling_frequency_max_hertz` | `cpu` | cpufreq | +| | | `node_cpu_scaling_frequency_hertz` | `cpu` | cpufreq | + +> **注意:** asset 指标报告的是 CPU 基频(静态,来自 /proc/cpuinfo),cpufreq 报告的是运行时频率(动态,来自 sysfs)。`node_cpu_frequency_max_hertz` 通常接近基频,但在无 cpufreq sysfs 的 VM 上完全缺失,故保留 asset 指标作为主源。 + +### 移除的 CPU 总量指标 + +**变更说明:** 移除机器级 CPU 总量指标(`cpu_sockets` / `cpu_cores` / `cpu_threads`),只保留 per-socket 设备级指标。机器级总量改由消费方从 per-socket 指标派生:`sockets = count(cpu_info 按 socket 去重)`、`cores = sum(cpu_device_cores)`、`threads = sum(cpu_device_threads)`。同时新增 `cpu_device_threads` 补齐此前缺失的 per-socket 线程数。 + +| 旧指标 | 旧标签 | 派生方式 | +|---|---|---| +| `siliconflow_asset_cpu_sockets` | `uuid` | `count(cpu_info 按 socket 去重)` | +| `siliconflow_asset_cpu_cores` | `uuid` | `sum(cpu_device_cores)` | +| `siliconflow_asset_cpu_threads` | `uuid` | `sum(cpu_device_threads)` | + +| 新增指标 | 标签 | 说明 | +|---|---|---| +| `siliconflow_asset_cpu_device_threads` | `uuid`, `socket` | 单插槽逻辑线程数 | + +### 保留的 asset_cpu 指标 + +| 指标 | 标签 | 说明 | 为何保留 | +|---|---|---|---| +| `siliconflow_asset_cpu_info` | `uuid`, `socket`, `model_name`, `vendor_id` | CPU 型号标识 | 默认采集器无此信息 | +| `siliconflow_asset_cpu_device_cores` | `uuid`, `socket` | 单插槽核数 | 默认采集器无此信息 | +| `siliconflow_asset_cpu_device_threads` | `uuid`, `socket` | 单插槽逻辑线程数 | 默认采集器无此信息 | +| `siliconflow_asset_cpu_device_cache_kb` | `uuid`, `socket` | 单插槽缓存大小 | 默认采集器无此信息 | + +--- + +## 3. siliconflow_asset_net_info + +**变更说明:** 移除 `mac` 标签,MAC 地址由 `netclass` 采集器的 `node_network_info` 提供。 + +### 新指标格式 + +``` +siliconflow_asset_net_info{uuid="", name="", physical="", master="", slaves="", vendor="", driver=""} 1 +``` + +### 旧标签 → 替代指标映射 + +| 旧标签 | 替代指标 | 替代标签 | 采集器 | +|---|---|---|---| +| `mac` | `node_network_info` | `address` | netclass | + +### 保留的标签 + +| 标签 | 说明 | 为何保留 | +|---|---|---| +| `uuid` | 机器唯一标识 | asset 采集器的关联键 | +| `name` | 网卡名称 | 指标行的主标识符,不可移除 | +| `physical` | 是否物理网卡 | 默认采集器无此信息,需检测 /sys/class/net/\/device | +| `master` | 从属的 bond 口 | 默认采集器无此信息 | +| `slaves` | bond 下挂从口列表 | 默认采集器无此信息 | +| `vendor` | PCI 厂商 ID | 默认采集器无此信息 | +| `driver` | 驱动名称 | 默认采集器无此信息 | + +--- + +## 4. siliconflow_asset_memory_total_mb + +**变更说明:** 移除整个指标,总内存量由 `meminfo` 采集器提供。 + +### 旧指标 → 替代指标映射 + +| 旧指标 | 替代指标 | 换算关系 | 采集器 | +|---|---|---|---| +| `siliconflow_asset_memory_total_mb` | `node_memory_MemTotal_bytes` | `旧值 ≈ 新值 / 1024 / 1024` | meminfo | + +### 保留的 asset_memory 指标 + +| 指标 | 标签 | 说明 | 为何保留 | +|---|---|---|---| +| `siliconflow_asset_memory_module_info` | `uuid`, `locator`, `bank_locator`, `size`, `type`, `speed`, `manufacturer`, `serial`, `part_number` | DIMM 模块身份信息 | 默认采集器无此信息,需 dmidecode 采集 | + +--- + +## 5. 无变更的指标 + +以下指标与默认采集器无重复,保持不变: + +| 指标 | 说明 | +|---|---| +| `siliconflow_asset_disk_info` | 磁盘身份信息(name, type, model, vendor, serial),默认 diskstats 无此信息 | +| `siliconflow_asset_disk_size_gb` | 磁盘容量,默认 diskstats 仅报告 I/O 统计 | +| `siliconflow_asset_gpu_info` | GPU/NPU 身份信息(支持 NVIDIA / 华为 NPU / 摩尔线程),默认采集器无 GPU 采集 | + +--- + +## PromQL 迁移示例 + +```promql +# === machine === + +# 旧:查询厂商 +siliconflow_asset_machine_info{vendor="Dell Inc."} +# 新: +node_dmi_info{system_vendor="Dell Inc."} + +# 旧:查询操作系统 +siliconflow_asset_machine_info{os="ubuntu"} +# 新: +node_os_info{name="ubuntu"} + +# 旧:查询内核版本 +siliconflow_asset_machine_info{kernel="5.15.0-91-generic"} +# 新: +node_uname_info{release="5.15.0-91-generic"} + +# === cpu === + +# 查询 CPU 基频:asset 指标已恢复为主源(VM 上唯一可得) +siliconflow_asset_cpu_device_frequency_mhz{socket="0"} +# 回退:asset 指标缺失时(裸金属/有 cpufreq sysfs)用 node_cpu_frequency_max_hertz +node_cpu_frequency_max_hertz{cpu="0"} + +# === net === + +# 旧:查询网卡 MAC 地址 +siliconflow_asset_net_info{mac="aa:bb:cc:dd:ee:ff"} +# 新: +node_network_info{address="aa:bb:cc:dd:ee:ff"} + +# 旧:关联网卡 MAC 与 vendor/driver +siliconflow_asset_net_info{vendor="0x15b3"} +# 新:通过 device 名 join +siliconflow_asset_net_info{vendor="0x15b3"} * on(instance, device) group_left(address) + label_replace(node_network_info, "device", "$1", "device", "(.*)") + +# === memory === + +# 旧:查询总内存 (MB) +siliconflow_asset_memory_total_mb +# 新:查询总内存 (bytes) +node_memory_MemTotal_bytes / 1024 / 1024 +``` diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000000..fc36299a66 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -e + +UUID_FILE="/var/lib/siliconflow_asset/uuid" + +if [ ! -f "$UUID_FILE" ] || [ ! -s "$UUID_FILE" ]; then + /bin/node_exporter --generate-uuid +fi + +exec /bin/node_exporter "$@" diff --git a/go.mod b/go.mod index bd7983b8c0..2e90e6cc1e 100644 --- a/go.mod +++ b/go.mod @@ -9,9 +9,11 @@ require ( github.com/dennwc/btrfs v0.0.0-20260222081608-edfb8b9e4f55 github.com/ema/qdisc v1.0.0 github.com/godbus/dbus/v5 v5.2.2 + github.com/google/uuid v1.6.0 github.com/hashicorp/go-envparse v0.1.0 github.com/hodgesds/perf-utils v0.7.0 github.com/illumos/go-kstat v0.0.0-20210513183136-173c9b0a9973 + github.com/jaypipes/ghw v0.25.0 github.com/jsimonetti/rtnetlink/v2 v2.2.0 github.com/lufia/iostat v1.2.1 github.com/mattn/go-xmlrpc v0.0.3 @@ -27,8 +29,9 @@ require ( github.com/prometheus/exporter-toolkit v0.17.1 github.com/prometheus/procfs v0.21.1 github.com/safchain/ethtool v0.7.0 + github.com/shirou/gopsutil/v3 v3.24.5 golang.org/x/sys v0.47.0 - howett.net/plist v1.0.1 + howett.net/plist v1.0.2-0.20250314012144-ee69052608d9 ) require ( @@ -38,18 +41,24 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/dennwc/ioctl v1.0.1-0.20181021180353-017804252068 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/uuid v1.6.0 // indirect + github.com/jaypipes/pcidb v1.1.1 // indirect github.com/jpillora/backoff v1.0.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/mdlayher/genetlink v1.4.0 // indirect github.com/mdlayher/socket v0.6.1 // indirect github.com/mdlayher/vsock v1.3.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect + github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/siebenmann/go-kstat v0.0.0-20210513183136-173c9b0a9973 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect github.com/xhit/go-str2duration/v2 v2.1.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect go.uber.org/atomic v1.7.0 // indirect go.uber.org/multierr v1.6.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect @@ -60,4 +69,5 @@ require ( golang.org/x/text v0.40.0 // indirect golang.org/x/time v0.15.0 // indirect google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 0ab3abcb66..a8c3ddd444 100644 --- a/go.sum +++ b/go.sum @@ -25,10 +25,13 @@ github.com/dennwc/ioctl v1.0.1-0.20181021180353-017804252068 h1:K71w/n/Y74EQsKo9 github.com/dennwc/ioctl v1.0.1-0.20181021180353-017804252068/go.mod h1:ellh2YB5ldny99SBU/VX7Nq0xiZbHphf1DrtHxxjMk0= github.com/ema/qdisc v1.0.0 h1:EHLG08FVRbWLg8uRICa3xzC9Zm0m7HyMHfXobWFnXYg= github.com/ema/qdisc v1.0.0/go.mod h1:FhIc0fLYi7f+lK5maMsesDqwYojIOh3VfRs8EVd5YJQ= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -39,6 +42,10 @@ github.com/hodgesds/perf-utils v0.7.0 h1:7KlHGMuig4FRH5fNw68PV6xLmgTe7jKs9hgAcEA github.com/hodgesds/perf-utils v0.7.0/go.mod h1:LAklqfDadNKpkxoAJNHpD5tkY0rkZEVdnCEWN5k4QJY= github.com/illumos/go-kstat v0.0.0-20210513183136-173c9b0a9973 h1:hk4LPqXIY/c9XzRbe7dA6qQxaT6Axcbny0L/G5a4owQ= github.com/illumos/go-kstat v0.0.0-20210513183136-173c9b0a9973/go.mod h1:PoK3ejP3LJkGTzKqRlpvCIFas3ncU02v8zzWDW+g0FY= +github.com/jaypipes/ghw v0.25.0 h1:+7HlAHtQSrCOafYC6oRjqxuCzDZXBr2dFgVlYRRafrs= +github.com/jaypipes/ghw v0.25.0/go.mod h1:Qk3UjdH8Xu/OiVyb/eDJqnDsUc+awHU75y23ErZU33s= +github.com/jaypipes/pcidb v1.1.1 h1:QmPhpsbmmnCwZmHeYAATxEaoRuiMAJusKYkUncMC0ro= +github.com/jaypipes/pcidb v1.1.1/go.mod h1:x27LT2krrUgjf875KxQXKB0Ha/YXLdZRVmw6hH0G7g8= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= @@ -50,6 +57,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lufia/iostat v1.2.1 h1:tnCdZBIglgxD47RyD55kfWQcJMGzO+1QBziSQfesf2k= github.com/lufia/iostat v1.2.1/go.mod h1:rEPNA0xXgjHQjuI5Cy05sLlS2oRcSlWHRLrvh/AQ+Pg= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/mattn/go-xmlrpc v0.0.3 h1:Y6WEMLEsqs3RviBrAa1/7qmbGB7DVD3brZIbqMbQdGY= github.com/mattn/go-xmlrpc v0.0.3/go.mod h1:mqc2dz7tP5x5BKlCahN/n+hs7OSZKJkS9JsHNBRlrxA= github.com/mdlayher/ethtool v0.6.1 h1:fSfcX6EN3yBqcB+vsCnq8hpbIT4vEa7T+BKb0NjT894= @@ -88,6 +97,12 @@ github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+ github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= github.com/safchain/ethtool v0.7.0 h1:rlJzfDetsVvT61uz8x1YIcFn12akMfuPulHtZjtb7Is= github.com/safchain/ethtool v0.7.0/go.mod h1:MenQKEjXdfkjD3mp2QdCk8B/hwvkrlOTm/FD4gTpFxQ= +github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= +github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= +github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= github.com/siebenmann/go-kstat v0.0.0-20210513183136-173c9b0a9973 h1:GfSdC6wKfTGcgCS7BtzF5694Amne1pGCSTY252WhlEY= github.com/siebenmann/go-kstat v0.0.0-20210513183136-173c9b0a9973/go.mod h1:G81aIFAMS9ECrwBYR9YxhlPjWgrItd+Kje78O6+uqm8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -101,8 +116,14 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -119,20 +140,24 @@ golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20211031064116-611d5d643895/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= -howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= +howett.net/plist v1.0.2-0.20250314012144-ee69052608d9 h1:eeH1AIcPvSc0Z25ThsYF+Xoqbn0CI/YnXVYoTLFdGQw= +howett.net/plist v1.0.2-0.20250314012144-ee69052608d9/go.mod h1:fyFX5Hj5tP1Mpk8obqA9MZgXT416Q5711SDT7dQLTLk= diff --git a/node_exporter.go b/node_exporter.go index 17c372eb31..e7fe61838d 100644 --- a/node_exporter.go +++ b/node_exporter.go @@ -20,6 +20,7 @@ import ( _ "net/http/pprof" "os" "os/user" + "path/filepath" "runtime" "slices" "sort" @@ -28,6 +29,7 @@ import ( "github.com/prometheus/common/promslog/flag" "github.com/alecthomas/kingpin/v2" + "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" promcollectors "github.com/prometheus/client_golang/prometheus/collectors" versioncollector "github.com/prometheus/client_golang/prometheus/collectors/version" @@ -200,6 +202,11 @@ func main() { maxProcs = kingpin.Flag( "runtime.gomaxprocs", "The target number of CPUs Go will run on (GOMAXPROCS)", ).Envar("GOMAXPROCS").Default("1").Int() + generateUUID = kingpin.Flag( + "generate-uuid", + "Generate a new asset UUID v4, write it to "+collector.AssetUUIDFilePath+" and exit. "+ + "The siliconflow_asset_* collectors label their metrics with this UUID.", + ).Bool() toolkitFlags = kingpinflag.AddFlags(kingpin.CommandLine, ":9100") ) @@ -211,6 +218,25 @@ func main() { kingpin.Parse() logger := promslog.New(promslogConfig) + // --generate-uuid: write a fresh asset UUID v4 to the asset UUID file and + // exit. The siliconflow_asset_* collectors read this file at scrape time to + // label their metrics with asset_uuid. Run this once per host before (or + // independently of) starting the exporter. + if *generateUUID { + id := uuid.NewString() + if err := os.MkdirAll(filepath.Dir(collector.AssetUUIDFilePath), 0o755); err != nil { + logger.Error("failed to create asset UUID directory", "path", filepath.Dir(collector.AssetUUIDFilePath), "err", err) + os.Exit(1) + } + if err := os.WriteFile(collector.AssetUUIDFilePath, []byte(id+"\n"), 0o644); err != nil { + logger.Error("failed to write asset UUID file", "path", collector.AssetUUIDFilePath, "err", err) + os.Exit(1) + } + logger.Info("generated asset UUID", "path", collector.AssetUUIDFilePath, "uuid", id) + fmt.Println(id) + os.Exit(0) + } + if *disableDefaultCollectors { collector.DisableDefaultCollectors() }