From 8715fcace48401a6b4027838c63b4e40fffadd37 Mon Sep 17 00:00:00 2001 From: Michael Chus Date: Sun, 15 Feb 2026 20:06:36 +0300 Subject: [PATCH] Align Reanimator export with updated integration guide --- REANIMATOR_EXPORT.md | 15 +- internal/exporter/reanimator_converter.go | 146 +++++++++++------- .../exporter/reanimator_converter_test.go | 115 +++++++++++++- .../exporter/reanimator_integration_test.go | 4 +- internal/exporter/reanimator_models.go | 20 +-- internal/server/handlers.go | 6 +- 6 files changed, 229 insertions(+), 77 deletions(-) diff --git a/REANIMATOR_EXPORT.md b/REANIMATOR_EXPORT.md index 83f0122..5a83cfd 100644 --- a/REANIMATOR_EXPORT.md +++ b/REANIMATOR_EXPORT.md @@ -34,9 +34,12 @@ - Генерация серийных номеров для PCIe устройств: `{board_serial}-PCIE-{slot}` - Объединение GPUs и NetworkAdapters в секцию pcie_devices - Фильтрация компонентов без серийных номеров (storage, PSU) -- Установка статуса по умолчанию "OK" для обнаруженных компонентов +- Нормализация статусов в допустимые значения (`OK`, `Warning`, `Critical`, `Unknown`; `Empty` только для memory) - RFC3339 формат для collected_at -- Вывод target_host из filename если отсутствует +- Вывод target_host из filename (`redfish://`, `ipmi://`) если отсутствует в source +- `target_host` опционален: если определить не удалось, поле не включается в JSON +- Нормализация `board.manufacturer` и `board.product_name`: строка `"NULL"` трактуется как отсутствующее значение +- Нормализация/очистка `source_type` и `protocol`: в экспорт попадают только допустимые значения из гайда ### 3. HTTP эндпоинт @@ -129,11 +132,11 @@ | LOGPile | Reanimator | Примечания | |---------|------------|------------| | `BoardInfo` | `board` | Прямой маппинг | -| `CPU` | `cpus` | + manufacturer (выводится) + status | +| `CPU` | `cpus` | + manufacturer (выводится) + status=`Unknown` при отсутствии фактического статуса | | `MemoryDIMM` | `memory` | Прямой маппинг | -| `Storage` | `storage` | + status (OK/Empty) | -| `PCIeDevice` | `pcie_devices` | + model + status | -| `GPU` | `pcie_devices` | Объединены как DisplayController | +| `Storage` | `storage` | + status=`Unknown` (статус источником не предоставляется) | +| `PCIeDevice` | `pcie_devices` | + model + status=`Unknown` | +| `GPU` | `pcie_devices` | Объединены как `device_class=DisplayController` | | `NetworkAdapter` | `pcie_devices` | Объединены как NetworkController | | `PSU` | `power_supplies` | Прямой маппинг | | `FirmwareInfo` | `firmware` | Прямой маппинг | diff --git a/internal/exporter/reanimator_converter.go b/internal/exporter/reanimator_converter.go index 596150c..69b847f 100644 --- a/internal/exporter/reanimator_converter.go +++ b/internal/exporter/reanimator_converter.go @@ -2,6 +2,8 @@ package exporter import ( "fmt" + "net/url" + "regexp" "strings" "time" @@ -22,25 +24,15 @@ func ConvertToReanimator(result *models.AnalysisResult) (*ReanimatorExport, erro return nil, fmt.Errorf("board serial_number is required for Reanimator export") } - // Determine target host (required field) - targetHost := result.TargetHost - if targetHost == "" { - // Try to extract from filename (e.g., "redfish://10.10.10.103") - if strings.HasPrefix(result.Filename, "redfish://") { - targetHost = strings.TrimPrefix(result.Filename, "redfish://") - } else if strings.HasPrefix(result.Filename, "ipmi://") { - targetHost = strings.TrimPrefix(result.Filename, "ipmi://") - } else { - targetHost = "unknown" - } - } + // Determine target host (optional field) + targetHost := inferTargetHost(result.TargetHost, result.Filename) boardSerial := result.Hardware.BoardInfo.SerialNumber export := &ReanimatorExport{ Filename: result.Filename, - SourceType: result.SourceType, - Protocol: result.Protocol, + SourceType: normalizeSourceType(result.SourceType), + Protocol: normalizeProtocol(result.Protocol), TargetHost: targetHost, CollectedAt: formatRFC3339(result.CollectedAt), Hardware: ReanimatorHardware{ @@ -68,8 +60,8 @@ func formatRFC3339(t time.Time) string { // convertBoard converts BoardInfo to Reanimator format func convertBoard(board models.BoardInfo) ReanimatorBoard { return ReanimatorBoard{ - Manufacturer: board.Manufacturer, - ProductName: board.ProductName, + Manufacturer: normalizeNullableString(board.Manufacturer), + ProductName: normalizeNullableString(board.ProductName), SerialNumber: board.SerialNumber, PartNumber: board.PartNumber, UUID: board.UUID, @@ -110,7 +102,7 @@ func convertCPUs(cpus []models.CPU) []ReanimatorCPU { FrequencyMHz: cpu.FrequencyMHz, MaxFrequencyMHz: cpu.MaxFreqMHz, Manufacturer: manufacturer, - Status: "OK", // CPUs are typically OK if detected + Status: "Unknown", }) } return result @@ -124,8 +116,8 @@ func convertMemory(memory []models.MemoryDIMM) []ReanimatorMemory { result := make([]ReanimatorMemory, 0, len(memory)) for _, mem := range memory { - status := mem.Status - if status == "" { + status := normalizeStatus(mem.Status, true) + if strings.TrimSpace(mem.Status) == "" { if mem.Present { status = "OK" } else { @@ -213,7 +205,7 @@ func convertPCIeDevices(hw *models.HardwareConfig, boardSerial string) []Reanima MaxLinkSpeed: pcie.MaxLinkSpeed, SerialNumber: serialNumber, Firmware: "", // PCIeDevice doesn't have firmware in models - Status: "OK", + Status: "Unknown", }) } @@ -227,9 +219,6 @@ func convertPCIeDevices(hw *models.HardwareConfig, boardSerial string) []Reanima // Determine device class deviceClass := "DisplayController" - if gpu.Model != "" { - deviceClass = gpu.Model - } result = append(result, ReanimatorPCIe{ Slot: gpu.Slot, @@ -245,7 +234,7 @@ func convertPCIeDevices(hw *models.HardwareConfig, boardSerial string) []Reanima MaxLinkSpeed: gpu.MaxLinkSpeed, SerialNumber: serialNumber, Firmware: gpu.Firmware, - Status: inferGPUStatus(gpu.Status), + Status: normalizeStatus(gpu.Status, false), }) } @@ -275,7 +264,7 @@ func convertPCIeDevices(hw *models.HardwareConfig, boardSerial string) []Reanima MaxLinkSpeed: "", SerialNumber: serialNumber, Firmware: nic.Firmware, - Status: inferNetworkStatus(nic.Status), + Status: normalizeStatus(nic.Status, false), }) } @@ -295,14 +284,7 @@ func convertPowerSupplies(psus []models.PSU) []ReanimatorPSU { continue } - status := psu.Status - if status == "" { - if psu.Present { - status = "OK" - } else { - status = "Empty" - } - } + status := normalizeStatus(psu.Status, false) result = append(result, ReanimatorPSU{ Slot: psu.Slot, @@ -329,28 +311,28 @@ func inferCPUManufacturer(model string) string { // Intel patterns if strings.Contains(upper, "INTEL") || - strings.Contains(upper, "XEON") || - strings.Contains(upper, "CORE I") { + strings.Contains(upper, "XEON") || + strings.Contains(upper, "CORE I") { return "Intel" } // AMD patterns if strings.Contains(upper, "AMD") || - strings.Contains(upper, "EPYC") || - strings.Contains(upper, "RYZEN") || - strings.Contains(upper, "THREADRIPPER") { + strings.Contains(upper, "EPYC") || + strings.Contains(upper, "RYZEN") || + strings.Contains(upper, "THREADRIPPER") { return "AMD" } // ARM patterns if strings.Contains(upper, "ARM") || - strings.Contains(upper, "CORTEX") { + strings.Contains(upper, "CORTEX") { return "ARM" } // Ampere patterns if strings.Contains(upper, "AMPERE") || - strings.Contains(upper, "ALTRA") { + strings.Contains(upper, "ALTRA") { return "Ampere" } @@ -373,23 +355,83 @@ func generatePCIeSerialNumber(boardSerial, slot, bdf string) string { // inferStorageStatus determines storage device status func inferStorageStatus(stor models.Storage) string { if !stor.Present { - return "Empty" + return "Unknown" } - return "OK" + return "Unknown" } -// inferGPUStatus converts GPU status to Reanimator status -func inferGPUStatus(status string) string { - if status == "" { - return "OK" +func normalizeSourceType(sourceType string) string { + normalized := strings.ToLower(strings.TrimSpace(sourceType)) + switch normalized { + case "api", "logfile", "manual": + return normalized + default: + return "" } - return status } -// inferNetworkStatus converts network adapter status to Reanimator status -func inferNetworkStatus(status string) string { - if status == "" { - return "OK" +func normalizeProtocol(protocol string) string { + normalized := strings.ToLower(strings.TrimSpace(protocol)) + switch normalized { + case "redfish", "ipmi", "snmp", "ssh": + return normalized + default: + return "" } - return status +} + +func normalizeNullableString(v string) string { + trimmed := strings.TrimSpace(v) + if strings.EqualFold(trimmed, "NULL") { + return "" + } + return trimmed +} + +func normalizeStatus(status string, allowEmpty bool) string { + switch strings.ToLower(strings.TrimSpace(status)) { + case "ok": + return "OK" + case "warning": + return "Warning" + case "critical": + return "Critical" + case "unknown": + return "Unknown" + case "empty": + if allowEmpty { + return "Empty" + } + return "Unknown" + default: + if allowEmpty { + return "Unknown" + } + return "Unknown" + } +} + +var ( + ipv4Regex = regexp.MustCompile(`(?:^|[^0-9])((?:\d{1,3}\.){3}\d{1,3})(?:[^0-9]|$)`) +) + +func inferTargetHost(targetHost, filename string) string { + if trimmed := strings.TrimSpace(targetHost); trimmed != "" { + return trimmed + } + + candidate := strings.TrimSpace(filename) + if candidate == "" { + return "" + } + + if parsed, err := url.Parse(candidate); err == nil && parsed.Hostname() != "" { + return parsed.Hostname() + } + + if submatches := ipv4Regex.FindStringSubmatch(candidate); len(submatches) > 1 { + return submatches[1] + } + + return "" } diff --git a/internal/exporter/reanimator_converter_test.go b/internal/exporter/reanimator_converter_test.go index 1c0e648..5834a98 100644 --- a/internal/exporter/reanimator_converter_test.go +++ b/internal/exporter/reanimator_converter_test.go @@ -1,6 +1,8 @@ package exporter import ( + "encoding/json" + "strings" "testing" "time" @@ -161,14 +163,14 @@ func TestInferStorageStatus(t *testing.T) { stor: models.Storage{ Present: true, }, - want: "OK", + want: "Unknown", }, { name: "not present", stor: models.Storage{ Present: false, }, - want: "Empty", + want: "Unknown", }, } @@ -216,8 +218,8 @@ func TestConvertCPUs(t *testing.T) { t.Errorf("expected AMD manufacturer for second CPU, got %q", result[1].Manufacturer) } - if result[0].Status != "OK" { - t.Errorf("expected OK status, got %q", result[0].Status) + if result[0].Status != "Unknown" { + t.Errorf("expected Unknown status, got %q", result[0].Status) } } @@ -276,8 +278,8 @@ func TestConvertStorage(t *testing.T) { t.Fatalf("expected 1 storage device (skipped one without serial), got %d", len(result)) } - if result[0].Status != "OK" { - t.Errorf("expected OK status, got %q", result[0].Status) + if result[0].Status != "Unknown" { + t.Errorf("expected Unknown status, got %q", result[0].Status) } } @@ -339,6 +341,9 @@ func TestConvertPCIeDevices(t *testing.T) { for _, dev := range result { if dev.SerialNumber == "GPU-001" { foundGPU = true + if dev.DeviceClass != "DisplayController" { + t.Errorf("expected GPU device_class DisplayController, got %q", dev.DeviceClass) + } break } } @@ -375,3 +380,101 @@ func TestConvertPowerSupplies(t *testing.T) { t.Errorf("expected OK status, got %q", result[0].Status) } } + +func TestConvertBoardNormalizesNULL(t *testing.T) { + board := convertBoard(models.BoardInfo{ + Manufacturer: " NULL ", + ProductName: "null", + SerialNumber: "TEST123", + }) + + if board.Manufacturer != "" { + t.Fatalf("expected empty manufacturer, got %q", board.Manufacturer) + } + if board.ProductName != "" { + t.Fatalf("expected empty product_name, got %q", board.ProductName) + } +} + +func TestSourceTypeOmittedWhenInvalidOrEmpty(t *testing.T) { + result, err := ConvertToReanimator(&models.AnalysisResult{ + Filename: "redfish://10.0.0.1", + SourceType: "archive", + TargetHost: "10.0.0.1", + Hardware: &models.HardwareConfig{ + BoardInfo: models.BoardInfo{SerialNumber: "TEST123"}, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + payload, err := json.Marshal(result) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + if strings.Contains(string(payload), `"source_type"`) { + t.Fatalf("expected source_type to be omitted for invalid value, got %s", string(payload)) + } +} + +func TestTargetHostOmittedWhenUnavailable(t *testing.T) { + result, err := ConvertToReanimator(&models.AnalysisResult{ + Filename: "test.json", + SourceType: "api", + Hardware: &models.HardwareConfig{ + BoardInfo: models.BoardInfo{SerialNumber: "TEST123"}, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + payload, err := json.Marshal(result) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + if strings.Contains(string(payload), `"target_host"`) { + t.Fatalf("expected target_host to be omitted when unavailable, got %s", string(payload)) + } +} + +func TestInferTargetHost(t *testing.T) { + tests := []struct { + name string + targetHost string + filename string + want string + }{ + { + name: "explicit target host wins", + targetHost: "10.0.0.10", + filename: "redfish://10.0.0.20", + want: "10.0.0.10", + }, + { + name: "hostname from URL", + filename: "redfish://10.10.10.103", + want: "10.10.10.103", + }, + { + name: "ip extracted from archive name", + filename: "nvidia_bug_report_192.168.12.34.tar.gz", + want: "192.168.12.34", + }, + { + name: "no host available", + filename: "test.json", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := inferTargetHost(tt.targetHost, tt.filename) + if got != tt.want { + t.Fatalf("inferTargetHost() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/exporter/reanimator_integration_test.go b/internal/exporter/reanimator_integration_test.go index 3bab934..f531640 100644 --- a/internal/exporter/reanimator_integration_test.go +++ b/internal/exporter/reanimator_integration_test.go @@ -197,7 +197,7 @@ func TestFullReanimatorExport(t *testing.T) { t.Errorf("CPU manufacturer not inferred: got %q", hw.CPUs[0].Manufacturer) } - if hw.CPUs[0].Status != "OK" { + if hw.CPUs[0].Status != "Unknown" { t.Errorf("CPU status mismatch: got %q", hw.CPUs[0].Status) } @@ -215,7 +215,7 @@ func TestFullReanimatorExport(t *testing.T) { t.Errorf("Expected 2 storage devices, got %d", len(hw.Storage)) } - if hw.Storage[0].Status != "OK" { + if hw.Storage[0].Status != "Unknown" { t.Errorf("Storage status mismatch: got %q", hw.Storage[0].Status) } diff --git a/internal/exporter/reanimator_models.go b/internal/exporter/reanimator_models.go index 4b16e43..c87d80a 100644 --- a/internal/exporter/reanimator_models.go +++ b/internal/exporter/reanimator_models.go @@ -3,9 +3,9 @@ package exporter // ReanimatorExport represents the top-level structure for Reanimator format export type ReanimatorExport struct { Filename string `json:"filename"` - SourceType string `json:"source_type"` + SourceType string `json:"source_type,omitempty"` Protocol string `json:"protocol,omitempty"` - TargetHost string `json:"target_host"` + TargetHost string `json:"target_host,omitempty"` CollectedAt string `json:"collected_at"` // RFC3339 format Hardware ReanimatorHardware `json:"hardware"` } @@ -38,14 +38,14 @@ type ReanimatorFirmware struct { // ReanimatorCPU represents processor information type ReanimatorCPU struct { - Socket int `json:"socket"` - Model string `json:"model"` - Cores int `json:"cores,omitempty"` - Threads int `json:"threads,omitempty"` - FrequencyMHz int `json:"frequency_mhz,omitempty"` - MaxFrequencyMHz int `json:"max_frequency_mhz,omitempty"` - Manufacturer string `json:"manufacturer,omitempty"` - Status string `json:"status,omitempty"` + Socket int `json:"socket"` + Model string `json:"model"` + Cores int `json:"cores,omitempty"` + Threads int `json:"threads,omitempty"` + FrequencyMHz int `json:"frequency_mhz,omitempty"` + MaxFrequencyMHz int `json:"max_frequency_mhz,omitempty"` + Manufacturer string `json:"manufacturer,omitempty"` + Status string `json:"status,omitempty"` } // ReanimatorMemory represents a memory module (DIMM) diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 96d1054..37a64f6 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -610,7 +610,11 @@ func (s *Server) handleExportReanimator(w http.ResponseWriter, r *http.Request) reanimatorData, err := exporter.ConvertToReanimator(result) if err != nil { - jsonError(w, fmt.Sprintf("Export failed: %v", err), http.StatusInternalServerError) + statusCode := http.StatusInternalServerError + if strings.Contains(err.Error(), "required for Reanimator export") { + statusCode = http.StatusBadRequest + } + jsonError(w, fmt.Sprintf("Export failed: %v", err), statusCode) return }