export: align reanimator contract v2.7
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
135
internal/parser/fru_manufactured.go
Normal file
135
internal/parser/fru_manufactured.go
Normal 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))
|
||||
}
|
||||
}
|
||||
65
internal/parser/fru_manufactured_test.go
Normal file
65
internal/parser/fru_manufactured_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
2
internal/parser/vendors/h3c/parser.go
vendored
2
internal/parser/vendors/h3c/parser.go
vendored
@@ -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
|
||||
}
|
||||
|
||||
33
internal/parser/vendors/inspur/event_logs_test.go
vendored
Normal file
33
internal/parser/vendors/inspur/event_logs_test.go
vendored
Normal 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])
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
2
internal/parser/vendors/inspur/idl.go
vendored
2
internal/parser/vendors/inspur/idl.go
vendored
@@ -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,
|
||||
|
||||
1
internal/parser/vendors/inspur/parser.go
vendored
1
internal/parser/vendors/inspur/parser.go
vendored
@@ -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
|
||||
|
||||
4
internal/parser/vendors/inspur/syslog.go
vendored
4
internal/parser/vendors/inspur/syslog.go
vendored
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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: "Подключение..."},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
81
internal/server/reanimator_example_regression_test.go
Normal file
81
internal/server/reanimator_example_regression_test.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user