406 lines
12 KiB
Go
406 lines
12 KiB
Go
package topology
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"git.mchus.pro/mchus/logpile/internal/exporter"
|
|
"git.mchus.pro/mchus/logpile/internal/models"
|
|
)
|
|
|
|
const ContractVersion = "1.0"
|
|
|
|
var (
|
|
cpuLocatorRE = regexp.MustCompile(`(?i)(?:^|[_ -])cpu\s*0*(\d+)`)
|
|
bankNodeRE = regexp.MustCompile(`(?i)node\s*0*(\d+)`)
|
|
nvLinkRE = regexp.MustCompile(`(?i)^NV(\d+)$`)
|
|
)
|
|
|
|
// Build creates the standalone topology.json document from the same
|
|
// normalized projection used by the chart, augmented by optional static Bee
|
|
// support-bundle evidence. It never executes commands or reads live state.
|
|
func Build(snapshot *exporter.ReanimatorExport, evidence *models.TopologyEvidence) *models.TopologyDocument {
|
|
doc := &models.TopologyDocument{Version: ContractVersion}
|
|
if snapshot == nil {
|
|
return doc
|
|
}
|
|
doc.Title = topologyTitle(snapshot)
|
|
hw := snapshot.Hardware
|
|
|
|
cpuIDs := make([]string, 0, len(hw.CPUs))
|
|
socketByCPU := make([]int, 0, len(hw.CPUs))
|
|
for i, cpu := range hw.CPUs {
|
|
socket := cpu.Socket
|
|
id := fmt.Sprintf("cpu:%d", socket)
|
|
if containsNode(doc.Nodes, id) {
|
|
id = fmt.Sprintf("cpu:%d:%d", socket, i)
|
|
}
|
|
s := socket
|
|
doc.Nodes = append(doc.Nodes, models.TopologyNode{ID: id, Kind: "cpu", Label: fmt.Sprintf("CPU %d", socket), Model: cleanCPUModel(cpu.Model), Status: cpu.Status, Socket: &s})
|
|
cpuIDs = append(cpuIDs, id)
|
|
socketByCPU = append(socketByCPU, socket)
|
|
}
|
|
|
|
numaToCPU := buildNUMAMap(hw.PCIeDevices, cpuIDs, socketByCPU)
|
|
bdfToNode := map[string]string{}
|
|
gpuNodeByBDF := map[string]string{}
|
|
for i, dev := range hw.PCIeDevices {
|
|
kind := pcieKind(dev)
|
|
if kind == "" {
|
|
continue
|
|
}
|
|
bdf := normalizeBDF(firstNonEmpty(dev.Slot, dev.BDF))
|
|
id := fmt.Sprintf("%s:%s", kind, bdf)
|
|
if bdf == "" {
|
|
id = fmt.Sprintf("%s:%d", kind, i)
|
|
}
|
|
node := models.TopologyNode{ID: id, Kind: kind, Label: strings.ToUpper(kind), Model: dev.Model, Status: dev.Status, NUMANode: dev.NUMANode, BDF: bdf, LinkSpeed: dev.LinkSpeed, MaxLinkSpeed: dev.MaxLinkSpeed}
|
|
doc.Nodes = append(doc.Nodes, node)
|
|
if bdf != "" {
|
|
bdfToNode[bdf] = id
|
|
if kind == "gpu" {
|
|
gpuNodeByBDF[bdf] = id
|
|
}
|
|
}
|
|
if dev.NUMANode != nil {
|
|
if cpuID := numaToCPU[*dev.NUMANode]; cpuID != "" {
|
|
doc.Edges = append(doc.Edges, models.TopologyEdge{From: cpuID, To: id, Kind: "pcie", Status: linkStatus(dev.LinkSpeed, dev.MaxLinkSpeed)})
|
|
}
|
|
}
|
|
}
|
|
|
|
bankNodes := parseDIMMBankNodes(evidenceString(evidence, func(e *models.TopologyEvidence) string { return e.DIMMType17 }))
|
|
for i, mem := range hw.Memory {
|
|
id := fmt.Sprintf("memory:%s", strings.TrimSpace(mem.Slot))
|
|
if strings.TrimSpace(mem.Slot) == "" || containsNode(doc.Nodes, id) {
|
|
id = fmt.Sprintf("memory:%d", i)
|
|
}
|
|
node := models.TopologyNode{ID: id, Kind: "memory", Label: "Memory", Model: mem.PartNumber, Status: mem.Status, SizeMB: mem.SizeMB}
|
|
doc.Nodes = append(doc.Nodes, node)
|
|
if rawNode, ok := memoryNode(mem.Slot, mem.Location, bankNodes); ok {
|
|
if cpuID := memoryCPU(rawNode, cpuIDs, socketByCPU); cpuID != "" {
|
|
doc.Edges = append(doc.Edges, models.TopologyEdge{From: cpuID, To: id, Kind: "memory"})
|
|
}
|
|
}
|
|
}
|
|
|
|
controllerByHCTL := parseStorageMap(evidenceString(evidence, func(e *models.TopologyEvidence) string { return e.StorageMap }))
|
|
for i, disk := range hw.Storage {
|
|
id := fmt.Sprintf("storage:%s", strings.TrimSpace(disk.Slot))
|
|
if strings.TrimSpace(disk.Slot) == "" || containsNode(doc.Nodes, id) {
|
|
id = fmt.Sprintf("storage:%d", i)
|
|
}
|
|
doc.Nodes = append(doc.Nodes, models.TopologyNode{ID: id, Kind: "storage", Label: firstNonEmpty(disk.Type, "Disk"), Model: disk.Model, Status: disk.Status})
|
|
if controller := bdfToNode[controllerByHCTL[strings.TrimSpace(disk.Slot)]]; controller != "" {
|
|
doc.Edges = append(doc.Edges, models.TopologyEdge{From: controller, To: id, Kind: "storage"})
|
|
}
|
|
}
|
|
|
|
for i, psu := range hw.PowerSupplies {
|
|
label := strings.TrimSpace(psu.Slot)
|
|
if label == "" {
|
|
label = fmt.Sprintf("PSU %d", i+1)
|
|
}
|
|
doc.Nodes = append(doc.Nodes, models.TopologyNode{ID: fmt.Sprintf("psu:%d", i), Kind: "psu", Label: label, Model: psu.Model, Status: psu.Status, WattageW: psu.WattageW})
|
|
}
|
|
for i, fw := range hw.Firmware {
|
|
doc.Nodes = append(doc.Nodes, models.TopologyNode{ID: fmt.Sprintf("firmware:%d", i), Kind: "firmware", Label: fw.DeviceName, Model: "fw " + fw.Version})
|
|
}
|
|
|
|
addNVLinkEdges(doc, evidence, gpuNodeByBDF)
|
|
return doc
|
|
}
|
|
|
|
func topologyTitle(s *exporter.ReanimatorExport) string {
|
|
parts := []string{strings.TrimSpace(s.Hardware.Board.ProductName), strings.TrimSpace(s.Hardware.Board.SerialNumber)}
|
|
var out []string
|
|
for _, p := range parts {
|
|
if p != "" {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return "Server topology"
|
|
}
|
|
return strings.Join(out, " - ")
|
|
}
|
|
|
|
func buildNUMAMap(devices []exporter.ReanimatorPCIe, cpuIDs []string, sockets []int) map[int]string {
|
|
result := map[int]string{}
|
|
if len(cpuIDs) == 0 {
|
|
return result
|
|
}
|
|
seen, hasZero := map[int]bool{}, false
|
|
for _, d := range devices {
|
|
if d.NUMANode != nil {
|
|
seen[*d.NUMANode] = true
|
|
hasZero = hasZero || *d.NUMANode == 0
|
|
}
|
|
}
|
|
var nodes []int
|
|
for n := range seen {
|
|
nodes = append(nodes, n)
|
|
}
|
|
sort.Ints(nodes)
|
|
for rank, n := range nodes {
|
|
if hasZero && rank < len(cpuIDs) {
|
|
result[n] = cpuIDs[rank]
|
|
continue
|
|
}
|
|
for i, socket := range sockets {
|
|
if socket == n {
|
|
result[n] = cpuIDs[i]
|
|
break
|
|
}
|
|
}
|
|
if result[n] == "" && rank < len(cpuIDs) {
|
|
result[n] = cpuIDs[rank]
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func pcieKind(d exporter.ReanimatorPCIe) string {
|
|
c := strings.ToLower(strings.TrimSpace(d.DeviceClass))
|
|
model := strings.ToLower(d.Model + " " + d.Manufacturer)
|
|
switch {
|
|
case strings.Contains(c, "video"), strings.Contains(c, "display"), strings.Contains(c, "3d"), strings.Contains(c, "processingaccelerator"), strings.Contains(model, "nvidia") && strings.Contains(model, "gpu"):
|
|
return "gpu"
|
|
case strings.Contains(c, "network"), strings.Contains(c, "ethernet"), strings.Contains(c, "fibrechannel"), len(d.MACAddresses) > 0:
|
|
return "nic"
|
|
case strings.Contains(c, "storage"), strings.Contains(c, "raid"):
|
|
return "raid"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func linkStatus(current, maximum string) string {
|
|
a, b := speedRank(current), speedRank(maximum)
|
|
if a == 0 || b == 0 {
|
|
return "Unknown"
|
|
}
|
|
if a < b {
|
|
return "Warning"
|
|
}
|
|
return "OK"
|
|
}
|
|
|
|
func speedRank(v string) float64 {
|
|
v = strings.ToLower(strings.TrimSpace(v))
|
|
v = strings.TrimPrefix(v, "gen")
|
|
for _, suffix := range []string{" gt/s", "gts", "gtps"} {
|
|
v = strings.TrimSuffix(v, suffix)
|
|
}
|
|
n, _ := strconv.ParseFloat(strings.TrimSpace(v), 64)
|
|
return n
|
|
}
|
|
|
|
func parseDIMMBankNodes(raw string) map[string]int {
|
|
out := map[string]int{}
|
|
for _, sec := range strings.Split(raw, "Memory Device") {
|
|
locator, node := "", -1
|
|
for _, line := range strings.Split(sec, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if v, ok := strings.CutPrefix(line, "Locator:"); ok {
|
|
locator = strings.TrimSpace(v)
|
|
}
|
|
if v, ok := strings.CutPrefix(line, "Bank Locator:"); ok {
|
|
if m := bankNodeRE.FindStringSubmatch(v); m != nil {
|
|
node, _ = strconv.Atoi(m[1])
|
|
}
|
|
}
|
|
}
|
|
if locator != "" && node >= 0 {
|
|
out[locator] = node
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func memoryNode(slot, location string, bank map[string]int) (int, bool) {
|
|
for _, value := range []string{slot, location} {
|
|
if m := cpuLocatorRE.FindStringSubmatch(strings.TrimSpace(value)); m != nil {
|
|
n, _ := strconv.Atoi(m[1])
|
|
return n, true
|
|
}
|
|
if n, ok := bank[strings.TrimSpace(value)]; ok {
|
|
return n, true
|
|
}
|
|
if m := bankNodeRE.FindStringSubmatch(value); m != nil {
|
|
n, _ := strconv.Atoi(m[1])
|
|
return n, true
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func memoryCPU(node int, cpuIDs []string, sockets []int) string {
|
|
for i, s := range sockets {
|
|
if s == node {
|
|
return cpuIDs[i]
|
|
}
|
|
}
|
|
if node >= 0 && node < len(cpuIDs) {
|
|
return cpuIDs[node]
|
|
}
|
|
if node > 0 && node <= len(cpuIDs) {
|
|
return cpuIDs[node-1]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func parseStorageMap(raw string) map[string]string {
|
|
out := map[string]string{}
|
|
for _, line := range strings.Split(raw, "\n") {
|
|
hctl, ctrl := "", ""
|
|
for _, field := range strings.Fields(line) {
|
|
if v, ok := strings.CutPrefix(field, "hctl="); ok {
|
|
hctl = v
|
|
}
|
|
if v, ok := strings.CutPrefix(field, "ctrl="); ok {
|
|
ctrl = normalizeBDF(v)
|
|
}
|
|
}
|
|
if hctl != "" && ctrl != "" {
|
|
out[hctl] = ctrl
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func addNVLinkEdges(doc *models.TopologyDocument, evidence *models.TopologyEvidence, gpuByBDF map[string]string) {
|
|
if evidence == nil || evidence.NVIDIATopology == "" || evidence.NVIDIAQueryCSV == "" {
|
|
return
|
|
}
|
|
idxToNode := map[int]string{}
|
|
for _, line := range strings.Split(evidence.NVIDIAQueryCSV, "\n") {
|
|
parts := strings.Split(line, ",")
|
|
if len(parts) < 2 {
|
|
continue
|
|
}
|
|
idx, err := strconv.Atoi(strings.TrimSpace(parts[0]))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if id := gpuByBDF[normalizeBDF(parts[1])]; id != "" {
|
|
idxToNode[idx] = id
|
|
}
|
|
}
|
|
for idx, id := range idxToNode {
|
|
for i := range doc.Nodes {
|
|
if doc.Nodes[i].ID == id {
|
|
doc.Nodes[i].Label = fmt.Sprintf("GPU %d", idx)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
lines := strings.Split(evidence.NVIDIATopology, "\n")
|
|
var gpuCols []int
|
|
header := -1
|
|
for i, line := range lines {
|
|
fields := strings.Fields(line)
|
|
if len(fields) > 1 && fields[0] == "GPU0" {
|
|
for _, f := range fields {
|
|
if strings.HasPrefix(f, "GPU") {
|
|
n, err := strconv.Atoi(strings.TrimPrefix(f, "GPU"))
|
|
if err == nil {
|
|
gpuCols = append(gpuCols, n)
|
|
}
|
|
}
|
|
}
|
|
header = i
|
|
break
|
|
}
|
|
}
|
|
if header < 0 {
|
|
return
|
|
}
|
|
seen := map[[2]int]bool{}
|
|
nvStatus := "OK"
|
|
if strings.Contains(strings.ToLower(evidence.NVLinkStatus), "<inactive>") {
|
|
nvStatus = "Warning"
|
|
}
|
|
if nvlinkHasErrors(evidence.NVLinkErrors) {
|
|
nvStatus = "Critical"
|
|
}
|
|
for _, line := range lines[header+1:] {
|
|
fields := strings.Fields(line)
|
|
if len(fields) < 2 || !strings.HasPrefix(fields[0], "GPU") {
|
|
continue
|
|
}
|
|
row, err := strconv.Atoi(strings.TrimPrefix(fields[0], "GPU"))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for col, gpu := range gpuCols {
|
|
if row == gpu || col+1 >= len(fields) {
|
|
continue
|
|
}
|
|
m := nvLinkRE.FindStringSubmatch(fields[col+1])
|
|
if m == nil {
|
|
continue
|
|
}
|
|
links, _ := strconv.Atoi(m[1])
|
|
a, b := row, gpu
|
|
if a > b {
|
|
a, b = b, a
|
|
}
|
|
key := [2]int{a, b}
|
|
if seen[key] || idxToNode[a] == "" || idxToNode[b] == "" {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
doc.Edges = append(doc.Edges, models.TopologyEdge{From: idxToNode[a], To: idxToNode[b], Kind: "nvlink", Status: nvStatus, Label: fmt.Sprintf("NV%d", links), Links: links})
|
|
}
|
|
}
|
|
}
|
|
|
|
func nvlinkHasErrors(raw string) bool {
|
|
for _, line := range strings.Split(raw, "\n") {
|
|
lower := strings.ToLower(line)
|
|
if !strings.Contains(lower, "error") {
|
|
continue
|
|
}
|
|
fields := strings.Fields(line)
|
|
if len(fields) == 0 {
|
|
continue
|
|
}
|
|
last := strings.Trim(fields[len(fields)-1], " ,")
|
|
if n, err := strconv.ParseInt(last, 10, 64); err == nil && n > 0 {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func evidenceString(e *models.TopologyEvidence, get func(*models.TopologyEvidence) string) string {
|
|
if e == nil {
|
|
return ""
|
|
}
|
|
return get(e)
|
|
}
|
|
func containsNode(nodes []models.TopologyNode, id string) bool {
|
|
for _, n := range nodes {
|
|
if n.ID == id {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
func normalizeBDF(v string) string { return strings.ToLower(strings.TrimSpace(v)) }
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, v := range values {
|
|
if strings.TrimSpace(v) != "" {
|
|
return strings.TrimSpace(v)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
func cleanCPUModel(v string) string {
|
|
return strings.Join(strings.Fields(strings.NewReplacer("(R)", "", "(TM)", "", " CPU", "", " Processor", "").Replace(v)), " ")
|
|
}
|