diff --git a/bible-local/01-overview.md b/bible-local/01-overview.md index 653288f..332e750 100644 --- a/bible-local/01-overview.md +++ b/bible-local/01-overview.md @@ -27,6 +27,7 @@ All modes converge on the same normalized hardware model and exporter pipeline. ## Current vendor coverage - Dell TSR +- Reanimator Easy Bee support bundles - H3C SDS G5/G6 - Inspur / Kaytus - NVIDIA HGX Field Diagnostics diff --git a/bible-local/06-parsers.md b/bible-local/06-parsers.md index a9d2a2b..42e5cf8 100644 --- a/bible-local/06-parsers.md +++ b/bible-local/06-parsers.md @@ -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 | |-----------|--------------|-------| | `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_g6` | H3C SDS G6 bundles | Similar flow with G6-specific files | | `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 | |--------|----|--------|-----------| | 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 | | NVIDIA HGX Field Diag | `nvidia` | Ready | Various HGX servers | | NVIDIA Bug Report | `nvidia_bug_report` | Ready | H100 systems | diff --git a/bible-local/10-decisions.md b/bible-local/10-decisions.md index 78df2ca..ac46625 100644 --- a/bible-local/10-decisions.md +++ b/bible-local/10-decisions.md @@ -949,3 +949,30 @@ strings forced such systems into fallback mode even when the platform shape was grammar rather than by explicit vendor strings. - 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. + +--- + +## 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. diff --git a/internal/parser/vendors/easy_bee/parser.go b/internal/parser/vendors/easy_bee/parser.go new file mode 100644 index 0000000..5274db7 --- /dev/null +++ b/internal/parser/vendors/easy_bee/parser.go @@ -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 "" +} diff --git a/internal/parser/vendors/easy_bee/parser_test.go b/internal/parser/vendors/easy_bee/parser_test.go new file mode 100644 index 0000000..5ad0aae --- /dev/null +++ b/internal/parser/vendors/easy_bee/parser_test.go @@ -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") + } +} diff --git a/internal/parser/vendors/vendors.go b/internal/parser/vendors/vendors.go index cc4cfd6..61ff125 100644 --- a/internal/parser/vendors/vendors.go +++ b/internal/parser/vendors/vendors.go @@ -5,6 +5,7 @@ package vendors import ( // 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/easy_bee" _ "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/nvidia"