feat: add reanimator easy bee parser
This commit is contained in:
@@ -27,6 +27,7 @@ All modes converge on the same normalized hardware model and exporter pipeline.
|
|||||||
## Current vendor coverage
|
## Current vendor coverage
|
||||||
|
|
||||||
- Dell TSR
|
- Dell TSR
|
||||||
|
- Reanimator Easy Bee support bundles
|
||||||
- H3C SDS G5/G6
|
- H3C SDS G5/G6
|
||||||
- Inspur / Kaytus
|
- Inspur / Kaytus
|
||||||
- NVIDIA HGX Field Diagnostics
|
- NVIDIA HGX Field Diagnostics
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ When `vendor_id` and `device_id` are known but the model name is missing or gene
|
|||||||
| Vendor ID | Input family | Notes |
|
| Vendor ID | Input family | Notes |
|
||||||
|-----------|--------------|-------|
|
|-----------|--------------|-------|
|
||||||
| `dell` | TSR ZIP archives | Broad hardware, firmware, sensors, lifecycle events |
|
| `dell` | TSR ZIP archives | Broad hardware, firmware, sensors, lifecycle events |
|
||||||
|
| `easy_bee` | `bee-support-*.tar.gz` | Imports embedded `export/bee-audit.json` snapshot from reanimator-easy-bee bundles |
|
||||||
| `h3c_g5` | H3C SDS G5 bundles | INI/XML/CSV-driven hardware and event parsing |
|
| `h3c_g5` | H3C SDS G5 bundles | INI/XML/CSV-driven hardware and event parsing |
|
||||||
| `h3c_g6` | H3C SDS G6 bundles | Similar flow with G6-specific files |
|
| `h3c_g6` | H3C SDS G6 bundles | Similar flow with G6-specific files |
|
||||||
| `inspur` | onekeylog archives | FRU/SDR plus optional Redis enrichment |
|
| `inspur` | onekeylog archives | FRU/SDR plus optional Redis enrichment |
|
||||||
@@ -139,6 +140,7 @@ with content markers (e.g. `Unraid kernel build`, parity data markers).
|
|||||||
| Vendor | ID | Status | Tested on |
|
| Vendor | ID | Status | Tested on |
|
||||||
|--------|----|--------|-----------|
|
|--------|----|--------|-----------|
|
||||||
| Dell TSR | `dell` | Ready | TSR nested zip archives |
|
| Dell TSR | `dell` | Ready | TSR nested zip archives |
|
||||||
|
| Reanimator Easy Bee | `easy_bee` | Ready | `bee-support-*.tar.gz` support bundles |
|
||||||
| Inspur / Kaytus | `inspur` | Ready | KR4268X2 onekeylog |
|
| Inspur / Kaytus | `inspur` | Ready | KR4268X2 onekeylog |
|
||||||
| NVIDIA HGX Field Diag | `nvidia` | Ready | Various HGX servers |
|
| NVIDIA HGX Field Diag | `nvidia` | Ready | Various HGX servers |
|
||||||
| NVIDIA Bug Report | `nvidia_bug_report` | Ready | H100 systems |
|
| NVIDIA Bug Report | `nvidia_bug_report` | Ready | H100 systems |
|
||||||
|
|||||||
@@ -949,3 +949,30 @@ strings forced such systems into fallback mode even when the platform shape was
|
|||||||
grammar rather than by explicit vendor strings.
|
grammar rather than by explicit vendor strings.
|
||||||
- Live collection gains a small amount of extra discovery I/O to harvest stable member IDs, but
|
- Live collection gains a small amount of extra discovery I/O to harvest stable member IDs, but
|
||||||
avoids slow deep probes such as `Assembly` just for profile selection.
|
avoids slow deep probes such as `Assembly` just for profile selection.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADL-037 — easy-bee archives are parsed from the embedded bee-audit snapshot
|
||||||
|
|
||||||
|
**Date:** 2026-03-25
|
||||||
|
**Context:**
|
||||||
|
`reanimator-easy-bee` support bundles already contain a normalized hardware snapshot in
|
||||||
|
`export/bee-audit.json` plus supporting logs and techdump files. Rebuilding the same inventory
|
||||||
|
from raw `techdump/` files inside LOGPile would duplicate parser logic and create drift between
|
||||||
|
the producer utility and archive importer.
|
||||||
|
|
||||||
|
**Decision:**
|
||||||
|
- Add a dedicated `easy_bee` vendor parser for `bee-support-*.tar.gz` bundles.
|
||||||
|
- Detect the bundle by `manifest.txt` (`bee_version=...`) plus `export/bee-audit.json`.
|
||||||
|
- Parse the archive from the embedded snapshot first; treat `techdump/` and runtime files as
|
||||||
|
secondary context only.
|
||||||
|
- Normalize snapshot-only fields needed by LOGPile, notably:
|
||||||
|
- flatten `hardware.sensors` groups into `[]SensorReading`
|
||||||
|
- turn runtime issues/status into `[]Event`
|
||||||
|
- synthesize a board FRU entry when the snapshot does not include FRU data
|
||||||
|
|
||||||
|
**Consequences:**
|
||||||
|
- LOGPile stays aligned with the schema emitted by `reanimator-easy-bee`.
|
||||||
|
- Adding support required only a thin archive adapter instead of a full hardware parser.
|
||||||
|
- If the upstream utility changes the embedded snapshot schema, the `easy_bee` adapter is the
|
||||||
|
only place that must be updated.
|
||||||
|
|||||||
601
internal/parser/vendors/easy_bee/parser.go
vendored
Normal file
601
internal/parser/vendors/easy_bee/parser.go
vendored
Normal file
@@ -0,0 +1,601 @@
|
|||||||
|
package easy_bee
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/models"
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/parser"
|
||||||
|
)
|
||||||
|
|
||||||
|
const parserVersion = "1.0"
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
parser.Register(&Parser{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parser imports support bundles produced by reanimator-easy-bee.
|
||||||
|
// These archives embed a ready-to-use hardware snapshot in export/bee-audit.json.
|
||||||
|
type Parser struct{}
|
||||||
|
|
||||||
|
func (p *Parser) Name() string {
|
||||||
|
return "Reanimator Easy Bee Parser"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Parser) Vendor() string {
|
||||||
|
return "easy_bee"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Parser) Version() string {
|
||||||
|
return parserVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Parser) Detect(files []parser.ExtractedFile) int {
|
||||||
|
confidence := 0
|
||||||
|
hasManifest := false
|
||||||
|
hasBeeAudit := false
|
||||||
|
hasRuntimeHealth := false
|
||||||
|
hasTechdump := false
|
||||||
|
hasBundlePrefix := false
|
||||||
|
|
||||||
|
for _, f := range files {
|
||||||
|
path := strings.ToLower(strings.TrimSpace(f.Path))
|
||||||
|
content := strings.ToLower(string(f.Content))
|
||||||
|
|
||||||
|
if !hasBundlePrefix && strings.Contains(path, "bee-support-") {
|
||||||
|
hasBundlePrefix = true
|
||||||
|
confidence += 5
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strings.HasSuffix(path, "/manifest.txt") || path == "manifest.txt") &&
|
||||||
|
strings.Contains(content, "bee_version=") {
|
||||||
|
hasManifest = true
|
||||||
|
confidence += 35
|
||||||
|
if strings.Contains(content, "export_dir=") {
|
||||||
|
confidence += 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(path, "/export/bee-audit.json") || path == "bee-audit.json" {
|
||||||
|
hasBeeAudit = true
|
||||||
|
confidence += 55
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasBundlePrefix && (strings.HasSuffix(path, "/export/runtime-health.json") || path == "runtime-health.json") {
|
||||||
|
hasRuntimeHealth = true
|
||||||
|
confidence += 10
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasBundlePrefix && !hasTechdump && strings.Contains(path, "/export/techdump/") {
|
||||||
|
hasTechdump = true
|
||||||
|
confidence += 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasManifest && hasBeeAudit {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
if hasBeeAudit && (hasRuntimeHealth || hasTechdump) {
|
||||||
|
confidence += 10
|
||||||
|
}
|
||||||
|
if confidence > 100 {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
return confidence
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, error) {
|
||||||
|
snapshotFile := findSnapshotFile(files)
|
||||||
|
if snapshotFile == nil {
|
||||||
|
return nil, fmt.Errorf("easy-bee snapshot not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
var snapshot beeSnapshot
|
||||||
|
if err := json.Unmarshal(snapshotFile.Content, &snapshot); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode %s: %w", snapshotFile.Path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest := parseManifest(files)
|
||||||
|
|
||||||
|
result := &models.AnalysisResult{
|
||||||
|
SourceType: strings.TrimSpace(snapshot.SourceType),
|
||||||
|
Protocol: strings.TrimSpace(snapshot.Protocol),
|
||||||
|
TargetHost: firstNonEmpty(snapshot.TargetHost, manifest.Host),
|
||||||
|
SourceTimezone: strings.TrimSpace(snapshot.SourceTimezone),
|
||||||
|
CollectedAt: chooseCollectedAt(snapshot, manifest),
|
||||||
|
InventoryLastModifiedAt: snapshot.InventoryLastModifiedAt,
|
||||||
|
RawPayloads: snapshot.RawPayloads,
|
||||||
|
Events: make([]models.Event, 0),
|
||||||
|
FRU: append([]models.FRUInfo(nil), snapshot.FRU...),
|
||||||
|
Sensors: make([]models.SensorReading, 0),
|
||||||
|
Hardware: &models.HardwareConfig{
|
||||||
|
Firmware: append([]models.FirmwareInfo(nil), snapshot.Hardware.Firmware...),
|
||||||
|
BoardInfo: snapshot.Hardware.Board,
|
||||||
|
Devices: append([]models.HardwareDevice(nil), snapshot.Hardware.Devices...),
|
||||||
|
CPUs: append([]models.CPU(nil), snapshot.Hardware.CPUs...),
|
||||||
|
Memory: append([]models.MemoryDIMM(nil), snapshot.Hardware.Memory...),
|
||||||
|
Storage: append([]models.Storage(nil), snapshot.Hardware.Storage...),
|
||||||
|
Volumes: append([]models.StorageVolume(nil), snapshot.Hardware.Volumes...),
|
||||||
|
PCIeDevices: normalizePCIeDevices(snapshot.Hardware.PCIeDevices),
|
||||||
|
GPUs: append([]models.GPU(nil), snapshot.Hardware.GPUs...),
|
||||||
|
NetworkCards: append([]models.NIC(nil), snapshot.Hardware.NetworkCards...),
|
||||||
|
NetworkAdapters: normalizeNetworkAdapters(snapshot.Hardware.NetworkAdapters),
|
||||||
|
PowerSupply: append([]models.PSU(nil), snapshot.Hardware.PowerSupply...),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Events = append(result.Events, snapshot.Events...)
|
||||||
|
result.Events = append(result.Events, convertRuntimeToEvents(snapshot.Runtime, result.CollectedAt)...)
|
||||||
|
result.Events = append(result.Events, convertEventLogs(snapshot.Hardware.EventLogs)...)
|
||||||
|
|
||||||
|
result.Sensors = append(result.Sensors, snapshot.Sensors...)
|
||||||
|
result.Sensors = append(result.Sensors, flattenSensorGroups(snapshot.Hardware.Sensors)...)
|
||||||
|
|
||||||
|
if len(result.FRU) == 0 {
|
||||||
|
if boardFRU, ok := buildBoardFRU(snapshot.Hardware.Board); ok {
|
||||||
|
result.FRU = append(result.FRU, boardFRU)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Hardware == nil || (result.Hardware.BoardInfo.SerialNumber == "" &&
|
||||||
|
len(result.Hardware.CPUs) == 0 &&
|
||||||
|
len(result.Hardware.Memory) == 0 &&
|
||||||
|
len(result.Hardware.Storage) == 0 &&
|
||||||
|
len(result.Hardware.PCIeDevices) == 0 &&
|
||||||
|
len(result.Hardware.Devices) == 0) {
|
||||||
|
return nil, fmt.Errorf("unsupported easy-bee snapshot format")
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type beeSnapshot struct {
|
||||||
|
SourceType string `json:"source_type,omitempty"`
|
||||||
|
Protocol string `json:"protocol,omitempty"`
|
||||||
|
TargetHost string `json:"target_host,omitempty"`
|
||||||
|
SourceTimezone string `json:"source_timezone,omitempty"`
|
||||||
|
CollectedAt time.Time `json:"collected_at,omitempty"`
|
||||||
|
InventoryLastModifiedAt time.Time `json:"inventory_last_modified_at,omitempty"`
|
||||||
|
RawPayloads map[string]any `json:"raw_payloads,omitempty"`
|
||||||
|
Events []models.Event `json:"events,omitempty"`
|
||||||
|
FRU []models.FRUInfo `json:"fru,omitempty"`
|
||||||
|
Sensors []models.SensorReading `json:"sensors,omitempty"`
|
||||||
|
Hardware beeHardware `json:"hardware"`
|
||||||
|
Runtime beeRuntime `json:"runtime,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type beeHardware struct {
|
||||||
|
Board models.BoardInfo `json:"board"`
|
||||||
|
Firmware []models.FirmwareInfo `json:"firmware,omitempty"`
|
||||||
|
Devices []models.HardwareDevice `json:"devices,omitempty"`
|
||||||
|
CPUs []models.CPU `json:"cpus,omitempty"`
|
||||||
|
Memory []models.MemoryDIMM `json:"memory,omitempty"`
|
||||||
|
Storage []models.Storage `json:"storage,omitempty"`
|
||||||
|
Volumes []models.StorageVolume `json:"volumes,omitempty"`
|
||||||
|
PCIeDevices []models.PCIeDevice `json:"pcie_devices,omitempty"`
|
||||||
|
GPUs []models.GPU `json:"gpus,omitempty"`
|
||||||
|
NetworkCards []models.NIC `json:"network_cards,omitempty"`
|
||||||
|
NetworkAdapters []models.NetworkAdapter `json:"network_adapters,omitempty"`
|
||||||
|
PowerSupply []models.PSU `json:"power_supplies,omitempty"`
|
||||||
|
Sensors beeSensorGroups `json:"sensors,omitempty"`
|
||||||
|
EventLogs []beeEventLog `json:"event_logs,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type beeSensorGroups struct {
|
||||||
|
Fans []beeFanSensor `json:"fans,omitempty"`
|
||||||
|
Power []beePowerSensor `json:"power,omitempty"`
|
||||||
|
Temperatures []beeTemperatureSensor `json:"temperatures,omitempty"`
|
||||||
|
Other []beeOtherSensor `json:"other,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type beeFanSensor struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Location string `json:"location,omitempty"`
|
||||||
|
RPM int `json:"rpm,omitempty"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type beePowerSensor struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Location string `json:"location,omitempty"`
|
||||||
|
VoltageV float64 `json:"voltage_v,omitempty"`
|
||||||
|
CurrentA float64 `json:"current_a,omitempty"`
|
||||||
|
PowerW float64 `json:"power_w,omitempty"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type beeTemperatureSensor struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Location string `json:"location,omitempty"`
|
||||||
|
Celsius float64 `json:"celsius,omitempty"`
|
||||||
|
ThresholdWarningCelsius float64 `json:"threshold_warning_celsius,omitempty"`
|
||||||
|
ThresholdCriticalCelsius float64 `json:"threshold_critical_celsius,omitempty"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type beeOtherSensor struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Location string `json:"location,omitempty"`
|
||||||
|
Value float64 `json:"value,omitempty"`
|
||||||
|
Unit string `json:"unit,omitempty"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type beeRuntime struct {
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
CheckedAt time.Time `json:"checked_at,omitempty"`
|
||||||
|
NetworkStatus string `json:"network_status,omitempty"`
|
||||||
|
Issues []beeRuntimeIssue `json:"issues,omitempty"`
|
||||||
|
Services []beeRuntimeStatus `json:"services,omitempty"`
|
||||||
|
Interfaces []beeInterface `json:"interfaces,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type beeRuntimeIssue struct {
|
||||||
|
Code string `json:"code,omitempty"`
|
||||||
|
Severity string `json:"severity,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type beeRuntimeStatus struct {
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type beeInterface struct {
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
State string `json:"state,omitempty"`
|
||||||
|
IPv4 []string `json:"ipv4,omitempty"`
|
||||||
|
Outcome string `json:"outcome,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type beeEventLog struct {
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
|
EventTime string `json:"event_time,omitempty"`
|
||||||
|
Severity string `json:"severity,omitempty"`
|
||||||
|
MessageID string `json:"message_id,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
RawPayload map[string]any `json:"raw_payload,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type manifestMetadata struct {
|
||||||
|
Host string
|
||||||
|
GeneratedAtUTC time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func findSnapshotFile(files []parser.ExtractedFile) *parser.ExtractedFile {
|
||||||
|
for i := range files {
|
||||||
|
path := strings.ToLower(strings.TrimSpace(files[i].Path))
|
||||||
|
if strings.HasSuffix(path, "/export/bee-audit.json") || path == "bee-audit.json" {
|
||||||
|
return &files[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range files {
|
||||||
|
path := strings.ToLower(strings.TrimSpace(files[i].Path))
|
||||||
|
if strings.HasSuffix(path, ".json") && strings.Contains(path, "reanimator") {
|
||||||
|
return &files[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseManifest(files []parser.ExtractedFile) manifestMetadata {
|
||||||
|
var meta manifestMetadata
|
||||||
|
|
||||||
|
for _, f := range files {
|
||||||
|
path := strings.ToLower(strings.TrimSpace(f.Path))
|
||||||
|
if !(strings.HasSuffix(path, "/manifest.txt") || path == "manifest.txt") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(string(f.Content), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
key, value, ok := strings.Cut(strings.TrimSpace(line), "=")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch strings.TrimSpace(key) {
|
||||||
|
case "host":
|
||||||
|
meta.Host = strings.TrimSpace(value)
|
||||||
|
case "generated_at_utc":
|
||||||
|
if ts, err := time.Parse(time.RFC3339, strings.TrimSpace(value)); err == nil {
|
||||||
|
meta.GeneratedAtUTC = ts.UTC()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
|
||||||
|
func chooseCollectedAt(snapshot beeSnapshot, manifest manifestMetadata) time.Time {
|
||||||
|
switch {
|
||||||
|
case !snapshot.CollectedAt.IsZero():
|
||||||
|
return snapshot.CollectedAt.UTC()
|
||||||
|
case !snapshot.Runtime.CheckedAt.IsZero():
|
||||||
|
return snapshot.Runtime.CheckedAt.UTC()
|
||||||
|
case !manifest.GeneratedAtUTC.IsZero():
|
||||||
|
return manifest.GeneratedAtUTC.UTC()
|
||||||
|
default:
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertRuntimeToEvents(runtime beeRuntime, fallback time.Time) []models.Event {
|
||||||
|
events := make([]models.Event, 0)
|
||||||
|
ts := runtime.CheckedAt
|
||||||
|
if ts.IsZero() {
|
||||||
|
ts = fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
if status := strings.TrimSpace(runtime.Status); status != "" {
|
||||||
|
desc := "Bee runtime status: " + status
|
||||||
|
if networkStatus := strings.TrimSpace(runtime.NetworkStatus); networkStatus != "" {
|
||||||
|
desc += " (network: " + networkStatus + ")"
|
||||||
|
}
|
||||||
|
events = append(events, models.Event{
|
||||||
|
Timestamp: ts,
|
||||||
|
Source: "Bee Runtime",
|
||||||
|
EventType: "Runtime Status",
|
||||||
|
Severity: mapSeverity(status),
|
||||||
|
Description: desc,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, issue := range runtime.Issues {
|
||||||
|
desc := strings.TrimSpace(issue.Description)
|
||||||
|
if desc == "" {
|
||||||
|
desc = "Bee runtime issue"
|
||||||
|
}
|
||||||
|
events = append(events, models.Event{
|
||||||
|
Timestamp: ts,
|
||||||
|
Source: "Bee Runtime",
|
||||||
|
EventType: "Runtime Issue",
|
||||||
|
Severity: mapSeverity(issue.Severity),
|
||||||
|
Description: desc,
|
||||||
|
RawData: strings.TrimSpace(issue.Code),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, svc := range runtime.Services {
|
||||||
|
status := strings.TrimSpace(svc.Status)
|
||||||
|
if status == "" || strings.EqualFold(status, "active") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
events = append(events, models.Event{
|
||||||
|
Timestamp: ts,
|
||||||
|
Source: "systemd",
|
||||||
|
EventType: "Service Status",
|
||||||
|
Severity: mapSeverity(status),
|
||||||
|
Description: fmt.Sprintf("%s is %s", strings.TrimSpace(svc.Name), status),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, iface := range runtime.Interfaces {
|
||||||
|
state := strings.TrimSpace(iface.State)
|
||||||
|
outcome := strings.TrimSpace(iface.Outcome)
|
||||||
|
if state == "" && outcome == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.EqualFold(state, "up") && strings.EqualFold(outcome, "lease_acquired") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
desc := fmt.Sprintf("interface %s state=%s outcome=%s", strings.TrimSpace(iface.Name), state, outcome)
|
||||||
|
events = append(events, models.Event{
|
||||||
|
Timestamp: ts,
|
||||||
|
Source: "network",
|
||||||
|
EventType: "Interface Status",
|
||||||
|
Severity: models.SeverityWarning,
|
||||||
|
Description: strings.TrimSpace(desc),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertEventLogs(items []beeEventLog) []models.Event {
|
||||||
|
events := make([]models.Event, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
message := strings.TrimSpace(item.Message)
|
||||||
|
if message == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ts := parseEventTime(item.EventTime)
|
||||||
|
rawData := strings.TrimSpace(item.MessageID)
|
||||||
|
events = append(events, models.Event{
|
||||||
|
Timestamp: ts,
|
||||||
|
Source: firstNonEmpty(strings.TrimSpace(item.Source), "Reanimator"),
|
||||||
|
EventType: "Event Log",
|
||||||
|
Severity: mapSeverity(item.Severity),
|
||||||
|
Description: message,
|
||||||
|
RawData: rawData,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseEventTime(raw string) time.Time {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
layouts := []string{time.RFC3339Nano, time.RFC3339}
|
||||||
|
for _, layout := range layouts {
|
||||||
|
if ts, err := time.Parse(layout, raw); err == nil {
|
||||||
|
return ts.UTC()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func flattenSensorGroups(groups beeSensorGroups) []models.SensorReading {
|
||||||
|
result := make([]models.SensorReading, 0, len(groups.Fans)+len(groups.Power)+len(groups.Temperatures)+len(groups.Other))
|
||||||
|
|
||||||
|
for _, fan := range groups.Fans {
|
||||||
|
result = append(result, models.SensorReading{
|
||||||
|
Name: sensorName(fan.Name, fan.Location),
|
||||||
|
Type: "fan",
|
||||||
|
Value: float64(fan.RPM),
|
||||||
|
Unit: "RPM",
|
||||||
|
Status: strings.TrimSpace(fan.Status),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, power := range groups.Power {
|
||||||
|
name := sensorName(power.Name, power.Location)
|
||||||
|
status := strings.TrimSpace(power.Status)
|
||||||
|
if power.PowerW != 0 {
|
||||||
|
result = append(result, models.SensorReading{
|
||||||
|
Name: name,
|
||||||
|
Type: "power",
|
||||||
|
Value: power.PowerW,
|
||||||
|
Unit: "W",
|
||||||
|
Status: status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if power.VoltageV != 0 {
|
||||||
|
result = append(result, models.SensorReading{
|
||||||
|
Name: name + " Voltage",
|
||||||
|
Type: "voltage",
|
||||||
|
Value: power.VoltageV,
|
||||||
|
Unit: "V",
|
||||||
|
Status: status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if power.CurrentA != 0 {
|
||||||
|
result = append(result, models.SensorReading{
|
||||||
|
Name: name + " Current",
|
||||||
|
Type: "current",
|
||||||
|
Value: power.CurrentA,
|
||||||
|
Unit: "A",
|
||||||
|
Status: status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, temp := range groups.Temperatures {
|
||||||
|
result = append(result, models.SensorReading{
|
||||||
|
Name: sensorName(temp.Name, temp.Location),
|
||||||
|
Type: "temperature",
|
||||||
|
Value: temp.Celsius,
|
||||||
|
Unit: "C",
|
||||||
|
Status: strings.TrimSpace(temp.Status),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, other := range groups.Other {
|
||||||
|
result = append(result, models.SensorReading{
|
||||||
|
Name: sensorName(other.Name, other.Location),
|
||||||
|
Type: "other",
|
||||||
|
Value: other.Value,
|
||||||
|
Unit: strings.TrimSpace(other.Unit),
|
||||||
|
Status: strings.TrimSpace(other.Status),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func sensorName(name, location string) string {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
location = strings.TrimSpace(location)
|
||||||
|
if name == "" {
|
||||||
|
return location
|
||||||
|
}
|
||||||
|
if location == "" {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return name + " [" + location + "]"
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePCIeDevices(items []models.PCIeDevice) []models.PCIeDevice {
|
||||||
|
out := append([]models.PCIeDevice(nil), items...)
|
||||||
|
for i := range out {
|
||||||
|
slot := strings.TrimSpace(out[i].Slot)
|
||||||
|
if out[i].BDF == "" && looksLikeBDF(slot) {
|
||||||
|
out[i].BDF = slot
|
||||||
|
}
|
||||||
|
if out[i].Slot == "" && out[i].BDF != "" {
|
||||||
|
out[i].Slot = out[i].BDF
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeNetworkAdapters(items []models.NetworkAdapter) []models.NetworkAdapter {
|
||||||
|
out := append([]models.NetworkAdapter(nil), items...)
|
||||||
|
for i := range out {
|
||||||
|
slot := strings.TrimSpace(out[i].Slot)
|
||||||
|
if out[i].BDF == "" && looksLikeBDF(slot) {
|
||||||
|
out[i].BDF = slot
|
||||||
|
}
|
||||||
|
if out[i].Slot == "" && out[i].BDF != "" {
|
||||||
|
out[i].Slot = out[i].BDF
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func looksLikeBDF(value string) bool {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if len(value) != len("0000:00:00.0") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i, r := range value {
|
||||||
|
switch i {
|
||||||
|
case 4, 7:
|
||||||
|
if r != ':' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
case 10:
|
||||||
|
if r != '.' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildBoardFRU(board models.BoardInfo) (models.FRUInfo, bool) {
|
||||||
|
if strings.TrimSpace(board.SerialNumber) == "" &&
|
||||||
|
strings.TrimSpace(board.Manufacturer) == "" &&
|
||||||
|
strings.TrimSpace(board.ProductName) == "" &&
|
||||||
|
strings.TrimSpace(board.PartNumber) == "" {
|
||||||
|
return models.FRUInfo{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return models.FRUInfo{
|
||||||
|
Description: "System Board",
|
||||||
|
Manufacturer: strings.TrimSpace(board.Manufacturer),
|
||||||
|
ProductName: strings.TrimSpace(board.ProductName),
|
||||||
|
SerialNumber: strings.TrimSpace(board.SerialNumber),
|
||||||
|
PartNumber: strings.TrimSpace(board.PartNumber),
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapSeverity(raw string) models.Severity {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||||
|
case "critical", "crit", "error", "failed", "failure":
|
||||||
|
return models.SeverityCritical
|
||||||
|
case "warning", "warn", "partial", "degraded", "inactive", "activating", "deactivating":
|
||||||
|
return models.SeverityWarning
|
||||||
|
default:
|
||||||
|
return models.SeverityInfo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
219
internal/parser/vendors/easy_bee/parser_test.go
vendored
Normal file
219
internal/parser/vendors/easy_bee/parser_test.go
vendored
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
package easy_bee
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/parser"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDetectBeeSupportArchive(t *testing.T) {
|
||||||
|
p := &Parser{}
|
||||||
|
files := []parser.ExtractedFile{
|
||||||
|
{
|
||||||
|
Path: "bee-support-debian-20260325-162030/manifest.txt",
|
||||||
|
Content: []byte("bee_version=1.0.0\nhost=debian\ngenerated_at_utc=2026-03-25T16:20:30Z\nexport_dir=/appdata/bee/export\n"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "bee-support-debian-20260325-162030/export/bee-audit.json",
|
||||||
|
Content: []byte(`{"hardware":{"board":{"serial_number":"SN-BEE-001"}}}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "bee-support-debian-20260325-162030/export/runtime-health.json",
|
||||||
|
Content: []byte(`{"status":"PARTIAL"}`),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := p.Detect(files); got < 90 {
|
||||||
|
t.Fatalf("expected high confidence detect score, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectRejectsNonBeeArchive(t *testing.T) {
|
||||||
|
p := &Parser{}
|
||||||
|
files := []parser.ExtractedFile{
|
||||||
|
{
|
||||||
|
Path: "random/manifest.txt",
|
||||||
|
Content: []byte("host=test\n"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "random/export/runtime-health.json",
|
||||||
|
Content: []byte(`{"status":"OK"}`),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := p.Detect(files); got != 0 {
|
||||||
|
t.Fatalf("expected detect score 0, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseBeeAuditSnapshot(t *testing.T) {
|
||||||
|
p := &Parser{}
|
||||||
|
files := []parser.ExtractedFile{
|
||||||
|
{
|
||||||
|
Path: "bee-support-debian-20260325-162030/manifest.txt",
|
||||||
|
Content: []byte("bee_version=1.0.0\nhost=debian\ngenerated_at_utc=2026-03-25T16:20:30Z\nexport_dir=/appdata/bee/export\n"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "bee-support-debian-20260325-162030/export/bee-audit.json",
|
||||||
|
Content: []byte(`{
|
||||||
|
"source_type": "manual",
|
||||||
|
"target_host": "debian",
|
||||||
|
"collected_at": "2026-03-25T16:08:09Z",
|
||||||
|
"runtime": {
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"checked_at": "2026-03-25T16:07:56Z",
|
||||||
|
"network_status": "OK",
|
||||||
|
"issues": [
|
||||||
|
{
|
||||||
|
"code": "nvidia_kernel_module_missing",
|
||||||
|
"severity": "warning",
|
||||||
|
"description": "NVIDIA kernel module is not loaded."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"name": "bee-web",
|
||||||
|
"status": "inactive"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hardware": {
|
||||||
|
"board": {
|
||||||
|
"manufacturer": "Supermicro",
|
||||||
|
"product_name": "AS-4124GQ-TNMI",
|
||||||
|
"serial_number": "S490387X4418273",
|
||||||
|
"part_number": "H12DGQ-NT6",
|
||||||
|
"uuid": "d868ae00-a61f-11ee-8000-7cc255e10309"
|
||||||
|
},
|
||||||
|
"firmware": [
|
||||||
|
{
|
||||||
|
"device_name": "BIOS",
|
||||||
|
"version": "2.8"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cpus": [
|
||||||
|
{
|
||||||
|
"status": "OK",
|
||||||
|
"status_checked_at": "2026-03-25T16:08:09Z",
|
||||||
|
"socket": 1,
|
||||||
|
"model": "AMD EPYC 7763 64-Core Processor",
|
||||||
|
"cores": 64,
|
||||||
|
"threads": 128,
|
||||||
|
"frequency_mhz": 2450,
|
||||||
|
"max_frequency_mhz": 3525
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"memory": [
|
||||||
|
{
|
||||||
|
"status": "OK",
|
||||||
|
"status_checked_at": "2026-03-25T16:08:09Z",
|
||||||
|
"slot": "P1-DIMMA1",
|
||||||
|
"location": "P0_Node0_Channel0_Dimm0",
|
||||||
|
"present": true,
|
||||||
|
"size_mb": 32768,
|
||||||
|
"type": "DDR4",
|
||||||
|
"max_speed_mhz": 3200,
|
||||||
|
"current_speed_mhz": 2933,
|
||||||
|
"manufacturer": "SK Hynix",
|
||||||
|
"serial_number": "80AD01224887286666",
|
||||||
|
"part_number": "HMA84GR7DJR4N-XN"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"storage": [
|
||||||
|
{
|
||||||
|
"status": "Unknown",
|
||||||
|
"status_checked_at": "2026-03-25T16:08:09Z",
|
||||||
|
"slot": "nvme0n1",
|
||||||
|
"type": "NVMe",
|
||||||
|
"model": "KCD6XLUL960G",
|
||||||
|
"serial_number": "2470A00XT5M8",
|
||||||
|
"interface": "NVMe",
|
||||||
|
"present": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pcie_devices": [
|
||||||
|
{
|
||||||
|
"status": "OK",
|
||||||
|
"status_checked_at": "2026-03-25T16:08:09Z",
|
||||||
|
"slot": "0000:05:00.0",
|
||||||
|
"vendor_id": 5555,
|
||||||
|
"device_id": 4123,
|
||||||
|
"device_class": "EthernetController",
|
||||||
|
"manufacturer": "Mellanox Technologies",
|
||||||
|
"model": "MT28908 Family [ConnectX-6]",
|
||||||
|
"link_width": 16,
|
||||||
|
"link_speed": "Gen4",
|
||||||
|
"max_link_width": 16,
|
||||||
|
"max_link_speed": "Gen4",
|
||||||
|
"mac_addresses": ["94:6d:ae:9a:75:4a"],
|
||||||
|
"present": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sensors": {
|
||||||
|
"power": [
|
||||||
|
{
|
||||||
|
"name": "PPT",
|
||||||
|
"location": "amdgpu-pci-1100",
|
||||||
|
"power_w": 95
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"temperatures": [
|
||||||
|
{
|
||||||
|
"name": "Composite",
|
||||||
|
"location": "nvme-pci-0600",
|
||||||
|
"celsius": 28.85,
|
||||||
|
"threshold_warning_celsius": 72.85,
|
||||||
|
"threshold_critical_celsius": 81.85,
|
||||||
|
"status": "OK"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := p.Parse(files)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Hardware == nil {
|
||||||
|
t.Fatal("expected hardware to be populated")
|
||||||
|
}
|
||||||
|
if result.TargetHost != "debian" {
|
||||||
|
t.Fatalf("expected target host debian, got %q", result.TargetHost)
|
||||||
|
}
|
||||||
|
wantCollectedAt := time.Date(2026, 3, 25, 16, 8, 9, 0, time.UTC)
|
||||||
|
if !result.CollectedAt.Equal(wantCollectedAt) {
|
||||||
|
t.Fatalf("expected collected_at %s, got %s", wantCollectedAt, result.CollectedAt)
|
||||||
|
}
|
||||||
|
if result.Hardware.BoardInfo.SerialNumber != "S490387X4418273" {
|
||||||
|
t.Fatalf("unexpected board serial %q", result.Hardware.BoardInfo.SerialNumber)
|
||||||
|
}
|
||||||
|
if len(result.Hardware.CPUs) != 1 {
|
||||||
|
t.Fatalf("expected 1 cpu, got %d", len(result.Hardware.CPUs))
|
||||||
|
}
|
||||||
|
if len(result.Hardware.Memory) != 1 {
|
||||||
|
t.Fatalf("expected 1 dimm, got %d", len(result.Hardware.Memory))
|
||||||
|
}
|
||||||
|
if len(result.Hardware.Storage) != 1 {
|
||||||
|
t.Fatalf("expected 1 storage device, got %d", len(result.Hardware.Storage))
|
||||||
|
}
|
||||||
|
if len(result.Hardware.PCIeDevices) != 1 {
|
||||||
|
t.Fatalf("expected 1 pcie device, got %d", len(result.Hardware.PCIeDevices))
|
||||||
|
}
|
||||||
|
if result.Hardware.PCIeDevices[0].BDF != "0000:05:00.0" {
|
||||||
|
t.Fatalf("expected BDF to be normalized from slot, got %q", result.Hardware.PCIeDevices[0].BDF)
|
||||||
|
}
|
||||||
|
if len(result.Sensors) != 2 {
|
||||||
|
t.Fatalf("expected 2 flattened sensors, got %d", len(result.Sensors))
|
||||||
|
}
|
||||||
|
if len(result.Events) < 3 {
|
||||||
|
t.Fatalf("expected runtime events to be created, got %d", len(result.Events))
|
||||||
|
}
|
||||||
|
if len(result.FRU) == 0 {
|
||||||
|
t.Fatal("expected board FRU fallback to be populated")
|
||||||
|
}
|
||||||
|
}
|
||||||
1
internal/parser/vendors/vendors.go
vendored
1
internal/parser/vendors/vendors.go
vendored
@@ -5,6 +5,7 @@ package vendors
|
|||||||
import (
|
import (
|
||||||
// Import vendor modules to trigger their init() registration
|
// Import vendor modules to trigger their init() registration
|
||||||
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/dell"
|
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/dell"
|
||||||
|
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/easy_bee"
|
||||||
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/h3c"
|
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/h3c"
|
||||||
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/inspur"
|
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/inspur"
|
||||||
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/nvidia"
|
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/nvidia"
|
||||||
|
|||||||
Reference in New Issue
Block a user