602 lines
18 KiB
Go
602 lines
18 KiB
Go
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 ""
|
|
}
|