export: align reanimator contract v2.7

This commit is contained in:
Mikhail Chusavitin
2026-03-15 23:27:32 +03:00
parent 9007f1b360
commit 476630190d
31 changed files with 3502 additions and 689 deletions

View File

@@ -69,6 +69,32 @@ func (c *RedfishConnector) Protocol() string {
return "redfish"
}
func (c *RedfishConnector) Probe(ctx context.Context, req Request) (*ProbeResult, error) {
baseURL, err := c.baseURL(req)
if err != nil {
return nil, err
}
client := c.httpClientWithTimeout(req, c.timeout)
if _, err := c.getJSON(ctx, client, req, baseURL, "/redfish/v1"); err != nil {
return nil, fmt.Errorf("redfish service root: %w", err)
}
systemPaths := c.discoverMemberPaths(ctx, client, req, baseURL, "/redfish/v1/Systems", "/redfish/v1/Systems/1")
primarySystem := firstNonEmptyPath(systemPaths, "/redfish/v1/Systems/1")
systemDoc, err := c.getJSON(ctx, client, req, baseURL, primarySystem)
if err != nil {
return nil, fmt.Errorf("redfish system: %w", err)
}
powerState := strings.TrimSpace(asString(systemDoc["PowerState"]))
return &ProbeResult{
Reachable: true,
Protocol: "redfish",
HostPowerState: powerState,
HostPoweredOn: isRedfishHostPoweredOn(powerState),
PowerControlAvailable: redfishResetActionTarget(systemDoc) != "",
SystemPath: primarySystem,
}, nil
}
func (c *RedfishConnector) debugf(format string, args ...interface{}) {
if !c.debug {
return
@@ -102,6 +128,21 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre
}
systemPaths := c.discoverMemberPaths(ctx, snapshotClient, req, baseURL, "/redfish/v1/Systems", "/redfish/v1/Systems/1")
primarySystem := firstNonEmptyPath(systemPaths, "/redfish/v1/Systems/1")
poweredOnByCollector := false
if primarySystem != "" {
if on, changed := c.ensureHostPowerForCollection(ctx, snapshotClient, req, baseURL, primarySystem, emit); on {
poweredOnByCollector = changed
}
}
defer func() {
if !poweredOnByCollector || primarySystem == "" {
return
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
c.restoreHostPowerAfterCollection(shutdownCtx, snapshotClient, req, baseURL, primarySystem, emit)
}()
chassisPaths := c.discoverMemberPaths(ctx, snapshotClient, req, baseURL, "/redfish/v1/Chassis", "/redfish/v1/Chassis/1")
managerPaths := c.discoverMemberPaths(ctx, snapshotClient, req, baseURL, "/redfish/v1/Managers", "/redfish/v1/Managers/1")
criticalPaths := redfishCriticalEndpoints(systemPaths, chassisPaths, managerPaths)
@@ -186,6 +227,257 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre
return result, nil
}
func (c *RedfishConnector) ensureHostPowerForCollection(ctx context.Context, client *http.Client, req Request, baseURL, systemPath string, emit ProgressFn) (hostOn bool, poweredOnByCollector bool) {
systemDoc, err := c.getJSON(ctx, client, req, baseURL, systemPath)
if err != nil {
if emit != nil {
emit(Progress{Status: "running", Progress: 18, Message: "Redfish: не удалось проверить PowerState host, сбор продолжается без power-control"})
}
return false, false
}
powerState := strings.TrimSpace(asString(systemDoc["PowerState"]))
if isRedfishHostPoweredOn(powerState) {
if emit != nil {
emit(Progress{Status: "running", Progress: 18, Message: fmt.Sprintf("Redfish: host включен (%s)", firstNonEmpty(powerState, "On"))})
}
return true, false
}
if emit != nil {
emit(Progress{Status: "running", Progress: 18, Message: fmt.Sprintf("Redfish: host выключен (%s)", firstNonEmpty(powerState, "Off"))})
}
if !req.PowerOnIfHostOff {
if emit != nil {
emit(Progress{Status: "running", Progress: 19, Message: "Redfish: включение host не запрошено, сбор продолжается на выключенном host"})
}
return false, false
}
resetTarget := redfishResetActionTarget(systemDoc)
resetType := redfishPickResetType(systemDoc, "On", "ForceOn")
if resetTarget == "" || resetType == "" {
if emit != nil {
emit(Progress{Status: "running", Progress: 19, Message: "Redfish: action ComputerSystem.Reset недоступен, сбор продолжается на выключенном host"})
}
return false, false
}
waitWindows := []time.Duration{5 * time.Second, 10 * time.Second, 30 * time.Second}
for i, waitFor := range waitWindows {
if emit != nil {
emit(Progress{Status: "running", Progress: 19, Message: fmt.Sprintf("Redfish: попытка включения host (%d/%d), ожидание %s", i+1, len(waitWindows), waitFor)})
}
if err := c.postJSON(ctx, client, req, baseURL, resetTarget, map[string]any{"ResetType": resetType}); err != nil {
if emit != nil {
emit(Progress{Status: "running", Progress: 19, Message: fmt.Sprintf("Redfish: включение host не удалось (%v)", err)})
}
continue
}
if c.waitForHostPowerState(ctx, client, req, baseURL, systemPath, true, waitFor) {
if !c.waitForStablePoweredOnHost(ctx, client, req, baseURL, systemPath, emit) {
if emit != nil {
emit(Progress{Status: "running", Progress: 20, Message: "Redfish: host включился, но не подтвердил стабильное состояние; сбор продолжается на выключенном host"})
}
return false, false
}
if emit != nil {
emit(Progress{Status: "running", Progress: 20, Message: "Redfish: host успешно включен и стабилен перед сбором"})
}
return true, true
}
if emit != nil {
emit(Progress{Status: "running", Progress: 20, Message: fmt.Sprintf("Redfish: host не включился за %s", waitFor)})
}
}
if emit != nil {
emit(Progress{Status: "running", Progress: 20, Message: "Redfish: host не удалось включить, сбор продолжается на выключенном host"})
}
return false, false
}
func (c *RedfishConnector) waitForStablePoweredOnHost(ctx context.Context, client *http.Client, req Request, baseURL, systemPath string, emit ProgressFn) bool {
stabilizationDelay := redfishPowerOnStabilizationDelay()
if stabilizationDelay > 0 {
if emit != nil {
emit(Progress{
Status: "running",
Progress: 20,
Message: fmt.Sprintf("Redfish: host включен, ожидание стабилизации %s перед началом сбора", stabilizationDelay),
})
}
timer := time.NewTimer(stabilizationDelay)
defer timer.Stop()
select {
case <-ctx.Done():
return false
case <-timer.C:
}
}
if emit != nil {
emit(Progress{
Status: "running",
Progress: 20,
Message: "Redfish: повторная проверка PowerState после стабилизации host",
})
}
return c.waitForHostPowerState(ctx, client, req, baseURL, systemPath, true, 5*time.Second)
}
func (c *RedfishConnector) restoreHostPowerAfterCollection(ctx context.Context, client *http.Client, req Request, baseURL, systemPath string, emit ProgressFn) {
systemDoc, err := c.getJSON(ctx, client, req, baseURL, systemPath)
if err != nil {
if emit != nil {
emit(Progress{Status: "running", Progress: 100, Message: "Redfish: не удалось повторно прочитать system перед выключением host"})
}
return
}
resetTarget := redfishResetActionTarget(systemDoc)
resetType := redfishPickResetType(systemDoc, "GracefulShutdown", "ForceOff", "PushPowerButton")
if resetTarget == "" || resetType == "" {
if emit != nil {
emit(Progress{Status: "running", Progress: 100, Message: "Redfish: выключение host после сбора недоступно"})
}
return
}
if emit != nil {
emit(Progress{Status: "running", Progress: 100, Message: "Redfish: выключаем host после завершения сбора"})
}
if err := c.postJSON(ctx, client, req, baseURL, resetTarget, map[string]any{"ResetType": resetType}); err != nil {
if emit != nil {
emit(Progress{Status: "running", Progress: 100, Message: fmt.Sprintf("Redfish: не удалось выключить host после сбора (%v)", err)})
}
return
}
if c.waitForHostPowerState(ctx, client, req, baseURL, systemPath, false, 20*time.Second) {
if emit != nil {
emit(Progress{Status: "running", Progress: 100, Message: "Redfish: host выключен после завершения сбора"})
}
return
}
if emit != nil {
emit(Progress{Status: "running", Progress: 100, Message: "Redfish: не удалось подтвердить выключение host после сбора"})
}
}
func (c *RedfishConnector) waitForHostPowerState(ctx context.Context, client *http.Client, req Request, baseURL, systemPath string, wantOn bool, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
for {
systemDoc, err := c.getJSON(ctx, client, req, baseURL, systemPath)
if err == nil {
if isRedfishHostPoweredOn(strings.TrimSpace(asString(systemDoc["PowerState"]))) == wantOn {
return true
}
}
if time.Now().After(deadline) {
return false
}
select {
case <-ctx.Done():
return false
case <-time.After(1 * time.Second):
}
}
}
func firstNonEmptyPath(paths []string, fallback string) string {
for _, p := range paths {
if strings.TrimSpace(p) != "" {
return p
}
}
return fallback
}
func isRedfishHostPoweredOn(state string) bool {
switch strings.ToLower(strings.TrimSpace(state)) {
case "on", "poweringon":
return true
default:
return false
}
}
func redfishResetActionTarget(systemDoc map[string]interface{}) string {
if systemDoc == nil {
return ""
}
actions, _ := systemDoc["Actions"].(map[string]interface{})
reset, _ := actions["#ComputerSystem.Reset"].(map[string]interface{})
target := strings.TrimSpace(asString(reset["target"]))
if target != "" {
return target
}
odataID := strings.TrimSpace(asString(systemDoc["@odata.id"]))
if odataID == "" {
return ""
}
return joinPath(odataID, "/Actions/ComputerSystem.Reset")
}
func redfishPickResetType(systemDoc map[string]interface{}, preferred ...string) string {
actions, _ := systemDoc["Actions"].(map[string]interface{})
reset, _ := actions["#ComputerSystem.Reset"].(map[string]interface{})
allowedRaw, _ := reset["ResetType@Redfish.AllowableValues"].([]interface{})
if len(allowedRaw) == 0 {
if len(preferred) > 0 {
return preferred[0]
}
return ""
}
allowed := make([]string, 0, len(allowedRaw))
for _, item := range allowedRaw {
if v := strings.TrimSpace(asString(item)); v != "" {
allowed = append(allowed, v)
}
}
for _, want := range preferred {
for _, have := range allowed {
if strings.EqualFold(want, have) {
return have
}
}
}
return ""
}
func (c *RedfishConnector) postJSON(ctx context.Context, client *http.Client, req Request, baseURL, resourcePath string, payload map[string]any) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
target := strings.TrimSpace(resourcePath)
if !strings.HasPrefix(strings.ToLower(target), "http://") && !strings.HasPrefix(strings.ToLower(target), "https://") {
target = baseURL + normalizeRedfishPath(target)
}
u, err := url.Parse(target)
if err != nil {
return err
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), strings.NewReader(string(body)))
if err != nil {
return err
}
httpReq.Header.Set("Content-Type", "application/json")
switch req.AuthType {
case "password":
httpReq.SetBasicAuth(req.Username, req.Password)
case "token":
httpReq.Header.Set("Authorization", "Bearer "+req.Token)
}
resp, err := client.Do(httpReq)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("status %d from %s: %s", resp.StatusCode, resourcePath, strings.TrimSpace(string(respBody)))
}
return nil
}
func (c *RedfishConnector) prefetchCriticalRedfishDocs(
ctx context.Context,
client *http.Client,
@@ -417,11 +709,17 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
driveDocs, err := c.getCollectionMembers(ctx, client, req, baseURL, driveCollectionPath)
if err == nil {
for _, driveDoc := range driveDocs {
if isVirtualStorageDrive(driveDoc) {
continue
}
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
if len(driveDocs) == 0 {
for _, driveDoc := range c.probeDirectDiskBayChildren(ctx, client, req, baseURL, driveCollectionPath) {
if isVirtualStorageDrive(driveDoc) {
continue
}
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
@@ -444,6 +742,9 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
if err != nil {
continue
}
if isVirtualStorageDrive(driveDoc) {
continue
}
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
@@ -452,6 +753,9 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
// Some implementations return drive fields right in storage member object.
if looksLikeDrive(member) {
if isVirtualStorageDrive(member) {
continue
}
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, member, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(member, supplementalDocs...))
}
@@ -462,13 +766,16 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
driveDocs, err := c.getCollectionMembers(ctx, client, req, baseURL, joinPath(enclosurePath, "/Drives"))
if err == nil {
for _, driveDoc := range driveDocs {
if looksLikeDrive(driveDoc) {
if looksLikeDrive(driveDoc) && !isVirtualStorageDrive(driveDoc) {
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
}
if len(driveDocs) == 0 {
for _, driveDoc := range c.probeDirectDiskBayChildren(ctx, client, req, baseURL, joinPath(enclosurePath, "/Drives")) {
if isVirtualStorageDrive(driveDoc) {
continue
}
out = append(out, parseDrive(driveDoc))
}
}
@@ -481,7 +788,7 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
"/Storage/IntelVROC/Drives",
"/Storage/IntelVROC/Controllers/1/Drives",
}) {
if looksLikeDrive(driveDoc) {
if looksLikeDrive(driveDoc) && !isVirtualStorageDrive(driveDoc) {
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
@@ -496,7 +803,7 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
}
for _, devAny := range devices {
devDoc, ok := devAny.(map[string]interface{})
if !ok || !looksLikeDrive(devDoc) {
if !ok || !looksLikeDrive(devDoc) || isVirtualStorageDrive(devDoc) {
continue
}
out = append(out, parseDriveWithSupplementalDocs(devDoc))
@@ -511,7 +818,7 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
continue
}
for _, driveDoc := range driveDocs {
if !looksLikeDrive(driveDoc) {
if !looksLikeDrive(driveDoc) || isVirtualStorageDrive(driveDoc) {
continue
}
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
@@ -523,7 +830,7 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
continue
}
for _, driveDoc := range c.probeSupermicroNVMeDiskBays(ctx, client, req, baseURL, chassisPath) {
if !looksLikeDrive(driveDoc) {
if !looksLikeDrive(driveDoc) || isVirtualStorageDrive(driveDoc) {
continue
}
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
@@ -773,6 +1080,7 @@ func (c *RedfishConnector) collectPCIeDevices(ctx context.Context, client *http.
continue
}
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, doc, "EnvironmentMetrics", "Metrics")
supplementalDocs = append(supplementalDocs, c.getChassisScopedPCIeSupplementalDocs(ctx, client, req, baseURL, doc)...)
for _, fn := range functionDocs {
supplementalDocs = append(supplementalDocs, c.getLinkedSupplementalDocs(ctx, client, req, baseURL, fn, "EnvironmentMetrics", "Metrics")...)
}
@@ -800,6 +1108,39 @@ func (c *RedfishConnector) collectPCIeDevices(ctx context.Context, client *http.
return dedupePCIeDevices(out)
}
func (c *RedfishConnector) getChassisScopedPCIeSupplementalDocs(ctx context.Context, client *http.Client, req Request, baseURL string, doc map[string]interface{}) []map[string]interface{} {
if !looksLikeNVSwitchPCIeDoc(doc) {
return nil
}
docPath := normalizeRedfishPath(asString(doc["@odata.id"]))
chassisPath := chassisPathForPCIeDoc(docPath)
if chassisPath == "" {
return nil
}
out := make([]map[string]interface{}, 0, 4)
seen := make(map[string]struct{})
add := func(path string) {
path = normalizeRedfishPath(path)
if path == "" {
return
}
if _, ok := seen[path]; ok {
return
}
supplementalDoc, err := c.getJSON(ctx, client, req, baseURL, path)
if err != nil || len(supplementalDoc) == 0 {
return
}
seen[path] = struct{}{}
out = append(out, supplementalDoc)
}
add(joinPath(chassisPath, "/EnvironmentMetrics"))
add(joinPath(chassisPath, "/ThermalSubsystem/ThermalMetrics"))
return out
}
func (c *RedfishConnector) discoverMemberPaths(ctx context.Context, client *http.Client, req Request, baseURL, collectionPath, fallbackPath string) []string {
collection, err := c.getJSON(ctx, client, req, baseURL, collectionPath)
if err == nil {
@@ -1810,6 +2151,15 @@ func redfishCriticalSlowGap() time.Duration {
return 1200 * time.Millisecond
}
func redfishPowerOnStabilizationDelay() time.Duration {
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_POWERON_STABILIZATION")); v != "" {
if d, err := time.ParseDuration(v); err == nil && d >= 0 {
return d
}
}
return 60 * time.Second
}
func redfishSnapshotMemoryRequestTimeout() time.Duration {
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_MEMORY_TIMEOUT")); v != "" {
if d, err := time.ParseDuration(v); err == nil && d > 0 {
@@ -1984,11 +2334,22 @@ func looksLikeCanonicalBDF(bdf string) bool {
return false
}
func sanitizeRedfishBDF(bdf string) string {
bdf = strings.TrimSpace(bdf)
if looksLikeCanonicalBDF(bdf) {
return bdf
}
return ""
}
func shouldCrawlPath(path string) bool {
if path == "" {
return false
}
normalized := normalizeRedfishPath(path)
if isAllowedNVSwitchFabricPath(normalized) {
return true
}
if strings.Contains(normalized, "/Chassis/") &&
strings.Contains(normalized, "/PCIeDevices/") &&
strings.Contains(normalized, "/PCIeFunctions/") {
@@ -2079,6 +2440,22 @@ func shouldCrawlPath(path string) bool {
return true
}
func isAllowedNVSwitchFabricPath(path string) bool {
if !strings.HasPrefix(path, "/redfish/v1/Fabrics/") {
return false
}
if !strings.Contains(path, "/Switches/NVSwitch_") {
return false
}
if strings.HasSuffix(path, "/Switches") || strings.Contains(path, "/Ports/NVLink_") || strings.HasSuffix(path, "/Ports") {
return true
}
if strings.Contains(path, "/Switches/NVSwitch_") && !strings.Contains(path, "/Ports/") {
return true
}
return false
}
func isRedfishMemoryMemberPath(path string) bool {
normalized := normalizeRedfishPath(path)
if !strings.Contains(normalized, "/Systems/") {
@@ -2880,23 +3257,38 @@ func parseNIC(doc map[string]interface{}) models.NetworkAdapter {
vendor = pciids.VendorName(vendorID)
}
location := redfishLocationLabel(doc["Location"])
slot := firstNonEmpty(redfishLocationLabel(doc["Location"]), asString(doc["Id"]), asString(doc["Name"]))
var firmware string
var portCount int
var linkWidth int
var maxLinkWidth int
var linkSpeed string
var maxLinkSpeed string
if controllers, ok := doc["Controllers"].([]interface{}); ok && len(controllers) > 0 {
if ctrl, ok := controllers[0].(map[string]interface{}); ok {
location = firstNonEmpty(location, redfishLocationLabel(ctrl["Location"]))
ctrlLocation := redfishLocationLabel(ctrl["Location"])
location = firstNonEmpty(location, ctrlLocation)
if isWeakRedfishNICSlotLabel(slot) {
slot = firstNonEmpty(ctrlLocation, slot)
}
firmware = asString(ctrl["FirmwarePackageVersion"])
if caps, ok := ctrl["ControllerCapabilities"].(map[string]interface{}); ok {
portCount = sanitizeNetworkPortCount(asInt(caps["NetworkPortCount"]))
}
if pcieIf, ok := ctrl["PCIeInterface"].(map[string]interface{}); ok {
linkWidth = asInt(pcieIf["LanesInUse"])
maxLinkWidth = asInt(pcieIf["MaxLanes"])
linkSpeed = firstNonEmpty(asString(pcieIf["PCIeType"]), asString(pcieIf["CurrentLinkSpeedGTs"]), asString(pcieIf["CurrentLinkSpeed"]))
maxLinkSpeed = firstNonEmpty(asString(pcieIf["MaxPCIeType"]), asString(pcieIf["MaxLinkSpeedGTs"]), asString(pcieIf["MaxLinkSpeed"]))
}
}
}
return models.NetworkAdapter{
Slot: firstNonEmpty(asString(doc["Id"]), asString(doc["Name"])),
Slot: slot,
Location: location,
Present: !strings.EqualFold(mapStatus(doc["Status"]), "Absent"),
BDF: asString(doc["BDF"]),
BDF: sanitizeRedfishBDF(asString(doc["BDF"])),
Model: strings.TrimSpace(model),
Vendor: strings.TrimSpace(vendor),
VendorID: vendorID,
@@ -2905,11 +3297,29 @@ func parseNIC(doc map[string]interface{}) models.NetworkAdapter {
PartNumber: asString(doc["PartNumber"]),
Firmware: firmware,
PortCount: portCount,
LinkWidth: linkWidth,
LinkSpeed: linkSpeed,
MaxLinkWidth: maxLinkWidth,
MaxLinkSpeed: maxLinkSpeed,
Status: mapStatus(doc["Status"]),
Details: redfishPCIeDetails(doc, nil),
}
}
func isWeakRedfishNICSlotLabel(slot string) bool {
slot = strings.TrimSpace(slot)
if slot == "" {
return true
}
if isNumericString(slot) {
return true
}
if strings.EqualFold(slot, "nic") || strings.HasPrefix(strings.ToLower(slot), "nic") && !strings.Contains(strings.ToLower(slot), "slot") {
return true
}
return false
}
func networkAdapterPCIeDevicePaths(doc map[string]interface{}) []string {
var out []string
if controllers, ok := doc["Controllers"].([]interface{}); ok {
@@ -2967,7 +3377,7 @@ func enrichNICFromPCIe(nic *models.NetworkAdapter, pcieDoc map[string]interface{
}
for _, fn := range functionDocs {
if strings.TrimSpace(nic.BDF) == "" {
nic.BDF = asString(fn["FunctionId"])
nic.BDF = sanitizeRedfishBDF(asString(fn["FunctionId"]))
}
if nic.VendorID == 0 {
nic.VendorID = asHexOrInt(fn["VendorId"])
@@ -3132,9 +3542,13 @@ func redfishPCIeDetailsWithSupplementalDocs(doc map[string]interface{}, function
lookupDocs = append(lookupDocs, functionDocs...)
lookupDocs = append(lookupDocs, supplementalDocs...)
details := make(map[string]any)
addFloatDetail(details, "temperature_c", redfishFirstNumericAcrossDocs(lookupDocs,
temperatureC := redfishFirstNumericAcrossDocs(lookupDocs,
"TemperatureCelsius", "TemperatureC", "Temperature",
))
)
if temperatureC == 0 {
temperatureC = redfishSupplementalThermalMetricForPCIe(doc, supplementalDocs)
}
addFloatDetail(details, "temperature_c", temperatureC)
addFloatDetail(details, "power_w", redfishFirstNumericAcrossDocs(lookupDocs,
"PowerConsumedWatts", "PowerWatts", "PowerConsumptionWatts",
))
@@ -3189,6 +3603,89 @@ func redfishPCIeDetailsWithSupplementalDocs(doc map[string]interface{}, function
return details
}
func redfishSupplementalThermalMetricForPCIe(doc map[string]interface{}, supplementalDocs []map[string]interface{}) float64 {
if len(supplementalDocs) == 0 {
return 0
}
deviceNames := redfishPCIeSupplementalDeviceNames(doc)
if len(deviceNames) == 0 {
return 0
}
for _, supplemental := range supplementalDocs {
readings, ok := supplemental["TemperatureReadingsCelsius"].([]interface{})
if !ok || len(readings) == 0 {
continue
}
for _, readingAny := range readings {
reading, ok := readingAny.(map[string]interface{})
if !ok {
continue
}
deviceName := strings.TrimSpace(asString(reading["DeviceName"]))
if deviceName == "" || !matchesAnyFold(deviceName, deviceNames) {
continue
}
if value := asFloat(reading["Reading"]); value != 0 {
return value
}
}
}
return 0
}
func redfishPCIeSupplementalDeviceNames(doc map[string]interface{}) []string {
names := make([]string, 0, 3)
for _, raw := range []string{
asString(doc["Id"]),
asString(doc["Name"]),
asString(doc["Model"]),
} {
name := strings.TrimSpace(raw)
if name == "" {
continue
}
if !matchesAnyFold(name, names) {
names = append(names, name)
}
}
return names
}
func matchesAnyFold(value string, candidates []string) bool {
for _, candidate := range candidates {
if strings.EqualFold(strings.TrimSpace(value), strings.TrimSpace(candidate)) {
return true
}
}
return false
}
func looksLikeNVSwitchPCIeDoc(doc map[string]interface{}) bool {
joined := strings.ToLower(strings.TrimSpace(strings.Join([]string{
asString(doc["Id"]),
asString(doc["Name"]),
asString(doc["Model"]),
asString(doc["Manufacturer"]),
}, " ")))
return strings.Contains(joined, "nvswitch")
}
func chassisPathForPCIeDoc(docPath string) string {
docPath = normalizeRedfishPath(docPath)
if !strings.Contains(docPath, "/PCIeDevices/") {
return ""
}
idx := strings.Index(docPath, "/PCIeDevices/")
if idx <= 0 {
return ""
}
chassisPath := docPath[:idx]
if !strings.HasPrefix(chassisPath, "/redfish/v1/Chassis/") {
return ""
}
return chassisPath
}
func redfishFirstNumeric(doc map[string]interface{}, keys ...string) float64 {
for _, key := range keys {
if v, ok := redfishLookupValue(doc, key); ok {
@@ -3392,7 +3889,7 @@ func parseGPUWithSupplementalDocs(doc map[string]interface{}, functionDocs []map
Details: redfishPCIeDetailsWithSupplementalDocs(doc, functionDocs, supplementalDocs),
}
if bdf := asString(doc["BDF"]); bdf != "" {
if bdf := sanitizeRedfishBDF(asString(doc["BDF"])); bdf != "" {
gpu.BDF = bdf
}
if gpu.BDF == "" {
@@ -3407,7 +3904,7 @@ func parseGPUWithSupplementalDocs(doc map[string]interface{}, functionDocs []map
for _, fn := range functionDocs {
if gpu.BDF == "" {
gpu.BDF = asString(fn["FunctionId"])
gpu.BDF = sanitizeRedfishBDF(asString(fn["FunctionId"]))
}
if gpu.VendorID == 0 {
gpu.VendorID = asHexOrInt(fn["VendorId"])
@@ -3448,7 +3945,7 @@ func parsePCIeDevice(doc map[string]interface{}, functionDocs []map[string]inter
func parsePCIeDeviceWithSupplementalDocs(doc map[string]interface{}, functionDocs []map[string]interface{}, supplementalDocs []map[string]interface{}) models.PCIeDevice {
dev := models.PCIeDevice{
Slot: firstNonEmpty(redfishLocationLabel(doc["Slot"]), redfishLocationLabel(doc["Location"]), asString(doc["Name"]), asString(doc["Id"])),
BDF: asString(doc["BDF"]),
BDF: sanitizeRedfishBDF(asString(doc["BDF"])),
DeviceClass: asString(doc["DeviceType"]),
Manufacturer: asString(doc["Manufacturer"]),
PartNumber: asString(doc["PartNumber"]),
@@ -3463,7 +3960,7 @@ func parsePCIeDeviceWithSupplementalDocs(doc map[string]interface{}, functionDoc
for _, fn := range functionDocs {
if dev.BDF == "" {
dev.BDF = asString(fn["FunctionId"])
dev.BDF = sanitizeRedfishBDF(asString(fn["FunctionId"]))
}
if dev.DeviceClass == "" || isGenericPCIeClassLabel(dev.DeviceClass) {
dev.DeviceClass = firstNonEmpty(asString(fn["DeviceClass"]), asString(fn["ClassCode"]))
@@ -3522,7 +4019,7 @@ func parsePCIeFunctionWithSupplementalDocs(doc map[string]interface{}, supplemen
dev := models.PCIeDevice{
Slot: slot,
BDF: asString(doc["FunctionId"]),
BDF: sanitizeRedfishBDF(asString(doc["BDF"])),
VendorID: asHexOrInt(doc["VendorId"]),
DeviceID: asHexOrInt(doc["DeviceId"]),
DeviceClass: firstNonEmpty(asString(doc["DeviceClass"]), asString(doc["ClassCode"]), "PCIe device"),
@@ -3534,6 +4031,9 @@ func parsePCIeFunctionWithSupplementalDocs(doc map[string]interface{}, supplemen
MaxLinkSpeed: firstNonEmpty(asString(doc["MaxLinkSpeedGTs"]), asString(doc["MaxLinkSpeed"])),
Details: redfishPCIeDetailsWithSupplementalDocs(doc, nil, supplementalDocs),
}
if dev.BDF == "" {
dev.BDF = firstNonEmpty(buildBDFfromOemPublic(doc), sanitizeRedfishBDF(asString(doc["FunctionId"])))
}
if isGenericPCIeClassLabel(dev.DeviceClass) {
if resolved := pciids.DeviceName(dev.VendorID, dev.DeviceID); resolved != "" {
dev.DeviceClass = resolved
@@ -3951,7 +4451,24 @@ func isVirtualStorageDrive(doc map[string]interface{}) bool {
}
mfr := strings.ToUpper(strings.TrimSpace(asString(doc["Manufacturer"])))
model := strings.ToUpper(strings.TrimSpace(asString(doc["Model"])))
if strings.Contains(mfr, "AMI") && strings.Contains(model, "VIRTUAL") {
name := strings.ToUpper(strings.TrimSpace(asString(doc["Name"])))
joined := strings.Join([]string{mfr, model, name}, " ")
if strings.Contains(mfr, "AMI") && strings.Contains(joined, "VIRTUAL") {
return true
}
for _, marker := range []string{
"VIRTUAL CDROM",
"VIRTUAL CD/DVD",
"VIRTUAL FLOPPY",
"VIRTUAL FDD",
"VIRTUAL USB",
"VIRTUAL MEDIA",
} {
if strings.Contains(joined, marker) {
return true
}
}
if strings.Contains(mfr, "AMERICAN MEGATRENDS") && (strings.Contains(joined, "CDROM") || strings.Contains(joined, "FLOPPY") || strings.Contains(joined, "FDD")) {
return true
}
return false

View File

@@ -93,12 +93,12 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) (
collectedAt, sourceTimezone := inferRedfishCollectionTime(managerDoc, rawPayloads)
result := &models.AnalysisResult{
CollectedAt: collectedAt,
CollectedAt: collectedAt,
SourceTimezone: sourceTimezone,
Events: append(append(append(make([]models.Event, 0, len(discreteEvents)+len(healthEvents)+len(driveFetchWarningEvents)+1), healthEvents...), discreteEvents...), driveFetchWarningEvents...),
FRU: assemblyFRU,
Sensors: dedupeSensorReadings(append(append(thresholdSensors, thermalSensors...), powerSensors...)),
RawPayloads: cloneRawPayloads(rawPayloads),
Events: append(append(append(make([]models.Event, 0, len(discreteEvents)+len(healthEvents)+len(driveFetchWarningEvents)+1), healthEvents...), discreteEvents...), driveFetchWarningEvents...),
FRU: assemblyFRU,
Sensors: dedupeSensorReadings(append(append(thresholdSensors, thermalSensors...), powerSensors...)),
RawPayloads: cloneRawPayloads(rawPayloads),
Hardware: &models.HardwareConfig{
BoardInfo: boardInfo,
CPUs: processors,
@@ -273,11 +273,7 @@ func (r redfishSnapshotReader) collectFirmwareInventory() []models.FirmwareInfo
if strings.TrimSpace(version) == "" {
continue
}
name := firstNonEmpty(
asString(doc["DeviceName"]),
asString(doc["Name"]),
asString(doc["Id"]),
)
name := firmwareInventoryDeviceName(doc)
name = strings.TrimSpace(name)
if name == "" {
continue
@@ -291,6 +287,25 @@ func (r redfishSnapshotReader) collectFirmwareInventory() []models.FirmwareInfo
return out
}
func firmwareInventoryDeviceName(doc map[string]interface{}) string {
name := strings.TrimSpace(asString(doc["DeviceName"]))
if name != "" {
return name
}
id := strings.TrimSpace(asString(doc["Id"]))
rawName := strings.TrimSpace(asString(doc["Name"]))
if strings.EqualFold(rawName, "Software Inventory") || strings.EqualFold(rawName, "Firmware Inventory") {
if id != "" {
return id
}
}
if rawName != "" {
return rawName
}
return id
}
func dedupeFirmwareInfo(items []models.FirmwareInfo) []models.FirmwareInfo {
seen := make(map[string]struct{}, len(items))
out := make([]models.FirmwareInfo, 0, len(items))
@@ -1094,6 +1109,9 @@ func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storag
continue
}
if looksLikeDrive(member) {
if isVirtualStorageDrive(member) {
continue
}
supplementalDocs := r.getLinkedSupplementalDocs(member, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(member, supplementalDocs...))
}
@@ -1102,13 +1120,16 @@ func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storag
driveDocs, err := r.getCollectionMembers(joinPath(enclosurePath, "/Drives"))
if err == nil {
for _, driveDoc := range driveDocs {
if looksLikeDrive(driveDoc) {
if looksLikeDrive(driveDoc) && !isVirtualStorageDrive(driveDoc) {
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
}
if len(driveDocs) == 0 {
for _, driveDoc := range r.probeDirectDiskBayChildren(joinPath(enclosurePath, "/Drives")) {
if isVirtualStorageDrive(driveDoc) {
continue
}
out = append(out, parseDrive(driveDoc))
}
}
@@ -1120,7 +1141,7 @@ func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storag
"/Storage/IntelVROC/Drives",
"/Storage/IntelVROC/Controllers/1/Drives",
}) {
if looksLikeDrive(driveDoc) {
if looksLikeDrive(driveDoc) && !isVirtualStorageDrive(driveDoc) {
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
@@ -1134,7 +1155,7 @@ func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storag
}
for _, devAny := range devices {
devDoc, ok := devAny.(map[string]interface{})
if !ok || !looksLikeDrive(devDoc) {
if !ok || !looksLikeDrive(devDoc) || isVirtualStorageDrive(devDoc) {
continue
}
out = append(out, parseDrive(devDoc))
@@ -1148,7 +1169,7 @@ func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storag
continue
}
for _, driveDoc := range driveDocs {
if !looksLikeDrive(driveDoc) {
if !looksLikeDrive(driveDoc) || isVirtualStorageDrive(driveDoc) {
continue
}
out = append(out, parseDrive(driveDoc))
@@ -1159,7 +1180,7 @@ func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storag
continue
}
for _, driveDoc := range r.probeSupermicroNVMeDiskBays(chassisPath) {
if !looksLikeDrive(driveDoc) {
if !looksLikeDrive(driveDoc) || isVirtualStorageDrive(driveDoc) {
continue
}
out = append(out, parseDrive(driveDoc))
@@ -1353,6 +1374,7 @@ func (r redfishSnapshotReader) collectPCIeDevices(systemPaths, chassisPaths []st
continue
}
supplementalDocs := r.getLinkedSupplementalDocs(doc, "EnvironmentMetrics", "Metrics")
supplementalDocs = append(supplementalDocs, r.getChassisScopedPCIeSupplementalDocs(doc)...)
for _, fn := range functionDocs {
supplementalDocs = append(supplementalDocs, r.getLinkedSupplementalDocs(fn, "EnvironmentMetrics", "Metrics")...)
}
@@ -1377,6 +1399,29 @@ func (r redfishSnapshotReader) collectPCIeDevices(systemPaths, chassisPaths []st
return dedupePCIeDevices(out)
}
func (r redfishSnapshotReader) getChassisScopedPCIeSupplementalDocs(doc map[string]interface{}) []map[string]interface{} {
if !looksLikeNVSwitchPCIeDoc(doc) {
return nil
}
docPath := normalizeRedfishPath(asString(doc["@odata.id"]))
chassisPath := chassisPathForPCIeDoc(docPath)
if chassisPath == "" {
return nil
}
out := make([]map[string]interface{}, 0, 4)
for _, path := range []string{
joinPath(chassisPath, "/EnvironmentMetrics"),
joinPath(chassisPath, "/ThermalSubsystem/ThermalMetrics"),
} {
supplementalDoc, err := r.getJSON(path)
if err != nil || len(supplementalDoc) == 0 {
continue
}
out = append(out, supplementalDoc)
}
return out
}
func stringsTrimTrailingSlash(s string) string {
for len(s) > 1 && s[len(s)-1] == '/' {
s = s[:len(s)-1]

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
@@ -206,6 +207,177 @@ func TestRedfishConnectorCollect(t *testing.T) {
}
}
func TestRedfishConnectorProbe(t *testing.T) {
mux := http.NewServeMux()
register := func(path string, payload interface{}) {
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(payload)
})
}
register("/redfish/v1", map[string]interface{}{"Name": "ServiceRoot"})
register("/redfish/v1/Systems/1", map[string]interface{}{
"@odata.id": "/redfish/v1/Systems/1",
"PowerState": "Off",
"Actions": map[string]interface{}{
"#ComputerSystem.Reset": map[string]interface{}{
"target": "/redfish/v1/Systems/1/Actions/ComputerSystem.Reset",
"ResetType@Redfish.AllowableValues": []interface{}{"On", "ForceOff"},
},
},
})
ts := httptest.NewTLSServer(mux)
defer ts.Close()
connector := NewRedfishConnector()
port := 443
host := ""
if u, err := url.Parse(ts.URL); err == nil {
host = u.Hostname()
if p := u.Port(); p != "" {
fmt.Sscanf(p, "%d", &port)
}
}
got, err := connector.Probe(context.Background(), Request{
Host: host,
Protocol: "redfish",
Port: port,
Username: "admin",
AuthType: "password",
Password: "secret",
TLSMode: "insecure",
})
if err != nil {
t.Fatalf("probe failed: %v", err)
}
if got == nil || !got.Reachable {
t.Fatalf("expected reachable probe result, got %+v", got)
}
if got.HostPoweredOn {
t.Fatalf("expected powered off host")
}
if got.HostPowerState != "Off" {
t.Fatalf("expected power state Off, got %q", got.HostPowerState)
}
if !got.PowerControlAvailable {
t.Fatalf("expected power control available")
}
}
func TestEnsureHostPowerForCollection_WaitsForStablePowerOn(t *testing.T) {
t.Setenv("LOGPILE_REDFISH_POWERON_STABILIZATION", "1ms")
powerState := "Off"
resetCalls := 0
mux := http.NewServeMux()
mux.HandleFunc("/redfish/v1/Systems/1", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"@odata.id": "/redfish/v1/Systems/1",
"PowerState": powerState,
"Actions": map[string]interface{}{
"#ComputerSystem.Reset": map[string]interface{}{
"target": "/redfish/v1/Systems/1/Actions/ComputerSystem.Reset",
"ResetType@Redfish.AllowableValues": []interface{}{"On"},
},
},
})
})
mux.HandleFunc("/redfish/v1/Systems/1/Actions/ComputerSystem.Reset", func(w http.ResponseWriter, r *http.Request) {
resetCalls++
powerState = "On"
w.WriteHeader(http.StatusOK)
})
ts := httptest.NewTLSServer(mux)
defer ts.Close()
u, err := url.Parse(ts.URL)
if err != nil {
t.Fatalf("parse server url: %v", err)
}
port := 443
if u.Port() != "" {
fmt.Sscanf(u.Port(), "%d", &port)
}
c := NewRedfishConnector()
hostOn, changed := c.ensureHostPowerForCollection(context.Background(), c.httpClientWithTimeout(Request{TLSMode: "insecure"}, 5*time.Second), Request{
Host: u.Hostname(),
Protocol: "redfish",
Port: port,
Username: "admin",
AuthType: "password",
Password: "secret",
TLSMode: "insecure",
PowerOnIfHostOff: true,
}, ts.URL, "/redfish/v1/Systems/1", nil)
if !hostOn || !changed {
t.Fatalf("expected stable power-on result, got hostOn=%v changed=%v", hostOn, changed)
}
if resetCalls != 1 {
t.Fatalf("expected one reset call, got %d", resetCalls)
}
}
func TestEnsureHostPowerForCollection_FailsIfHostDoesNotStayOnAfterStabilization(t *testing.T) {
t.Setenv("LOGPILE_REDFISH_POWERON_STABILIZATION", "1ms")
powerState := "Off"
mux := http.NewServeMux()
mux.HandleFunc("/redfish/v1/Systems/1", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
current := powerState
if powerState == "On" {
powerState = "Off"
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"@odata.id": "/redfish/v1/Systems/1",
"PowerState": current,
"Actions": map[string]interface{}{
"#ComputerSystem.Reset": map[string]interface{}{
"target": "/redfish/v1/Systems/1/Actions/ComputerSystem.Reset",
"ResetType@Redfish.AllowableValues": []interface{}{"On"},
},
},
})
})
mux.HandleFunc("/redfish/v1/Systems/1/Actions/ComputerSystem.Reset", func(w http.ResponseWriter, r *http.Request) {
powerState = "On"
w.WriteHeader(http.StatusOK)
})
ts := httptest.NewTLSServer(mux)
defer ts.Close()
u, err := url.Parse(ts.URL)
if err != nil {
t.Fatalf("parse server url: %v", err)
}
port := 443
if u.Port() != "" {
fmt.Sscanf(u.Port(), "%d", &port)
}
c := NewRedfishConnector()
hostOn, changed := c.ensureHostPowerForCollection(context.Background(), c.httpClientWithTimeout(Request{TLSMode: "insecure"}, 5*time.Second), Request{
Host: u.Hostname(),
Protocol: "redfish",
Port: port,
Username: "admin",
AuthType: "password",
Password: "secret",
TLSMode: "insecure",
PowerOnIfHostOff: true,
}, ts.URL, "/redfish/v1/Systems/1", nil)
if hostOn || changed {
t.Fatalf("expected unstable power-on result to fail, got hostOn=%v changed=%v", hostOn, changed)
}
}
func TestParsePCIeDeviceSlot_FromNestedRedfishSlotLocation(t *testing.T) {
doc := map[string]interface{}{
"Id": "NIC1",
@@ -629,6 +801,42 @@ func TestParseNIC_PortCountFromControllerCapabilities(t *testing.T) {
}
}
func TestParseNIC_PrefersControllerSlotLabelAndPCIeInterface(t *testing.T) {
nic := parseNIC(map[string]interface{}{
"Id": "1",
"Model": "MCX75310AAS-NEAT",
"Manufacturer": "Supermicro",
"Controllers": []interface{}{
map[string]interface{}{
"Location": map[string]interface{}{
"PartLocation": map[string]interface{}{
"ServiceLabel": "PCIe Slot 1 (1)",
},
},
"PCIeInterface": map[string]interface{}{
"LanesInUse": 16,
"MaxLanes": 16,
"PCIeType": "Gen5",
"MaxPCIeType": "Gen5",
},
},
},
})
if nic.Slot != "PCIe Slot 1 (1)" {
t.Fatalf("expected slot from controller location, got %q", nic.Slot)
}
if nic.Location != "PCIe Slot 1 (1)" {
t.Fatalf("expected location from controller location, got %q", nic.Location)
}
if nic.LinkWidth != 16 || nic.MaxLinkWidth != 16 {
t.Fatalf("expected link widths from controller PCIeInterface, got current=%d max=%d", nic.LinkWidth, nic.MaxLinkWidth)
}
if nic.LinkSpeed != "Gen5" || nic.MaxLinkSpeed != "Gen5" {
t.Fatalf("expected link speeds from controller PCIeInterface, got current=%q max=%q", nic.LinkSpeed, nic.MaxLinkSpeed)
}
}
func TestParseNIC_DropsUnrealisticPortCount(t *testing.T) {
nic := parseNIC(map[string]interface{}{
"Id": "1",
@@ -670,6 +878,48 @@ func TestParsePCIeDevice_PrefersFunctionClassOverDeviceType(t *testing.T) {
}
}
func TestParsePCIeComponents_DoNotTreatNumericFunctionIDAsBDF(t *testing.T) {
pcieFn := parsePCIeFunction(map[string]interface{}{
"Id": "1",
"FunctionId": "1",
"DeviceClass": "NetworkController",
"VendorId": "0x15b3",
"DeviceId": "0x1021",
}, 1)
if pcieFn.BDF != "" {
t.Fatalf("expected empty BDF for numeric FunctionId, got %q", pcieFn.BDF)
}
gpu := parseGPU(map[string]interface{}{
"Id": "GPU1",
"Name": "GPU1",
"PCIeFunctions": map[string]interface{}{
"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/GPU1/PCIeFunctions",
},
}, []map[string]interface{}{
{
"FunctionId": "1",
"VendorId": "0x10de",
"DeviceId": "0x2331",
},
}, 1)
if gpu.BDF != "" {
t.Fatalf("expected GPU BDF to stay empty when only numeric FunctionId exists, got %q", gpu.BDF)
}
nic := parseNIC(map[string]interface{}{"Id": "1"})
enrichNICFromPCIe(&nic, map[string]interface{}{}, []map[string]interface{}{
{
"FunctionId": "1",
"VendorId": "0x15b3",
"DeviceId": "0x1021",
},
}, nil)
if nic.BDF != "" {
t.Fatalf("expected NIC BDF to stay empty when only numeric FunctionId exists, got %q", nic.BDF)
}
}
func TestParseComponents_UseNestedSerialNumberFallback(t *testing.T) {
doc := map[string]interface{}{
"Name": "dev0",
@@ -727,9 +977,9 @@ func TestParseCPUAndMemory_CollectOemDetails(t *testing.T) {
"TemperatureCelsius": 63,
"Oem": map[string]interface{}{
"VendorX": map[string]interface{}{
"MicrocodeVersion": "0x2b000643",
"MicrocodeVersion": "0x2b000643",
"UncorrectableErrors": 1,
"ThermalThrottled": true,
"ThermalThrottled": true,
},
},
},
@@ -749,18 +999,18 @@ func TestParseCPUAndMemory_CollectOemDetails(t *testing.T) {
dimms := parseMemory([]map[string]interface{}{
{
"Id": "DIMM0",
"Id": "DIMM0",
"DeviceLocator": "CPU0_C0D0",
"CapacityMiB": 32768,
"SerialNumber": "DIMM-001",
"CapacityMiB": 32768,
"SerialNumber": "DIMM-001",
"Oem": map[string]interface{}{
"VendorX": map[string]interface{}{
"CorrectableECCErrorCount": 12,
"UncorrectableECCErrorCount": 2,
"TemperatureC": 41.5,
"CorrectableECCErrorCount": 12,
"UncorrectableECCErrorCount": 2,
"TemperatureC": 41.5,
"SpareBlocksRemainingPercent": 88,
"PerformanceDegraded": true,
"DataLossDetected": false,
"PerformanceDegraded": true,
"DataLossDetected": false,
},
},
},
@@ -831,9 +1081,9 @@ func TestReplayRedfishFromRawPayloads_UsesProcessorAndMemoryMetrics(t *testing.T
},
},
"/redfish/v1/Systems/1/Memory/DIMM0/MemoryMetrics": map[string]interface{}{
"CorrectableECCErrorCount": 14,
"TemperatureCelsius": 42,
"PerformanceDegraded": true,
"CorrectableECCErrorCount": 14,
"TemperatureCelsius": 42,
"PerformanceDegraded": true,
"SpareBlocksRemainingPercent": 91,
},
},
@@ -897,10 +1147,10 @@ func TestReplayRedfishFromRawPayloads_UsesDriveMetrics(t *testing.T) {
},
},
"/redfish/v1/Systems/1/Storage/RAID1/Drives/Drive0/DriveMetrics": map[string]interface{}{
"PowerOnHours": 1001,
"MediaErrors": 3,
"PowerOnHours": 1001,
"MediaErrors": 3,
"AvailableSparePercent": 92,
"TemperatureCelsius": 37,
"TemperatureCelsius": 37,
},
},
}
@@ -978,9 +1228,9 @@ func TestParseDriveAndPSU_CollectComponentMetricsIntoDetails(t *testing.T) {
"SerialNumber": "SSD-002",
"Oem": map[string]interface{}{
"Public": map[string]interface{}{
"temperature": 19,
"temperature": 19,
"PercentAvailableSpare": 93,
"PercentageUsed": 7,
"PercentageUsed": 7,
},
},
})
@@ -1076,9 +1326,9 @@ func TestParseComponentDetails_UseLinkedSupplementalMetrics(t *testing.T) {
"SerialNumber": "SSD-001",
},
map[string]interface{}{
"PowerOnHours": 5001,
"MediaErrors": 2,
"TemperatureC": 39.5,
"PowerOnHours": 5001,
"MediaErrors": 2,
"TemperatureC": 39.5,
"LifeUsedPercent": 9,
},
)
@@ -1093,7 +1343,7 @@ func TestParseComponentDetails_UseLinkedSupplementalMetrics(t *testing.T) {
},
1,
map[string]interface{}{
"Temperature": 44,
"Temperature": 44,
"LifeRemainingPercent": 97,
},
)
@@ -2206,6 +2456,48 @@ func TestLooksLikeGPU_NVSwitchExcluded(t *testing.T) {
}
}
func TestFirmwareInventoryDeviceName_PrefersIDForGenericSoftwareInventory(t *testing.T) {
doc := map[string]interface{}{
"Id": "HGX_FW_NVSwitch_0",
"Name": "Software Inventory",
"Version": "96.10.73.00.01",
}
got := firmwareInventoryDeviceName(doc)
if got != "HGX_FW_NVSwitch_0" {
t.Fatalf("expected firmware inventory id to be used, got %q", got)
}
}
func TestParsePCIeDeviceWithSupplementalDocs_NVSwitchThermalMetrics(t *testing.T) {
doc := map[string]interface{}{
"@odata.id": "/redfish/v1/Chassis/HGX_NVSwitch_0/PCIeDevices/NVSwitch_0",
"Id": "NVSwitch_0",
"Model": "NVSwitch",
"Manufacturer": "NVIDIA",
"DeviceType": "SingleFunction",
}
supplementalDocs := []map[string]interface{}{
{
"TemperatureReadingsCelsius": []interface{}{
map[string]interface{}{
"DeviceName": "NVSwitch_0",
"Reading": "31.593750",
},
},
},
}
got := parsePCIeDeviceWithSupplementalDocs(doc, nil, supplementalDocs)
if got.Details == nil {
t.Fatalf("expected NVSwitch details to be populated")
}
if temp := got.Details["temperature_c"]; temp != 31.59375 {
t.Fatalf("expected NVSwitch thermal metric, got %#v", got.Details)
}
}
func TestShouldCrawlPath_MemoryAndProcessorMetricsAreAllowed(t *testing.T) {
if !shouldCrawlPath("/redfish/v1/Systems/1/Memory/CPU0_C0D0") {
t.Fatalf("expected direct DIMM resource to be crawlable")
@@ -2222,6 +2514,12 @@ func TestShouldCrawlPath_MemoryAndProcessorMetricsAreAllowed(t *testing.T) {
if shouldCrawlPath("/redfish/v1/Chassis/1/PCIeDevices/0/PCIeFunctions/1") {
t.Fatalf("expected noisy chassis pciefunctions branch to be skipped")
}
if !shouldCrawlPath("/redfish/v1/Fabrics/HGX_NVLinkFabric_0/Switches/NVSwitch_0") {
t.Fatalf("expected NVSwitch fabric resource to be crawlable")
}
if !shouldCrawlPath("/redfish/v1/Fabrics/HGX_NVLinkFabric_0/Switches/NVSwitch_0/Ports/NVLink_0/Metrics") {
t.Fatalf("expected NVLink port metrics to be crawlable")
}
}
func TestIsRedfishMemoryMemberPath(t *testing.T) {
@@ -2531,3 +2829,19 @@ func TestNVMePostProbeSkipsNonStorageChassis(t *testing.T) {
t.Fatalf("expected StorageBackplane to be selected, got %q", selected[0])
}
}
func TestIsVirtualStorageDrive_AMIVirtualMedia(t *testing.T) {
doc := map[string]interface{}{
"Id": "USB_Device1_Port4",
"Name": "Virtual Cdrom Device",
"Model": "Virtual Cdrom Device",
"Manufacturer": "American Megatrends Inc.",
"Protocol": "USB",
"CapacityBytes": 0,
"SerialNumber": "AAAABBBBCCCC1",
}
if !isVirtualStorageDrive(doc) {
t.Fatalf("expected AMI virtual media to be filtered")
}
}

View File

@@ -7,14 +7,15 @@ import (
)
type Request struct {
Host string
Protocol string
Port int
Username string
AuthType string
Password string
Token string
TLSMode string
Host string
Protocol string
Port int
Username string
AuthType string
Password string
Token string
TLSMode string
PowerOnIfHostOff bool
}
type Progress struct {
@@ -25,7 +26,20 @@ type Progress struct {
type ProgressFn func(Progress)
type ProbeResult struct {
Reachable bool
Protocol string
HostPowerState string
HostPoweredOn bool
PowerControlAvailable bool
SystemPath string
}
type Connector interface {
Protocol() string
Collect(ctx context.Context, req Request, emit ProgressFn) (*models.AnalysisResult, error)
}
type Prober interface {
Probe(ctx context.Context, req Request) (*ProbeResult, error)
}

View File

@@ -45,12 +45,13 @@ func ConvertToReanimator(result *models.AnalysisResult) (*ReanimatorExport, erro
Hardware: ReanimatorHardware{
Board: convertBoard(result.Hardware.BoardInfo),
Firmware: dedupeFirmware(convertFirmware(result.Hardware.Firmware)),
CPUs: convertCPUsFromDevices(devices, collectedAt, result.Hardware.BoardInfo.SerialNumber, buildCPUMicrocodeBySocket(result.Hardware.Firmware)),
Memory: convertMemoryFromDevices(devices, collectedAt),
Storage: convertStorageFromDevices(devices, collectedAt),
PCIeDevices: convertPCIeFromDevices(devices, collectedAt, result.Hardware.BoardInfo.SerialNumber),
PowerSupplies: convertPSUsFromDevices(devices, collectedAt),
CPUs: dedupeCPUs(convertCPUsFromDevices(devices, collectedAt, result.Hardware.BoardInfo.SerialNumber, buildCPUMicrocodeBySocket(result.Hardware.Firmware))),
Memory: dedupeMemory(convertMemoryFromDevices(devices, collectedAt)),
Storage: dedupeStorage(convertStorageFromDevices(devices, collectedAt)),
PCIeDevices: dedupePCIe(convertPCIeFromDevices(devices, collectedAt)),
PowerSupplies: dedupePSUs(convertPSUsFromDevices(devices, collectedAt)),
Sensors: convertSensors(result.Sensors),
EventLogs: convertEventLogs(result.Events, collectedAt),
},
}
@@ -91,6 +92,7 @@ func buildDevicesFromLegacy(hw *models.HardwareConfig) []models.HardwareDevice {
return nil
}
all := make([]models.HardwareDevice, 0, len(hw.CPUs)+len(hw.Memory)+len(hw.Storage)+len(hw.PCIeDevices)+len(hw.GPUs)+len(hw.NetworkAdapters)+len(hw.PowerSupply))
nvswitchFirmwareBySlot := buildNVSwitchFirmwareBySlot(hw.Firmware)
appendDevice := func(d models.HardwareDevice) {
all = append(all, d)
}
@@ -175,6 +177,16 @@ func buildDevicesFromLegacy(hw *models.HardwareConfig) []models.HardwareDevice {
if pcieModel == "" {
pcieModel = pcie.Description
}
details := mergeDetailMaps(nil, pcie.Details)
pcieFirmware := stringFromDetailMap(details, "firmware")
if pcieFirmware == "" && isNVSwitchPCIeDevice(pcie) {
pcieFirmware = nvswitchFirmwareBySlot[normalizeNVSwitchSlotForLookup(pcie.Slot)]
if pcieFirmware != "" {
details = mergeDetailMaps(details, map[string]any{
"firmware": pcieFirmware,
})
}
}
appendDevice(models.HardwareDevice{
Kind: models.DeviceKindPCIe,
Slot: pcie.Slot,
@@ -197,7 +209,7 @@ func buildDevicesFromLegacy(hw *models.HardwareConfig) []models.HardwareDevice {
StatusAtCollect: pcie.StatusAtCollect,
StatusHistory: pcie.StatusHistory,
ErrorDescription: pcie.ErrorDescription,
Details: mergeDetailMaps(nil, pcie.Details),
Details: details,
})
}
for _, gpu := range hw.GPUs {
@@ -353,6 +365,7 @@ func dedupeCanonicalDevices(items []models.HardwareDevice) []models.HardwareDevi
var unmatched []models.HardwareDevice
for _, item := range noKey {
mergeKind := canonicalMergeKind(item.Kind)
identity := deviceIdentity(item)
mfr := strings.ToLower(strings.TrimSpace(item.Manufacturer))
if identity == "" {
@@ -363,7 +376,8 @@ func dedupeCanonicalDevices(items []models.HardwareDevice) []models.HardwareDevi
matchCount := 0
for _, k := range order {
existing := byKey[k].item
if deviceIdentity(existing) == identity &&
if canonicalMergeKind(existing.Kind) == mergeKind &&
deviceIdentity(existing) == identity &&
strings.ToLower(strings.TrimSpace(existing.Manufacturer)) == mfr {
matchKey = k
matchCount++
@@ -507,32 +521,43 @@ func mergeDetailMaps(primary, secondary map[string]any) map[string]any {
}
func canonicalKey(item models.HardwareDevice) string {
kind := canonicalMergeKind(item.Kind)
if sn := normalizedSerial(item.SerialNumber); sn != "" {
return "sn:" + strings.ToLower(sn)
return kind + "|sn:" + strings.ToLower(sn)
}
if bdf := strings.ToLower(strings.TrimSpace(item.BDF)); bdf != "" {
return "bdf:" + bdf
return kind + "|bdf:" + bdf
}
return ""
}
func canonicalLooseKey(item models.HardwareDevice) string {
kind := canonicalMergeKind(item.Kind)
slot := strings.ToLower(strings.TrimSpace(item.Slot))
model := strings.ToLower(strings.TrimSpace(item.Model))
part := strings.ToLower(strings.TrimSpace(item.PartNumber))
mfr := strings.ToLower(strings.TrimSpace(item.Manufacturer))
if item.VendorID != 0 && item.DeviceID != 0 && slot != "" {
return fmt.Sprintf("slotid:%s|%d|%d", slot, item.VendorID, item.DeviceID)
return fmt.Sprintf("%s|slotid:%s|%d|%d", kind, slot, item.VendorID, item.DeviceID)
}
if slot != "" && model != "" && mfr != "" {
return "slotmodel:" + slot + "|" + model + "|" + mfr
return kind + "|slotmodel:" + slot + "|" + model + "|" + mfr
}
if slot != "" && part != "" && mfr != "" {
return "slotpart:" + slot + "|" + part + "|" + mfr
return kind + "|slotpart:" + slot + "|" + part + "|" + mfr
}
return ""
}
func canonicalMergeKind(kind string) string {
switch kind {
case models.DeviceKindPCIe, models.DeviceKindGPU, models.DeviceKindNetwork:
return "pcie-class"
default:
return strings.TrimSpace(kind)
}
}
func canonicalScore(item models.HardwareDevice) int {
score := 0
if normalizedSerial(item.SerialNumber) != "" {
@@ -607,18 +632,19 @@ func convertCPUsFromDevices(devices []models.HardwareDevice, collectedAt, boardS
UncorrectableErrorCount: int64FromDetailMap(d.Details, "uncorrectable_error_count"),
LifeRemainingPct: floatFromDetailMap(d.Details, "life_remaining_pct"),
LifeUsedPct: floatFromDetailMap(d.Details, "life_used_pct"),
SerialNumber: generatedCPUSerial(d.SerialNumber, boardSerial, socket),
Firmware: firstNonEmptyString(
SerialNumber: strings.TrimSpace(d.SerialNumber),
Firmware: firstNonEmptyString(
stringFromDetailMap(d.Details, "microcode"),
microcodeBySocket[socket],
stringFromDetailMap(d.Details, "firmware"),
),
Manufacturer: inferCPUManufacturer(d.Model),
Status: cpuStatus,
StatusCheckedAt: meta.StatusCheckedAt,
StatusChangedAt: meta.StatusChangedAt,
StatusHistory: meta.StatusHistory,
ErrorDescription: meta.ErrorDescription,
Manufacturer: inferCPUManufacturer(d.Model),
Status: cpuStatus,
StatusCheckedAt: meta.StatusCheckedAt,
StatusChangedAt: meta.StatusChangedAt,
ManufacturedYearWeek: manufacturedYearWeekFromDetails(d.Details),
StatusHistory: meta.StatusHistory,
ErrorDescription: meta.ErrorDescription,
})
}
return result
@@ -657,6 +683,7 @@ func convertMemoryFromDevices(devices []models.HardwareDevice, collectedAt strin
Status: status,
StatusCheckedAt: meta.StatusCheckedAt,
StatusChangedAt: meta.StatusChangedAt,
ManufacturedYearWeek: manufacturedYearWeekFromDetails(d.Details),
StatusHistory: meta.StatusHistory,
ErrorDescription: meta.ErrorDescription,
})
@@ -670,6 +697,9 @@ func convertStorageFromDevices(devices []models.HardwareDevice, collectedAt stri
if d.Kind != models.DeviceKindStorage {
continue
}
if isVirtualExportStorageDevice(d) {
continue
}
if strings.TrimSpace(d.SerialNumber) == "" {
continue
}
@@ -709,6 +739,7 @@ func convertStorageFromDevices(devices []models.HardwareDevice, collectedAt stri
Status: status,
StatusCheckedAt: meta.StatusCheckedAt,
StatusChangedAt: meta.StatusChangedAt,
ManufacturedYearWeek: manufacturedYearWeekFromDetails(d.Details),
StatusHistory: meta.StatusHistory,
ErrorDescription: meta.ErrorDescription,
})
@@ -716,19 +747,25 @@ func convertStorageFromDevices(devices []models.HardwareDevice, collectedAt stri
return result
}
func convertPCIeFromDevices(devices []models.HardwareDevice, collectedAt, boardSerial string) []ReanimatorPCIe {
func convertPCIeFromDevices(devices []models.HardwareDevice, collectedAt string) []ReanimatorPCIe {
result := make([]ReanimatorPCIe, 0)
for _, d := range devices {
if d.Kind != models.DeviceKindPCIe && d.Kind != models.DeviceKindGPU && d.Kind != models.DeviceKindNetwork {
continue
}
if isStorageEndpointPCIeDevice(d) {
continue
}
if isPlaceholderPCIeExportDevice(d) {
continue
}
if d.Present != nil && !*d.Present {
continue
}
deviceClass := normalizePCIeDeviceClass(d)
model := d.Model
model := normalizePlaceholderDeviceModel(d.Model)
if model == "" {
model = d.PartNumber
model = normalizePlaceholderDeviceModel(d.PartNumber)
}
// General rule: if model not found in source data but PCI IDs are known, resolve from pci.ids.
if model == "" && d.VendorID != 0 && d.DeviceID != 0 {
@@ -749,8 +786,9 @@ func convertPCIeFromDevices(devices []models.HardwareDevice, collectedAt, boardS
)
status := normalizeStatus(d.Status, false)
meta := buildStatusMeta(status, d.StatusCheckedAt, d.StatusChangedAt, d.StatusHistory, d.ErrorDescription, collectedAt)
slot := firstNonEmptyString(d.Slot, d.BDF)
result = append(result, ReanimatorPCIe{
Slot: d.Slot,
Slot: slot,
VendorID: d.VendorID,
DeviceID: d.DeviceID,
NUMANode: d.NUMANode,
@@ -780,11 +818,12 @@ func convertPCIeFromDevices(devices []models.HardwareDevice, collectedAt, boardS
MaxLinkWidth: d.MaxLinkWidth,
MaxLinkSpeed: d.MaxLinkSpeed,
MACAddresses: append([]string(nil), d.MACAddresses...),
SerialNumber: generatedPCIeSerial(d.SerialNumber, boardSerial, d.Slot),
Firmware: d.Firmware,
SerialNumber: strings.TrimSpace(d.SerialNumber),
Firmware: firstNonEmptyString(d.Firmware, stringFromDetailMap(d.Details, "firmware")),
Status: status,
StatusCheckedAt: meta.StatusCheckedAt,
StatusChangedAt: meta.StatusChangedAt,
ManufacturedYearWeek: manufacturedYearWeekFromDetails(d.Details),
StatusHistory: meta.StatusHistory,
ErrorDescription: meta.ErrorDescription,
})
@@ -792,6 +831,96 @@ func convertPCIeFromDevices(devices []models.HardwareDevice, collectedAt, boardS
return result
}
func isStorageEndpointPCIeDevice(d models.HardwareDevice) bool {
if d.Kind != models.DeviceKindPCIe {
return false
}
class := strings.ToLower(strings.TrimSpace(d.DeviceClass))
if !strings.Contains(class, "storage") && !strings.Contains(class, "nonvolatile") && !strings.Contains(class, "nvme") {
return false
}
joined := strings.ToLower(strings.TrimSpace(strings.Join([]string{
d.Slot,
d.Model,
d.PartNumber,
d.Manufacturer,
stringFromDetailMap(d.Details, "description"),
}, " ")))
if strings.Contains(joined, "raid") || strings.Contains(joined, "hba") || strings.Contains(joined, "controller") {
return false
}
return strings.Contains(joined, "nvme") ||
strings.Contains(joined, "ssd") ||
strings.Contains(joined, "u.2") ||
strings.Contains(joined, "e1.s") ||
strings.Contains(joined, "e3.s") ||
strings.Contains(joined, "disk") ||
strings.Contains(joined, "drive")
}
func isVirtualExportStorageDevice(d models.HardwareDevice) bool {
if d.Kind != models.DeviceKindStorage {
return false
}
mfr := strings.ToUpper(strings.TrimSpace(d.Manufacturer))
model := strings.ToUpper(strings.TrimSpace(d.Model))
slot := strings.ToUpper(strings.TrimSpace(d.Slot))
if strings.Contains(mfr, "AMERICAN MEGATRENDS") || strings.Contains(mfr, "AMI") {
joined := strings.Join([]string{mfr, model, slot}, " ")
for _, marker := range []string{
"VIRTUAL CDROM",
"VIRTUAL CD/DVD",
"VIRTUAL FLOPPY",
"VIRTUAL FDD",
"VIRTUAL MEDIA",
"USB_DEVICE",
} {
if strings.Contains(joined, marker) {
return true
}
}
}
return false
}
func isPlaceholderPCIeExportDevice(d models.HardwareDevice) bool {
if d.Kind != models.DeviceKindPCIe && d.Kind != models.DeviceKindNetwork {
return false
}
if strings.TrimSpace(d.BDF) != "" {
return false
}
if d.VendorID != 0 || d.DeviceID != 0 {
return false
}
if normalizedSerial(d.SerialNumber) != "" {
return false
}
if len(d.MACAddresses) > 0 {
return false
}
if strings.TrimSpace(d.Firmware) != "" {
return false
}
if d.LinkWidth != 0 || d.MaxLinkWidth != 0 || strings.TrimSpace(d.LinkSpeed) != "" || strings.TrimSpace(d.MaxLinkSpeed) != "" {
return false
}
if hasMeaningfulExporterText(d.Model) || hasMeaningfulExporterText(d.PartNumber) || hasMeaningfulExporterText(d.Manufacturer) || hasMeaningfulExporterText(stringFromDetailMap(d.Details, "description")) {
return false
}
class := strings.ToLower(strings.TrimSpace(d.DeviceClass))
if class != "" && class != "unknown" && class != "other" && class != "pcie device" && class != "network" && class != "network controller" && class != "networkcontroller" {
return false
}
return isNumericExporterSlot(d.Slot)
}
func convertPSUsFromDevices(devices []models.HardwareDevice, collectedAt string) []ReanimatorPSU {
result := make([]ReanimatorPSU, 0)
for _, d := range devices {
@@ -805,30 +934,66 @@ func convertPSUsFromDevices(devices []models.HardwareDevice, collectedAt string)
status := normalizeStatus(d.Status, false)
meta := buildStatusMeta(status, d.StatusCheckedAt, d.StatusChangedAt, d.StatusHistory, d.ErrorDescription, collectedAt)
result = append(result, ReanimatorPSU{
Slot: d.Slot,
Model: d.Model,
Vendor: d.Manufacturer,
WattageW: d.WattageW,
SerialNumber: d.SerialNumber,
PartNumber: d.PartNumber,
Firmware: d.Firmware,
Status: status,
InputType: d.InputType,
InputPowerW: float64(d.InputPowerW),
OutputPowerW: float64(d.OutputPowerW),
InputVoltage: d.InputVoltage,
TemperatureC: firstNonZeroFloat(float64(d.TemperatureC), floatFromDetailMap(d.Details, "temperature_c")),
LifeRemainingPct: floatFromDetailMap(d.Details, "life_remaining_pct"),
LifeUsedPct: floatFromDetailMap(d.Details, "life_used_pct"),
StatusCheckedAt: meta.StatusCheckedAt,
StatusChangedAt: meta.StatusChangedAt,
StatusHistory: meta.StatusHistory,
ErrorDescription: meta.ErrorDescription,
Slot: d.Slot,
Model: d.Model,
Vendor: d.Manufacturer,
WattageW: d.WattageW,
SerialNumber: d.SerialNumber,
PartNumber: d.PartNumber,
Firmware: d.Firmware,
Status: status,
InputType: d.InputType,
InputPowerW: float64(d.InputPowerW),
OutputPowerW: float64(d.OutputPowerW),
InputVoltage: d.InputVoltage,
TemperatureC: firstNonZeroFloat(float64(d.TemperatureC), floatFromDetailMap(d.Details, "temperature_c")),
LifeRemainingPct: floatFromDetailMap(d.Details, "life_remaining_pct"),
LifeUsedPct: floatFromDetailMap(d.Details, "life_used_pct"),
StatusCheckedAt: meta.StatusCheckedAt,
StatusChangedAt: meta.StatusChangedAt,
ManufacturedYearWeek: manufacturedYearWeekFromDetails(d.Details),
StatusHistory: meta.StatusHistory,
ErrorDescription: meta.ErrorDescription,
})
}
return result
}
func convertEventLogs(events []models.Event, collectedAt string) []ReanimatorEventLog {
if len(events) == 0 {
return nil
}
out := make([]ReanimatorEventLog, 0, len(events))
for _, event := range events {
source := normalizeEventLogSource(event.Source)
message := strings.TrimSpace(event.Description)
if source == "" || message == "" {
continue
}
item := ReanimatorEventLog{
Source: source,
EventTime: formatEventLogTime(event.Timestamp, collectedAt),
Severity: normalizeEventLogSeverity(event.Severity),
MessageID: strings.TrimSpace(event.ID),
Message: message,
ComponentRef: firstNonEmptyString(strings.TrimSpace(event.SensorName), strings.TrimSpace(event.SensorType)),
}
if raw := strings.TrimSpace(event.RawData); raw != "" {
item.RawPayload = map[string]any{
"raw_data": raw,
}
}
out = append(out, item)
}
if len(out) == 0 {
return nil
}
return out
}
func convertSensors(sensors []models.SensorReading) *ReanimatorSensors {
if len(sensors) == 0 {
return nil
@@ -836,7 +1001,7 @@ func convertSensors(sensors []models.SensorReading) *ReanimatorSensors {
out := &ReanimatorSensors{}
seenFans := map[string]struct{}{}
seenPower := map[string]struct{}{}
powerIndex := map[string]int{}
seenTemps := map[string]struct{}{}
seenOther := map[string]struct{}{}
@@ -845,10 +1010,12 @@ func convertSensors(sensors []models.SensorReading) *ReanimatorSensors {
if name == "" {
continue
}
if !sensorHasNumericReading(s) {
continue
}
status := normalizeSensorStatus(s.Status)
sType := strings.ToLower(strings.TrimSpace(s.Type))
unit := strings.TrimSpace(s.Unit)
location := inferSensorLocation(name)
switch {
case sType == "fan" || strings.EqualFold(unit, "RPM"):
@@ -856,49 +1023,41 @@ func convertSensors(sensors []models.SensorReading) *ReanimatorSensors {
continue
}
out.Fans = append(out.Fans, ReanimatorFanSensor{
Name: name,
Location: location,
RPM: int(s.Value),
Status: status,
Name: name,
RPM: int(s.Value),
Status: status,
})
case sType == "power" || sType == "voltage" || sType == "current" || strings.EqualFold(unit, "V") || strings.EqualFold(unit, "A") || strings.EqualFold(unit, "W"):
if seenFirst(seenPower, name) {
baseName := groupedPowerSensorName(name)
if idx, ok := powerIndex[baseName]; ok {
mergePowerSensorReading(&out.Power[idx], sType, unit, s.Value, status)
continue
}
item := ReanimatorPowerSensor{
Name: name,
Location: location,
Status: status,
}
switch {
case sType == "current" || strings.EqualFold(unit, "A"):
item.CurrentA = s.Value
case sType == "power" || strings.EqualFold(unit, "W"):
item.PowerW = s.Value
default:
item.VoltageV = s.Value
Name: baseName,
Status: status,
}
mergePowerSensorReading(&item, sType, unit, s.Value, status)
powerIndex[baseName] = len(out.Power)
out.Power = append(out.Power, item)
case sType == "temperature" || strings.EqualFold(unit, "C") || strings.EqualFold(unit, "°C"):
if seenFirst(seenTemps, name) {
continue
}
out.Temperatures = append(out.Temperatures, ReanimatorTemperatureSensor{
Name: name,
Location: location,
Celsius: s.Value,
Status: status,
Name: name,
Celsius: s.Value,
Status: status,
})
default:
if seenFirst(seenOther, name) {
continue
}
out.Other = append(out.Other, ReanimatorOtherSensor{
Name: name,
Location: location,
Value: s.Value,
Unit: unit,
Status: status,
Name: name,
Value: s.Value,
Unit: unit,
Status: status,
})
}
}
@@ -909,6 +1068,69 @@ func convertSensors(sensors []models.SensorReading) *ReanimatorSensors {
return out
}
func groupedPowerSensorName(name string) string {
trimmed := strings.TrimSpace(name)
lower := strings.ToLower(trimmed)
for _, suffix := range []string{"_inputpower", "_inputvoltage", "_inputcurrent"} {
if strings.HasSuffix(lower, suffix) {
return strings.TrimSpace(trimmed[:len(trimmed)-len(suffix)])
}
}
return trimmed
}
func mergePowerSensorReading(item *ReanimatorPowerSensor, sType, unit string, value float64, status string) {
if item == nil {
return
}
switch {
case sType == "current" || strings.EqualFold(unit, "A"):
item.CurrentA = value
case sType == "power" || strings.EqualFold(unit, "W"):
item.PowerW = value
default:
item.VoltageV = value
}
item.Status = mergeSensorStatus(item.Status, status)
}
func mergeSensorStatus(current, incoming string) string {
current = strings.TrimSpace(current)
incoming = strings.TrimSpace(incoming)
if current == "" {
return incoming
}
if incoming == "" {
return current
}
if sensorStatusRank(incoming) > sensorStatusRank(current) {
return incoming
}
return current
}
func sensorStatusRank(status string) int {
switch strings.ToLower(strings.TrimSpace(status)) {
case "critical":
return 3
case "warning":
return 2
case "ok":
return 1
default:
return 0
}
}
func sensorHasNumericReading(s models.SensorReading) bool {
if strings.TrimSpace(s.RawValue) != "" {
if _, err := strconv.ParseFloat(strings.TrimSpace(s.RawValue), 64); err == nil {
return true
}
}
return s.Value != 0
}
func isDeviceBoundFirmwareName(name string) bool {
n := strings.TrimSpace(strings.ToLower(name))
if n == "" {
@@ -937,6 +1159,7 @@ func isDeviceBoundFirmwareName(name string) bool {
// HGX baseboard firmware inventory IDs for device-bound components
strings.Contains(n, "_fw_gpu_") ||
strings.Contains(n, "_fw_nvswitch_") ||
strings.Contains(n, "_fw_erot_") ||
strings.Contains(n, "_inforom_gpu_") {
return true
}
@@ -944,6 +1167,60 @@ func isDeviceBoundFirmwareName(name string) bool {
return cpuMicrocodeFirmwareRegex.MatchString(strings.TrimSpace(name))
}
func normalizeEventLogSource(source string) string {
switch strings.ToLower(strings.TrimSpace(source)) {
case "redfish":
return "redfish"
case "sel", "bmc", "ipmi", "idrac", "lifecycle controller", "lifecyclecontroller":
return "bmc"
case "system", "syslog", "smart", "zfs", "file", "gpu", "dmi", "nvidia driver", "gpu field diagnostics", "fan", "memory", "host":
return "host"
default:
return ""
}
}
func normalizeEventLogSeverity(severity models.Severity) string {
switch severity {
case models.SeverityCritical:
return "Critical"
case models.SeverityWarning:
return "Warning"
case models.SeverityInfo:
return "Info"
default:
return ""
}
}
func formatEventLogTime(ts time.Time, collectedAt string) string {
if !ts.IsZero() {
return ts.UTC().Format(time.RFC3339)
}
return strings.TrimSpace(collectedAt)
}
func manufacturedYearWeekFromDetails(details map[string]any) string {
if details == nil {
return ""
}
value := normalizeManufacturedYearWeek(stringFromDetailMap(details, "manufactured_year_week"))
if value != "" {
return value
}
return normalizeManufacturedYearWeek(stringFromDetailMap(details, "mfg_date"))
}
var manufacturedYearWeekRegex = regexp.MustCompile(`^\d{4}-W\d{2}$`)
func normalizeManufacturedYearWeek(value string) string {
value = strings.TrimSpace(strings.ToUpper(value))
if manufacturedYearWeekRegex.MatchString(value) {
return value
}
return ""
}
// isDeviceBoundFirmwareFQDD returns true if the description looks like a device-bound FQDD
// (e.g. NIC.Integrated.1-1-1, PSU.Slot.1, Disk.Bay.0:..., RAID.SL.3-1, InfiniBand.Slot.1-1).
// These firmware entries are already embedded in the device itself and must not appear
@@ -989,7 +1266,7 @@ func buildCPUMicrocodeBySocket(firmware []models.FirmwareInfo) map[int]string {
}
// convertCPUs converts CPU information to Reanimator format
func convertCPUs(cpus []models.CPU, collectedAt, boardSerial string) []ReanimatorCPU {
func convertCPUs(cpus []models.CPU, collectedAt string) []ReanimatorCPU {
if len(cpus) == 0 {
return nil
}
@@ -1018,7 +1295,7 @@ func convertCPUs(cpus []models.CPU, collectedAt, boardSerial string) []Reanimato
Threads: cpu.Threads,
FrequencyMHz: cpu.FrequencyMHz,
MaxFrequencyMHz: cpu.MaxFreqMHz,
SerialNumber: generatedCPUSerial(cpu.SerialNumber, boardSerial, cpu.Socket),
SerialNumber: strings.TrimSpace(cpu.SerialNumber),
Firmware: "",
Manufacturer: manufacturer,
Status: cpuStatus,
@@ -1120,7 +1397,7 @@ func convertStorage(storage []models.Storage, collectedAt string) []ReanimatorSt
}
// convertPCIeDevices converts PCIe devices, GPUs, and network adapters to Reanimator format
func convertPCIeDevices(hw *models.HardwareConfig, collectedAt, boardSerial string) []ReanimatorPCIe {
func convertPCIeDevices(hw *models.HardwareConfig, collectedAt string) []ReanimatorPCIe {
result := make([]ReanimatorPCIe, 0)
gpuSlots := make(map[string]struct{}, len(hw.GPUs))
nvswitchFirmwareBySlot := buildNVSwitchFirmwareBySlot(hw.Firmware)
@@ -1140,12 +1417,23 @@ func convertPCIeDevices(hw *models.HardwareConfig, collectedAt, boardSerial stri
continue
}
serialNumber := generatedPCIeSerial(pcie.SerialNumber, boardSerial, pcie.Slot)
if isStorageEndpointPCIeDevice(models.HardwareDevice{
Kind: models.DeviceKindPCIe,
Slot: pcie.Slot,
DeviceClass: pcie.DeviceClass,
Model: pcie.Description,
PartNumber: pcie.PartNumber,
Manufacturer: pcie.Manufacturer,
}) {
continue
}
serialNumber := strings.TrimSpace(pcie.SerialNumber)
// Determine model: PartNumber > Description (chip name) > DeviceClass (bus width fallback)
model := pcie.PartNumber
model := normalizePlaceholderDeviceModel(pcie.PartNumber)
if model == "" {
model = pcie.Description
model = normalizePlaceholderDeviceModel(pcie.Description)
}
if model == "" {
model = pcie.DeviceClass
@@ -1191,7 +1479,7 @@ func convertPCIeDevices(hw *models.HardwareConfig, collectedAt, boardSerial stri
// Convert GPUs as PCIe devices
for _, gpu := range hw.GPUs {
serialNumber := generatedPCIeSerial(gpu.SerialNumber, boardSerial, gpu.Slot)
serialNumber := strings.TrimSpace(gpu.SerialNumber)
status := normalizeStatus(gpu.Status, false)
meta := buildStatusMeta(
@@ -1233,7 +1521,7 @@ func convertPCIeDevices(hw *models.HardwareConfig, collectedAt, boardSerial stri
continue
}
serialNumber := generatedPCIeSerial(nic.SerialNumber, boardSerial, nic.Slot)
serialNumber := strings.TrimSpace(nic.SerialNumber)
status := normalizeStatus(nic.Status, false)
meta := buildStatusMeta(
@@ -1285,19 +1573,25 @@ func buildNVSwitchFirmwareBySlot(firmware []models.FirmwareInfo) map[string]stri
result := make(map[string]string)
for _, fw := range firmware {
name := strings.TrimSpace(fw.DeviceName)
if !strings.HasPrefix(strings.ToUpper(name), "NVSWITCH ") {
if strings.HasPrefix(strings.ToUpper(name), "HGX_FW_EROT_") {
continue
}
rest := strings.TrimSpace(name[len("NVSwitch "):])
if rest == "" {
slot := ""
switch {
case strings.HasPrefix(strings.ToUpper(name), "NVSWITCH "):
rest := strings.TrimSpace(name[len("NVSwitch "):])
if rest == "" {
continue
}
slot = rest
if idx := strings.Index(rest, " ("); idx > 0 {
slot = strings.TrimSpace(rest[:idx])
}
case strings.HasPrefix(strings.ToUpper(name), "HGX_FW_NVSWITCH_"):
slot = strings.TrimPrefix(strings.ToUpper(name), "HGX_FW_")
default:
continue
}
slot := rest
if idx := strings.Index(rest, " ("); idx > 0 {
slot = strings.TrimSpace(rest[:idx])
}
slot = normalizeNVSwitchSlotForLookup(slot)
if slot == "" {
continue
@@ -1388,28 +1682,6 @@ func seenFirst(seen map[string]struct{}, key string) bool {
return false
}
func inferSensorLocation(name string) string {
lower := strings.ToLower(strings.TrimSpace(name))
switch {
case strings.Contains(lower, "front"):
return "Front"
case strings.Contains(lower, "rear"):
return "Rear"
case strings.Contains(lower, "inlet"):
return "Front"
case strings.Contains(lower, "cpu0"):
return "CPU0"
case strings.Contains(lower, "cpu1"):
return "CPU1"
case strings.Contains(lower, "psu0"):
return "PSU0"
case strings.Contains(lower, "psu1"):
return "PSU1"
default:
return ""
}
}
func normalizeSensorStatus(status string) string {
return normalizeStatus(status, false)
}
@@ -1829,29 +2101,6 @@ func boolFromPresentPtr(v *bool, defaultValue bool) bool {
return *v
}
func generatedCPUSerial(serial, boardSerial string, socket int) string {
if normalized := normalizedSerial(serial); normalized != "" {
return normalized
}
if strings.TrimSpace(boardSerial) != "" {
return fmt.Sprintf("%s-CPU-%d", strings.TrimSpace(boardSerial), socket)
}
return fmt.Sprintf("CPU-%d", socket)
}
func generatedPCIeSerial(serial, boardSerial, slot string) string {
if normalized := normalizedSerial(serial); normalized != "" {
return normalized
}
if strings.TrimSpace(slot) == "" {
return ""
}
if strings.TrimSpace(boardSerial) != "" {
return fmt.Sprintf("%s-PCIE-%s", strings.TrimSpace(boardSerial), strings.TrimSpace(slot))
}
return fmt.Sprintf("PCIE-%s", strings.TrimSpace(slot))
}
func floatFromDetailMap(details map[string]any, key string) float64 {
if details == nil {
return 0
@@ -1946,6 +2195,42 @@ func normalizeNetworkDeviceClass(portType, model, description string) string {
}
}
func normalizePlaceholderDeviceModel(model string) string {
trimmed := strings.TrimSpace(model)
switch strings.ToLower(trimmed) {
case "", "network device view", "pci device view", "pcie device view", "storage device view":
return ""
default:
return trimmed
}
}
func hasMeaningfulExporterText(v string) bool {
s := strings.ToLower(strings.TrimSpace(v))
if s == "" {
return false
}
switch s {
case "-", "n/a", "na", "none", "null", "unknown", "network device view", "pci device view", "pcie device view", "storage device view":
return false
default:
return true
}
}
func isNumericExporterSlot(slot string) bool {
slot = strings.TrimSpace(slot)
if slot == "" {
return false
}
for _, r := range slot {
if r < '0' || r > '9' {
return false
}
}
return true
}
// inferStorageStatus determines storage device status
func inferStorageStatus(stor models.Storage) string {
if !stor.Present {

View File

@@ -210,7 +210,7 @@ func TestConvertCPUs(t *testing.T) {
},
}
result := convertCPUs(cpus, "2026-02-10T15:30:00Z", "BOARD-001")
result := convertCPUs(cpus, "2026-02-10T15:30:00Z")
if len(result) != 2 {
t.Fatalf("expected 2 CPUs, got %d", len(result))
@@ -227,8 +227,8 @@ func TestConvertCPUs(t *testing.T) {
if result[0].Status != "Unknown" {
t.Errorf("expected Unknown status, got %q", result[0].Status)
}
if result[0].SerialNumber != "BOARD-001-CPU-0" {
t.Errorf("expected generated CPU serial, got %q", result[0].SerialNumber)
if result[0].SerialNumber != "" {
t.Errorf("expected empty CPU serial when source serial is absent, got %q", result[0].SerialNumber)
}
}
@@ -259,6 +259,158 @@ func TestConvertMemory(t *testing.T) {
}
}
func TestConvertToReanimator_CPUSerialIsNotSynthesizedAndSocketIsDeduped(t *testing.T) {
input := &models.AnalysisResult{
Filename: "cpu-dedupe.json",
Hardware: &models.HardwareConfig{
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"},
Devices: []models.HardwareDevice{
{
Kind: models.DeviceKindCPU,
Slot: "CPU1",
Model: "Xeon Platinum",
Cores: 56,
Status: "OK",
Details: map[string]any{
"socket": 1,
},
},
},
CPUs: []models.CPU{
{Socket: 1, Model: "Xeon Platinum", Cores: 56, Status: "OK"},
{Socket: 2, Model: "Xeon Platinum", Cores: 56, Status: "OK"},
},
},
}
out, err := ConvertToReanimator(input)
if err != nil {
t.Fatalf("ConvertToReanimator() failed: %v", err)
}
if len(out.Hardware.CPUs) != 2 {
t.Fatalf("expected exactly two CPUs after socket dedupe, got %d", len(out.Hardware.CPUs))
}
for _, cpu := range out.Hardware.CPUs {
if cpu.SerialNumber != "" {
t.Fatalf("expected CPU serial to stay empty when source serial is absent, got %q", cpu.SerialNumber)
}
}
}
func TestConvertToReanimator_ExportsEventLogsAndOmitsPCIeBDFJSON(t *testing.T) {
input := &models.AnalysisResult{
Filename: "events.json",
CollectedAt: time.Date(2026, 3, 15, 12, 0, 0, 0, time.UTC),
Events: []models.Event{
{
ID: "0x0042",
Timestamp: time.Date(2026, 3, 15, 11, 59, 0, 0, time.UTC),
Source: "SEL",
SensorName: "CPU0_C0D0",
Severity: models.SeverityWarning,
Description: "Correctable ECC error threshold exceeded",
RawData: "sel_record_id=42",
},
{
Source: "LOGPile",
Severity: models.SeverityWarning,
Description: "internal warning should not leak to event_logs",
},
},
Hardware: &models.HardwareConfig{
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"},
Devices: []models.HardwareDevice{
{
Kind: models.DeviceKindPCIe,
Slot: "",
BDF: "0000:18:00.0",
DeviceClass: "NetworkController",
Manufacturer: "Mellanox",
Model: "ConnectX-6",
Status: "OK",
Details: map[string]any{
"manufactured_year_week": "2024-W07",
},
},
},
},
}
out, err := ConvertToReanimator(input)
if err != nil {
t.Fatalf("ConvertToReanimator() failed: %v", err)
}
if len(out.Hardware.EventLogs) != 1 {
t.Fatalf("expected 1 exported event log, got %d", len(out.Hardware.EventLogs))
}
log := out.Hardware.EventLogs[0]
if log.Source != "bmc" {
t.Fatalf("expected SEL source to map to bmc, got %#v", log)
}
if log.ComponentRef != "CPU0_C0D0" {
t.Fatalf("expected sensor name to map to component_ref, got %#v", log)
}
if len(out.Hardware.PCIeDevices) != 1 {
t.Fatalf("expected 1 pcie device, got %d", len(out.Hardware.PCIeDevices))
}
if out.Hardware.PCIeDevices[0].Slot != "0000:18:00.0" {
t.Fatalf("expected slot to fall back to BDF, got %#v", out.Hardware.PCIeDevices[0])
}
if out.Hardware.PCIeDevices[0].ManufacturedYearWeek != "2024-W07" {
t.Fatalf("expected manufactured_year_week to be exported, got %#v", out.Hardware.PCIeDevices[0])
}
payload, err := json.Marshal(out)
if err != nil {
t.Fatalf("json.Marshal() failed: %v", err)
}
if strings.Contains(string(payload), `"bdf"`) {
t.Fatalf("expected pcie bdf field to stay out of JSON payload: %s", payload)
}
}
func TestConvertToReanimator_EventLogSourceMappingSupportsDellAndHostSyslog(t *testing.T) {
input := &models.AnalysisResult{
Filename: "event-source-map.json",
CollectedAt: time.Date(2026, 3, 15, 12, 0, 0, 0, time.UTC),
Events: []models.Event{
{
ID: "SYS1001",
Timestamp: time.Date(2026, 3, 15, 11, 58, 0, 0, time.UTC),
Source: "iDRAC",
SensorName: "NIC.Slot.1-1-1",
Severity: models.SeverityWarning,
Description: "Link is down",
},
{
ID: "syslog_1",
Timestamp: time.Date(2026, 3, 15, 11, 59, 0, 0, time.UTC),
Source: "syslog",
SensorName: "systemd[1]",
Severity: models.SeverityInfo,
Description: "Started Example Service",
},
},
Hardware: &models.HardwareConfig{
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"},
},
}
out, err := ConvertToReanimator(input)
if err != nil {
t.Fatalf("ConvertToReanimator() failed: %v", err)
}
if len(out.Hardware.EventLogs) != 2 {
t.Fatalf("expected 2 event logs, got %d", len(out.Hardware.EventLogs))
}
if out.Hardware.EventLogs[0].Source != "bmc" {
t.Fatalf("expected iDRAC event to map to bmc, got %#v", out.Hardware.EventLogs[0])
}
if out.Hardware.EventLogs[1].Source != "host" {
t.Fatalf("expected syslog event to map to host, got %#v", out.Hardware.EventLogs[1])
}
}
func TestConvertStorage(t *testing.T) {
storage := []models.Storage{
{
@@ -288,6 +440,46 @@ func TestConvertStorage(t *testing.T) {
}
}
func TestConvertToReanimator_SkipsAMIVirtualStorageDevices(t *testing.T) {
input := &models.AnalysisResult{
Filename: "virtual-media.json",
Hardware: &models.HardwareConfig{
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"},
Storage: []models.Storage{
{
Slot: "USB_Device1_Port4",
Type: "HDD",
Model: "Virtual Cdrom Device",
SerialNumber: "AAAABBBBCCCC1",
Manufacturer: "American Megatrends Inc.",
Interface: "USB",
Present: true,
},
{
Slot: "OB01",
Type: "NVMe",
Model: "Memblaze PBlaze7",
SerialNumber: "REAL-NVME-001",
Manufacturer: "Memblaze",
Interface: "NVMe",
Present: true,
},
},
},
}
out, err := ConvertToReanimator(input)
if err != nil {
t.Fatalf("ConvertToReanimator() failed: %v", err)
}
if len(out.Hardware.Storage) != 1 {
t.Fatalf("expected only one real storage device to remain, got %d", len(out.Hardware.Storage))
}
if out.Hardware.Storage[0].SerialNumber != "REAL-NVME-001" {
t.Fatalf("expected virtual AMI storage to be skipped, got %#v", out.Hardware.Storage)
}
}
func TestConvertStorage_RemainingEndurance(t *testing.T) {
pct100 := 100
pct3 := 3
@@ -370,16 +562,16 @@ func TestConvertPCIeDevices(t *testing.T) {
},
}
result := convertPCIeDevices(hw, "2026-02-10T15:30:00Z", "BOARD-001")
result := convertPCIeDevices(hw, "2026-02-10T15:30:00Z")
// Should have: 2 PCIe devices + 1 GPU + 1 NIC = 4 total
if len(result) != 4 {
t.Fatalf("expected 4 PCIe devices total, got %d", len(result))
}
// Check that serial is generated for second PCIe device
if result[1].SerialNumber != "BOARD-001-PCIE-PCIeCard2" {
t.Errorf("expected generated serial for missing device serial, got %q", result[1].SerialNumber)
// Missing serials must remain absent.
if result[1].SerialNumber != "" {
t.Errorf("expected empty serial for missing device serial, got %q", result[1].SerialNumber)
}
// Check GPU was included
@@ -416,20 +608,71 @@ func TestConvertPCIeDevices_NVSwitchWithoutSerialRemainsEmpty(t *testing.T) {
},
}
result := convertPCIeDevices(hw, "2026-02-10T15:30:00Z", "BOARD-001")
result := convertPCIeDevices(hw, "2026-02-10T15:30:00Z")
if len(result) != 1 {
t.Fatalf("expected 1 PCIe device, got %d", len(result))
}
if result[0].SerialNumber != "BOARD-001-PCIE-NVSWITCH1" {
t.Fatalf("expected generated NVSwitch serial, got %q", result[0].SerialNumber)
if result[0].SerialNumber != "" {
t.Fatalf("expected empty NVSwitch serial, got %q", result[0].SerialNumber)
}
if result[0].Firmware != "96.10.6D.00.01" {
t.Fatalf("expected NVSwitch firmware 96.10.6D.00.01, got %q", result[0].Firmware)
}
}
func TestConvertToReanimator_MapsHGXNVSwitchFirmwareToPCIeDevice(t *testing.T) {
input := &models.AnalysisResult{
Hardware: &models.HardwareConfig{
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"},
Firmware: []models.FirmwareInfo{
{DeviceName: "HGX_FW_ERoT_NVSwitch_0", Version: "00.02.0192.0000_n00"},
{DeviceName: "HGX_FW_NVSwitch_0", Version: "96.10.73.00.01"},
},
PCIeDevices: []models.PCIeDevice{
{
Slot: "NVSwitch_0",
DeviceClass: "NVSwitch",
Manufacturer: "NVIDIA",
Details: map[string]any{
"temperature_c": 31.59375,
},
},
},
},
}
out, err := ConvertToReanimator(input)
if err != nil {
t.Fatalf("ConvertToReanimator() failed: %v", err)
}
if len(out.Hardware.PCIeDevices) != 1 {
t.Fatalf("expected one NVSwitch PCIe device, got %d", len(out.Hardware.PCIeDevices))
}
got := out.Hardware.PCIeDevices[0]
if got.Firmware != "96.10.73.00.01" {
t.Fatalf("expected HGX NVSwitch firmware to map to device, got %#v", got)
}
if got.TemperatureC != 31.59375 {
t.Fatalf("expected NVSwitch temperature to be exported, got %#v", got)
}
}
func TestBuildNVSwitchFirmwareBySlot_SkipsERoTFirmware(t *testing.T) {
got := buildNVSwitchFirmwareBySlot([]models.FirmwareInfo{
{DeviceName: "HGX_FW_ERoT_NVSwitch_0", Version: "00.02.0192.0000_n00"},
{DeviceName: "HGX_FW_NVSwitch_0", Version: "96.10.73.00.01"},
})
if len(got) != 1 {
t.Fatalf("expected only main NVSwitch firmware to remain, got %#v", got)
}
if got["NVSWITCH_0"] != "96.10.73.00.01" {
t.Fatalf("expected main NVSwitch firmware, got %#v", got)
}
}
func TestConvertPCIeDevices_SkipsDisplayControllerDuplicates(t *testing.T) {
hw := &models.HardwareConfig{
PCIeDevices: []models.PCIeDevice{
@@ -449,7 +692,7 @@ func TestConvertPCIeDevices_SkipsDisplayControllerDuplicates(t *testing.T) {
},
}
result := convertPCIeDevices(hw, "2026-02-10T15:30:00Z", "BOARD-001")
result := convertPCIeDevices(hw, "2026-02-10T15:30:00Z")
if len(result) != 1 {
t.Fatalf("expected only dedicated GPU record without duplicate display PCIe, got %d", len(result))
}
@@ -482,7 +725,7 @@ func TestConvertPCIeDevices_MapsGPUStatusHistory(t *testing.T) {
},
}
result := convertPCIeDevices(hw, "2026-02-10T15:30:00Z", "BOARD-001")
result := convertPCIeDevices(hw, "2026-02-10T15:30:00Z")
if len(result) != 1 {
t.Fatalf("expected 1 converted GPU, got %d", len(result))
}
@@ -667,11 +910,11 @@ func TestConvertToReanimator_DeduplicatesAllSections(t *testing.T) {
if len(out.Hardware.Firmware) != 1 {
t.Fatalf("expected deduped firmware len=1, got %d", len(out.Hardware.Firmware))
}
if len(out.Hardware.CPUs) != 2 {
t.Fatalf("expected cpus len=2 (no serial/bdf dedupe), got %d", len(out.Hardware.CPUs))
if len(out.Hardware.CPUs) != 1 {
t.Fatalf("expected cpus len=1 after socket dedupe, got %d", len(out.Hardware.CPUs))
}
if len(out.Hardware.Memory) != 2 {
t.Fatalf("expected memory len=2 (different serials), got %d", len(out.Hardware.Memory))
if len(out.Hardware.Memory) != 1 {
t.Fatalf("expected memory len=1 after slot dedupe, got %d", len(out.Hardware.Memory))
}
if len(out.Hardware.Storage) != 1 {
t.Fatalf("expected deduped storage len=1, got %d", len(out.Hardware.Storage))
@@ -679,8 +922,8 @@ func TestConvertToReanimator_DeduplicatesAllSections(t *testing.T) {
if len(out.Hardware.PowerSupplies) != 1 {
t.Fatalf("expected deduped psu len=1, got %d", len(out.Hardware.PowerSupplies))
}
if len(out.Hardware.PCIeDevices) != 4 {
t.Fatalf("expected pcie len=4 with serial->bdf dedupe, got %d", len(out.Hardware.PCIeDevices))
if len(out.Hardware.PCIeDevices) != 2 {
t.Fatalf("expected pcie len=2 after final pcie-class dedupe, got %d", len(out.Hardware.PCIeDevices))
}
gpuCount := 0
@@ -689,8 +932,8 @@ func TestConvertToReanimator_DeduplicatesAllSections(t *testing.T) {
gpuCount++
}
}
if gpuCount != 2 {
t.Fatalf("expected two #GPU0 records (pcie+gpu kinds), got %d", gpuCount)
if gpuCount != 1 {
t.Fatalf("expected one merged #GPU0 record, got %d", gpuCount)
}
}
@@ -914,6 +1157,143 @@ func TestConvertToReanimator_MergesCanonicalAndLegacyDevices(t *testing.T) {
}
}
func TestConvertToReanimator_DoesNotMergeStorageIntoPCIeBySharedSerial(t *testing.T) {
input := &models.AnalysisResult{
Filename: "nvme-redfish.json",
Hardware: &models.HardwareConfig{
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"},
Storage: []models.Storage{
{
Slot: "Disk.Bay.0",
Type: "NVMe",
Model: "MZQL21T9HCJR-00A07",
SerialNumber: "S64GNNFX612200",
Manufacturer: "Samsung",
Firmware: "GDC5A02Q",
Present: true,
},
},
PCIeDevices: []models.PCIeDevice{
{
Slot: "NVMeSSD1",
BDF: "0000:81:00.0",
DeviceClass: "MassStorageController",
Description: "MZQL21T9HCJR-00A07",
SerialNumber: "S64GNNFX612200",
Manufacturer: "Samsung",
},
},
},
}
out, err := ConvertToReanimator(input)
if err != nil {
t.Fatalf("ConvertToReanimator() failed: %v", err)
}
if len(out.Hardware.Storage) != 1 {
t.Fatalf("expected storage record to survive shared-serial canonical merge, got %d", len(out.Hardware.Storage))
}
if out.Hardware.Storage[0].Slot != "Disk.Bay.0" {
t.Fatalf("expected storage slot Disk.Bay.0, got %q", out.Hardware.Storage[0].Slot)
}
if len(out.Hardware.PCIeDevices) != 0 {
t.Fatalf("expected NVMe storage endpoint to be excluded from pcie export, got %d records", len(out.Hardware.PCIeDevices))
}
}
func TestConvertToReanimator_LeavesStorageControllersInPCIe(t *testing.T) {
input := &models.AnalysisResult{
Hardware: &models.HardwareConfig{
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-123"},
PCIeDevices: []models.PCIeDevice{
{
Slot: "PCIe Slot 3",
BDF: "0000:5e:00.0",
DeviceClass: "MassStorageController",
Description: "MegaRAID Controller",
PartNumber: "PERC H755",
SerialNumber: "RAID-001",
Manufacturer: "Dell",
},
},
},
}
out, err := ConvertToReanimator(input)
if err != nil {
t.Fatalf("ConvertToReanimator() failed: %v", err)
}
if len(out.Hardware.PCIeDevices) != 1 {
t.Fatalf("expected RAID controller to remain in pcie export, got %d", len(out.Hardware.PCIeDevices))
}
}
func TestConvertToReanimator_PCIePlaceholderModelFallsBackToPCIIDs(t *testing.T) {
input := &models.AnalysisResult{
Hardware: &models.HardwareConfig{
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-123"},
Devices: []models.HardwareDevice{
{
Kind: models.DeviceKindNetwork,
Slot: "NIC1",
Model: "Network Device View",
VendorID: 0x15b3,
DeviceID: 0x101d,
Manufacturer: "Mellanox",
Present: boolPtr(true),
},
},
},
}
out, err := ConvertToReanimator(input)
if err != nil {
t.Fatalf("ConvertToReanimator() failed: %v", err)
}
if len(out.Hardware.PCIeDevices) != 1 {
t.Fatalf("expected one pcie export, got %d", len(out.Hardware.PCIeDevices))
}
if strings.EqualFold(out.Hardware.PCIeDevices[0].Model, "Network Device View") {
t.Fatalf("expected placeholder model to be replaced, got %q", out.Hardware.PCIeDevices[0].Model)
}
if out.Hardware.PCIeDevices[0].SerialNumber != "" {
t.Fatalf("expected missing pcie serial to stay empty, got %q", out.Hardware.PCIeDevices[0].SerialNumber)
}
}
func TestConvertToReanimator_SkipsPlaceholderNetworkPCIeRecords(t *testing.T) {
input := &models.AnalysisResult{
Hardware: &models.HardwareConfig{
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-123"},
Devices: []models.HardwareDevice{
{
Kind: models.DeviceKindNetwork,
Slot: "1",
Status: "Unknown",
},
{
Kind: models.DeviceKindNetwork,
Slot: "NIC2",
Model: "ConnectX-7",
Manufacturer: "NVIDIA",
Present: boolPtr(true),
},
},
},
}
out, err := ConvertToReanimator(input)
if err != nil {
t.Fatalf("ConvertToReanimator() failed: %v", err)
}
if len(out.Hardware.PCIeDevices) != 1 {
t.Fatalf("expected only one meaningful pcie-class device, got %d", len(out.Hardware.PCIeDevices))
}
if out.Hardware.PCIeDevices[0].Slot != "NIC2" {
t.Fatalf("expected placeholder numeric-slot NIC to be skipped, got %+v", out.Hardware.PCIeDevices)
}
}
func TestConvertToReanimator_ExportsSensorsAndPSUTelemetry(t *testing.T) {
input := &models.AnalysisResult{
Filename: "vitals.json",
@@ -987,6 +1367,60 @@ func TestConvertToReanimator_ExportsSensorsAndPSUTelemetry(t *testing.T) {
}
}
func TestConvertToReanimator_SkipsSensorsWithoutNumericReadings(t *testing.T) {
input := &models.AnalysisResult{
Filename: "sensor-gaps.json",
Sensors: []models.SensorReading{
{Name: "CPU0 Temp", Type: "temperature", Status: "OK", RawValue: "N/A"},
{Name: "PSU1 Power", Type: "power", Status: "OK", RawValue: ""},
{Name: "Fan1", Type: "fan", Status: "OK", RawValue: "not present"},
{Name: "Humidity", Type: "humidity", Status: "OK", RawValue: "unknown"},
},
Hardware: &models.HardwareConfig{
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"},
},
}
out, err := ConvertToReanimator(input)
if err != nil {
t.Fatalf("ConvertToReanimator() failed: %v", err)
}
if out.Hardware.Sensors != nil {
t.Fatalf("expected sensors to be omitted when all readings are non-numeric, got %+v", out.Hardware.Sensors)
}
}
func TestConvertToReanimator_MergesSiblingPowerSensors(t *testing.T) {
input := &models.AnalysisResult{
Filename: "power-sensors.json",
Sensors: []models.SensorReading{
{Name: "Power Supply Bay 8_InputPower", Type: "power", Value: 231, Unit: "W", Status: "OK"},
{Name: "Power Supply Bay 8_InputVoltage", Type: "voltage", Value: 228, Unit: "V", Status: "OK"},
},
Hardware: &models.HardwareConfig{
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"},
},
}
out, err := ConvertToReanimator(input)
if err != nil {
t.Fatalf("ConvertToReanimator() failed: %v", err)
}
if out.Hardware.Sensors == nil || len(out.Hardware.Sensors.Power) != 1 {
t.Fatalf("expected one merged power sensor, got %#v", out.Hardware.Sensors)
}
got := out.Hardware.Sensors.Power[0]
if got.Name != "Power Supply Bay 8" {
t.Fatalf("expected merged sensor name, got %q", got.Name)
}
if got.PowerW != 231 || got.VoltageV != 228 {
t.Fatalf("expected merged power/voltage readings, got %#v", got)
}
if got.Location != "" {
t.Fatalf("expected sensor location to be omitted, got %#v", got)
}
}
func TestConvertToReanimator_PreservesCanonicalDedupWithoutDeviceVitals(t *testing.T) {
input := &models.AnalysisResult{
Filename: "dedup-vitals.json",
@@ -1336,6 +1770,7 @@ func TestIsDeviceBoundFirmwareName(t *testing.T) {
{"NVMe Drive", true},
// HGX FW ID patterns (in case Id is used as name)
{"HGX_FW_GPU_SXM_1", true},
{"HGX_FW_ERoT_NVSwitch_0", true},
{"HGX_InfoROM_GPU_SXM_2", true},
// System-level firmware — must NOT be excluded
{"BIOS", false},

View File

@@ -20,6 +20,7 @@ type ReanimatorHardware struct {
PCIeDevices []ReanimatorPCIe `json:"pcie_devices,omitempty"`
PowerSupplies []ReanimatorPSU `json:"power_supplies,omitempty"`
Sensors *ReanimatorSensors `json:"sensors,omitempty"`
EventLogs []ReanimatorEventLog `json:"event_logs,omitempty"`
}
// ReanimatorBoard represents motherboard/server information
@@ -65,6 +66,7 @@ type ReanimatorCPU struct {
Status string `json:"status,omitempty"`
StatusCheckedAt string `json:"status_checked_at,omitempty"`
StatusChangedAt string `json:"status_changed_at,omitempty"`
ManufacturedYearWeek string `json:"manufactured_year_week,omitempty"`
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
}
@@ -92,6 +94,7 @@ type ReanimatorMemory struct {
Status string `json:"status,omitempty"`
StatusCheckedAt string `json:"status_checked_at,omitempty"`
StatusChangedAt string `json:"status_changed_at,omitempty"`
ManufacturedYearWeek string `json:"manufactured_year_week,omitempty"`
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
}
@@ -125,6 +128,7 @@ type ReanimatorStorage struct {
Status string `json:"status,omitempty"`
StatusCheckedAt string `json:"status_checked_at,omitempty"`
StatusChangedAt string `json:"status_changed_at,omitempty"`
ManufacturedYearWeek string `json:"manufactured_year_week,omitempty"`
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
}
@@ -152,7 +156,7 @@ type ReanimatorPCIe struct {
SFPRXPowerDBm float64 `json:"sfp_rx_power_dbm,omitempty"`
SFPVoltageV float64 `json:"sfp_voltage_v,omitempty"`
SFPBiasMA float64 `json:"sfp_bias_ma,omitempty"`
BDF string `json:"bdf,omitempty"`
BDF string `json:"-"`
DeviceClass string `json:"device_class,omitempty"`
Manufacturer string `json:"manufacturer,omitempty"`
Model string `json:"model,omitempty"`
@@ -167,32 +171,46 @@ type ReanimatorPCIe struct {
Status string `json:"status,omitempty"`
StatusCheckedAt string `json:"status_checked_at,omitempty"`
StatusChangedAt string `json:"status_changed_at,omitempty"`
ManufacturedYearWeek string `json:"manufactured_year_week,omitempty"`
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
}
// ReanimatorPSU represents a power supply unit
type ReanimatorPSU struct {
Slot string `json:"slot"`
Present *bool `json:"present,omitempty"`
Model string `json:"model,omitempty"`
Vendor string `json:"vendor,omitempty"`
WattageW int `json:"wattage_w,omitempty"`
SerialNumber string `json:"serial_number,omitempty"`
PartNumber string `json:"part_number,omitempty"`
Firmware string `json:"firmware,omitempty"`
Status string `json:"status,omitempty"`
InputType string `json:"input_type,omitempty"`
InputPowerW float64 `json:"input_power_w,omitempty"`
OutputPowerW float64 `json:"output_power_w,omitempty"`
InputVoltage float64 `json:"input_voltage,omitempty"`
TemperatureC float64 `json:"temperature_c,omitempty"`
LifeRemainingPct float64 `json:"life_remaining_pct,omitempty"`
LifeUsedPct float64 `json:"life_used_pct,omitempty"`
StatusCheckedAt string `json:"status_checked_at,omitempty"`
StatusChangedAt string `json:"status_changed_at,omitempty"`
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
Slot string `json:"slot"`
Present *bool `json:"present,omitempty"`
Model string `json:"model,omitempty"`
Vendor string `json:"vendor,omitempty"`
WattageW int `json:"wattage_w,omitempty"`
SerialNumber string `json:"serial_number,omitempty"`
PartNumber string `json:"part_number,omitempty"`
Firmware string `json:"firmware,omitempty"`
Status string `json:"status,omitempty"`
InputType string `json:"input_type,omitempty"`
InputPowerW float64 `json:"input_power_w,omitempty"`
OutputPowerW float64 `json:"output_power_w,omitempty"`
InputVoltage float64 `json:"input_voltage,omitempty"`
TemperatureC float64 `json:"temperature_c,omitempty"`
LifeRemainingPct float64 `json:"life_remaining_pct,omitempty"`
LifeUsedPct float64 `json:"life_used_pct,omitempty"`
StatusCheckedAt string `json:"status_checked_at,omitempty"`
StatusChangedAt string `json:"status_changed_at,omitempty"`
ManufacturedYearWeek string `json:"manufactured_year_week,omitempty"`
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
}
type ReanimatorEventLog struct {
Source string `json:"source"`
EventTime string `json:"event_time,omitempty"`
Severity string `json:"severity,omitempty"`
MessageID string `json:"message_id,omitempty"`
Message string `json:"message"`
ComponentRef string `json:"component_ref,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
RawPayload map[string]any `json:"raw_payload,omitempty"`
}
type ReanimatorSensors struct {

View File

@@ -0,0 +1,135 @@
package parser
import (
"fmt"
"regexp"
"strings"
"time"
"git.mchus.pro/mchus/logpile/internal/models"
)
var manufacturedYearWeekPattern = regexp.MustCompile(`^\d{4}-W\d{2}$`)
// NormalizeManufacturedYearWeek converts common FRU manufacturing date formats
// into contract-compatible YYYY-Www values. Unknown or ambiguous inputs return "".
func NormalizeManufacturedYearWeek(raw string) string {
value := strings.TrimSpace(raw)
if value == "" {
return ""
}
upper := strings.ToUpper(value)
if manufacturedYearWeekPattern.MatchString(upper) {
return upper
}
layouts := []string{
time.RFC3339,
"2006-01-02T15:04:05",
"2006-01-02 15:04:05",
"2006-01-02",
"2006/01/02",
"01/02/2006 15:04:05",
"01/02/2006",
"01-02-2006",
"Mon Jan 2 15:04:05 2006",
"Mon Jan _2 15:04:05 2006",
"Jan 2 2006",
"Jan _2 2006",
}
for _, layout := range layouts {
if ts, err := time.Parse(layout, value); err == nil {
year, week := ts.ISOWeek()
return formatYearWeek(year, week)
}
}
return ""
}
func formatYearWeek(year, week int) string {
if year <= 0 || week <= 0 || week > 53 {
return ""
}
return fmt.Sprintf("%04d-W%02d", year, week)
}
// ApplyManufacturedYearWeekFromFRU attaches normalized manufactured_year_week to
// component details by exact serial-number match. Board-level FRU entries are not
// expanded to components.
func ApplyManufacturedYearWeekFromFRU(frus []models.FRUInfo, hw *models.HardwareConfig) {
if hw == nil || len(frus) == 0 {
return
}
bySerial := make(map[string]string, len(frus))
for _, fru := range frus {
serial := normalizeFRUSerial(fru.SerialNumber)
yearWeek := NormalizeManufacturedYearWeek(fru.MfgDate)
if serial == "" || yearWeek == "" {
continue
}
if _, exists := bySerial[serial]; exists {
continue
}
bySerial[serial] = yearWeek
}
if len(bySerial) == 0 {
return
}
for i := range hw.CPUs {
attachYearWeek(&hw.CPUs[i].Details, bySerial[normalizeFRUSerial(hw.CPUs[i].SerialNumber)])
}
for i := range hw.Memory {
attachYearWeek(&hw.Memory[i].Details, bySerial[normalizeFRUSerial(hw.Memory[i].SerialNumber)])
}
for i := range hw.Storage {
attachYearWeek(&hw.Storage[i].Details, bySerial[normalizeFRUSerial(hw.Storage[i].SerialNumber)])
}
for i := range hw.PCIeDevices {
attachYearWeek(&hw.PCIeDevices[i].Details, bySerial[normalizeFRUSerial(hw.PCIeDevices[i].SerialNumber)])
}
for i := range hw.GPUs {
attachYearWeek(&hw.GPUs[i].Details, bySerial[normalizeFRUSerial(hw.GPUs[i].SerialNumber)])
}
for i := range hw.NetworkAdapters {
attachYearWeek(&hw.NetworkAdapters[i].Details, bySerial[normalizeFRUSerial(hw.NetworkAdapters[i].SerialNumber)])
}
for i := range hw.PowerSupply {
attachYearWeek(&hw.PowerSupply[i].Details, bySerial[normalizeFRUSerial(hw.PowerSupply[i].SerialNumber)])
}
}
func attachYearWeek(details *map[string]any, yearWeek string) {
if yearWeek == "" {
return
}
if *details == nil {
*details = map[string]any{}
}
if existing, ok := (*details)["manufactured_year_week"]; ok && strings.TrimSpace(toString(existing)) != "" {
return
}
(*details)["manufactured_year_week"] = yearWeek
}
func normalizeFRUSerial(v string) string {
s := strings.TrimSpace(v)
if s == "" {
return ""
}
switch strings.ToUpper(s) {
case "N/A", "NA", "NULL", "UNKNOWN", "-", "0":
return ""
default:
return strings.ToUpper(s)
}
}
func toString(v any) string {
switch x := v.(type) {
case string:
return x
default:
return strings.TrimSpace(fmt.Sprint(v))
}
}

View File

@@ -0,0 +1,65 @@
package parser
import (
"testing"
"git.mchus.pro/mchus/logpile/internal/models"
)
func TestNormalizeManufacturedYearWeek(t *testing.T) {
tests := []struct {
in string
want string
}{
{"2024-W07", "2024-W07"},
{"2024-02-13", "2024-W07"},
{"02/13/2024", "2024-W07"},
{"Tue Feb 13 12:00:00 2024", "2024-W07"},
{"", ""},
{"not-a-date", ""},
}
for _, tt := range tests {
if got := NormalizeManufacturedYearWeek(tt.in); got != tt.want {
t.Fatalf("NormalizeManufacturedYearWeek(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestApplyManufacturedYearWeekFromFRU_AttachesByExactSerial(t *testing.T) {
hw := &models.HardwareConfig{
PowerSupply: []models.PSU{
{
Slot: "PSU0",
SerialNumber: "PSU-SN-001",
},
},
Storage: []models.Storage{
{
Slot: "OB01",
SerialNumber: "DISK-SN-001",
},
},
}
fru := []models.FRUInfo{
{
Description: "PSU0_FRU (ID 30)",
SerialNumber: "PSU-SN-001",
MfgDate: "2024-02-13",
},
{
Description: "Builtin FRU Device (ID 0)",
SerialNumber: "BOARD-SN-001",
MfgDate: "2024-02-01",
},
}
ApplyManufacturedYearWeekFromFRU(fru, hw)
if got := hw.PowerSupply[0].Details["manufactured_year_week"]; got != "2024-W07" {
t.Fatalf("expected PSU year week 2024-W07, got %#v", hw.PowerSupply[0].Details)
}
if hw.Storage[0].Details != nil {
t.Fatalf("expected unmatched storage serial to stay untouched, got %#v", hw.Storage[0].Details)
}
}

View File

@@ -216,6 +216,7 @@ func parseH3CG5(files []parser.ExtractedFile) *models.AnalysisResult {
}
result.Hardware.Storage = dedupeStorage(result.Hardware.Storage)
result.Hardware.Volumes = dedupeVolumes(result.Hardware.Volumes)
parser.ApplyManufacturedYearWeekFromFRU(result.FRU, result.Hardware)
return result
}
@@ -286,6 +287,7 @@ func parseH3CG6(files []parser.ExtractedFile) *models.AnalysisResult {
}
result.Hardware.Storage = dedupeStorage(result.Hardware.Storage)
result.Hardware.Volumes = dedupeVolumes(result.Hardware.Volumes)
parser.ApplyManufacturedYearWeekFromFRU(result.FRU, result.Hardware)
return result
}

View File

@@ -0,0 +1,33 @@
package inspur
import "testing"
func TestParseIDLLog_UsesBMCSourceForEventLogs(t *testing.T) {
content := []byte(`|2025-12-02T17:54:27+08:00|MEMORY|Assert|Warning|0C180401|CPU1_C4D0 Memory Device Disabled - Assert|`)
events := ParseIDLLog(content)
if len(events) != 1 {
t.Fatalf("expected 1 event, got %d", len(events))
}
if events[0].Source != "BMC" {
t.Fatalf("expected IDL events to use BMC source, got %#v", events[0])
}
if events[0].SensorName != "CPU1_C4D0" {
t.Fatalf("expected extracted DIMM component ref, got %#v", events[0])
}
}
func TestParseSyslog_UsesHostSourceAndProcessAsSensorName(t *testing.T) {
content := []byte(`<13>2026-03-15T14:03:11+00:00 host123 systemd[1]: Started Example Service`)
events := ParseSyslog(content, "syslog/info")
if len(events) != 1 {
t.Fatalf("expected 1 event, got %d", len(events))
}
if events[0].Source != "syslog" {
t.Fatalf("expected syslog source, got %#v", events[0])
}
if events[0].SensorName != "systemd[1]" {
t.Fatalf("expected process name in sensor/component slot, got %#v", events[0])
}
}

View File

@@ -165,7 +165,10 @@ func TestParseIDLLog_ParsesStructuredJSONLine(t *testing.T) {
if events[0].ID != "17FFB002" {
t.Fatalf("expected event ID 17FFB002, got %q", events[0].ID)
}
if events[0].Source != "PCIE" {
t.Fatalf("expected source PCIE, got %q", events[0].Source)
if events[0].Source != "BMC" {
t.Fatalf("expected BMC source for IDL event, got %q", events[0].Source)
}
if events[0].SensorType != "pcie" {
t.Fatalf("expected component type pcie, got %#v", events[0])
}
}

View File

@@ -60,7 +60,7 @@ func ParseIDLLog(content []byte) []models.Event {
events = append(events, models.Event{
ID: eventID,
Timestamp: ts,
Source: component,
Source: "BMC",
SensorType: strings.ToLower(component),
SensorName: sensorName,
EventType: eventType,

View File

@@ -217,6 +217,7 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
// Apply RAID disk serials from audit.log (authoritative: last non-NULL SN change).
// These override redis/component.log serials which may be stale after disk replacement.
applyRAIDSlotSerials(result.Hardware, raidSlotSerials)
parser.ApplyManufacturedYearWeekFromFRU(result.FRU, result.Hardware)
}
return result, nil

View File

@@ -48,9 +48,9 @@ func ParseSyslog(content []byte, sourcePath string) []models.Event {
event := models.Event{
ID: generateEventID(sourcePath, lineNum),
Timestamp: timestamp,
Source: matches[4],
Source: "syslog",
SensorType: "syslog",
SensorName: matches[3],
SensorName: matches[4],
Description: matches[5],
Severity: severity,
RawData: line,

View File

@@ -14,16 +14,50 @@ import (
func newCollectTestServer() (*Server, *httptest.Server) {
s := &Server{
jobManager: NewJobManager(),
jobManager: NewJobManager(),
collectors: testCollectorRegistry(),
}
mux := http.NewServeMux()
mux.HandleFunc("POST /api/collect/probe", s.handleCollectProbe)
mux.HandleFunc("POST /api/collect", s.handleCollectStart)
mux.HandleFunc("GET /api/collect/{id}", s.handleCollectStatus)
mux.HandleFunc("POST /api/collect/{id}/cancel", s.handleCollectCancel)
return s, httptest.NewServer(mux)
}
func TestCollectProbe(t *testing.T) {
_, ts := newCollectTestServer()
defer ts.Close()
body := `{"host":"bmc-off.local","protocol":"redfish","port":443,"username":"admin","auth_type":"password","password":"secret","tls_mode":"strict"}`
resp, err := http.Post(ts.URL+"/api/collect/probe", "application/json", bytes.NewBufferString(body))
if err != nil {
t.Fatalf("post collect probe failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var payload CollectProbeResponse
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
t.Fatalf("decode probe response: %v", err)
}
if !payload.Reachable {
t.Fatalf("expected reachable=true, got false")
}
if payload.HostPoweredOn {
t.Fatalf("expected host powered off in probe response")
}
if payload.HostPowerState != "Off" {
t.Fatalf("expected host power state Off, got %q", payload.HostPowerState)
}
if !payload.PowerControlAvailable {
t.Fatalf("expected power control to be available")
}
}
func TestCollectLifecycleToTerminal(t *testing.T) {
_, ts := newCollectTestServer()
defer ts.Close()

View File

@@ -17,6 +17,20 @@ func (c *mockConnector) Protocol() string {
return c.protocol
}
func (c *mockConnector) Probe(ctx context.Context, req collector.Request) (*collector.ProbeResult, error) {
if strings.Contains(strings.ToLower(req.Host), "fail") {
return nil, context.DeadlineExceeded
}
return &collector.ProbeResult{
Reachable: true,
Protocol: c.protocol,
HostPowerState: map[bool]string{true: "On", false: "Off"}[!strings.Contains(strings.ToLower(req.Host), "off")],
HostPoweredOn: !strings.Contains(strings.ToLower(req.Host), "off"),
PowerControlAvailable: true,
SystemPath: "/redfish/v1/Systems/1",
}, nil
}
func (c *mockConnector) Collect(ctx context.Context, req collector.Request, emit collector.ProgressFn) (*models.AnalysisResult, error) {
steps := []collector.Progress{
{Status: CollectStatusRunning, Progress: 20, Message: "Подключение..."},

View File

@@ -11,14 +11,24 @@ const (
)
type CollectRequest struct {
Host string `json:"host"`
Protocol string `json:"protocol"`
Port int `json:"port"`
Username string `json:"username"`
AuthType string `json:"auth_type"`
Password string `json:"password,omitempty"`
Token string `json:"token,omitempty"`
TLSMode string `json:"tls_mode"`
Host string `json:"host"`
Protocol string `json:"protocol"`
Port int `json:"port"`
Username string `json:"username"`
AuthType string `json:"auth_type"`
Password string `json:"password,omitempty"`
Token string `json:"token,omitempty"`
TLSMode string `json:"tls_mode"`
PowerOnIfHostOff bool `json:"power_on_if_host_off,omitempty"`
}
type CollectProbeResponse struct {
Reachable bool `json:"reachable"`
Protocol string `json:"protocol,omitempty"`
HostPowerState string `json:"host_power_state,omitempty"`
HostPoweredOn bool `json:"host_powered_on"`
PowerControlAvailable bool `json:"power_control_available"`
Message string `json:"message,omitempty"`
}
type CollectJobResponse struct {

View File

@@ -1510,6 +1510,69 @@ func (s *Server) handleCollectStart(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(job.toJobResponse("Collection job accepted"))
}
func (s *Server) handleCollectProbe(w http.ResponseWriter, r *http.Request) {
var req CollectRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "Invalid JSON body", http.StatusBadRequest)
return
}
if err := validateCollectRequest(req); err != nil {
jsonError(w, err.Error(), http.StatusBadRequest)
return
}
connector, ok := s.getCollector(req.Protocol)
if !ok {
jsonError(w, "Коннектор для протокола не зарегистрирован", http.StatusBadRequest)
return
}
prober, ok := connector.(collector.Prober)
if !ok {
jsonError(w, "Проверка подключения для протокола не поддерживается", http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
result, err := prober.Probe(ctx, toCollectorRequest(req))
if err != nil {
jsonError(w, "Проверка подключения не удалась: "+err.Error(), http.StatusBadRequest)
return
}
message := "Связь с BMC установлена"
if result != nil {
switch {
case !result.HostPoweredOn && result.PowerControlAvailable:
message = "Связь с BMC установлена, host выключен. Можно включить перед сбором."
case !result.HostPoweredOn:
message = "Связь с BMC установлена, host выключен."
default:
message = "Связь с BMC установлена, host включен."
}
}
hostPowerState := ""
hostPoweredOn := false
powerControlAvailable := false
reachable := false
if result != nil {
reachable = result.Reachable
hostPowerState = strings.TrimSpace(result.HostPowerState)
hostPoweredOn = result.HostPoweredOn
powerControlAvailable = result.PowerControlAvailable
}
jsonResponse(w, CollectProbeResponse{
Reachable: reachable,
Protocol: req.Protocol,
HostPowerState: hostPowerState,
HostPoweredOn: hostPoweredOn,
PowerControlAvailable: powerControlAvailable,
Message: message,
})
}
func (s *Server) handleCollectStatus(w http.ResponseWriter, r *http.Request) {
jobID := strings.TrimSpace(r.PathValue("id"))
if !isValidCollectJobID(jobID) {
@@ -1787,14 +1850,15 @@ func applyCollectSourceMetadata(result *models.AnalysisResult, req CollectReques
func toCollectorRequest(req CollectRequest) collector.Request {
return collector.Request{
Host: req.Host,
Protocol: req.Protocol,
Port: req.Port,
Username: req.Username,
AuthType: req.AuthType,
Password: req.Password,
Token: req.Token,
TLSMode: req.TLSMode,
Host: req.Host,
Protocol: req.Protocol,
Port: req.Port,
Username: req.Username,
AuthType: req.AuthType,
Password: req.Password,
Token: req.Token,
TLSMode: req.TLSMode,
PowerOnIfHostOff: req.PowerOnIfHostOff,
}
}

View File

@@ -32,17 +32,17 @@ type RawExportPackage struct {
}
type RawExportSource struct {
Kind string `json:"kind"` // file_bytes | live_redfish | snapshot_json
Filename string `json:"filename,omitempty"`
MIMEType string `json:"mime_type,omitempty"`
Encoding string `json:"encoding,omitempty"` // base64
Data string `json:"data,omitempty"`
Protocol string `json:"protocol,omitempty"`
TargetHost string `json:"target_host,omitempty"`
SourceTimezone string `json:"source_timezone,omitempty"`
RawPayloads map[string]any `json:"raw_payloads,omitempty"`
CollectLogs []string `json:"collect_logs,omitempty"`
CollectMeta *CollectRequestMeta `json:"collect_meta,omitempty"`
Kind string `json:"kind"` // file_bytes | live_redfish | snapshot_json
Filename string `json:"filename,omitempty"`
MIMEType string `json:"mime_type,omitempty"`
Encoding string `json:"encoding,omitempty"` // base64
Data string `json:"data,omitempty"`
Protocol string `json:"protocol,omitempty"`
TargetHost string `json:"target_host,omitempty"`
SourceTimezone string `json:"source_timezone,omitempty"`
RawPayloads map[string]any `json:"raw_payloads,omitempty"`
CollectLogs []string `json:"collect_logs,omitempty"`
CollectMeta *CollectRequestMeta `json:"collect_meta,omitempty"`
}
func newRawExportFromUploadedFile(filename, mimeType string, payload []byte, result *models.AnalysisResult) *RawExportPackage {
@@ -50,13 +50,13 @@ func newRawExportFromUploadedFile(filename, mimeType string, payload []byte, res
Format: rawExportFormatV1,
ExportedAt: time.Now().UTC(),
Source: RawExportSource{
Kind: "file_bytes",
Filename: filename,
MIMEType: mimeType,
Encoding: "base64",
Data: base64.StdEncoding.EncodeToString(payload),
Protocol: resultProtocol(result),
TargetHost: resultTargetHost(result),
Kind: "file_bytes",
Filename: filename,
MIMEType: mimeType,
Encoding: "base64",
Data: base64.StdEncoding.EncodeToString(payload),
Protocol: resultProtocol(result),
TargetHost: resultTargetHost(result),
SourceTimezone: resultSourceTimezone(result),
},
}
@@ -81,13 +81,13 @@ func newRawExportFromLiveCollect(result *models.AnalysisResult, req CollectReque
Format: rawExportFormatV1,
ExportedAt: time.Now().UTC(),
Source: RawExportSource{
Kind: "live_redfish",
Protocol: req.Protocol,
TargetHost: req.Host,
Kind: "live_redfish",
Protocol: req.Protocol,
TargetHost: req.Host,
SourceTimezone: resultSourceTimezone(result),
RawPayloads: rawPayloads,
CollectLogs: append([]string(nil), logs...),
CollectMeta: &meta,
RawPayloads: rawPayloads,
CollectLogs: append([]string(nil), logs...),
CollectMeta: &meta,
},
}
}
@@ -386,6 +386,10 @@ func buildParserFieldSummary(result *models.AnalysisResult) map[string]any {
return out
}
hw := result.Hardware
out["vendor"] = hw.BoardInfo.Manufacturer
out["model"] = hw.BoardInfo.ProductName
out["serial"] = hw.BoardInfo.SerialNumber
out["part_number"] = hw.BoardInfo.PartNumber
out["hardware"] = map[string]any{
"board": hw.BoardInfo,
"counts": map[string]int{

View File

@@ -1,101 +1,35 @@
package server
import (
"archive/zip"
"bytes"
"encoding/json"
"strings"
"testing"
"time"
"git.mchus.pro/mchus/logpile/internal/models"
)
func TestCollectLogTimeBounds(t *testing.T) {
lines := []string{
"2026-02-28T13:10:13.7442032Z Задача поставлена в очередь",
"not-a-timestamp line",
"2026-02-28T13:31:00.5077486Z Сбор завершен",
}
startedAt, finishedAt, ok := collectLogTimeBounds(lines)
if !ok {
t.Fatalf("expected bounds to be parsed")
}
if got := formatRawExportDuration(finishedAt.Sub(startedAt)); got != "20m47s" {
t.Fatalf("unexpected duration: %s", got)
}
}
func TestBuildHumanReadableCollectionLog_IncludesDurationHeader(t *testing.T) {
pkg := &RawExportPackage{
Format: rawExportFormatV1,
Source: RawExportSource{
Kind: "live_redfish",
CollectLogs: []string{
"2026-02-28T13:10:13.7442032Z Redfish: подключение к BMC...",
"2026-02-28T13:31:00.5077486Z Сбор завершен",
func TestBuildParserFieldSummary_MirrorsBoardInfoToTopLevel(t *testing.T) {
result := &models.AnalysisResult{
Hardware: &models.HardwareConfig{
BoardInfo: models.BoardInfo{
Manufacturer: "Supermicro",
ProductName: "SYS-821GE-TNHR",
SerialNumber: "A514359X5C08846",
PartNumber: "SYS-821GE-TNHR",
},
},
}
logText := buildHumanReadableCollectionLog(pkg, nil, "LOGPile test")
for _, token := range []string{
"Collection Started:",
"Collection Finished:",
"Collection Duration:",
} {
if !strings.Contains(logText, token) {
t.Fatalf("expected %q in log header", token)
}
}
}
func TestParseRawExportBundle_ExtractsCollectedAtHintFromParserFields(t *testing.T) {
pkg := &RawExportPackage{
Format: rawExportFormatV1,
ExportedAt: time.Date(2026, 2, 25, 9, 59, 41, 479023400, time.UTC),
Source: RawExportSource{
Kind: "live_redfish",
},
}
pkgJSON, err := json.Marshal(pkg)
if err != nil {
t.Fatalf("marshal pkg: %v", err)
}
parserFields := []byte(`{"collected_at":"2026-02-25T09:58:05.9129753Z"}`)
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
jf, err := zw.Create(rawExportBundlePackageFile)
if err != nil {
t.Fatalf("create package file: %v", err)
}
if _, err := jf.Write(pkgJSON); err != nil {
t.Fatalf("write package file: %v", err)
}
ff, err := zw.Create(rawExportBundleFieldsFile)
if err != nil {
t.Fatalf("create parser fields file: %v", err)
}
if _, err := ff.Write(parserFields); err != nil {
t.Fatalf("write parser fields file: %v", err)
}
if err := zw.Close(); err != nil {
t.Fatalf("close zip writer: %v", err)
}
gotPkg, ok, err := parseRawExportBundle(buf.Bytes())
if err != nil {
t.Fatalf("parse bundle: %v", err)
}
if !ok || gotPkg == nil {
t.Fatalf("expected valid raw export bundle")
}
want := time.Date(2026, 2, 25, 9, 58, 5, 912975300, time.UTC)
if !gotPkg.CollectedAtHint.Equal(want) {
t.Fatalf("expected collected_at hint %s, got %s", want, gotPkg.CollectedAtHint)
got := buildParserFieldSummary(result)
if got["vendor"] != "Supermicro" {
t.Fatalf("expected vendor mirror, got %v", got["vendor"])
}
if got["model"] != "SYS-821GE-TNHR" {
t.Fatalf("expected model mirror, got %v", got["model"])
}
if got["serial"] != "A514359X5C08846" {
t.Fatalf("expected serial mirror, got %v", got["serial"])
}
if got["part_number"] != "SYS-821GE-TNHR" {
t.Fatalf("expected part_number mirror, got %v", got["part_number"])
}
}

View File

@@ -0,0 +1,81 @@
package server
import (
"os"
"path/filepath"
"strings"
"testing"
"git.mchus.pro/mchus/logpile/internal/exporter"
)
func TestReanimatorExport_RedfishExampleDoesNotDuplicateCPUs(t *testing.T) {
payload, err := os.ReadFile(filepath.Join("..", "..", "example", "2026-03-11 (SYS-821GE-TNHR) - A514359X5C08846.zip"))
if err != nil {
t.Fatalf("read example bundle: %v", err)
}
rawPkg, ok, err := parseRawExportBundle(payload)
if err != nil {
t.Fatalf("parse raw export bundle: %v", err)
}
if !ok {
t.Fatalf("example bundle was not recognized as raw export bundle")
}
s := &Server{}
result, vendor, err := s.reanalyzeRawExportPackage(rawPkg)
if err != nil {
t.Fatalf("reanalyze raw export bundle: %v", err)
}
if vendor != "redfish" {
t.Fatalf("expected redfish vendor, got %q", vendor)
}
if result == nil || result.Hardware == nil {
t.Fatalf("expected parsed hardware result")
}
if got := len(result.Hardware.CPUs); got != 2 {
t.Fatalf("expected 2 CPUs after replay, got %d", got)
}
reanimatorData, err := exporter.ConvertToReanimator(result)
if err != nil {
t.Fatalf("ConvertToReanimator: %v", err)
}
if got := len(reanimatorData.Hardware.CPUs); got != 2 {
t.Fatalf("expected 2 CPUs in reanimator export, got %d", got)
}
for i, cpu := range reanimatorData.Hardware.CPUs {
if cpu.SerialNumber != "" {
t.Fatalf("expected CPU %d serial to stay empty, got %q", i, cpu.SerialNumber)
}
}
for _, dev := range reanimatorData.Hardware.PCIeDevices {
joined := strings.ToLower(strings.Join([]string{dev.Slot, dev.Model, dev.DeviceClass}, " "))
if strings.Contains(joined, "nvme") || strings.Contains(joined, "ssd") || strings.Contains(joined, "disk") {
t.Fatalf("expected storage endpoint to stay out of pcie export, got %+v", dev)
}
if strings.EqualFold(dev.Model, "Network Device View") {
t.Fatalf("expected placeholder network model to be replaced, got %+v", dev)
}
if strings.Contains(dev.SerialNumber, "-PCIE-") {
t.Fatalf("expected no synthetic pcie serials, got %+v", dev)
}
if isNumericOnly(dev.Slot) && dev.DeviceClass == "NetworkController" && dev.Model == "" && dev.SerialNumber == "" {
t.Fatalf("expected placeholder numeric-slot network export to be skipped, got %+v", dev)
}
}
}
func isNumericOnly(v string) bool {
v = strings.TrimSpace(v)
if v == "" {
return false
}
for _, r := range v {
if r < '0' || r > '9' {
return false
}
}
return true
}

View File

@@ -88,6 +88,7 @@ func (s *Server) setupRoutes() {
s.mux.HandleFunc("DELETE /api/clear", s.handleClear)
s.mux.HandleFunc("POST /api/shutdown", s.handleShutdown)
s.mux.HandleFunc("POST /api/collect", s.handleCollectStart)
s.mux.HandleFunc("POST /api/collect/probe", s.handleCollectProbe)
s.mux.HandleFunc("GET /api/collect/{id}", s.handleCollectStatus)
s.mux.HandleFunc("POST /api/collect/{id}/cancel", s.handleCollectCancel)
}