Improve Redfish recovery flow and raw export timing diagnostics
This commit is contained in:
@@ -538,6 +538,10 @@ func (c *RedfishConnector) collectRawRedfishTree(ctx context.Context, client *ht
|
||||
const workers = 6
|
||||
const heartbeatInterval = 5 * time.Second
|
||||
crawlStart := time.Now()
|
||||
memoryClient := c.httpClientWithTimeout(req, redfishSnapshotMemoryRequestTimeout())
|
||||
memoryGate := make(chan struct{}, redfishSnapshotMemoryConcurrency())
|
||||
branchLimiter := newRedfishSnapshotBranchLimiter(redfishSnapshotBranchConcurrency())
|
||||
branchRetryPause := redfishSnapshotBranchRequeueBackoff()
|
||||
|
||||
out := make(map[string]interface{}, maxDocuments)
|
||||
fetchErrors := make(map[string]string)
|
||||
@@ -619,9 +623,59 @@ func (c *RedfishConnector) collectRawRedfishTree(ctx context.Context, client *ht
|
||||
for i := 0; i < workers; i++ {
|
||||
go func(workerID int) {
|
||||
for current := range jobs {
|
||||
if !branchLimiter.tryAcquire(current) {
|
||||
select {
|
||||
case jobs <- current:
|
||||
c.debugSnapshotf("worker=%d requeue branch-busy path=%s branch=%s queue_len=%d", workerID, current, redfishSnapshotBranchKey(current), len(jobs))
|
||||
select {
|
||||
case <-time.After(branchRetryPause):
|
||||
case <-ctx.Done():
|
||||
}
|
||||
continue
|
||||
default:
|
||||
}
|
||||
if !branchLimiter.waitAcquire(ctx, current, branchRetryPause) {
|
||||
n := atomic.AddInt32(&processed, 1)
|
||||
mu.Lock()
|
||||
if _, ok := fetchErrors[current]; !ok && ctx.Err() != nil {
|
||||
fetchErrors[current] = ctx.Err().Error()
|
||||
}
|
||||
mu.Unlock()
|
||||
if emit != nil && ctx.Err() != nil {
|
||||
emit(Progress{
|
||||
Status: "running",
|
||||
Progress: 92 + int(minInt32(n/200, 6)),
|
||||
Message: fmt.Sprintf("Redfish snapshot: ошибка на %s", compactProgressPath(current)),
|
||||
})
|
||||
}
|
||||
wg.Done()
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
lastPath.Store(current)
|
||||
c.debugSnapshotf("worker=%d fetch start path=%s queue_len=%d", workerID, current, len(jobs))
|
||||
doc, err := c.getJSON(ctx, client, req, baseURL, current)
|
||||
doc, err := func() (map[string]interface{}, error) {
|
||||
defer branchLimiter.release(current)
|
||||
if !isRedfishMemoryMemberPath(current) {
|
||||
return c.getJSON(ctx, client, req, baseURL, current)
|
||||
}
|
||||
select {
|
||||
case memoryGate <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
defer func() { <-memoryGate }()
|
||||
return c.getJSONWithRetry(
|
||||
ctx,
|
||||
memoryClient,
|
||||
req,
|
||||
baseURL,
|
||||
current,
|
||||
redfishSnapshotMemoryRetryAttempts(),
|
||||
redfishSnapshotMemoryRetryBackoff(),
|
||||
)
|
||||
}()
|
||||
if err == nil {
|
||||
mu.Lock()
|
||||
out[current] = doc
|
||||
@@ -1018,6 +1072,7 @@ func redfishCriticalEndpoints(systemPaths, chassisPaths, managerPaths []string)
|
||||
add(joinPath(p, "/Oem/Public/ThermalConfig"))
|
||||
add(joinPath(p, "/ThermalConfig"))
|
||||
add(joinPath(p, "/Processors"))
|
||||
add(joinPath(p, "/Memory"))
|
||||
add(joinPath(p, "/Storage"))
|
||||
add(joinPath(p, "/SimpleStorage"))
|
||||
add(joinPath(p, "/PCIeDevices"))
|
||||
@@ -1122,6 +1177,24 @@ func redfishCriticalPlanBAttempts() int {
|
||||
return 3
|
||||
}
|
||||
|
||||
func redfishCriticalMemberRetryAttempts() int {
|
||||
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_CRITICAL_MEMBER_RETRIES")); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= 6 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func redfishCriticalMemberRecoveryMax() int {
|
||||
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_CRITICAL_MEMBER_RECOVERY_MAX")); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= 1024 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 48
|
||||
}
|
||||
|
||||
func redfishCriticalRetryBackoff() time.Duration {
|
||||
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_CRITICAL_BACKOFF")); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil && d >= 0 {
|
||||
@@ -1149,6 +1222,128 @@ func redfishCriticalSlowGap() time.Duration {
|
||||
return 1200 * time.Millisecond
|
||||
}
|
||||
|
||||
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 {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return 25 * time.Second
|
||||
}
|
||||
|
||||
func redfishSnapshotMemoryRetryAttempts() int {
|
||||
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_MEMORY_RETRIES")); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= 8 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
func redfishSnapshotMemoryRetryBackoff() time.Duration {
|
||||
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_MEMORY_BACKOFF")); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil && d >= 0 {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return 800 * time.Millisecond
|
||||
}
|
||||
|
||||
func redfishSnapshotMemoryConcurrency() int {
|
||||
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_MEMORY_WORKERS")); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= 8 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func redfishSnapshotBranchConcurrency() int {
|
||||
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_BRANCH_WORKERS")); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= 8 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func redfishSnapshotBranchRequeueBackoff() time.Duration {
|
||||
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_BRANCH_BACKOFF")); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil && d >= 0 {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return 35 * time.Millisecond
|
||||
}
|
||||
|
||||
type redfishSnapshotBranchLimiter struct {
|
||||
limit int
|
||||
mu sync.Mutex
|
||||
inFlight map[string]int
|
||||
}
|
||||
|
||||
func newRedfishSnapshotBranchLimiter(limit int) *redfishSnapshotBranchLimiter {
|
||||
if limit < 1 {
|
||||
limit = 1
|
||||
}
|
||||
return &redfishSnapshotBranchLimiter{
|
||||
limit: limit,
|
||||
inFlight: make(map[string]int),
|
||||
}
|
||||
}
|
||||
|
||||
func (l *redfishSnapshotBranchLimiter) tryAcquire(path string) bool {
|
||||
branch := redfishSnapshotBranchKey(path)
|
||||
if branch == "" {
|
||||
return true
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if l.inFlight[branch] >= l.limit {
|
||||
return false
|
||||
}
|
||||
l.inFlight[branch]++
|
||||
return true
|
||||
}
|
||||
|
||||
func (l *redfishSnapshotBranchLimiter) waitAcquire(ctx context.Context, path string, backoff time.Duration) bool {
|
||||
branch := redfishSnapshotBranchKey(path)
|
||||
if branch == "" {
|
||||
return true
|
||||
}
|
||||
if backoff < 0 {
|
||||
backoff = 0
|
||||
}
|
||||
for {
|
||||
if l.tryAcquire(path) {
|
||||
return true
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case <-time.After(backoff):
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (l *redfishSnapshotBranchLimiter) release(path string) {
|
||||
branch := redfishSnapshotBranchKey(path)
|
||||
if branch == "" {
|
||||
return
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
switch n := l.inFlight[branch]; {
|
||||
case n <= 1:
|
||||
delete(l.inFlight, branch)
|
||||
default:
|
||||
l.inFlight[branch] = n - 1
|
||||
}
|
||||
}
|
||||
|
||||
func redfishLinkRefs(doc map[string]interface{}, topKey, nestedKey string) []string {
|
||||
top, ok := doc[topKey].(map[string]interface{})
|
||||
if !ok {
|
||||
@@ -1238,6 +1433,54 @@ func shouldCrawlPath(path string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func isRedfishMemoryMemberPath(path string) bool {
|
||||
normalized := normalizeRedfishPath(path)
|
||||
if !strings.Contains(normalized, "/Systems/") {
|
||||
return false
|
||||
}
|
||||
if !strings.Contains(normalized, "/Memory/") {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(normalized, "/MemoryMetrics") || strings.Contains(normalized, "/Assembly") {
|
||||
return false
|
||||
}
|
||||
after := strings.SplitN(normalized, "/Memory/", 2)
|
||||
if len(after) != 2 {
|
||||
return false
|
||||
}
|
||||
suffix := strings.TrimSpace(after[1])
|
||||
if suffix == "" || strings.Contains(suffix, "/") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func redfishCollectionHasExplicitMembers(doc map[string]interface{}) bool {
|
||||
return len(redfishCollectionMemberRefs(doc)) > 0
|
||||
}
|
||||
|
||||
func redfishSnapshotBranchKey(path string) string {
|
||||
normalized := normalizeRedfishPath(path)
|
||||
if normalized == "" || normalized == "/redfish/v1" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(strings.Trim(normalized, "/"), "/")
|
||||
if len(parts) < 3 {
|
||||
return normalized
|
||||
}
|
||||
if parts[0] != "redfish" || parts[1] != "v1" {
|
||||
return normalized
|
||||
}
|
||||
// Keep subsystem branches independent, e.g. Systems/1/Memory vs Systems/1/PCIeDevices.
|
||||
if len(parts) >= 5 && (parts[2] == "Systems" || parts[2] == "Chassis" || parts[2] == "Managers") {
|
||||
return "/" + strings.Join(parts[:5], "/")
|
||||
}
|
||||
if len(parts) >= 4 {
|
||||
return "/" + strings.Join(parts[:4], "/")
|
||||
}
|
||||
return "/" + strings.Join(parts[:3], "/")
|
||||
}
|
||||
|
||||
func (c *RedfishConnector) getLinkedPCIeFunctions(ctx context.Context, client *http.Client, req Request, baseURL string, doc map[string]interface{}) []map[string]interface{} {
|
||||
// Newer Redfish payloads often keep function references in Links.PCIeFunctions.
|
||||
if links, ok := doc["Links"].(map[string]interface{}); ok {
|
||||
@@ -1381,14 +1624,51 @@ func (c *RedfishConnector) getJSONWithRetry(ctx context.Context, client *http.Cl
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func (c *RedfishConnector) collectCriticalCollectionMembersSequential(ctx context.Context, client *http.Client, req Request, baseURL, collectionPath string, collectionDoc map[string]interface{}) (map[string]interface{}, bool) {
|
||||
func (c *RedfishConnector) collectCriticalCollectionMembersSequential(
|
||||
ctx context.Context,
|
||||
client *http.Client,
|
||||
req Request,
|
||||
baseURL string,
|
||||
collectionDoc map[string]interface{},
|
||||
rawTree map[string]interface{},
|
||||
fetchErrs map[string]string,
|
||||
) (map[string]interface{}, bool) {
|
||||
memberPaths := redfishCollectionMemberRefs(collectionDoc)
|
||||
if len(memberPaths) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
out := make(map[string]interface{})
|
||||
|
||||
retryableMissing := make([]string, 0, len(memberPaths))
|
||||
unknownMissing := make([]string, 0, len(memberPaths))
|
||||
for _, memberPath := range memberPaths {
|
||||
doc, err := c.getJSONWithRetry(ctx, client, req, baseURL, memberPath, redfishCriticalRetryAttempts(), redfishCriticalRetryBackoff())
|
||||
memberPath = normalizeRedfishPath(memberPath)
|
||||
if memberPath == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := rawTree[memberPath]; exists {
|
||||
continue
|
||||
}
|
||||
if msg, hasErr := fetchErrs[memberPath]; hasErr {
|
||||
if !isRetryableRedfishFetchError(fmt.Errorf("%s", msg)) {
|
||||
continue
|
||||
}
|
||||
retryableMissing = append(retryableMissing, memberPath)
|
||||
continue
|
||||
}
|
||||
unknownMissing = append(unknownMissing, memberPath)
|
||||
}
|
||||
|
||||
candidates := append(retryableMissing, unknownMissing...)
|
||||
if len(candidates) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
if maxMembers := redfishCriticalMemberRecoveryMax(); maxMembers > 0 && len(candidates) > maxMembers {
|
||||
candidates = candidates[:maxMembers]
|
||||
}
|
||||
|
||||
out := make(map[string]interface{}, len(candidates))
|
||||
for _, memberPath := range candidates {
|
||||
doc, err := c.getJSONWithRetry(ctx, client, req, baseURL, memberPath, redfishCriticalMemberRetryAttempts(), redfishCriticalRetryBackoff())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -1398,6 +1678,7 @@ func (c *RedfishConnector) collectCriticalCollectionMembersSequential(ctx contex
|
||||
}
|
||||
|
||||
func (c *RedfishConnector) recoverCriticalRedfishDocsPlanB(ctx context.Context, client *http.Client, req Request, baseURL string, criticalPaths []string, rawTree map[string]interface{}, fetchErrs map[string]string, emit ProgressFn) int {
|
||||
planBStart := time.Now()
|
||||
var targets []string
|
||||
seenTargets := make(map[string]struct{})
|
||||
addTarget := func(path string) {
|
||||
@@ -1493,15 +1774,20 @@ func (c *RedfishConnector) recoverCriticalRedfishDocsPlanB(ctx context.Context,
|
||||
rawTree[p] = doc
|
||||
delete(fetchErrs, p)
|
||||
recovered++
|
||||
if members, ok := c.collectCriticalCollectionMembersSequential(ctx, client, req, baseURL, p, doc); ok {
|
||||
|
||||
if members, ok := c.collectCriticalCollectionMembersSequential(ctx, client, req, baseURL, doc, rawTree, fetchErrs); ok {
|
||||
for mp, md := range members {
|
||||
if _, exists := rawTree[mp]; !exists {
|
||||
rawTree[mp] = md
|
||||
recovered++
|
||||
if _, exists := rawTree[mp]; exists {
|
||||
continue
|
||||
}
|
||||
rawTree[mp] = md
|
||||
delete(fetchErrs, mp)
|
||||
recovered++
|
||||
}
|
||||
}
|
||||
if shouldSlowProbeCriticalCollection(p) {
|
||||
|
||||
// Numeric slow-probe is expensive; skip it when collection already advertises explicit members.
|
||||
if shouldSlowProbeCriticalCollection(p) && !redfishCollectionHasExplicitMembers(doc) {
|
||||
if children := c.probeDirectRedfishCollectionChildrenSlow(ctx, client, req, baseURL, p); len(children) > 0 {
|
||||
for cp, cd := range children {
|
||||
if _, exists := rawTree[cp]; exists {
|
||||
@@ -1514,6 +1800,7 @@ func (c *RedfishConnector) recoverCriticalRedfishDocsPlanB(ctx context.Context,
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
fetchErrs[p] = err.Error()
|
||||
// If collection endpoint times out, still try direct child probing for common numeric paths.
|
||||
if shouldSlowProbeCriticalCollection(p) {
|
||||
@@ -1529,6 +1816,13 @@ func (c *RedfishConnector) recoverCriticalRedfishDocsPlanB(ctx context.Context,
|
||||
}
|
||||
}
|
||||
}
|
||||
if emit != nil {
|
||||
emit(Progress{
|
||||
Status: "running",
|
||||
Progress: 97,
|
||||
Message: fmt.Sprintf("Redfish: plan-B завершен за %s (targets=%d, recovered=%d)", time.Since(planBStart).Round(time.Second), len(targets), recovered),
|
||||
})
|
||||
}
|
||||
return recovered
|
||||
}
|
||||
|
||||
|
||||
@@ -75,8 +75,11 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) (
|
||||
nics := r.collectNICs(chassisPaths)
|
||||
r.enrichNICsFromNetworkInterfaces(&nics, systemPaths)
|
||||
thresholdSensors := r.collectThresholdSensors(chassisPaths)
|
||||
thermalSensors := r.collectThermalSensors(chassisPaths)
|
||||
powerSensors := r.collectPowerSensors(chassisPaths)
|
||||
discreteEvents := r.collectDiscreteSensorEvents(chassisPaths)
|
||||
healthEvents := r.collectHealthSummaryEvents(chassisPaths)
|
||||
driveFetchWarningEvents := buildDriveFetchWarningEvents(rawPayloads)
|
||||
managerDoc, _ := r.getJSON(primaryManager)
|
||||
networkProtocolDoc, _ := r.getJSON(joinPath(primaryManager, "/NetworkProtocol"))
|
||||
firmware := parseFirmware(systemDoc, biosDoc, managerDoc, secureBootDoc, networkProtocolDoc)
|
||||
@@ -85,9 +88,9 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) (
|
||||
applyBoardInfoFallbackFromDocs(&boardInfo, boardFallbackDocs)
|
||||
|
||||
result := &models.AnalysisResult{
|
||||
Events: append(append(make([]models.Event, 0, len(discreteEvents)+len(healthEvents)+1), healthEvents...), discreteEvents...),
|
||||
Events: append(append(append(make([]models.Event, 0, len(discreteEvents)+len(healthEvents)+len(driveFetchWarningEvents)+1), healthEvents...), discreteEvents...), driveFetchWarningEvents...),
|
||||
FRU: make([]models.FRUInfo, 0),
|
||||
Sensors: thresholdSensors,
|
||||
Sensors: dedupeSensorReadings(append(append(thresholdSensors, thermalSensors...), powerSensors...)),
|
||||
RawPayloads: cloneRawPayloads(rawPayloads),
|
||||
Hardware: &models.HardwareConfig{
|
||||
BoardInfo: boardInfo,
|
||||
@@ -168,6 +171,55 @@ func redfishFetchErrorsFromRawPayloads(rawPayloads map[string]any) map[string]st
|
||||
}
|
||||
}
|
||||
|
||||
func buildDriveFetchWarningEvents(rawPayloads map[string]any) []models.Event {
|
||||
errs := redfishFetchErrorsFromRawPayloads(rawPayloads)
|
||||
if len(errs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
paths := make([]string, 0, len(errs))
|
||||
timeoutCount := 0
|
||||
for path, msg := range errs {
|
||||
normalizedPath := normalizeRedfishPath(path)
|
||||
if !strings.Contains(strings.ToLower(normalizedPath), "/drives/") {
|
||||
continue
|
||||
}
|
||||
paths = append(paths, normalizedPath)
|
||||
low := strings.ToLower(msg)
|
||||
if strings.Contains(low, "timeout") || strings.Contains(low, "deadline exceeded") {
|
||||
timeoutCount++
|
||||
}
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
sort.Strings(paths)
|
||||
preview := paths
|
||||
const maxPreview = 8
|
||||
if len(preview) > maxPreview {
|
||||
preview = preview[:maxPreview]
|
||||
}
|
||||
rawData := strings.Join(preview, ", ")
|
||||
if len(paths) > len(preview) {
|
||||
rawData = fmt.Sprintf("%s (+%d more)", rawData, len(paths)-len(preview))
|
||||
}
|
||||
if timeoutCount > 0 {
|
||||
rawData = fmt.Sprintf("timeouts=%d; paths=%s", timeoutCount, rawData)
|
||||
}
|
||||
|
||||
return []models.Event{
|
||||
{
|
||||
Timestamp: time.Now(),
|
||||
Source: "Redfish",
|
||||
EventType: "Collection Warning",
|
||||
Severity: models.SeverityWarning,
|
||||
Description: fmt.Sprintf("%d drive documents were unavailable; storage details may be incomplete", len(paths)),
|
||||
RawData: rawData,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (r redfishSnapshotReader) collectFirmwareInventory() []models.FirmwareInfo {
|
||||
docs, err := r.getCollectionMembers("/redfish/v1/UpdateService/FirmwareInventory")
|
||||
if err != nil || len(docs) == 0 {
|
||||
@@ -219,9 +271,12 @@ func (r redfishSnapshotReader) collectThresholdSensors(chassisPaths []string) []
|
||||
out := make([]models.SensorReading, 0)
|
||||
seen := make(map[string]struct{})
|
||||
for _, chassisPath := range chassisPaths {
|
||||
docs, err := r.getCollectionMembers(joinPath(chassisPath, "/ThresholdSensors"))
|
||||
if err != nil || len(docs) == 0 {
|
||||
continue
|
||||
thresholdPath := joinPath(chassisPath, "/ThresholdSensors")
|
||||
docs, _ := r.getCollectionMembers(thresholdPath)
|
||||
if len(docs) == 0 {
|
||||
if thresholdDoc, err := r.getJSON(thresholdPath); err == nil {
|
||||
docs = append(docs, redfishInlineSensors(thresholdDoc)...)
|
||||
}
|
||||
}
|
||||
for _, doc := range docs {
|
||||
sensor, ok := parseThresholdSensor(doc)
|
||||
@@ -293,37 +348,235 @@ func parseThresholdSensor(doc map[string]interface{}) (models.SensorReading, boo
|
||||
func (r redfishSnapshotReader) collectDiscreteSensorEvents(chassisPaths []string) []models.Event {
|
||||
out := make([]models.Event, 0)
|
||||
for _, chassisPath := range chassisPaths {
|
||||
docs, err := r.getCollectionMembers(joinPath(chassisPath, "/DiscreteSensors"))
|
||||
if err != nil || len(docs) == 0 {
|
||||
continue
|
||||
discretePath := joinPath(chassisPath, "/DiscreteSensors")
|
||||
docs, _ := r.getCollectionMembers(discretePath)
|
||||
if len(docs) == 0 {
|
||||
if discreteDoc, err := r.getJSON(discretePath); err == nil {
|
||||
docs = append(docs, redfishInlineSensors(discreteDoc)...)
|
||||
}
|
||||
}
|
||||
for _, doc := range docs {
|
||||
name := firstNonEmpty(asString(doc["Name"]), asString(doc["Id"]))
|
||||
status := mapStatus(doc["Status"])
|
||||
if status == "" {
|
||||
status = firstNonEmpty(asString(doc["Health"]), asString(doc["State"]))
|
||||
}
|
||||
if name == "" || status == "" {
|
||||
ev, ok := parseDiscreteSensorEvent(doc)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
normalized := strings.ToLower(strings.TrimSpace(status))
|
||||
if normalized == "ok" || normalized == "enabled" || normalized == "normal" || normalized == "present" {
|
||||
continue
|
||||
}
|
||||
out = append(out, models.Event{
|
||||
Timestamp: time.Now(),
|
||||
Source: "Redfish",
|
||||
SensorName: name,
|
||||
EventType: "Discrete Sensor Status",
|
||||
Severity: models.SeverityWarning,
|
||||
Description: fmt.Sprintf("%s reports %s", name, status),
|
||||
RawData: firstNonEmpty(asString(doc["Description"]), status),
|
||||
})
|
||||
out = append(out, ev)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseDiscreteSensorEvent(doc map[string]interface{}) (models.Event, bool) {
|
||||
name := firstNonEmpty(asString(doc["Name"]), asString(doc["Id"]))
|
||||
status := mapStatus(doc["Status"])
|
||||
if status == "" {
|
||||
status = firstNonEmpty(asString(doc["Health"]), asString(doc["State"]))
|
||||
}
|
||||
if name == "" || status == "" {
|
||||
return models.Event{}, false
|
||||
}
|
||||
normalized := strings.ToLower(strings.TrimSpace(status))
|
||||
if normalized == "ok" || normalized == "enabled" || normalized == "normal" || normalized == "present" {
|
||||
return models.Event{}, false
|
||||
}
|
||||
return models.Event{
|
||||
Timestamp: time.Now(),
|
||||
Source: "Redfish",
|
||||
SensorName: name,
|
||||
EventType: "Discrete Sensor Status",
|
||||
Severity: models.SeverityWarning,
|
||||
Description: fmt.Sprintf("%s reports %s", name, status),
|
||||
RawData: firstNonEmpty(asString(doc["Description"]), status),
|
||||
}, true
|
||||
}
|
||||
|
||||
func (r redfishSnapshotReader) collectThermalSensors(chassisPaths []string) []models.SensorReading {
|
||||
out := make([]models.SensorReading, 0)
|
||||
for _, chassisPath := range chassisPaths {
|
||||
doc, err := r.getJSON(joinPath(chassisPath, "/Thermal"))
|
||||
if err != nil || len(doc) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, fanDoc := range redfishArrayObjects(doc["Fans"]) {
|
||||
out = append(out, parseThermalFanSensor(fanDoc))
|
||||
}
|
||||
for _, tempDoc := range redfishArrayObjects(doc["Temperatures"]) {
|
||||
out = append(out, parseThermalTemperatureSensor(tempDoc))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r redfishSnapshotReader) collectPowerSensors(chassisPaths []string) []models.SensorReading {
|
||||
out := make([]models.SensorReading, 0)
|
||||
for _, chassisPath := range chassisPaths {
|
||||
doc, err := r.getJSON(joinPath(chassisPath, "/Power"))
|
||||
if err != nil || len(doc) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, parsePowerOemPublicSensors(doc)...)
|
||||
for _, controlDoc := range redfishArrayObjects(doc["PowerControl"]) {
|
||||
if sensor, ok := parsePowerControlSensor(controlDoc); ok {
|
||||
out = append(out, sensor)
|
||||
}
|
||||
}
|
||||
for _, psuDoc := range redfishArrayObjects(doc["PowerSupplies"]) {
|
||||
out = append(out, parsePowerSupplySensors(psuDoc)...)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseThermalFanSensor(doc map[string]interface{}) models.SensorReading {
|
||||
name := firstNonEmpty(asString(doc["Name"]), asString(doc["MemberId"]), "Fan")
|
||||
unit := firstNonEmpty(asString(doc["ReadingUnits"]), "RPM")
|
||||
value := asFloat(doc["Reading"])
|
||||
raw := firstNonEmpty(asString(doc["Reading"]), asString(doc["Name"]))
|
||||
status := firstNonEmpty(mapStatus(doc["Status"]), asString(doc["State"]), asString(doc["Health"]), "unknown")
|
||||
return models.SensorReading{
|
||||
Name: name,
|
||||
Type: "fan_speed",
|
||||
Value: value,
|
||||
Unit: unit,
|
||||
RawValue: raw,
|
||||
Status: status,
|
||||
}
|
||||
}
|
||||
|
||||
func parseThermalTemperatureSensor(doc map[string]interface{}) models.SensorReading {
|
||||
name := firstNonEmpty(asString(doc["Name"]), asString(doc["MemberId"]), "Temperature")
|
||||
reading := asFloat(doc["ReadingCelsius"])
|
||||
raw := asString(doc["ReadingCelsius"])
|
||||
if raw == "" {
|
||||
reading = asFloat(doc["Reading"])
|
||||
raw = asString(doc["Reading"])
|
||||
}
|
||||
status := firstNonEmpty(mapStatus(doc["Status"]), asString(doc["State"]), asString(doc["Health"]), "unknown")
|
||||
return models.SensorReading{
|
||||
Name: name,
|
||||
Type: "temperature",
|
||||
Value: reading,
|
||||
Unit: "C",
|
||||
RawValue: raw,
|
||||
Status: status,
|
||||
}
|
||||
}
|
||||
|
||||
func parsePowerOemPublicSensors(doc map[string]interface{}) []models.SensorReading {
|
||||
oem, ok := doc["Oem"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
public, ok := oem["Public"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var out []models.SensorReading
|
||||
add := func(name, key string) {
|
||||
raw := asString(public[key])
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return
|
||||
}
|
||||
out = append(out, models.SensorReading{
|
||||
Name: name,
|
||||
Type: "power",
|
||||
Value: asFloat(public[key]),
|
||||
Unit: "W",
|
||||
RawValue: raw,
|
||||
Status: "OK",
|
||||
})
|
||||
}
|
||||
add("Total_Power", "TotalPower")
|
||||
add("CPU_Power", "CurrentCPUPowerWatts")
|
||||
add("Memory_Power", "CurrentMemoryPowerWatts")
|
||||
add("Fan_Power", "CurrentFANPowerWatts")
|
||||
return out
|
||||
}
|
||||
|
||||
func parsePowerControlSensor(doc map[string]interface{}) (models.SensorReading, bool) {
|
||||
raw := asString(doc["PowerConsumedWatts"])
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return models.SensorReading{}, false
|
||||
}
|
||||
name := firstNonEmpty(asString(doc["Name"]), asString(doc["MemberId"]), "PowerControl")
|
||||
status := firstNonEmpty(mapStatus(doc["Status"]), asString(doc["State"]), asString(doc["Health"]), "unknown")
|
||||
return models.SensorReading{
|
||||
Name: name + "_Consumed",
|
||||
Type: "power",
|
||||
Value: asFloat(doc["PowerConsumedWatts"]),
|
||||
Unit: "W",
|
||||
RawValue: raw,
|
||||
Status: status,
|
||||
}, true
|
||||
}
|
||||
|
||||
func parsePowerSupplySensors(doc map[string]interface{}) []models.SensorReading {
|
||||
name := firstNonEmpty(asString(doc["Name"]), "PSU")
|
||||
status := firstNonEmpty(mapStatus(doc["Status"]), asString(doc["State"]), asString(doc["Health"]), "unknown")
|
||||
var out []models.SensorReading
|
||||
add := func(suffix, key, unit string) {
|
||||
raw := asString(doc[key])
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return
|
||||
}
|
||||
out = append(out, models.SensorReading{
|
||||
Name: fmt.Sprintf("%s_%s", name, suffix),
|
||||
Type: strings.ToLower(suffix),
|
||||
Value: asFloat(doc[key]),
|
||||
Unit: unit,
|
||||
RawValue: raw,
|
||||
Status: status,
|
||||
})
|
||||
}
|
||||
add("InputPower", "PowerInputWatts", "W")
|
||||
add("OutputPower", "LastPowerOutputWatts", "W")
|
||||
add("InputVoltage", "LineInputVoltage", "V")
|
||||
return out
|
||||
}
|
||||
|
||||
func redfishArrayObjects(v any) []map[string]interface{} {
|
||||
list, ok := v.([]interface{})
|
||||
if !ok || len(list) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]map[string]interface{}, 0, len(list))
|
||||
for _, item := range list {
|
||||
m, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func redfishInlineSensors(doc map[string]interface{}) []map[string]interface{} {
|
||||
return redfishArrayObjects(doc["Sensors"])
|
||||
}
|
||||
|
||||
func dedupeSensorReadings(items []models.SensorReading) []models.SensorReading {
|
||||
if len(items) <= 1 {
|
||||
return items
|
||||
}
|
||||
out := make([]models.SensorReading, 0, len(items))
|
||||
seen := make(map[string]struct{}, len(items))
|
||||
for _, s := range items {
|
||||
key := strings.ToLower(strings.TrimSpace(s.Name) + "|" + strings.TrimSpace(s.Type))
|
||||
if strings.TrimSpace(key) == "|" {
|
||||
key = strings.ToLower(strings.TrimSpace(s.RawValue))
|
||||
}
|
||||
if strings.TrimSpace(key) == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r redfishSnapshotReader) collectHealthSummaryEvents(chassisPaths []string) []models.Event {
|
||||
out := make([]models.Event, 0)
|
||||
for _, chassisPath := range chassisPaths {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.mchus.pro/mchus/logpile/internal/models"
|
||||
@@ -303,6 +304,210 @@ func TestReplayRedfishFromRawPayloads_FallbackCollectionMembersByPrefix(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplayRedfishFromRawPayloads_ParsesInlineThresholdAndDiscreteSensors(t *testing.T) {
|
||||
raw := map[string]any{
|
||||
"redfish_tree": map[string]interface{}{
|
||||
"/redfish/v1": map[string]interface{}{
|
||||
"Systems": map[string]interface{}{"@odata.id": "/redfish/v1/Systems"},
|
||||
"Chassis": map[string]interface{}{"@odata.id": "/redfish/v1/Chassis"},
|
||||
"Managers": map[string]interface{}{"@odata.id": "/redfish/v1/Managers"},
|
||||
},
|
||||
"/redfish/v1/Systems": map[string]interface{}{
|
||||
"Members": []interface{}{
|
||||
map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1"},
|
||||
},
|
||||
},
|
||||
"/redfish/v1/Systems/1": map[string]interface{}{
|
||||
"Id": "1",
|
||||
"Manufacturer": "Inspur",
|
||||
"Model": "NF5688M7",
|
||||
"SerialNumber": "23E100051",
|
||||
},
|
||||
"/redfish/v1/Chassis": map[string]interface{}{
|
||||
"Members": []interface{}{
|
||||
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1"},
|
||||
},
|
||||
},
|
||||
"/redfish/v1/Chassis/1": map[string]interface{}{
|
||||
"Id": "1",
|
||||
},
|
||||
"/redfish/v1/Managers": map[string]interface{}{
|
||||
"Members": []interface{}{
|
||||
map[string]interface{}{"@odata.id": "/redfish/v1/Managers/1"},
|
||||
},
|
||||
},
|
||||
"/redfish/v1/Managers/1": map[string]interface{}{
|
||||
"Id": "1",
|
||||
},
|
||||
"/redfish/v1/Chassis/1/ThresholdSensors": map[string]interface{}{
|
||||
"Sensors": []interface{}{
|
||||
map[string]interface{}{
|
||||
"Name": "Inlet_Temp",
|
||||
"Reading": 16,
|
||||
"ReadingUnits": "deg_c",
|
||||
"State": "Enabled",
|
||||
},
|
||||
},
|
||||
},
|
||||
"/redfish/v1/Chassis/1/DiscreteSensors": map[string]interface{}{
|
||||
"Sensors": []interface{}{
|
||||
map[string]interface{}{
|
||||
"Name": "PSU_Redundant",
|
||||
"State": "Disabled",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got, err := ReplayRedfishFromRawPayloads(raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("replay failed: %v", err)
|
||||
}
|
||||
if len(got.Sensors) == 0 {
|
||||
t.Fatalf("expected sensors from inline ThresholdSensors")
|
||||
}
|
||||
foundSensor := false
|
||||
for _, s := range got.Sensors {
|
||||
if s.Name == "Inlet_Temp" {
|
||||
foundSensor = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundSensor {
|
||||
t.Fatalf("expected Inlet_Temp sensor in replay output")
|
||||
}
|
||||
foundEvent := false
|
||||
for _, ev := range got.Events {
|
||||
if ev.EventType == "Discrete Sensor Status" && ev.SensorName == "PSU_Redundant" {
|
||||
foundEvent = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundEvent {
|
||||
t.Fatalf("expected discrete sensor warning event from inline DiscreteSensors")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplayRedfishFromRawPayloads_CollectsThermalAndPowerSensors(t *testing.T) {
|
||||
raw := map[string]any{
|
||||
"redfish_tree": map[string]interface{}{
|
||||
"/redfish/v1": map[string]interface{}{
|
||||
"Systems": map[string]interface{}{"@odata.id": "/redfish/v1/Systems"},
|
||||
"Chassis": map[string]interface{}{"@odata.id": "/redfish/v1/Chassis"},
|
||||
"Managers": map[string]interface{}{"@odata.id": "/redfish/v1/Managers"},
|
||||
},
|
||||
"/redfish/v1/Systems": map[string]interface{}{
|
||||
"Members": []interface{}{
|
||||
map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1"},
|
||||
},
|
||||
},
|
||||
"/redfish/v1/Systems/1": map[string]interface{}{
|
||||
"Id": "1",
|
||||
"Manufacturer": "Inspur",
|
||||
"Model": "NF5688M7",
|
||||
"SerialNumber": "23E100051",
|
||||
},
|
||||
"/redfish/v1/Chassis": map[string]interface{}{
|
||||
"Members": []interface{}{
|
||||
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1"},
|
||||
},
|
||||
},
|
||||
"/redfish/v1/Chassis/1": map[string]interface{}{
|
||||
"Id": "1",
|
||||
},
|
||||
"/redfish/v1/Managers": map[string]interface{}{
|
||||
"Members": []interface{}{
|
||||
map[string]interface{}{"@odata.id": "/redfish/v1/Managers/1"},
|
||||
},
|
||||
},
|
||||
"/redfish/v1/Managers/1": map[string]interface{}{
|
||||
"Id": "1",
|
||||
},
|
||||
"/redfish/v1/Chassis/1/Thermal": map[string]interface{}{
|
||||
"Fans": []interface{}{
|
||||
map[string]interface{}{
|
||||
"Name": "FAN0_F_Speed",
|
||||
"Reading": 9279,
|
||||
"ReadingUnits": "RPM",
|
||||
"Status": map[string]interface{}{
|
||||
"Health": "OK",
|
||||
"State": "Enabled",
|
||||
},
|
||||
},
|
||||
},
|
||||
"Temperatures": []interface{}{
|
||||
map[string]interface{}{
|
||||
"Name": "CPU0_Temp",
|
||||
"ReadingCelsius": 44,
|
||||
"Status": map[string]interface{}{
|
||||
"Health": "OK",
|
||||
"State": "Enabled",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/redfish/v1/Chassis/1/Power": map[string]interface{}{
|
||||
"Oem": map[string]interface{}{
|
||||
"Public": map[string]interface{}{
|
||||
"TotalPower": 1836,
|
||||
"CurrentCPUPowerWatts": 304,
|
||||
"CurrentMemoryPowerWatts": 75,
|
||||
"CurrentFANPowerWatts": 180,
|
||||
},
|
||||
},
|
||||
"PowerControl": []interface{}{
|
||||
map[string]interface{}{
|
||||
"Name": "System Power Control 1",
|
||||
"PowerConsumedWatts": 1836,
|
||||
"Status": map[string]interface{}{
|
||||
"Health": "OK",
|
||||
"State": "Enabled",
|
||||
},
|
||||
},
|
||||
},
|
||||
"PowerSupplies": []interface{}{
|
||||
map[string]interface{}{
|
||||
"Name": "Power Supply 1",
|
||||
"PowerInputWatts": 180,
|
||||
"LastPowerOutputWatts": 155,
|
||||
"LineInputVoltage": 223.25,
|
||||
"Status": map[string]interface{}{
|
||||
"Health": "OK",
|
||||
"State": "Enabled",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got, err := ReplayRedfishFromRawPayloads(raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("replay failed: %v", err)
|
||||
}
|
||||
if len(got.Sensors) == 0 {
|
||||
t.Fatalf("expected non-empty sensors")
|
||||
}
|
||||
expected := map[string]bool{
|
||||
"FAN0_F_Speed": false,
|
||||
"CPU0_Temp": false,
|
||||
"Total_Power": false,
|
||||
"System Power Control 1_Consumed": false,
|
||||
"Power Supply 1_InputPower": false,
|
||||
}
|
||||
for _, s := range got.Sensors {
|
||||
if _, ok := expected[s.Name]; ok {
|
||||
expected[s.Name] = true
|
||||
}
|
||||
}
|
||||
for name, found := range expected {
|
||||
if !found {
|
||||
t.Fatalf("expected sensor %q in replay output", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichNICFromPCIeFunctions(t *testing.T) {
|
||||
nic := parseNIC(map[string]interface{}{
|
||||
"Id": "1",
|
||||
@@ -502,6 +707,79 @@ func TestRecoverCriticalRedfishDocsPlanB_RetriesMembersFromExistingCollection(t
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoverCriticalRedfishDocsPlanB_RetriesMembersFromSystemMemoryCollection(t *testing.T) {
|
||||
t.Setenv("LOGPILE_REDFISH_CRITICAL_COOLDOWN", "0s")
|
||||
t.Setenv("LOGPILE_REDFISH_CRITICAL_SLOW_GAP", "0s")
|
||||
t.Setenv("LOGPILE_REDFISH_CRITICAL_PLANB_RETRIES", "1")
|
||||
t.Setenv("LOGPILE_REDFISH_CRITICAL_RETRIES", "1")
|
||||
t.Setenv("LOGPILE_REDFISH_CRITICAL_BACKOFF", "0s")
|
||||
|
||||
const systemPath = "/redfish/v1/Systems/1"
|
||||
const memoryPath = "/redfish/v1/Systems/1/Memory"
|
||||
const dimmPath = "/redfish/v1/Systems/1/Memory/CPU1_C1D1"
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc(dimmPath, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"Id": "CPU1_C1D1",
|
||||
"Name": "CPU1_C1D1",
|
||||
"DeviceLocator": "CPU1_C1D1",
|
||||
"CapacityMiB": 65536,
|
||||
"MemoryDeviceType": "DDR5",
|
||||
"Status": map[string]interface{}{"State": "Enabled", "Health": "OK"},
|
||||
"SerialNumber": "DIMM-SN-001",
|
||||
"PartNumber": "DIMM-PN-001",
|
||||
})
|
||||
})
|
||||
ts := httptest.NewServer(mux)
|
||||
defer ts.Close()
|
||||
|
||||
rawTree := map[string]interface{}{
|
||||
memoryPath: map[string]interface{}{
|
||||
"Members": []interface{}{
|
||||
map[string]interface{}{"@odata.id": dimmPath},
|
||||
},
|
||||
},
|
||||
}
|
||||
fetchErrs := map[string]string{
|
||||
dimmPath: `Get "https://example/redfish/v1/Systems/1/Memory/CPU1_C1D1": context deadline exceeded (Client.Timeout exceeded while awaiting headers)`,
|
||||
}
|
||||
|
||||
criticalPaths := redfishCriticalEndpoints([]string{systemPath}, nil, nil)
|
||||
hasMemoryPath := false
|
||||
for _, p := range criticalPaths {
|
||||
if p == memoryPath {
|
||||
hasMemoryPath = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasMemoryPath {
|
||||
t.Fatalf("expected critical endpoints to include %s", memoryPath)
|
||||
}
|
||||
|
||||
c := NewRedfishConnector()
|
||||
recovered := c.recoverCriticalRedfishDocsPlanB(
|
||||
context.Background(),
|
||||
ts.Client(),
|
||||
Request{},
|
||||
ts.URL,
|
||||
criticalPaths,
|
||||
rawTree,
|
||||
fetchErrs,
|
||||
nil,
|
||||
)
|
||||
if recovered == 0 {
|
||||
t.Fatalf("expected plan-B to recover at least one DIMM document")
|
||||
}
|
||||
if _, ok := rawTree[dimmPath]; !ok {
|
||||
t.Fatalf("expected recovered DIMM doc for %s", dimmPath)
|
||||
}
|
||||
if _, ok := fetchErrs[dimmPath]; ok {
|
||||
t.Fatalf("expected DIMM fetch error for %s to be cleared", dimmPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplayCollectStorage_ProbesSupermicroNVMeDiskBayWhenCollectionEmpty(t *testing.T) {
|
||||
r := redfishSnapshotReader{tree: map[string]interface{}{
|
||||
"/redfish/v1/Systems": map[string]interface{}{
|
||||
@@ -828,6 +1106,70 @@ func TestReplayRedfishFromRawPayloads_AddsMissingServerModelWarning(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplayRedfishFromRawPayloads_AddsDriveFetchWarning(t *testing.T) {
|
||||
raw := map[string]any{
|
||||
"redfish_tree": map[string]interface{}{
|
||||
"/redfish/v1": map[string]interface{}{
|
||||
"Systems": map[string]interface{}{"@odata.id": "/redfish/v1/Systems"},
|
||||
"Chassis": map[string]interface{}{"@odata.id": "/redfish/v1/Chassis"},
|
||||
"Managers": map[string]interface{}{"@odata.id": "/redfish/v1/Managers"},
|
||||
},
|
||||
"/redfish/v1/Systems": map[string]interface{}{
|
||||
"Members": []interface{}{
|
||||
map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1"},
|
||||
},
|
||||
},
|
||||
"/redfish/v1/Systems/1": map[string]interface{}{
|
||||
"Manufacturer": "Inspur",
|
||||
"Model": "NF5688M7",
|
||||
"SerialNumber": "23E100051",
|
||||
},
|
||||
"/redfish/v1/Chassis": map[string]interface{}{
|
||||
"Members": []interface{}{
|
||||
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1"},
|
||||
},
|
||||
},
|
||||
"/redfish/v1/Chassis/1": map[string]interface{}{
|
||||
"Id": "1",
|
||||
"Manufacturer": "Inspur",
|
||||
"Model": "NF5688M7",
|
||||
},
|
||||
"/redfish/v1/Managers": map[string]interface{}{
|
||||
"Members": []interface{}{
|
||||
map[string]interface{}{"@odata.id": "/redfish/v1/Managers/1"},
|
||||
},
|
||||
},
|
||||
"/redfish/v1/Managers/1": map[string]interface{}{
|
||||
"Id": "1",
|
||||
},
|
||||
},
|
||||
"redfish_fetch_errors": []map[string]interface{}{
|
||||
{
|
||||
"path": "/redfish/v1/Chassis/1/Drives/FP00HDD00",
|
||||
"error": `Get "...": context deadline exceeded (Client.Timeout exceeded while awaiting headers)`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got, err := ReplayRedfishFromRawPayloads(raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("replay failed: %v", err)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, ev := range got.Events {
|
||||
if ev.Source == "Redfish" &&
|
||||
ev.EventType == "Collection Warning" &&
|
||||
strings.Contains(strings.ToLower(ev.Description), "drive documents were unavailable") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected collection warning event for drive fetch errors")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplayCollectGPUs_SkipsModelOnlyDuplicateFromGraphicsControllers(t *testing.T) {
|
||||
r := redfishSnapshotReader{tree: map[string]interface{}{
|
||||
"/redfish/v1/Systems/1/PCIeDevices": map[string]interface{}{
|
||||
@@ -1008,6 +1350,48 @@ func TestShouldCrawlPath_MemorySubresourcesAreSkipped(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRedfishMemoryMemberPath(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{path: "/redfish/v1/Systems/1/Memory", want: false},
|
||||
{path: "/redfish/v1/Systems/1/Memory/CPU0_C0D0", want: true},
|
||||
{path: "/redfish/v1/Systems/1/Memory/CPU0_C0D0/Assembly", want: false},
|
||||
{path: "/redfish/v1/Systems/1/Memory/CPU0_C0D0/MemoryMetrics", want: false},
|
||||
{path: "/redfish/v1/Chassis/1/Memory/CPU0_C0D0", want: false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := isRedfishMemoryMemberPath(tc.path)
|
||||
if got != tc.want {
|
||||
t.Fatalf("isRedfishMemoryMemberPath(%q) = %v, want %v", tc.path, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedfishSnapshotBranchKey(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{path: "", want: ""},
|
||||
{path: "/redfish/v1", want: ""},
|
||||
{path: "/redfish/v1/Systems", want: "/redfish/v1/Systems"},
|
||||
{path: "/redfish/v1/Systems/1", want: "/redfish/v1/Systems/1"},
|
||||
{path: "/redfish/v1/Systems/1/Memory", want: "/redfish/v1/Systems/1/Memory"},
|
||||
{path: "/redfish/v1/Systems/1/Memory/CPU0_C0D0", want: "/redfish/v1/Systems/1/Memory"},
|
||||
{path: "/redfish/v1/Systems/1/PCIeDevices/GPU1", want: "/redfish/v1/Systems/1/PCIeDevices"},
|
||||
{path: "/redfish/v1/Chassis/1/Sensors/1", want: "/redfish/v1/Chassis/1/Sensors"},
|
||||
{path: "/redfish/v1/UpdateService/FirmwareInventory/BIOS", want: "/redfish/v1/UpdateService/FirmwareInventory"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := redfishSnapshotBranchKey(tc.path)
|
||||
if got != tc.want {
|
||||
t.Fatalf("redfishSnapshotBranchKey(%q) = %q, want %q", tc.path, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldPostProbeCollectionPath(t *testing.T) {
|
||||
if !shouldPostProbeCollectionPath("/redfish/v1/Chassis/1/Sensors") {
|
||||
t.Fatalf("expected sensors collection to be post-probed")
|
||||
|
||||
Reference in New Issue
Block a user